Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
golang.org/x/crypto v0.55.0
golang.org/x/crypto v0.56.0
golang.org/x/term v0.45.0
gopkg.in/yaml.v3 v3.0.1
)
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
Expand Down
345 changes: 301 additions & 44 deletions site/src/components/Analytics.astro

Large diffs are not rendered by default.

149 changes: 149 additions & 0 deletions site/src/components/AnalyticsConsent.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
---
/* The consent prompt, for readers whose location requires one.
*
* Markup only. Analytics.astro decides whether this is ever shown and handles
* the clicks: this renders hidden and is revealed by that script alone, once it
* has either a stored preference or an answer from the location lookup. Putting
* the logic in two places is how the banner ends up shown to someone who
* already declined.
*
* It renders on every page rather than only where the site footer does, because
* a reader who arrives from a search result lands on a documentation page, and
* a consent prompt that only appears on the landing page asks the question of
* the wrong half of the audience.
*
* `hidden` in the source, not `display: none` in a stylesheet: the prompt is
* absent for the reader whose location does not require it and for the reader
* who has already answered, and neither should see it flash while a stylesheet
* loads. A build without a measurement id never renders it at all.
*/
const enabled =
Boolean(import.meta.env.PUBLIC_GA_MEASUREMENT_ID?.trim()) && import.meta.env.PROD;
---

{
enabled && (
<section
id="ob-analytics-consent"
class="ob-consent"
role="region"
aria-labelledby="ob-consent-title"
hidden
>
<div class="ob-consent-body">
<p id="ob-consent-title" class="ob-consent-title">Analytics on this site</p>
<p class="ob-consent-copy">
Onebox would like to count visits and which pages get read, using Google Analytics.
Nothing about the servers you deploy to is involved. <a href="/privacy">
What is collected
</a>.
</p>
</div>
<div class="ob-consent-actions">
<button type="button" class="ob-consent-button" data-consent="denied">
Do not allow
</button>
<button
type="button"
class="ob-consent-button ob-consent-button-primary"
data-consent="granted"
>
Allow
</button>
</div>
</section>
)
}

<style>
.ob-consent {
position: fixed;
z-index: calc(var(--sl-z-index-navbar) + 1);
inset-block-end: max(1rem, env(safe-area-inset-bottom));
inset-inline: 1rem;
margin-inline: auto;
max-width: 34rem;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem 1.5rem;
padding: 1rem 1.15rem;
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.6rem;
background: var(--sl-color-black);
box-shadow: 0 1rem 2.5rem -0.75rem rgb(0 0 0 / 0.5);
}

.ob-consent-title {
margin: 0 0 0.2rem;
font-size: var(--sl-text-sm);
font-weight: 600;
color: var(--sl-color-white);
}

.ob-consent-copy {
margin: 0;
font-size: var(--sl-text-xs);
line-height: 1.6;
color: var(--sl-color-gray-3);
}

.ob-consent-body {
flex: 1 1 16rem;
}

.ob-consent-actions {
display: flex;
gap: 0.5rem;
}

.ob-consent-button {
padding: 0.4rem 0.85rem;
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.35rem;
background: transparent;
color: var(--sl-color-gray-2);
font: inherit;
font-size: var(--sl-text-xs);
cursor: pointer;
}

.ob-consent-button:hover {
border-color: var(--sl-color-gray-4);
color: var(--sl-color-white);
}

/* The landing page's primary action, in miniature: a solid fill of the
token that contrasts with the page, labelled in the token that contrasts
with the fill. `--sl-color-white` is a role rather than a colour — it is
near-black under the light theme — so a label set in it on an accent fill
is dark-on-blue in daylight. The pair below inverts correctly in both. */
.ob-consent-button-primary {
border-color: var(--sl-color-white);
background: var(--sl-color-white);
color: var(--sl-color-black);
}

.ob-consent-button-primary:hover {
border-color: var(--sl-color-gray-1);
background: var(--sl-color-gray-1);
color: var(--sl-color-black);
}

