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 +12 -0
- package/package.json +2 -2
- package/template/README.md +15 -1
- package/template/package.json +4 -4
- package/template/src/components/atoms/ConsentLink.astro +16 -0
- package/template/src/components/organisms/ConsentBanner.astro +77 -0
- package/template/src/components/organisms/Footer.astro +19 -11
- package/template/src/components/seo/Seo.astro +11 -9
- package/template/src/layouts/Base.astro +15 -4
- package/template/src/lib/consent-client.ts +113 -0
- package/template/src/lib/consent-store.ts +32 -0
- package/template/src/lib/consent.ts +29 -0
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
|
+
"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.
|
|
49
|
+
"vitest": "^5.0.5"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
52
|
"build": "tsdown",
|
package/template/README.md
CHANGED
|
@@ -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,
|
|
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.
|
package/template/package.json
CHANGED
|
@@ -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.
|
|
22
|
-
"bearminion": "^1.
|
|
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.
|
|
31
|
-
"@storybook-astro/framework": "^1.
|
|
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
|
-
<
|
|
46
|
-
{legal}
|
|
47
|
-
|
|
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
|
-
<
|
|
84
|
-
{legal}
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{/*
|
|
65
|
+
{/* `<` is escaped so a value cannot close the script tag. */}
|
|
64
66
|
{
|
|
65
|
-
|
|
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
|
|
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
|
-
{
|
|
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'});"
|