@getvitops/astro 8.2.0 → 8.3.0-alpha.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,260 @@
1
+ /**
2
+ * Pure decisions behind `<SiteNav />` / `<SiteFooter />` — which menus render, to what
3
+ * depth, at which container breakpoint, and how an item reads. Kept out of the `.astro`
4
+ * files so they are asserted in `sitenav.test.ts` without rendering (the same split
5
+ * `tree.ts` makes for `<Tree />`).
6
+ *
7
+ * The item shape is structural, not imported from `emdash`: `@getvitops/astro` has no
8
+ * dependency on the CMS, and an EmDash `MenuItem` already satisfies it.
9
+ */
10
+
11
+ export type NavBreakpoint = 'sm' | 'md' | 'lg' | 'xl';
12
+
13
+ /** Container breakpoints, in rem — the same four `layout.css` queries use. */
14
+ export const BREAKPOINT_REM: Record<NavBreakpoint, number> = { sm: 30, md: 48, lg: 64, xl: 80 };
15
+
16
+ const BREAKPOINT_ORDER: NavBreakpoint[] = ['sm', 'md', 'lg', 'xl'];
17
+
18
+ export interface NavItem {
19
+ label: string;
20
+ url?: string | null;
21
+ target?: string;
22
+ /**
23
+ * EmDash menu items have no description field. The documented convention until
24
+ * they do: **`titleAttr` is the item's description**, shown as a second line under
25
+ * a dropdown/drawer sub-item. It is deliberately NOT emitted as a `title=""`
26
+ * tooltip — that would announce it twice and is unreachable by keyboard/touch.
27
+ */
28
+ titleAttr?: string;
29
+ /** Extra classes from the editor; passed through on the link. */
30
+ cssClasses?: string;
31
+ children?: NavItem[];
32
+ }
33
+
34
+ /** A menu as `getMenu()` returns it, or just its items. */
35
+ export type NavMenuInput = { items?: NavItem[] | null } | NavItem[] | null | undefined;
36
+
37
+ /** The three menu names `<SiteNav>` knows, and the tier each one is. */
38
+ export const MENU_NAMES = {
39
+ primary: 'primary',
40
+ mobile: 'primary-mobile',
41
+ mega: 'primary-mega',
42
+ } as const;
43
+
44
+ export function menuItems(menu: NavMenuInput): NavItem[] {
45
+ if (!menu) return [];
46
+ const items = Array.isArray(menu) ? menu : (menu.items ?? []);
47
+ return items;
48
+ }
49
+
50
+ /**
51
+ * Fetch whichever of the three header menus exist. `getMenu` is passed in rather than
52
+ * imported, so this stays CMS-free: `loadNavMenus(getMenu)` with `emdash`'s.
53
+ */
54
+ export async function loadNavMenus(
55
+ getMenu: (name: string) => Promise<NavMenuInput>,
56
+ ): Promise<{ primary: NavMenuInput; mobile: NavMenuInput; mega: NavMenuInput }> {
57
+ const [primary, mobile, mega] = await Promise.all([
58
+ getMenu(MENU_NAMES.primary),
59
+ getMenu(MENU_NAMES.mobile),
60
+ getMenu(MENU_NAMES.mega),
61
+ ]);
62
+ return { primary, mobile, mega };
63
+ }
64
+
65
+ /**
66
+ * Cut a tree to `depth` levels (1 = top level only). Returns copies, never mutates —
67
+ * a menu object from `getMenu()` is cached and shared across renders.
68
+ */
69
+ export function trimDepth(items: NavItem[], depth: number): NavItem[] {
70
+ return items.map((item) => ({
71
+ ...item,
72
+ children: depth > 1 ? trimDepth(item.children ?? [], depth - 1) : [],
73
+ }));
74
+ }
75
+
76
+ /** How many levels the tree actually has (0 for an empty menu). */
77
+ export function treeDepth(items: NavItem[]): number {
78
+ return items.reduce((max, item) => Math.max(max, 1 + treeDepth(item.children ?? [])), 0);
79
+ }
80
+
81
+ /**
82
+ * `aria-current="page"` for the item whose URL is the current page. Trailing slashes
83
+ * are ignored; external URLs, `#` and `mailto:`/`tel:` are never current. A query or
84
+ * hash on the item is ignored too, so `/contact#form` still matches `/contact`.
85
+ */
86
+ export function isCurrent(url: string | null | undefined, currentPath: string): boolean {
87
+ if (!url || url.startsWith('#')) return false;
88
+ // Only site-relative paths can be "this page": an absolute URL may be another origin.
89
+ if (!url.startsWith('/') || url.startsWith('//')) return false;
90
+ const path = url;
91
+ const norm = (p: string) => {
92
+ const bare = p.split(/[?#]/)[0] || '/';
93
+ return bare.length > 1 ? bare.replace(/\/+$/, '') : bare;
94
+ };
95
+ return norm(path) === norm(currentPath);
96
+ }
97
+
98
+ /** A branch is current-ish (open the disclosure by default) when a descendant is current. */
99
+ export function containsCurrent(item: NavItem, currentPath: string): boolean {
100
+ return (item.children ?? []).some(
101
+ (c) => isCurrent(c.url, currentPath) || containsCurrent(c, currentPath),
102
+ );
103
+ }
104
+
105
+ export interface SiteNavTier {
106
+ kind: 'mobile' | 'default' | 'mega';
107
+ /** Menu name this tier is read from. */
108
+ menu: string;
109
+ items: NavItem[];
110
+ /** The `.sitenav--bp-*` class — where THIS nav promotes from drawer to navbar. */
111
+ breakpoint: NavBreakpoint;
112
+ /** Levels rendered. */
113
+ depth: number;
114
+ /**
115
+ * Wrapper classes that show this tier only inside its container-width window
116
+ * (`sitenav-tier--below-md`, `sitenav-tier--from-xl`). Empty when it is the only tier.
117
+ */
118
+ visibility: string[];
119
+ }
120
+
121
+ export interface SiteNavOptions {
122
+ primary?: NavMenuInput;
123
+ mobile?: NavMenuInput;
124
+ mega?: NavMenuInput;
125
+ /** Where the default nav promotes from drawer to dropdown navbar. Default `md`. */
126
+ breakpoint?: NavBreakpoint;
127
+ /** Where `primary-mobile` hands over to the default nav. Default = `breakpoint`. */
128
+ mobileBreakpoint?: NavBreakpoint;
129
+ /** Where `primary-mega` takes over from the default nav. Default `xl`. */
130
+ megaBreakpoint?: NavBreakpoint;
131
+ /** Levels for the default nav. Default 3 (link, dropdown, fly-out). */
132
+ depth?: number;
133
+ /** Levels for the mega panel. Default 3. */
134
+ megaDepth?: number;
135
+ }
136
+
137
+ /**
138
+ * Decide which navs render. The default menu is always the middle tier; variants are
139
+ * opt-in by simply existing (adding a `primary-mobile` menu in the admin swaps the
140
+ * small-screen nav with no code change). With NO `primary` menu but a variant present,
141
+ * the variant still renders — but alone it is shown at every width rather than
142
+ * leaving a window with no nav.
143
+ *
144
+ * Window arithmetic, with m = mobileBreakpoint and x = megaBreakpoint:
145
+ * mobile : below m
146
+ * default : from m (only if mobile exists) and below x (only if mega exists)
147
+ * mega : from x
148
+ * A mega breakpoint at or below the mobile one is a configuration error and throws —
149
+ * the default window would be empty and the page would silently lose its nav.
150
+ */
151
+ export function selectTiers(opts: SiteNavOptions): SiteNavTier[] {
152
+ const bp = opts.breakpoint ?? 'md';
153
+ const mbp = opts.mobileBreakpoint ?? bp;
154
+ const xbp = opts.megaBreakpoint ?? 'xl';
155
+ const depth = Math.max(1, opts.depth ?? 3);
156
+ const megaDepth = Math.max(1, opts.megaDepth ?? 3);
157
+
158
+ const primary = menuItems(opts.primary);
159
+ const mobile = menuItems(opts.mobile);
160
+ const mega = menuItems(opts.mega);
161
+
162
+ const hasMobile = mobile.length > 0;
163
+ const hasMega = mega.length > 0;
164
+
165
+ if (hasMobile && hasMega && BREAKPOINT_REM[xbp] <= BREAKPOINT_REM[mbp]) {
166
+ throw new Error(
167
+ `SiteNav: megaBreakpoint "${xbp}" must be wider than mobileBreakpoint "${mbp}" — ` +
168
+ `otherwise the default nav has no width window to render in.`,
169
+ );
170
+ }
171
+
172
+ // The default tier falls back to whichever variant exists when `primary` is empty,
173
+ // so a site that only defined `primary-mobile` is never left with no header nav.
174
+ const tiers: SiteNavTier[] = [];
175
+
176
+ if (hasMobile) {
177
+ tiers.push({
178
+ kind: 'mobile',
179
+ menu: MENU_NAMES.mobile,
180
+ items: trimDepth(mobile, 1),
181
+ // Never promotes inside its own window, but it must name a breakpoint: the hide
182
+ // point is where the wide flag would flip, so the two can't disagree.
183
+ breakpoint: mbp,
184
+ depth: 1,
185
+ visibility: primary.length || hasMega ? [`below-${mbp}`] : [],
186
+ });
187
+ }
188
+
189
+ if (primary.length) {
190
+ const visibility: string[] = [];
191
+ if (hasMobile) visibility.push(`from-${mbp}`);
192
+ if (hasMega) visibility.push(`below-${xbp}`);
193
+ tiers.push({
194
+ kind: 'default',
195
+ menu: MENU_NAMES.primary,
196
+ items: trimDepth(primary, depth),
197
+ breakpoint: hasMobile ? mbp : bp,
198
+ depth,
199
+ visibility,
200
+ });
201
+ }
202
+
203
+ if (hasMega) {
204
+ tiers.push({
205
+ kind: 'mega',
206
+ menu: MENU_NAMES.mega,
207
+ items: trimDepth(mega, megaDepth),
208
+ breakpoint: xbp,
209
+ depth: megaDepth,
210
+ // With no default tier the mega menu must cover the narrow widths itself, so it
211
+ // is only windowed when something else renders below it.
212
+ visibility: primary.length || hasMobile ? [`from-${xbp}`] : [],
213
+ });
214
+ }
215
+
216
+ // A lone tier is never windowed. (Guards the arithmetic above if branches change.)
217
+ if (tiers.length === 1) tiers[0]!.visibility = [];
218
+ return tiers;
219
+ }
220
+
221
+ /** Every visibility class `selectTiers` can emit — `sitenav.css` must define each. */
222
+ export const VISIBILITY_CLASSES: string[] = BREAKPOINT_ORDER.flatMap((b) => [
223
+ `below-${b}`,
224
+ `from-${b}`,
225
+ ]);
226
+
227
+ /** Wrapper `class` for one tier. */
228
+ export function tierClass(tier: SiteNavTier): string[] {
229
+ return [
230
+ 'sitenav-tier',
231
+ `sitenav-tier--${tier.kind}`,
232
+ ...tier.visibility.map((v) => `sitenav-tier--${v}`),
233
+ ];
234
+ }
235
+
236
+ /** Extra classes on the `.sitenav` root for a tier. */
237
+ export function navClass(tier: SiteNavTier): string[] {
238
+ return [
239
+ 'sitenav',
240
+ `sitenav--bp-${tier.breakpoint}`,
241
+ 'sitenav--drawer-end',
242
+ ...(tier.kind === 'mega' ? ['sitenav--mega'] : []),
243
+ ];
244
+ }
245
+
246
+ /** Description text for an item, or undefined. See `NavItem.titleAttr`. */
247
+ export function itemDescription(item: NavItem): string | undefined {
248
+ const d = item.titleAttr?.trim();
249
+ return d ? d : undefined;
250
+ }
251
+
252
+ /** A link opening in a new tab must say so to AT and not leak the opener. */
253
+ export function linkRel(item: NavItem): string | undefined {
254
+ return item.target === '_blank' ? 'noopener noreferrer' : undefined;
255
+ }
256
+
257
+ /** Unique, stable drawer id per tier — the toggle ↔ panel contract (see NavShell's `id`). */
258
+ export function drawerId(base: string, tier: SiteNavTier): string {
259
+ return `${base}-${tier.kind}`;
260
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The inline scripts `<Head />` emits, as plain strings.
3
+ *
4
+ * They live here rather than in `Head.astro`'s frontmatter for one reason: a
5
+ * Content-Security-Policy that doesn't allow `'unsafe-inline'` has to name each
6
+ * inline script by SHA-256, and the hash is of the exact emitted bytes. One
7
+ * constant imported by both the component (which emits it) and `csp.ts` (which
8
+ * hashes it) makes "the hash matches what the page serves" true by construction
9
+ * rather than by a test remembering to compare two copies. `csp.test.ts` and the
10
+ * container render test still assert it against real rendered HTML.
11
+ *
12
+ * No imports and no Node builtins: `Head.astro` runs in a Worker for any page
13
+ * that is rendered on demand.
14
+ */
15
+
16
+ /**
17
+ * Applies the remembered colour scheme before first paint.
18
+ *
19
+ * The key MUST match `WCColorSchemeToggle.STORAGE_KEY` in @getvitops/core;
20
+ * `head.test.ts` asserts they stay in step. try/catch because localStorage
21
+ * throws in private/partitioned contexts.
22
+ */
23
+ export const THEME_SCRIPT =
24
+ `try{var s=localStorage.getItem('vitops-color-scheme');` +
25
+ `if(s==='light'||s==='dark'){document.documentElement.dataset.theme=s;` +
26
+ `document.documentElement.style.colorScheme=s+' only'}}catch(e){}`;
27
+
28
+ /**
29
+ * The consent gate's build-time signal: "something on this page may need
30
+ * permission, and the real gate hasn't loaded yet."
31
+ *
32
+ * `consent.js` and `elements.js` are both deferred modules with no ordering
33
+ * guarantee between them, so `<wc-color-scheme-toggle>` can upgrade and be clicked
34
+ * before the gate exists. Without this, a caller probing for
35
+ * `window.vitopsConsent` in that window would see nothing, correctly conclude
36
+ * that sites without the gate should just store — and store. The stub closes the
37
+ * window by making the gate's *existence* known synchronously in `<head>`, which
38
+ * is the only place that beats every deferred bundle.
39
+ *
40
+ * It answers `false` to everything, because nothing is granted until the store
41
+ * has been read, and queues what it was asked so `consent/runtime.ts` can replay
42
+ * it on evaluation. `q` entries are `[category]` for `require()` and
43
+ * `[category, resolve]` for `request()`. Same shape as the `dataLayer`/`gtag`
44
+ * stub in `analytics.ts` and for the same reason.
45
+ *
46
+ * Emitted only when the gate is on: its absence is what tells a caller the site
47
+ * has no gate at all.
48
+ */
49
+ export const CONSENT_STUB =
50
+ `window.vitopsConsent=window.vitopsConsent||{q:[],` +
51
+ `require:function(c){this.q.push([c]);return false},` +
52
+ `request:function(c){var s=this;return new Promise(function(r){s.q.push([c,r])})},` +
53
+ `granted:function(){return false},needed:function(){return false}};`;
package/src/json-ld.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Serialise a JSON-LD object for inlining in a `<script type="application/ld+json">`.
3
+ *
4
+ * `JSON.stringify` alone is not safe there: the HTML parser ends a script element at the first
5
+ * `</script`, whatever JSON string it sits inside, so a review, FAQ answer or forum post
6
+ * containing `</script><script>…` would break out and run. The escapes below are still valid
7
+ * JSON (`<` parses back to `<`), so search engines read the same document.
8
+ *
9
+ * U+2028/U+2029 are escaped too: they are legal in JSON but were line terminators in JS source
10
+ * before ES2019, and this string is also embedded in HTML contexts that predate that.
11
+ */
12
+ export function jsonLdScript(value: unknown): string {
13
+ return JSON.stringify(value)
14
+ .replaceAll('<', '\\u003c')
15
+ .replaceAll('>', '\\u003e')
16
+ .replaceAll('&', '\\u0026')
17
+ .replaceAll('
', '\\u2028')
18
+ .replaceAll('
', '\\u2029');
19
+ }
package/virtual.d.ts CHANGED
@@ -63,11 +63,28 @@ declare module 'virtual:getvitops/head' {
63
63
  // site config is already parsed, and because a pixel is a `marketing` demand
64
64
  // the banner must have a row for.
65
65
  ads: { enabled: boolean; properties: Record<string, unknown> } | null;
66
+ turnstileSiteKey: string | null;
66
67
  }
67
68
  const data: HeadData;
68
69
  export default data;
69
70
  }
70
71
 
72
+ /**
73
+ * The committed `.vitops/reviews.json` cache as a build-time import — for components that
74
+ * render in a Worker and so cannot read the file (the EmDash `vitops.reviews` block). Always
75
+ * defined: with no cache file it is `{ version: 1, locations: {} }`.
76
+ */
77
+ declare module 'virtual:getvitops/reviews' {
78
+ // Structurally `ReviewCache` from `@getvitops/utils/reviews` (an ambient module in a
79
+ // shipped .d.ts cannot import from src). Kept loose on purpose: callers re-type it on the
80
+ // way into `selectReviews`, which is where the real contract is.
81
+ const cache: {
82
+ version: 1;
83
+ locations: Record<string, { reviews: Record<string, unknown>[] } & Record<string, unknown>>;
84
+ };
85
+ export default cache;
86
+ }
87
+
71
88
  /** Types for the virtual module the getvitops() integration provides to <Icon />. */
72
89
  declare module 'virtual:getvitops/icons' {
73
90
  // Keep these keys in step with `IconsData` in src/integration.ts, for the same