@getvitops/astro 3.0.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.
- package/CHANGELOG.md +159 -0
- package/README.md +1 -1
- package/dist/index.d.mts +68 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +51 -4
- package/dist/index.mjs.map +1 -1
- package/dist/routes/conversion.d.mts +45 -0
- package/dist/routes/conversion.d.mts.map +1 -0
- package/dist/routes/conversion.mjs +95 -0
- package/dist/routes/conversion.mjs.map +1 -0
- package/package.json +19 -6
- package/src/components/CookieConsent.astro +23 -15
- package/src/components/Head.astro +39 -1
- package/src/components/Tracking.astro +43 -0
- package/src/components/Tree.astro +82 -0
- package/src/components/TreeNode.astro +63 -0
- package/src/components/WebComponentLoader.astro +1 -1
- package/src/components/tree.ts +19 -0
- package/src/scripts/tracking.ts +147 -0
- package/src/tracking.ts +82 -0
- package/virtual.d.ts +5 -0
|
@@ -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": "
|
|
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": [
|
|
@@ -23,7 +23,13 @@
|
|
|
23
23
|
"src/components/NavShellToggle.astro",
|
|
24
24
|
"src/schemas",
|
|
25
25
|
"virtual.d.ts",
|
|
26
|
-
"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"
|
|
27
33
|
],
|
|
28
34
|
"type": "module",
|
|
29
35
|
"types": "./dist/index.d.mts",
|
|
@@ -32,10 +38,15 @@
|
|
|
32
38
|
"types": "./dist/index.d.mts",
|
|
33
39
|
"import": "./dist/index.mjs"
|
|
34
40
|
},
|
|
41
|
+
"./routes": {
|
|
42
|
+
"types": "./dist/routes/conversion.d.mts",
|
|
43
|
+
"import": "./dist/routes/conversion.mjs"
|
|
44
|
+
},
|
|
35
45
|
"./Head.astro": "./src/components/Head.astro",
|
|
36
46
|
"./Seo.astro": "./src/components/Seo.astro",
|
|
37
47
|
"./Analytics.astro": "./src/components/Analytics.astro",
|
|
38
48
|
"./CookieConsent.astro": "./src/components/CookieConsent.astro",
|
|
49
|
+
"./Tracking.astro": "./src/components/Tracking.astro",
|
|
39
50
|
"./components/Subgrid.astro": "./src/components/Subgrid.astro",
|
|
40
51
|
"./components/Cards.astro": "./src/components/Cards.astro",
|
|
41
52
|
"./components/NodeRenderer.astro": "./src/components/NodeRenderer.astro",
|
|
@@ -46,6 +57,8 @@
|
|
|
46
57
|
"./components/Drawer.astro": "./src/components/Drawer.astro",
|
|
47
58
|
"./components/NavShell.astro": "./src/components/NavShell.astro",
|
|
48
59
|
"./components/NavShellToggle.astro": "./src/components/NavShellToggle.astro",
|
|
60
|
+
"./components/Tree.astro": "./src/components/Tree.astro",
|
|
61
|
+
"./components/tree.ts": "./src/components/tree.ts",
|
|
49
62
|
"./schemas/*": "./src/schemas/*",
|
|
50
63
|
"./package.json": "./package.json"
|
|
51
64
|
},
|
|
@@ -53,10 +66,10 @@
|
|
|
53
66
|
"access": "public"
|
|
54
67
|
},
|
|
55
68
|
"dependencies": {
|
|
56
|
-
"@getvitops/
|
|
57
|
-
"@getvitops/
|
|
58
|
-
"@getvitops/
|
|
59
|
-
"@getvitops/
|
|
69
|
+
"@getvitops/generator": "4.0.0",
|
|
70
|
+
"@getvitops/vite": "4.0.0",
|
|
71
|
+
"@getvitops/core": "4.0.0",
|
|
72
|
+
"@getvitops/utils": "4.0.0"
|
|
60
73
|
},
|
|
61
74
|
"devDependencies": {
|
|
62
75
|
"@astrojs/sitemap": "^3.7.3",
|
|
@@ -14,6 +14,13 @@
|
|
|
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
|
|
@@ -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
|
-
{
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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>
|
|
@@ -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
|
|
@@ -80,12 +107,23 @@ const THEME_SCRIPT =
|
|
|
80
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.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <Tracking /> — ad-click attribution capture. Place in your layout's <head>;
|
|
4
|
+
* requires the vitops() integration, which supplies `virtual:getvitops/head`.
|
|
5
|
+
*
|
|
6
|
+
* A dumb renderer over `resolveTracking()` (src/tracking.ts) — the one decision,
|
|
7
|
+
* which consent category `_ac` waits on, lives there and is unit-tested.
|
|
8
|
+
*
|
|
9
|
+
* Two things it emits, and the marker is the interesting one:
|
|
10
|
+
*
|
|
11
|
+
* - **The marker** carries `data-consent` (which category) and
|
|
12
|
+
* `data-consent-cookies` (what to clear). It deliberately does *not* carry
|
|
13
|
+
* `data-vitops-tag`, so the consent runtime's `scan()` never tries to activate
|
|
14
|
+
* it — this script is ungated by design, because reading the query string is
|
|
15
|
+
* not storage and the click ID is only in the URL on the landing page. But
|
|
16
|
+
* `clearCookies()` queries `[data-consent="<category>"]` and reads
|
|
17
|
+
* `data-consent-cookies` off whatever it finds, so revoking consent clears
|
|
18
|
+
* `_ac`. That cleanup did not exist before.
|
|
19
|
+
* - **The script**, which captures synchronously and then waits on consent for
|
|
20
|
+
* the *write* only. See src/scripts/tracking.ts.
|
|
21
|
+
*
|
|
22
|
+
* `<meta>` rather than a `<span>` so it is valid in `<head>`, where this
|
|
23
|
+
* component belongs — the capture wants to run before the visitor can navigate.
|
|
24
|
+
*/
|
|
25
|
+
// @ts-ignore — virtual module provided by the vitops() integration at build time
|
|
26
|
+
import head from 'virtual:getvitops/head';
|
|
27
|
+
|
|
28
|
+
const { enabled, category, cookies } = head.tracking ?? { enabled: false };
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
{enabled && (
|
|
32
|
+
<>
|
|
33
|
+
<meta
|
|
34
|
+
name="vitops-tracking"
|
|
35
|
+
data-vitops-tracking
|
|
36
|
+
data-consent={category}
|
|
37
|
+
data-consent-cookies={cookies?.length ? cookies.join(',') : undefined}
|
|
38
|
+
/>
|
|
39
|
+
<script>
|
|
40
|
+
import '../scripts/tracking.ts';
|
|
41
|
+
</script>
|
|
42
|
+
</>
|
|
43
|
+
)}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* An accessible disclosure tree, progressively enhanced.
|
|
4
|
+
*
|
|
5
|
+
* Tier-3 in the framework's terms — an authoring convenience that emits the right
|
|
6
|
+
* `.tree` markup and requires **no runtime JS**. What it renders is the whole
|
|
7
|
+
* content, and it stands on its own:
|
|
8
|
+
*
|
|
9
|
+
* - structure is real `<ul>`/`<li>` nesting, so assistive tech announces list
|
|
10
|
+
* position and depth. A `<div>` soup looks identical and tells a screen-reader
|
|
11
|
+
* user nothing about where they are;
|
|
12
|
+
* - every branch is a native `<details>`/`<summary>`, so expanded state and
|
|
13
|
+
* keyboard operation come from the platform. No `role="tree"`, no
|
|
14
|
+
* `aria-expanded` bookkeeping — a hand-rolled ARIA tree that loses its JS is
|
|
15
|
+
* worse than no ARIA at all;
|
|
16
|
+
* - nodes carry ids, so any node is linkable without JS.
|
|
17
|
+
*
|
|
18
|
+
* **This component emits its own `<wc-tree>`** — do NOT wrap it in one. That is the
|
|
19
|
+
* tier-3 rule ("the wrapper emits its `<wc-*>` tag with the accessible fallback
|
|
20
|
+
* inside"), and `CookieConsent.astro` does the same with `<wc-consent>`.
|
|
21
|
+
* `<wc-tree><Tree /></wc-tree>` nests two elements binding the same tree; the
|
|
22
|
+
* element now defers to an enclosing one, so it degrades to a no-op rather than two
|
|
23
|
+
* duelling toolbars, but the single call is the intended shape.
|
|
24
|
+
*
|
|
25
|
+
* `<wc-tree>` upgrades it with filter, expand/collapse-all and deep-linking. If
|
|
26
|
+
* that never loads, only those three conveniences are missing — which is why
|
|
27
|
+
* `enhance={false}` is a supported way to get the bare fragment.
|
|
28
|
+
*/
|
|
29
|
+
import type { TreeItem } from './tree.ts';
|
|
30
|
+
import TreeNode from './TreeNode.astro';
|
|
31
|
+
|
|
32
|
+
interface Props {
|
|
33
|
+
items: TreeItem[];
|
|
34
|
+
/** Placeholder + aria-label for the filter `<wc-tree>` generates. */
|
|
35
|
+
label?: string;
|
|
36
|
+
/** Accessible name for the list itself. */
|
|
37
|
+
ariaLabel?: string;
|
|
38
|
+
/** Branches shallower than this render open. 0 = everything closed. */
|
|
39
|
+
openDepth?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Emit the `<wc-tree>` wrapper (default `true`).
|
|
42
|
+
*
|
|
43
|
+
* Set false when the fragment is going somewhere the element cannot run or is
|
|
44
|
+
* unwanted — an email, a PDF, a server-rendered block on a page that doesn't load
|
|
45
|
+
* `elements.js`. The tree itself is unchanged; only the enhancement layer is
|
|
46
|
+
* omitted, so nothing is lost but filter/expand-all/deep-link.
|
|
47
|
+
*/
|
|
48
|
+
enhance?: boolean;
|
|
49
|
+
}
|
|
50
|
+
const {
|
|
51
|
+
items,
|
|
52
|
+
label = 'Filter',
|
|
53
|
+
ariaLabel,
|
|
54
|
+
openDepth = 0,
|
|
55
|
+
enhance = true,
|
|
56
|
+
// Anything else is passed through to `<wc-tree>`, so the element can grow
|
|
57
|
+
// attributes without this component's prop surface tracking them.
|
|
58
|
+
...rest
|
|
59
|
+
} = Astro.props;
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
{/* One `<ul>`, rendered once, wrapped or not — duplicating the tree markup per
|
|
63
|
+
branch is how the two paths would drift. */}
|
|
64
|
+
<Fragment>
|
|
65
|
+
{
|
|
66
|
+
enhance ? (
|
|
67
|
+
<wc-tree label={label} {...rest}>
|
|
68
|
+
<ul class="tree tree--lines" aria-label={ariaLabel}>
|
|
69
|
+
{items.map((item) => (
|
|
70
|
+
<TreeNode item={item} depth={0} openDepth={openDepth} />
|
|
71
|
+
))}
|
|
72
|
+
</ul>
|
|
73
|
+
</wc-tree>
|
|
74
|
+
) : (
|
|
75
|
+
<ul class="tree tree--lines" aria-label={ariaLabel}>
|
|
76
|
+
{items.map((item) => (
|
|
77
|
+
<TreeNode item={item} depth={0} openDepth={openDepth} />
|
|
78
|
+
))}
|
|
79
|
+
</ul>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
</Fragment>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* One node of a `<Tree />`, recursing through `Astro.self`.
|
|
4
|
+
*
|
|
5
|
+
* Two shapes, and the choice is semantic rather than cosmetic:
|
|
6
|
+
*
|
|
7
|
+
* - a **branch** wraps its label in `<details>`/`<summary>` — a native
|
|
8
|
+
* disclosure button, so expanded state is announced and keyboard operation
|
|
9
|
+
* is free. The child `<ul>` lives inside the `<details>`, which is what makes
|
|
10
|
+
* it collapse with no JS.
|
|
11
|
+
* - a **leaf** is a plain `.tree__content` row. A `<details>` with nothing to
|
|
12
|
+
* disclose would still render a control and announce as collapsible.
|
|
13
|
+
*
|
|
14
|
+
* The item class sits on the `<li>` in both cases, so `<wc-tree>`'s filter treats
|
|
15
|
+
* leaves and branches as one kind of thing and hiding one hides its whole row.
|
|
16
|
+
*/
|
|
17
|
+
import type { TreeItem } from './tree.ts';
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
item: TreeItem;
|
|
21
|
+
depth: number;
|
|
22
|
+
/** Branches at a depth below this render open. */
|
|
23
|
+
openDepth: number;
|
|
24
|
+
}
|
|
25
|
+
const { item, depth, openDepth } = Astro.props;
|
|
26
|
+
|
|
27
|
+
const kids = item.children ?? [];
|
|
28
|
+
const isBranch = kids.length > 0;
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
<li class="tree__item" id={item.id}>
|
|
32
|
+
{
|
|
33
|
+
isBranch ? (
|
|
34
|
+
<details open={depth < openDepth}>
|
|
35
|
+
<summary>
|
|
36
|
+
<span class="tree__toggle" aria-hidden="true" />
|
|
37
|
+
<span class="tree__label">
|
|
38
|
+
{item.unnamed ? <em>{item.label}</em> : <code>{item.label}</code>}
|
|
39
|
+
{item.badge && <span class="badge">{item.badge}</span>}
|
|
40
|
+
{item.flag && <span class="badge badge-danger">{item.flag}</span>}
|
|
41
|
+
</span>
|
|
42
|
+
</summary>
|
|
43
|
+
{item.descriptionHtml && <p class="tree__desc" set:html={item.descriptionHtml} />}
|
|
44
|
+
<ul class="tree">
|
|
45
|
+
{kids.map((kid) => (
|
|
46
|
+
<Astro.self item={kid} depth={depth + 1} openDepth={openDepth} />
|
|
47
|
+
))}
|
|
48
|
+
</ul>
|
|
49
|
+
</details>
|
|
50
|
+
) : (
|
|
51
|
+
<Fragment>
|
|
52
|
+
<span class="tree__content">
|
|
53
|
+
<span class="tree__label">
|
|
54
|
+
{item.unnamed ? <em>{item.label}</em> : <code>{item.label}</code>}
|
|
55
|
+
{item.badge && <span class="badge">{item.badge}</span>}
|
|
56
|
+
{item.flag && <span class="badge badge-danger">{item.flag}</span>}
|
|
57
|
+
</span>
|
|
58
|
+
</span>
|
|
59
|
+
{item.descriptionHtml && <p class="tree__desc" set:html={item.descriptionHtml} />}
|
|
60
|
+
</Fragment>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
</li>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** One node of a `<Tree />`. See `Tree.astro` for the rendering contract. */
|
|
2
|
+
export interface TreeItem {
|
|
3
|
+
label: string;
|
|
4
|
+
/** Small type/meta chip after the label. */
|
|
5
|
+
badge?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Rendered with `set:html`, so it MUST already be escaped. For schema text use
|
|
8
|
+
* the generator's `renderInlineMarkdown()` — one tested escaper beats each
|
|
9
|
+
* caller doing its own, which is how one of them eventually doesn't.
|
|
10
|
+
*/
|
|
11
|
+
descriptionHtml?: string;
|
|
12
|
+
/** Extra chip, e.g. "required". */
|
|
13
|
+
flag?: string;
|
|
14
|
+
/** Anchor id, so the node is linkable with no JS. */
|
|
15
|
+
id?: string;
|
|
16
|
+
/** An unnamed group (a union branch) — label renders as emphasis, not code. */
|
|
17
|
+
unnamed?: boolean;
|
|
18
|
+
children?: TreeItem[];
|
|
19
|
+
}
|