@getvitops/astro 2.1.0 → 4.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,45 @@
1
+ import { EmailBinding, NotifyContext, notify } from "@getvitops/utils/notify";
2
+
3
+ //#region src/routes/conversion.d.ts
4
+ /** The subset of Astro's `APIContext` this needs — typed structurally so the
5
+ * factory does not pin an Astro version. */
6
+ interface ConversionRequestContext {
7
+ request: Request;
8
+ locals?: unknown;
9
+ }
10
+ interface ConversionRouteOptions {
11
+ /** Where notifications go. Build it from your site config. */
12
+ context: NotifyContext;
13
+ /**
14
+ * The Workers `send_email` binding for this request, e.g.
15
+ * `(locals) => locals.runtime.env.EMAIL`. Omit it in dev and the notification
16
+ * is printed instead of sent.
17
+ */
18
+ binding?: (locals: unknown) => EmailBinding | undefined;
19
+ /**
20
+ * Reject a submission before anything is sent. Return a message to refuse.
21
+ * This is where a consumer puts required-field checks and spam rules.
22
+ */
23
+ validate?: (body: Record<string, unknown>) => string | null;
24
+ /** Called with every channel outcome, delivered or not. Defaults to console. */
25
+ onResult?: (result: Awaited<ReturnType<typeof notify>>) => void;
26
+ /** Injected in tests. */
27
+ now?: () => number;
28
+ }
29
+ /**
30
+ * Build a POST handler for conversions.
31
+ *
32
+ * Returns 204 for a beacon and 200 with `{ success }` for a form, matching what
33
+ * `navigator.sendBeacon` and a fetch-submitting form each expect.
34
+ *
35
+ * **A failed notification does not fail the request.** The visitor has already
36
+ * submitted; refusing them because our mail did not go out turns a lost
37
+ * notification into a lost conversion. Failures are reported through `onResult`.
38
+ */
39
+ declare function createConversionRoute(options: ConversionRouteOptions): ({
40
+ request,
41
+ locals
42
+ }: ConversionRequestContext) => Promise<Response>;
43
+ //#endregion
44
+ export { ConversionRequestContext, ConversionRouteOptions, createConversionRoute };
45
+ //# sourceMappingURL=conversion.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conversion.d.mts","names":[],"sources":["../../src/routes/conversion.ts"],"mappings":";;;;;UAgCiB,wBAAA;EACf,OAAA,EAAS,OAAO;EAChB,MAAA;AAAA;AAAA,UAGe,sBAAA;EAe+B;EAb9C,OAAA,EAAS,aAAA;EAeT;;AAAG;AA0BL;;EAnCE,OAAA,IAAW,MAAA,cAAoB,YAAA;EAmCc;;;;EA9B7C,QAAA,IAAY,IAAA,EAAM,MAAA;EAiC4C;EA/B9D,QAAA,IAAY,MAAA,EAAQ,OAAA,CAAQ,UAAA,QAAkB,MAAA;EA+BuB;EA7BrE,GAAA;AAAA;;;;;;;;;AA6B8E;;iBAHhE,qBAAA,CAAsB,OAAA,EAAS,sBAAA;EAG/B,OAAA;EAAA;AAAA,GAAqB,wBAAA,KAA2B,OAAA,CAAQ,QAAA"}
@@ -0,0 +1,95 @@
1
+ import { notify } from "@getvitops/utils/notify";
2
+ import { parseTrackingCookie } from "@getvitops/utils/tracking";
3
+
4
+ //#region src/routes/conversion.ts
5
+ /**
6
+ * The conversion endpoint, as a factory.
7
+ *
8
+ * A site's contact form and its `tel:` beacon both mean the same thing — someone
9
+ * got in touch — and both want the same treatment: read the attribution cookie
10
+ * off the request, build the event, notify whoever the config says. That is the
11
+ * same three lines in every project, so it ships here.
12
+ *
13
+ * What deliberately does **not** ship here is validation and business rules. A
14
+ * consumer's form has its own required fields, its own spam handling and its own
15
+ * response shape, and baking a guess at those in would make the factory something
16
+ * to fight rather than mount. The handler takes the parsed body and an optional
17
+ * `validate`; everything else is the caller's.
18
+ *
19
+ * ```ts
20
+ * // src/pages/api/track.ts
21
+ * export const prerender = false;
22
+ * import { createConversionRoute } from '@getvitops/astro/routes';
23
+ * import config from '../../site.config.json';
24
+ *
25
+ * export const POST = createConversionRoute({
26
+ * context: toNotifyContext(config),
27
+ * binding: (locals) => locals.runtime?.env?.EMAIL,
28
+ * });
29
+ * ```
30
+ */
31
+ /** Fields that are never part of a submission's meaning. */
32
+ const CONTROL_FIELDS = new Set(["event", "phone"]);
33
+ function toFormData(body) {
34
+ const out = {};
35
+ for (const [key, value] of Object.entries(body)) {
36
+ if (CONTROL_FIELDS.has(key)) continue;
37
+ if (value === void 0 || value === null) continue;
38
+ out[key] = String(value);
39
+ }
40
+ return out;
41
+ }
42
+ /**
43
+ * Build a POST handler for conversions.
44
+ *
45
+ * Returns 204 for a beacon and 200 with `{ success }` for a form, matching what
46
+ * `navigator.sendBeacon` and a fetch-submitting form each expect.
47
+ *
48
+ * **A failed notification does not fail the request.** The visitor has already
49
+ * submitted; refusing them because our mail did not go out turns a lost
50
+ * notification into a lost conversion. Failures are reported through `onResult`.
51
+ */
52
+ function createConversionRoute(options) {
53
+ const { context, binding, validate, onResult, now = Date.now } = options;
54
+ return async ({ request, locals }) => {
55
+ let body;
56
+ try {
57
+ const parsed = await request.json();
58
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return Response.json({
59
+ success: false,
60
+ error: "Malformed request."
61
+ }, { status: 400 });
62
+ body = parsed;
63
+ } catch {
64
+ return Response.json({
65
+ success: false,
66
+ error: "Malformed request."
67
+ }, { status: 400 });
68
+ }
69
+ const isCall = body["event"] === "call";
70
+ if (isCall && typeof body["phone"] !== "string") return Response.json({
71
+ success: false,
72
+ error: "Missing phone."
73
+ }, { status: 400 });
74
+ const refusal = validate?.(body);
75
+ if (refusal) return Response.json({
76
+ success: false,
77
+ error: refusal
78
+ }, { status: 400 });
79
+ const result = await notify({
80
+ type: isCall ? "call" : "form",
81
+ ...isCall ? { phone: String(body["phone"]).replace(/^tel:/, "") } : { formData: toFormData(body) },
82
+ tracking: parseTrackingCookie(request.headers.get("cookie")),
83
+ userAgent: request.headers.get("user-agent") ?? void 0,
84
+ ip: request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? void 0,
85
+ at: now()
86
+ }, context, { email: binding?.(locals) });
87
+ if (onResult) onResult(result);
88
+ else for (const r of result.results) if (!r.sent) console.warn(`[vitops] notification not sent (${r.channel}): ${r.reason}`);
89
+ return isCall ? new Response(null, { status: 204 }) : Response.json({ success: true });
90
+ };
91
+ }
92
+
93
+ //#endregion
94
+ export { createConversionRoute };
95
+ //# sourceMappingURL=conversion.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conversion.mjs","names":[],"sources":["../../src/routes/conversion.ts"],"sourcesContent":["/**\n * The conversion endpoint, as a factory.\n *\n * A site's contact form and its `tel:` beacon both mean the same thing — someone\n * got in touch — and both want the same treatment: read the attribution cookie\n * off the request, build the event, notify whoever the config says. That is the\n * same three lines in every project, so it ships here.\n *\n * What deliberately does **not** ship here is validation and business rules. A\n * consumer's form has its own required fields, its own spam handling and its own\n * response shape, and baking a guess at those in would make the factory something\n * to fight rather than mount. The handler takes the parsed body and an optional\n * `validate`; everything else is the caller's.\n *\n * ```ts\n * // src/pages/api/track.ts\n * export const prerender = false;\n * import { createConversionRoute } from '@getvitops/astro/routes';\n * import config from '../../site.config.json';\n *\n * export const POST = createConversionRoute({\n * context: toNotifyContext(config),\n * binding: (locals) => locals.runtime?.env?.EMAIL,\n * });\n * ```\n */\nimport { type EmailBinding, notify, type NotifyContext } from '@getvitops/utils/notify';\nimport { parseTrackingCookie } from '@getvitops/utils/tracking';\nimport type { ConversionEvent } from '@getvitops/utils/tracking';\n\n/** The subset of Astro's `APIContext` this needs — typed structurally so the\n * factory does not pin an Astro version. */\nexport interface ConversionRequestContext {\n request: Request;\n locals?: unknown;\n}\n\nexport interface ConversionRouteOptions {\n /** Where notifications go. Build it from your site config. */\n context: NotifyContext;\n /**\n * The Workers `send_email` binding for this request, e.g.\n * `(locals) => locals.runtime.env.EMAIL`. Omit it in dev and the notification\n * is printed instead of sent.\n */\n binding?: (locals: unknown) => EmailBinding | undefined;\n /**\n * Reject a submission before anything is sent. Return a message to refuse.\n * This is where a consumer puts required-field checks and spam rules.\n */\n validate?: (body: Record<string, unknown>) => string | null;\n /** Called with every channel outcome, delivered or not. Defaults to console. */\n onResult?: (result: Awaited<ReturnType<typeof notify>>) => void;\n /** Injected in tests. */\n now?: () => number;\n}\n\n/** Fields that are never part of a submission's meaning. */\nconst CONTROL_FIELDS = new Set(['event', 'phone']);\n\nfunction toFormData(body: Record<string, unknown>): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [key, value] of Object.entries(body)) {\n if (CONTROL_FIELDS.has(key)) continue;\n if (value === undefined || value === null) continue;\n out[key] = String(value);\n }\n return out;\n}\n\n/**\n * Build a POST handler for conversions.\n *\n * Returns 204 for a beacon and 200 with `{ success }` for a form, matching what\n * `navigator.sendBeacon` and a fetch-submitting form each expect.\n *\n * **A failed notification does not fail the request.** The visitor has already\n * submitted; refusing them because our mail did not go out turns a lost\n * notification into a lost conversion. Failures are reported through `onResult`.\n */\nexport function createConversionRoute(options: ConversionRouteOptions) {\n const { context, binding, validate, onResult, now = Date.now } = options;\n\n return async ({ request, locals }: ConversionRequestContext): Promise<Response> => {\n let body: Record<string, unknown>;\n try {\n const parsed: unknown = await request.json();\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return Response.json({ success: false, error: 'Malformed request.' }, { status: 400 });\n }\n body = parsed as Record<string, unknown>;\n } catch {\n return Response.json({ success: false, error: 'Malformed request.' }, { status: 400 });\n }\n\n const isCall = body['event'] === 'call';\n if (isCall && typeof body['phone'] !== 'string') {\n return Response.json({ success: false, error: 'Missing phone.' }, { status: 400 });\n }\n\n const refusal = validate?.(body);\n if (refusal) return Response.json({ success: false, error: refusal }, { status: 400 });\n\n const event: ConversionEvent = {\n type: isCall ? 'call' : 'form',\n ...(isCall\n ? { phone: String(body['phone']).replace(/^tel:/, '') }\n : { formData: toFormData(body) }),\n tracking: parseTrackingCookie(request.headers.get('cookie')),\n userAgent: request.headers.get('user-agent') ?? undefined,\n // Cloudflare's own header first; `x-forwarded-for` is the portable fallback\n // and may carry a proxy chain, whose first entry is the client.\n ip:\n request.headers.get('cf-connecting-ip') ??\n request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??\n undefined,\n at: now(),\n };\n\n const result = await notify(event, context, { email: binding?.(locals) });\n\n if (onResult) onResult(result);\n else\n for (const r of result.results) {\n if (!r.sent) console.warn(`[vitops] notification not sent (${r.channel}): ${r.reason}`);\n }\n\n return isCall ? new Response(null, { status: 204 }) : Response.json({ success: true });\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0DA,MAAM,iBAAiB,IAAI,IAAI,CAAC,SAAS,OAAO,CAAC;AAEjD,SAAS,WAAW,MAAuD;CACzE,MAAM,MAA8B,CAAC;CACrC,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,IAAI,GAAG;EAC/C,IAAI,eAAe,IAAI,GAAG,GAAG;EAC7B,IAAI,UAAU,UAAa,UAAU,MAAM;EAC3C,IAAI,OAAO,OAAO,KAAK;CACzB;CACA,OAAO;AACT;;;;;;;;;;;AAYA,SAAgB,sBAAsB,SAAiC;CACrE,MAAM,EAAE,SAAS,SAAS,UAAU,UAAU,MAAM,KAAK,QAAQ;CAEjE,OAAO,OAAO,EAAE,SAAS,aAA0D;EACjF,IAAI;EACJ,IAAI;GACF,MAAM,SAAkB,MAAM,QAAQ,KAAK;GAC3C,IAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,MAAM,QAAQ,MAAM,GACvE,OAAO,SAAS,KAAK;IAAE,SAAS;IAAO,OAAO;GAAqB,GAAG,EAAE,QAAQ,IAAI,CAAC;GAEvF,OAAO;EACT,QAAQ;GACN,OAAO,SAAS,KAAK;IAAE,SAAS;IAAO,OAAO;GAAqB,GAAG,EAAE,QAAQ,IAAI,CAAC;EACvF;EAEA,MAAM,SAAS,KAAK,aAAa;EACjC,IAAI,UAAU,OAAO,KAAK,aAAa,UACrC,OAAO,SAAS,KAAK;GAAE,SAAS;GAAO,OAAO;EAAiB,GAAG,EAAE,QAAQ,IAAI,CAAC;EAGnF,MAAM,UAAU,WAAW,IAAI;EAC/B,IAAI,SAAS,OAAO,SAAS,KAAK;GAAE,SAAS;GAAO,OAAO;EAAQ,GAAG,EAAE,QAAQ,IAAI,CAAC;EAkBrF,MAAM,SAAS,MAAM,OAAO;GAf1B,MAAM,SAAS,SAAS;GACxB,GAAI,SACA,EAAE,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,QAAQ,SAAS,EAAE,EAAE,IACpD,EAAE,UAAU,WAAW,IAAI,EAAE;GACjC,UAAU,oBAAoB,QAAQ,QAAQ,IAAI,QAAQ,CAAC;GAC3D,WAAW,QAAQ,QAAQ,IAAI,YAAY,KAAK;GAGhD,IACE,QAAQ,QAAQ,IAAI,kBAAkB,KACtC,QAAQ,QAAQ,IAAI,iBAAiB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,EAAE,EAAE,KAAK,KAC5D;GACF,IAAI,IAAI;EAGsB,GAAG,SAAS,EAAE,OAAO,UAAU,MAAM,EAAE,CAAC;EAExE,IAAI,UAAU,SAAS,MAAM;OAE3B,KAAK,MAAM,KAAK,OAAO,SACrB,IAAI,CAAC,EAAE,MAAM,QAAQ,KAAK,mCAAmC,EAAE,QAAQ,KAAK,EAAE,QAAQ;EAG1F,OAAO,SAAS,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC,IAAI,SAAS,KAAK,EAAE,SAAS,KAAK,CAAC;CACvF;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getvitops/astro",
3
- "version": "2.1.0",
3
+ "version": "4.0.0",
4
4
  "description": "Astro integration for the Vitops design system — favicons/PWA, web-component scripts, and CSS, via a getvitops() integration + <Head /> component.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -19,9 +19,17 @@
