@gradial/aci 0.1.52-rc.21 → 0.1.52-rc.24

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 (143) hide show
  1. package/README.md +79 -5
  2. package/dist/astro/index.js +4 -0
  3. package/dist/audience-data-models.d.ts +12 -0
  4. package/dist/audience-data-models.js +3 -0
  5. package/dist/cli/mintlify-render.d.mts +1 -0
  6. package/dist/cli/mintlify-render.mjs +8 -0
  7. package/dist/collect/aci.js +1 -1
  8. package/dist/collect/aci.js.sha256 +1 -1
  9. package/dist/collect/api.d.ts +3 -0
  10. package/dist/collect/api.js +19 -0
  11. package/dist/collect/assignments.d.ts +7 -9
  12. package/dist/collect/assignments.js +23 -32
  13. package/dist/collect/batcher.d.ts +3 -2
  14. package/dist/collect/consent/envelope.d.ts +7 -1
  15. package/dist/collect/consent/envelope.js +3 -1
  16. package/dist/collect/consent/mapping.d.ts +2 -0
  17. package/dist/collect/consent/mapping.js +1 -0
  18. package/dist/collect/consenthold.d.ts +38 -0
  19. package/dist/collect/consenthold.js +82 -0
  20. package/dist/collect/declarative.js +7 -5
  21. package/dist/collect/gapbuffer.d.ts +13 -4
  22. package/dist/collect/gapbuffer.js +10 -4
  23. package/dist/collect/index.js +66 -19
  24. package/dist/collect/pageviews.d.ts +11 -11
  25. package/dist/collect/pageviews.js +27 -22
  26. package/dist/consent-banner/index.d.ts +17 -0
  27. package/dist/consent-banner/index.js +23 -0
  28. package/dist/content/provider.d.ts +8 -0
  29. package/dist/index.d.ts +1 -1
  30. package/dist/internal/domain/edge/identity.d.ts +5 -0
  31. package/dist/internal/domain/edge/identity.js +49 -13
  32. package/dist/internal/domain/edge/mimic_token.d.ts +1 -0
  33. package/dist/internal/domain/edge/mimic_token.js +74 -7
  34. package/dist/internal/domain/edge/personalization-core.d.ts +8 -33
  35. package/dist/internal/domain/edge/personalization-core.js +151 -195
  36. package/dist/internal/domain/edge/personalization_index.d.ts +17 -0
  37. package/dist/internal/domain/edge/personalization_index.js +56 -0
  38. package/dist/internal/domain/edge/record/codec.d.ts +1 -30
  39. package/dist/internal/domain/edge/record/codec.js +1 -156
  40. package/dist/internal/domain/edge/resolve.js +55 -38
  41. package/dist/internal/domain/edge/types.d.ts +41 -32
  42. package/dist/internal/domain/edge/variant_key.d.ts +3 -0
  43. package/dist/internal/domain/edge/variant_key.js +44 -0
  44. package/dist/internal/platform/collectionstore/errors.d.ts +13 -0
  45. package/dist/internal/platform/collectionstore/errors.js +28 -0
  46. package/dist/mintlify/blocks.d.ts +6 -0
  47. package/dist/mintlify/blocks.js +198 -0
  48. package/dist/mintlify/config.d.ts +3 -0
  49. package/dist/mintlify/config.js +77 -0
  50. package/dist/mintlify/index.d.ts +3 -0
  51. package/dist/mintlify/index.js +2 -0
  52. package/dist/mintlify/json.d.ts +8 -0
  53. package/dist/mintlify/json.js +37 -0
  54. package/dist/mintlify/materialize.d.ts +12 -0
  55. package/dist/mintlify/materialize.js +159 -0
  56. package/dist/mintlify/types.d.ts +69 -0
  57. package/dist/mintlify/types.js +1 -0
  58. package/dist/mintlify/validate.d.ts +7 -0
  59. package/dist/mintlify/validate.js +252 -0
  60. package/dist/next/assignments-navigation.d.ts +4 -0
  61. package/dist/next/assignments-navigation.js +18 -0
  62. package/dist/next/assignments-script.d.ts +3 -2
  63. package/dist/next/assignments-script.js +5 -5
  64. package/dist/next/error-seat.js +3 -0
  65. package/dist/next/index.d.ts +1 -0
  66. package/dist/next/index.js +3 -0
  67. package/dist/next/render.js +8 -1
  68. package/dist/react/VideoPlayer.d.ts +1 -1
  69. package/dist/react/index.d.ts +1 -1
  70. package/dist/registry.d.ts +4 -2
  71. package/dist/testing/conformance/checks.d.ts +2 -2
  72. package/dist/testing/conformance/checks.js +2 -0
  73. package/dist/testing/conformance/index.d.ts +1 -0
  74. package/dist/testing/conformance/index.js +1 -0
  75. package/dist/testing/conformance/next-stubs.d.ts +6 -0
  76. package/dist/testing/conformance/next-stubs.js +9 -0
  77. package/dist/testing/conformance/preset.d.ts +3 -2
  78. package/dist/testing/conformance/preset.js +12 -8
  79. package/dist/testing/conformance/root-identity.d.ts +31 -0
  80. package/dist/testing/conformance/root-identity.js +380 -0
  81. package/dist/testing/conformance/stubs/next-image.d.ts +7 -0
  82. package/dist/testing/conformance/stubs/next-image.js +5 -0
  83. package/dist/testing/conformance/stubs/next-link.d.ts +7 -0
  84. package/dist/testing/conformance/stubs/next-link.js +4 -0
  85. package/dist/testing/conformance/types.d.ts +2 -2
  86. package/dist/testing/index.js +2 -2
  87. package/dist/types/component.d.ts +9 -0
  88. package/package.json +11 -7
  89. package/src/cli/mintlify-render.mjs +9 -0
  90. package/src/types/component.ts +10 -0
  91. package/templates/astro/starter-content/src/components/chrome/Footer.astro +5 -3
  92. package/templates/astro/starter-content/src/components/chrome/Header.astro +5 -3
  93. package/templates/astro/starter-content/src/components/elements/Button.astro +5 -2
  94. package/templates/astro/starter-content/src/components/elements/ButtonBlock.astro +5 -3
  95. package/templates/astro/starter-content/src/components/sections/ContainerSection.astro +4 -3
  96. package/templates/astro/starter-content/src/components/sections/FeatureCard.astro +4 -3
  97. package/templates/astro/starter-content/src/components/sections/FeatureGrid.astro +4 -3
  98. package/templates/astro/starter-content/src/components/sections/HomeHero.astro +5 -3
  99. package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +4 -3
  100. package/templates/astro/starter-content/src/components/sections/StatsBar.astro +4 -3
  101. package/templates/astro/starter-content/src/fixtures/sections.ts +1 -1
  102. package/templates/astro/starter-content/src/stories/CompositionStoryFrame.astro +7 -2
  103. package/templates/astro/template/package.json.tmpl +4 -4
  104. package/templates/common/.agents/skills/aci/SKILL.md +50 -0
  105. package/templates/common/.agents/skills/authoring/SKILL.md +4 -3
  106. package/templates/common/.agents/skills/authoring/references/block-registry.md +15 -0
  107. package/templates/common/.agents/skills/migration/references/selector-map.md +1 -1
  108. package/templates/common/.content/schemas/site.schema.json +11 -1
  109. package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000101}/asset.json +1 -1
  110. package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000102}/asset.json +1 -1
  111. package/templates/common/starter-content/.content/pages/404/_index.json +1 -1
  112. package/templates/common/starter-content/.content/pages/{home/_index.json → _index.json} +2 -2
  113. package/templates/common/starter-content/.content/pages/about/_index.json +1 -1
  114. package/templates/common/starter-content/.content/pages/error/_index.json +1 -1
  115. package/templates/common/starter-content/.content/patterns/components/button.json +2 -2
  116. package/templates/common/starter-content/.content/patterns/components/container.json +2 -2
  117. package/templates/common/starter-content/.content/patterns/components/feature_card.json +2 -2
  118. package/templates/common/starter-content/.content/patterns/components/feature_grid.json +2 -2
  119. package/templates/common/starter-content/.content/patterns/components/home_hero.json +2 -2
  120. package/templates/common/starter-content/.content/patterns/components/site-footer.json +2 -2
  121. package/templates/common/starter-content/.content/patterns/components/site-navigation.json +2 -2
  122. package/templates/common/starter-content/.content/patterns/components/stats_bar.json +2 -2
  123. package/templates/common/starter-content/.content/patterns/layouts/default.json +2 -2
  124. package/templates/common/starter-content/.content/schemas/site.schema.json +11 -1
  125. package/templates/nextjs/starter-content/src/components/chrome/Footer.tsx +4 -3
  126. package/templates/nextjs/starter-content/src/components/chrome/Header.tsx +4 -3
  127. package/templates/nextjs/starter-content/src/components/elements/Button.tsx +4 -3
  128. package/templates/nextjs/starter-content/src/components/elements/ButtonBlock.tsx +4 -3
  129. package/templates/nextjs/starter-content/src/components/sections/ContainerSection.tsx +4 -2
  130. package/templates/nextjs/starter-content/src/components/sections/FeatureCard.tsx +4 -3
  131. package/templates/nextjs/starter-content/src/components/sections/FeatureGrid.tsx +4 -2
  132. package/templates/nextjs/starter-content/src/components/sections/HomeHero.tsx +4 -3
  133. package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +4 -2
  134. package/templates/nextjs/starter-content/src/components/sections/StatsBar.tsx +4 -3
  135. package/templates/astro/starter-content/src/render/RenderPage.astro +0 -67
  136. package/templates/common/starter-content/.content/patterns/components/personalization_choice.json +0 -42
  137. package/templates/nextjs/starter-content/src/components/elements/AtomicBlock.tsx +0 -13
  138. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000101}/editorial.json +0 -0
  139. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000101}/governance.json +0 -0
  140. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000101}/versions/v0001.json +0 -0
  141. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000102}/editorial.json +0 -0
  142. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000102}/governance.json +0 -0
  143. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000102}/versions/v0001.json +0 -0
