create-bearminion 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # create-bearminion
2
2
 
3
+ ## 1.5.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 8a428e7: Sitewide JSON-LD. `site.seo.jsonLd` is optional (up to 10 blocks, 50 KB, so no migration) and the default template emits its blocks on every page. A page's own `seo.jsonLd` still replaces only the generated `WebPage` block, and a block with the same `@id` is emitted once, the page's first. With the conventional ids `<url>/#organization` and `<url>/#website` in the site's blocks, the generated `WebPage` gets `isPartOf` and `publisher` as `@id` references. The Site tab has a Structured data group with an Edit JSON-LD dialog and a Generate button that fills an `Organization` and a `WebSite` from the site name, address and language, replacing blocks with the same `@id`. The logo is added only for a full https address. A warning shows while the site address is still `example.com`. `siteJsonLdIds`, `mergeJsonLd`, `hasJsonLdId`, `generateSiteJsonLd`, `upsertJsonLd` and `isPlaceholderSiteUrl` are exported; themes that emit JSON-LD should use `mergeJsonLd` (see the theme docs).
8
+
9
+ ## 1.4.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 5d333af: Cookie banner for analytics. `site.consent` (`enabled`, `title`, `body`, `accept`, `reject`, `settings`, `policyLabel`, `policyHref`) is optional, and the banner is on by default: a site that has analytics snippets now asks visitors first, so one that did not want a banner needs `consent.enabled: false` (Site tab, Cookie banner switch). The template sets Google Consent Mode v2 to denied before any tag, keeps the snippets in `<template>` until the visitor accepts, then sends the update and runs them. A footer link, or a corner cookie icon (`reopen: "icon"`), reopens the banner, and withdrawing clears Google Analytics cookies and reloads. The Site tab has a Cookie banner group with the switch and an Edit banner dialog for the text and a privacy policy link. `siteConsentSchema`, `CONSENT_REOPEN`, `consentCopy`, `CONSENT_COPY`, `CONSENT_LIMITS`, `CONSENT_HREF` and `consentTextIssues` are exported. Themes that emit the snippets should hold them back the same way (see the theme docs).
14
+
3
15
  ## 1.3.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-bearminion",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Create a BearMinion site: an Astro website you edit by clicking on it.",
5
5
  "license": "MIT",
6
6
  "author": "Jakub Lange",
@@ -46,7 +46,7 @@
46
46
  "@types/node": "^22.19.0",
47
47
  "tsdown": "0.23.1",
48
48
  "typescript": "~5.9.3",
49
- "vitest": "^5.0.4"
49
+ "vitest": "^5.0.5"
50
50
  },