19
19
  "src/components/Popover.astro",
20
20
  "src/components/Details.astro",
21
21
  "src/components/Drawer.astro",
22
+ "src/components/NavShell.astro",
23
+ "src/components/NavShellToggle.astro",
22
24
  "src/schemas",
23
25
  "virtual.d.ts",
24
- "CHANGELOG.md"
26
+ "CHANGELOG.md",
27
+ "src/components/Tracking.astro",
28
+ "src/scripts/tracking.ts",
29
+ "src/tracking.ts",
30
+ "src/components/Tree.astro",
31
+ "src/components/TreeNode.astro",
32
+ "src/components/tree.ts"
25
33
  ],
26
34
  "type": "module",
27
35
  "types": "./dist/index.d.mts",
@@ -30,10 +38,15 @@
30
38
  "types": "./dist/index.d.mts",
31
39
  "import": "./dist/index.mjs"
32
40
  },
41
+ "./routes": {
42
+ "types": "./dist/routes/conversion.d.mts",
43
+ "import": "./dist/routes/conversion.mjs"
44
+ },
33
45
  "./Head.astro": "./src/components/Head.astro",
34
46
  "./Seo.astro": "./src/components/Seo.astro",
35
47
  "./Analytics.astro": "./src/components/Analytics.astro",