@@ -0,0 +1,82 @@
1
+ // Consent-pending hold (personalization-onboarding REQ-15, D-ONB-13): on a
2
+ // site whose tag names a CMP preset, a consent integration will write the
3
+ // aci_consent envelope — often after hydration. Until it does, and while no
4
+ // identity exists (no aci_m marker), the collector can only drop what the SDK
5
+ // sends (no identity cookie, no identity-tier consent). Rather than transmit
6
+ // a first view's exposure and page view to be discarded, emit holds them:
7
+ //
8
+ // - in page memory only — never sessionStorage, never transmitted;
9
+ // - bounded: CONSENT_HOLD_CAP events (the newest are refused, so the
10
+ // first-view exposure and page view survive) and CONSENT_HOLD_MAX_AGE_MS;
11
+ // - a granting decision releases them, in capture order with their capture-
12
+ // time eventIds, into the batcher so they leave in the next envelope under
13
+ // the just-written consent envelope (they were never enveloped before, so
14
+ // no eventId is reused across envelopes);
15
+ // - a non-granting decision, or pagehide without a decision, discards them.
16
+ export const CONSENT_HOLD_CAP = 50;
17
+ export const CONSENT_HOLD_MAX_AGE_MS = 30 * 60 * 1000;
18
+ export class ConsentHold {
19
+ events = [];
20
+ now;
21
+ debug;
22
+ constructor(env) {
23
+ this.now = env?.now ?? Date.now;
24
+ this.debug = env?.debug;
25
+ }
26
+ push(event) {
27
+ this.expire();
28
+ if (this.events.length >= CONSENT_HOLD_CAP) {
29
+ this.debug?.("buffer", { reason: "consent_hold_cap_drop_newest", eventId: event.eventId });
30
+ return;
31
+ }
32
+ this.events.push({ event, heldAt: this.now() });
33
+ }
34
+ size() {
35
+ return this.events.length;
36
+ }
37
+ /** release returns and clears the unexpired held events, in capture order. */
38
+ release() {
39
+ this.expire();
40
+ const released = this.events.map((held) => held.event);
41
+ this.events = [];
42
+ return released;
43
+ }
44
+ /** discard drops every held event; nothing is ever sent for them. */
45
+ discard(reason) {
46
+ if (this.events.length > 0) {
47
+ this.debug?.("buffer", { reason: `consent_hold_discarded_${reason}`, count: this.events.length });
48
+ }
49
+ this.events = [];
50
+ }
51
+ expire() {
52
+ const cutoff = this.now() - CONSENT_HOLD_MAX_AGE_MS;
53
+ let expired = 0;
54
+ while (expired < this.events.length && this.events[expired].heldAt < cutoff) {
55
+ expired += 1;
56
+ }
57
+ if (expired > 0) {
58
+ this.events.splice(0, expired);
59
+ this.debug?.("buffer", { reason: "consent_hold_expired", count: expired });
60
+ }
61
+ }
62
+ }
63
+ /**
64
+ * resolveConsentHold applies a consent decision to the hold: nothing while
65
+ * undecided; a grant releases every held event into the batcher; any other
66
+ * decision discards them. Runs from the consent write path and on every
67
+ * batcher flush tick, so a decision written by another tab or by site code
68
+ * is noticed too.
69
+ */
70
+ export function resolveConsentHold(hold, deps) {
71
+ if (hold.size() === 0 || !deps.decided())
72
+ return;
73
+ if (!deps.grants()) {
74
+ hold.discard("denied");
75
+ return;
76
+ }
77
+ const events = hold.release();
78
+ for (const event of events) {
79
+ deps.batcher.push(event);
80
+ }
81
+ deps.debug?.("buffer", { reason: "consent_hold_released", count: events.length });
82
+ }
@@ -1,6 +1,7 @@
1
1
  // Declarative events (design/04): elements opt in via data-aci-event, with
