create-bearminion 1.2.1 → 1.4.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 +17 -0
- package/dist/cli.mjs +27 -27
- package/package.json +1 -1
- package/template/README.md +26 -1
- package/template/package.json +1 -1
- 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 +23 -15
- package/template/src/components/seo/Seo.astro +6 -1
- package/template/src/layouts/Base.astro +17 -1
- package/template/src/lib/analytics.ts +33 -0
- 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,22 @@
|
|
|
1
1
|
# create-bearminion
|
|
2
2
|
|
|
3
|
+
## 1.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 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).
|
|
8
|
+
|
|
9
|
+
## 1.3.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 4de8434: Custom JSON-LD and analytics snippets. A page's `seo.jsonLd` holds parsed JSON-LD blocks that replace the generated `WebPage` markup, and `site.analytics` (`head`, `bodyEnd`) holds raw snippets the template emits on the live site only: not in `astro dev`, and not in Vercel or Netlify preview deployments. Nothing needs configuring; `BEARMINION_ANALYTICS=on|off` overrides the choice for hosts that cannot be told apart. The Pages tab shows an SEO card (search result preview and badges) with an Edit SEO button that opens a dialog for every SEO field, including JSON-LD with validation and script-tag unwrapping. The Site tab has an Analytics & scripts group. `parseJsonLdInput`, `formatJsonLd`, `jsonLdWarnings`, `jsonLdSchema` and `siteAnalyticsSchema` are exported. Themes need to render the two new fields (see the theme docs).
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 826b0ac: `create-bearminion` speaks in the BearMinion voice: the prompts, progress lines, errors, next steps and `--help` text. The messages say the same things, with fewer words and a grumpier bear.
|
|
18
|
+
- 2032a4a: Template: footer columns stay left-aligned on small screens instead of piling up on the right. They stack in one column on phones, two columns on larger phones, three columns on tablets, and sit beside the brand block from wide layouts up.
|
|
19
|
+
|
|
3
20
|
## 1.2.1
|
|
4
21
|
|
|
5
22
|
### Patch Changes
|
package/dist/cli.mjs
CHANGED
|
@@ -943,7 +943,7 @@ function runCommand(pm, script) {
|
|
|
943
943
|
//#endregion
|
|
944
944
|
//#region src/args.ts
|
|
945
945
|
var UsageError = class extends Error {};
|
|
946
|
-
const HELP = `
|
|
946
|
+
const HELP = `Scaffold a BearMinion site: Astro, plain JSON content, an editor you click on. No database, no drama.
|
|
947
947
|
|
|
948
948
|
Usage
|
|
949
949
|
npm create bearminion@latest [directory] -- [options]
|
|
@@ -951,11 +951,11 @@ Usage
|
|
|
951
951
|
Options
|
|
952
952
|
--name <name> Site name (default: from the directory)
|
|
953
953
|
--url <address> Public address, such as https://example.com (you can set it later in the editor)
|
|
954
|
-
--storybook Include Storybook
|
|
955
|
-
--pm <manager> Package manager
|
|
956
|
-
--no-install
|
|
957
|
-
--no-git
|
|
958
|
-
-y, --yes Ask nothing
|
|
954
|
+
--storybook Include Storybook (off by default; it is a lot of extra packages)
|
|
955
|
+
--pm <manager> Package manager: ${PACKAGE_MANAGERS.join(", ")} (default: the one you ran)
|
|
956
|
+
--no-install Skip installing dependencies
|
|
957
|
+
--no-git Skip git init
|
|
958
|
+
-y, --yes Ask nothing, use defaults for whatever is missing
|
|
959
959
|
-h, --help Show this help
|
|
960
960
|
-v, --version Show the version
|
|
961
961
|
`;
|
|
@@ -968,9 +968,9 @@ function parseCliArgs(argv) {
|
|
|
968
968
|
throw new UsageError(error instanceof Error ? error.message : String(error));
|
|
969
969
|
}
|
|
970
970
|
const { values, positionals } = parsed;
|
|
971
|
-
if (positionals.length > 1) throw new UsageError(`
|
|
971
|
+
if (positionals.length > 1) throw new UsageError(`One directory, please. Got ${positionals.length}: ${positionals.join(" ")}`);
|
|
972
972
|
const pm = values.pm;
|
|
973
|
-
if (pm !== void 0 && !isPackageManager(pm)) throw new UsageError(`
|
|
973
|
+
if (pm !== void 0 && !isPackageManager(pm)) throw new UsageError(`"${pm}" isn't a package manager I know. Use one of: ${PACKAGE_MANAGERS.join(", ")}.`);
|
|
974
974
|
return {
|
|
975
975
|
...positionals[0] !== void 0 ? { directory: positionals[0] } : {},
|
|
976
976
|
...values.name !== void 0 ? { name: values.name } : {},
|
|
@@ -1269,9 +1269,9 @@ async function main() {
|
|
|
1269
1269
|
return 0;
|
|
1270
1270
|
}
|
|
1271
1271
|
const interactive = !options.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1272
|
-
intro("
|
|
1272
|
+
intro("🐻 Oh, great. Another website. Let's make this quick.");
|
|
1273
1273
|
const directory = options.directory ?? (interactive ? await ask(text({
|
|
1274
|
-
message: "
|
|
1274
|
+
message: "Which folder? A new or empty one.",
|
|
1275
1275
|
placeholder: `./${DEFAULT_DIRECTORY}`,
|
|
1276
1276
|
defaultValue: DEFAULT_DIRECTORY
|
|
1277
1277
|
})) : DEFAULT_DIRECTORY);
|
|
@@ -1279,11 +1279,11 @@ async function main() {
|
|
|
1279
1279
|
const targetDir = path.resolve(directory);
|
|
1280
1280
|
const blocking = await blockingEntries(targetDir);
|
|
1281
1281
|
if (blocking.length > 0) {
|
|
1282
|
-
cancel(
|
|
1282
|
+
cancel(`Grrr. ${directory} isn't empty (${preview(blocking)}). Pick another folder, or clean up your mess first.`);
|
|
1283
1283
|
return 1;
|
|
1284
1284
|
}
|
|
1285
1285
|
const siteName = options.name ?? (interactive ? await ask(text({
|
|
1286
|
-
message: "What
|
|
1286
|
+
message: "What are we calling this masterpiece?",
|
|
1287
1287
|
placeholder: toSiteName(directory),
|
|
1288
1288
|
defaultValue: toSiteName(directory)
|
|
1289
1289
|
})) : toSiteName(directory));
|
|
@@ -1292,23 +1292,23 @@ async function main() {
|
|
|
1292
1292
|
if (options.url !== void 0) {
|
|
1293
1293
|
const normalized = normalizeUrl(options.url);
|
|
1294
1294
|
if (!normalized) {
|
|
1295
|
-
cancel(`"${options.url}"
|
|
1295
|
+
cancel(`"${options.url}" isn't a web address. Try something like https://example.com.`);
|
|
1296
1296
|
return 1;
|
|
1297
1297
|
}
|
|
1298
1298
|
url = normalized;
|
|
1299
1299
|
} else if (interactive) {
|
|
1300
1300
|
const answer = await ask(text({
|
|
1301
|
-
message: "What
|
|
1302
|
-
placeholder: "https://example.com (Enter to skip
|
|
1301
|
+
message: "What's its web address?",
|
|
1302
|
+
placeholder: "https://example.com (Enter to skip. Set it later under Site → URL)",
|
|
1303
1303
|
defaultValue: "",
|
|
1304
|
-
validate: (value) => !value || normalizeUrl(value) ? void 0 : "
|
|
1304
|
+
validate: (value) => !value || normalizeUrl(value) ? void 0 : "That's not a web address. Try https://example.com, or leave it empty."
|
|
1305
1305
|
}));
|
|
1306
1306
|
if (answer === void 0) return cancelled();
|
|
1307
1307
|
url = answer ? normalizeUrl(answer) ?? void 0 : void 0;
|
|
1308
1308
|
}
|
|
1309
1309
|
const pm = options.packageManager ?? detectPackageManager(process.env.npm_config_user_agent);
|
|
1310
1310
|
const spin = spinner();
|
|
1311
|
-
spin.start("
|
|
1311
|
+
spin.start("Unpacking the template");
|
|
1312
1312
|
await scaffold({
|
|
1313
1313
|
templateDir: path.join(packageRoot, "template"),
|
|
1314
1314
|
targetDir,
|
|
@@ -1317,25 +1317,25 @@ async function main() {
|
|
|
1317
1317
|
...url ? { url } : {},
|
|
1318
1318
|
storybook: options.storybook
|
|
1319
1319
|
});
|
|
1320
|
-
spin.stop(`
|
|
1320
|
+
spin.stop(`Done. ${siteName} lives in ${path.relative(process.cwd(), targetDir) || "."}${options.storybook ? ", Storybook included" : ""}`);
|
|
1321
1321
|
let installed = false;
|
|
1322
1322
|
if (options.install) {
|
|
1323
|
-
spin.start(`
|
|
1323
|
+
spin.start(`Fetching the bare minimum with ${pm}`);
|
|
1324
1324
|
const result = await install(pm, targetDir);
|
|
1325
1325
|
installed = result.ok;
|
|
1326
|
-
if (result.ok) spin.stop("
|
|
1326
|
+
if (result.ok) spin.stop("Dependencies installed. Could have been worse.");
|
|
1327
1327
|
else {
|
|
1328
1328
|
spin.error(`${pm} install failed`);
|
|
1329
1329
|
log.message(result.output);
|
|
1330
|
-
log.warn(`
|
|
1330
|
+
log.warn(`Sort out whatever that is, then run ${pm} install in the folder.`);
|
|
1331
1331
|
}
|
|
1332
1332
|
}
|
|
1333
1333
|
if (options.git) {
|
|
1334
|
-
if (insideGitRepository(path.dirname(targetDir))) log.info("
|
|
1334
|
+
if (insideGitRepository(path.dirname(targetDir))) log.info("Already inside a git repository, so I didn't start another one.");
|
|
1335
1335
|
else {
|
|
1336
1336
|
const git = initGit(targetDir);
|
|
1337
|
-
if (!git.initialized) log.warn(`
|
|
1338
|
-
else if (!git.committed) log.warn("
|
|
1337
|
+
if (!git.initialized) log.warn(`Couldn't create a git repository: ${git.reason ?? "git is not available"}`);
|
|
1338
|
+
else if (!git.committed) log.warn("Repository created, first commit failed. Set your name and email with git config, then commit. Not my department.");
|
|
1339
1339
|
}
|
|
1340
1340
|
}
|
|
1341
1341
|
const steps = [
|
|
@@ -1343,8 +1343,8 @@ async function main() {
|
|
|
1343
1343
|
...installed ? [] : [`${pm} install`],
|
|
1344
1344
|
runCommand(pm, "dev")
|
|
1345
1345
|
];
|
|
1346
|
-
note(`${steps.join("\n")}\n\nOpen the address it prints
|
|
1347
|
-
outro(url ? `Ready to publish at ${url}.` : "Set your web address under Site → URL before you publish.");
|
|
1346
|
+
note(`${steps.join("\n")}\n\nOpen the address it prints, press Ctrl+E (Cmd+E on a Mac) and click on things.`, "Next steps");
|
|
1347
|
+
outro(url ? `Ready to publish at ${url}. I'm going back to sleep.` : "Set your web address under Site → URL before you publish. I'll be asleep.");
|
|
1348
1348
|
return 0;
|
|
1349
1349
|
}
|
|
1350
1350
|
/** Awaits a prompt; `undefined` when the person pressed Ctrl+C or Esc. */
|
|
@@ -1353,7 +1353,7 @@ async function ask(prompt) {
|
|
|
1353
1353
|
return typeof value === "string" ? value : void 0;
|
|
1354
1354
|
}
|
|
1355
1355
|
function cancelled() {
|
|
1356
|
-
cancel("Nothing was created.");
|
|
1356
|
+
cancel("Nothing was created. Back to sleep.");
|
|
1357
1357
|
return 1;
|
|
1358
1358
|
}
|
|
1359
1359
|
function preview(entries) {
|
package/package.json
CHANGED
package/template/README.md
CHANGED
|
@@ -184,12 +184,37 @@ 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.
|
|
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, 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
|
|
|
191
191
|
The style guide at `/style-guide` renders every typography size and button variant. `/variants` renders every section, header, and footer variant. They live in `src/dev/` and only `npm run dev` serves them (the `devPages` integration in `astro.config.ts`), so they never reach the built site. Delete that integration and the folder if you do not want them.
|
|
192
192
|
|
|
193
|
+
## Analytics
|
|
194
|
+
|
|
195
|
+
`site.analytics.head` and `site.analytics.bodyEnd` are raw HTML snippets (the editor's Site → Analytics & scripts). `Base.astro` writes `head` at the end of `<head>` and `bodyEnd` before `</body>`, and only when `analyticsEnabled()` in `src/lib/analytics.ts` says the build is the live site's:
|
|
196
|
+
|
|
197
|
+
1. `BEARMINION_ANALYTICS=on` or `off` decides, when set. `1`, `true` and `yes` also mean on; `0`, `false` and `no` mean off.
|
|
198
|
+
2. Otherwise a production build is required, so `npm run dev` sends no visits.
|
|
199
|
+
3. Vercel (`VERCEL_ENV`) and Netlify (`NETLIFY`, `CONTEXT`) say which deployment is building, so only their production deployments include the snippets.
|
|
200
|
+
4. On any other host every production build includes them. Cloudflare Pages and similar hosts do not say which build is a preview. Set `BEARMINION_ANALYTICS=off` in the host's preview environment (Cloudflare lets you set variables per environment), or `on` to test a snippet locally with `npm run dev`.
|
|
201
|
+
|
|
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
|
+
|
|
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
|
+
|
|
193
218
|
## Dev editor
|
|
194
219
|
|
|
195
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
|
@@ -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
|
)
|
|
@@ -68,9 +72,9 @@ const wordmark = splitMarkedText(site.name, site.nameMarks)
|
|
|
68
72
|
</span>
|
|
69
73
|
)}
|
|
70
74
|
</div>
|
|
71
|
-
<div class="
|
|
75
|
+
<div class="grid w-full grid-cols-1 gap-x-8 gap-y-10 @min-[26rem]:grid-cols-2 @min-[40rem]:grid-cols-3 @min-[56rem]:ml-auto @min-[56rem]:flex @min-[56rem]:w-auto @min-[56rem]:flex-wrap @min-[56rem]:justify-end">
|
|
72
76
|
{columns.map((column, columnIndex) => (
|
|
73
|
-
<div class="flex
|
|
77
|
+
<div class="flex min-w-0 flex-col gap-3.5 @min-[56rem]:w-40 @min-[56rem]:min-w-32">
|
|
74
78
|
<p class="font-mono text-[0.6875rem] tracking-[0.08em] text-muted uppercase" data-field={`footer.columns.${columnIndex}.title`}>
|
|
75
79
|
{column.title}
|
|
76
80
|
</p>
|
|
@@ -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
|
)
|
|
@@ -99,9 +104,9 @@ const wordmark = splitMarkedText(site.name, site.nameMarks)
|
|
|
99
104
|
</Typography>
|
|
100
105
|
)}
|
|
101
106
|
</div>
|
|
102
|
-
<div class="
|
|
107
|
+
<div class="grid w-full grid-cols-1 gap-x-8 gap-y-8 @min-[26rem]:grid-cols-2 @min-[40rem]:grid-cols-3 @min-[56rem]:ml-auto @min-[56rem]:flex @min-[56rem]:w-auto @min-[56rem]:flex-wrap @min-[56rem]:justify-end">
|
|
103
108
|
{columns.map((column, columnIndex) => (
|
|
104
|
-
<div class="flex min-w-
|
|
109
|
+
<div class="flex min-w-0 flex-col gap-3.5 @min-[56rem]:min-w-32">
|
|
105
110
|
<p class="font-mono text-[0.6875rem] tracking-[0.08em] text-muted uppercase" data-field={`footer.columns.${columnIndex}.title`}>
|
|
106
111
|
{column.title}
|
|
107
112
|
</p>
|
|
@@ -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) => {
|
|
@@ -60,4 +60,9 @@ const jsonLd = {
|
|
|
60
60
|
<meta name="twitter:title" content={title} />
|
|
61
61
|
<meta name="twitter:description" content={description} />
|
|
62
62
|
{ogImage && <meta name="twitter:image" content={ogImage} />}
|
|
63
|
-
|
|
63
|
+
{/* Custom JSON-LD replaces the generated WebPage block. `<` is escaped so a value cannot close the script tag. */}
|
|
64
|
+
{
|
|
65
|
+
(page.seo.jsonLd ?? [jsonLd]).map((block) => (
|
|
66
|
+
<script type="application/ld+json" set:html={JSON.stringify(block).replace(/</g, '\\u003c')} />
|
|
67
|
+
))
|
|
68
|
+
}
|
|
@@ -1,10 +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'
|
|
8
|
+
import { analyticsEnabled } from '../lib/analytics'
|
|
7
9
|
import { resolveLocalAsset } from '../lib/assets'
|
|
10
|
+
import { CONSENT_DEFAULT_SCRIPT, consentCopy, consentRequired } from '../lib/consent'
|
|
8
11
|
import '../styles/global.css'
|
|
9
12
|
|
|
10
13
|
interface Props {
|
|
@@ -18,6 +21,13 @@ const ICON_TYPES: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg'
|
|
|
18
21
|
const iconType = icon ? ICON_TYPES[icon.format] : undefined
|
|
19
22
|
const paletteCss = themeCss(site.theme.colors)
|
|
20
23
|
const themeColor = site.theme.mode === 'dark' ? '#1c1e24' : '#f7f8f8'
|
|
24
|
+
// Snippets ship in the live site's build only: not in `astro dev`, not in host previews. See lib/analytics.ts.
|
|
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
|
|
21
31
|
---
|
|
22
32
|
|
|
23
33
|
<!doctype html>
|
|
@@ -27,6 +37,9 @@ const themeColor = site.theme.mode === 'dark' ? '#1c1e24' : '#f7f8f8'
|
|
|
27
37
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
28
38
|
<Seo site={site} page={page} pathname={Astro.url.pathname} />
|
|
29
39
|
<meta name="theme-color" content={themeColor} />
|
|
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} />}
|
|
30
43
|
{/* Always present so the editor can update the palette live. */}
|
|
31
44
|
<style id="bm-theme" is:inline set:html={paletteCss}></style>
|
|
32
45
|
{icon && <link rel="icon" type={iconType} href={icon.src} />}
|
|
@@ -42,7 +55,10 @@ const themeColor = site.theme.mode === 'dark' ? '#1c1e24' : '#f7f8f8'
|
|
|
42
55
|
<main id="content">
|
|
43
56
|
<slot />
|
|
44
57
|
</main>
|
|
45
|
-
<Footer site={site} />
|
|
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} />}
|
|
46
62
|
<script>
|
|
47
63
|
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
48
64
|
const observer =
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Build-time environment, as `process.env` gives it. */
|
|
2
|
+
export type Env = Record<string, string | undefined>
|
|
3
|
+
|
|
4
|
+
const ON = new Set(['on', '1', 'true', 'yes'])
|
|
5
|
+
const OFF = new Set(['off', '0', 'false', 'no'])
|
|
6
|
+
|
|
7
|
+
function readSwitch(value: string | undefined): boolean | undefined {
|
|
8
|
+
const word = value?.trim().toLowerCase()
|
|
9
|
+
if (word === undefined) return undefined
|
|
10
|
+
if (ON.has(word)) return true
|
|
11
|
+
if (OFF.has(word)) return false
|
|
12
|
+
return undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether this build puts `site.analytics` into its pages (`Base.astro` calls it with `process.env` and
|
|
17
|
+
* `import.meta.env.PROD`). Nobody has to set anything:
|
|
18
|
+
*
|
|
19
|
+
* 1. `BEARMINION_ANALYTICS=on|off` decides, when someone sets it (for hosts that cannot be told apart, or a
|
|
20
|
+
* local test).
|
|
21
|
+
* 2. Otherwise only a production build counts, so `astro dev` sends no visits.
|
|
22
|
+
* 3. Vercel (`VERCEL_ENV`) and Netlify (`NETLIFY`, `CONTEXT`) say which deployment this is, so their preview
|
|
23
|
+
* deployments send none either.
|
|
24
|
+
* 4. Any other host: every production build counts.
|
|
25
|
+
*/
|
|
26
|
+
export function analyticsEnabled(env: Env, production: boolean): boolean {
|
|
27
|
+
const override = readSwitch(env.BEARMINION_ANALYTICS)
|
|
28
|
+
if (override !== undefined) return override
|
|
29
|
+
if (!production) return false
|
|
30
|
+
if (env.VERCEL_ENV) return env.VERCEL_ENV === 'production'
|
|
31
|
+
if (env.NETLIFY === 'true') return env.CONTEXT === 'production'
|
|
32
|
+
return true
|
|
33
|
+
}
|
|
@@ -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'});"
|