@gradial/aci 0.1.41 → 0.1.42

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.
Files changed (102) hide show
  1. package/CHANGELOG.md +9 -13
  2. package/README.md +4 -4
  3. package/dist/astro/index.d.ts +1 -1
  4. package/dist/astro/index.js +1 -1
  5. package/dist/astro/page.d.ts +6 -0
  6. package/dist/astro/page.js +3 -2
  7. package/dist/cli/compile-registry.d.mts +2 -0
  8. package/dist/cli/compile-registry.mjs +759 -0
  9. package/dist/cli/materialize-template.d.mts +19 -0
  10. package/dist/cli/materialize-template.mjs +128 -0
  11. package/dist/cli/validate-content.d.mts +2 -0
  12. package/dist/cli/validate-content.mjs +426 -0
  13. package/dist/cli/verify-next-cdn-routes.d.mts +2 -0
  14. package/dist/cli/verify-next-cdn-routes.mjs +47 -0
  15. package/dist/collect/assignments.d.ts +65 -0
  16. package/dist/collect/assignments.js +138 -0
  17. package/dist/collect/batcher.d.ts +21 -0
  18. package/dist/collect/batcher.js +10 -0
  19. package/dist/collect/declarative.d.ts +1 -1
  20. package/dist/collect/declarative.js +7 -1
  21. package/dist/collect/index.js +30 -4
  22. package/dist/collect/pageviews.d.ts +15 -3
  23. package/dist/collect/pageviews.js +28 -13
  24. package/dist/collect/transport.d.ts +2 -0
  25. package/dist/collect/transport.js +32 -0
  26. package/dist/consent-banner/index.d.ts +33 -0
  27. package/dist/consent-banner/index.js +99 -0
  28. package/dist/content/personalization.d.ts +35 -0
  29. package/dist/content/personalization.js +57 -0
  30. package/dist/content/types.d.ts +7 -1
  31. package/dist/edge/personalization-core.d.ts +11 -0
  32. package/dist/edge/personalization-core.js +265 -0
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.js +1 -0
  35. package/dist/next/assignments-script.d.ts +14 -0
  36. package/dist/next/assignments-script.js +17 -0
  37. package/dist/next/collect-route.d.ts +19 -0
  38. package/dist/next/collect-route.js +351 -0
  39. package/dist/next/index.d.ts +1 -0
  40. package/dist/next/index.js +1 -0
  41. package/dist/next/middleware.d.ts +15 -1
  42. package/dist/next/middleware.js +71 -27
  43. package/dist/next/page.js +2 -1
  44. package/dist/next/personalization-host.d.ts +118 -0
  45. package/dist/next/personalization-host.js +276 -0
  46. package/dist/next/server.js +6 -6
  47. package/dist/next/vercel-personalization.d.ts +3 -0
  48. package/dist/next/vercel-personalization.js +3 -0
  49. package/dist/next/vercel-s3-personalization.d.ts +58 -0
  50. package/dist/next/vercel-s3-personalization.js +220 -0
  51. package/dist/providers/s3.d.ts +1 -1
  52. package/dist/providers/s3.js +1 -1
  53. package/dist/react/VideoPlayer.d.ts +6 -6
  54. package/dist/react/index.d.ts +6 -6
  55. package/dist/types/page.d.ts +1 -1
  56. package/package.json +19 -1
  57. package/src/astro/ConsentBanner.astro +62 -0
  58. package/src/cli/verify-next-cdn-routes.mjs +5 -4
  59. package/src/consent-banner/styles.css +110 -0
  60. package/src/types/page.ts +1 -1
  61. package/templates/astro/starter-content/src/cms/contracts/components/index.ts +2 -0
  62. package/templates/astro/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  63. package/templates/astro/starter-content/src/cms/registry.ts +3 -0
  64. package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +41 -0
  65. package/templates/astro/starter-content/src/render/RenderPage.astro +3 -2
  66. package/templates/astro/template/src/layouts/SiteLayout.astro +3 -1
  67. package/templates/astro/template/src/pages/[...slug].astro +1 -0
  68. package/templates/common/.content/audiences/preference-blue/_index.json +11 -0
  69. package/templates/common/.content/audiences/preference-red/_index.json +11 -0
  70. package/templates/common/.content/overlays/audience/preference-blue/pages/home/_index.json +13 -0
  71. package/templates/common/.content/overlays/audience/preference-red/pages/home/_index.json +13 -0
  72. package/templates/common/.content/pages/home/_index.json +114 -4
  73. package/templates/common/.content/segments/preference-blue/_index.json +20 -0
  74. package/templates/common/.content/segments/preference-red/_index.json +20 -0
  75. package/templates/common/contracts/components/index.ts +21 -1
  76. package/templates/common/contracts/components/personalizationChoice.contract.ts +17 -0
  77. package/templates/nextjs/starter-content/src/cms/contracts/components/index.ts +3 -1
  78. package/templates/nextjs/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  79. package/templates/nextjs/starter-content/src/cms/registry.ts +3 -0
  80. package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +53 -0
  81. package/templates/nextjs/starter-content/src/middleware.ts +11 -0
  82. package/templates/nextjs/template/src/app/collect/route.ts +18 -0
  83. package/templates/nextjs/template/src/middleware.ts +6 -2
  84. package/templates/astro/template/.aci.yaml +0 -28
  85. package/templates/common/.content/.gradial-dam/blobs/sha256/7f/bf/7fbfc7fe230ff12688c496f660119e41d9725c4504d82e310c15665c73c09292 +0 -0
  86. package/templates/common/.content/.gradial-dam/blobs/sha256/a0/f5/a0f506689de9b15a7055f70103a3f02cd3ba105771ae2de6754df2ede1210d3a +0 -0
  87. package/templates/common/.content/.gradial-dam/blobs/sha256/dd/b7/ddb7e9642be08317bcd4b2bd26b58ba8d0d34dd7239e03e684a4f3634fa3845b +0 -0
  88. package/templates/common/.content/.gradial-dam/blobs/sha256/fb/32/fb3260e8286ea8231ea1c77b9c0be92d6edd9026565731eb14cfdcfd6b1b75b3 +0 -0
  89. package/templates/common/.content/config/robots.txt +0 -2
  90. package/templates/common/.content/config/site.json +0 -197
  91. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/asset.json +0 -9
  92. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/editorial.json +0 -14
  93. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/governance.json +0 -23
  94. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/versions/v0001.json +0 -37
  95. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/asset.json +0 -9
  96. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/editorial.json +0 -14
  97. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/governance.json +0 -23
  98. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/versions/v0001.json +0 -37
  99. package/templates/common/.content/fragments/navbar/_index.json +0 -25
  100. package/templates/common/.content/fragments/site-footer/_index.json +0 -62
  101. package/templates/common/.content/pages/about/_index.json +0 -51
  102. package/templates/nextjs/template/.aci.yaml +0 -26