36
48
  "./CookieConsent.astro": "./src/components/CookieConsent.astro",
49
+ "./Tracking.astro": "./src/components/Tracking.astro",
37
50
  "./components/Subgrid.astro": "./src/components/Subgrid.astro",
38
51
  "./components/Cards.astro": "./src/components/Cards.astro",
39
52
  "./components/NodeRenderer.astro": "./src/components/NodeRenderer.astro",
@@ -42,6 +55,10 @@
42
55
  "./components/Popover.astro": "./src/components/Popover.astro",
43
56
  "./components/Details.astro": "./src/components/Details.astro",
44
57
  "./components/Drawer.astro": "./src/components/Drawer.astro",
58
+ "./components/NavShell.astro": "./src/components/NavShell.astro",
59
+ "./components/NavShellToggle.astro": "./src/components/NavShellToggle.astro",
60
+ "./components/Tree.astro": "./src/components/Tree.astro",
61
+ "./components/tree.ts": "./src/components/tree.ts",
45
62
  "./schemas/*": "./src/schemas/*",
46
63
  "./package.json": "./package.json"
47
64
  },
@@ -49,10 +66,10 @@
49
66
  "access": "public"
50
67
  },
51
68
  "dependencies": {
52
- "@getvitops/core": "2.1.0",
53
- "@getvitops/generator": "2.1.0",
54
- "@getvitops/utils": "2.1.0",
55
- "@getvitops/vite": "2.1.0"
69
+ "@getvitops/generator": "4.0.0",
70
+ "@getvitops/vite": "4.0.0",
71
+ "@getvitops/core": "4.0.0",
72
+ "@getvitops/utils": "4.0.0"
56
73
  },
