@getvitops/astro 4.1.0 → 6.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.
@@ -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
- * `(locals) => locals.runtime.env.EMAIL`. Omit it in dev and the notification
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":";;;;;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"}
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: toNotifyContext(config),
27
- * binding: (locals) => locals.runtime?.env?.EMAIL,
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 { 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"}
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": "4.1.0",
3
+ "version": "6.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": [
@@ -16,6 +16,7 @@
16
16
  "src/ads.ts",
17
17
  "src/components/Subgrid.astro",
18
18
  "src/components/Cards.astro",
19
+ "src/components/Carousel.astro",
19
20
  "src/components/NodeRenderer.astro",
20
21
  "src/components/WebComponentLoader.astro",
21
22
  "src/components/Icon.astro",
@@ -32,7 +33,8 @@
32
33
  "src/tracking.ts",
33
34
  "src/components/Tree.astro",
34
35
  "src/components/TreeNode.astro",
35
- "src/components/tree.ts"
36
+ "src/components/tree.ts",
37
+ "src/exports/tracking.ts"
36
38
  ],
37
39
  "type": "module",
38
40
  "types": "./dist/index.d.mts",
@@ -45,6 +47,10 @@
45
47
  "types": "./dist/routes/conversion.d.mts",
46
48
  "import": "./dist/routes/conversion.mjs"
47
49
  },
50
+ "./tracking": {
51
+ "types": "./dist/exports/tracking.d.mts",
52
+ "import": "./dist/exports/tracking.mjs"
53
+ },
48
54
  "./Head.astro": "./src/components/Head.astro",
49
55
  "./Seo.astro": "./src/components/Seo.astro",
50
56
  "./Analytics.astro": "./src/components/Analytics.astro",
@@ -53,6 +59,7 @@
53
59
  "./Tracking.astro": "./src/components/Tracking.astro",
54
60
  "./components/Subgrid.astro": "./src/components/Subgrid.astro",
55
61
  "./components/Cards.astro": "./src/components/Cards.astro",
62
+ "./components/Carousel.astro": "./src/components/Carousel.astro",
56
63
  "./components/NodeRenderer.astro": "./src/components/NodeRenderer.astro",
57
64
  "./components/WebComponentLoader.astro": "./src/components/WebComponentLoader.astro",
58
65
  "./components/Icon.astro": "./src/components/Icon.astro",
@@ -70,10 +77,10 @@
70
77
  "access": "public"
71
78
  },
72
79
  "dependencies": {
73
- "@getvitops/core": "4.1.0",
74
- "@getvitops/generator": "4.1.0",
75
- "@getvitops/vite": "4.1.0",
76
- "@getvitops/utils": "4.1.0"
80
+ "@getvitops/core": "6.0.0",
81
+ "@getvitops/generator": "6.0.0",
82
+ "@getvitops/utils": "6.0.0",
83
+ "@getvitops/vite": "6.0.0"
77
84
  },
