@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.
- package/dist/astro/index.d.ts +1 -1
- package/dist/astro/index.js +1 -1
- package/dist/astro/page.d.ts +6 -0
- package/dist/astro/page.js +3 -2
- package/dist/cli/compile-registry.d.mts +2 -0
- package/dist/cli/compile-registry.mjs +759 -0
- package/dist/cli/materialize-template.d.mts +19 -0
- package/dist/cli/materialize-template.mjs +128 -0
- package/dist/cli/validate-content.d.mts +2 -0
- package/dist/cli/validate-content.mjs +426 -0
- package/dist/cli/verify-next-cdn-routes.d.mts +2 -0
- package/dist/cli/verify-next-cdn-routes.mjs +47 -0
- package/dist/collect/assignments.d.ts +65 -0
- package/dist/collect/assignments.js +138 -0
- package/dist/collect/batcher.d.ts +21 -0
- package/dist/collect/batcher.js +10 -0
- package/dist/collect/declarative.d.ts +1 -1
- package/dist/collect/declarative.js +7 -1
- package/dist/collect/index.js +30 -4
- package/dist/collect/pageviews.d.ts +15 -3
- package/dist/collect/pageviews.js +28 -13
- package/dist/collect/transport.d.ts +2 -0
- package/dist/collect/transport.js +32 -0
- package/dist/content/personalization.d.ts +35 -0
- package/dist/content/personalization.js +57 -0
- package/dist/content/types.d.ts +7 -1
- package/dist/edge/personalization-core.d.ts +11 -0
- package/dist/edge/personalization-core.js +265 -0
- package/dist/next/assignments-script.d.ts +14 -0
- package/dist/next/assignments-script.js +17 -0
- package/dist/next/collect-route.d.ts +19 -0
- package/dist/next/collect-route.js +351 -0
- package/dist/next/index.d.ts +1 -0
- package/dist/next/index.js +1 -0
- package/dist/next/middleware.d.ts +15 -1
- package/dist/next/middleware.js +71 -27
- package/dist/next/page.js +2 -1
- package/dist/next/personalization-host.d.ts +118 -0
- package/dist/next/personalization-host.js +276 -0
- package/dist/next/server.js +6 -6
- package/dist/next/vercel-personalization.d.ts +3 -0
- package/dist/next/vercel-personalization.js +3 -0
- package/dist/next/vercel-s3-personalization.d.ts +58 -0
- package/dist/next/vercel-s3-personalization.js +220 -0
- package/dist/providers/s3.d.ts +1 -1
- package/dist/providers/s3.js +1 -1
- package/dist/react/VideoPlayer.d.ts +4 -4
- package/dist/react/index.d.ts +4 -4
- package/dist/types/page.d.ts +1 -1
- package/package.json +24 -16
- package/src/cli/validate-content.mjs +0 -0
- package/src/cli/verify-next-cdn-routes.mjs +5 -4
- package/src/types/page.ts +1 -1
- package/templates/astro/starter-content/src/cms/contracts/components/index.ts +2 -0
- package/templates/astro/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
- package/templates/astro/starter-content/src/cms/registry.ts +3 -0
- package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +41 -0
- package/templates/astro/starter-content/src/render/RenderPage.astro +3 -2
- package/templates/astro/template/src/layouts/SiteLayout.astro +3 -1
- package/templates/astro/template/src/pages/[...slug].astro +1 -0
- package/templates/common/.content/audiences/preference-blue/_index.json +11 -0
- package/templates/common/.content/audiences/preference-red/_index.json +11 -0
- package/templates/common/.content/overlays/audience/preference-blue/pages/home/_index.json +13 -0
- package/templates/common/.content/overlays/audience/preference-red/pages/home/_index.json +13 -0
- package/templates/common/.content/pages/home/_index.json +114 -4
- package/templates/common/.content/segments/preference-blue/_index.json +20 -0
- package/templates/common/.content/segments/preference-red/_index.json +20 -0
- package/templates/common/contracts/components/index.ts +21 -1
- package/templates/common/contracts/components/personalizationChoice.contract.ts +17 -0
- package/templates/nextjs/starter-content/src/cms/contracts/components/index.ts +3 -1
- package/templates/nextjs/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
- package/templates/nextjs/starter-content/src/cms/registry.ts +3 -0
- package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +53 -0
- package/templates/nextjs/starter-content/src/middleware.ts +11 -0
- package/templates/nextjs/template/src/app/collect/route.ts +18 -0
- package/templates/nextjs/template/src/middleware.ts +6 -2
- package/templates/astro/template/.aci.yaml +0 -28
- package/templates/common/.content/.gradial-dam/blobs/sha256/7f/bf/7fbfc7fe230ff12688c496f660119e41d9725c4504d82e310c15665c73c09292 +0 -0
- package/templates/common/.content/.gradial-dam/blobs/sha256/a0/f5/a0f506689de9b15a7055f70103a3f02cd3ba105771ae2de6754df2ede1210d3a +0 -0
- package/templates/common/.content/.gradial-dam/blobs/sha256/dd/b7/ddb7e9642be08317bcd4b2bd26b58ba8d0d34dd7239e03e684a4f3634fa3845b +0 -0
- package/templates/common/.content/.gradial-dam/blobs/sha256/fb/32/fb3260e8286ea8231ea1c77b9c0be92d6edd9026565731eb14cfdcfd6b1b75b3 +0 -0
- package/templates/common/.content/config/robots.txt +0 -2
- package/templates/common/.content/config/site.json +0 -197
- package/templates/common/.content/dam/folders/seed/ast_site_about_hero/asset.json +0 -9
- package/templates/common/.content/dam/folders/seed/ast_site_about_hero/editorial.json +0 -14
- package/templates/common/.content/dam/folders/seed/ast_site_about_hero/governance.json +0 -23
- package/templates/common/.content/dam/folders/seed/ast_site_about_hero/versions/v0001.json +0 -37
- package/templates/common/.content/dam/folders/seed/ast_site_home_hero/asset.json +0 -9
- package/templates/common/.content/dam/folders/seed/ast_site_home_hero/editorial.json +0 -14
- package/templates/common/.content/dam/folders/seed/ast_site_home_hero/governance.json +0 -23
- package/templates/common/.content/dam/folders/seed/ast_site_home_hero/versions/v0001.json +0 -37
- package/templates/common/.content/fragments/navbar/_index.json +0 -25
- package/templates/common/.content/fragments/site-footer/_index.json +0 -62
- package/templates/common/.content/pages/about/_index.json +0 -51
- 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 {
|
package/dist/collect/batcher.js
CHANGED
|
@@ -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;
|
package/dist/collect/index.js
CHANGED
|
@@ -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
|
-
*
|
|
19
|
-
*
|
|
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
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
+
handler();
|
|
36
37
|
return result;
|
|
37
38
|
};
|
|
38
39
|
};
|
|
39
40
|
wrap("pushState");
|
|
40
41
|
wrap("replaceState");
|
|
41
|
-
win.addEventListener("popstate",
|
|
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, "<")
|
|
53
53
|
.replace(/>/g, ">");
|
|
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
|
+
}
|
package/dist/content/types.d.ts
CHANGED
|
@@ -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
|
+
};
|