.ob-consent-button:focus-visible {
outline: 2px solid var(--sl-color-accent-high);
outline-offset: 2px;
}

/* Stacked, the buttons want the full width rather than the right edge. */
@media (max-width: 30rem) {
.ob-consent-actions {
flex: 1 1 100%;
}

.ob-consent-button {
flex: 1;
}
}
</style>
170 changes: 170 additions & 0 deletions site/src/components/AnalyticsPreference.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
---
/* The standing analytics control, for the privacy page.
*
* The banner asks once. This is where the answer is changed afterwards, which
* is the half of consent that is easy to leave out: permission that cannot be
* withdrawn as readily as it was given was never permission.
*
* It states the current situation before offering the buttons, because "allowed
* or not" is the question a reader arrives with, and in the regions that do not
* require a prompt the honest answer is that measurement is already running.
*
* The status line is scripted rather than rendered, since the answer lives in
* the reader's browser and this site is static: every reader would otherwise be
* served the same cached sentence about a choice that is theirs alone.
*/
const enabled =
Boolean(import.meta.env.PUBLIC_GA_MEASUREMENT_ID?.trim()) && import.meta.env.PROD;
---

{
enabled ? (
<>
<p>
The site uses Google Analytics 4 to count visits and see which pages get read. It runs
only when it is allowed to, and it is configured to learn as little as will still
answer that question.
</p>
<div id="ob-analytics-preference" class="ob-preference">
<p class="ob-preference-status" aria-live="polite">
Checking whether analytics are running in this browser.
</p>
<div class="ob-preference-actions">
<button type="button" class="ob-preference-button" data-consent="denied">
Do not allow
</button>
<button type="button" class="ob-preference-button" data-consent="granted">
Allow
</button>
</div>
</div>
</>
) : (
<p>
This build of the site has no analytics configured, so nothing is measured, nothing is
stored, and there is nothing to turn off. The rest of this section describes what a build
that has them does.
</p>
)
}

<script is:inline>
(function () {
var root = document.getElementById("ob-analytics-preference");
var api = window.oneboxAnalytics;
if (!root || !api) return;

var status = root.querySelector(".ob-preference-status");
var buttons = root.querySelectorAll("[data-consent]");

function render() {
var preference = api.preference();

if (api.refused()) {
status.textContent =
"Your browser sends a Do Not Track or Global Privacy Control signal, so " +
"analytics do not run here whatever this control says.";
for (var i = 0; i < buttons.length; i++) buttons[i].disabled = true;
return;
}

for (var j = 0; j < buttons.length; j++) {
buttons[j].disabled = false;
// No trailing comma after the last argument: `is:inline` ships this
// script verbatim, and a trailing comma in a call is ES2017. Every
// browser too old for it fails to parse the whole script rather than
// one line of it, which would leave this control inert -- a standing
// opt-out that renders and does nothing is the worst of the states
// this file can be in.
buttons[j].setAttribute(
"aria-pressed",
String(buttons[j].dataset.consent === preference)
);
}

if (preference === "granted") {
status.textContent = "Analytics are allowed in this browser.";
return;
}
if (preference === "denied") {
status.textContent = "Analytics are not allowed in this browser.";
return;
}

api.requirement().then(function (requirement) {
if (api.preference() !== null) return;
status.textContent =
requirement === "not_required"
? "Analytics are running in this browser. You can turn them off here."
: "You have not yet answered the analytics question in this browser.";
});
}

root.addEventListener("click", function (event) {
var button = event.target instanceof Element && event.target.closest("[data-consent]");
if (!button) return;
api.setPreference(button.dataset.consent);
});

api.subscribe(render);
render();
})();
</script>

<style>
.ob-preference {
margin-block: 1rem;
padding: 1rem 1.15rem;
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.6rem;
background: var(--sl-color-black);
}

.ob-preference-status {
margin: 0;
font-size: var(--sl-text-sm);
line-height: 1.6;
color: var(--sl-color-gray-2);
}

.ob-preference-actions {
display: flex;
gap: 0.5rem;
margin-block-start: 0.85rem;
}