2
2
  // data-aci-on=click|visible (visible = first intersection >= 50%, once per
3
- // element per page view) and data-aci-prop-* string properties. One
3
+ // element per page view) and data-aci-prop-* string properties. Every event
4
+ // carries the current page url so ingest can derive path and UTM. One
4
5
  // delegated click listener + one IntersectionObserver regardless of element
5
6
  // count. Malformed declarations are ignored silently.
6
7
  const EVENT_ATTR = "data-aci-event";
@@ -35,13 +36,13 @@ function extractProps(el) {
35
36
  }
36
37
  return props;
37
38
  }
38
- function declaredEvent(el, debug) {
39
+ function declaredEvent(el, url, debug) {
39
40
  const eventName = toCustomEventName(el.getAttribute(EVENT_ATTR));
40
41
  if (eventName === undefined) {
41
42
  debug?.("capture", { reason: "malformed_declaration", attr: EVENT_ATTR });
42
43
  return undefined;
43
44
  }
44
- return { eventName, properties: extractProps(el) };
45
+ return { eventName, url, properties: extractProps(el) };
45
46
  }
46
47
  function trigger(el) {
47
48
  return el.getAttribute(ON_ATTR)?.trim().toLowerCase() ?? "click";
@@ -53,6 +54,7 @@ function trigger(el) {
53
54
  */
54
55
  export function startDeclarative(emit, env, refresh) {
55
56
  const { document: doc, debug } = env;
57
+ const loc = doc.defaultView?.location;
56
58
  doc.addEventListener("click", (e) => {
57
59
  try {
58
60
  const target = e.target;
@@ -66,7 +68,7 @@ export function startDeclarative(emit, env, refresh) {
66
68
  return;
67
69
  if (trigger(el) !== "click")
68
70
  return; // visible or malformed trigger
69
- const event = declaredEvent(el, debug);
71
+ const event = declaredEvent(el, loc?.href, debug);
70
72
  if (event)
71
73
  emit(event);
72
74
  }
@@ -92,7 +94,7 @@ export function startDeclarative(emit, env, refresh) {
92
94
  continue;
93
95
  fired.add(entry.target);
94
96
  obs.unobserve(entry.target); // once per element per page view
95
- const event = declaredEvent(entry.target, debug);
97
+ const event = declaredEvent(entry.target, loc?.href, debug);
96
98
  if (event)
97
99
  emit(event);
98
100
  }
@@ -39,12 +39,21 @@ export interface RouteDeps {
39
39
  marker: () => boolean;
40
40
  gapBuffer: GapBuffer;
41
41
  batcher: EventSink;
42
+ /**
43
+ * Whether a CMP-preset site's consent decision is still pending and no
44
+ * identity exists (consenthold.ts). Absent means never pending.
45
+ */
46
+ consentPending?: () => boolean;
47
+ /** In-memory hold for consent-pending events (consenthold.ts). */
48
+ consentHold?: EventSink;
42
49
  }
43
50
  /**
44
- * routeEvent implements the emit routing (design/05): events captured in
45
- * the consent gap — envelope grants personalization AND the marker is
46
- * absent — are buffered; everything else goes straight to the batcher (the
47
- * collector enforces consent server-side).
51
+ * routeEvent implements the emit routing (design/05): events captured while
52
+ * a CMP's consent decision is pending (and no identity exists) are held in
53
+ * page memory until the decision; events captured in the consent gap —
54
+ * envelope grants personalization AND the marker is absent — are buffered;
55
+ * everything else goes straight to the batcher (the collector enforces
56
+ * consent server-side).
48
57
  */
49
58
  export declare function routeEvent(event: CollectEvent, deps: RouteDeps): void;
50
59
  /**
@@ -104,12 +104,18 @@ export function hasMintMarker(doc) {
104
104
  return false;
105
105
  }
106
106
  /**
107
- * routeEvent implements the emit routing (design/05): events captured in
108
- * the consent gap — envelope grants personalization AND the marker is
109
- * absent — are buffered; everything else goes straight to the batcher (the
110
- * collector enforces consent server-side).
107
+ * routeEvent implements the emit routing (design/05): events captured while
108
+ * a CMP's consent decision is pending (and no identity exists) are held in
109
+ * page memory until the decision; events captured in the consent gap —
110
+ * envelope grants personalization AND the marker is absent — are buffered;
111
+ * everything else goes straight to the batcher (the collector enforces
112
+ * consent server-side).
111
113
  */
112
114
  export function routeEvent(event, deps) {
115
+ if (deps.consentHold && deps.consentPending?.()) {
116
+ deps.consentHold.push(event);
117
+ return;
118
+ }
113
119
  if (deps.grants() && !deps.marker()) {
114
120
  deps.gapBuffer.push(event);
115
121
  return;
@@ -3,9 +3,11 @@
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
+ import { createAssignments, readAssignments, withAssignmentContext } from "./assignments.js";
7
7
  import { Batcher } from "./batcher.js";
8
+ import { CONSENT_BANNER_DECIDED_EVENT, consentBannerPending, } from "../consent-banner/index.js";
8
9
  import { createDebug, readTagConfig } from "./config.js";
10
+ import { ConsentHold, resolveConsentHold } from "./consenthold.js";
9
11
  import { start as startC15t } from "./consent/adapters/c15t.js";
10
12
  import { start as startCookiebot } from "./consent/adapters/cookiebot.js";
11
13
  import { start as startOneTrust } from "./consent/adapters/onetrust.js";
@@ -74,6 +76,33 @@ export function init(win) {
74
76
  return false;
75
77
  }
76
78
  };
79
+ const decided = () => {
80
+ try {
81
+ return readConsentCookie(doc) !== undefined;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ };
87
+ // Consent-pending hold (personalization-onboarding REQ-15): a decision
88
+ // is pending when a consent integration has declared it will write the
89
+ // envelope and has not yet. A CMP preset is that declaration; under the
90
+ // manual preset it is ACI's own consent banner, through its state API.
91
+ // Until the decision, and while no identity exists, the collector could
92
+ // only drop what we send, so events wait in page memory instead. A
93
+ // manual site with no banner keeps immediate sending.
94
+ const integrationPending = cfg.preset === "manual"
95
+ ? () => {
96
+ try {
97
+ return consentBannerPending(doc);
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ : () => true;
104
+ const consentHold = new ConsentHold({ debug });
105
+ const consentPending = () => !decided() && !marker() && integrationPending();
77
106
  const transportEnv = {
78
107
  navigator: w.navigator,
79
108
  fetch: typeof w.fetch === "function"
@@ -85,9 +114,14 @@ export function init(win) {
85
114
  batcher = new Batcher({
86
115
  send: (body) => send(body, transportEnv),
87
116
  debug,
88
- // The marker is re-checked on every flush tick, so a mint by another
89
- // tab or navigation is noticed.
90
- onTick: () => maybeFlushGapBuffer(gapBuffer, batcher, marker, debug),
117
+ // Every flush tick re-checks the consent decision (so a decision
118
+ // written by another tab or site code releases or discards the hold)
119
+ // and then the marker (so a mint by another tab or navigation is
120
+ // noticed). Held events are older than gap-buffered ones: release first.
121
+ onTick: () => {
122
+ resolveConsentHold(consentHold, { decided, grants, batcher, debug });
123
+ maybeFlushGapBuffer(gapBuffer, batcher, marker, debug);
124
+ },
91
125
  timers: {
92
126
  setInterval: (fn, ms) => w.setInterval(fn, ms),
93
127
  clearInterval: (id) => w.clearInterval(id),
@@ -95,9 +129,8 @@ export function init(win) {
95
129
  documentTarget: doc,
96
130
  windowTarget: w,
97
131
  });
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
132
+ // Every emitted event carries all active assignments (REQ-3) unless it
133
+ // already supplies its explicit context — exposures and explicit conversions carry their own. The handle
101
134
  // is created (without emitting) BEFORE emit so every dependency of emit
102
135
  // exists before emit can run; exposures flow at start() below.
103
136
  const assignments = createAssignments({ document: doc, window: w, debug });
@@ -105,16 +138,33 @@ export function init(win) {
105
138
  routeEvent({
106
139
  ...withAssignmentContext(event, assignments),
107
140
  eventId: ulid(undefined, (bytes) => w.crypto.getRandomValues(bytes)),
108
- }, { grants, marker, gapBuffer, batcher });
141
+ }, { grants, marker, gapBuffer, batcher, consentHold, consentPending });
109
142
  };
143
+ // A decision written through the SDK (or announced by the ACI banner)
144
+ // flushes at once: the tick releases (grant) or discards (anything else)
145
+ // the hold, and a released first view leaves before the visitor can
146
+ // navigate away.
147
+ const onDecision = () => batcher.flush();
148
+ doc.addEventListener(CONSENT_BANNER_DECIDED_EVENT, onDecision);
110
149
  // Started BEFORE the command-queue drain and page views: pre-load
111
150
  // aci('track', ...) calls and the initial rum.page_view must be stamped
112
151
  // with the active assignment context, or queued conversions can never
113
152
  // match a goal (review-quorum finding). Consent commands drained after
114
153
  // this stay safe — events wait in the gap buffer until grants resolve.
115
154
  assignments.start(emit);
155
+ let emitPageView = () => { };
156
+ const navigation = installNavigationHook(w, [
157
+ contexts => assignments.rearm(contexts),
158
+ () => emitPageView(),
159
+ ], () => assignments.clear());
160
+ // Astro dispatches this nonbubbling event on document after the router commits.
161
+ doc.addEventListener("astro:page-load", () => navigation.complete({
162
+ url: w.location.href,
163
+ assignments: readAssignments(doc, debug),
164
+ }));
116
165
  installCommandQueue(w, {
117
- track: (name, properties) => emit({ eventName: name, properties }),
166
+ navigationComplete: completion => navigation.complete(completion),
167
+ track: (name, properties) => emit({ eventName: name, properties, url: w.location.href }),
118
168
  consent: ({ p, src }) => {
119
169
  const purposes = sanitizePurposes(p);
120
170
  if (purposes === undefined) {
@@ -126,20 +176,12 @@ export function init(win) {
126
176
  debug("consent", { reason: "malformed_source" });
127
177
  return;
128
178
  }
129
- if (!applyConsent(purposes, source, { document: doc, gapBuffer })) {
179
+ if (!applyConsent(purposes, source, { document: doc, gapBuffer, onDecision })) {
130
180
  debug("consent", { reason: "oversized_envelope" });
131
181
  }
132
182
  },
133
183
  }, debug);
134
- const emitPageView = startPageViews(emit, w);
135
- // The single history patch (one pushState/replaceState wrap, one dedupe
136
- // state, one popstate listener). Order is load-bearing: the assignments
137
- // re-arm runs BEFORE the SPA page view so rum.page_view is stamped with
138
- // the new page's experiment context.
139
- installNavigationHook(w, [
140
- () => assignments.rearm(),
141
- emitPageView,
142
- ]);
184
+ emitPageView = startPageViews(emit, w);
143
185
  startDeclarative(emit, {
144
186
  document: doc,
145
187
  intersectionObserver: w.IntersectionObserver,
@@ -169,6 +211,7 @@ export function init(win) {
169
211
  document: doc,
170
212
  categories: cfg.categories,
171
213
  gapBuffer,
214
+ onDecision,
172
215
  debug,
173
216
  scheduler: {
174
217
  setTimeout: (fn, ms) => w.setTimeout(fn, ms),
@@ -177,6 +220,10 @@ export function init(win) {
177
220
  });
178
221
  }
179
222
  batcher.start();
223
+ // Registered after batcher.start(), so the batcher's own pagehide flush
224
+ // has already released a hold whose decision exists; whatever remains is
225
+ // undecided and never leaves the page.
226
+ w.addEventListener("pagehide", () => consentHold.discard("unload"));
180
227
  maybeFlushGapBuffer(gapBuffer, batcher, marker, debug);
181
228
  debug("init", { config: cfg });
182
229
  }
@@ -1,3 +1,4 @@
1
+ import type { ExperimentAssignment } from "./assignments.js";
1
2
  import type { PendingEvent } from "./batcher.js";
2
3
  export declare const PAGE_VIEW_EVENT = "rum.page_view";
3
4
  interface HistoryLike {
@@ -14,17 +15,16 @@ export interface PageViewWindow {
14
15
  history: HistoryLike;
15
16
  addEventListener(type: string, fn: () => void): void;
16
17
  }
17
- /**
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.
26
- */
27
- export declare function installNavigationHook(win: PageViewWindow, subscribers: ReadonlyArray<() => void>): void;
18
+ export interface NavigationCompletion {
19
+ /** Absolute URL of the page the framework just committed. */
20
+ url: string;
21
+ /** Complete committed assignment set, including [] for a base page. */
22
+ assignments: ExperimentAssignment[];
23
+ }
24
+ /** History signals invalidate context; only framework completion arms a view. */
25
+ export declare function installNavigationHook(win: PageViewWindow, subscribers: ReadonlyArray<(assignments: ExperimentAssignment[]) => void>, onStart?: () => void): {
26
+ complete(completion: NavigationCompletion): void;
27
+ };
28
28
  /**
29
29
  * startPageViews emits the initial page view and returns the emitter for
30
30
  * SPA route-change views — init subscribes it to the single navigation hook
@@ -1,45 +1,50 @@
1
1
  // Page views (design/04): eventName "rum.page_view" with url (the full href
2
2
  // including query) and referrer — the SDK never populates utm, ingest
3
3
  // derives it from the URL. One event per document load plus one per
4
- // history-API route change; hash-only changes and same-URL repeats never
4
+ // completed framework route change; hash-only changes and same-URL repeats never
5
5
  // double count.
6
6
  export const PAGE_VIEW_EVENT = "rum.page_view";
7
7
  function stripHash(href) {
8
8
  const i = href.indexOf("#");
9
9
  return i === -1 ? href : href.slice(0, i);
10
10
  }
11
- /**
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.
20
- */
21
- export function installNavigationHook(win, subscribers) {
22
- let last = stripHash(win.location.href);
11
+ /** History signals invalidate context; only framework completion arms a view. */
12
+ export function installNavigationHook(win, subscribers, onStart = () => { }) {
13
+ let observed = stripHash(win.location.href);
14
+ let completed = observed;
15
+ let pending = false;
23
16
  const handler = () => {
24
17
  const current = stripHash(win.location.href);
25
- if (current === last)
26
- return; // hash-only change or same-URL repeat
27
- last = current;
28
- for (const onNavigate of subscribers)
29
- onNavigate();
18
+ if (current === observed)
19
+ return;
20
+ observed = current;
21
+ pending = true;
22
+ onStart();
30
23
  };
31
24
  const history = win.history;
32
- const wrap = (method) => {
25
+ for (const method of ["pushState", "replaceState"]) {
33
26
  const original = history[method].bind(history);
34
27
  history[method] = (...args) => {
35
28
  const result = original(...args);
36
29
  handler();
37
30
  return result;
38
31
  };
39
- };
40
- wrap("pushState");
41
- wrap("replaceState");
32
+ }
42
33
  win.addEventListener("popstate", handler);
34
+ return {
35
+ complete({ url, assignments }) {
36
+ const current = stripHash(win.location.href);
37
+ if (stripHash(url) !== current)
38
+ return; // late commit for another route
39
+ handler();
40
+ if (!pending && completed === current)
41
+ return;
42
+ pending = false;
43
+ completed = current;
44
+ for (const onNavigate of subscribers)
45
+ onNavigate(assignments);
46
+ },
47
+ };
43
48
  }
44
49
  /**
45
50
  * startPageViews emits the initial page view and returns the emitter for
@@ -16,6 +16,10 @@ export interface ConsentBannerOptions {
16
16
  /** Callback fired after a consent decision is written. */
17
17
  onDecision?: (granted: boolean) => void;
18
18
  }
19
+ /** Rendered with the page by ConsentBanner.astro, before any script runs. */
20
+ export declare const CONSENT_BANNER_MOUNT_ATTR = "data-aci-consent-banner-mount";
21
+ /** Dispatched on the document right after the banner writes a decision. */
22
+ export declare const CONSENT_BANNER_DECIDED_EVENT = "aci:consent-banner-decided";
19
23
  /**
20
24
  * hasConsentCookie returns true when the aci_consent cookie exists (the user
21
25
  * has already made a decision, regardless of whether they granted or denied).
@@ -23,6 +27,19 @@ export interface ConsentBannerOptions {
23
27
  export declare function hasConsentCookie(doc: {
24
28
  cookie: string;
25
29
  }): boolean;
30
+ /**
31
+ * consentBannerPending is the banner's state API for the collect SDK
32
+ * (personalization-onboarding REQ-15): true while this page's ACI consent
33
+ * banner awaits the visitor's decision, meaning the banner is declared (its
34
+ * mount point, which ConsentBanner.astro renders with the HTML) or mounted,
35
+ * and no aci_consent decision exists. Because the mount point ships in the
36
+ * HTML, the answer is already right for the first deferred script to run,
37
+ * whether the collect SDK or the banner's own script loads first.
38
+ */
39
+ export declare function consentBannerPending(doc: {
40
+ cookie: string;
41
+ querySelector(selectors: string): unknown;
42
+ }): boolean;
26
43
  /**
27
44
  * mountConsentBanner checks for an existing consent cookie. If none exists,
28
45
  * it injects a banner into the document body and wires up accept/reject
@@ -18,6 +18,10 @@ const DEFAULTS = {
18
18
  const BANNER_ID = "aci-consent-banner";
19
19
  const BANNER_ATTR = "data-aci-consent-banner";
20
20
  const BANNER_HIDDEN = "aci-consent-banner--hidden";
21
+ /** Rendered with the page by ConsentBanner.astro, before any script runs. */
22
+ export const CONSENT_BANNER_MOUNT_ATTR = "data-aci-consent-banner-mount";
23
+ /** Dispatched on the document right after the banner writes a decision. */
24
+ export const CONSENT_BANNER_DECIDED_EVENT = "aci:consent-banner-decided";
21
25
  /**
22
26
  * hasConsentCookie returns true when the aci_consent cookie exists (the user
23
27
  * has already made a decision, regardless of whether they granted or denied).
@@ -25,6 +29,20 @@ const BANNER_HIDDEN = "aci-consent-banner--hidden";
25
29
  export function hasConsentCookie(doc) {
26
30
  return readConsentCookie(doc) !== undefined;
27
31
  }
32
+ /**
33
+ * consentBannerPending is the banner's state API for the collect SDK
34
+ * (personalization-onboarding REQ-15): true while this page's ACI consent
35
+ * banner awaits the visitor's decision, meaning the banner is declared (its
36
+ * mount point, which ConsentBanner.astro renders with the HTML) or mounted,
37
+ * and no aci_consent decision exists. Because the mount point ships in the
38
+ * HTML, the answer is already right for the first deferred script to run,
39
+ * whether the collect SDK or the banner's own script loads first.
40
+ */
41
+ export function consentBannerPending(doc) {
42
+ if (hasConsentCookie(doc))
43
+ return false;
44
+ return doc.querySelector(`[${CONSENT_BANNER_MOUNT_ATTR}], [${BANNER_ATTR}]`) !== null;
45
+ }
28
46
  /**
29
47
  * mountConsentBanner checks for an existing consent cookie. If none exists,
30
48
  * it injects a banner into the document body and wires up accept/reject
@@ -47,6 +65,11 @@ export function mountConsentBanner(doc, opts = {}) {
47
65
  const purposes = granted ? [PERSONALIZATION_PURPOSE] : [];
48
66
  if (!applyConsent(purposes, config.source, { document: doc }))
49
67
  return;
68
+ // Tell a collect SDK on this page to act on the decision now rather than
69
+ // at its next flush tick (it releases or discards what it held).
70
+ const EventCtor = doc.defaultView?.Event;
71
+ if (EventCtor)
72
+ doc.dispatchEvent(new EventCtor(CONSENT_BANNER_DECIDED_EVENT));
50
73
  banner.classList.add(BANNER_HIDDEN);
51
74
  setTimeout(cleanup, 300);
52
75
  opts.onDecision?.(granted);
@@ -19,8 +19,16 @@ export interface FragmentEntry {
19
19
  digest: string;
20
20
  fragmentRefs?: string[];
21
21
  }
22
+ export interface LayoutVariantEntry {
23
+ payloadRef: string;
24
+ digest: string;
25
+ slots: LayoutSlot[];
26
+ defaults?: Record<string, FragmentRef[]>;
27
+ }
22
28
  export interface LayoutEntry {
23
29
  name: string;
30
+ digest?: string;
31
+ variants?: Record<string, LayoutVariantEntry>;
24
32
  payloadRef: string;
25
33
  slots: LayoutSlot[];
26
34
  defaults?: Record<string, FragmentRef[]>;
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { createRegistry, registryLookup } from './registry.js';
4
4
  export type { Registry, RegistryEntry, AnyComponent } from './registry.js';
5
5
  export { blockRef, blockRefArray } from './types/block-ref.js';
6
6
  export type { BlockRef, BlockRefEntry, BlockRefMeta, BlockRefUnion } from './types/block-ref.js';
7
- export type { ComponentContract, ComponentContractID, ComponentContractValidator, ComponentRegistry, ComponentValidationContext, ComponentValidationIssue, InferComponentProps, } from './types/component.js';
7
+ export type { ComponentContract, ComponentContractID, ComponentContractValidator, ComponentRegistry, ComponentValidationContext, ComponentValidationIssue, InferComponentProps, RootIdentityProps, } from './types/component.js';
8
8
  export { ImageSchema, imageAttrs, primaryImageSource, type Image, type ImageSource, type ImageAttrs, type ImageSlotContract, type SlotOutput, } from './types/image.js';
9
9
  export { LinkSchema, type Link, type LinkTarget, type LinkResolverContext, resolveLink, resolveHref, assertSafeHref, normalizeLinkRel, } from './types/link.js';
10
10
  export { HeadingSchema, InlineContentSchema, RichTextValueSchema, type Heading, type InlineContent, type RichTextValue, } from './types/rich-text.js';
@@ -2,6 +2,7 @@ import type { IdentitySigningKeys, SigningKey, CookieMutation, ConsentSnapshot,
2
2
  export declare const DEFAULT_IDENTITY_TTL_SECONDS: number;
3
3
  export declare function parseCookieHeader(value: string | null): Map<string, string>;
4
4
  export declare function consentSignals(request: Request, cookies: Map<string, string>): ConsentSignals;
5
+ export declare function identityIssuancePermitted(consent: ConsentSignals, country: string, optOutCountries: readonly string[]): boolean;
5
6
  export declare function parseConsent(raw: string, decodeCookieValue?: boolean): ConsentSnapshot | null;
6
7
  export declare function trimConsentWhitespace(value: string): string;
7
8
  export declare function verifyIdentityCookie(value: string, keys: IdentitySigningKeys): Promise<string | null>;
@@ -12,3 +13,7 @@ export declare function sha256Hex(value: string): Promise<string>;
12
13
  export declare function verifyClaims(value: string, keys: Record<string, string>, now: Date): Promise<Record<string, unknown> | null>;
13
14
  export declare function cookieMutation(name: string, value: string, maxAge: number, now: Date, secure: boolean, httpOnly: boolean): CookieMutation;
14
15
  export declare function expireCookie(name: string, secure: boolean): CookieMutation;
16
+ export declare function verifyHMAC(value: string, signature: string, secret: string): Promise<boolean>;
17
+ export declare function hmacSHA256(value: string, secret: string): Promise<string>;
18
+ export declare function base64URLEncode(bytes: Uint8Array): string;
19
+ export declare function base64URLDecode(value: string): Uint8Array;