@gradial/aci 0.1.40 → 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 (95) hide show
  1. package/dist/astro/index.d.ts +1 -1
  2. package/dist/astro/index.js +1 -1
  3. package/dist/astro/page.d.ts +6 -0
  4. package/dist/astro/page.js +3 -2
  5. package/dist/cli/compile-registry.d.mts +2 -0
  6. package/dist/cli/compile-registry.mjs +759 -0
  7. package/dist/cli/materialize-template.d.mts +19 -0
  8. package/dist/cli/materialize-template.mjs +128 -0
  9. package/dist/cli/validate-content.d.mts +2 -0
  10. package/dist/cli/validate-content.mjs +426 -0
  11. package/dist/cli/verify-next-cdn-routes.d.mts +2 -0
  12. package/dist/cli/verify-next-cdn-routes.mjs +47 -0
  13. package/dist/collect/assignments.d.ts +65 -0
  14. package/dist/collect/assignments.js +138 -0
  15. package/dist/collect/batcher.d.ts +21 -0
  16. package/dist/collect/batcher.js +10 -0
  17. package/dist/collect/declarative.d.ts +1 -1
  18. package/dist/collect/declarative.js +7 -1
  19. package/dist/collect/index.js +30 -4
  20. package/dist/collect/pageviews.d.ts +15 -3
  21. package/dist/collect/pageviews.js +28 -13
  22. package/dist/collect/transport.d.ts +2 -0
  23. package/dist/collect/transport.js +32 -0
  24. package/dist/content/personalization.d.ts +35 -0
  25. package/dist/content/personalization.js +57 -0
  26. package/dist/content/types.d.ts +7 -1
  27. package/dist/edge/personalization-core.d.ts +11 -0
  28. package/dist/edge/personalization-core.js +265 -0
  29. package/dist/next/assignments-script.d.ts +14 -0
  30. package/dist/next/assignments-script.js +17 -0
  31. package/dist/next/collect-route.d.ts +19 -0
  32. package/dist/next/collect-route.js +351 -0
  33. package/dist/next/index.d.ts +1 -0
  34. package/dist/next/index.js +1 -0
  35. package/dist/next/middleware.d.ts +15 -1
  36. package/dist/next/middleware.js +71 -27
  37. package/dist/next/page.js +2 -1
  38. package/dist/next/personalization-host.d.ts +118 -0
  39. package/dist/next/personalization-host.js +276 -0
  40. package/dist/next/server.js +6 -6
  41. package/dist/next/vercel-personalization.d.ts +3 -0
  42. package/dist/next/vercel-personalization.js +3 -0
  43. package/dist/next/vercel-s3-personalization.d.ts +58 -0
  44. package/dist/next/vercel-s3-personalization.js +220 -0
  45. package/dist/providers/s3.d.ts +1 -1
  46. package/dist/providers/s3.js +1 -1
  47. package/dist/react/VideoPlayer.d.ts +4 -4
  48. package/dist/react/index.d.ts +4 -4
  49. package/dist/types/page.d.ts +1 -1
  50. package/package.json +24 -16
  51. package/src/cli/validate-content.mjs +0 -0
  52. package/src/cli/verify-next-cdn-routes.mjs +5 -4
  53. package/src/types/page.ts +1 -1
  54. package/templates/astro/starter-content/src/cms/contracts/components/index.ts +2 -0
  55. package/templates/astro/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  56. package/templates/astro/starter-content/src/cms/registry.ts +3 -0
  57. package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +41 -0
  58. package/templates/astro/starter-content/src/render/RenderPage.astro +3 -2
  59. package/templates/astro/template/src/layouts/SiteLayout.astro +3 -1
  60. package/templates/astro/template/src/pages/[...slug].astro +1 -0
  61. package/templates/common/.content/audiences/preference-blue/_index.json +11 -0
  62. package/templates/common/.content/audiences/preference-red/_index.json +11 -0
  63. package/templates/common/.content/overlays/audience/preference-blue/pages/home/_index.json +13 -0
  64. package/templates/common/.content/overlays/audience/preference-red/pages/home/_index.json +13 -0
  65. package/templates/common/.content/pages/home/_index.json +114 -4
  66. package/templates/common/.content/segments/preference-blue/_index.json +20 -0
  67. package/templates/common/.content/segments/preference-red/_index.json +20 -0
  68. package/templates/common/contracts/components/index.ts +21 -1
  69. package/templates/common/contracts/components/personalizationChoice.contract.ts +17 -0
  70. package/templates/nextjs/starter-content/src/cms/contracts/components/index.ts +3 -1
  71. package/templates/nextjs/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  72. package/templates/nextjs/starter-content/src/cms/registry.ts +3 -0
  73. package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +53 -0
  74. package/templates/nextjs/starter-content/src/middleware.ts +11 -0
  75. package/templates/nextjs/template/src/app/collect/route.ts +18 -0
  76. package/templates/nextjs/template/src/middleware.ts +6 -2
  77. package/templates/astro/template/.aci.yaml +0 -28
  78. package/templates/common/.content/.gradial-dam/blobs/sha256/7f/bf/7fbfc7fe230ff12688c496f660119e41d9725c4504d82e310c15665c73c09292 +0 -0
  79. package/templates/common/.content/.gradial-dam/blobs/sha256/a0/f5/a0f506689de9b15a7055f70103a3f02cd3ba105771ae2de6754df2ede1210d3a +0 -0
  80. package/templates/common/.content/.gradial-dam/blobs/sha256/dd/b7/ddb7e9642be08317bcd4b2bd26b58ba8d0d34dd7239e03e684a4f3634fa3845b +0 -0
  81. package/templates/common/.content/.gradial-dam/blobs/sha256/fb/32/fb3260e8286ea8231ea1c77b9c0be92d6edd9026565731eb14cfdcfd6b1b75b3 +0 -0
  82. package/templates/common/.content/config/robots.txt +0 -2
  83. package/templates/common/.content/config/site.json +0 -197
  84. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/asset.json +0 -9
  85. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/editorial.json +0 -14
  86. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/governance.json +0 -23
  87. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/versions/v0001.json +0 -37
  88. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/asset.json +0 -9
  89. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/editorial.json +0 -14
  90. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/governance.json +0 -23
  91. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/versions/v0001.json +0 -37
  92. package/templates/common/.content/fragments/navbar/_index.json +0 -25
  93. package/templates/common/.content/fragments/site-footer/_index.json +0 -62
  94. package/templates/common/.content/pages/about/_index.json +0 -51
  95. 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
