@getvitops/astro 1.0.0 → 2.1.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 +599 -0
- package/README.md +222 -3
- package/dist/index.d.mts +422 -5
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +637 -15
- package/dist/index.mjs.map +1 -1
- package/package.json +12 -5
- package/src/analytics.ts +325 -0
- package/src/components/Analytics.astro +59 -0
- package/src/components/CookieConsent.astro +106 -0
- package/src/components/Details.astro +10 -4
- package/src/components/Drawer.astro +9 -3
- package/src/components/Head.astro +46 -1
- package/src/components/Icon.astro +94 -0
- package/src/components/Popover.astro +9 -3
- package/src/seo.ts +33 -3
- package/virtual.d.ts +49 -0
package/src/analytics.ts
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `<Analytics />` resolution layer — provider config in, the exact tags to
|
|
3
|
+
* emit out.
|
|
4
|
+
*
|
|
5
|
+
* Kept separate from `Analytics.astro` for the same reason `seo.ts` is separate
|
|
6
|
+
* from `Seo.astro`: everything decidable is a pure function and asserted in
|
|
7
|
+
* `analytics.test.ts`, and the component only renders what this returns.
|
|
8
|
+
*
|
|
9
|
+
* Two properties here are the point of the whole file:
|
|
10
|
+
*
|
|
11
|
+
* - **Which consent category a provider needs is derived, never declared.** It
|
|
12
|
+
* follows from whether that provider sets cookies, which follows from its own
|
|
13
|
+
* configuration (Matomo with `disableCookies` is genuinely cookieless; Matomo
|
|
14
|
+
* with `cookies: true` is not). A consumer cannot mark Google Analytics
|
|
15
|
+
* `necessary` to skip a banner, because `setsCookies` isn't theirs to set.
|
|
16
|
+
* - **The same fact drives the legal documents.** `@getvitops/generator`'s
|
|
17
|
+
* `legal/providers.ts` maps the *site config's* `analytics` block onto named
|
|
18
|
+
* processors with cookie lists. If a provider is configured here but not
|
|
19
|
+
* there, the site runs a tag its own cookie notice doesn't disclose — so the
|
|
20
|
+
* integration warns, and `warnings` is returned as data so that check is a
|
|
21
|
+
* test rather than a log line someone might scroll past.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* When a tag is allowed to load. Third-party analytics is never worth a
|
|
26
|
+
* millisecond of the critical path, so the default puts every tag after `load`.
|
|
27
|
+
*/
|
|
28
|
+
export type AnalyticsStrategy = 'idle' | 'async' | 'interaction';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The consent vocabulary, mirroring `@getvitops/core`'s `consent/store.ts`.
|
|
32
|
+
* Mirrored rather than imported because this value is `JSON.stringify`d into the
|
|
33
|
+
* virtual module and the component reads it at build time, while core's copy is a
|
|
34
|
+
* browser runtime — `consent.test.ts` and `analytics.test.ts` pin both ends.
|
|
35
|
+
*/
|
|
36
|
+
export type ConsentCategory = 'necessary' | 'analytics' | 'marketing' | 'preferences';
|
|
37
|
+
|
|
38
|
+
export type OptionalConsentCategory = Exclude<ConsentCategory, 'necessary'>;
|
|
39
|
+
|
|
40
|
+
export interface GoogleAnalyticsOptions {
|
|
41
|
+
/** Measurement ID, e.g. `G-XXXXXXXXXX`. */
|
|
42
|
+
id: string;
|
|
43
|
+
/**
|
|
44
|
+
* Which category gates it (default `analytics`). Set `marketing` when the
|
|
45
|
+
* property feeds Google Ads — the tag is the same, the permission isn't.
|
|
46
|
+
*/
|
|
47
|
+
category?: OptionalConsentCategory;
|
|
48
|
+
/** Extra `gtag('config', id, …)` parameters, e.g. `{ send_page_view: false }`. */
|
|
49
|
+
config?: Record<string, string | number | boolean>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ClarityOptions {
|
|
53
|
+
/** Clarity project ID. */
|
|
54
|
+
id: string;
|
|
55
|
+
category?: OptionalConsentCategory;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface MatomoOptions {
|
|
59
|
+
/** Instance base URL — self-hosted, or `https://<you>.matomo.cloud`. */
|
|
60
|
+
url: string;
|
|
61
|
+
/** Site ID within that instance. */
|
|
62
|
+
siteId: string | number;
|
|
63
|
+
/**
|
|
64
|
+
* Let Matomo set its cookies (default **false**).
|
|
65
|
+
*
|
|
66
|
+
* The default is what makes Matomo the cookieless option: with
|
|
67
|
+
* `disableCookies` it stores no identifier on the device, needs no banner, and
|
|
68
|
+
* the generated cookie notice can state positively that it sets none. Turning
|
|
69
|
+
* this on gives you returning-visitor metrics and a consent obligation.
|
|
70
|
+
*/
|
|
71
|
+
cookies?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface PlausibleOptions {
|
|
75
|
+
/** The domain as registered in Plausible, e.g. `example.com`. */
|
|
76
|
+
domain: string;
|
|
77
|
+
/** Script URL — override for a self-hosted instance or a custom-events build. */
|
|
78
|
+
src?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface GetvitopsAnalyticsOptions {
|
|
82
|
+
googleAnalytics?: string | GoogleAnalyticsOptions;
|
|
83
|
+
clarity?: string | ClarityOptions;
|
|
84
|
+
matomo?: MatomoOptions;
|
|
85
|
+
plausible?: string | PlausibleOptions;
|
|
86
|
+
/** When tags load (default `'idle'`). */
|
|
87
|
+
strategy?: AnalyticsStrategy;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The consent gate, configured on the integration rather than inside `analytics`.
|
|
92
|
+
*
|
|
93
|
+
* That placement is the design, not an accident: analytics is one consumer of
|
|
94
|
+
* consent, not its owner. A/B assignment, account personalisation and third-party
|
|
95
|
+
* embeds gate on the same choice via `data-consent`, and a site can enable the
|
|
96
|
+
* banner with no analytics configured at all.
|
|
97
|
+
*/
|
|
98
|
+
export interface GetvitopsConsentOptions {
|
|
99
|
+
/**
|
|
100
|
+
* Categories the banner offers. Defaults to the ones the configured providers
|
|
101
|
+
* actually need — offering a choice nothing is waiting on is noise.
|
|
102
|
+
*/
|
|
103
|
+
categories?: OptionalConsentCategory[];
|
|
104
|
+
/** Link the banner to the cookie notice (`vitops legal` renders one). */
|
|
105
|
+
policyUrl?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One tag, fully resolved. */
|
|
109
|
+
export interface ResolvedTag {
|
|
110
|
+
key: 'googleAnalytics' | 'clarity' | 'matomo' | 'plausible';
|
|
111
|
+
/** The provider as a reader would recognise it — used in warnings. */
|
|
112
|
+
provider: string;
|
|
113
|
+
category: ConsentCategory;
|
|
114
|
+
setsCookies: boolean;
|
|
115
|
+
/** Names (or `prefix*` patterns) to clear when consent is withdrawn. */
|
|
116
|
+
cookies: string[];
|
|
117
|
+
strategy: AnalyticsStrategy;
|
|
118
|
+
/**
|
|
119
|
+
* Emitted inert (`type="text/plain"`) for the runtime to activate, rather than
|
|
120
|
+
* as a live `<script>`. False only for the one case that needs no runtime at
|
|
121
|
+
* all: a cookieless provider with `strategy: 'async'`.
|
|
122
|
+
*/
|
|
123
|
+
gated: boolean;
|
|
124
|
+
/** Bootstrap JS. Runs before `src`, so a queue is populated before it drains. */
|
|
125
|
+
inline: string;
|
|
126
|
+
src: string | null;
|
|
127
|
+
attrs: Record<string, string>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface ResolvedAnalytics {
|
|
131
|
+
tags: ResolvedTag[];
|
|
132
|
+
/** Does anything here need `@getvitops/core/consent` loaded? */
|
|
133
|
+
needsRuntime: boolean;
|
|
134
|
+
/** Configuration problems, as prose. The integration logs these. */
|
|
135
|
+
warnings: string[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ResolveAnalyticsContext {
|
|
139
|
+
/** Is the consent gate enabled on this site (`getvitops({ consent })`)? */
|
|
140
|
+
consent?: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* IDs land inside an inline `<script>`, so they are checked rather than trusted.
|
|
145
|
+
* A `</script>` in a measurement ID is not a plausible accident, but the cost of
|
|
146
|
+
* the guard is one regex and the cost of not having it is arbitrary script
|
|
147
|
+
* injection from a config file.
|
|
148
|
+
*/
|
|
149
|
+
const SAFE_ID = /^[\w.-]+$/;
|
|
150
|
+
|
|
151
|
+
function bad(value: string): boolean {
|
|
152
|
+
return !SAFE_ID.test(value);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** JSON is a subset of JS object-literal syntax; `<` can't appear unescaped. */
|
|
156
|
+
function literal(value: Record<string, string | number | boolean>): string {
|
|
157
|
+
return JSON.stringify(value).replaceAll('<', '\\u003c');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function googleAnalytics(input: string | GoogleAnalyticsOptions): ResolvedTag | string {
|
|
161
|
+
const opts = typeof input === 'string' ? { id: input } : input;
|
|
162
|
+
if (bad(opts.id)) return `googleAnalytics: "${opts.id}" is not a valid measurement ID. Skipped.`;
|
|
163
|
+
const config = opts.config ? `,${literal(opts.config)}` : '';
|
|
164
|
+
return {
|
|
165
|
+
key: 'googleAnalytics',
|
|
166
|
+
provider: 'Google Analytics',
|
|
167
|
+
category: opts.category ?? 'analytics',
|
|
168
|
+
setsCookies: true,
|
|
169
|
+
cookies: ['_ga', '_ga_*', '_gid', '_gat', '_gat_*'],
|
|
170
|
+
strategy: 'idle',
|
|
171
|
+
gated: true,
|
|
172
|
+
inline:
|
|
173
|
+
`window.dataLayer=window.dataLayer||[];` +
|
|
174
|
+
`function gtag(){dataLayer.push(arguments)}` +
|
|
175
|
+
`gtag('js',new Date());` +
|
|
176
|
+
`gtag('config','${opts.id}'${config});`,
|
|
177
|
+
src: `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(opts.id)}`,
|
|
178
|
+
attrs: {},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function clarity(input: string | ClarityOptions): ResolvedTag | string {
|
|
183
|
+
const opts = typeof input === 'string' ? { id: input } : input;
|
|
184
|
+
if (bad(opts.id)) return `clarity: "${opts.id}" is not a valid project ID. Skipped.`;
|
|
185
|
+
const category = opts.category ?? 'analytics';
|
|
186
|
+
// Clarity's snippet injects its own <script>, so there is no `src` for us to
|
|
187
|
+
// schedule — activation *is* the load.
|
|
188
|
+
//
|
|
189
|
+
// The consentv2 call is not redundant with having gated the tag: Microsoft
|
|
190
|
+
// enforces consent signals for EEA/UK/CH traffic from the project side, and
|
|
191
|
+
// without one Clarity degrades to no-consent mode even though the visitor
|
|
192
|
+
// agreed. `ad_Storage` follows the category, because that is the only thing
|
|
193
|
+
// that tells us whether this tag was permitted for advertising.
|
|
194
|
+
return {
|
|
195
|
+
key: 'clarity',
|
|
196
|
+
provider: 'Microsoft Clarity',
|
|
197
|
+
category,
|
|
198
|
+
setsCookies: true,
|
|
199
|
+
cookies: ['_clck', '_clsk', 'MUID', 'CLID', 'ANONCHK', 'SM'],
|
|
200
|
+
strategy: 'idle',
|
|
201
|
+
gated: true,
|
|
202
|
+
inline:
|
|
203
|
+
`(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};` +
|
|
204
|
+
`t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;` +
|
|
205
|
+
`y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y)})` +
|
|
206
|
+
`(window,document,"clarity","script","${opts.id}");` +
|
|
207
|
+
`window.clarity('consentv2',{ad_Storage:'${
|
|
208
|
+
category === 'marketing' ? 'granted' : 'denied'
|
|
209
|
+
}',analytics_Storage:'granted'});`,
|
|
210
|
+
src: null,
|
|
211
|
+
attrs: {},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function matomo(opts: MatomoOptions): ResolvedTag | string {
|
|
216
|
+
const siteId = String(opts.siteId);
|
|
217
|
+
if (bad(siteId)) return `matomo: "${siteId}" is not a valid site ID. Skipped.`;
|
|
218
|
+
let base: URL;
|
|
219
|
+
try {
|
|
220
|
+
base = new URL(opts.url);
|
|
221
|
+
} catch {
|
|
222
|
+
return `matomo: "${opts.url}" is not a valid URL. Skipped.`;
|
|
223
|
+
}
|
|
224
|
+
const url = `${base.origin}${base.pathname.replace(/\/*$/, '')}/`;
|
|
225
|
+
const cookies = opts.cookies === true;
|
|
226
|
+
|
|
227
|
+
// `disableCookies` must be pushed *before* trackPageView, or the first request
|
|
228
|
+
// of the visit sets the cookies this option exists to avoid.
|
|
229
|
+
return {
|
|
230
|
+
key: 'matomo',
|
|
231
|
+
provider: 'Matomo',
|
|
232
|
+
category: cookies ? 'analytics' : 'necessary',
|
|
233
|
+
setsCookies: cookies,
|
|
234
|
+
cookies: cookies ? ['_pk_id*', '_pk_ses*', '_pk_ref*', '_pk_cvar*', '_pk_hsr*'] : [],
|
|
235
|
+
strategy: 'idle',
|
|
236
|
+
gated: true,
|
|
237
|
+
inline:
|
|
238
|
+
`var _paq=window._paq=window._paq||[];` +
|
|
239
|
+
(cookies ? '' : `_paq.push(['disableCookies']);`) +
|
|
240
|
+
`_paq.push(['trackPageView']);_paq.push(['enableLinkTracking']);` +
|
|
241
|
+
`(function(){var u="${url}";` +
|
|
242
|
+
`_paq.push(['setTrackerUrl',u+'matomo.php']);_paq.push(['setSiteId','${siteId}']);` +
|
|
243
|
+
`var d=document,g=d.createElement('script'),s=d.getElementsByTagName('script')[0];` +
|
|
244
|
+
`g.async=true;g.src=u+'matomo.js';s.parentNode.insertBefore(g,s)})();`,
|
|
245
|
+
src: null,
|
|
246
|
+
attrs: {},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function plausible(input: string | PlausibleOptions): ResolvedTag | string {
|
|
251
|
+
const opts = typeof input === 'string' ? { domain: input } : input;
|
|
252
|
+
if (bad(opts.domain)) return `plausible: "${opts.domain}" is not a valid domain. Skipped.`;
|
|
253
|
+
return {
|
|
254
|
+
key: 'plausible',
|
|
255
|
+
provider: 'Plausible Analytics',
|
|
256
|
+
// Cookieless and storing no cross-site identifier, so there is nothing for a
|
|
257
|
+
// banner to ask about. It still goes through the runtime for scheduling.
|
|
258
|
+
category: 'necessary',
|
|
259
|
+
setsCookies: false,
|
|
260
|
+
cookies: [],
|
|
261
|
+
strategy: 'idle',
|
|
262
|
+
gated: true,
|
|
263
|
+
inline: '',
|
|
264
|
+
src: opts.src ?? 'https://plausible.io/js/script.js',
|
|
265
|
+
attrs: { 'data-domain': opts.domain },
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Resolve the configured providers into the tags to emit.
|
|
271
|
+
*
|
|
272
|
+
* An invalid provider is dropped with a warning rather than throwing: one
|
|
273
|
+
* malformed ID should not take down a build, and a missing tag is visible in the
|
|
274
|
+
* output while a failed build is just a red wall.
|
|
275
|
+
*/
|
|
276
|
+
export function resolveAnalytics(
|
|
277
|
+
opts: GetvitopsAnalyticsOptions | undefined,
|
|
278
|
+
ctx: ResolveAnalyticsContext = {},
|
|
279
|
+
): ResolvedAnalytics {
|
|
280
|
+
const warnings: string[] = [];
|
|
281
|
+
const tags: ResolvedTag[] = [];
|
|
282
|
+
if (!opts) return { tags, needsRuntime: false, warnings };
|
|
283
|
+
|
|
284
|
+
const strategy = opts.strategy ?? 'idle';
|
|
285
|
+
const results = [
|
|
286
|
+
opts.googleAnalytics === undefined ? null : googleAnalytics(opts.googleAnalytics),
|
|
287
|
+
opts.clarity === undefined ? null : clarity(opts.clarity),
|
|
288
|
+
opts.matomo === undefined ? null : matomo(opts.matomo),
|
|
289
|
+
opts.plausible === undefined ? null : plausible(opts.plausible),
|
|
290
|
+
];
|
|
291
|
+
|
|
292
|
+
for (const result of results) {
|
|
293
|
+
if (result === null) continue;
|
|
294
|
+
if (typeof result === 'string') {
|
|
295
|
+
warnings.push(result);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
// A cookieless tag on `async` needs nothing from the runtime, so it is emitted
|
|
299
|
+
// as an ordinary <script async> and that site ships no consent bundle at all.
|
|
300
|
+
const gated = result.category !== 'necessary' || strategy !== 'async';
|
|
301
|
+
tags.push({ ...result, strategy, gated });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const setting = tags.filter((t) => t.setsCookies);
|
|
305
|
+
if (setting.length && !ctx.consent) {
|
|
306
|
+
warnings.push(
|
|
307
|
+
`${setting.map((t) => t.provider).join(' and ')} ${setting.length === 1 ? 'sets' : 'set'} ` +
|
|
308
|
+
`cookies (${setting.flatMap((t) => t.cookies).join(', ')}) and \`consent\` is off, so ` +
|
|
309
|
+
`${setting.length === 1 ? 'it loads' : 'they load'} for every visitor with no way to ` +
|
|
310
|
+
'decline. Set `consent: true`, or switch to a cookieless provider (Plausible, or Matomo ' +
|
|
311
|
+
'with its default `cookies: false`).',
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { tags, needsRuntime: tags.some((t) => t.gated), warnings };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Which optional categories a set of tags actually needs a visitor to decide. */
|
|
319
|
+
export function consentCategories(tags: ResolvedTag[]): OptionalConsentCategory[] {
|
|
320
|
+
const used = new Set<OptionalConsentCategory>();
|
|
321
|
+
for (const tag of tags) {
|
|
322
|
+
if (tag.category !== 'necessary') used.add(tag.category);
|
|
323
|
+
}
|
|
324
|
+
return (['analytics', 'marketing', 'preferences'] as const).filter((c) => used.has(c));
|
|
325
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <Analytics /> — the site's analytics tags, off the critical path and behind the
|
|
4
|
+
* consent gate. Place in your layout's <head>; requires the getvitops()
|
|
5
|
+
* integration, which supplies `virtual:getvitops/head`.
|
|
6
|
+
*
|
|
7
|
+
* A dumb renderer over `resolveAnalytics()` (src/analytics.ts) — every decision
|
|
8
|
+
* about which tag, which consent category and which cookies lives there and is
|
|
9
|
+
* unit-tested. Nothing in this file chooses anything.
|
|
10
|
+
*
|
|
11
|
+
* The gated tags are `type="text/plain"`, which is the whole trick: the browser
|
|
12
|
+
* never parses the body as script and never fetches the library, so an undecided
|
|
13
|
+
* or declining visitor's page issues no third-party request at all. Consent
|
|
14
|
+
* implemented by *asking a tracker not to track* is a promise; this is a fact
|
|
15
|
+
* about the document. `@getvitops/core/consent` (loaded by <Head />) activates
|
|
16
|
+
* them when their category is granted and their strategy has fired.
|
|
17
|
+
*
|
|
18
|
+
* Emits no `preconnect` on purpose: warming a connection at parse time is exactly
|
|
19
|
+
* the critical-path cost the `idle` strategy exists to avoid.
|
|
20
|
+
*/
|
|
21
|
+
import { type AnalyticsStrategy, resolveAnalytics } from '../analytics.ts';
|
|
22
|
+
// @ts-ignore — virtual module provided by the getvitops() integration at build time
|
|
23
|
+
import head from 'virtual:getvitops/head';
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
/** Render nothing (e.g. on a preview deployment). Defaults to true. */
|
|
27
|
+
enabled?: boolean;
|
|
28
|
+
/** Override the configured load strategy for this page. */
|
|
29
|
+
strategy?: AnalyticsStrategy;
|
|
30
|
+
}
|
|
31
|
+
const { enabled = true, strategy } = Astro.props;
|
|
32
|
+
|
|
33
|
+
const options = strategy ? { ...head.analytics, strategy } : head.analytics;
|
|
34
|
+
const { tags } = enabled
|
|
35
|
+
? resolveAnalytics(options, { consent: head.consent })
|
|
36
|
+
: { tags: [] };
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
{tags.map((tag) => tag.gated
|
|
40
|
+
? (
|
|
41
|
+
<script
|
|
42
|
+
is:inline
|
|
43
|
+
type="text/plain"
|
|
44
|
+
data-vitops-tag
|
|
45
|
+
data-consent={tag.category}
|
|
46
|
+
data-strategy={tag.strategy}
|
|
47
|
+
data-src={tag.src}
|
|
48
|
+
data-consent-cookies={tag.cookies.length ? tag.cookies.join(',') : undefined}
|
|
49
|
+
{...tag.attrs}
|
|
50
|
+
set:html={tag.inline}
|
|
51
|
+
/>
|
|
52
|
+
)
|
|
53
|
+
: (
|
|
54
|
+
<>
|
|
55
|
+
{tag.inline && <script is:inline set:html={tag.inline} />}
|
|
56
|
+
{tag.src && <script is:inline async src={tag.src} {...tag.attrs}></script>}
|
|
57
|
+
</>
|
|
58
|
+
)
|
|
59
|
+
)}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <CookieConsent /> — the consent banner. Place near the end of <body>; requires
|
|
4
|
+
* the getvitops() integration with `consent` enabled, which is what loads
|
|
5
|
+
* `@getvitops/core/consent` (the gate + `<wc-consent>`).
|
|
6
|
+
*
|
|
7
|
+
* This renders the *fallback*: a real, semantic form with real buttons. The
|
|
8
|
+
* element parses and augments it — it never renders from empty. The markup ships
|
|
9
|
+
* `hidden` because with no JS the gate never runs, so no gated tag loads and no
|
|
10
|
+
* non-essential cookie is set: there is genuinely nothing to consent to, and a
|
|
11
|
+
* banner would be asking permission for something that isn't happening.
|
|
12
|
+
*
|
|
13
|
+
* The banner does not itself set a cookie. Only an explicit accept/reject/save
|
|
14
|
+
* writes one — otherwise the thing asking permission would be the thing needing
|
|
15
|
+
* it.
|
|
16
|
+
*
|
|
17
|
+
* **Consent is not an analytics feature.** The same gate covers A/B assignment,
|
|
18
|
+
* personalisation and embedded third-party content: mark any of them
|
|
19
|
+
* `data-consent="<category>"` and they wait on the same choice. Which categories
|
|
20
|
+
* this banner offers therefore comes from the integration, not from
|
|
21
|
+
* `<Analytics />`.
|
|
22
|
+
*/
|
|
23
|
+
// @ts-ignore — virtual module provided by the getvitops() integration at build time
|
|
24
|
+
import head from 'virtual:getvitops/head';
|
|
25
|
+
import type { OptionalConsentCategory } from '../analytics.ts';
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
/** Categories to offer. Defaults to those the configured providers need. */
|
|
29
|
+
categories?: OptionalConsentCategory[];
|
|
30
|
+
/** Link to the cookie notice (`vitops legal` renders one). */
|
|
31
|
+
policyUrl?: string;
|
|
32
|
+
policyLabel?: string;
|
|
33
|
+
heading?: string;
|
|
34
|
+
acceptLabel?: string;
|
|
35
|
+
rejectLabel?: string;
|
|
36
|
+
saveLabel?: string;
|
|
37
|
+
/** Per-category wording, merged over the defaults. */
|
|
38
|
+
labels?: Partial<Record<OptionalConsentCategory, string>>;
|
|
39
|
+
/**
|
|
40
|
+
* Keep an already-running tracker alive after a revoke (default false — we
|
|
41
|
+
* reload). See `ConsentApi.reloadOnRevoke`.
|
|
42
|
+
*/
|
|
43
|
+
noReloadOnRevoke?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_LABELS: Record<OptionalConsentCategory, string> = {
|
|
47
|
+
analytics: 'Analytics — how the site is used, so we can improve it',
|
|
48
|
+
marketing: 'Marketing — measuring which campaigns bring people here',
|
|
49
|
+
preferences: 'Preferences — remembering choices you make on the site',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const {
|
|
53
|
+
categories = head.consentCategories ?? ['analytics'],
|
|
54
|
+
policyUrl = head.consentPolicyUrl ?? undefined,
|
|
55
|
+
policyLabel = 'Cookie notice',
|
|
56
|
+
heading = 'Cookies on this site',
|
|
57
|
+
acceptLabel = 'Accept',
|
|
58
|
+
rejectLabel = 'Reject',
|
|
59
|
+
saveLabel = 'Save choices',
|
|
60
|
+
labels = {},
|
|
61
|
+
noReloadOnRevoke = false,
|
|
62
|
+
} = Astro.props;
|
|
63
|
+
|
|
64
|
+
const wording = { ...DEFAULT_LABELS, ...labels };
|
|
65
|
+
// One category is a yes/no question; checkboxes for it would be a form standing in
|
|
66
|
+
// for two buttons that already say the same thing.
|
|
67
|
+
const choose = categories.length > 1;
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
<wc-consent hidden {...noReloadOnRevoke ? { 'reload-on-revoke': 'false' } : {}}>
|
|
71
|
+
{/*
|
|
72
|
+
Shown in the top layer. A plain fixed banner resolves against the nearest
|
|
73
|
+
containing block, and `body { container-type: inline-size }` — ordinary in a
|
|
74
|
+
framework whose breakpoints are container queries — makes body one, trapping
|
|
75
|
+
the banner mid-page. `manual`, not `auto`: light dismiss would let a stray
|
|
76
|
+
click count as a decision.
|
|
77
|
+
*/}
|
|
78
|
+
<form class="consent" popover="manual" aria-label={heading}>
|
|
79
|
+
<div class="consent__body">
|
|
80
|
+
<slot>
|
|
81
|
+
<p>
|
|
82
|
+
We use cookies to understand how this site is used. You can decline without
|
|
83
|
+
losing any functionality.
|
|
84
|
+
</p>
|
|
85
|
+
</slot>
|
|
86
|
+
{policyUrl && <a class="link" href={policyUrl}>{policyLabel}</a>}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{choose && (
|
|
90
|
+
<div class="consent__options">
|
|
91
|
+
{categories.map((category) => (
|
|
92
|
+
<label>
|
|
93
|
+
<input type="checkbox" name={category} />
|
|
94
|
+
{wording[category]}
|
|
95
|
+
</label>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
<div class="consent__actions">
|
|
101
|
+
<button type="button" class="btn" data-consent-reject>{rejectLabel}</button>
|
|
102
|
+
{choose && <button type="button" class="btn" data-consent-save>{saveLabel}</button>}
|
|
103
|
+
<button type="button" class="cta" data-consent-accept>{acceptLabel}</button>
|
|
104
|
+
</div>
|
|
105
|
+
</form>
|
|
106
|
+
</wc-consent>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
import
|
|
2
|
+
import Icon from './Icon.astro';
|
|
3
3
|
|
|
4
4
|
interface Item {
|
|
5
5
|
label: string;
|
|
@@ -12,8 +12,14 @@ interface Props {
|
|
|
12
12
|
icon?: string;
|
|
13
13
|
items: Item[];
|
|
14
14
|
class?: string;
|
|
15
|
-
/**
|
|
16
|
-
*
|
|
15
|
+
/**
|
|
16
|
+
* Map a semantic name onto a fully-qualified one before rendering.
|
|
17
|
+
*
|
|
18
|
+
* @deprecated The `icons` option on `getvitops()` now does this centrally, so
|
|
19
|
+
* the site's set is configured once instead of threaded through every call
|
|
20
|
+
* site. Left in place because removing it would break consumers passing one;
|
|
21
|
+
* when omitted, `<Icon />` resolves through the site config.
|
|
22
|
+
*/
|
|
17
23
|
iconResolver?: (name: string) => string;
|
|
18
24
|
}
|
|
19
25
|
|
|
@@ -28,7 +34,7 @@ const {
|
|
|
28
34
|
<details class={`details-collapse ${className || ''}`}>
|
|
29
35
|
<summary class="details-trigger"><slot>
|
|
30
36
|
{label}
|
|
31
|
-
<Icon name={iconResolver('
|
|
37
|
+
<Icon name={iconResolver('chevron-down')} class="details-icon" /></slot>
|
|
32
38
|
</summary>
|
|
33
39
|
<slot name="content" />
|
|
34
40
|
</details>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
import
|
|
2
|
+
import Icon from './Icon.astro';
|
|
3
3
|
|
|
4
4
|
type Attrs = Record<string, string | number | boolean>;
|
|
5
5
|
|
|
@@ -16,8 +16,14 @@ interface Props {
|
|
|
16
16
|
class?: string;
|
|
17
17
|
maxWidth?: string;
|
|
18
18
|
maxHeight?: string;
|
|
19
|
-
/**
|
|
20
|
-
*
|
|
19
|
+
/**
|
|
20
|
+
* Map a semantic name onto a fully-qualified one before rendering.
|
|
21
|
+
*
|
|
22
|
+
* @deprecated The `icons` option on `getvitops()` now does this centrally, so
|
|
23
|
+
* the site's set is configured once instead of threaded through every call
|
|
24
|
+
* site. Left in place because removing it would break consumers passing one;
|
|
25
|
+
* when omitted, `<Icon />` resolves through the site config.
|
|
26
|
+
*/
|
|
21
27
|
iconResolver?: (name: string) => string;
|
|
22
28
|
}
|
|
23
29
|
|
|
@@ -4,22 +4,28 @@
|
|
|
4
4
|
* layout's <head>. Requires the getvitops() integration (it provides the
|
|
5
5
|
* `virtual:getvitops/head` config + copies the assets into public/).
|
|
6
6
|
*
|
|
7
|
-
* Renders: favicon + PWA manifest tags,
|
|
7
|
+
* Renders: favicon + PWA manifest tags, the webfont <Font /> tags for families
|
|
8
|
+
* declared via getvitops({ fonts }), and the web-component runtime scripts in
|
|
8
9
|
* canonical order/priority. It does NOT emit a stylesheet <link> — the CSS is
|
|
9
10
|
* imported into Astro's graph by the integration and Astro emits that link.
|
|
10
11
|
*/
|
|
11
12
|
// @ts-ignore — virtual module provided by the getvitops() integration at build time
|
|
12
13
|
import head from 'virtual:getvitops/head';
|
|
14
|
+
// @ts-ignore — `Font` is provided by Astro's Fonts API (astro >= 7)
|
|
15
|
+
import { Font } from 'astro:assets';
|
|
13
16
|
|
|
14
17
|
interface Props {
|
|
15
18
|
favicons?: boolean;
|
|
16
19
|
webComponents?: boolean;
|
|
17
20
|
editor?: boolean;
|
|
21
|
+
/** Emit `<Font />` for the declared families (default true when any were declared). */
|
|
22
|
+
fonts?: boolean;
|
|
18
23
|
}
|
|
19
24
|
const {
|
|
20
25
|
favicons = head.favicons,
|
|
21
26
|
webComponents = head.webComponents,
|
|
22
27
|
editor = head.editor,
|
|
28
|
+
fonts = true,
|
|
23
29
|
} = Astro.props;
|
|
24
30
|
const wc = head.wcBase;
|
|
25
31
|
|
|
@@ -34,6 +40,19 @@ const THEME_SCRIPT =
|
|
|
34
40
|
`try{var s=localStorage.getItem('vitops-color-scheme');` +
|
|
35
41
|
`if(s==='light'||s==='dark'){document.documentElement.dataset.theme=s;` +
|
|
36
42
|
`document.documentElement.style.colorScheme=s+' only'}}catch(e){}`;
|
|
43
|
+
|
|
44
|
+
/*
|
|
45
|
+
* No `<html>` class flips here on purpose. `animation.css` used to need `no-js`
|
|
46
|
+
* and `.no-scroll-timeline` set before first paint; it now asks the platform
|
|
47
|
+
* directly — `@media (scripting: enabled)` and `@supports not
|
|
48
|
+
* (animation-timeline: view())` — so there is nothing to inject.
|
|
49
|
+
*
|
|
50
|
+
* That is not a simplification for its own sake: a class-based switch could only
|
|
51
|
+
* ever reach pages that render this component, so a Bricks site or a consumer
|
|
52
|
+
* with their own `<head>` stayed broken, and `no-js` in particular had to be
|
|
53
|
+
* written into an `<html>` tag Astro does not own. The CSS version is correct for
|
|
54
|
+
* every consumer of the stylesheet, including the ones that never load any JS.
|
|
55
|
+
*/
|
|
37
56
|
---
|
|
38
57
|
|
|
39
58
|
{favicons && head.faviconLinks.map((l: { rel: string; href: string; type?: string; sizes?: string }) => (
|
|
@@ -41,6 +60,23 @@ const THEME_SCRIPT =
|
|
|
41
60
|
))}
|
|
42
61
|
{favicons && head.themeColor && <meta name="theme-color" content={head.themeColor} />}
|
|
43
62
|
|
|
63
|
+
{/*
|
|
64
|
+
Webfonts declared via getvitops({ fonts }). Astro's `fonts:` config resolves and
|
|
65
|
+
optimises the files; this component is what actually puts the @font-face on the page,
|
|
66
|
+
so without it the config is inert and every --font-* token falls through to its
|
|
67
|
+
fallback stack.
|
|
68
|
+
|
|
69
|
+
Only families this integration registered — <Font /> throws on a cssVariable Astro
|
|
70
|
+
cannot resolve, so a family from a user-level `fonts:` or another integration is
|
|
71
|
+
deliberately not listed here; render <Font /> for those yourself.
|
|
72
|
+
|
|
73
|
+
`preload` comes from the declaration, so which faces get a preload link is a property
|
|
74
|
+
of the font, not something re-decided in every layout.
|
|
75
|
+
*/}
|
|
76
|
+
{fonts && head.fonts.map((f: { cssVariable: string; preload: boolean | object[] }) => (
|
|
77
|
+
<Font cssVariable={f.cssVariable} preload={f.preload} />
|
|
78
|
+
))}
|
|
79
|
+
|
|
44
80
|
{/* Set by getvitops({ sitemap }); null when no sitemap was registered. */}
|
|
45
81
|
{head.sitemap && <link rel="sitemap" type="application/xml" href={head.sitemap} />}
|
|
46
82
|
|
|
@@ -66,6 +102,15 @@ const THEME_SCRIPT =
|
|
|
66
102
|
</>
|
|
67
103
|
)}
|
|
68
104
|
|
|
105
|
+
{/*
|
|
106
|
+
The consent gate + <wc-consent> — its own Lit-free bundle, loaded whenever the
|
|
107
|
+
gate is on or a tag needs scheduling. Deliberately NOT inside the
|
|
108
|
+
`webComponents` block: this module decides whether third-party tags run at all,
|
|
109
|
+
so it must not be switched off along with the element runtime, and it must be
|
|
110
|
+
free to load ahead of the deferred Lit bundle rather than behind it.
|
|
111
|
+
*/}
|
|
112
|
+
{head.consentRuntime && <script is:inline type="module" src={`${wc}/consent.js`}></script>}
|
|
113
|
+
|
|
69
114
|
{/*
|
|
70
115
|
The live theme editor — a separate, opt-in bundle (getvitops({ editor: true })).
|
|
71
116
|
It is tooling, not part of the runtime, so it stays out of elements.js and off
|