.ob-preference-button {
padding: 0.4rem 0.85rem;
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.35rem;
background: transparent;
color: var(--sl-color-gray-2);
font: inherit;
font-size: var(--sl-text-xs);
cursor: pointer;
}

.ob-preference-button:hover:not(:disabled) {
border-color: var(--sl-color-gray-4);
color: var(--sl-color-white);
}

/* Same inversion as the banner's primary action: `--sl-color-white` is the
token that contrasts with the page, so it is the fill here and never the
label on top of it. */
.ob-preference-button[aria-pressed="true"] {
border-color: var(--sl-color-white);
background: var(--sl-color-white);
color: var(--sl-color-black);
}

.ob-preference-button:disabled {
opacity: 0.55;
cursor: not-allowed;
}

.ob-preference-button:focus-visible {
outline: 2px solid var(--sl-color-accent-high);
outline-offset: 2px;
}
</style>
17 changes: 17 additions & 0 deletions site/src/components/Footer.astro
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash";
<div class="meta sl-flex">
<EditLink />
<LastUpdated />
<a href="/privacy">Privacy</a>
</div>
)
}
Expand All @@ -75,4 +76,20 @@ const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash";
margin-inline-start: auto;
}

/* Outside the regions that require a consent prompt no banner is ever
shown, so the control on /privacy is the only way to turn analytics
off -- and SiteFooter, which carries the other link to it, renders on
splash pages alone. A reader arriving from a search result lands here
and would otherwise have no route to it. One text link, not the whole
band. */
.meta a {
color: var(--sl-color-gray-3);
text-decoration: none;
}

.meta a:hover {
color: var(--sl-color-white);
text-decoration: underline;
}
Comment on lines +85 to +93

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not what happens — Astro scopes this rule, so it already reaches only the Privacy link.

.meta a compiles to a selector carrying this component's scope on both elements:

.meta:where(.astro-jo6i4kqk) a:where(.astro-jo6i4kqk){color:var(--sl-color-gray-3);text-decoration:none}

And in the built page, EditLink's anchor carries its own scope class, not this one:

<div class="meta sl-flex astro-jo6i4kqk">
  <a href="…/edit/main/…" class="sl-flex print:hidden astro-xk5gmzsg">…Edit page</a>
  <p>Last updated: <time >Sep 1, 2026</time></p>
  <a href="/privacy" class="astro-jo6i4kqk">Privacy</a>
</div>

Scoped styles don't cross into child components, which is exactly why line 75 needs :global to reach the <p> that LastUpdated renders. The same boundary is what keeps this rule off EditLink, so "one text link, not the whole band" is the behaviour and not just the intent. Adding an explicit class here would restate a scope the compiler already applies.


</style>
2 changes: 2 additions & 0 deletions site/src/components/PageFrame.astro
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
*/
import MobileMenuToggle from "@astrojs/starlight/components/MobileMenuToggle.astro";
import SiteFooter from "./SiteFooter.astro";
import AnalyticsConsent from "./AnalyticsConsent.astro";

const { hasSidebar } = Astro.locals.starlightRoute;
const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash";
Expand All @@ -45,6 +46,7 @@ const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash";
}
<div class="main-frame"><slot /></div>
{isSplash && <SiteFooter />}
<AnalyticsConsent />
</div>

<style>
Expand Down
2 changes: 2 additions & 0 deletions site/src/components/SiteFooter.astro
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ const year = new Date().getFullYear();
<span>&copy; {year} <a href="/">Onebox</a> by <a href="https://labstack.com">LabStack LLC</a></span>
<span class="sep" aria-hidden="true">·</span>
<span><a href="https://github.com/labstack/onebox/blob/main/LICENSE">Apache-2.0</a></span>
<span class="sep" aria-hidden="true">·</span>
<span><a href="/privacy">Privacy</a></span>
</p>
<nav class="ob-sitefooter-links" aria-label="LabStack">
<a class="ob-icon" href="https://x.com/labstack" aria-label="LabStack on X">
Expand Down
Loading