@gradial/aci 0.1.41 → 0.1.43
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/CHANGELOG.md +15 -11
- package/README.md +4 -4
- 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/audience-data-models.d.ts +286 -0
- package/dist/audience-data-models.js +46 -0
- package/dist/cli/compile-registry.d.mts +6 -0
- package/dist/cli/compile-registry.mjs +774 -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 +32 -0
- package/dist/cli/validate-content.mjs +464 -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/aci.js +1 -0
- package/dist/collect/aci.js.sha256 +1 -0
- package/dist/collect/assignments.d.ts +65 -0
- package/dist/collect/assignments.js +138 -0
- package/dist/collect/batcher.d.ts +23 -0
- package/dist/collect/batcher.js +28 -6
- package/dist/collect/consent/envelope.d.ts +9 -3
- package/dist/collect/consent/envelope.js +58 -40
- package/dist/collect/declarative.d.ts +1 -1
- package/dist/collect/declarative.js +7 -1
- package/dist/collect/index.js +39 -6
- package/dist/collect/linkclicks.js +4 -2
- package/dist/collect/pageviews.d.ts +15 -3
- package/dist/collect/pageviews.js +28 -13
- package/dist/collect/transport.d.ts +4 -0
- package/dist/collect/transport.js +37 -2
- package/dist/consent/portable-envelope.d.ts +9 -0
- package/dist/consent/portable-envelope.js +110 -0
- package/dist/consent-banner/index.d.ts +33 -0
- package/dist/consent-banner/index.js +100 -0
- package/dist/content/assets.d.ts +1 -0
- package/dist/content/assets.js +1 -0
- package/dist/content/personalization.d.ts +35 -0
- package/dist/content/personalization.js +57 -0
- package/dist/content/resolve-slots.js +1 -0
- package/dist/content/types.d.ts +9 -1
- package/dist/edge/personalization-core.d.ts +11 -0
- package/dist/edge/personalization-core.js +265 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -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 +422 -0
- package/dist/next/envelope-digest.d.ts +21 -0
- package/dist/next/envelope-digest.js +58 -0
- package/dist/next/index.d.ts +1 -0
- package/dist/next/index.js +1 -0
- package/dist/next/middleware.d.ts +31 -3
- package/dist/next/middleware.js +254 -30
- package/dist/next/page.js +2 -1
- package/dist/next/personalization-host.d.ts +127 -0
- package/dist/next/personalization-host.js +257 -0
- package/dist/next/server.d.ts +1 -0
- package/dist/next/server.js +9 -6
- package/dist/next/vercel-collect-sdk-route.d.ts +27 -0
- package/dist/next/vercel-collect-sdk-route.js +205 -0
- package/dist/next/vercel-personalization.d.ts +4 -0
- package/dist/next/vercel-personalization.js +4 -0
- package/dist/next/vercel-s3-personalization.d.ts +79 -0
- package/dist/next/vercel-s3-personalization.js +574 -0
- package/dist/providers/s3.d.ts +20 -5
- package/dist/providers/s3.js +93 -23
- package/dist/react/VideoPlayer.d.ts +6 -6
- package/dist/react/index.d.ts +6 -6
- package/dist/testing/conformance/checks.js +75 -4
- package/dist/types/block-ref.d.ts +2 -0
- package/dist/types/block-ref.js +1 -0
- package/dist/types/page.d.ts +1 -1
- package/package.json +44 -15
- package/src/astro/ConsentBanner.astro +62 -0
- package/src/cli/compile-registry.mjs +24 -10
- package/src/cli/validate-content.mjs +52 -13
- package/src/cli/verify-next-cdn-routes.mjs +5 -4
- package/src/consent-banner/styles.css +110 -0
- package/src/types/block-ref.test.ts +45 -0
- package/src/types/block-ref.ts +2 -0
- 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/fixtures/atomicBlocks.ts +7 -6
- package/templates/astro/starter-content/src/fixtures/composition.ts +3 -2
- package/templates/astro/starter-content/src/fixtures/sections.ts +2 -1
- 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/.agents/skills/authoring/SKILL.md +11 -3
- package/templates/common/.agents/skills/authoring/references/block-slots.md +3 -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 +124 -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/common/starter-content/.content/pages/about/_index.json +3 -0
- package/templates/common/starter-content/.content/pages/home/_index.json +9 -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 +41 -0
- package/templates/nextjs/template/src/app/collect/route.ts +18 -0
- package/templates/nextjs/template/src/middleware.ts +36 -2
- package/templates/nextjs/template/src/middleware.ts.tmpl +31 -1
- 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
package/dist/collect/index.js
CHANGED
|
@@ -3,18 +3,19 @@
|
|
|
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";
|
|
9
10
|
import { start as startCookiebot } from "./consent/adapters/cookiebot.js";
|
|
10
11
|
import { start as startOneTrust } from "./consent/adapters/onetrust.js";
|
|
11
12
|
import { start as startTrustArc } from "./consent/adapters/trustarc.js";
|
|
12
|
-
import { applyConsent, envelopeGrantsPersonalization, readConsentCookie, sanitizePurposes, } from "./consent/envelope.js";
|
|
13
|
+
import { applyConsent, envelopeGrantsPersonalization, readConsentCookie, sanitizeConsentSource, sanitizePurposes, } from "./consent/envelope.js";
|
|
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 }) => {
|
|
@@ -108,14 +121,34 @@ export function init(win) {
|
|
|
108
121
|
debug("consent", { reason: "malformed_purposes" });
|
|
109
122
|
return;
|
|
110
123
|
}
|
|
111
|
-
|
|
124
|
+
const source = sanitizeConsentSource(src);
|
|
125
|
+
if (source === undefined) {
|
|
126
|
+
debug("consent", { reason: "malformed_source" });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (!applyConsent(purposes, source, { document: doc, gapBuffer })) {
|
|
130
|
+
debug("consent", { reason: "oversized_envelope" });
|
|
131
|
+
}
|
|
112
132
|
},
|
|
113
133
|
}, debug);
|
|
114
|
-
startPageViews(emit, w);
|
|
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
|
+
]);
|
|
115
143
|
startDeclarative(emit, {
|
|
116
144
|
document: doc,
|
|
117
145
|
intersectionObserver: w.IntersectionObserver,
|
|
118
146
|
debug,
|
|
147
|
+
}, () => {
|
|
148
|
+
void refreshClaims(transportEnv).then((refreshed) => {
|
|
149
|
+
if (refreshed)
|
|
150
|
+
w.location.reload();
|
|
151
|
+
});
|
|
119
152
|
});
|
|
120
153
|
startLinkClicks(emit, { document: doc, debug });
|
|
121
154
|
startWebVitals(emit, {
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// document; the closest a[href] ancestor of the click target is recorded.
|
|
4
4
|
// target="_blank" links are still captured — the click event is useful RUM
|
|
5
5
|
// even when the browser opens a new tab, and dropping them would lose a
|
|
6
|
-
// measurable fraction of outbound intent.
|
|
6
|
+
// measurable fraction of outbound intent. Capture phase keeps application
|
|
7
|
+
// stopPropagation handlers from suppressing telemetry. Errors are swallowed
|
|
8
|
+
// silently.
|
|
7
9
|
export const LINK_CLICK_EVENT = "rum.link_click";
|
|
8
10
|
/**
|
|
9
11
|
* startLinkClicks installs one delegated click listener on document that
|
|
@@ -31,5 +33,5 @@ export function startLinkClicks(emit, env) {
|
|
|
31
33
|
catch (err) {
|
|
32
34
|
debug?.("capture", { reason: "link_click_failed", error: String(err) });
|
|
33
35
|
}
|
|
34
|
-
});
|
|
36
|
+
}, { capture: true });
|
|
35
37
|
}
|
|
@@ -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,12 +7,15 @@ 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. */
|
|
13
14
|
navigator?: BeaconNavigator | undefined;
|
|
14
15
|
fetch?: FetchLike | undefined;
|
|
15
16
|
debug?: DebugFn;
|
|
17
|
+
/** Injectable only so refresh-envelope identity is deterministic in tests. */
|
|
18
|
+
createEnvelopeId?: () => string;
|
|
16
19
|
}
|
|
17
20
|
/**
|
|
18
21
|
* send posts one serialized batch envelope to the relative collect path.
|
|
@@ -20,3 +23,4 @@ export interface TransportEnv {
|
|
|
20
23
|
* never affected.
|
|
21
24
|
*/
|
|
22
25
|
export declare function send(body: string, env?: TransportEnv): void;
|
|
26
|
+
export declare function refreshClaims(env?: TransportEnv): Promise<boolean>;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Transport (design/05): navigator.sendBeacon("/collect", Blob text/plain)
|
|
2
2
|
// with a keepalive-fetch fallback. Fire-and-forget — no retry loop in v1;
|
|
3
|
-
//
|
|
4
|
-
// stay exactly same-origin
|
|
3
|
+
// the sealed envelopeId and bytes are the identity any future retry layer
|
|
4
|
+
// must reuse. The path is RELATIVE so requests stay exactly same-origin
|
|
5
|
+
// (data-plane design/04).
|
|
6
|
+
import { ulid } from "./ulid.js";
|
|
5
7
|
export const COLLECT_PATH = "/collect";
|
|
6
8
|
/**
|
|
7
9
|
* send posts one serialized batch envelope to the relative collect path.
|
|
@@ -50,3 +52,36 @@ export function send(body, env) {
|
|
|
50
52
|
debug?.("flush", { reason: "fetch_failed", error: String(err) });
|
|
51
53
|
}
|
|
52
54
|
}
|
|
55
|
+
// refreshClaims deliberately uses fetch rather than sendBeacon. The host's
|
|
56
|
+
// response may refresh the visitor's audience state, so this explicit action
|
|
57
|
+
// must make a normal same-origin request with an empty event envelope.
|
|
58
|
+
export function refreshClaims(env) {
|
|
59
|
+
const fetchFn = env !== undefined && "fetch" in env
|
|
60
|
+
? env.fetch
|
|
61
|
+
: globalThis.fetch;
|
|
62
|
+
if (!fetchFn)
|
|
63
|
+
return Promise.resolve(false);
|
|
64
|
+
try {
|
|
65
|
+
const envelopeId = (env?.createEnvelopeId ?? ulid)();
|
|
66
|
+
const body = new Blob([`{"envelopeId":"${envelopeId}","events":[]}`], { type: "text/plain" });
|
|
67
|
+
return fetchFn(COLLECT_PATH, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
body,
|
|
70
|
+
keepalive: true,
|
|
71
|
+
credentials: "same-origin",
|
|
72
|
+
}).then((response) => {
|
|
73
|
+
if (typeof response === "object" && response !== null && "ok" in response && response.ok === false) {
|
|
74
|
+
env?.debug?.("flush", { reason: "refresh_failed", status: "not_ok" });
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}).catch((err) => {
|
|
79
|
+
env?.debug?.("flush", { reason: "refresh_failed", error: String(err) });
|
|
80
|
+
return false;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
env?.debug?.("flush", { reason: "refresh_failed", error: String(err) });
|
|
85
|
+
return Promise.resolve(false);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface PortableConsentSnapshot {
|
|
2
|
+
version: number;
|
|
3
|
+
purposes: string[];
|
|
4
|
+
grantedAt: string;
|
|
5
|
+
source?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function parsePortableConsentEnvelope(raw: string, decodeCookieValue?: boolean): PortableConsentSnapshot | null;
|
|
8
|
+
export declare function portableConsentAllowsPersonalization(snapshot: PortableConsentSnapshot | null): boolean;
|
|
9
|
+
export declare function trimPortableConsentWhitespace(value: string): string;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const encoder = new TextEncoder();
|
|
2
|
+
const MAX_CONSENT_ENVELOPE_BYTES = 2048;
|
|
3
|
+
const MAX_CONSENT_PURPOSES = 32;
|
|
4
|
+
const MAX_CONSENT_PURPOSE_BYTES = 128;
|
|
5
|
+
const MAX_CONSENT_SOURCE_BYTES = 512;
|
|
6
|
+
const MAX_CONSENT_POLICY_VERSION = 2147483647;
|
|
7
|
+
const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/;
|
|
8
|
+
const CONSENT_EDGE_WHITESPACE = /^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g;
|
|
9
|
+
const LEGACY_GRANTS = new Set([
|
|
10
|
+
'true',
|
|
11
|
+
'all',
|
|
12
|
+
'granted',
|
|
13
|
+
'yes',
|
|
14
|
+
'personalization',
|
|
15
|
+
'personalized',
|
|
16
|
+
]);
|
|
17
|
+
export function parsePortableConsentEnvelope(raw, decodeCookieValue = true) {
|
|
18
|
+
let decoded = trimPortableConsentWhitespace(raw);
|
|
19
|
+
if (!decoded)
|
|
20
|
+
return null;
|
|
21
|
+
if (decodeCookieValue) {
|
|
22
|
+
try {
|
|
23
|
+
decoded = decodeURIComponent(decoded);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Evaluate the original cookie value.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
decoded = trimPortableConsentWhitespace(decoded);
|
|
30
|
+
if (!decoded
|
|
31
|
+
|| encoder.encode(decoded).byteLength > MAX_CONSENT_ENVELOPE_BYTES) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (LEGACY_GRANTS.has(decoded.toLowerCase())) {
|
|
35
|
+
return {
|
|
36
|
+
version: 0,
|
|
37
|
+
purposes: ['personalization'],
|
|
38
|
+
grantedAt: '',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(decoded);
|
|
43
|
+
const grantedAt = typeof parsed.t === 'string' ? parsed.t : '';
|
|
44
|
+
if (!Number.isInteger(parsed.v)
|
|
45
|
+
|| parsed.v < 0
|
|
46
|
+
|| parsed.v > MAX_CONSENT_POLICY_VERSION
|
|
47
|
+
|| !Array.isArray(parsed.p)
|
|
48
|
+
|| parsed.p.length > MAX_CONSENT_PURPOSES
|
|
49
|
+
|| !parsed.p.every((item) => typeof item === 'string')
|
|
50
|
+
|| parsed.p.some((item) => encoder.encode(item).byteLength > MAX_CONSENT_PURPOSE_BYTES)
|
|
51
|
+
|| (parsed.t !== undefined
|
|
52
|
+
&& parsed.t !== null
|
|
53
|
+
&& (typeof parsed.t !== 'string'
|
|
54
|
+
|| parsed.t === ''
|
|
55
|
+
|| !isPortableRFC3339(parsed.t)))
|
|
56
|
+
|| (parsed.src !== undefined
|
|
57
|
+
&& parsed.src !== null
|
|
58
|
+
&& typeof parsed.src !== 'string')
|
|
59
|
+
|| (typeof parsed.src === 'string'
|
|
60
|
+
&& encoder.encode(parsed.src).byteLength > MAX_CONSENT_SOURCE_BYTES)) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
version: parsed.v,
|
|
65
|
+
purposes: parsed.p,
|
|
66
|
+
grantedAt,
|
|
67
|
+
source: typeof parsed.src === 'string' ? parsed.src : undefined,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function portableConsentAllowsPersonalization(snapshot) {
|
|
75
|
+
return snapshot?.purposes.some((purpose) => trimPortableConsentWhitespace(purpose).toLowerCase()
|
|
76
|
+
=== 'personalization') || false;
|
|
77
|
+
}
|
|
78
|
+
export function trimPortableConsentWhitespace(value) {
|
|
79
|
+
return value.replace(CONSENT_EDGE_WHITESPACE, '');
|
|
80
|
+
}
|
|
81
|
+
function isPortableRFC3339(value) {
|
|
82
|
+
const match = RFC3339.exec(value);
|
|
83
|
+
if (!match || Number.isNaN(Date.parse(value)))
|
|
84
|
+
return false;
|
|
85
|
+
const year = Number(match[1]);
|
|
86
|
+
const month = Number(match[2]);
|
|
87
|
+
const day = Number(match[3]);
|
|
88
|
+
const hour = Number(match[4]);
|
|
89
|
+
const minute = Number(match[5]);
|
|
90
|
+
const second = Number(match[6]);
|
|
91
|
+
if (month < 1
|
|
92
|
+
|| month > 12
|
|
93
|
+
|| day < 1
|
|
94
|
+
|| day > daysInMonth(year, month)
|
|
95
|
+
|| hour > 23
|
|
96
|
+
|| minute > 59
|
|
97
|
+
|| second > 59) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const zone = match[8];
|
|
101
|
+
return zone === 'Z'
|
|
102
|
+
|| (Number(zone.slice(1, 3)) <= 23
|
|
103
|
+
&& Number(zone.slice(4, 6)) <= 59);
|
|
104
|
+
}
|
|
105
|
+
function daysInMonth(year, month) {
|
|
106
|
+
if (month === 2) {
|
|
107
|
+
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
|
|
108
|
+
}
|
|
109
|
+
return [4, 6, 9, 11].includes(month) ? 30 : 31;
|
|
110
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface ConsentBannerOptions {
|
|
2
|
+
/** Heading text. Default: "Privacy" */
|
|
3
|
+
heading?: string;
|
|
4
|
+
/** Body text explaining what the user is consenting to. */
|
|
5
|
+
message?: string;
|
|
6
|
+
/** Label for the accept button. Default: "Accept" */
|
|
7
|
+
acceptLabel?: string;
|
|
8
|
+
/** Label for the reject button. Default: "Reject" */
|
|
9
|
+
rejectLabel?: string;
|
|
10
|
+
/** Label for a link to the privacy policy. Optional. */
|
|
11
|
+
privacyPolicyLabel?: string;
|
|
12
|
+
/** URL to the privacy policy. Optional. */
|
|
13
|
+
privacyPolicyHref?: string;
|
|
14
|
+
/** Source identifier written to the cookie envelope. Default: "banner" */
|
|
15
|
+
source?: string;
|
|
16
|
+
/** Callback fired after a consent decision is written. */
|
|
17
|
+
onDecision?: (granted: boolean) => void;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* hasConsentCookie returns true when the aci_consent cookie exists (the user
|
|
21
|
+
* has already made a decision, regardless of whether they granted or denied).
|
|
22
|
+
*/
|
|
23
|
+
export declare function hasConsentCookie(doc: {
|
|
24
|
+
cookie: string;
|
|
25
|
+
}): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* mountConsentBanner checks for an existing consent cookie. If none exists,
|
|
28
|
+
* it injects a banner into the document body and wires up accept/reject
|
|
29
|
+
* buttons. If a cookie already exists, it does nothing (idempotent).
|
|
30
|
+
*
|
|
31
|
+
* Returns a cleanup function that removes the banner and event listeners.
|
|
32
|
+
*/
|
|
33
|
+
export declare function mountConsentBanner(doc: Document, opts?: ConsentBannerOptions): () => void;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// First-party consent banner (design/04, preset="manual"): a self-contained
|
|
2
|
+
// client-side component for sites that don't have an external CMP (c15t,
|
|
3
|
+
// OneTrust, etc.). Checks for the aci_consent cookie, shows a banner if
|
|
4
|
+
// absent, and calls applyConsent() on user decision.
|
|
5
|
+
//
|
|
6
|
+
// The banner is framework-agnostic — it renders plain DOM and is driven by
|
|
7
|
+
// CSS classes so each site can style it. The Astro wrapper component
|
|
8
|
+
// (packages/sdk/src/astro/ConsentBanner.astro) imports and invokes this,
|
|
9
|
+
// then applies site-specific styles.
|
|
10
|
+
import { applyConsent, readConsentCookie, PERSONALIZATION_PURPOSE, } from "../collect/consent/envelope.js";
|
|
11
|
+
const DEFAULTS = {
|
|
12
|
+
heading: "Privacy",
|
|
13
|
+
message: "We use cookies to personalize content and improve your experience. You can accept or reject personalization.",
|
|
14
|
+
acceptLabel: "Accept",
|
|
15
|
+
rejectLabel: "Reject",
|
|
16
|
+
source: "banner",
|
|
17
|
+
};
|
|
18
|
+
const BANNER_ID = "aci-consent-banner";
|
|
19
|
+
const BANNER_ATTR = "data-aci-consent-banner";
|
|
20
|
+
const BANNER_HIDDEN = "aci-consent-banner--hidden";
|
|
21
|
+
/**
|
|
22
|
+
* hasConsentCookie returns true when the aci_consent cookie exists (the user
|
|
23
|
+
* has already made a decision, regardless of whether they granted or denied).
|
|
24
|
+
*/
|
|
25
|
+
export function hasConsentCookie(doc) {
|
|
26
|
+
return readConsentCookie(doc) !== undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* mountConsentBanner checks for an existing consent cookie. If none exists,
|
|
30
|
+
* it injects a banner into the document body and wires up accept/reject
|
|
31
|
+
* buttons. If a cookie already exists, it does nothing (idempotent).
|
|
32
|
+
*
|
|
33
|
+
* Returns a cleanup function that removes the banner and event listeners.
|
|
34
|
+
*/
|
|
35
|
+
export function mountConsentBanner(doc, opts = {}) {
|
|
36
|
+
if (hasConsentCookie(doc))
|
|
37
|
+
return () => { };
|
|
38
|
+
if (doc.getElementById(BANNER_ID))
|
|
39
|
+
return () => { };
|
|
40
|
+
const config = { ...DEFAULTS, ...opts };
|
|
41
|
+
const banner = createBannerElement(config, doc);
|
|
42
|
+
doc.body.appendChild(banner);
|
|
43
|
+
const cleanup = () => {
|
|
44
|
+
banner.remove();
|
|
45
|
+
};
|
|
46
|
+
const decide = (granted) => {
|
|
47
|
+
const purposes = granted ? [PERSONALIZATION_PURPOSE] : [];
|
|
48
|
+
if (!applyConsent(purposes, config.source, { document: doc }))
|
|
49
|
+
return;
|
|
50
|
+
banner.classList.add(BANNER_HIDDEN);
|
|
51
|
+
setTimeout(cleanup, 300);
|
|
52
|
+
opts.onDecision?.(granted);
|
|
53
|
+
};
|
|
54
|
+
banner.querySelector("[data-aci-consent-accept]")?.addEventListener("click", () => decide(true));
|
|
55
|
+
banner.querySelector("[data-aci-consent-reject]")?.addEventListener("click", () => decide(false));
|
|
56
|
+
return cleanup;
|
|
57
|
+
}
|
|
58
|
+
function createBannerElement(config, doc) {
|
|
59
|
+
const banner = doc.createElement("div");
|
|
60
|
+
banner.id = BANNER_ID;
|
|
61
|
+
banner.setAttribute(BANNER_ATTR, "");
|
|
62
|
+
banner.className = BANNER_ID;
|
|
63
|
+
const content = doc.createElement("div");
|
|
64
|
+
content.className = `${BANNER_ID}__content`;
|
|
65
|
+
const text = doc.createElement("div");
|
|
66
|
+
text.className = `${BANNER_ID}__text`;
|
|
67
|
+
const heading = doc.createElement("p");
|
|
68
|
+
heading.className = `${BANNER_ID}__heading`;
|
|
69
|
+
heading.textContent = config.heading;
|
|
70
|
+
text.appendChild(heading);
|
|
71
|
+
const message = doc.createElement("p");
|
|
72
|
+
message.className = `${BANNER_ID}__message`;
|
|
73
|
+
message.textContent = config.message;
|
|
74
|
+
text.appendChild(message);
|
|
75
|
+
if (config.privacyPolicyHref && config.privacyPolicyLabel) {
|
|
76
|
+
const link = doc.createElement("a");
|
|
77
|
+
link.className = `${BANNER_ID}__privacy-link`;
|
|
78
|
+
link.href = config.privacyPolicyHref;
|
|
79
|
+
link.textContent = config.privacyPolicyLabel;
|
|
80
|
+
text.appendChild(link);
|
|
81
|
+
}
|
|
82
|
+
content.appendChild(text);
|
|
83
|
+
const actions = doc.createElement("div");
|
|
84
|
+
actions.className = `${BANNER_ID}__actions`;
|
|
85
|
+
const rejectBtn = doc.createElement("button");
|
|
86
|
+
rejectBtn.type = "button";
|
|
87
|
+
rejectBtn.className = `${BANNER_ID}__btn ${BANNER_ID}__btn--reject`;
|
|
88
|
+
rejectBtn.setAttribute("data-aci-consent-reject", "");
|
|
89
|
+
rejectBtn.textContent = config.rejectLabel;
|
|
90
|
+
actions.appendChild(rejectBtn);
|
|
91
|
+
const acceptBtn = doc.createElement("button");
|
|
92
|
+
acceptBtn.type = "button";
|
|
93
|
+
acceptBtn.className = `${BANNER_ID}__btn ${BANNER_ID}__btn--accept`;
|
|
94
|
+
acceptBtn.setAttribute("data-aci-consent-accept", "");
|
|
95
|
+
acceptBtn.textContent = config.acceptLabel;
|
|
96
|
+
actions.appendChild(acceptBtn);
|
|
97
|
+
content.appendChild(actions);
|
|
98
|
+
banner.appendChild(content);
|
|
99
|
+
return banner;
|
|
100
|
+
}
|
package/dist/content/assets.d.ts
CHANGED
package/dist/content/assets.js
CHANGED
|
@@ -19,6 +19,7 @@ export async function getReleaseAssetResponse(input) {
|
|
|
19
19
|
accessKeyId: config.accessKeyId,
|
|
20
20
|
secretAccessKey: config.secretAccessKey,
|
|
21
21
|
sessionToken: config.sessionToken,
|
|
22
|
+
roleArn: config.roleArn,
|
|
22
23
|
endpoint: config.endpoint,
|
|
23
24
|
});
|
|
24
25
|
const key = joinKey(provider.config.keyPrefix, 'assets', clean);
|
|
@@ -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
|
+
}
|
|
@@ -12,6 +12,7 @@ export async function resolveSlots(layout, pageRegions, getFragment) {
|
|
|
12
12
|
for (const ref of defaults) {
|
|
13
13
|
const fragment = await getFragment(ref.fragmentId);
|
|
14
14
|
blocks.push({
|
|
15
|
+
$type: 'component',
|
|
15
16
|
id: fragment.id,
|
|
16
17
|
component: fragment.component,
|
|
17
18
|
props: fragment.props || {},
|