@@ -0,0 +1,65 @@
1
+ import type { PendingEvent } from "./batcher.js";
2
+ import type { DebugFn } from "./config.js";
3
+ export declare const EXPERIMENT_EXPOSURE_EVENT = "experiment.exposure";
4
+ /** One active assignment read from the page's data-aci-assignments tag. */
5
+ export interface ExperimentAssignment {
6
+ experimentId: string;
7
+ armId: string;
8
+ allocationVersion: string;
9
+ }
10
+ interface DocumentLike {
11
+ querySelector(selectors: string): {
12
+ textContent: string | null;
13
+ } | null;
14
+ }
15
+ /**
16
+ * readAssignments parses the page's data-aci-assignments tag. Absence, a
17
+ * malformed body, or an unknown version are tolerated as "no assignments"
18
+ * (debug record, no throw).
19
+ */
20
+ export declare function readAssignments(doc: DocumentLike, debug?: DebugFn): ExperimentAssignment[];
21
+ export interface AssignmentsEnv {
22
+ document: DocumentLike;
23
+ /** Only location.href is read (the exposure event's url). */
24
+ window: {
25
+ location: {
26
+ href: string;
27
+ };
28
+ };
29
+ debug?: DebugFn;
30
+ }
31
+ export interface AssignmentsHandle {
32
+ /** The assignments active for the current page view (copies). */
33
+ getActiveAssignments(): ExperimentAssignment[];
34
+ /**
35
+ * start reads the assignments tag and emits `experiment.exposure` once per
36
+ * (experiment, page view) through `emit`. Called once by init AFTER the
37
+ * emit pipeline is fully constructed — the handle is created first so
38
+ * emit's withAssignmentContext dependency exists before emit can run.
39
+ */
40
+ start(emit: (event: PendingEvent) => void): void;
41
+ /**
42
+ * rearm resets the per-page-view exposure dedupe and re-reads the tag.
43
+ * Wired by init into the shared navigation hook, ORDERED BEFORE the SPA
44
+ * page-view emitter so rum.page_view is stamped with the new page's
45
+ * context (REQ-2/3; see the module-header stale-tag limitation).
46
+ */
47
+ rearm(): void;
48
+ }
49
+ /**
50
+ * withAssignmentContext stamps the first active assignment's experiment
51
+ * context onto an event that does not already carry one (REQ-3). Events with
52
+ * explicit context (exposures, explicit conversions) are never overwritten.
53
+ */
54
+ export declare function withAssignmentContext(event: PendingEvent, handle: AssignmentsHandle | undefined): PendingEvent;
55
+ /**
56
+ * createAssignments builds the assignments collector without emitting
57
+ * anything: creation and exposure emission are split so init can construct
58
+ * the emit pipeline against the handle before any event flows (no circular
59
+ * "emit captures a not-yet-assigned handle" wiring). start() reads the tag
60
+ * and emits exposures; rearm() is the SPA-navigation reset, fanned out from
61
+ * the single navigation hook installed by init — so an "exposure per page
62
+ * view" matches a "page view" exactly.
63
+ */
64
+ export declare function createAssignments(env: AssignmentsEnv): AssignmentsHandle;
65
+ export {};
@@ -0,0 +1,138 @@
1
+ // Experiment assignments (personalization-reporting design/04/05): the
2
+ // starters render the page's build-time assignment context as a
3
+ // `<script type="application/json" data-aci-assignments>` JSON tag
4
+ // ({"v":1,"experiments":[{experimentId,armId,allocationVersion}]}). This
5
+ // collector parses it, emits `experiment.exposure` at most once per
6
+ // (experiment, page view) (REQ-2), and exposes the active assignments so the
7
+ // emit path can stamp experiment context on every event while an assignment
8
+ // is active (REQ-3). Absent or malformed tags are a silent no-op with a
9
+ // debug record; the page is never affected.
10
+ //
11
+ // KNOWN LIMITATION — SPA client-side routing reads a stale tag. The re-arm
12
+ // (AssignmentsHandle.rearm, wired to the shared navigation hook in init)
13
+ // runs synchronously when pushState/replaceState returns, but client
14
+ // routers (e.g. the Next app router) swap the page's DOM — including the
15
+ // assignments tag — AFTER pushState returns. The re-arm therefore reads the
16
+ // PREVIOUS page's tag. The design assumption is MPA / full-SSR-first
17
+ // navigation, where every page view is a fresh document load and the tag is
18
+ // always current; under pure client-side transitions, exposures and context
19
+ // stamping for the newly routed page lag one navigation behind. Accepted
20
+ // for now — do not "fix" with a MutationObserver.
21
+ export const EXPERIMENT_EXPOSURE_EVENT = "experiment.exposure";
22
+ const ASSIGNMENTS_SELECTOR = 'script[type="application/json"][data-aci-assignments]';
23
+ const ASSIGNMENTS_VERSION = 1;
24
+ function parseAssignment(value) {
25
+ if (typeof value !== "object" || value === null)
26
+ return null;
27
+ const record = value;
28
+ const experimentId = record.experimentId;
29
+ const armId = record.armId;
30
+ const allocationVersion = record.allocationVersion;
31
+ if (typeof experimentId !== "string" ||
32
+ experimentId === "" ||
33
+ typeof armId !== "string" ||
34
+ armId === "" ||
35
+ typeof allocationVersion !== "string") {
36
+ return null;
37
+ }
38
+ return { experimentId, armId, allocationVersion };
39
+ }
40
+ /**
41
+ * readAssignments parses the page's data-aci-assignments tag. Absence, a
42
+ * malformed body, or an unknown version are tolerated as "no assignments"
43
+ * (debug record, no throw).
44
+ */
45
+ export function readAssignments(doc, debug) {
46
+ try {
47
+ const tag = doc.querySelector(ASSIGNMENTS_SELECTOR);
48
+ if (!tag)
49
+ return [];
50
+ const raw = tag.textContent;
51
+ if (raw == null || raw.trim() === "")
52
+ return [];
53
+ const parsed = JSON.parse(raw);
54
+ if (typeof parsed !== "object" || parsed === null) {
55
+ debug?.("capture", { reason: "assignments_malformed" });
56
+ return [];
57
+ }
58
+ const body = parsed;
59
+ if (body.v !== ASSIGNMENTS_VERSION || !Array.isArray(body.experiments)) {
60
+ debug?.("capture", { reason: "assignments_malformed" });
61
+ return [];
62
+ }
63
+ const assignments = [];
64
+ for (const entry of body.experiments) {
65
+ const assignment = parseAssignment(entry);
66
+ if (assignment)
67
+ assignments.push(assignment);
68
+ }
69
+ return assignments;
70
+ }
71
+ catch (err) {
72
+ debug?.("capture", {
73
+ reason: "assignments_parse_failed",
74
+ error: String(err),
75
+ });
76
+ return [];
77
+ }
78
+ }
79
+ /**
80
+ * withAssignmentContext stamps the first active assignment's experiment
81
+ * context onto an event that does not already carry one (REQ-3). Events with
82
+ * explicit context (exposures, explicit conversions) are never overwritten.
83
+ */
84
+ export function withAssignmentContext(event, handle) {
85
+ if (!handle || event.experiment !== undefined)
86
+ return event;
87
+ const first = handle.getActiveAssignments()[0];
88
+ if (!first)
89
+ return event;
90
+ const experiment = {
91
+ experimentId: first.experimentId,
92
+ armId: first.armId,
93
+ allocationVersion: first.allocationVersion,
94
+ };
95
+ return { ...event, experiment };
96
+ }
97
+ /**
98
+ * createAssignments builds the assignments collector without emitting
99
+ * anything: creation and exposure emission are split so init can construct
100
+ * the emit pipeline against the handle before any event flows (no circular
101
+ * "emit captures a not-yet-assigned handle" wiring). start() reads the tag
102
+ * and emits exposures; rearm() is the SPA-navigation reset, fanned out from
103
+ * the single navigation hook installed by init — so an "exposure per page
104
+ * view" matches a "page view" exactly.
105
+ */
106
+ export function createAssignments(env) {
107
+ let active = [];
108
+ const exposed = new Set();
109
+ let emit;
110
+ const arm = () => {
111
+ active = readAssignments(env.document, env.debug);
112
+ for (const assignment of active) {
113
+ if (exposed.has(assignment.experimentId))
114
+ continue;
115
+ exposed.add(assignment.experimentId);
116
+ emit?.({
117
+ eventName: EXPERIMENT_EXPOSURE_EVENT,
118
+ url: env.window.location.href,
119
+ experiment: {
120
+ experimentId: assignment.experimentId,
121
+ armId: assignment.armId,
122
+ allocationVersion: assignment.allocationVersion,
123
+ },
124
+ });
125
+ }
126
+ };
127
+ return {
128
+ getActiveAssignments: () => active.map((assignment) => ({ ...assignment })),
129
+ start(emitFn) {
130
+ emit = emitFn;
131
+ arm();
132
+ },
133
+ rearm() {
134
+ exposed.clear();
135
+ arm();
136
+ },
137
+ };
138
+ }
@@ -1,4 +1,16 @@
1
1
  import type { DebugFn } from "./config.js";