57
74
  "devDependencies": {
58
75
  "@astrojs/sitemap": "^3.7.3",
package/src/analytics.ts CHANGED
@@ -136,7 +136,7 @@ export interface ResolvedAnalytics {
136
136
  }
137
137
 
138
138
  export interface ResolveAnalyticsContext {
139
- /** Is the consent gate enabled on this site (`getvitops({ consent })`)? */
139
+ /** Is the consent gate enabled on this site (`vitops({ consent })`)? */
140
140
  consent?: boolean;
141
141
  }
142
142
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  /**
3
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()
4
+ * consent gate. Place in your layout's <head>; requires the vitops()
5
5
  * integration, which supplies `virtual:getvitops/head`.
6
6
  *
7
7
  * A dumb renderer over `resolveAnalytics()` (src/analytics.ts) — every decision
@@ -19,7 +19,7 @@
19
19
  * the critical-path cost the `idle` strategy exists to avoid.
20
20
  */
21
21
  import { type AnalyticsStrategy, resolveAnalytics } from '../analytics.ts';
22
- // @ts-ignore — virtual module provided by the getvitops() integration at build time
22
+ // @ts-ignore — virtual module provided by the vitops() integration at build time
23
23
  import head from 'virtual:getvitops/head';
24
24
 
25
25
  interface Props {
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  /**
3
3
  * <CookieConsent /> — the consent banner. Place near the end of <body>; requires
4
- * the getvitops() integration with `consent` enabled, which is what loads
4
+ * the vitops() integration with `consent` enabled, which is what loads
5
5
  * `@getvitops/core/consent` (the gate + `<wc-consent>`).
6
6
  *
7
7
  * This renders the *fallback*: a real, semantic form with real buttons. The
@@ -14,13 +14,20 @@
14
14
  * writes one — otherwise the thing asking permission would be the thing needing
15
15
  * it.
16
16
  *
17
+ * **`categories` is what the banner *can* ask, not what it will.** A row here is
18
+ * markup, and a hidden row costs nothing, so the default is generous — every
19
+ * category the site could plausibly gate. `<wc-consent>` reveals only the rows
20
+ * something has actually demanded and the visitor hasn't answered, so being
21
+ * generous here buys coverage without buying interruptions. Narrow it only to
22
+ * refuse a category outright.
23
+ *
17
24
  * **Consent is not an analytics feature.** The same gate covers A/B assignment,
18
25
  * personalisation and embedded third-party content: mark any of them
19
26
  * `data-consent="<category>"` and they wait on the same choice. Which categories
20
27
  * this banner offers therefore comes from the integration, not from
21
28
  * `<Analytics />`.
22
29
  */
23
- // @ts-ignore — virtual module provided by the getvitops() integration at build time
30
+ // @ts-ignore — virtual module provided by the vitops() integration at build time
24
31
  import head from 'virtual:getvitops/head';
25
32
  import type { OptionalConsentCategory } from '../analytics.ts';
26
33
 
@@ -50,7 +57,7 @@ const DEFAULT_LABELS: Record<OptionalConsentCategory, string> = {
50
57
  };
51
58
 
52
59
  const {
53
- categories = head.consentCategories ?? ['analytics'],
60
+ categories = head.consentCategories ?? ['analytics', 'preferences'],
54
61
  policyUrl = head.consentPolicyUrl ?? undefined,
55
62
  policyLabel = 'Cookie notice',
56
63
  heading = 'Cookies on this site',
@@ -62,9 +69,6 @@ const {
62
69
  } = Astro.props;
63
70
 
64
71
  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
72
  ---
69
73
 
70
74
  <wc-consent hidden {...noReloadOnRevoke ? { 'reload-on-revoke': 'false' } : {}}>
@@ -86,20 +90,24 @@ const choose = categories.length > 1;
86
90
  {policyUrl && <a class="link" href={policyUrl}>{policyLabel}</a>}
87
91
  </div>
88
92
 
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
- )}
93
+ {/*
94
+ A row per offered category, always rendered. Which of them the visitor
95
+ actually sees is a runtime decision — `data-consent-row` is how
96
+ `<wc-consent>` addresses one to reveal or hide it, and it hides the whole
97
+ group when a showing is about a single category.
98
+ */}
99
+ <div class="consent__options">
100
+ {categories.map((category) => (
101
+ <label data-consent-row={category}>
102
+ <input type="checkbox" name={category} />
103
+ {wording[category]}
104
+ </label>
105
+ ))}
106
+ </div>
99
107
 
100
108
  <div class="consent__actions">
101
109
  <button type="button" class="btn" data-consent-reject>{rejectLabel}</button>
102
- {choose && <button type="button" class="btn" data-consent-save>{saveLabel}</button>}
110
+ <button type="button" class="btn" data-consent-save>{saveLabel}</button>
103
111
  <button type="button" class="cta" data-consent-accept>{acceptLabel}</button>
104
112
  </div>
105
113
  </form>
@@ -15,7 +15,7 @@ interface Props {
15
15
  /**
16
16
  * Map a semantic name onto a fully-qualified one before rendering.
17
17
  *
18
- * @deprecated The `icons` option on `getvitops()` now does this centrally, so
18
+ * @deprecated The `icons` option on `vitops()` now does this centrally, so
19
19
  * the site's set is configured once instead of threaded through every call
20
20
  * site. Left in place because removing it would break consumers passing one;
21
21
  * when omitted, `<Icon />` resolves through the site config.
@@ -19,7 +19,7 @@ interface Props {
19
19
  /**
20
20
  * Map a semantic name onto a fully-qualified one before rendering.
21
21
  *
22
- * @deprecated The `icons` option on `getvitops()` now does this centrally, so
22
+ * @deprecated The `icons` option on `vitops()` now does this centrally, so
23
23
  * the site's set is configured once instead of threaded through every call
24
24
  * site. Left in place because removing it would break consumers passing one;
25
25
  * when omitted, `<Icon />` resolves through the site config.
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  /**
3
3
  * <Head /> — the Vitops design system's <head> contributions. Place inside your
4
- * layout's <head>. Requires the getvitops() integration (it provides the
4
+ * layout's <head>. Requires the vitops() integration (it provides the
5
5
  * `virtual:getvitops/head` config + copies the assets into public/).
6
6
  *
7
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
+ * declared via vitops({ fonts }), and the web-component runtime scripts in
9
9
  * canonical order/priority. It does NOT emit a stylesheet <link> — the CSS is
10
10
  * imported into Astro's graph by the integration and Astro emits that link.
11
11
  */
12
- // @ts-ignore — virtual module provided by the getvitops() integration at build time
12
+ // @ts-ignore — virtual module provided by the vitops() integration at build time
13
13
  import head from 'virtual:getvitops/head';
14
14
  // @ts-ignore — `Font` is provided by Astro's Fonts API (astro >= 7)
15
15
  import { Font } from 'astro:assets';
@@ -41,6 +41,33 @@ const THEME_SCRIPT =
41
41
  `if(s==='light'||s==='dark'){document.documentElement.dataset.theme=s;` +
42
42
  `document.documentElement.style.colorScheme=s+' only'}}catch(e){}`;
43
43
 
44
+ /**
45
+ * The consent gate's build-time signal: "something on this page may need
46
+ * permission, and the real gate hasn't loaded yet."
47
+ *
48
+ * `consent.js` and `elements.js` are both deferred modules with no ordering
49
+ * guarantee between them, so `<wc-color-scheme-toggle>` can upgrade and be clicked
50
+ * before the gate exists. Without this, a caller probing for
51
+ * `window.vitopsConsent` in that window would see nothing, correctly conclude
52
+ * that sites without the gate should just store — and store. The stub closes the
53
+ * window by making the gate's *existence* known synchronously in `<head>`, which
54
+ * is the only place that beats every deferred bundle.
55
+ *
56
+ * It answers `false` to everything, because nothing is granted until the store
57
+ * has been read, and queues what it was asked so `consent/runtime.ts` can replay
58
+ * it on evaluation. `q` entries are `[category]` for `require()` and
59
+ * `[category, resolve]` for `request()`. Same shape as the `dataLayer`/`gtag`
60
+ * stub in `analytics.ts` and for the same reason.
61
+ *
62
+ * Emitted only when the gate is on: its absence is what tells a caller the site
63
+ * has no gate at all.
64
+ */
65
+ const CONSENT_STUB =
66
+ `window.vitopsConsent=window.vitopsConsent||{q:[],` +
67
+ `require:function(c){this.q.push([c]);return false},` +
68
+ `request:function(c){var s=this;return new Promise(function(r){s.q.push([c,r])})},` +
69
+ `granted:function(){return false},needed:function(){return false}};`;
70
+
44
71
  /*
45
72
  * No `<html>` class flips here on purpose. `animation.css` used to need `no-js`
46
73
  * and `.no-scroll-timeline` set before first paint; it now asks the platform
@@ -61,7 +88,7 @@ const THEME_SCRIPT =
61
88
  {favicons && head.themeColor && <meta name="theme-color" content={head.themeColor} />}
62
89
 
63
90
  {/*
64
- Webfonts declared via getvitops({ fonts }). Astro's `fonts:` config resolves and
91
+ Webfonts declared via vitops({ fonts }). Astro's `fonts:` config resolves and
65
92
  optimises the files; this component is what actually puts the @font-face on the page,
66
93
  so without it the config is inert and every --font-* token falls through to its
67
94
  fallback stack.
@@ -77,15 +104,26 @@ const THEME_SCRIPT =
77
104
  <Font cssVariable={f.cssVariable} preload={f.preload} />
78
105
  ))}
79
106
 
80
- {/* Set by getvitops({ sitemap }); null when no sitemap was registered. */}
107
+ {/* Set by vitops({ sitemap }); null when no sitemap was registered. */}
81
108
  {head.sitemap && <link rel="sitemap" type="application/xml" href={head.sitemap} />}
82
109
 
110
+ {/*
111
+ The consent stub — synchronous, and ahead of every module script on purpose.
112
+ See CONSENT_STUB above: it exists so that code which loads before the gate can
113
+ tell "the gate isn't here yet" from "this site has no gate", and queue rather
114
+ than assume it may store. Keyed on `head.consent` (is the gate on?) rather than
115
+ `head.consentRuntime` (does anything need scheduling?) — a site whose only tag
116
+ is cookieless loads the runtime to schedule it but gates nothing, so there is
117
+ nothing for a caller to queue behind.
118
+ */}
119
+ {head.consent && <script is:inline set:html={CONSENT_STUB} />}
120
+
83
121
  {webComponents && (
84
122
  <>
85
123
  {/*
86
124
  Apply the remembered colour scheme before first paint.
87
125
 
88
- `<color-scheme-toggle>` persists the choice to localStorage, but it loads
126
+ `<wc-color-scheme-toggle>` persists the choice to localStorage, but it loads
89
127
  as a deferred module — so without this every navigation would paint the
90
128
  light token set and flip to dark once the element upgraded. Synchronous and
91
129
  inline on purpose: it has to run before the body renders.
@@ -112,7 +150,7 @@ const THEME_SCRIPT =
112
150
  {head.consentRuntime && <script is:inline type="module" src={`${wc}/consent.js`}></script>}
113
151
 
114
152
  {/*
115
- The live theme editor — a separate, opt-in bundle (getvitops({ editor: true })).
153
+ The live theme editor — a separate, opt-in bundle (vitops({ editor: true })).
116
154
  It is tooling, not part of the runtime, so it stays out of elements.js and off
117
155
  every page that doesn't ask for it. Loads independently of `webComponents`:
118
156
  <wc-theme-editor> registers itself and pulls in no framework element.
@@ -67,13 +67,13 @@ if (icons.engine === 'inline' && qualified) {
67
67
  svg = await loadIconSvg(qualified, { resolveFrom: icons.root });
68
68
  if (!svg)
69
69
  console.warn(
70
- `[getvitops] <Icon name="${name}"> — "${qualified}" not found. Is ` +
70
+ `[vitops] <Icon name="${name}"> — "${qualified}" not found. Is ` +
71
71
  `@iconify-json/${qualified.slice(0, qualified.indexOf(':'))} installed?`,
72
72
  );
73
73
  } else if (icons.engine === 'none') {
74
74
  console.warn(
75
- `[getvitops] <Icon name="${name}"> rendered but icons are not configured. ` +
76
- 'Add `icons: true` to getvitops() in astro.config.',
75
+ `[vitops] <Icon name="${name}"> rendered but icons are not configured. ` +
76
+ 'Add `icons: true` to vitops() in astro.config.',
77
77
  );
78
78
  }
79
79
 
@@ -0,0 +1,123 @@
1
+ ---
2
+ /**
3
+ * NavShell — a navigation aside beside main content at width, collapsing to a
4
+ * toggle + drawer below it. Markup only: every declaration lives in the
5
+ * framework's `navshell` pattern (patterns/navshell.css), which is emitted in
6
+ * all three output formats.
7
+ *
8
+ * It nests. A docs page is typically two of these — a site nav wrapping an
9
+ * on-this-page nav — promoting at different breakpoints:
10
+ *
11
+ * <NavShell id="site-nav" label="Documentation" breakpoint="md">
12
+ * <Fragment slot="nav">…</Fragment>
13
+ * <main>
14
+ * <NavShell id="page-toc" label="On this page" breakpoint="lg" side="end">
15
+ * <Fragment slot="nav">…</Fragment>
16
+ * …prose…
17
+ * </NavShell>
18
+ * </main>
19
+ * </NavShell>
20
+ *
21
+ * Nothing here may reach for a Tailwind utility: Tailwind doesn't scan
22
+ * node_modules, so a class only it can emit is silently never generated — and
23
+ * classes in the generator's TW_CLASH list are stripped from the framework's own
24
+ * tailwind bundle by design. `navshell*` and `drawer*` are in neither list.
25
+ *
26
+ * No runtime JS: the toggle is `<button popovertarget>` against a `[popover]`
27
+ * panel, so it works from the first paint and degrades to a visible panel if the
28
+ * Popover API is missing.
29
+ */
30
+ interface Props {
31
+ /**
32
+ * The panel's id, and the `popovertarget` the toggle points at.
33
+ *
34
+ * Required, never generated. It IS the toggle↔panel contract, and a generated
35
+ * id would make the one real hazard of nesting — two instances colliding on
36
+ * the same id, so the outer toggle opens the inner panel — invisible rather
37
+ * than impossible. Making the author name each instance makes a collision a
38
+ * thing you can see in the markup.
39
+ */
40
+ id: string;
41
+ /** Accessible name for the `<nav>`, e.g. "Documentation" / "On this page". */
42
+ label: string;
43
+ /** Container width at which the aside appears. Default `md` (48rem). */
44
+ breakpoint?: 'sm' | 'md' | 'lg' | 'xl';
45
+ /** Which side the aside sits on at width. Default `start`. */
46
+ side?: 'start' | 'end';
47
+ /**
48
+ * Aside column width once promoted, e.g. `'14rem'`. Defaults to the design
49
+ * system's `--width-nav` token rather than a literal, so a site retunes every
50
+ * nav column in one place.
51
+ */
52
+ size?: string;
53
+ /** Accessible name for the toggle. Defaults to `Open ${label}`. */
54
+ toggleLabel?: string;
55
+ /**
56
+ * Where the toggle lives. `'inline'` renders a `.navshell__bar` row above the
57
+ * content; `'external'` renders none, for when a `<NavShellToggle>` elsewhere
58
+ * (typically the site header) opens this shell instead.
59
+ *
60
+ * Two NESTED shells should not both be `'inline'`: their bars are sticky at
61
+ * the same offset and land on top of each other. Hoist the outer one.
62
+ */
63
+ toggle?: 'inline' | 'external';
64
+ class?: string;
65
+ }
66
+
67
+ const {
68
+ id,
69
+ label,
70
+ breakpoint = 'md',
71
+ side = 'start',
72
+ // Not a literal: the default REFERENCES the design system's token, so an
73
+ // unsized instance still tracks `--width-nav`. A literal here would shadow it
74
+ // on every instance and quietly make the token unreachable through this
75
+ // component, which is the only way most consumers will use the pattern.
76
+ size = 'var(--width-nav, 16rem)',
77
+ toggleLabel,
78
+ toggle = 'inline',
79
+ class: className,
80
+ } = Astro.props;
81
+
82
+ // Always emitted, even when the author passes no `size`. `--navshell-size`
83
+ // inherits (it can't be registered as non-inheriting — a registered property's
84
+ // initial-value must be computationally independent, and this one is in rem), so
85
+ // without this an inner instance would silently adopt an outer instance's column
86
+ // width. Writing it on every root severs the inheritance at each boundary.
87
+ const style = `--navshell-size: ${size}`;
88
+
89
+ // The drawer slides in from the side its aside will occupy, so the motion points
90
+ // at where the panel is going to live.
91
+ const edge = side === 'end' ? 'drawer--right' : 'drawer--left';
92
+ ---
93
+
94
+ <div
95
+ class:list={['navshell', `navshell--bp-${breakpoint}`, side === 'end' && 'navshell--end', className]}
96
+ style={style}
97
+ >
98
+ {toggle === 'inline' && (
99
+ <div class="navshell__bar">
100
+ <slot name="toggle">
101
+ <button class="navshell__toggle" popovertarget={id} aria-label={toggleLabel ?? `Open ${label}`}>
102
+ <slot name="toggle-label">{label}</slot>
103
+ </button>
104
+ </slot>
105
+ </div>
106
+ )}
107
+
108
+ <div id={id} popover class:list={['navshell__panel', 'drawer', edge]}>
109
+ <button
110
+ class="navshell__close"
111
+ popovertarget={id}
112
+ popovertargetaction="hide"
113
+ aria-label={`Close ${label}`}>&times;</button
114
+ >
115
+ <nav class="navshell__nav" aria-label={label}>
116
+ <slot name="nav" />
117
+ </nav>
118
+ </div>
119
+
120
+ <div class="navshell__main">
121
+ <slot />
122
+ </div>
123
+ </div>
@@ -0,0 +1,45 @@
1
+ ---
2
+ /**
3
+ * NavShellToggle — a `<NavShell>` toggle rendered outside its shell, so it can
4
+ * sit in a site header or navbar rather than in a row of its own.
5
+ *
6
+ * Two nested shells both rendering their own sticky toggle row put those rows at
7
+ * the same offset, on top of each other. Hoisting the outer one into the header
8
+ * — which is already sticky — leaves a single in-flow bar and no conflict. It is
9
+ * also simply where a site menu button belongs.
10
+ *
11
+ * <header class="navbar">
12
+ * <a href="/">Brand</a>
13
+ * <NavShellToggle for="site-nav" label="Menu" breakpoint="md" />
14
+ * </header>
15
+ * …
16
+ * <NavShell id="site-nav" label="Documentation" breakpoint="md" toggle="external">
17
+ *
18
+ * `breakpoint` MUST match the shell's: outside `.navshell` the toggle cannot read
19
+ * the shell's internal "am I wide?" flag (a style query resolves against an
20
+ * ancestor container, and the header is not one), so it carries its own copy.
21
+ * Mismatch them and you get a toggle visible next to an expanded sidebar, or no
22
+ * way to open the nav at all.
23
+ *
24
+ * No runtime JS: `popovertarget` is an id reference, which works from anywhere in
25
+ * the document.
26
+ */
27
+ interface Props {
28
+ /** The `id` of the `<NavShell>` this opens. */
29
+ for: string;
30
+ /** Visible text (and accessible name, unless `aria-label` is given). */
31
+ label: string;
32
+ /** Must match the shell's `breakpoint`. Default `md`. */
33
+ breakpoint?: 'sm' | 'md' | 'lg' | 'xl';
34
+ class?: string;
35
+ }
36
+
37
+ const { for: target, label, breakpoint = 'md', class: className } = Astro.props;
38
+ ---
39
+
40
+ <button
41
+ class:list={['navshell-toggle', `navshell-toggle--bp-${breakpoint}`, className]}
42
+ popovertarget={target}
43
+ >
44
+ <slot>{label}</slot>
45
+ </button>