@getvitops/astro 4.0.0 → 5.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 +303 -0
- package/README.md +22 -0
- package/dist/exports/tracking.d.mts +3 -0
- package/dist/exports/tracking.mjs +5 -0
- package/dist/index.d.mts +66 -56
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +221 -41
- package/dist/index.mjs.map +1 -1
- package/dist/routes/conversion.d.mts +6 -3
- package/dist/routes/conversion.d.mts.map +1 -1
- package/dist/routes/conversion.mjs +21 -3
- package/dist/routes/conversion.mjs.map +1 -1
- package/dist/tracking-CmSfxWC3.mjs +47 -0
- package/dist/tracking-CmSfxWC3.mjs.map +1 -0
- package/dist/tracking-DdEKExlE.d.mts +65 -0
- package/dist/tracking-DdEKExlE.d.mts.map +1 -0
- package/package.json +15 -6
- package/src/ads.ts +156 -0
- package/src/analytics.ts +22 -4
- package/src/components/Ads.astro +47 -0
- package/src/components/Analytics.astro +7 -30
- package/src/components/Cards.astro +38 -24
- package/src/components/GatedTags.astro +46 -0
- package/src/components/Subgrid.astro +39 -21
- package/src/components/Tree.astro +2 -2
- package/src/components/TreeNode.astro +1 -1
- package/src/exports/tracking.ts +26 -0
- package/src/scripts/tracking.ts +13 -1
- package/src/tracking.ts +13 -0
- package/virtual.d.ts +6 -0
|
@@ -11,9 +11,12 @@ interface ConversionRouteOptions {
|
|
|
11
11
|
/** Where notifications go. Build it from your site config. */
|
|
12
12
|
context: NotifyContext;
|
|
13
13
|
/**
|
|
14
|
-
* The Workers `send_email` binding for this request, e.g.
|
|
15
|
-
* `
|
|
16
|
-
* is printed instead of sent.
|
|
14
|
+
* The Workers `send_email` binding for this request, e.g. `() => env.EMAIL`
|
|
15
|
+
* with `env` imported from `cloudflare:workers`. Omit it in dev and the
|
|
16
|
+
* notification is printed instead of sent.
|
|
17
|
+
*
|
|
18
|
+
* Not `(locals) => locals.runtime.env.EMAIL` — Astro removed `locals.runtime`
|
|
19
|
+
* in v6, and this package peers on `>= 7`.
|
|
17
20
|
*/
|
|
18
21
|
binding?: (locals: unknown) => EmailBinding | undefined;
|
|
19
22
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"conversion.d.mts","names":[],"sources":["../../src/routes/conversion.ts"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"conversion.d.mts","names":[],"sources":["../../src/routes/conversion.ts"],"mappings":";;;;;UAkDiB,wBAAA;EACf,OAAA,EAAS,OAAO;EAChB,MAAA;AAAA;AAAA,UAGe,sBAAA;EAiDD;EA/Cd,OAAA,EAAS,aAAA;EA+CK;;;;;;AAAgE;;EAtC9E,OAAA,IAAW,MAAA,cAAoB,YAAA;;;;;EAK/B,QAAA,IAAY,IAAA,EAAM,MAAA;;EAElB,QAAA,IAAY,MAAA,EAAQ,OAAA,CAAQ,UAAA,QAAkB,MAAA;;EAE9C,GAAA;AAAA;;;;;;;;;;;iBA0Bc,qBAAA,CAAsB,OAAA,EAAS,sBAAA;EAG/B,OAAA;EAAA;AAAA,GAAqB,wBAAA,KAA2B,OAAA,CAAQ,QAAA"}
|
|
@@ -16,17 +16,35 @@ import { parseTrackingCookie } from "@getvitops/utils/tracking";
|
|
|
16
16
|
* to fight rather than mount. The handler takes the parsed body and an optional
|
|
17
17
|
* `validate`; everything else is the caller's.
|
|
18
18
|
*
|
|
19
|
+
* The path matters: the capture script beacons `tel:` conversions at
|
|
20
|
+
* `TRACKING_ENDPOINT` (`/api/track`, exported from `@getvitops/astro/tracking`),
|
|
21
|
+
* and the integration warns at build if no route answers it.
|
|
22
|
+
*
|
|
19
23
|
* ```ts
|
|
20
24
|
* // src/pages/api/track.ts
|
|
21
25
|
* export const prerender = false;
|
|
26
|
+
* import { env } from 'cloudflare:workers';
|
|
22
27
|
* import { createConversionRoute } from '@getvitops/astro/routes';
|
|
23
|
-
* import config from '../../site.config.json';
|
|
24
28
|
*
|
|
25
29
|
* export const POST = createConversionRoute({
|
|
26
|
-
* context:
|
|
27
|
-
*
|
|
30
|
+
* context: {
|
|
31
|
+
* // A bare address is shorthand for `{ provider: 'cloudflare', to }`.
|
|
32
|
+
* notifications: { email: 'sales@acme.ca' },
|
|
33
|
+
* canonical: 'https://acme.ca',
|
|
34
|
+
* organizationName: 'Acme',
|
|
35
|
+
* },
|
|
36
|
+
* binding: () => env.EMAIL,
|
|
28
37
|
* });
|
|
29
38
|
* ```
|
|
39
|
+
*
|
|
40
|
+
* Two things this example used to get wrong, both of which cost a consumer real
|
|
41
|
+
* time. It read the binding off `locals.runtime.env`, which Astro removed in v6
|
|
42
|
+
* — and because the resulting throw precedes the response, Astro re-reads the
|
|
43
|
+
* request and reports a misleading "Body has already been used" instead of the
|
|
44
|
+
* actual error. This package peers on `astro >= 7`, so that form could not work
|
|
45
|
+
* for any supported consumer; `import { env } from 'cloudflare:workers'` is the
|
|
46
|
+
* current one. It also called a `toNotifyContext(config)` helper that does not
|
|
47
|
+
* exist — `context` is a plain `NotifyContext`, built however you like.
|
|
30
48
|
*/
|
|
31
49
|
/** Fields that are never part of a submission's meaning. */
|
|
32
50
|
const CONTROL_FIELDS = new Set(["event", "phone"]);
|
|
@@ -1 +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 {
|
|
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 * The path matters: the capture script beacons `tel:` conversions at\n * `TRACKING_ENDPOINT` (`/api/track`, exported from `@getvitops/astro/tracking`),\n * and the integration warns at build if no route answers it.\n *\n * ```ts\n * // src/pages/api/track.ts\n * export const prerender = false;\n * import { env } from 'cloudflare:workers';\n * import { createConversionRoute } from '@getvitops/astro/routes';\n *\n * export const POST = createConversionRoute({\n * context: {\n * // A bare address is shorthand for `{ provider: 'cloudflare', to }`.\n * notifications: { email: 'sales@acme.ca' },\n * canonical: 'https://acme.ca',\n * organizationName: 'Acme',\n * },\n * binding: () => env.EMAIL,\n * });\n * ```\n *\n * Two things this example used to get wrong, both of which cost a consumer real\n * time. It read the binding off `locals.runtime.env`, which Astro removed in v6\n * — and because the resulting throw precedes the response, Astro re-reads the\n * request and reports a misleading \"Body has already been used\" instead of the\n * actual error. This package peers on `astro >= 7`, so that form could not work\n * for any supported consumer; `import { env } from 'cloudflare:workers'` is the\n * current one. It also called a `toNotifyContext(config)` helper that does not\n * exist — `context` is a plain `NotifyContext`, built however you like.\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. `() => env.EMAIL`\n * with `env` imported from `cloudflare:workers`. Omit it in dev and the\n * notification is printed instead of sent.\n *\n * Not `(locals) => locals.runtime.env.EMAIL` — Astro removed `locals.runtime`\n * in v6, and this package peers on `>= 7`.\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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+EA,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"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//#region src/tracking.ts
|
|
2
|
+
/** The cookie the capture script writes. Mirrors `TRACKING_COOKIE` in utils. */
|
|
3
|
+
const TRACKING_COOKIE = "_ac";
|
|
4
|
+
/**
|
|
5
|
+
* Where the capture script sends its `tel:` conversion beacon.
|
|
6
|
+
*
|
|
7
|
+
* The route itself is the consumer's to write (`createConversionRoute()` in
|
|
8
|
+
* `@getvitops/astro/routes`), so this is the one place the two halves agree on
|
|
9
|
+
* a path. It was a bare literal inside the inlined script: a consumer who named
|
|
10
|
+
* the file anything else got a silent 404 on every beacon and lost conversions
|
|
11
|
+
* with no error anywhere to explain it. Exported so a consumer can assert
|
|
12
|
+
* against it, mirrored in `scripts/tracking.ts` because that file is inlined
|
|
13
|
+
* into the document and cannot import, and pinned by `tracking.test.ts`.
|
|
14
|
+
*/
|
|
15
|
+
const TRACKING_ENDPOINT = "/api/track";
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the tracking config into what the component emits.
|
|
18
|
+
*
|
|
19
|
+
* Two warnings, both about the same failure — a persistent identifier written
|
|
20
|
+
* with no way for the visitor to decline, or a permission asked for with no way
|
|
21
|
+
* to answer. Returned as data rather than logged here so the check is a test
|
|
22
|
+
* rather than a line someone might scroll past, exactly as `resolveAnalytics`
|
|
23
|
+
* does.
|
|
24
|
+
*/
|
|
25
|
+
function resolveTracking(opts, ctx = {}) {
|
|
26
|
+
const warnings = [];
|
|
27
|
+
const category = opts?.category ?? "marketing";
|
|
28
|
+
const enabled = opts?.enabled === true;
|
|
29
|
+
if (!enabled) return {
|
|
30
|
+
enabled: false,
|
|
31
|
+
category,
|
|
32
|
+
cookies: [],
|
|
33
|
+
warnings
|
|
34
|
+
};
|
|
35
|
+
if (!ctx.consent) warnings.push(`tracking is on, so this site writes the \`${"_ac"}\` cookie — a 90-day identifier tying a visitor to the ad that brought them — and \`consent\` is off, so it is written for every visitor with no way to decline. Set \`consent: true\`.`);
|
|
36
|
+
else if (ctx.consentCategories && !ctx.consentCategories.includes(category)) warnings.push(`tracking demands the \`${category}\` consent category, but the banner offers ${ctx.consentCategories.join(", ") || "nothing"}. Add \`${category}\` to \`consent.categories\`, or the visitor is asked about something the banner never names.`);
|
|
37
|
+
return {
|
|
38
|
+
enabled,
|
|
39
|
+
category,
|
|
40
|
+
cookies: ["_ac"],
|
|
41
|
+
warnings
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
//#endregion
|
|
46
|
+
export { TRACKING_ENDPOINT as n, resolveTracking as r, TRACKING_COOKIE as t };
|
|
47
|
+
//# sourceMappingURL=tracking-CmSfxWC3.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tracking-CmSfxWC3.mjs","names":[],"sources":["../src/tracking.ts"],"sourcesContent":["/**\n * The `<Tracking />` resolution layer — tracking config in, what to emit out.\n *\n * Separate from `Tracking.astro` for the same reason `analytics.ts` is separate\n * from `Analytics.astro`: everything decidable is a pure function asserted in\n * `tracking.test.ts`, and the component only renders what this returns.\n *\n * The one decision here is **which consent category the `_ac` cookie waits on**,\n * and it is stated rather than inferred. `_ac` is a 90-day identifier tying a\n * visitor to the ad that brought them, so `marketing` is the default — but a site\n * using click IDs purely for its own campaign reporting may reasonably file it\n * under `analytics`, and that is the consumer's call to make, not ours to guess.\n */\n\n/** Which consent category the attribution cookie waits on. */\nexport type TrackingCategory = 'marketing' | 'analytics';\n\nexport interface GetvitopsTrackingOptions {\n /** Capture ad click IDs and UTMs into `_ac`. */\n enabled?: boolean;\n /** Default `marketing`. */\n category?: TrackingCategory;\n /** Informational; capture recognises every known click-ID parameter regardless. */\n platforms?: string[];\n}\n\n/** The cookie the capture script writes. Mirrors `TRACKING_COOKIE` in utils. */\nexport const TRACKING_COOKIE = '_ac';\n\n/**\n * Where the capture script sends its `tel:` conversion beacon.\n *\n * The route itself is the consumer's to write (`createConversionRoute()` in\n * `@getvitops/astro/routes`), so this is the one place the two halves agree on\n * a path. It was a bare literal inside the inlined script: a consumer who named\n * the file anything else got a silent 404 on every beacon and lost conversions\n * with no error anywhere to explain it. Exported so a consumer can assert\n * against it, mirrored in `scripts/tracking.ts` because that file is inlined\n * into the document and cannot import, and pinned by `tracking.test.ts`.\n */\nexport const TRACKING_ENDPOINT = '/api/track';\n\nexport interface ResolvedTracking {\n enabled: boolean;\n category: TrackingCategory;\n /** Cookie names to clear on revoke — rides on `data-consent-cookies`. */\n cookies: string[];\n /** Configuration problems, as prose. The integration logs these. */\n warnings: string[];\n}\n\nexport interface ResolveTrackingContext {\n /** Is the consent gate enabled on this site (`vitops({ consent })`)? */\n consent?: boolean;\n /** Categories the banner offers, so we can catch a demand with no row. */\n consentCategories?: readonly string[];\n}\n\n/**\n * Resolve the tracking config into what the component emits.\n *\n * Two warnings, both about the same failure — a persistent identifier written\n * with no way for the visitor to decline, or a permission asked for with no way\n * to answer. Returned as data rather than logged here so the check is a test\n * rather than a line someone might scroll past, exactly as `resolveAnalytics`\n * does.\n */\nexport function resolveTracking(\n opts: GetvitopsTrackingOptions | undefined,\n ctx: ResolveTrackingContext = {},\n): ResolvedTracking {\n const warnings: string[] = [];\n const category: TrackingCategory = opts?.category ?? 'marketing';\n const enabled = opts?.enabled === true;\n\n if (!enabled) return { enabled: false, category, cookies: [], warnings };\n\n if (!ctx.consent) {\n warnings.push(\n `tracking is on, so this site writes the \\`${TRACKING_COOKIE}\\` cookie — a 90-day ` +\n 'identifier tying a visitor to the ad that brought them — and `consent` is off, so it ' +\n 'is written for every visitor with no way to decline. Set `consent: true`.',\n );\n } else if (ctx.consentCategories && !ctx.consentCategories.includes(category)) {\n // The banner would be raised for a category it renders no row for: the\n // visitor is asked a question the form does not show them.\n warnings.push(\n `tracking demands the \\`${category}\\` consent category, but the banner offers ` +\n `${ctx.consentCategories.join(', ') || 'nothing'}. Add \\`${category}\\` to ` +\n '`consent.categories`, or the visitor is asked about something the banner never names.',\n );\n }\n\n return { enabled, category, cookies: [TRACKING_COOKIE], warnings };\n}\n"],"mappings":";;AA2BA,MAAa,kBAAkB;;;;;;;;;;;;AAa/B,MAAa,oBAAoB;;;;;;;;;;AA2BjC,SAAgB,gBACd,MACA,MAA8B,CAAC,GACb;CAClB,MAAM,WAAqB,CAAC;CAC5B,MAAM,WAA6B,MAAM,YAAY;CACrD,MAAM,UAAU,MAAM,YAAY;CAElC,IAAI,CAAC,SAAS,OAAO;EAAE,SAAS;EAAO;EAAU,SAAS,CAAC;EAAG;CAAS;CAEvE,IAAI,CAAC,IAAI,SACP,SAAS,KACP,mDAA6D,wLAG/D;MACK,IAAI,IAAI,qBAAqB,CAAC,IAAI,kBAAkB,SAAS,QAAQ,GAG1E,SAAS,KACP,0BAA0B,SAAS,6CAC9B,IAAI,kBAAkB,KAAK,IAAI,KAAK,UAAU,UAAU,SAAS,8FAExE;CAGF,OAAO;EAAE;EAAS;EAAU,SAAS,MAAgB;EAAG;CAAS;AACnE"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
//#region src/tracking.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The `<Tracking />` resolution layer — tracking config in, what to emit out.
|
|
4
|
+
*
|
|
5
|
+
* Separate from `Tracking.astro` for the same reason `analytics.ts` is separate
|
|
6
|
+
* from `Analytics.astro`: everything decidable is a pure function asserted in
|
|
7
|
+
* `tracking.test.ts`, and the component only renders what this returns.
|
|
8
|
+
*
|
|
9
|
+
* The one decision here is **which consent category the `_ac` cookie waits on**,
|
|
10
|
+
* and it is stated rather than inferred. `_ac` is a 90-day identifier tying a
|
|
11
|
+
* visitor to the ad that brought them, so `marketing` is the default — but a site
|
|
12
|
+
* using click IDs purely for its own campaign reporting may reasonably file it
|
|
13
|
+
* under `analytics`, and that is the consumer's call to make, not ours to guess.
|
|
14
|
+
*/
|
|
15
|
+
/** Which consent category the attribution cookie waits on. */
|
|
16
|
+
type TrackingCategory = 'marketing' | 'analytics';
|
|
17
|
+
interface GetvitopsTrackingOptions {
|
|
18
|
+
/** Capture ad click IDs and UTMs into `_ac`. */
|
|
19
|
+
enabled?: boolean;
|
|
20
|
+
/** Default `marketing`. */
|
|
21
|
+
category?: TrackingCategory;
|
|
22
|
+
/** Informational; capture recognises every known click-ID parameter regardless. */
|
|
23
|
+
platforms?: string[];
|
|
24
|
+
}
|
|
25
|
+
/** The cookie the capture script writes. Mirrors `TRACKING_COOKIE` in utils. */
|
|
26
|
+
declare const TRACKING_COOKIE = "_ac";
|
|
27
|
+
/**
|
|
28
|
+
* Where the capture script sends its `tel:` conversion beacon.
|
|
29
|
+
*
|
|
30
|
+
* The route itself is the consumer's to write (`createConversionRoute()` in
|
|
31
|
+
* `@getvitops/astro/routes`), so this is the one place the two halves agree on
|
|
32
|
+
* a path. It was a bare literal inside the inlined script: a consumer who named
|
|
33
|
+
* the file anything else got a silent 404 on every beacon and lost conversions
|
|
34
|
+
* with no error anywhere to explain it. Exported so a consumer can assert
|
|
35
|
+
* against it, mirrored in `scripts/tracking.ts` because that file is inlined
|
|
36
|
+
* into the document and cannot import, and pinned by `tracking.test.ts`.
|
|
37
|
+
*/
|
|
38
|
+
declare const TRACKING_ENDPOINT = "/api/track";
|
|
39
|
+
interface ResolvedTracking {
|
|
40
|
+
enabled: boolean;
|
|
41
|
+
category: TrackingCategory;
|
|
42
|
+
/** Cookie names to clear on revoke — rides on `data-consent-cookies`. */
|
|
43
|
+
cookies: string[];
|
|
44
|
+
/** Configuration problems, as prose. The integration logs these. */
|
|
45
|
+
warnings: string[];
|
|
46
|
+
}
|
|
47
|
+
interface ResolveTrackingContext {
|
|
48
|
+
/** Is the consent gate enabled on this site (`vitops({ consent })`)? */
|
|
49
|
+
consent?: boolean;
|
|
50
|
+
/** Categories the banner offers, so we can catch a demand with no row. */
|
|
51
|
+
consentCategories?: readonly string[];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the tracking config into what the component emits.
|
|
55
|
+
*
|
|
56
|
+
* Two warnings, both about the same failure — a persistent identifier written
|
|
57
|
+
* with no way for the visitor to decline, or a permission asked for with no way
|
|
58
|
+
* to answer. Returned as data rather than logged here so the check is a test
|
|
59
|
+
* rather than a line someone might scroll past, exactly as `resolveAnalytics`
|
|
60
|
+
* does.
|
|
61
|
+
*/
|
|
62
|
+
declare function resolveTracking(opts: GetvitopsTrackingOptions | undefined, ctx?: ResolveTrackingContext): ResolvedTracking;
|
|
63
|
+
//#endregion
|
|
64
|
+
export { TRACKING_ENDPOINT as a, TRACKING_COOKIE as i, ResolveTrackingContext as n, TrackingCategory as o, ResolvedTracking as r, resolveTracking as s, GetvitopsTrackingOptions as t };
|
|
65
|
+
//# sourceMappingURL=tracking-DdEKExlE.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tracking-DdEKExlE.d.mts","names":[],"sources":["../src/tracking.ts"],"mappings":";;AAeA;;;;AAA4B;AAE5B;;;;;;;;KAFY,gBAAA;AAAA,UAEK,wBAAA;EAUJ;EARX,OAAA;;EAEA,QAAA,GAAW,gBAAgB;EAMD;EAJ1B,SAAA;AAAA;;cAIW,eAAA;AAaiB;AAE9B;;;;;;;;;;AAF8B,cAAjB,iBAAA;AAAA,UAEI,gBAAA;EACf,OAAA;EACA,QAAA,EAAU,gBAAgB;EAS1B;EAPA,OAAA;EAqBc;EAnBd,QAAA;AAAA;AAAA,UAGe,sBAAA;EAkBV;EAhBL,OAAA;EAiBiB;EAfjB,iBAAiB;AAAA;;;;;;AAeA;;;;iBAHH,eAAA,CACd,IAAA,EAAM,wBAAA,cACN,GAAA,GAAK,sBAAA,GACJ,gBAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getvitops/astro",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.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": [
|
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
"src/components/Seo.astro",
|
|
10
10
|
"src/seo.ts",
|
|
11
11
|
"src/components/Analytics.astro",
|
|
12
|
+
"src/components/Ads.astro",
|
|
13
|
+
"src/components/GatedTags.astro",
|
|
12
14
|
"src/components/CookieConsent.astro",
|
|
13
15
|
"src/analytics.ts",
|
|
16
|
+
"src/ads.ts",
|
|
14
17
|
"src/components/Subgrid.astro",
|
|
15
18
|
"src/components/Cards.astro",
|
|
16
19
|
"src/components/NodeRenderer.astro",
|
|
@@ -29,7 +32,8 @@
|
|
|
29
32
|
"src/tracking.ts",
|
|
30
33
|
"src/components/Tree.astro",
|
|
31
34
|
"src/components/TreeNode.astro",
|
|
32
|
-
"src/components/tree.ts"
|
|
35
|
+
"src/components/tree.ts",
|
|
36
|
+
"src/exports/tracking.ts"
|
|
33
37
|
],
|
|
34
38
|
"type": "module",
|
|
35
39
|
"types": "./dist/index.d.mts",
|
|
@@ -42,9 +46,14 @@
|
|
|
42
46
|
"types": "./dist/routes/conversion.d.mts",
|
|
43
47
|
"import": "./dist/routes/conversion.mjs"
|
|
44
48
|
},
|
|
49
|
+
"./tracking": {
|
|
50
|
+
"types": "./dist/exports/tracking.d.mts",
|
|
51
|
+
"import": "./dist/exports/tracking.mjs"
|
|
52
|
+
},
|
|
45
53
|
"./Head.astro": "./src/components/Head.astro",
|
|
46
54
|
"./Seo.astro": "./src/components/Seo.astro",
|
|
47
55
|
"./Analytics.astro": "./src/components/Analytics.astro",
|
|
56
|
+
"./Ads.astro": "./src/components/Ads.astro",
|
|
48
57
|
"./CookieConsent.astro": "./src/components/CookieConsent.astro",
|
|
49
58
|
"./Tracking.astro": "./src/components/Tracking.astro",
|
|
50
59
|
"./components/Subgrid.astro": "./src/components/Subgrid.astro",
|
|
@@ -66,10 +75,10 @@
|
|
|
66
75
|
"access": "public"
|
|
67
76
|
},
|
|
68
77
|
"dependencies": {
|
|
69
|
-
"@getvitops/
|
|
70
|
-
"@getvitops/
|
|
71
|
-
"@getvitops/
|
|
72
|
-
"@getvitops/
|
|
78
|
+
"@getvitops/core": "5.0.0",
|
|
79
|
+
"@getvitops/generator": "5.0.0",
|
|
80
|
+
"@getvitops/utils": "5.0.0",
|
|
81
|
+
"@getvitops/vite": "5.0.0"
|
|
73
82
|
},
|
|
74
83
|
"devDependencies": {
|
|
75
84
|
"@astrojs/sitemap": "^3.7.3",
|
package/src/ads.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `<Ads />` resolution layer — the site's ad properties in, the exact tags to
|
|
3
|
+
* emit out.
|
|
4
|
+
*
|
|
5
|
+
* A sibling of `analytics.ts`, deliberately not part of it. The two render the
|
|
6
|
+
* same gated markup, but they answer different questions and four things differ:
|
|
7
|
+
*
|
|
8
|
+
* - **Where they come from.** Analytics providers are integration options
|
|
9
|
+
* (`vitops({ analytics })`); ad properties are `site.ads` in the site config,
|
|
10
|
+
* because the ad account is a fact about the site that `vitops ads setup`,
|
|
11
|
+
* `vitops ads lint` and the generated cookie notice all read too.
|
|
12
|
+
* - **Who decides the consent category.** `resolveAnalytics` *derives* it from
|
|
13
|
+
* whether the provider sets cookies, so a consumer cannot mark Google Analytics
|
|
14
|
+
* `necessary` to dodge a banner. Every ad pixel sets cookies and is advertising,
|
|
15
|
+
* so the answer is `marketing` unless the config says `analytics` — a stated
|
|
16
|
+
* fact, not a derivation.
|
|
17
|
+
* - **What the key means.** `ResolvedTag['key']` is the four analytics providers,
|
|
18
|
+
* and `analytics.ts` pairs that set against the generator's processor table.
|
|
19
|
+
* Widening it to eight ad platforms would make that check answer for tags it
|
|
20
|
+
* does not govern.
|
|
21
|
+
* - **When they fire.** Analytics is gated per environment by
|
|
22
|
+
* `environments.<env>.analytics`; a preview deployment sending pageviews is
|
|
23
|
+
* survivable, one firing conversion pixels is not, so ads gets its own switch.
|
|
24
|
+
*
|
|
25
|
+
* Everything decidable is here and unit-tested; `Ads.astro` renders what this
|
|
26
|
+
* returns and chooses nothing.
|
|
27
|
+
*/
|
|
28
|
+
import { AD_PLATFORMS, AD_PROVIDER_KEYS, type AdProvider } from '@getvitops/utils/ads';
|
|
29
|
+
import type { AnalyticsStrategy, ConsentCategory, GatedTag } from './analytics.ts';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One ad property, mirroring a `site.ads` entry field for field.
|
|
33
|
+
*
|
|
34
|
+
* Structural rather than `SiteAdProperty` imported from the generator, matching
|
|
35
|
+
* how `GetvitopsSeoOptions` and `GetvitopsTrackingOptions` already work: the
|
|
36
|
+
* component surface must stay usable by a consumer who passes a plain object.
|
|
37
|
+
*/
|
|
38
|
+
export interface GetvitopsAdProperty {
|
|
39
|
+
accountId?: string;
|
|
40
|
+
pixelId?: string;
|
|
41
|
+
conversionLabel?: string;
|
|
42
|
+
/** Default `marketing`. `analytics` for a property used purely for measurement. */
|
|
43
|
+
category?: 'marketing' | 'analytics';
|
|
44
|
+
/** False keeps the property on record without emitting its tag. */
|
|
45
|
+
enabled?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type GetvitopsAdsOptions = Partial<Record<AdProvider, GetvitopsAdProperty>>;
|
|
49
|
+
|
|
50
|
+
/** One ad tag. `key` is the platform, so a caller can find one without matching prose. */
|
|
51
|
+
export interface ResolvedAdTag extends GatedTag {
|
|
52
|
+
key: AdProvider;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ResolvedAds {
|
|
56
|
+
tags: ResolvedAdTag[];
|
|
57
|
+
/** Does anything here need `@getvitops/core/consent` loaded? */
|
|
58
|
+
needsRuntime: boolean;
|
|
59
|
+
/** Configuration problems, as prose. The integration logs these. */
|
|
60
|
+
warnings: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ResolveAdsContext {
|
|
64
|
+
/** Is the consent gate enabled on this site (`vitops({ consent })`)? */
|
|
65
|
+
consent?: boolean;
|
|
66
|
+
/** Categories the banner offers, so we can catch a demand with no row. */
|
|
67
|
+
consentCategories?: readonly string[];
|
|
68
|
+
/** `environments.<env>.ads` — false switches every tag off for this build. */
|
|
69
|
+
enabled?: boolean;
|
|
70
|
+
/** When the tags load. Default `idle`: a pixel is never worth the critical path. */
|
|
71
|
+
strategy?: AnalyticsStrategy;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* IDs land inside an inline `<script>`, so they are checked rather than trusted —
|
|
76
|
+
* the same guard, for the same reason, as `analytics.ts`'s `SAFE_ID`. A `</script>`
|
|
77
|
+
* in a pixel ID is not a plausible accident; arbitrary script injection from a
|
|
78
|
+
* config file is not a plausible thing to allow either.
|
|
79
|
+
*/
|
|
80
|
+
const SAFE_ID = /^[\w.-]+$/;
|
|
81
|
+
|
|
82
|
+
/** Resolve the configured ad properties into the tags `<Ads />` emits. */
|
|
83
|
+
export function resolveAds(
|
|
84
|
+
ads: GetvitopsAdsOptions | undefined,
|
|
85
|
+
ctx: ResolveAdsContext = {},
|
|
86
|
+
): ResolvedAds {
|
|
87
|
+
const warnings: string[] = [];
|
|
88
|
+
const tags: ResolvedAdTag[] = [];
|
|
89
|
+
const strategy = ctx.strategy ?? 'idle';
|
|
90
|
+
|
|
91
|
+
const configured = AD_PROVIDER_KEYS.filter((p) => ads?.[p] != null);
|
|
92
|
+
if (configured.length === 0) return { tags, needsRuntime: false, warnings };
|
|
93
|
+
|
|
94
|
+
// A whole-environment switch, checked before anything else: a preview deploy
|
|
95
|
+
// must be able to carry the same config without firing a single pixel.
|
|
96
|
+
if (ctx.enabled === false) return { tags, needsRuntime: false, warnings };
|
|
97
|
+
|
|
98
|
+
for (const provider of configured) {
|
|
99
|
+
const entry = ads![provider]!;
|
|
100
|
+
if (entry.enabled === false) continue;
|
|
101
|
+
|
|
102
|
+
const platform = AD_PLATFORMS[provider];
|
|
103
|
+
const id = entry[platform.tag.needs];
|
|
104
|
+
if (!id) {
|
|
105
|
+
warnings.push(
|
|
106
|
+
`ads: ${provider} has no ${platform.tag.needs}, so no tag is emitted. ` +
|
|
107
|
+
`Run \`vitops ads setup\` to be prompted for it.`,
|
|
108
|
+
);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!SAFE_ID.test(id)) {
|
|
112
|
+
warnings.push(`ads: ${provider} ${platform.tag.needs} "${id}" is not a valid ID. Skipped.`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const category: ConsentCategory = entry.category ?? 'marketing';
|
|
117
|
+
tags.push({
|
|
118
|
+
key: provider,
|
|
119
|
+
provider: platform.name,
|
|
120
|
+
category,
|
|
121
|
+
// Every platform in the table sets cookies; the tag is gated whenever the
|
|
122
|
+
// site has a gate. With no gate the tag still renders — and the warning
|
|
123
|
+
// below says what that means, rather than silently declining to advertise.
|
|
124
|
+
setsCookies: true,
|
|
125
|
+
cookies: platform.tag.cookies,
|
|
126
|
+
strategy,
|
|
127
|
+
gated: ctx.consent === true,
|
|
128
|
+
inline: platform.tag.bootstrap(id, { provider, ...entry }),
|
|
129
|
+
src: platform.tag.src ? platform.tag.src(id) : null,
|
|
130
|
+
attrs: {},
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (tags.length === 0) return { tags, needsRuntime: false, warnings };
|
|
135
|
+
|
|
136
|
+
if (ctx.consent !== true) {
|
|
137
|
+
warnings.push(
|
|
138
|
+
`ads: ${tags.map((t) => t.provider).join(', ')} ${tags.length === 1 ? 'sets' : 'set'} ` +
|
|
139
|
+
'advertising cookies, and `consent` is off — so they load for every visitor with no way ' +
|
|
140
|
+
'to decline. Set `consent: true`.',
|
|
141
|
+
);
|
|
142
|
+
} else {
|
|
143
|
+
const missing = [...new Set(tags.map((t) => t.category))].filter(
|
|
144
|
+
(c) => ctx.consentCategories && !ctx.consentCategories.includes(c),
|
|
145
|
+
);
|
|
146
|
+
if (missing.length)
|
|
147
|
+
warnings.push(
|
|
148
|
+
`ads: the tags demand the ${missing.join(', ')} consent categor${missing.length === 1 ? 'y' : 'ies'}, ` +
|
|
149
|
+
`but the banner offers ${ctx.consentCategories?.join(', ') || 'nothing'}. Add ` +
|
|
150
|
+
`${missing.join(', ')} to \`consent.categories\`, or the tags wait on a question the ` +
|
|
151
|
+
'banner never asks.',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { tags, needsRuntime: true, warnings };
|
|
156
|
+
}
|
package/src/analytics.ts
CHANGED
|
@@ -105,9 +105,22 @@ export interface GetvitopsConsentOptions {
|
|
|
105
105
|
policyUrl?: string;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
/**
|
|
109
|
+
* A third-party tag, fully resolved — everything `<GatedTags />` needs to render
|
|
110
|
+
* one, with nothing left to decide.
|
|
111
|
+
*
|
|
112
|
+
* Shared by `<Analytics />` and `<Ads />` on purpose: the inert-markup rule
|
|
113
|
+
* (`type="text/plain"`, the URL on `data-src`, never a live `src`) is the whole
|
|
114
|
+
* consent gate, and it must have exactly one implementation. What differs between
|
|
115
|
+
* the two is where the tag comes from and who decides its category, which is why
|
|
116
|
+
* they have separate resolvers over one shape rather than one resolver over two
|
|
117
|
+
* vocabularies.
|
|
118
|
+
*
|
|
119
|
+
* `key` is left to each resolver: analytics keys are its four providers, ads keys
|
|
120
|
+
* are `AdProvider`. Widening it into one union here would let a provider from
|
|
121
|
+
* either side satisfy a check meant for the other.
|
|
122
|
+
*/
|
|
123
|
+
export interface GatedTag {
|
|
111
124
|
/** The provider as a reader would recognise it — used in warnings. */
|
|
112
125
|
provider: string;
|
|
113
126
|
category: ConsentCategory;
|
|
@@ -127,6 +140,11 @@ export interface ResolvedTag {
|
|
|
127
140
|
attrs: Record<string, string>;
|
|
128
141
|
}
|
|
129
142
|
|
|
143
|
+
/** One analytics tag. */
|
|
144
|
+
export interface ResolvedTag extends GatedTag {
|
|
145
|
+
key: 'googleAnalytics' | 'clarity' | 'matomo' | 'plausible';
|
|
146
|
+
}
|
|
147
|
+
|
|
130
148
|
export interface ResolvedAnalytics {
|
|
131
149
|
tags: ResolvedTag[];
|
|
132
150
|
/** Does anything here need `@getvitops/core/consent` loaded? */
|
|
@@ -316,7 +334,7 @@ export function resolveAnalytics(
|
|
|
316
334
|
}
|
|
317
335
|
|
|
318
336
|
/** Which optional categories a set of tags actually needs a visitor to decide. */
|
|
319
|
-
export function consentCategories(tags:
|
|
337
|
+
export function consentCategories(tags: GatedTag[]): OptionalConsentCategory[] {
|
|
320
338
|
const used = new Set<OptionalConsentCategory>();
|
|
321
339
|
for (const tag of tags) {
|
|
322
340
|
if (tag.category !== 'necessary') used.add(tag.category);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <Ads /> — the site's advertising pixels, off the critical path and behind the
|
|
4
|
+
* consent gate. Place in your layout's <head>; requires the vitops()
|
|
5
|
+
* integration, which supplies `virtual:getvitops/head`.
|
|
6
|
+
*
|
|
7
|
+
* A dumb renderer over `resolveAds()` (src/ads.ts) — which properties, which
|
|
8
|
+
* consent category, which cookies and whether this environment fires at all are
|
|
9
|
+
* decided there and unit-tested. Nothing in this file chooses anything.
|
|
10
|
+
*
|
|
11
|
+
* The properties come from the site config's `site.ads` block, not from an
|
|
12
|
+
* integration option: the ad account is a fact about the site, and the same block
|
|
13
|
+
* drives `vitops ads setup` (the domain-verification record), `vitops ads lint`
|
|
14
|
+
* and the cookie notice `vitops legal` generates. One source, so a pixel cannot be
|
|
15
|
+
* running while the site's own notice omits it.
|
|
16
|
+
*
|
|
17
|
+
* Pairs with <Tracking />, which captures the click ID the pixel's platform
|
|
18
|
+
* stamped on the landing URL. Neither implies the other — attribution works with
|
|
19
|
+
* no pixel, and a pixel works with no attribution — but a site running ads
|
|
20
|
+
* normally wants both.
|
|
21
|
+
*/
|
|
22
|
+
import { resolveAds } from '../ads.ts';
|
|
23
|
+
import type { AnalyticsStrategy } from '../analytics.ts';
|
|
24
|
+
import GatedTags from './GatedTags.astro';
|
|
25
|
+
// @ts-ignore — virtual module provided by the vitops() integration at build time
|
|
26
|
+
import head from 'virtual:getvitops/head';
|
|
27
|
+
|
|
28
|
+
interface Props {
|
|
29
|
+
/** Render nothing on this page. Defaults to true (the per-environment switch is `environments.<env>.ads`). */
|
|
30
|
+
enabled?: boolean;
|
|
31
|
+
/** Override the load strategy for this page. */
|
|
32
|
+
strategy?: AnalyticsStrategy;
|
|
33
|
+
}
|
|
34
|
+
const { enabled = true, strategy } = Astro.props;
|
|
35
|
+
|
|
36
|
+
// `head.ads` is already environment-resolved by the integration; `enabled` here is
|
|
37
|
+
// the per-page override on top of it.
|
|
38
|
+
const { tags } = enabled
|
|
39
|
+
? resolveAds(head.ads?.properties, {
|
|
40
|
+
consent: head.consent,
|
|
41
|
+
enabled: head.ads?.enabled,
|
|
42
|
+
...(strategy ? { strategy } : {}),
|
|
43
|
+
})
|
|
44
|
+
: { tags: [] };
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
<GatedTags tags={tags} />
|
|
@@ -8,17 +8,14 @@
|
|
|
8
8
|
* about which tag, which consent category and which cookies lives there and is
|
|
9
9
|
* unit-tested. Nothing in this file chooses anything.
|
|
10
10
|
*
|
|
11
|
-
* The
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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.
|
|
11
|
+
* The markup itself is <GatedTags />, shared with <Ads />: the inert
|
|
12
|
+
* `type="text/plain"` + `data-src` shape is the whole gate, and it has exactly one
|
|
13
|
+
* implementation. <Ads /> is a sibling rather than part of this component because
|
|
14
|
+
* ad properties come from the site config, state their own consent category and
|
|
15
|
+
* switch per environment separately — see src/ads.ts.
|
|
20
16
|
*/
|
|
21
17
|
import { type AnalyticsStrategy, resolveAnalytics } from '../analytics.ts';
|
|
18
|
+
import GatedTags from './GatedTags.astro';
|
|
22
19
|
// @ts-ignore — virtual module provided by the vitops() integration at build time
|
|
23
20
|
import head from 'virtual:getvitops/head';
|
|
24
21
|
|
|
@@ -36,24 +33,4 @@ const { tags } = enabled
|
|
|
36
33
|
: { tags: [] };
|
|
37
34
|
---
|
|
38
35
|
|
|
39
|
-
{tags
|
|
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
|
-
)}
|
|
36
|
+
<GatedTags tags={tags} />
|