2
+ /**
3
+ * Structured experiment context (personalization-reporting REQ-1/REQ-3):
4
+ * populated from the page's data-aci-assignments tag so conversions are
5
+ * attributable to (experiment, arm) at ingest. Explicit conversions may add
6
+ * goalId.
7
+ */
8
+ export interface ExperimentContext {
9
+ experimentId: string;
10
+ armId: string;
11
+ allocationVersion: string;
12
+ goalId?: string;
13
+ }
2
14
  /** One collect event on the wire (personalization-data-plane design/04). */
3
15
  export interface CollectEvent {
4
16
  eventId: string;
@@ -7,11 +19,20 @@ export interface CollectEvent {
7
19
  properties?: Record<string, unknown>;
8
20
  url?: string;
9
21
  referrer?: string;
22
+ experiment?: ExperimentContext;
10
23
  }
11
24
  /** An event at capture time, before the eventId is stamped. */
12
25
  export type PendingEvent = Omit<CollectEvent, "eventId">;
13
26
  /** Planning threshold: full serialized envelope, UTF-8 bytes. */
14
27
  export declare const MAX_BATCH_BYTES: number;
28
+ /**
29
+ * Hard event-count cap per envelope. The Next collect-route imports this
30
+ * constant and rejects larger envelopes (the Go handler enforces only the
31
+ * 64KiB body cap today), so a byte-capped-only batch of small events (~400
32
+ * fit in 60KiB) would 400 on that host and silently lose the whole flush.
33
+ * The client caps at the strictest host.
34
+ */
35
+ export declare const MAX_BATCH_EVENTS = 100;
15
36
  /** Flush interval (build-time constant, design/04 config table). */
16
37
  export declare const FLUSH_INTERVAL_MS = 5000;
17
38
  export interface BatcherTimers {
@@ -4,6 +4,14 @@
4
4
  // per-event size computed at enqueue — so a flush stays O(n).
5
5
  /** Planning threshold: full serialized envelope, UTF-8 bytes. */
6
6
  export const MAX_BATCH_BYTES = 60 * 1024;
7
+ /**
8
+ * Hard event-count cap per envelope. The Next collect-route imports this
9
+ * constant and rejects larger envelopes (the Go handler enforces only the
10
+ * 64KiB body cap today), so a byte-capped-only batch of small events (~400
11
+ * fit in 60KiB) would 400 on that host and silently lose the whole flush.
12
+ * The client caps at the strictest host.
13
+ */
14
+ export const MAX_BATCH_EVENTS = 100;
7
15
  /** Flush interval (build-time constant, design/04 config table). */
8
16
  export const FLUSH_INTERVAL_MS = 5000;
9
17
  const ENVELOPE_PREFIX = '{"events":[';
@@ -111,6 +119,8 @@ export class Batcher {
111
119
  const next = this.queue[0];
112
120
  if (ENVELOPE_OVERHEAD + next.bytes > this.maxBytes)
113
121
  break; // oversize: outer loop drops it
122
+ if (parts.length >= MAX_BATCH_EVENTS)
123
+ break; // host-side envelope cap
114
124
  const nextSize = size + next.bytes + (parts.length > 0 ? 1 : 0);
115
125
  if (nextSize > this.maxBytes)
116
126
  break;
@@ -31,5 +31,5 @@ export declare function toCustomEventName(raw: unknown): string | undefined;
31
31
  * environment has an IntersectionObserver, observes every visible-declared
32
32
  * element present at start.
33
33
  */
34
- export declare function startDeclarative(emit: (event: PendingEvent) => void, env: DeclarativeEnv): void;
34
+ export declare function startDeclarative(emit: (event: PendingEvent) => void, env: DeclarativeEnv, refresh?: () => void): void;
35
35
  export {};
@@ -6,6 +6,7 @@
6
6
  const EVENT_ATTR = "data-aci-event";
7
7
  const ON_ATTR = "data-aci-on";
8
8
  const PROP_ATTR_PREFIX = "data-aci-prop-";
9
+ const REFRESH_ATTR = "data-aci-refresh";
9
10
  const CUSTOM_PREFIX = "custom.";
10
11
  const VISIBLE_THRESHOLD = 0.5;
11
12
  /**
@@ -50,11 +51,16 @@ function trigger(el) {
50
51
  * environment has an IntersectionObserver, observes every visible-declared
51
52
  * element present at start.
52
53
  */
53
- export function startDeclarative(emit, env) {
54
+ export function startDeclarative(emit, env, refresh) {
54
55
  const { document: doc, debug } = env;
55
56
  doc.addEventListener("click", (e) => {
56
57
  try {
57
58
  const target = e.target;
59
+ const refreshTarget = target?.closest?.(`[${REFRESH_ATTR}]`);
60
+ if (refreshTarget) {
61
+ refresh?.();
62
+ return;
63
+ }
58
64
  const el = target?.closest?.(`[${EVENT_ATTR}]`);
59
65
  if (!el)
60
66
  return;
@@ -3,6 +3,7 @@
3
3
  // init(); every failure is swallowed after a debug record so the page is
4
4
  // never affected (QR-2/3).
5
5
  import { installCommandQueue } from "./api.js";
6
+ import { createAssignments, withAssignmentContext } from "./assignments.js";
6
7
  import { Batcher } from "./batcher.js";
7
8
  import { createDebug, readTagConfig } from "./config.js";
8
9
  import { start as startC15t } from "./consent/adapters/c15t.js";
@@ -13,8 +14,8 @@ import { applyConsent, envelopeGrantsPersonalization, readConsentCookie, sanitiz
13
14
  import { startDeclarative } from "./declarative.js";
14
15
  import { GapBuffer, hasMintMarker, maybeFlushGapBuffer, routeEvent, } from "./gapbuffer.js";
15
16
  import { startLinkClicks } from "./linkclicks.js";
16
- import { startPageViews } from "./pageviews.js";
17
- import { send } from "./transport.js";
17
+ import { installNavigationHook, startPageViews, } from "./pageviews.js";
18
+ import { refreshClaims, send } from "./transport.js";
18
19
  import { ulid } from "./ulid.js";
19
20
  import { startVariationViews } from "./variationviews.js";
20
21
  import { startWebVitals } from "./webvitals.js";
@@ -94,12 +95,24 @@ export function init(win) {
94
95
  documentTarget: doc,
95
96
  windowTarget: w,
96
97
  });
98
+ // While an assignment is active, every emitted event carries the first
99
+ // active assignment's experiment context (REQ-3) unless it already has
100
+ // one — exposures and explicit conversions carry their own. The handle
101
+ // is created (without emitting) BEFORE emit so every dependency of emit
102
+ // exists before emit can run; exposures flow at start() below.
103
+ const assignments = createAssignments({ document: doc, window: w, debug });
97
104
  const emit = (event) => {
98
105
  routeEvent({
99
- ...event,
106
+ ...withAssignmentContext(event, assignments),
100
107
  eventId: ulid(undefined, (bytes) => w.crypto.getRandomValues(bytes)),
101
108
  }, { grants, marker, gapBuffer, batcher });
102
109
  };
110
+ // Started BEFORE the command-queue drain and page views: pre-load
111
+ // aci('track', ...) calls and the initial rum.page_view must be stamped
112
+ // with the active assignment context, or queued conversions can never
113
+ // match a goal (review-quorum finding). Consent commands drained after
114
+ // this stay safe — events wait in the gap buffer until grants resolve.
115
+ assignments.start(emit);
103
116
  installCommandQueue(w, cfg, {
104
117
  track: (name, properties) => emit({ eventName: name, properties }),
105
118
  consent: ({ p, src }) => {
@@ -111,11 +124,24 @@ export function init(win) {
111
124
  applyConsent(purposes, typeof src === "string" && src.trim() !== "" ? src.trim() : "manual", { document: doc, gapBuffer });
112
125
  },
113
126
  }, debug);
114
- startPageViews(emit, w);
127
+ const emitPageView = startPageViews(emit, w);
128
+ // The single history patch (one pushState/replaceState wrap, one dedupe
129
+ // state, one popstate listener). Order is load-bearing: the assignments
130
+ // re-arm runs BEFORE the SPA page view so rum.page_view is stamped with
131
+ // the new page's experiment context.
132
+ installNavigationHook(w, [
133
+ () => assignments.rearm(),
134
+ emitPageView,
135
+ ]);
115
136
  startDeclarative(emit, {
116
137
  document: doc,
117
138
  intersectionObserver: w.IntersectionObserver,
118
139
  debug,
140
+ }, () => {
141
+ void refreshClaims(transportEnv).then((refreshed) => {
142
+ if (refreshed)
143
+ w.location.reload();
144
+ });
119
145
  });
120
146
  startLinkClicks(emit, { document: doc, debug });
121
147
  startWebVitals(emit, {
@@ -15,8 +15,20 @@ export interface PageViewWindow {
15
15
  addEventListener(type: string, fn: () => void): void;
16
16
  }
17
17
  /**
18
- * startPageViews emits the initial page view and hooks
19
- * pushState/replaceState/popstate for SPA route changes.
18
+ * installNavigationHook wires pushState/replaceState/popstate and invokes
19
+ * the subscribers, in order, once per SPA route change; hash-only changes
20
+ * and same-URL repeats never fire. Installed exactly ONCE by init — one
21
+ * history patch, one dedupe state — and shared by the assignments collector
22
+ * and page views so "page view" means exactly the same thing to both
23
+ * (REQ-2). Subscriber order is the contract: init lists the assignments
24
+ * re-arm BEFORE the page-view emitter so rum.page_view is stamped with the
25
+ * new page's experiment context.
20
26
  */
21
- export declare function startPageViews(emit: (event: PendingEvent) => void, win: PageViewWindow): void;
27
+ export declare function installNavigationHook(win: PageViewWindow, subscribers: ReadonlyArray<() => void>): void;
28
+ /**
29
+ * startPageViews emits the initial page view and returns the emitter for
30
+ * SPA route-change views — init subscribes it to the single navigation hook
31
+ * (after the assignments re-arm; see installNavigationHook).
32
+ */
33
+ export declare function startPageViews(emit: (event: PendingEvent) => void, win: PageViewWindow): () => void;
22
34
  export {};
@@ -9,34 +9,49 @@ function stripHash(href) {
9
9
  return i === -1 ? href : href.slice(0, i);
10
10
  }
11
11
  /**
12
- * startPageViews emits the initial page view and hooks
13
- * pushState/replaceState/popstate for SPA route changes.
12
+ * installNavigationHook wires pushState/replaceState/popstate and invokes
13
+ * the subscribers, in order, once per SPA route change; hash-only changes
14
+ * and same-URL repeats never fire. Installed exactly ONCE by init — one
15
+ * history patch, one dedupe state — and shared by the assignments collector
16
+ * and page views so "page view" means exactly the same thing to both
17
+ * (REQ-2). Subscriber order is the contract: init lists the assignments
18
+ * re-arm BEFORE the page-view emitter so rum.page_view is stamped with the
19
+ * new page's experiment context.
14
20
  */
15
- export function startPageViews(emit, win) {
16
- const view = () => emit({
17
- eventName: PAGE_VIEW_EVENT,
18
- url: win.location.href,
19
- referrer: win.document.referrer,
20
- });
21
+ export function installNavigationHook(win, subscribers) {
21
22
  let last = stripHash(win.location.href);
22
- view();
23
- const onNavigate = () => {
23
+ const handler = () => {
24
24
  const current = stripHash(win.location.href);
25
25
  if (current === last)
26
26
  return; // hash-only change or same-URL repeat
27
27
  last = current;
28
- view();
28
+ for (const onNavigate of subscribers)
29
+ onNavigate();
29
30
  };
30
31
  const history = win.history;
31
32
  const wrap = (method) => {
32
33
  const original = history[method].bind(history);
33
34
  history[method] = (...args) => {
34
35
  const result = original(...args);
35
- onNavigate();
36
+ handler();
36
37
  return result;
37
38
  };
38
39
  };
39
40
  wrap("pushState");
40
41
  wrap("replaceState");
41
- win.addEventListener("popstate", onNavigate);
42
+ win.addEventListener("popstate", handler);
43
+ }
44
+ /**
45
+ * startPageViews emits the initial page view and returns the emitter for
46
+ * SPA route-change views — init subscribes it to the single navigation hook
47
+ * (after the assignments re-arm; see installNavigationHook).
48
+ */
49
+ export function startPageViews(emit, win) {
50
+ const view = () => emit({
51
+ eventName: PAGE_VIEW_EVENT,
52
+ url: win.location.href,
53
+ referrer: win.document.referrer,
54
+ });
55
+ view();
56
+ return view;
42
57
  }
@@ -7,6 +7,7 @@ export type FetchLike = (input: string, init?: {
7
7
  method?: string;
8
8
  body?: Blob;
9
9
  keepalive?: boolean;
10
+ credentials?: "same-origin";
10
11
  }) => Promise<unknown>;
11
12
  export interface TransportEnv {
12
13
  /** Present key (even if undefined) overrides the global for tests. */
@@ -20,3 +21,4 @@ export interface TransportEnv {
20
21
  * never affected.
21
22
  */
22
23
  export declare function send(body: string, env?: TransportEnv): void;
24
+ export declare function refreshClaims(env?: TransportEnv): Promise<boolean>;
@@ -50,3 +50,35 @@ export function send(body, env) {
50
50
  debug?.("flush", { reason: "fetch_failed", error: String(err) });
51
51
  }
52
52
  }
53
+ // refreshClaims deliberately uses fetch rather than sendBeacon. The host's
54
+ // response may refresh the visitor's audience state, so this explicit action
55
+ // must make a normal same-origin request with an empty event envelope.
56
+ export function refreshClaims(env) {
57
+ const fetchFn = env !== undefined && "fetch" in env
58
+ ? env.fetch
59
+ : globalThis.fetch;
60
+ if (!fetchFn)
61
+ return Promise.resolve(false);
62
+ try {
63
+ const body = new Blob(['{"events":[]}'], { type: "text/plain" });
64
+ return fetchFn(COLLECT_PATH, {
65
+ method: "POST",
66
+ body,
67
+ keepalive: true,
68
+ credentials: "same-origin",
69
+ }).then((response) => {
70
+ if (typeof response === "object" && response !== null && "ok" in response && response.ok === false) {
71
+ env?.debug?.("flush", { reason: "refresh_failed", status: "not_ok" });
72
+ return false;
73
+ }
74
+ return true;
75
+ }).catch((err) => {
76
+ env?.debug?.("flush", { reason: "refresh_failed", error: String(err) });
77
+ return false;
78
+ });
79
+ }
80
+ catch (err) {
81
+ env?.debug?.("flush", { reason: "refresh_failed", error: String(err) });
82
+ return Promise.resolve(false);
83
+ }
84
+ }
@@ -0,0 +1,33 @@
1
+ export interface ConsentBannerOptions {
2
+ /** Heading text. Default: "Privacy" */
3
+ heading?: string;
4
+ /** Body text explaining what the user is consenting to. */
5
+ message?: string;
6
+ /** Label for the accept button. Default: "Accept" */
7
+ acceptLabel?: string;
8
+ /** Label for the reject button. Default: "Reject" */
9
+ rejectLabel?: string;
10
+ /** Label for a link to the privacy policy. Optional. */
11
+ privacyPolicyLabel?: string;
12
+ /** URL to the privacy policy. Optional. */
13
+ privacyPolicyHref?: string;
14
+ /** Source identifier written to the cookie envelope. Default: "banner" */
15
+ source?: string;
16
+ /** Callback fired after a consent decision is written. */
17
+ onDecision?: (granted: boolean) => void;
18
+ }
19
+ /**
20
+ * hasConsentCookie returns true when the aci_consent cookie exists (the user
21
+ * has already made a decision, regardless of whether they granted or denied).
22
+ */
23
+ export declare function hasConsentCookie(doc: {
24
+ cookie: string;
25
+ }): boolean;
26
+ /**
27
+ * mountConsentBanner checks for an existing consent cookie. If none exists,
28
+ * it injects a banner into the document body and wires up accept/reject
29
+ * buttons. If a cookie already exists, it does nothing (idempotent).
30
+ *
31
+ * Returns a cleanup function that removes the banner and event listeners.
32
+ */
33
+ export declare function mountConsentBanner(doc: Document, opts?: ConsentBannerOptions): () => void;
@@ -0,0 +1,99 @@
1
+ // First-party consent banner (design/04, preset="manual"): a self-contained
2
+ // client-side component for sites that don't have an external CMP (c15t,
3
+ // OneTrust, etc.). Checks for the aci_consent cookie, shows a banner if
4
+ // absent, and calls applyConsent() on user decision.
5
+ //
6
+ // The banner is framework-agnostic — it renders plain DOM and is driven by
7
+ // CSS classes so each site can style it. The Astro wrapper component
8
+ // (packages/sdk/src/astro/ConsentBanner.astro) imports and invokes this,
9
+ // then applies site-specific styles.
10
+ import { applyConsent, readConsentCookie, PERSONALIZATION_PURPOSE, } from "../collect/consent/envelope.js";
11
+ const DEFAULTS = {
12
+ heading: "Privacy",
13
+ message: "We use cookies to personalize content and improve your experience. You can accept or reject personalization.",
14
+ acceptLabel: "Accept",
15
+ rejectLabel: "Reject",
16
+ source: "banner",
17
+ };
18
+ const BANNER_ID = "aci-consent-banner";
19
+ const BANNER_ATTR = "data-aci-consent-banner";
20
+ const BANNER_HIDDEN = "aci-consent-banner--hidden";
21
+ /**
22
+ * hasConsentCookie returns true when the aci_consent cookie exists (the user
23
+ * has already made a decision, regardless of whether they granted or denied).
24
+ */
25
+ export function hasConsentCookie(doc) {
26
+ return readConsentCookie(doc) !== undefined;
27
+ }
28
+ /**
29
+ * mountConsentBanner checks for an existing consent cookie. If none exists,
30
+ * it injects a banner into the document body and wires up accept/reject
31
+ * buttons. If a cookie already exists, it does nothing (idempotent).
32
+ *
33
+ * Returns a cleanup function that removes the banner and event listeners.
34
+ */
35
+ export function mountConsentBanner(doc, opts = {}) {
36
+ if (hasConsentCookie(doc))
37
+ return () => { };
38
+ if (doc.getElementById(BANNER_ID))
39
+ return () => { };
40
+ const config = { ...DEFAULTS, ...opts };
41
+ const banner = createBannerElement(config, doc);
42
+ doc.body.appendChild(banner);
43
+ const cleanup = () => {
44
+ banner.remove();
45
+ };
46
+ const decide = (granted) => {
47
+ const purposes = granted ? [PERSONALIZATION_PURPOSE] : [];
48
+ applyConsent(purposes, config.source, { document: doc });
49
+ banner.classList.add(BANNER_HIDDEN);
50
+ setTimeout(cleanup, 300);
51
+ opts.onDecision?.(granted);
52
+ };
53
+ banner.querySelector("[data-aci-consent-accept]")?.addEventListener("click", () => decide(true));
54
+ banner.querySelector("[data-aci-consent-reject]")?.addEventListener("click", () => decide(false));
55
+ return cleanup;
56
+ }
57
+ function createBannerElement(config, doc) {
58
+ const banner = doc.createElement("div");
59
+ banner.id = BANNER_ID;
60
+ banner.setAttribute(BANNER_ATTR, "");
61
+ banner.className = BANNER_ID;
62
+ const content = doc.createElement("div");
63
+ content.className = `${BANNER_ID}__content`;
64
+ const text = doc.createElement("div");
65
+ text.className = `${BANNER_ID}__text`;
66
+ const heading = doc.createElement("p");
67
+ heading.className = `${BANNER_ID}__heading`;
68
+ heading.textContent = config.heading;
69
+ text.appendChild(heading);
70
+ const message = doc.createElement("p");
71
+ message.className = `${BANNER_ID}__message`;
72
+ message.textContent = config.message;
73
+ text.appendChild(message);
74
+ if (config.privacyPolicyHref && config.privacyPolicyLabel) {
75
+ const link = doc.createElement("a");
76
+ link.className = `${BANNER_ID}__privacy-link`;
77
+ link.href = config.privacyPolicyHref;
78
+ link.textContent = config.privacyPolicyLabel;
79
+ text.appendChild(link);
80
+ }
81
+ content.appendChild(text);
82
+ const actions = doc.createElement("div");
83
+ actions.className = `${BANNER_ID}__actions`;
84
+ const rejectBtn = doc.createElement("button");
85
+ rejectBtn.type = "button";
86
+ rejectBtn.className = `${BANNER_ID}__btn ${BANNER_ID}__btn--reject`;
87
+ rejectBtn.setAttribute("data-aci-consent-reject", "");
88
+ rejectBtn.textContent = config.rejectLabel;
89
+ actions.appendChild(rejectBtn);
90
+ const acceptBtn = doc.createElement("button");
91
+ acceptBtn.type = "button";
92
+ acceptBtn.className = `${BANNER_ID}__btn ${BANNER_ID}__btn--accept`;
93
+ acceptBtn.setAttribute("data-aci-consent-accept", "");
94
+ acceptBtn.textContent = config.acceptLabel;
95
+ actions.appendChild(acceptBtn);
96
+ content.appendChild(actions);
97
+ banner.appendChild(content);
98
+ return banner;
99
+ }