@getvitops/astro 3.0.0 → 4.1.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": "3.0.0",
3
+ "version": "4.1.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",
@@ -23,7 +26,13 @@
23
26
  "src/components/NavShellToggle.astro",
24
27
  "src/schemas",
25
28
  "virtual.d.ts",
26
- "CHANGELOG.md"
29
+ "CHANGELOG.md",
30
+ "src/components/Tracking.astro",
31
+ "src/scripts/tracking.ts",
32
+ "src/tracking.ts",
33
+ "src/components/Tree.astro",
34
+ "src/components/TreeNode.astro",
35
+ "src/components/tree.ts"
27
36
  ],
28
37
  "type": "module",
29
38
  "types": "./dist/index.d.mts",
@@ -32,10 +41,16 @@
32
41
  "types": "./dist/index.d.mts",
33
42
  "import": "./dist/index.mjs"
34
43
  },
44
+ "./routes": {
45
+ "types": "./dist/routes/conversion.d.mts",
46
+ "import": "./dist/routes/conversion.mjs"
47
+ },
35
48
  "./Head.astro": "./src/components/Head.astro",
36
49
  "./Seo.astro": "./src/components/Seo.astro",
37
50
  "./Analytics.astro": "./src/components/Analytics.astro",
51
+ "./Ads.astro": "./src/components/Ads.astro",
38
52
  "./CookieConsent.astro": "./src/components/CookieConsent.astro",
53
+ "./Tracking.astro": "./src/components/Tracking.astro",
39
54
  "./components/Subgrid.astro": "./src/components/Subgrid.astro",
40
55
  "./components/Cards.astro": "./src/components/Cards.astro",
41
56
  "./components/NodeRenderer.astro": "./src/components/NodeRenderer.astro",
@@ -46,6 +61,8 @@
46
61
  "./components/Drawer.astro": "./src/components/Drawer.astro",
47
62
  "./components/NavShell.astro": "./src/components/NavShell.astro",
48
63
  "./components/NavShellToggle.astro": "./src/components/NavShellToggle.astro",
64
+ "./components/Tree.astro": "./src/components/Tree.astro",
65
+ "./components/tree.ts": "./src/components/tree.ts",
49
66
  "./schemas/*": "./src/schemas/*",
50
67
  "./package.json": "./package.json"
51
68
  },
@@ -53,10 +70,10 @@
53
70
  "access": "public"
54
71
  },
55
72
  "dependencies": {
56
- "@getvitops/core": "3.0.0",
57
- "@getvitops/utils": "3.0.0",
58
- "@getvitops/generator": "3.0.0",
59
- "@getvitops/vite": "3.0.0"
73
+ "@getvitops/core": "4.1.0",
74
+ "@getvitops/generator": "4.1.0",
75
+ "@getvitops/vite": "4.1.0",
76
+ "@getvitops/utils": "4.1.0"
60
77
  },
61
78
  "devDependencies": {
62
79
  "@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
- /** One tag, fully resolved. */
109
- export interface ResolvedTag {
110
- key: 'googleAnalytics' | 'clarity' | 'matomo' | 'plausible';
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: ResolvedTag[]): OptionalConsentCategory[] {
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 gated tags are `type="text/plain"`, which is the whole trick: the browser
12
- * never parses the body as script and never fetches the library, so an undecided
13
- * or declining visitor's page issues no third-party request at all. Consent
14
- * implemented by *asking a tracker not to track* is a promise; this is a fact
15
- * about the document. `@getvitops/core/consent` (loaded by <Head />) activates
16
- * them when their category is granted and their strategy has fired.
17
- *
18
- * Emits no `preconnect` on purpose: warming a connection at parse time is exactly
19
- * the critical-path cost the `idle` strategy exists to avoid.
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.map((tag) => tag.gated
40
- ? (
41
- <script
42
- is:inline
43
- type="text/plain"
44
- data-vitops-tag
45
- data-consent={tag.category}
46
- data-strategy={tag.strategy}
47
- data-src={tag.src}
48
- data-consent-cookies={tag.cookies.length ? tag.cookies.join(',') : undefined}
49
- {...tag.attrs}
50
- set:html={tag.inline}
51
- />
52
- )
53
- : (
54
- <>
55
- {tag.inline && <script is:inline set:html={tag.inline} />}
56
- {tag.src && <script is:inline async src={tag.src} {...tag.attrs}></script>}
57
- </>
58
- )
59
- )}
36
+ <GatedTags tags={tags} />
@@ -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
- {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>
@@ -0,0 +1,46 @@
1
+ ---
2
+ /**
3
+ * <GatedTags /> — the one place a third-party tag becomes markup.
4
+ *
5
+ * Shared by <Analytics /> and <Ads />, and shared deliberately: the gate is
6
+ * `type="text/plain"` with the URL on `data-src`, so the browser never parses the
7
+ * body as script and never fetches the library. An undecided or declining
8
+ * visitor's page issues no third-party request at all. Consent implemented by
9
+ * *asking a tracker not to track* is a promise; this is a fact about the document
10
+ * — and a fact with two implementations is one rewrite away from being neither.
11
+ *
12
+ * Decides nothing. Every tag arrives fully resolved from `analytics.ts` or
13
+ * `ads.ts`, both of which are pure and unit-tested.
14
+ *
15
+ * Emits no `preconnect` on purpose: warming a connection at parse time is exactly
16
+ * the critical-path cost the `idle` strategy exists to avoid.
17
+ */
18
+ import type { GatedTag } from '../analytics.ts';
19
+
20
+ interface Props {
21
+ tags: GatedTag[];
22
+ }
23
+ const { tags } = Astro.props;
24
+ ---
25
+
26
+ {tags.map((tag) => tag.gated
27
+ ? (
28
+ <script
29
+ is:inline
30
+ type="text/plain"
31
+ data-vitops-tag
32
+ data-consent={tag.category}
33
+ data-strategy={tag.strategy}
34
+ data-src={tag.src}
35
+ data-consent-cookies={tag.cookies.length ? tag.cookies.join(',') : undefined}
36
+ {...tag.attrs}
37
+ set:html={tag.inline}
38
+ />
39
+ )
40
+ : (
41
+ <>
42
+ {tag.inline && <script is:inline set:html={tag.inline} />}
43
+ {tag.src && <script is:inline async src={tag.src} {...tag.attrs}></script>}
44
+ </>
45
+ )
46
+ )}