51
51
  "scripts": {
52
52
  "build": "tsdown",
@@ -184,7 +184,7 @@ The site's own images (`logo.png`, `favicon.png`, `og.png`, `hero.png`) live in
184
184
 
185
185
  ## SEO and sitemap
186
186
 
187
- `Seo.astro` reads the page `seo` object and the site defaults in `site.json`. The document title is `seo.titleTemplate` with `%s` replaced by the page SEO title. Description falls back to the site description. The canonical URL is `seo.canonical` when set, otherwise the page path joined to `site.url`. Robots is `noindex` or `index`, plus `nofollow` or `follow`. Open Graph, Twitter, and WebPage JSON-LD use the same title, description, and image. A page that sets `seo.jsonLd` gets those blocks instead of the generated WebPage block, one script tag per block. `site.analytics.head` and `site.analytics.bodyEnd` are emitted by `Base.astro` in production builds only.
187
+ `Seo.astro` reads the page `seo` object and the site defaults in `site.json`. The document title is `seo.titleTemplate` with `%s` replaced by the page SEO title. Description falls back to the site description. The canonical URL is `seo.canonical` when set, otherwise the page path joined to `site.url`. Robots is `noindex` or `index`, plus `nofollow` or `follow`. Open Graph, Twitter, and WebPage JSON-LD use the same title, description, and image. Every block in `site.seo.jsonLd` (usually an `Organization` and a `WebSite`) is emitted on every page, and the generated WebPage then points at them by `@id`. A page that sets `seo.jsonLd` gets those blocks instead of the generated WebPage block only, never instead of the site's. A block with the same `@id` is emitted once, the page's copy winning. One script tag per block. `site.analytics.head` and `site.analytics.bodyEnd` are emitted by `Base.astro` in production builds only, behind the [cookie banner](#cookie-banner) unless it is switched off.
188
188
 
189
189
  `astro.config.ts` sets `site` from `site.json` and adds `@astrojs/sitemap`. The filter keeps content pages whose `seo.noindex` is false. `content/pages/preview.json` is the sample noindex page: it is built, it emits `noindex`, and it is absent from the sitemap. `src/pages/robots.txt.ts` points crawlers at `sitemap-index.xml`.
190
190
 
@@ -201,6 +201,20 @@ The style guide at `/style-guide` renders every typography size and button varia
201
201
 
202
202
  People who only use the editor never set a variable: steps 2 and 3 cover the usual setup. The snippets are not sanitised, so give write access to `content/site.json` only to people you would trust with the site's code.
203
203
 
204
+ ## Cookie banner
205
+
206
+ When snippets are set and `site.consent.enabled` is not `false`, `consentRequired()` in `src/lib/consent.ts` is true and the banner and the footer link render in every build, dev and previews included, so you see what visitors get. Only a build that ships the snippets (the rules above) holds them back; the others have none to hold. On the live build `Base.astro` changes three things:
207
+
208
+ - **Before any snippet:** an inline script sets Google Consent Mode v2 to `denied` for `ad_storage`, `ad_user_data`, `ad_personalization` and `analytics_storage`.
209
+ - **The snippets** move into `<template data-consent-snippet="head|body">`, so they are inert. Nothing in them loads or runs.
210
+ - **`ConsentBanner.astro`** is rendered, with a way to reopen it: a footer link (`ConsentLink.astro`, label `site.consent.settings`), or with `site.consent.reopen: "icon"` a fixed rounded cookie icon in the bottom-left corner (`data-consent-icon`, shown only while the banner is closed, labelled by the same text).
211
+
212
+ `src/lib/consent-client.ts` shows the banner when there is no stored choice, and keeps the choice in `localStorage` (`bm-consent`, 180 days, bump `CONSENT_VERSION` in `consent-store.ts` to ask everyone again). On accept it sends `gtag('consent', 'update', { analytics_storage: 'granted' })`, then rebuilds the held-back scripts into the page, so they run after the update and work for any vendor, not only Google. Withdrawing deletes `_ga`, `_ga_*`, `_gid` and `_gat*` on the host and its parent domains and reloads, because a loaded script cannot be unloaded. Cookies that other tools set are not cleared. The banner has one category, analytics, and Accept and Reject look the same on purpose.
213
+
214
+ In dev and previews the banner and its choices work, and nothing is loaded or sent, so your own testing stays out of the numbers. `<noscript>` fallbacks in `bodyEnd`, such as the Google Tag Manager iframe, cannot wait for consent and never run while the banner is on.
215
+
216
+ A theme with its own layout needs three things: render the `template` elements and a banner element carrying `data-consent-banner` (hidden by default) with `data-consent-accept` and `data-consent-reject` buttons, put `data-consent-open` on any element that should reopen it, and import `initConsent` from the script. The default words come from `consentCopy(site.consent)`, which `bearminion` exports.
217
+
204
218
  ## Dev editor
205
219
 
206
220
  `astro.config.ts` registers `bearminion()`. It is active only while `pnpm dev` is running. The production build does not add editor routes, scripts, or the overlay.
@@ -18,8 +18,8 @@
18
18
  "@fontsource/instrument-serif": "^5.3.0",
19
19
  "@fontsource/jetbrains-mono": "^5.3.0",
20
20
  "@tailwindcss/vite": "^4.1.12",
21
- "astro": "^7.3.8",
22
- "bearminion": "^1.3.0",
21
+ "astro": "^7.4.1",
22
+ "bearminion": "^1.5.0",
23
23
  "class-variance-authority": "^0.7.1",
24
24
  "sharp": "^0.35.5",
25
25
  "tailwind-merge": "^3.7.0",
@@ -27,8 +27,8 @@
27
27
  "zod": "^4.6.7"
28
28
  },
29
29
  "devDependencies": {
30
- "@astrojs/check": "^0.9.5",
31
- "@storybook-astro/framework": "^1.12.0",
30
+ "@astrojs/check": "^0.9.11",
31
+ "@storybook-astro/framework": "^1.13.0",
32
32
  "@storybook/builder-vite": "^10.6.2",
33
33
  "@types/node": "^22.19.0",
34
34
  "storybook": "^10.6.2",
@@ -0,0 +1,16 @@
1
+ ---
2
+ interface Props {
3
+ label: string
4
+ class?: string
5
+ }
6
+
7
+ const { label, class: className } = Astro.props
8
+ ---
9
+
10
+ <button
11
+ type="button"
12
+ data-consent-open
13
+ class:list={['cursor-pointer text-left underline-offset-4 transition-colors hover:text-fg hover:underline', className]}
14
+ >
15
+ {label}
16
+ </button>
@@ -0,0 +1,77 @@
1
+ ---
2
+ import type { SiteConsent } from 'bearminion'
3
+ import { consentCopy } from '../../lib/consent'
4
+ import { button } from '../atoms/button'
5
+ import Typography from '../atoms/Typography.astro'
6
+
7
+ interface Props {
8
+ consent?: SiteConsent
9
+ }
10
+
11
+ const copy = consentCopy(Astro.props.consent)
12
+ const choiceClass = button({ variant: 'secondary', size: 'sm' })
13
+ const icon = Astro.props.consent?.reopen === 'icon'
14
+ ---
15
+
16
+ {/* Hidden until the script finds no stored choice, so a returning visitor never sees it flash. */}
17
+ <div
18
+ data-organism="consent-banner"
19
+ data-consent-banner
20
+ data-tone="base"
21
+ role="region"
22
+ aria-label={copy.title}
23
+ tabindex="-1"
24
+ hidden
25
+ class="fixed inset-x-4 bottom-4 z-50 mx-auto flex max-w-xl flex-col gap-4 rounded-2xl bg-bg p-5 text-fg shadow-xl ring-line outline-none sm:right-auto sm:left-6 sm:mx-0 sm:w-[26rem]"
26
+ >
27
+ <div class="flex flex-col gap-1.5">
28
+ <Typography as="p" size="h3">{copy.title}</Typography>
29
+ <Typography as="p" size="small" color="muted">
30
+ {copy.body}
31
+ {
32
+ copy.policyHref && (
33
+ <>
34
+ {' '}
35
+ <a href={copy.policyHref} class="underline underline-offset-4 hover:text-fg">
36
+ {copy.policyLabel}
37
+ </a>
38
+ </>
39
+ )
40
+ }
41
+ </Typography>
42
+ </div>
43
+ {/* Same look and size on both: refusing must be as easy as accepting. */}
44
+ <div class="flex flex-wrap gap-3">
45
+ <button type="button" class={choiceClass} data-consent-accept>{copy.accept}</button>
46
+ <button type="button" class={choiceClass} data-consent-reject>{copy.reject}</button>
47
+ </div>
48
+ </div>
49
+
50
+ {/* The corner icon reopens the banner once it is closed, in place of the footer link. */}
51
+ {
52
+ icon && (
53
+ <button
54
+ type="button"
55
+ data-consent-open
56
+ data-consent-icon
57
+ hidden
58
+ aria-label={copy.settings}
59
+ title={copy.settings}
60
+ class="fixed bottom-4 left-4 z-40 grid size-11 cursor-pointer place-items-center rounded-xl bg-bg text-fg shadow-lg ring-line transition-transform hover:scale-105 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fg sm:bottom-6 sm:left-6"
61
+ >
62
+ <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
63
+ <path d="M12 3a9 9 0 1 0 9 9 4 4 0 0 1-4-4 4 4 0 0 1-5-5z" />
64
+ <circle cx="8.5" cy="11" r="0.7" fill="currentColor" />
65
+ <circle cx="12" cy="16" r="0.7" fill="currentColor" />
66
+ <circle cx="15.5" cy="14" r="0.7" fill="currentColor" />
67
+ </svg>
68
+ </button>
69
+ )
70
+ }
71
+
72
+ <script>
73
+ import { initConsent } from '../../lib/consent-client'
74
+
75
+ const banner = document.querySelector<HTMLElement>('[data-consent-banner]')
76
+ if (banner) initConsent(banner)
77
+ </script>
@@ -2,15 +2,18 @@
2
2
  import type { Site } from './site-definition'
3
3
  import { markClass, splitMarkedText } from 'bearminion/marks'
4
4
  import { anchorAttrs } from '../../lib/link-attrs'
5
+ import ConsentLink from '../atoms/ConsentLink.astro'
5
6
  import LogoMark from '../atoms/LogoMark.astro'
6
7
  import Typography from '../atoms/Typography.astro'
7
8
  import FooterLinks from './FooterLinks.astro'
8
9
 
9
10
  interface Props {
10
11
  site: Site
12
+ /** The label of the link that reopens the cookie banner. Unset when the page has no banner. */
13
+ consentLabel?: string
11
14
  }
12
15
 
13
- const { site } = Astro.props
16
+ const { site, consentLabel } = Astro.props
14
17
  const { variant, tone, text, legal, badge, columns } = site.footer
15
18
  const flatLinks = columns
16
19
  .flatMap((column, columnIndex) => column.links.map((link, linkIndex) => ({ link, columnIndex, linkIndex })))
@@ -41,10 +44,11 @@ const wordmark = splitMarkedText(site.name, site.nameMarks)
41
44
  ))}
