@getvitops/astro 4.1.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 +225 -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 +1 -51
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +77 -36
- 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 +11 -6
- package/src/components/Cards.astro +38 -24
- 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 +11 -1
- package/src/tracking.ts +13 -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": [
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"src/tracking.ts",
|
|
33
33
|
"src/components/Tree.astro",
|
|
34
34
|
"src/components/TreeNode.astro",
|
|
35
|
-
"src/components/tree.ts"
|
|
35
|
+
"src/components/tree.ts",
|
|
36
|
+
"src/exports/tracking.ts"
|
|
36
37
|
],
|
|
37
38
|
"type": "module",
|
|
38
39
|
"types": "./dist/index.d.mts",
|
|
@@ -45,6 +46,10 @@
|
|
|
45
46
|
"types": "./dist/routes/conversion.d.mts",
|
|
46
47
|
"import": "./dist/routes/conversion.mjs"
|
|
47
48
|
},
|
|
49
|
+
"./tracking": {
|
|
50
|
+
"types": "./dist/exports/tracking.d.mts",
|
|
51
|
+
"import": "./dist/exports/tracking.mjs"
|
|
52
|
+
},
|
|
48
53
|
"./Head.astro": "./src/components/Head.astro",
|
|
49
54
|
"./Seo.astro": "./src/components/Seo.astro",
|
|
50
55
|
"./Analytics.astro": "./src/components/Analytics.astro",
|
|
@@ -70,10 +75,10 @@
|
|
|
70
75
|
"access": "public"
|
|
71
76
|
},
|
|
72
77
|
"dependencies": {
|
|
73
|
-
"@getvitops/core": "
|
|
74
|
-
"@getvitops/generator": "
|
|
75
|
-
"@getvitops/
|
|
76
|
-
"@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"
|
|
77
82
|
},
|
|
78
83
|
"devDependencies": {
|
|
79
84
|
"@astrojs/sitemap": "^3.7.3",
|
|
@@ -1,34 +1,48 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* Cards — a `Subgrid` whose
|
|
4
|
-
*
|
|
3
|
+
* Cards — a `Subgrid` whose cards are clickable across their whole area, with their
|
|
4
|
+
* text still selectable.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* **This component emits its own `<wc-cards>`** — do NOT wrap it in one. That is the
|
|
7
|
+
* tier-3 rule ("the wrapper emits its `<wc-*>` tag with the accessible fallback
|
|
8
|
+
* inside"), and `Tree.astro` and `CookieConsent.astro` do the same.
|
|
9
|
+
* `<wc-cards><Cards /></wc-cards>` nests two elements governing the same cards; the
|
|
10
|
+
* inner one defers to the enclosing one so it degrades to a no-op rather than two
|
|
11
|
+
* forwarded clicks, but the single call is the intended shape.
|
|
12
|
+
*
|
|
13
|
+
* <Cards>
|
|
14
|
+
* <li class="card subgrid-card">
|
|
15
|
+
* <p class="font-eyebrow">Service</p>
|
|
16
|
+
* <p class="font-heading"><a class="link" href="/a">Title</a></p>
|
|
17
|
+
* <p>Blurb…</p>
|
|
18
|
+
* </li>
|
|
19
|
+
* </Cards>
|
|
20
|
+
*
|
|
21
|
+
* The fallback is the card's own link, so with no JS every card is still reachable —
|
|
22
|
+
* only the click-anywhere convenience is missing, and the pointer cursor comes from
|
|
23
|
+
* the element too, so the affordance never appears without the behaviour.
|
|
24
|
+
*
|
|
25
|
+
* **`<Subgrid>` is the unenhanced form**, which is why there is no `enhance={false}`
|
|
26
|
+
* prop: a flag would be a second way to say the same thing. Reach for
|
|
27
|
+
* `.stretched-link` instead when you want whole-card clicking with no JS at all and
|
|
28
|
+
* can accept that the card's text stops being selectable.
|
|
29
|
+
*
|
|
30
|
+
* It no longer parses the slot to add `class="card"`. That was its only job, and it is
|
|
31
|
+
* what forced `Subgrid` to rebuild every child as an `<li>` — see that file. Write the
|
|
32
|
+
* class on the item.
|
|
9
33
|
*/
|
|
10
34
|
import type { HTMLAttributes } from 'astro/types';
|
|
11
35
|
import Subgrid from './Subgrid.astro';
|
|
12
|
-
import { parseRenderedSlots, getAttribute, serialize } from '@getvitops/utils';
|
|
13
|
-
|
|
14
|
-
export interface Props extends HTMLAttributes<'ul'> {}
|
|
15
36
|
|
|
16
|
-
|
|
37
|
+
export interface Props extends HTMLAttributes<'ul'> {
|
|
38
|
+
as?: 'ul' | 'ol' | 'div';
|
|
39
|
+
/** Selector for the cards the element governs. Defaults to `.card`. */
|
|
40
|
+
item?: string;
|
|
41
|
+
}
|
|
17
42
|
|
|
18
|
-
const
|
|
19
|
-
const slots = nodes.filter(node => 'tagName' in node).map(node => ({
|
|
20
|
-
html: serialize(node),
|
|
21
|
-
class: getAttribute(node, 'class'),
|
|
22
|
-
style: getAttribute(node, 'style'),
|
|
23
|
-
}));
|
|
43
|
+
const { item, ...rest } = Astro.props;
|
|
24
44
|
---
|
|
25
45
|
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
{...rest}
|
|
30
|
-
>
|
|
31
|
-
{slots.map((slot) => (
|
|
32
|
-
<li class:list={["card", slot.class]} style={slot.style} set:html={slot.html}></li>
|
|
33
|
-
))}
|
|
34
|
-
</Subgrid>
|
|
46
|
+
<wc-cards item={item}>
|
|
47
|
+
<Subgrid {...rest}><slot /></Subgrid>
|
|
48
|
+
</wc-cards>
|
|
@@ -1,32 +1,50 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* Subgrid —
|
|
4
|
-
*
|
|
5
|
-
* lives in the framework's `subgrid`
|
|
6
|
-
*
|
|
3
|
+
* Subgrid — a grid whose items re-declare the parent's row tracks, so the tranches
|
|
4
|
+
* inside every item (head, body, footer) land on the same row lines no matter how
|
|
5
|
+
* much content each one holds. Every declaration lives in the framework's `subgrid`
|
|
6
|
+
* pattern (patterns/subgrid.css); this file is markup only.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* **The slot is rendered verbatim — author the items yourself.** This used to parse
|
|
9
|
+
* the slotted HTML and replace each child with an `<li>`, carrying over its class
|
|
10
|
+
* and style. That existed only because a `<ul>` may contain nothing but `<li>`, and
|
|
11
|
+
* it cost more than it bought: the child's tag was discarded, and every attribute
|
|
12
|
+
* had to be copied by hand, so `id`, `data-*` and `aria-*` were silently dropped and
|
|
13
|
+
* `href` could not survive at all. Passing the slot through means there is nothing to
|
|
14
|
+
* copy and nothing to lose.
|
|
12
15
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
+
* <Subgrid>
|
|
17
|
+
* <li class="card subgrid-card">…</li>
|
|
18
|
+
* </Subgrid>
|
|
19
|
+
*
|
|
20
|
+
* `as` picks the container. It defaults to `ul`, because a set of cards IS a list and
|
|
21
|
+
* that is what tells assistive tech how many there are and where you are among them.
|
|
22
|
+
* Use `as="div"` when the items are genuinely not a list.
|
|
23
|
+
*
|
|
24
|
+
* **`role="list"` is restored automatically for `ul`/`ol`.** `.subgrid` sets
|
|
25
|
+
* `list-style: none`, and that stops Safari + VoiceOver announcing a list at all — so
|
|
26
|
+
* the marker reset silently costs the semantics the `<ul>` was chosen for. Passing
|
|
27
|
+
* your own `role` wins.
|
|
28
|
+
*
|
|
29
|
+
* Size it from the consumer's own CSS, which keeps media-query overrides working:
|
|
30
|
+
* `--subgrid-cols`, `--subgrid-row-span`, `--subgrid-gap`, `--subgrid-row-gap`.
|
|
31
|
+
*
|
|
32
|
+
* For a card whose whole area should be clickable, see `<Cards />` (selectable text,
|
|
33
|
+
* needs JS) or `.stretched-link` (no JS, text not selectable).
|
|
16
34
|
*/
|
|
17
35
|
import type { HTMLAttributes } from 'astro/types';
|
|
18
|
-
import { parseRenderedSlots, getAttribute, serialize, styleList } from '@getvitops/utils';
|
|
19
36
|
|
|
20
|
-
export interface Props extends HTMLAttributes<'ul'> {
|
|
37
|
+
export interface Props extends HTMLAttributes<'ul'> {
|
|
38
|
+
/** Container element. `ul`/`ol` also get `role="list"`. */
|
|
39
|
+
as?: 'ul' | 'ol' | 'div';
|
|
40
|
+
}
|
|
21
41
|
|
|
22
|
-
const { class: className,
|
|
42
|
+
const { as: Tag = 'ul', class: className, role, ...rest } = Astro.props;
|
|
23
43
|
|
|
24
|
-
|
|
25
|
-
const
|
|
44
|
+
// Only lists lose semantics to `list-style: none`, so only lists get the role back.
|
|
45
|
+
const listRole = Tag === 'ul' || Tag === 'ol' ? 'list' : undefined;
|
|
26
46
|
---
|
|
27
47
|
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
))}
|
|
32
|
-
</ul>
|
|
48
|
+
<Tag class:list={['subgrid', className]} role={role ?? listRole} {...rest}>
|
|
49
|
+
<slot />
|
|
50
|
+
</Tag>
|
|
@@ -65,14 +65,14 @@ const {
|
|
|
65
65
|
{
|
|
66
66
|
enhance ? (
|
|
67
67
|
<wc-tree label={label} {...rest}>
|
|
68
|
-
<ul class="tree tree--lines" aria-label={ariaLabel}>
|
|
68
|
+
<ul class="tree tree--lines" role="list" aria-label={ariaLabel}>
|
|
69
69
|
{items.map((item) => (
|
|
70
70
|
<TreeNode item={item} depth={0} openDepth={openDepth} />
|
|
71
71
|
))}
|
|
72
72
|
</ul>
|
|
73
73
|
</wc-tree>
|
|
74
74
|
) : (
|
|
75
|
-
<ul class="tree tree--lines" aria-label={ariaLabel}>
|
|
75
|
+
<ul class="tree tree--lines" role="list" aria-label={ariaLabel}>
|
|
76
76
|
{items.map((item) => (
|
|
77
77
|
<TreeNode item={item} depth={0} openDepth={openDepth} />
|
|
78
78
|
))}
|
|
@@ -41,7 +41,7 @@ const isBranch = kids.length > 0;
|
|
|
41
41
|
</span>
|
|
42
42
|
</summary>
|
|
43
43
|
{item.descriptionHtml && <p class="tree__desc" set:html={item.descriptionHtml} />}
|
|
44
|
-
<ul class="tree">
|
|
44
|
+
<ul class="tree" role="list">
|
|
45
45
|
{kids.map((kid) => (
|
|
46
46
|
<Astro.self item={kid} depth={depth + 1} openDepth={openDepth} />
|
|
47
47
|
))}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@getvitops/astro/tracking` — the attribution vocabulary, re-exported.
|
|
3
|
+
*
|
|
4
|
+
* The documented conversion flow spans two packages: `<Tracking />` captures the
|
|
5
|
+
* click ID from `@getvitops/astro`, and the route reads it back with
|
|
6
|
+
* `parseTrackingCookie` from `@getvitops/utils/tracking`. Under strict pnpm a
|
|
7
|
+
* consumer cannot resolve the second from app code without adding
|
|
8
|
+
* `@getvitops/utils` as a direct dependency — so the flow as written in the
|
|
9
|
+
* changelog needed two installs, and the obvious workaround (importing the same
|
|
10
|
+
* symbols from `@getvitops/astro`'s index) is the one that drags the whole
|
|
11
|
+
* integration, and its Node builtins, toward a Worker bundle.
|
|
12
|
+
*
|
|
13
|
+
* This entry is the third option and the correct one: one install, and a module
|
|
14
|
+
* graph that reaches only `@getvitops/utils/tracking` — no `sharp`, no
|
|
15
|
+
* `node:child_process`, nothing that cannot run in a Worker. It is a separate
|
|
16
|
+
* build entry for exactly the reason `./routes` is.
|
|
17
|
+
*/
|
|
18
|
+
export * from '@getvitops/utils/tracking';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where the capture script beacons `tel:` conversions.
|
|
22
|
+
*
|
|
23
|
+
* Re-exported here so a consumer writing the route can assert against the same
|
|
24
|
+
* constant the script uses, rather than retyping the path.
|
|
25
|
+
*/
|
|
26
|
+
export { TRACKING_ENDPOINT } from '../tracking.ts';
|
package/src/scripts/tracking.ts
CHANGED
|
@@ -26,6 +26,16 @@ const COOKIE_NAME = '_ac';
|
|
|
26
26
|
const COOKIE_DAYS = 90;
|
|
27
27
|
const AB_COOKIE = '_ab';
|
|
28
28
|
|
|
29
|
+
// Where the `tel:` conversion beacon goes. Mirrors TRACKING_ENDPOINT in
|
|
30
|
+
// ../tracking.ts (pinned by `tracking.test.ts`), copied for the same reason as
|
|
31
|
+
// the parameter lists below — this file is inlined into the document.
|
|
32
|
+
//
|
|
33
|
+
// It was previously a bare literal at the call site, which made the contract
|
|
34
|
+
// between this script and the route the consumer writes invisible: name the
|
|
35
|
+
// file anything else and every beacon 404s, conversions vanish, and nothing
|
|
36
|
+
// errors anywhere. The integration now warns at build when the route is absent.
|
|
37
|
+
const TRACK_ENDPOINT = '/api/track';
|
|
38
|
+
|
|
29
39
|
// Mirrors CLICK_ID_PARAMS/UTM_PARAMS in @getvitops/utils/tracking, deliberately
|
|
30
40
|
// copied rather than imported: this file is inlined into the document by
|
|
31
41
|
// <Tracking />, so a bare import would either fail or drag a module graph into
|
|
@@ -144,6 +154,6 @@ document.addEventListener('click', (e) => {
|
|
|
144
154
|
|
|
145
155
|
// Fire-and-forget beacon; don't block the tel: navigation
|
|
146
156
|
if (navigator.sendBeacon) {
|
|
147
|
-
navigator.sendBeacon(
|
|
157
|
+
navigator.sendBeacon(TRACK_ENDPOINT, JSON.stringify({ event: 'call', phone: link.href }));
|
|
148
158
|
}
|
|
149
159
|
});
|
package/src/tracking.ts
CHANGED
|
@@ -27,6 +27,19 @@ export interface GetvitopsTrackingOptions {
|
|
|
27
27
|
/** The cookie the capture script writes. Mirrors `TRACKING_COOKIE` in utils. */
|
|
28
28
|
export const TRACKING_COOKIE = '_ac';
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Where the capture script sends its `tel:` conversion beacon.
|
|
32
|
+
*
|
|
33
|
+
* The route itself is the consumer's to write (`createConversionRoute()` in
|
|
34
|
+
* `@getvitops/astro/routes`), so this is the one place the two halves agree on
|
|
35
|
+
* a path. It was a bare literal inside the inlined script: a consumer who named
|
|
36
|
+
* the file anything else got a silent 404 on every beacon and lost conversions
|
|
37
|
+
* with no error anywhere to explain it. Exported so a consumer can assert
|
|
38
|
+
* against it, mirrored in `scripts/tracking.ts` because that file is inlined
|
|
39
|
+
* into the document and cannot import, and pinned by `tracking.test.ts`.
|
|
40
|
+
*/
|
|
41
|
+
export const TRACKING_ENDPOINT = '/api/track';
|
|
42
|
+
|
|
30
43
|
export interface ResolvedTracking {
|
|
31
44
|
enabled: boolean;
|
|
32
45
|
category: TrackingCategory;
|