+ }
@@ -55,3 +55,38 @@ export declare function collectTagAttributes(siteConfig: PersonalizationSiteConf
55
55
  * when personalization is disabled.
56
56
  */
57
57
  export declare function collectScriptTagHTML(siteConfig: PersonalizationSiteConfig | null | undefined): string;
58
+ /** Version of the data-aci-assignments tag body. */
59
+ export declare const ASSIGNMENTS_TAG_VERSION = 1;
60
+ /**
61
+ * One experiment assignment carried by a variant payload's `experiments`
62
+ * block (wire field names match the SDK event contract).
63
+ */
64
+ export interface ExperimentAssignmentContext {
65
+ experimentId: string;
66
+ armId: string;
67
+ allocationVersion: string;
68
+ }
69
+ /**
70
+ * The minimal page-payload surface assignments tag emission reads: the
71
+ * top-level `experiments` block stamped at build time onto experiment
72
+ * variant payloads. `KernelPage` satisfies this structurally.
73
+ */
74
+ export interface AssignmentsPayloadSource {
75
+ experiments?: ExperimentAssignmentContext[] | null;
76
+ }
77
+ /**
78
+ * assignmentsScriptJSON derives the HTML-safe JSON body of the
79
+ * data-aci-assignments tag ({"v":1,"experiments":[…]}) from the page
80
+ * payload, or "" when the payload carries no experiment assignments.
81
+ * `<` / `>` / `&` are \u-escaped so the JSON can never break out of the
82
+ * script element (standard JSON-in-script escaping; application/json tags
83
+ * are data, never executed).
84
+ */
85
+ export declare function assignmentsScriptJSON(page: AssignmentsPayloadSource | null | undefined): string;
86
+ /**
87
+ * assignmentsScriptTagHTML renders the contract tag
88
+ * (`<script type="application/json" data-aci-assignments>…</script>`,
89
+ * design/04) as an HTML snippet for emitters without a React element seam
90
+ * (the Astro slug page). Returns "" when the payload carries no assignments.
91
+ */
92
+ export declare function assignmentsScriptTagHTML(page: AssignmentsPayloadSource | null | undefined): string;
@@ -52,3 +52,60 @@ function escapeAttribute(value) {
52
52
  .replace(/</g, "&lt;")
53
53
  .replace(/>/g, "&gt;");
54
54
  }
55
+ // ---------------------------------------------------------------------------
56
+ // Assignments tag (personalization-reporting design/04, D-PR-6): experiment
57
+ // variant payloads carry a build-time `experiments` block; the starters
58
+ // render it as ONE page-level JSON script tag the collect SDK reads for
59
+ // exposure emission and context stamping. Base payloads carry no block and
60
+ // emit no tag. Like the collect tag, both framework emitters derive the tag
61
+ // through this module so there is exactly one derivation code path.
62
+ // ---------------------------------------------------------------------------
63
+ /** Version of the data-aci-assignments tag body. */
64
+ export const ASSIGNMENTS_TAG_VERSION = 1;
65
+ function isAssignment(value) {
66
+ if (typeof value !== "object" || value === null)
67
+ return false;
68
+ const record = value;
69
+ return (typeof record.experimentId === "string" &&
70
+ record.experimentId !== "" &&
71
+ typeof record.armId === "string" &&
72
+ record.armId !== "" &&
73
+ typeof record.allocationVersion === "string");
74
+ }
75
+ /**
76
+ * assignmentsScriptJSON derives the HTML-safe JSON body of the
77
+ * data-aci-assignments tag ({"v":1,"experiments":[…]}) from the page
78
+ * payload, or "" when the payload carries no experiment assignments.
79
+ * `<` / `>` / `&` are \u-escaped so the JSON can never break out of the
80
+ * script element (standard JSON-in-script escaping; application/json tags
81
+ * are data, never executed).
82
+ */
83
+ export function assignmentsScriptJSON(page) {
84
+ const experiments = (page?.experiments ?? []).filter(isAssignment);
85
+ if (experiments.length === 0)
86
+ return "";
87
+ const body = JSON.stringify({
88
+ v: ASSIGNMENTS_TAG_VERSION,
89
+ experiments: experiments.map(({ experimentId, armId, allocationVersion }) => ({
90
+ experimentId,
91
+ armId,
92
+ allocationVersion,
93
+ })),
94
+ });
95
+ return body
96
+ .replace(/</g, "\\u003c")
97
+ .replace(/>/g, "\\u003e")
98
+ .replace(/&/g, "\\u0026");
99
+ }
100
+ /**
101
+ * assignmentsScriptTagHTML renders the contract tag
102
+ * (`<script type="application/json" data-aci-assignments>…</script>`,
103
+ * design/04) as an HTML snippet for emitters without a React element seam
104
+ * (the Astro slug page). Returns "" when the payload carries no assignments.
105
+ */
106
+ export function assignmentsScriptTagHTML(page) {
107
+ const json = assignmentsScriptJSON(page);
108
+ if (!json)
109
+ return "";
110
+ return `<script type="application/json" data-aci-assignments>${json}</script>`;
111
+ }
@@ -1,4 +1,4 @@
1
- import type { SitePersonalizationConfig } from './personalization.js';
1
+ import type { ExperimentAssignmentContext, SitePersonalizationConfig } from './personalization.js';
2
2
  export type ButtonVariant = 'primary' | 'secondary' | 'tertiary' | 'outline' | 'ghost';
3
3
  export interface KernelImage {
4
4
  src: string;
@@ -58,6 +58,12 @@ export interface KernelPage {
58
58
  layout: string;
59
59
  metadata: KernelPageSeo;
60
60
  regions: Record<string, KernelPageBlock[]>;
61
+ /**
62
+ * Build-time experiment assignment context (personalization-reporting
63
+ * design/04); present only on experiment variant payloads. The starters
64
+ * render it as the data-aci-assignments tag.
65
+ */
66
+ experiments?: ExperimentAssignmentContext[];
61
67
  }
62
68
  export interface KernelSiteConfig {
63
69
  id: string;
@@ -0,0 +1,11 @@
1
+ export function selectPersonalizationVariant(request: any, route: any, claims: any, experiments: any, identity: any): any;
2
+ export function resolvePersonalizationDecision(request: any, route: any, claims: any, experiments: any, identity: any): {
3
+ variant: any;
4
+ experiment: {
5
+ experimentId: any;
6
+ armId: any;
7
+ allocationVersion: string;
8
+ variantKey: any;
9
+ bucket: number;
10
+ } | null;
11
+ };