@gradial/aci 0.1.28 → 0.1.29
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/README.md +47 -0
- package/bin/aci-darwin-arm64 +0 -0
- package/dist/assets/index.d.ts +1 -1
- package/dist/astro/index.d.ts +1 -0
- package/dist/astro/index.js +7 -0
- package/dist/collect/api.d.ts +24 -0
- package/dist/collect/api.js +63 -0
- package/dist/collect/batcher.d.ts +65 -0
- package/dist/collect/batcher.js +127 -0
- package/dist/collect/config.d.ts +30 -0
- package/dist/collect/config.js +53 -0
- package/dist/collect/consent/adapters/c15t.d.ts +2 -0
- package/dist/collect/consent/adapters/c15t.js +43 -0
- package/dist/collect/consent/adapters/cookiebot.d.ts +2 -0
- package/dist/collect/consent/adapters/cookiebot.js +48 -0
- package/dist/collect/consent/adapters/onetrust.d.ts +2 -0
- package/dist/collect/consent/adapters/onetrust.js +54 -0
- package/dist/collect/consent/adapters/trustarc.d.ts +2 -0
- package/dist/collect/consent/adapters/trustarc.js +65 -0
- package/dist/collect/consent/envelope.d.ts +49 -0
- package/dist/collect/consent/envelope.js +100 -0
- package/dist/collect/consent/mapping.d.ts +62 -0
- package/dist/collect/consent/mapping.js +129 -0
- package/dist/collect/declarative.d.ts +35 -0
- package/dist/collect/declarative.js +100 -0
- package/dist/collect/gapbuffer.d.ts +55 -0
- package/dist/collect/gapbuffer.js +134 -0
- package/dist/collect/index.d.ts +6 -0
- package/dist/collect/index.js +154 -0
- package/dist/collect/linkclicks.d.ts +12 -0
- package/dist/collect/linkclicks.js +35 -0
- package/dist/collect/pageviews.d.ts +22 -0
- package/dist/collect/pageviews.js +42 -0
- package/dist/collect/transport.d.ts +22 -0
- package/dist/collect/transport.js +52 -0
- package/dist/collect/ulid.d.ts +6 -0
- package/dist/collect/ulid.js +36 -0
- package/dist/collect/variationviews.d.ts +31 -0
- package/dist/collect/variationviews.js +80 -0
- package/dist/collect/webvitals.d.ts +38 -0
- package/dist/collect/webvitals.js +99 -0
- package/dist/content/index.d.ts +1 -0
- package/dist/content/index.js +1 -0
- package/dist/content/personalization.d.ts +57 -0
- package/dist/content/personalization.js +54 -0
- package/dist/content/types.d.ts +3 -0
- package/dist/index.d.ts +1 -1
- package/dist/next/collect-script.d.ts +12 -0
- package/dist/next/collect-script.js +15 -0
- package/dist/next/config.js +1 -0
- package/dist/next/index.d.ts +1 -0
- package/dist/next/index.js +1 -0
- package/dist/next/page.d.ts +2 -0
- package/dist/next/page.js +5 -2
- package/dist/providers/file.js +1 -8
- package/dist/react/VideoPlayer.d.ts +3 -3
- package/dist/react/index.d.ts +4 -4
- package/dist/react/index.js +2 -1
- package/dist/types/image.d.ts +37 -11
- package/dist/types/image.js +20 -17
- package/dist/types/media.d.ts +14 -2
- package/dist/types/video.d.ts +7 -1
- package/package.json +1 -1
- package/src/types/image.ts +44 -29
- package/templates/astro/template/src/fixtures/sections.ts +2 -1
- package/templates/astro/template/src/layouts/SiteLayout.astro +77 -1
- package/templates/astro/template/src/pages/[...slug].astro +3 -2
- package/templates/common/.content/config/site.json +5 -0
- package/templates/common/.content/schemas/site.schema.json +15 -0
- package/templates/common/README.md.tmpl +15 -0
- package/templates/nextjs/template/package.json.tmpl +2 -0
- package/templates/nextjs/template/src/app/_components/ConsentManager.tsx +132 -0
- package/templates/nextjs/template/src/app/layout.tsx +3 -1
- package/templates/nextjs/template/src/components/sections/HomeHero.tsx +1 -1
package/README.md
CHANGED
|
@@ -94,6 +94,53 @@ List available templates:
|
|
|
94
94
|
npx @gradial/aci@0.1.27 init --list
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
+
## Personalization: The One-Tag Install
|
|
98
|
+
|
|
99
|
+
Personalization collection is one script tag. Externally rendered sites paste
|
|
100
|
+
it once, verbatim; Gradial-rendered sites (Next `createPage`, the Astro
|
|
101
|
+
starter layout) emit the same tag automatically and never hand-write it:
|
|
102
|
+
|
|
103
|
+
```html
|
|
104
|
+
<script defer src="/_aci/aci.js" data-aci-consent="c15t"></script>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Boundary (v1):** the site MUST be served through a Gradial edge binding
|
|
108
|
+
(CloudFront, Vercel, or the local adapter). The edge serves the SDK at the
|
|
109
|
+
stable `/_aci/aci.js` URL, provides `/collect`, and mints visitor identity —
|
|
110
|
+
none of which exist on fully external hosting, which is out of scope pending
|
|
111
|
+
OQ-6 (REQ-13).
|
|
112
|
+
|
|
113
|
+
Tag attributes (the tag is the only per-site configuration surface):
|
|
114
|
+
|
|
115
|
+
| Attribute | Meaning |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| `data-aci-consent` | CMP preset: `c15t`, `onetrust`, `cookiebot`, `trustarc`, or `manual` (default `manual` — only the JS consent API `aci('consent', …)` writes envelopes) |
|
|
118
|
+
| `data-aci-consent-categories` | Flat comma list of CMP categories that imply the personalization purpose (e.g. `measurement,marketing`); each preset ships a sane default |
|
|
119
|
+
| `data-aci-site` | Optional site ID, debug only — the edge binding is the site authority |
|
|
120
|
+
| `data-aci-debug` | `1` enables structured `console.debug` diagnostics |
|
|
121
|
+
|
|
122
|
+
For Gradial-rendered sites the tag is driven by an optional block on the
|
|
123
|
+
site config doc (`/config/site.json`); absent block = disabled, no tag:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"personalization": {
|
|
128
|
+
"collect": true,
|
|
129
|
+
"consentPreset": "c15t",
|
|
130
|
+
"consentCategories": "measurement,marketing"
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The starter kits default to the `c15t` preset. The Next kit ships a working
|
|
136
|
+
c15t setup (`ConsentManager` in `src/app/_components/`, offline mode by
|
|
137
|
+
default, hosted mode via `NEXT_PUBLIC_C15T_BACKEND_URL`) plus the bridge
|
|
138
|
+
that mirrors the c15t store onto `window.c15t`, where the SDK reads it. c15t
|
|
139
|
+
has no Astro UI package, so the Astro kit ships the bridge only (in
|
|
140
|
+
`SiteLayout.astro`): add a banner via a React island (`@astrojs/react` +
|
|
141
|
+
`@c15t/react`) or any setup built on c15t's store and the bridge picks it
|
|
142
|
+
up; until then only the `manual` JS consent API writes envelopes.
|
|
143
|
+
|
|
97
144
|
## Verify Locally
|
|
98
145
|
|
|
99
146
|
From `packages/sdk`:
|
package/bin/aci-darwin-arm64
CHANGED
|
Binary file
|
package/dist/assets/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { type Image, type ImageSource, type ImageAttrs, ImageSchema, imageAttrs, primaryImageSource, } from '../types/image.js';
|
|
1
|
+
export { type Image, type ImageFallback, type ImageSource, type ImageAttrs, ImageSchema, imageAttrs, primaryImageSource, } from '../types/image.js';
|
|
2
2
|
export { type Video, type VideoSource, VideoSchema, VideoSourceSchema, } from '../types/video.js';
|
|
3
3
|
export { type Asset, AssetSchema, isImage, isVideo, isAsset, } from '../types/media.js';
|
package/dist/astro/index.d.ts
CHANGED
|
@@ -38,6 +38,7 @@ export declare function getPageRuntimeRenderInput(request: Request): RenderInput
|
|
|
38
38
|
export { createRegistry, registryLookup } from '../registry.js';
|
|
39
39
|
export type { Registry, RegistryEntry, AnyComponent } from '../registry.js';
|
|
40
40
|
export type { BlockRef } from '../types/block-ref.js';
|
|
41
|
+
export { collectScriptTagHTML, collectTagAttributes, COLLECT_SCRIPT_SRC, type CollectTagAttributes, type PersonalizationSiteConfig, type SitePersonalizationConfig, } from '../content/personalization.js';
|
|
41
42
|
export interface RenderableBlock {
|
|
42
43
|
Component: AnyComponent | undefined;
|
|
43
44
|
props: Record<string, unknown>;
|
package/dist/astro/index.js
CHANGED
|
@@ -36,6 +36,13 @@ export function getPageRuntimeRenderInput(request) {
|
|
|
36
36
|
// Registry helpers
|
|
37
37
|
// ---------------------------------------------------------------------------
|
|
38
38
|
export { createRegistry, registryLookup } from '../registry.js';
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Personalization collect tag — the Astro integration has no attribute-
|
|
41
|
+
// bearing HTML-emission seam (injectScript injects script *content*, not a
|
|
42
|
+
// tag with data attributes), so the kit layout emits the contract tag via
|
|
43
|
+
// this snippet builder (design/04; design/09 Task 9b).
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
export { collectScriptTagHTML, collectTagAttributes, COLLECT_SCRIPT_SRC, } from '../content/personalization.js';
|
|
39
46
|
export function createRenderChildren(registry) {
|
|
40
47
|
function renderChildren(blocks) {
|
|
41
48
|
return blocks.map((block) => {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { DebugFn } from "./config.js";
|
|
2
|
+
export interface CommandHandlers {
|
|
3
|
+
/** Receives the normalized custom.* event name. */
|
|
4
|
+
track: (name: string, properties?: Record<string, unknown>) => void;
|
|
5
|
+
/** Manual consent write; only reachable under the manual preset. */
|
|
6
|
+
consent: (envelope: {
|
|
7
|
+
p: unknown;
|
|
8
|
+
src: unknown;
|
|
9
|
+
}) => void;
|
|
10
|
+
}
|
|
11
|
+
export interface CommandQueueConfig {
|
|
12
|
+
preset: string;
|
|
13
|
+
}
|
|
14
|
+
interface AciHost {
|
|
15
|
+
aci?: unknown;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* installCommandQueue replaces any pre-load stub on window.aci with the live
|
|
19
|
+
* dispatcher, draining queued calls in order first. Malformed or unknown
|
|
20
|
+
* commands are ignored after a debug record; the CMP preset is authoritative
|
|
21
|
+
* over consent commands (manual mode only).
|
|
22
|
+
*/
|
|
23
|
+
export declare function installCommandQueue(win: AciHost, cfg: CommandQueueConfig, handlers: CommandHandlers, debug?: DebugFn): void;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Browser command API (design/04): window.aci is a write-only command
|
|
2
|
+
// queue. Pre-load calls push onto aci.q via the documented array-push stub;
|
|
3
|
+
// installCommandQueue drains that queue in order and becomes the live
|
|
4
|
+
// dispatcher. Commands return undefined — nothing is readable back.
|
|
5
|
+
import { toCustomEventName } from "./declarative.js";
|
|
6
|
+
function isPlainObjectLike(value) {
|
|
7
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* installCommandQueue replaces any pre-load stub on window.aci with the live
|
|
11
|
+
* dispatcher, draining queued calls in order first. Malformed or unknown
|
|
12
|
+
* commands are ignored after a debug record; the CMP preset is authoritative
|
|
13
|
+
* over consent commands (manual mode only).
|
|
14
|
+
*/
|
|
15
|
+
export function installCommandQueue(win, cfg, handlers, debug) {
|
|
16
|
+
const dispatch = (...args) => {
|
|
17
|
+
try {
|
|
18
|
+
const [command, ...rest] = args;
|
|
19
|
+
if (command === "track") {
|
|
20
|
+
const name = toCustomEventName(rest[0]);
|
|
21
|
+
if (name === undefined) {
|
|
22
|
+
debug?.("capture", { reason: "malformed_track_name", name: rest[0] });
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const properties = rest[1];
|
|
26
|
+
if (properties !== undefined && !isPlainObjectLike(properties)) {
|
|
27
|
+
debug?.("capture", { reason: "malformed_track_properties", name });
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
handlers.track(name, properties);
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
if (command === "consent") {
|
|
34
|
+
if (cfg.preset !== "manual") {
|
|
35
|
+
// The CMP is authoritative (design/04 consent-writer precedence).
|
|
36
|
+
debug?.("consent", { reason: "preset_authoritative", preset: cfg.preset });
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const envelope = rest[0];
|
|
40
|
+
if (!isPlainObjectLike(envelope)) {
|
|
41
|
+
debug?.("consent", { reason: "malformed_consent_envelope" });
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
handlers.consent({ p: envelope.p, src: envelope.src });
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
debug?.("capture", { reason: "unknown_command", command });
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
debug?.("capture", { reason: "command_failed", error: String(err) });
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
};
|
|
54
|
+
const stub = win.aci;
|
|
55
|
+
const queued = stub?.q;
|
|
56
|
+
win.aci = dispatch;
|
|
57
|
+
if (queued == null)
|
|
58
|
+
return;
|
|
59
|
+
for (const call of Array.from(queued)) {
|
|
60
|
+
const args = Array.from(call);
|
|
61
|
+
dispatch(...args);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { DebugFn } from "./config.js";
|
|
2
|
+
/** One collect event on the wire (personalization-data-plane design/04). */
|
|
3
|
+
export interface CollectEvent {
|
|
4
|
+
eventId: string;
|
|
5
|
+
eventName: string;
|
|
6
|
+
occurredAt?: string;
|
|
7
|
+
properties?: Record<string, unknown>;
|
|
8
|
+
url?: string;
|
|
9
|
+
referrer?: string;
|
|
10
|
+
}
|
|
11
|
+
/** An event at capture time, before the eventId is stamped. */
|
|
12
|
+
export type PendingEvent = Omit<CollectEvent, "eventId">;
|
|
13
|
+
/** Planning threshold: full serialized envelope, UTF-8 bytes. */
|
|
14
|
+
export declare const MAX_BATCH_BYTES: number;
|
|
15
|
+
/** Flush interval (build-time constant, design/04 config table). */
|
|
16
|
+
export declare const FLUSH_INTERVAL_MS = 5000;
|
|
17
|
+
export interface BatcherTimers {
|
|
18
|
+
setInterval(fn: () => void, ms: number): unknown;
|
|
19
|
+
clearInterval(id: unknown): void;
|
|
20
|
+
}
|
|
21
|
+
interface ListenerTarget {
|
|
22
|
+
addEventListener(type: string, fn: () => void): void;
|
|
23
|
+
}
|
|
24
|
+
export interface BatcherOptions {
|
|
25
|
+
/** Transport for one serialized batch envelope. */
|
|
26
|
+
send: (body: string) => void;
|
|
27
|
+
debug?: DebugFn;
|
|
28
|
+
/** Runs at the start of every flush tick (gap-buffer marker re-check). */
|
|
29
|
+
onTick?: () => void;
|
|
30
|
+
maxBatchBytes?: number;
|
|
31
|
+
flushIntervalMs?: number;
|
|
32
|
+
timers?: BatcherTimers;
|
|
33
|
+
documentTarget?: ListenerTarget & {
|
|
34
|
+
visibilityState?: string;
|
|
35
|
+
};
|
|
36
|
+
windowTarget?: ListenerTarget;
|
|
37
|
+
}
|
|
38
|
+
export declare class Batcher {
|
|
39
|
+
private readonly options;
|
|
40
|
+
private readonly maxBytes;
|
|
41
|
+
private queue;
|
|
42
|
+
private queuedBytes;
|
|
43
|
+
private flushing;
|
|
44
|
+
private intervalId;
|
|
45
|
+
constructor(options: BatcherOptions);
|
|
46
|
+
get pending(): number;
|
|
47
|
+
push(event: CollectEvent): void;
|
|
48
|
+
/**
|
|
49
|
+
* flush is one tick: run the onTick hook (which may push more events, e.g.
|
|
50
|
+
* a gap-buffer drain), then send everything queued in threshold-sized
|
|
51
|
+
* envelopes. Re-entrant pushes are drained in the same tick.
|
|
52
|
+
*/
|
|
53
|
+
flush(): void;
|
|
54
|
+
/**
|
|
55
|
+
* start installs the interval and lifecycle flush triggers, then flushes
|
|
56
|
+
* immediately: init queues the initial page view before start (index.ts),
|
|
57
|
+
* and holding it for the first interval tick would delay the session's
|
|
58
|
+
* first signal by up to FLUSH_INTERVAL_MS.
|
|
59
|
+
*/
|
|
60
|
+
start(): void;
|
|
61
|
+
stop(): void;
|
|
62
|
+
private envelopeBytes;
|
|
63
|
+
private drain;
|
|
64
|
+
}
|
|
65
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Batcher (design/05): queue events, split so no request exceeds the 60 KiB
|
|
2
|
+
// planning threshold (64 KiB wire ceiling), flush on start, size, interval,
|
|
3
|
+
// visibilitychange and pagehide. Byte accounting is incremental — a running
|
|
4
|
+
// per-event size computed at enqueue — so a flush stays O(n).
|
|
5
|
+
/** Planning threshold: full serialized envelope, UTF-8 bytes. */
|
|
6
|
+
export const MAX_BATCH_BYTES = 60 * 1024;
|
|
7
|
+
/** Flush interval (build-time constant, design/04 config table). */
|
|
8
|
+
export const FLUSH_INTERVAL_MS = 5000;
|
|
9
|
+
const ENVELOPE_PREFIX = '{"events":[';
|
|
10
|
+
const ENVELOPE_SUFFIX = "]}";
|
|
11
|
+
const ENVELOPE_OVERHEAD = ENVELOPE_PREFIX.length + ENVELOPE_SUFFIX.length;
|
|
12
|
+
const encoder = new TextEncoder();
|
|
13
|
+
const utf8ByteLength = (s) => encoder.encode(s).length;
|
|
14
|
+
export class Batcher {
|
|
15
|
+
options;
|
|
16
|
+
maxBytes;
|
|
17
|
+
queue = [];
|
|
18
|
+
queuedBytes = 0;
|
|
19
|
+
flushing = false;
|
|
20
|
+
intervalId;
|
|
21
|
+
constructor(options) {
|
|
22
|
+
this.options = options;
|
|
23
|
+
this.maxBytes = options.maxBatchBytes ?? MAX_BATCH_BYTES;
|
|
24
|
+
}
|
|
25
|
+
get pending() {
|
|
26
|
+
return this.queue.length;
|
|
27
|
+
}
|
|
28
|
+
push(event) {
|
|
29
|
+
const json = JSON.stringify(event);
|
|
30
|
+
const bytes = utf8ByteLength(json);
|
|
31
|
+
this.queue.push({ json, bytes, eventId: event.eventId });
|
|
32
|
+
this.queuedBytes += bytes;
|
|
33
|
+
if (!this.flushing && this.envelopeBytes() >= this.maxBytes) {
|
|
34
|
+
this.flush();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* flush is one tick: run the onTick hook (which may push more events, e.g.
|
|
39
|
+
* a gap-buffer drain), then send everything queued in threshold-sized
|
|
40
|
+
* envelopes. Re-entrant pushes are drained in the same tick.
|
|
41
|
+
*/
|
|
42
|
+
flush() {
|
|
43
|
+
if (this.flushing)
|
|
44
|
+
return;
|
|
45
|
+
this.flushing = true;
|
|
46
|
+
try {
|
|
47
|
+
this.options.onTick?.();
|
|
48
|
+
this.drain();
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
this.flushing = false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* start installs the interval and lifecycle flush triggers, then flushes
|
|
56
|
+
* immediately: init queues the initial page view before start (index.ts),
|
|
57
|
+
* and holding it for the first interval tick would delay the session's
|
|
58
|
+
* first signal by up to FLUSH_INTERVAL_MS.
|
|
59
|
+
*/
|
|
60
|
+
start() {
|
|
61
|
+
const timers = this.options.timers ?? {
|
|
62
|
+
setInterval: (fn, ms) => setInterval(fn, ms),
|
|
63
|
+
clearInterval: (id) => clearInterval(id),
|
|
64
|
+
};
|
|
65
|
+
this.intervalId = timers.setInterval(() => this.flush(), this.options.flushIntervalMs ?? FLUSH_INTERVAL_MS);
|
|
66
|
+
const doc = this.options.documentTarget ??
|
|
67
|
+
(typeof document !== "undefined" ? document : undefined);
|
|
68
|
+
doc?.addEventListener("visibilitychange", () => {
|
|
69
|
+
if (doc.visibilityState === "hidden")
|
|
70
|
+
this.flush();
|
|
71
|
+
});
|
|
72
|
+
const win = this.options.windowTarget ??
|
|
73
|
+
(typeof window !== "undefined" ? window : undefined);
|
|
74
|
+
win?.addEventListener("pagehide", () => this.flush());
|
|
75
|
+
this.flush();
|
|
76
|
+
}
|
|
77
|
+
stop() {
|
|
78
|
+
if (this.intervalId !== undefined) {
|
|
79
|
+
const timers = this.options.timers;
|
|
80
|
+
if (timers) {
|
|
81
|
+
timers.clearInterval(this.intervalId);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
clearInterval(this.intervalId);
|
|
85
|
+
}
|
|
86
|
+
this.intervalId = undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
envelopeBytes() {
|
|
90
|
+
const commas = this.queue.length > 1 ? this.queue.length - 1 : 0;
|
|
91
|
+
return ENVELOPE_OVERHEAD + this.queuedBytes + commas;
|
|
92
|
+
}
|
|
93
|
+
drain() {
|
|
94
|
+
while (this.queue.length > 0) {
|
|
95
|
+
const head = this.queue[0];
|
|
96
|
+
if (ENVELOPE_OVERHEAD + head.bytes > this.maxBytes) {
|
|
97
|
+
// A single event whose own encoding exceeds the threshold: drop it
|
|
98
|
+
// with a debug record and keep going — never loop on it.
|
|
99
|
+
this.queue.shift();
|
|
100
|
+
this.queuedBytes -= head.bytes;
|
|
101
|
+
this.options.debug?.("flush", {
|
|
102
|
+
reason: "oversize_event",
|
|
103
|
+
eventId: head.eventId,
|
|
104
|
+
bytes: head.bytes,
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const parts = [];
|
|
109
|
+
let size = ENVELOPE_OVERHEAD;
|
|
110
|
+
while (this.queue.length > 0) {
|
|
111
|
+
const next = this.queue[0];
|
|
112
|
+
if (ENVELOPE_OVERHEAD + next.bytes > this.maxBytes)
|
|
113
|
+
break; // oversize: outer loop drops it
|
|
114
|
+
const nextSize = size + next.bytes + (parts.length > 0 ? 1 : 0);
|
|
115
|
+
if (nextSize > this.maxBytes)
|
|
116
|
+
break;
|
|
117
|
+
parts.push(next.json);
|
|
118
|
+
size = nextSize;
|
|
119
|
+
this.queue.shift();
|
|
120
|
+
this.queuedBytes -= next.bytes;
|
|
121
|
+
}
|
|
122
|
+
if (parts.length === 0)
|
|
123
|
+
continue;
|
|
124
|
+
this.options.send(ENVELOPE_PREFIX + parts.join(",") + ENVELOPE_SUFFIX);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type ConsentPreset = "c15t" | "onetrust" | "cookiebot" | "trustarc" | "manual";
|
|
2
|
+
export interface TagConfig {
|
|
3
|
+
/** Optional site id, for debug and a future multi-site surface. */
|
|
4
|
+
site: string | undefined;
|
|
5
|
+
/** CMP preset; "manual" means only the JS consent API writes envelopes. */
|
|
6
|
+
preset: ConsentPreset;
|
|
7
|
+
/** Raw flat comma list of CMP categories implying personalization. */
|
|
8
|
+
categories: string | undefined;
|
|
9
|
+
/** Structured console.debug diagnostics enabled (data-aci-debug="1"). */
|
|
10
|
+
debug: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Structured diagnostics kinds (design/05): stable event-shaped records a
|
|
14
|
+
* future debug overlay can render. "init" covers configuration and swallowed
|
|
15
|
+
* init failures.
|
|
16
|
+
*/
|
|
17
|
+
export type DebugKind = "init" | "capture" | "consent" | "flush" | "buffer";
|
|
18
|
+
export type DebugFn = (kind: DebugKind, detail: unknown) => void;
|
|
19
|
+
/**
|
|
20
|
+
* readTagConfig parses the data-aci-* attributes off the SDK's own script
|
|
21
|
+
* element (document.currentScript). Missing or unrecognized values fall back
|
|
22
|
+
* to safe defaults; unknown attributes are tolerated.
|
|
23
|
+
*/
|
|
24
|
+
export declare function readTagConfig(script: Element | null | undefined): TagConfig;
|
|
25
|
+
/**
|
|
26
|
+
* createDebug returns the structured debug emitter: {t, kind, detail}
|
|
27
|
+
* records via console.debug when enabled, a no-op otherwise. The sink and
|
|
28
|
+
* clock are injectable for tests. Diagnostics never throw.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createDebug(enabled: boolean, log?: (record: unknown) => void, now?: () => number): DebugFn;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Tag configuration (design/04): the script tag is the only per-site
|
|
2
|
+
// configuration surface. Unknown attributes are ignored.
|
|
3
|
+
const PRESETS = new Set([
|
|
4
|
+
"c15t",
|
|
5
|
+
"onetrust",
|
|
6
|
+
"cookiebot",
|
|
7
|
+
"trustarc",
|
|
8
|
+
"manual",
|
|
9
|
+
]);
|
|
10
|
+
function attribute(script, name) {
|
|
11
|
+
const value = script?.getAttribute?.(name);
|
|
12
|
+
if (value == null)
|
|
13
|
+
return undefined;
|
|
14
|
+
const trimmed = value.trim();
|
|
15
|
+
return trimmed === "" ? undefined : trimmed;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* readTagConfig parses the data-aci-* attributes off the SDK's own script
|
|
19
|
+
* element (document.currentScript). Missing or unrecognized values fall back
|
|
20
|
+
* to safe defaults; unknown attributes are tolerated.
|
|
21
|
+
*/
|
|
22
|
+
export function readTagConfig(script) {
|
|
23
|
+
const rawPreset = attribute(script, "data-aci-consent")?.toLowerCase();
|
|
24
|
+
const preset = rawPreset !== undefined && PRESETS.has(rawPreset)
|
|
25
|
+
? rawPreset
|
|
26
|
+
: "manual";
|
|
27
|
+
return {
|
|
28
|
+
site: attribute(script, "data-aci-site"),
|
|
29
|
+
preset,
|
|
30
|
+
categories: attribute(script, "data-aci-consent-categories"),
|
|
31
|
+
debug: attribute(script, "data-aci-debug") === "1",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* createDebug returns the structured debug emitter: {t, kind, detail}
|
|
36
|
+
* records via console.debug when enabled, a no-op otherwise. The sink and
|
|
37
|
+
* clock are injectable for tests. Diagnostics never throw.
|
|
38
|
+
*/
|
|
39
|
+
export function createDebug(enabled, log, now) {
|
|
40
|
+
if (!enabled) {
|
|
41
|
+
return () => { };
|
|
42
|
+
}
|
|
43
|
+
const emit = log ?? ((record) => console.debug("[aci]", record));
|
|
44
|
+
const clock = now ?? Date.now;
|
|
45
|
+
return (kind, detail) => {
|
|
46
|
+
try {
|
|
47
|
+
emit({ t: clock(), kind, detail });
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Diagnostics must never affect the page.
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// c15t adapter (P0, dogfood): a thin subscription over the shared mapping
|
|
2
|
+
// core. Reads window.c15t's consents record (category -> boolean, modeled on
|
|
3
|
+
// @c15t/nextjs's ConsentState) once on discovery, then follows
|
|
4
|
+
// onConsentChange payloads (wrapped {consents} or a bare record).
|
|
5
|
+
import { guard, handleCmpState, pollForGlobal, } from "../mapping.js";
|
|
6
|
+
const PRESET = "c15t";
|
|
7
|
+
function normalizeConsents(record) {
|
|
8
|
+
if (typeof record !== "object" || record === null)
|
|
9
|
+
return undefined;
|
|
10
|
+
const categories = {};
|
|
11
|
+
for (const [key, value] of Object.entries(record)) {
|
|
12
|
+
if (typeof value === "boolean")
|
|
13
|
+
categories[key.toLowerCase()] = value;
|
|
14
|
+
}
|
|
15
|
+
return { categories };
|
|
16
|
+
}
|
|
17
|
+
function payloadConsents(payload, c15t) {
|
|
18
|
+
if (typeof payload === "object" && payload !== null) {
|
|
19
|
+
if ("consents" in payload) {
|
|
20
|
+
return payload.consents;
|
|
21
|
+
}
|
|
22
|
+
return payload; // bare consents record
|
|
23
|
+
}
|
|
24
|
+
return c15t.consents; // unknown payload shape: re-read the global
|
|
25
|
+
}
|
|
26
|
+
export function start(deps) {
|
|
27
|
+
pollForGlobal(() => {
|
|
28
|
+
const c15t = deps.window.c15t;
|
|
29
|
+
return typeof c15t === "object" && c15t !== null
|
|
30
|
+
? c15t
|
|
31
|
+
: undefined;
|
|
32
|
+
}, (c15t) => {
|
|
33
|
+
guard(deps.debug, () => {
|
|
34
|
+
const handle = (record) => {
|
|
35
|
+
const state = normalizeConsents(record);
|
|
36
|
+
if (state)
|
|
37
|
+
handleCmpState(state, PRESET, deps);
|
|
38
|
+
};
|
|
39
|
+
handle(c15t.consents); // read the current state once
|
|
40
|
+
c15t.onConsentChange?.((payload) => guard(deps.debug, () => handle(payloadConsents(payload, c15t))));
|
|
41
|
+
});
|
|
42
|
+
}, deps);
|
|
43
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Cookiebot adapter: reads window.Cookiebot.consent (a category -> boolean
|
|
2
|
+
// record: preferences/statistics/marketing) and follows the documented
|
|
3
|
+
// window events CookiebotOnConsentReady / CookiebotOnAccept /
|
|
4
|
+
// CookiebotOnDecline. Before the visitor has responded
|
|
5
|
+
// (Cookiebot.hasResponse !== true) the state is not a decision — the
|
|
6
|
+
// adapter writes nothing (fail closed).
|
|
7
|
+
import { guard, handleCmpState, pollForGlobal, } from "../mapping.js";
|
|
8
|
+
const PRESET = "cookiebot";
|
|
9
|
+
const CONSENT_EVENTS = [
|
|
10
|
+
"CookiebotOnConsentReady",
|
|
11
|
+
"CookiebotOnAccept",
|
|
12
|
+
"CookiebotOnDecline",
|
|
13
|
+
];
|
|
14
|
+
function readGlobal(w) {
|
|
15
|
+
const cookiebot = w.Cookiebot;
|
|
16
|
+
return typeof cookiebot === "object" && cookiebot !== null
|
|
17
|
+
? cookiebot
|
|
18
|
+
: undefined;
|
|
19
|
+
}
|
|
20
|
+
function normalizeConsent(record) {
|
|
21
|
+
if (typeof record !== "object" || record === null)
|
|
22
|
+
return undefined;
|
|
23
|
+
const categories = {};
|
|
24
|
+
for (const [key, value] of Object.entries(record)) {
|
|
25
|
+
if (typeof value === "boolean")
|
|
26
|
+
categories[key.toLowerCase()] = value;
|
|
27
|
+
}
|
|
28
|
+
return { categories };
|
|
29
|
+
}
|
|
30
|
+
export function start(deps) {
|
|
31
|
+
const readAndHandle = () => {
|
|
32
|
+
const cookiebot = readGlobal(deps.window);
|
|
33
|
+
if (!cookiebot)
|
|
34
|
+
return;
|
|
35
|
+
if (cookiebot.hasResponse !== true)
|
|
36
|
+
return; // no decision yet: fail closed
|
|
37
|
+
const state = normalizeConsent(cookiebot.consent);
|
|
38
|
+
if (state)
|
|
39
|
+
handleCmpState(state, PRESET, deps);
|
|
40
|
+
};
|
|
41
|
+
const target = deps.window;
|
|
42
|
+
if (typeof target.addEventListener === "function") {
|
|
43
|
+
for (const name of CONSENT_EVENTS) {
|
|
44
|
+
target.addEventListener(name, () => guard(deps.debug, readAndHandle));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
pollForGlobal(() => readGlobal(deps.window), () => guard(deps.debug, readAndHandle), deps);
|
|
48
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// OneTrust adapter: reads the window.OnetrustActiveGroups string (the
|
|
2
|
+
// active-group list, e.g. ",C0001,C0002,") and chains OptanonWrapper —
|
|
3
|
+
// OneTrust invokes it on load and on every consent change.
|
|
4
|
+
//
|
|
5
|
+
// The active-groups surface only enumerates GRANTED groups, so denial is
|
|
6
|
+
// inferred: a configured category shaped like a standard group id (C0002,
|
|
7
|
+
// C0004, ...) that is absent from the list reads as recognized-but-denied,
|
|
8
|
+
// which lets a genuine revoke propagate. Custom-named categories that never
|
|
9
|
+
// appear stay unrecognized and write nothing (fail closed).
|
|
10
|
+
import { guard, handleCmpState, pollForGlobal, resolveCategories, } from "../mapping.js";
|
|
11
|
+
const PRESET = "onetrust";
|
|
12
|
+
const STANDARD_GROUP_ID = /^c\d{4}$/;
|
|
13
|
+
function normalizeGroups(groups, configured) {
|
|
14
|
+
const categories = {};
|
|
15
|
+
for (const token of groups.split(",")) {
|
|
16
|
+
const group = token.trim().toLowerCase();
|
|
17
|
+
if (group !== "")
|
|
18
|
+
categories[group] = true;
|
|
19
|
+
}
|
|
20
|
+
for (const category of configured ?? []) {
|
|
21
|
+
if (!(category in categories) && STANDARD_GROUP_ID.test(category)) {
|
|
22
|
+
categories[category] = false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { categories };
|
|
26
|
+
}
|
|
27
|
+
export function start(deps) {
|
|
28
|
+
const w = deps.window;
|
|
29
|
+
const readAndHandle = () => {
|
|
30
|
+
const groups = w.OnetrustActiveGroups;
|
|
31
|
+
if (typeof groups !== "string")
|
|
32
|
+
return;
|
|
33
|
+
const configured = resolveCategories(PRESET, deps.categories);
|
|
34
|
+
handleCmpState(normalizeGroups(groups, configured), PRESET, deps);
|
|
35
|
+
};
|
|
36
|
+
// Chain the wrapper immediately so consent changes reach us even when
|
|
37
|
+
// OneTrust loads (or the visitor decides) after this adapter starts.
|
|
38
|
+
const previous = w.OptanonWrapper;
|
|
39
|
+
w.OptanonWrapper = () => {
|
|
40
|
+
if (typeof previous === "function") {
|
|
41
|
+
try {
|
|
42
|
+
previous();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// The site's own wrapper is third-party code; never let it stop us.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
guard(deps.debug, readAndHandle);
|
|
49
|
+
};
|
|
50
|
+
pollForGlobal(() => {
|
|
51
|
+
const groups = w.OnetrustActiveGroups;
|
|
52
|
+
return typeof groups === "string" ? groups : undefined;
|
|
53
|
+
}, () => guard(deps.debug, readAndHandle), deps);
|
|
54
|
+
}
|