@getvitops/astro 1.0.0 → 2.0.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.
@@ -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 { Icon } from 'astro-icon/components';
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
- /** Resolve semantic icon names to fully-qualified astro-icon names.
16
- * Defaults to pass-through (expects already-qualified names like 'fa7-solid:chevron-down'). */
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('expand-more')} class="details-icon" /></slot>
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 { Icon } from 'astro-icon/components';
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
- /** Resolve semantic icon names to fully-qualified astro-icon names.
20
- * Defaults to pass-through (expects already-qualified names like 'fa7-solid:bars'). */
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, and the web-component runtime scripts in
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