78
85
  "devDependencies": {
79
86
  "@astrojs/sitemap": "^3.7.3",
@@ -1,34 +1,48 @@
1
1
  ---
2
2
  /**
3
- * Cards — a `Subgrid` whose items are `card`s. The child's own class and style
4
- * ride along, so `<article class="bg-surface-muted">` becomes `<li class="card bg-surface-muted">`.
3
+ * Cards — a `Subgrid` whose cards are clickable across their whole area, with their
4
+ * text still selectable.
5
5
  *
6
- * The class has to be put on the `<li>` we hand to Subgrid rather than written
7
- * into the slotted element: `serialize()` returns a node's *inner* HTML, so the
8
- * element carrying the attribute is discarded on the way through.
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
- const { class: className, ...rest } = Astro.props;
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 nodes = parseRenderedSlots(await Astro.slots.render('default'));
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
- <Subgrid
27
- role="list"
28
- class={className}
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>
@@ -0,0 +1,230 @@
1
+ ---
2
+ /**
3
+ * A scroll-snap carousel that works with no JavaScript, in every engine.
4
+ *
5
+ * Tier-3 in the framework's terms — an authoring convenience that emits the right
6
+ * `.carousel` markup and requires **no runtime JS**. What it renders stands on its
7
+ * own:
8
+ *
9
+ * - the slides are a real `<ul>`/`<li>`, so assistive tech announces list
10
+ * position and count;
11
+ * - the scroller carries `tabindex="0"`, so arrow keys scroll it — the keyboard
12
+ * route the platform gives any focusable scroll container, and the reason
13
+ * inclusive-components.design/a-content-slider/ focuses the scroller rather
14
+ * than building a widget;
15
+ * - `scroll-snap` and (in Chromium today) `::scroll-button()` / `::scroll-marker`
16
+ * give buttons and dots with no script at all;
17
+ * - a visible scrollbar and a "Scroll for more" hint say the strip scrolls, which
18
+ * was the missing signal wherever those pseudo-elements are absent;
19
+ * - off-screen images are lazy **without JS**: `loading="lazy"` is honoured for
20
+ * images clipped by a horizontal scroll container, so there is no `data-src` +
21
+ * IntersectionObserver dance to fail.
22
+ *
23
+ * **This component emits its own `<wc-carousel>`** — do NOT wrap it in one. That is
24
+ * the tier-3 rule ("the wrapper emits its `<wc-*>` tag with the accessible fallback
25
+ * inside"), and `Cards.astro`, `Tree.astro` and `CookieConsent.astro` all do the
26
+ * same. The element defers to an enclosing one, so a nested pair degrades to a
27
+ * no-op rather than cloning the same slides twice, but the single call is the
28
+ * intended shape.
29
+ *
30
+ * `<wc-carousel>` adds the opt-in cloned loop, autoplay, and — only where the
31
+ * browser lacks the scroll-marker pseudo-elements — real prev/next buttons and a
32
+ * real dot nav. If it never loads, that enhancement is all that is missing, which
33
+ * is why `enhance={false}` is a supported way to get the bare fragment.
34
+ *
35
+ * Three deliberate a11y choices:
36
+ *
37
+ * - **The group role is on the shell, the list role stays on the `<ul>`.** The
38
+ * inclusive-components article puts `role="region"` on the scroller because
39
+ * there the scroller *is* the container; a shell lets us have real list
40
+ * semantics *and* a named group. `group` rather than `region` because `region`
41
+ * is a landmark and four carousels would grow four landmark entries — pass
42
+ * `landmark` when the carousel really is a page-level landmark.
43
+ * - **The visible hint is `aria-hidden`, and a separate `.sr-only` element is the
44
+ * description.** The hint fades once the visitor scrolls, so as a description
45
+ * it would go stale; and its text is about a pointer affordance, whereas what a
46
+ * keyboard user needs described is the arrow keys. One string cannot honestly
47
+ * be both.
48
+ * - **`aria-describedby` sits on the `<ul>`**, which is the focusable thing, since
49
+ * a description is announced on focus.
50
+ */
51
+ import type { HTMLAttributes } from 'astro/types';
52
+
53
+ export interface CarouselSlide {
54
+ src: string;
55
+ alt: string;
56
+ /**
57
+ * Intrinsic size. Emitted as `width`/`height` so the UA derives an aspect ratio
58
+ * and the slide reserves space before the image lands. When unknown,
59
+ * `--carousel-slide-aspect` still reserves the box, so CLS is covered either way.
60
+ */
61
+ width?: number;
62
+ height?: number;
63
+ srcset?: string;
64
+ sizes?: string;
65
+ caption?: string;
66
+ }
67
+
68
+ export interface Props extends Omit<HTMLAttributes<'div'>, 'slot'> {
69
+ /** Accessible name. Required — an unnamed group is unfindable. */
70
+ label: string;
71
+ /** Image slides. Omit and use the default slot for richer slides. */
72
+ slides?: CarouselSlide[];
73
+ /** Milliseconds between slides. Implies `loop`. */
74
+ autoplay?: number;
75
+ /**
76
+ * Clone the slides for a seamless infinite strip. **Off by default:** it triples
77
+ * the DOM and duplicates every image element, makes the scrollbar report 3× the
78
+ * real content, and most carousels do not want it.
79
+ */
80
+ loop?: boolean;
81
+ /** Prev/next controls. */
82
+ buttons?: boolean;
83
+ /** Dot navigation. */
84
+ markers?: boolean;
85
+ /** Dots below the strip rather than overlaid on it. */
86
+ markersBelow?: boolean;
87
+ /** CSS multicol paging. Chromium-only; degrades to a plain strip elsewhere. */
88
+ columns?: number;
89
+ /** Dim and inert the off-screen slides. */
90
+ inert?: boolean;
91
+ /** `scroll-snap-stop: always` — never skip a slide in one fling. */
92
+ forceStop?: boolean;
93
+ /** Hide the scrollbar. Costs the no-JS "this scrolls" signal; be deliberate. */
94
+ noScrollbar?: boolean;
95
+ /** `--carousel-slide-aspect`, the ratio every slide's media box takes. */
96
+ aspect?: string;
97
+ /** Visible affordance text. `false` removes it. */
98
+ hint?: string | false;
99
+ /** Expose as a landmark (`role="region"`) rather than `role="group"`. */
100
+ landmark?: boolean;
101
+ /**
102
+ * Emit the `<wc-carousel>` wrapper (default `true`).
103
+ *
104
+ * Set false where the element cannot run — an email, a PDF, a page that does not
105
+ * load `elements.js`. The carousel is unchanged; only the enhancement layer goes,
106
+ * so nothing is lost but the loop, autoplay and the non-Chromium control set.
107
+ */
108
+ enhance?: boolean;
109
+ /**
110
+ * Id prefix for the slide ids and the description. Defaults to a slug of
111
+ * `label`. Astro has no `useId`, and a random one would change on every build —
112
+ * so this is derived, deterministic and overridable when two carousels on a page
113
+ * share a label.
114
+ */
115
+ id?: string;
116
+ }
117
+
118
+ const {
119
+ label,
120
+ slides = [],
121
+ autoplay,
122
+ loop = false,
123
+ buttons = true,
124
+ markers = true,
125
+ markersBelow = false,
126
+ columns,
127
+ inert = false,
128
+ forceStop = false,
129
+ noScrollbar = false,
130
+ aspect = '16 / 9',
131
+ hint = 'Scroll for more',
132
+ landmark = false,
133
+ enhance = true,
134
+ id,
135
+ class: className,
136
+ role,
137
+ style,
138
+ ...rest
139
+ } = Astro.props;
140
+
141
+ const uid =
142
+ id ??
143
+ `carousel-${
144
+ label
145
+ .toLowerCase()
146
+ .replace(/[^a-z0-9]+/g, '-')
147
+ .replace(/^-|-$/g, '') || 'x'
148
+ }`;
149
+
150
+ const classes = [
151
+ 'carousel',
152
+ buttons && 'carousel--scroll-buttons',
153
+ markers && 'carousel--scroll-markers',
154
+ markersBelow && 'carousel--markers-below',
155
+ columns && 'carousel--auto-pages',
156
+ inert && 'carousel--inert',
157
+ forceStop && 'carousel--force-stop',
158
+ noScrollbar && 'carousel--no-scrollbar',
159
+ className,
160
+ ];
161
+
162
+ const vars = [
163
+ `--carousel-slide-aspect:${aspect}`,
164
+ columns ? `--carousel-columns:${columns}` : '',
165
+ style ?? '',
166
+ ]
167
+ .filter(Boolean)
168
+ .join(';');
169
+
170
+ const Shell = enhance ? 'wc-carousel' : 'div';
171
+ ---
172
+
173
+ <Shell
174
+ class:list={classes}
175
+ role={role ?? (landmark ? 'region' : 'group')}
176
+ aria-roledescription="carousel"
177
+ aria-label={label}
178
+ loop={enhance && (loop || (autoplay ?? 0) > 0) ? '' : undefined}
179
+ autoplay={enhance && autoplay ? autoplay : undefined}
180
+ style={vars}
181
+ {...rest}
182
+ >
183
+ <ul
184
+ class="carousel__track"
185
+ role="list"
186
+ tabindex="0"
187
+ aria-label={label}
188
+ aria-describedby={`${uid}-desc`}
189
+ >
190
+ {
191
+ slides.map((slide, i) => (
192
+ <li class="carousel__slide" id={`${uid}-slide-${i + 1}`}>
193
+ <figure class="carousel__media">
194
+ {/* The first slide is the LCP candidate, so it is eager and high
195
+ priority; every later one is lazy, low and async. A horizontal
196
+ scroll container clips them, and `loading="lazy"` is honoured for
197
+ that, so this is genuinely JS-free deferral. `decoding` is left
198
+ unset on the first: `sync` can block the main thread, and the UA's
199
+ own choice for an eager LCP image is better than ours. */}
200
+ <img
201
+ src={slide.src}
202
+ alt={slide.alt}
203
+ srcset={slide.srcset}
204
+ sizes={slide.sizes}
205
+ width={slide.width}
206
+ height={slide.height}
207
+ loading={i === 0 ? 'eager' : 'lazy'}
208
+ fetchpriority={i === 0 ? 'high' : 'low'}
209
+ decoding={i === 0 ? undefined : 'async'}
210
+ />
211
+ {slide.caption && (
212
+ <figcaption class="carousel__caption font-caption">{slide.caption}</figcaption>
213
+ )}
214
+ </figure>
215
+ </li>
216
+ ))
217
+ }
218
+ <slot />
219
+ </ul>
220
+ {
221
+ hint !== false && (
222
+ <p class="carousel__hint font-footnote" aria-hidden="true">
223
+ {hint}
224
+ </p>
225
+ )
226
+ }
227
+ <p id={`${uid}-desc`} class="sr-only">
228
+ Use the left and right arrow keys, or scroll sideways, to move between slides.
229
+ </p>
230
+ </Shell>