42
45
  </ul>
43
46
  </div>
44
- {legal && (
45
- <p class="mt-10 border-t border-[var(--line)] pt-6 text-[0.8125rem] text-muted" data-field="footer.legal">
46
- {legal}
47
- </p>
47
+ {(legal || consentLabel) && (
48
+ <div class="mt-10 flex flex-wrap items-baseline gap-x-6 gap-y-2 border-t border-[var(--line)] pt-6 text-[0.8125rem] text-muted">
49
+ {legal && <p data-field="footer.legal">{legal}</p>}
50
+ {consentLabel && <ConsentLink label={consentLabel} />}
51
+ </div>
48
52
  )}
49
53
  </div>
50
54
  )
@@ -79,10 +83,11 @@ const wordmark = splitMarkedText(site.name, site.nameMarks)
79
83
  ))}
80
84
  </div>
81
85
  </div>
82
- {legal && (
83
- <p class="mt-12 border-t border-[var(--line)] pt-6 text-[0.8125rem] text-muted" data-field="footer.legal">
84
- {legal}
85
- </p>
86
+ {(legal || consentLabel) && (
87
+ <div class="mt-12 flex flex-wrap items-baseline gap-x-6 gap-y-2 border-t border-[var(--line)] pt-6 text-[0.8125rem] text-muted">
88
+ {legal && <p data-field="footer.legal">{legal}</p>}
89
+ {consentLabel && <ConsentLink label={consentLabel} />}
90
+ </div>
86
91
  )}
87
92
  </div>
88
93
  )
@@ -111,14 +116,17 @@ const wordmark = splitMarkedText(site.name, site.nameMarks)
111
116
  </div>
112
117
  </div>
113
118
  <div class="mt-[clamp(3rem,6cqi,5rem)] flex flex-wrap justify-between gap-x-8 gap-y-2 text-[0.8125rem] text-muted">
114
- {legal && <p data-field="footer.legal">{legal}</p>}
119
+ <div class="flex flex-wrap items-baseline gap-x-6 gap-y-2">
120
+ {legal && <p data-field="footer.legal">{legal}</p>}
121
+ {consentLabel && <ConsentLink label={consentLabel} />}
122
+ </div>
115
123
  <a href="#content" class="transition-colors hover:text-fg">
116
124
  Back to top ↑
117
125
  </a>
118
126
  </div>
119
127
  <p
120
128
  aria-hidden="true"
121
- class="mt-4 text-[clamp(4rem,0.5rem+18.2cqi,15.5rem)] leading-[0.78] font-bold tracking-[-0.07em] whitespace-nowrap translate-y-[0.12em]"
129
+ class="pointer-events-none mt-4 text-[clamp(4rem,0.5rem+18.2cqi,15.5rem)] leading-[0.78] font-bold tracking-[-0.07em] whitespace-nowrap translate-y-[0.12em]"
122
130
  >
123
131
  {
124
132
  wordmark.map((run) => {
@@ -1,5 +1,5 @@
1
1
  ---
2
- import type { Page } from 'bearminion'
2
+ import { hasJsonLdId, mergeJsonLd, siteJsonLdIds, type Page } from 'bearminion'
3
3
  import type { Site } from '../organisms/site-definition'
4
4
  import { isRemote, resolveLocalAsset } from '../../lib/assets'
5
5
 
@@ -31,19 +31,21 @@ const robots = [page.seo.noindex ? 'noindex' : 'index', page.seo.nofollow ? 'nof
31
31
  const ogImageSrc = page.seo.ogImage ?? site.seo.ogImage
32
32
  const ogImage = ogImageSrc ? absoluteImage(ogImageSrc) : undefined
33
33
 
34
- const jsonLd = {
34
+ // Blocks from site.seo.jsonLd go on every page. When the site describes itself, the generated WebPage points at those blocks.
35
+ const siteBlocks = site.seo.jsonLd ?? []
36
+ const ids = siteJsonLdIds(site.url)
37
+ const webPage = {
35
38
  '@context': 'https://schema.org',
36
39
  '@type': 'WebPage',
37
40
  name: title,
38
41
  description,
39
42
  url: canonical,
40
43
  ...(ogImage ? { image: ogImage } : {}),
41
- isPartOf: {
42
- '@type': 'WebSite',
43
- name: site.name,
44
- url: site.url,
45
- },
44
+ isPartOf: hasJsonLdId(siteBlocks, ids.website) ? { '@id': ids.website } : { '@type': 'WebSite', name: site.name, url: site.url },
45
+ ...(hasJsonLdId(siteBlocks, ids.organization) ? { publisher: { '@id': ids.organization } } : {}),
46
46
  }
47
+ // A page's own JSON-LD replaces only the generated WebPage. Blocks with the same @id are emitted once, the page's first.
48
+ const blocks = mergeJsonLd(siteBlocks, page.seo.jsonLd ?? [webPage])
47
49
  ---
48
50
 
49
51
  <title>{title}</title>
@@ -60,9 +62,9 @@ const jsonLd = {
60
62
  <meta name="twitter:title" content={title} />
61
63
  <meta name="twitter:description" content={description} />
62
64
  {ogImage && <meta name="twitter:image" content={ogImage} />}
63
- {/* Custom JSON-LD replaces the generated WebPage block. `<` is escaped so a value cannot close the script tag. */}
65
+ {/* `<` is escaped so a value cannot close the script tag. */}
64
66
  {
65
- (page.seo.jsonLd ?? [jsonLd]).map((block) => (
67
+ blocks.map((block) => (
66
68
  <script type="application/ld+json" set:html={JSON.stringify(block).replace(/</g, '\\u003c')} />
67
69
  ))
68
70
  }
@@ -1,11 +1,13 @@
1
1
  ---
2
2
  import { themeCss, type Page } from 'bearminion'
3
3
  import type { Site } from '../components/organisms/site-definition'
4
+ import ConsentBanner from '../components/organisms/ConsentBanner.astro'
4
5
  import Footer from '../components/organisms/Footer.astro'
5
6
  import Header from '../components/organisms/Header.astro'
6
7
  import Seo from '../components/seo/Seo.astro'
7
8
  import { analyticsEnabled } from '../lib/analytics'
8
9
  import { resolveLocalAsset } from '../lib/assets'
10
+ import { CONSENT_DEFAULT_SCRIPT, consentCopy, consentRequired } from '../lib/consent'
9
11
  import '../styles/global.css'
10
12
 
11
13
  interface Props {
@@ -20,7 +22,12 @@ const iconType = icon ? ICON_TYPES[icon.format] : undefined
20
22
  const paletteCss = themeCss(site.theme.colors)
21
23
  const themeColor = site.theme.mode === 'dark' ? '#1c1e24' : '#f7f8f8'
22
24
  // Snippets ship in the live site's build only: not in `astro dev`, not in host previews. See lib/analytics.ts.
23
- const analytics = analyticsEnabled(process.env, import.meta.env.PROD) ? site.analytics : undefined
25
+ const analyticsOn = analyticsEnabled(process.env, import.meta.env.PROD)
26
+ const analytics = analyticsOn ? site.analytics : undefined
27
+ // With a banner, the snippets wait inside <template> until the visitor accepts. The banner itself shows in dev
28
+ // and previews too, so the owner sees it; only the live build has snippets to hold back. See lib/consent.ts.
29
+ const asks = consentRequired(site)
30
+ const gated = asks && analyticsOn
24
31
  ---
25
32
 
26
33
  <!doctype html>
@@ -30,7 +37,9 @@ const analytics = analyticsEnabled(process.env, import.meta.env.PROD) ? site.ana
30
37
  <meta name="viewport" content="width=device-width, initial-scale=1" />
31
38
  <Seo site={site} page={page} pathname={Astro.url.pathname} />
32
39
  <meta name="theme-color" content={themeColor} />
33
- {analytics?.head && <Fragment set:html={analytics.head} />}
40
+ {gated && <script is:inline set:html={CONSENT_DEFAULT_SCRIPT} />}
41
+ {analytics?.head && !asks && <Fragment set:html={analytics.head} />}
42
+ {gated && analytics?.head && <template data-consent-snippet="head" set:html={analytics.head} />}
34
43
  {/* Always present so the editor can update the palette live. */}
35
44
  <style id="bm-theme" is:inline set:html={paletteCss}></style>
36
45
  {icon && <link rel="icon" type={iconType} href={icon.src} />}
@@ -46,8 +55,10 @@ const analytics = analyticsEnabled(process.env, import.meta.env.PROD) ? site.ana
46
55
  <main id="content">
47
56
  <slot />
48
57
  </main>
49
- <Footer site={site} />
50
- {analytics?.bodyEnd && <Fragment set:html={analytics.bodyEnd} />}
58
+ <Footer site={site} consentLabel={asks && site.consent?.reopen !== 'icon' ? consentCopy(site.consent).settings : undefined} />
59
+ {analytics?.bodyEnd && !asks && <Fragment set:html={analytics.bodyEnd} />}
60
+ {gated && analytics?.bodyEnd && <template data-consent-snippet="body" set:html={analytics.bodyEnd} />}
61
+ {asks && <ConsentBanner consent={site.consent} />}
51
62
  <script>
52
63
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
53
64
  const observer =
@@ -0,0 +1,113 @@
1
+ import { CONSENT_KEY, parseChoice, serializeChoice } from './consent-store'
2
+
3
+ declare global {
4
+ interface Window {
5
+ gtag?: (...args: unknown[]) => void
6
+ }
7
+ }
8
+
9
+ let activated = false
10
+
11
+ function stored() {
12
+ try {
13
+ return parseChoice(localStorage.getItem(CONSENT_KEY), Date.now())
14
+ } catch {
15
+ return null
16
+ }
17
+ }
18
+
19
+ function store(analytics: boolean): void {
20
+ try {
21
+ localStorage.setItem(CONSENT_KEY, serializeChoice(analytics, Date.now()))
22
+ } catch {
23
+ // Storage is blocked: the choice lasts until the page closes.
24
+ }
25
+ }
26
+
27
+ /** Puts the held-back snippets into the page. Scripts are rebuilt, because a cloned script does not run. */
28
+ function activate(): void {
29
+ if (activated) return
30
+ activated = true
31
+ for (const template of document.querySelectorAll<HTMLTemplateElement>('template[data-consent-snippet]')) {
32
+ const target = template.dataset.consentSnippet === 'body' ? document.body : document.head
33
+ for (const node of Array.from(template.content.childNodes)) {
34
+ if (!(node instanceof HTMLScriptElement)) {
35
+ target.append(node.cloneNode(true))
36
+ continue
37
+ }
38
+ const script = document.createElement('script')
39
+ for (const attribute of Array.from(node.attributes)) script.setAttribute(attribute.name, attribute.value)
40
+ script.text = node.text
41
+ target.append(script)
42
+ }
43
+ }
44
+ }
45
+
46
+ /** Removes Google Analytics' cookies from this host and every parent domain. */
47
+ function clearAnalyticsCookies(): void {
48
+ const parts = location.hostname.split('.')
49
+ const domains = parts.map((_, index) => parts.slice(index).join('.')).filter((domain) => domain.includes('.'))
50
+ for (const pair of document.cookie.split(';')) {
51
+ const name = pair.split('=')[0]?.trim() ?? ''
52
+ if (name !== '_ga' && name !== '_gid' && !name.startsWith('_ga_') && !name.startsWith('_gat')) continue
53
+ // biome-ignore lint/suspicious/noDocumentCookie: the Cookie Store API is not in every browser the template supports
54
+ document.cookie = `${name}=; Max-Age=0; path=/`
55
+ // biome-ignore lint/suspicious/noDocumentCookie: same
56
+ for (const domain of domains) document.cookie = `${name}=; Max-Age=0; path=/; domain=${domain}`
57
+ }
58
+ }
59
+
60
+ function signal(analytics: boolean): void {
61
+ window.gtag?.('consent', 'update', { analytics_storage: analytics ? 'granted' : 'denied' })
62
+ if (analytics) activate()
63
+ }
64
+
65
+ /** Shows the banner until the visitor has chosen, applies a stored choice, and wires the buttons and the footer link. */
66
+ export function initConsent(banner: HTMLElement): void {
67
+ let opener: HTMLElement | null = null
68
+ // The corner icon, when the site uses one, shows only while the banner is closed.
69
+ const icon = document.querySelector<HTMLElement>('[data-consent-icon]')
70
+ const sync = () => {
71
+ if (icon) icon.hidden = !banner.hidden
72
+ }
73
+ const open = () => {
74
+ banner.hidden = false
75
+ sync()
76
+ banner.focus()
77
+ }
78
+ const close = () => {
79
+ banner.hidden = true
80
+ sync()
81
+ opener?.focus()
82
+ opener = null
83
+ }
84
+
85
+ const previous = stored()
86
+ if (previous) signal(previous.analytics)
87
+ else banner.hidden = false
88
+ sync()
89
+
90
+ function choose(analytics: boolean): void {
91
+ const wasOn = stored()?.analytics === true || activated
92
+ store(analytics)
93
+ if (!analytics && wasOn) {
94
+ // Loaded scripts cannot be unloaded: clear what they wrote, and reload without them.
95
+ signal(false)
96
+ clearAnalyticsCookies()
97
+ location.reload()
98
+ return
99
+ }
100
+ signal(analytics)
101
+ close()
102
+ }
103
+
104
+ banner.querySelector('[data-consent-accept]')?.addEventListener('click', () => choose(true))
105
+ banner.querySelector('[data-consent-reject]')?.addEventListener('click', () => choose(false))
106
+ for (const link of document.querySelectorAll<HTMLElement>('[data-consent-open]')) {
107
+ link.addEventListener('click', (event) => {
108
+ event.preventDefault()
109
+ opener = link
110
+ open()
111
+ })
112
+ }
113
+ }
@@ -0,0 +1,32 @@
1
+ // The visitor's stored choice. No imports, so the banner's script ships without the rest of the library.
2
+
3
+ export const CONSENT_KEY = 'bm-consent'
4
+ /** Bump to ask everyone again. */
5
+ export const CONSENT_VERSION = 1
6
+ /** A choice is asked again after this long. */
7
+ export const CONSENT_MAX_AGE_MS = 180 * 24 * 60 * 60 * 1000
8
+
9
+ export interface ConsentChoice {
10
+ v: number
11
+ analytics: boolean
12
+ at: number
13
+ }
14
+
15
+ export function serializeChoice(analytics: boolean, now: number): string {
16
+ return JSON.stringify({ v: CONSENT_VERSION, analytics, at: now } satisfies ConsentChoice)
17
+ }
18
+
19
+ /** The stored choice, or `null` when there is none, it is unreadable, it is from an older version, or it has expired. */
20
+ export function parseChoice(raw: string | null, now: number): ConsentChoice | null {
21
+ if (!raw) return null
22
+ try {
23
+ const value: unknown = JSON.parse(raw)
24
+ if (typeof value !== 'object' || value === null) return null
25
+ const { v, analytics, at } = value as Record<string, unknown>
26
+ if (v !== CONSENT_VERSION || typeof analytics !== 'boolean' || typeof at !== 'number') return null
27
+ if (at > now || now - at >= CONSENT_MAX_AGE_MS) return null
28
+ return { v, analytics, at }
29
+ } catch {
30
+ return null
31
+ }
32
+ }
@@ -0,0 +1,29 @@
1
+ import { CONSENT_COPY, consentCopy, type SiteConsent } from 'bearminion'
2
+
3
+ // The words are the library's, so the editor's banner dialog shows exactly what this theme renders.
4
+ export { CONSENT_COPY, consentCopy }
5
+
6
+ interface ConsentSite {
7
+ analytics?: { head?: string; bodyEnd?: string }
8
+ consent?: SiteConsent
9
+ }
10
+
11
+ /**
12
+ * Whether this site asks for consent: the owner has set analytics snippets and has not switched the banner
13
+ * off. Without snippets there is nothing to ask about. This does not depend on the build: dev and previews
14
+ * show the banner too, so the owner sees what visitors get. Only a live build also holds the snippets back
15
+ * (`analyticsEnabled`), because only a live build ships them.
16
+ */
17
+ export function consentRequired(site: ConsentSite): boolean {
18
+ if (site.consent?.enabled === false) return false
19
+ return Boolean(site.analytics?.head?.trim() || site.analytics?.bodyEnd?.trim())
20
+ }
21
+
22
+ /**
23
+ * Google Consent Mode v2, basic mode: every signal starts denied, before any tag exists. The snippets stay
24
+ * inert until the visitor accepts, and `consent-client.ts` then sends the update and adds them. `ad_*` stay
25
+ * denied: this banner has one category, analytics.
26
+ */
27
+ export const CONSENT_DEFAULT_SCRIPT =
28
+ 'window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}window.gtag=gtag;' +
29
+ "gtag('consent','default',{ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied',analytics_storage:'denied'});"