@gradial/aci 0.1.28 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +47 -0
  2. package/bin/aci-darwin-arm64 +0 -0
  3. package/dist/astro/index.d.ts +1 -0
  4. package/dist/astro/index.js +7 -0
  5. package/dist/collect/api.d.ts +24 -0
  6. package/dist/collect/api.js +63 -0
  7. package/dist/collect/batcher.d.ts +65 -0
  8. package/dist/collect/batcher.js +127 -0
  9. package/dist/collect/config.d.ts +30 -0
  10. package/dist/collect/config.js +53 -0
  11. package/dist/collect/consent/adapters/c15t.d.ts +2 -0
  12. package/dist/collect/consent/adapters/c15t.js +43 -0
  13. package/dist/collect/consent/adapters/cookiebot.d.ts +2 -0
  14. package/dist/collect/consent/adapters/cookiebot.js +48 -0
  15. package/dist/collect/consent/adapters/onetrust.d.ts +2 -0
  16. package/dist/collect/consent/adapters/onetrust.js +54 -0
  17. package/dist/collect/consent/adapters/trustarc.d.ts +2 -0
  18. package/dist/collect/consent/adapters/trustarc.js +65 -0
  19. package/dist/collect/consent/envelope.d.ts +49 -0
  20. package/dist/collect/consent/envelope.js +100 -0
  21. package/dist/collect/consent/mapping.d.ts +62 -0
  22. package/dist/collect/consent/mapping.js +129 -0
  23. package/dist/collect/declarative.d.ts +35 -0
  24. package/dist/collect/declarative.js +100 -0
  25. package/dist/collect/gapbuffer.d.ts +55 -0
  26. package/dist/collect/gapbuffer.js +134 -0
  27. package/dist/collect/index.d.ts +6 -0
  28. package/dist/collect/index.js +154 -0
  29. package/dist/collect/linkclicks.d.ts +12 -0
  30. package/dist/collect/linkclicks.js +35 -0
  31. package/dist/collect/pageviews.d.ts +22 -0
  32. package/dist/collect/pageviews.js +42 -0
  33. package/dist/collect/transport.d.ts +22 -0
  34. package/dist/collect/transport.js +52 -0
  35. package/dist/collect/ulid.d.ts +6 -0
  36. package/dist/collect/ulid.js +36 -0
  37. package/dist/collect/variationviews.d.ts +31 -0
  38. package/dist/collect/variationviews.js +80 -0
  39. package/dist/collect/webvitals.d.ts +38 -0
  40. package/dist/collect/webvitals.js +99 -0
  41. package/dist/content/index.d.ts +1 -0
  42. package/dist/content/index.js +1 -0
  43. package/dist/content/personalization.d.ts +57 -0
  44. package/dist/content/personalization.js +54 -0
  45. package/dist/content/types.d.ts +3 -0
  46. package/dist/next/collect-script.d.ts +12 -0
  47. package/dist/next/collect-script.js +15 -0
  48. package/dist/next/config.js +1 -0
  49. package/dist/next/index.d.ts +1 -0
  50. package/dist/next/index.js +1 -0
  51. package/dist/next/page.d.ts +2 -0
  52. package/dist/next/page.js +5 -2
  53. package/dist/providers/file.js +1 -8
  54. package/dist/react/VideoPlayer.d.ts +3 -3
  55. package/dist/react/index.d.ts +4 -4
  56. package/dist/react/index.js +1 -1
  57. package/dist/types/image.d.ts +27 -13
  58. package/dist/types/image.js +17 -17
  59. package/dist/types/media.d.ts +10 -4
  60. package/dist/types/video.d.ts +5 -2
  61. package/package.json +8 -9
  62. package/src/cli/validate-content.mjs +0 -0
  63. package/src/types/image.ts +32 -29
  64. package/templates/astro/template/src/fixtures/sections.ts +1 -1
  65. package/templates/astro/template/src/layouts/SiteLayout.astro +77 -1
  66. package/templates/astro/template/src/pages/[...slug].astro +3 -2
  67. package/templates/common/.content/config/site.json +5 -0
  68. package/templates/common/.content/schemas/site.schema.json +15 -0
  69. package/templates/common/README.md.tmpl +15 -0
  70. package/templates/nextjs/template/package.json.tmpl +2 -0
  71. package/templates/nextjs/template/src/app/_components/ConsentManager.tsx +132 -0
  72. package/templates/nextjs/template/src/app/layout.tsx +3 -1
  73. package/templates/nextjs/template/src/components/sections/HomeHero.tsx +1 -1
@@ -0,0 +1,65 @@
1
+ // TrustArc adapter: implemented against the documented
2
+ // window.truste.cma.callApi("getConsentDecision", domain) surface, which
3
+ // returns { consentDecision: <bucket level>, source }. Bucket levels map to
4
+ // cumulative categories: 1 = required, 2 = +functional, 3 = +advertising.
5
+ // Level 0 (no decision) writes nothing (fail closed). Consent changes are
6
+ // detected via TrustArc's PrivacyManagerAPI postMessage traffic, which
7
+ // triggers a re-read. TrustArc's client surfaces vary by deployment; this
8
+ // is the simplest documented shape (noted in the implementation report).
9
+ import { guard, handleCmpState, pollForGlobal, } from "../mapping.js";
10
+ const PRESET = "trustarc";
11
+ const BUCKET_LEVELS = {
12
+ required: 1,
13
+ functional: 2,
14
+ advertising: 3,
15
+ };
16
+ const API_MARKER = "PrivacyManagerAPI";
17
+ function readCma(w) {
18
+ const truste = w.truste;
19
+ const cma = truste?.cma;
20
+ const callApi = cma?.callApi;
21
+ return typeof callApi === "function"
22
+ ? callApi.bind(cma)
23
+ : undefined;
24
+ }
25
+ function normalizeDecision(result) {
26
+ if (typeof result !== "object" || result === null)
27
+ return undefined;
28
+ const decision = result.consentDecision;
29
+ if (typeof decision !== "number" || !Number.isFinite(decision) || decision < 1) {
30
+ return undefined; // no asserted decision: fail closed
31
+ }
32
+ const categories = {};
33
+ for (const [name, level] of Object.entries(BUCKET_LEVELS)) {
34
+ categories[name] = decision >= level;
35
+ }
36
+ return { categories };
37
+ }
38
+ function mentionsPrivacyManager(data) {
39
+ if (typeof data === "string")
40
+ return data.includes(API_MARKER);
41
+ return typeof data === "object" && data !== null && API_MARKER in data;
42
+ }
43
+ function hostnameOf(w) {
44
+ const location = w.location;
45
+ return typeof location?.hostname === "string" ? location.hostname : undefined;
46
+ }
47
+ export function start(deps) {
48
+ const readAndHandle = () => {
49
+ const callApi = readCma(deps.window);
50
+ if (!callApi)
51
+ return;
52
+ const state = normalizeDecision(callApi("getConsentDecision", hostnameOf(deps.window)));
53
+ if (state)
54
+ handleCmpState(state, PRESET, deps);
55
+ };
56
+ const target = deps.window;
57
+ if (typeof target.addEventListener === "function") {
58
+ target.addEventListener("message", (ev) => guard(deps.debug, () => {
59
+ if (!mentionsPrivacyManager(ev?.data))
60
+ return;
61
+ readAndHandle();
62
+ }));
63
+ }
64
+ pollForGlobal(() => readCma(deps.window), () => guard(deps.debug, readAndHandle), deps);
65
+ }
@@ -0,0 +1,49 @@
1
+ export declare const PERSONALIZATION_PURPOSE = "personalization";
2
+ export declare const CONSENT_COOKIE = "aci_consent";
3
+ export declare const CONSENT_ENVELOPE_VERSION = 1;
4
+ /** Consent persistence horizon; the CMP remains the source of truth. */
5
+ export declare const CONSENT_MAX_AGE_S: number;
6
+ export interface CookieDocument {
7
+ cookie: string;
8
+ location?: {
9
+ protocol?: string;
10
+ };
11
+ }
12
+ export interface ConsentWriteDeps {
13
+ document: CookieDocument;
14
+ now?: () => number;
15
+ }
16
+ export interface Purgeable {
17
+ purge(): void;
18
+ }
19
+ export interface ApplyConsentDeps extends ConsentWriteDeps {
20
+ gapBuffer?: Purgeable;
21
+ }
22
+ /**
23
+ * writeConsent builds the {v:1, p, t, src} envelope, URL-encodes it and sets
24
+ * the aci_consent cookie (Path=/, SameSite=Lax, Secure on https).
25
+ */
26
+ export declare function writeConsent(purposes: string[], src: string, deps: ConsentWriteDeps): void;
27
+ /**
28
+ * applyConsent is the single write path for every consent decision — CMP
29
+ * adapters and the manual JS API alike. Any decision where personalization
30
+ * is absent purges the gap buffer (REQ-8), and the envelope is still
31
+ * written: withdrawal must reach the server.
32
+ */
33
+ export declare function applyConsent(purposes: string[], src: string, deps: ApplyConsentDeps): void;
34
+ /** readConsentCookie returns the raw (still URL-encoded) cookie value. */
35
+ export declare function readConsentCookie(doc: {
36
+ cookie: string;
37
+ }): string | undefined;
38
+ /**
39
+ * envelopeGrantsPersonalization mirrors the server-side check: URL-decode,
40
+ * accept legacy bare tokens, else parse the JSON envelope and look for the
41
+ * personalization purpose. Anything unreadable grants nothing.
42
+ */
43
+ export declare function envelopeGrantsPersonalization(raw: string | undefined): boolean;
44
+ /**
45
+ * sanitizePurposes validates a caller-supplied purposes value (the manual
46
+ * consent API): an array of strings, trimmed. Anything else is undefined
47
+ * and the command is ignored.
48
+ */
49
+ export declare function sanitizePurposes(raw: unknown): string[] | undefined;
@@ -0,0 +1,100 @@
1
+ // Consent envelope writer (design/04, D-ONB-4): the aci_consent cookie
2
+ // carries URL-encoded JSON {v:1, p, t, src} — cookie values cannot carry
3
+ // quotes. The collector and the edge URL-decode before parsing, with a
4
+ // legacy bare-token fallback during the deprecation window.
5
+ export const PERSONALIZATION_PURPOSE = "personalization";
6
+ export const CONSENT_COOKIE = "aci_consent";
7
+ export const CONSENT_ENVELOPE_VERSION = 1;
8
+ /** Consent persistence horizon; the CMP remains the source of truth. */
9
+ export const CONSENT_MAX_AGE_S = 365 * 24 * 60 * 60;
10
+ /** Mirrors the server-side grandfathered tokens (audience collect_signals). */
11
+ const LEGACY_GRANT_TOKENS = new Set([
12
+ "true",
13
+ "all",
14
+ "granted",
15
+ "yes",
16
+ "personalization",
17
+ "personalized",
18
+ ]);
19
+ /**
20
+ * writeConsent builds the {v:1, p, t, src} envelope, URL-encodes it and sets
21
+ * the aci_consent cookie (Path=/, SameSite=Lax, Secure on https).
22
+ */
23
+ export function writeConsent(purposes, src, deps) {
24
+ const envelope = {
25
+ v: CONSENT_ENVELOPE_VERSION,
26
+ p: purposes,
27
+ t: new Date((deps.now ?? Date.now)()).toISOString(),
28
+ src,
29
+ };
30
+ const value = encodeURIComponent(JSON.stringify(envelope));
31
+ let cookie = `${CONSENT_COOKIE}=${value}; Path=/; Max-Age=${CONSENT_MAX_AGE_S}; SameSite=Lax`;
32
+ if (deps.document.location?.protocol === "https:") {
33
+ cookie += "; Secure";
34
+ }
35
+ deps.document.cookie = cookie;
36
+ }
37
+ /**
38
+ * applyConsent is the single write path for every consent decision — CMP
39
+ * adapters and the manual JS API alike. Any decision where personalization
40
+ * is absent purges the gap buffer (REQ-8), and the envelope is still
41
+ * written: withdrawal must reach the server.
42
+ */
43
+ export function applyConsent(purposes, src, deps) {
44
+ if (!purposes.includes(PERSONALIZATION_PURPOSE)) {
45
+ deps.gapBuffer?.purge();
46
+ }
47
+ writeConsent(purposes, src, deps);
48
+ }
49
+ /** readConsentCookie returns the raw (still URL-encoded) cookie value. */
50
+ export function readConsentCookie(doc) {
51
+ for (const part of doc.cookie.split(";")) {
52
+ const trimmed = part.trim();
53
+ if (trimmed.startsWith(CONSENT_COOKIE + "=")) {
54
+ return trimmed.slice(CONSENT_COOKIE.length + 1);
55
+ }
56
+ }
57
+ return undefined;
58
+ }
59
+ /**
60
+ * envelopeGrantsPersonalization mirrors the server-side check: URL-decode,
61
+ * accept legacy bare tokens, else parse the JSON envelope and look for the
62
+ * personalization purpose. Anything unreadable grants nothing.
63
+ */
64
+ export function envelopeGrantsPersonalization(raw) {
65
+ if (raw === undefined || raw === "")
66
+ return false;
67
+ let decoded = raw;
68
+ try {
69
+ decoded = decodeURIComponent(raw);
70
+ }
71
+ catch {
72
+ // Not URL-encoded; evaluate as-is.
73
+ }
74
+ if (LEGACY_GRANT_TOKENS.has(decoded.trim().toLowerCase()))
75
+ return true;
76
+ try {
77
+ const envelope = JSON.parse(decoded);
78
+ return (Array.isArray(envelope.p) &&
79
+ envelope.p.includes(PERSONALIZATION_PURPOSE));
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ }
85
+ /**
86
+ * sanitizePurposes validates a caller-supplied purposes value (the manual
87
+ * consent API): an array of strings, trimmed. Anything else is undefined
88
+ * and the command is ignored.
89
+ */
90
+ export function sanitizePurposes(raw) {
91
+ if (!Array.isArray(raw))
92
+ return undefined;
93
+ const purposes = [];
94
+ for (const item of raw) {
95
+ if (typeof item !== "string")
96
+ return undefined;
97
+ purposes.push(item.trim());
98
+ }
99
+ return purposes;
100
+ }
@@ -0,0 +1,62 @@
1
+ import type { DebugFn } from "../config.js";
2
+ import { type CookieDocument, type Purgeable } from "./envelope.js";
3
+ /** A CMP state normalized by its adapter: category -> granted. */
4
+ export interface NormalizedCmpState {
5
+ categories: Record<string, boolean>;
6
+ }
7
+ /** Sane per-preset defaults for the flat category list (design/04). */
8
+ export declare const PRESET_DEFAULT_CATEGORIES: Record<string, string>;
9
+ /**
10
+ * resolveCategories parses the configured flat comma list (or the preset
11
+ * default) into normalized category tokens. Returns null when the list is
12
+ * unparseable — the caller writes NOTHING (fail closed).
13
+ */
14
+ export declare function resolveCategories(preset: string, raw: string | undefined): string[] | null;
15
+ /**
16
+ * mapCategories maps a normalized CMP state through the configured category
17
+ * list: the personalization purpose is granted when any recognized category
18
+ * is granted (single-purpose world, DD-ONB-6).
19
+ */
20
+ export declare function mapCategories(state: NormalizedCmpState, categories: string[]): string[];
21
+ export interface ConsentTargetDeps {
22
+ document: CookieDocument;
23
+ /** Raw configured category list (tag attribute); preset default when absent. */
24
+ categories?: string | undefined;
25
+ gapBuffer?: Purgeable | undefined;
26
+ debug?: DebugFn | undefined;
27
+ now?: (() => number) | undefined;
28
+ }
29
+ /**
30
+ * handleCmpState is the shared onCmpChange: resolve the mapping, reject the
31
+ * two fail-closed cases, then apply the decision through the single write
32
+ * path. Nothing thrown inside ever escapes to the CMP or the page.
33
+ */
34
+ export declare function handleCmpState(state: NormalizedCmpState, preset: string, deps: ConsentTargetDeps): void;
35
+ export interface PollScheduler {
36
+ setTimeout(fn: () => void, ms: number): unknown;
37
+ clearTimeout(id: unknown): void;
38
+ }
39
+ export interface AdapterDeps extends ConsentTargetDeps {
40
+ /** The page window hosting the CMP global. */
41
+ window: Record<string, unknown>;
42
+ /** Injectable clock for the CMP poll. */
43
+ scheduler?: PollScheduler | undefined;
44
+ }
45
+ export declare const CMP_POLL_INTERVAL_MS = 250;
46
+ export declare const CMP_POLL_TIMEOUT_MS = 10000;
47
+ /**
48
+ * pollForGlobal waits for a CMP global to appear: an immediate read, then a
49
+ * poll every CMP_POLL_INTERVAL_MS for up to CMP_POLL_TIMEOUT_MS, then give
50
+ * up silently (D-ONB-8). A throwing read counts as a sabotaged CMP and
51
+ * stops the poll.
52
+ */
53
+ export declare function pollForGlobal<T>(read: () => T | undefined, onReady: (value: T) => void, deps?: {
54
+ scheduler?: PollScheduler | undefined;
55
+ debug?: DebugFn | undefined;
56
+ }): void;
57
+ /**
58
+ * guard contains adapter callbacks: any exception from a CMP surface is
59
+ * swallowed after a debug record — no cookie write happens, nothing escapes
60
+ * to the page (the sabotaged-adapter contract).
61
+ */
62
+ export declare function guard(debug: DebugFn | undefined, fn: () => void): void;
@@ -0,0 +1,129 @@
1
+ // Shared consent-mapping core (design/05, D-ONB-8): every CMP adapter is a
2
+ // thin subscription over this module. Three-case writer semantics (REQ-7):
3
+ // (a) an unparseable mapping or an unrecognized CMP state writes NOTHING
4
+ // (fail closed); (b) a genuine revoke writes the empty-purposes envelope and
5
+ // purges the gap buffer; (c) a grant writes the purposes.
6
+ import { applyConsent, PERSONALIZATION_PURPOSE, } from "./envelope.js";
7
+ const CMP_SOURCE_PREFIX = "cmp-";
8
+ const CATEGORY_TOKEN = /^[a-z0-9._-]+$/;
9
+ /** Sane per-preset defaults for the flat category list (design/04). */
10
+ export const PRESET_DEFAULT_CATEGORIES = {
11
+ c15t: "measurement,marketing",
12
+ onetrust: "c0002,c0004",
13
+ cookiebot: "statistics,marketing",
14
+ trustarc: "functional,advertising",
15
+ };
16
+ /**
17
+ * resolveCategories parses the configured flat comma list (or the preset
18
+ * default) into normalized category tokens. Returns null when the list is
19
+ * unparseable — the caller writes NOTHING (fail closed).
20
+ */
21
+ export function resolveCategories(preset, raw) {
22
+ const source = raw ?? PRESET_DEFAULT_CATEGORIES[preset];
23
+ if (source === undefined)
24
+ return null;
25
+ const tokens = source
26
+ .split(",")
27
+ .map((token) => token.trim().toLowerCase())
28
+ .filter((token) => token !== "");
29
+ if (tokens.length === 0)
30
+ return null;
31
+ for (const token of tokens) {
32
+ if (!CATEGORY_TOKEN.test(token))
33
+ return null; // one bad token poisons the mapping
34
+ }
35
+ return tokens;
36
+ }
37
+ /**
38
+ * mapCategories maps a normalized CMP state through the configured category
39
+ * list: the personalization purpose is granted when any recognized category
40
+ * is granted (single-purpose world, DD-ONB-6).
41
+ */
42
+ export function mapCategories(state, categories) {
43
+ const known = categories.filter((c) => c in state.categories);
44
+ return known.some((c) => state.categories[c] === true)
45
+ ? [PERSONALIZATION_PURPOSE]
46
+ : [];
47
+ }
48
+ /**
49
+ * handleCmpState is the shared onCmpChange: resolve the mapping, reject the
50
+ * two fail-closed cases, then apply the decision through the single write
51
+ * path. Nothing thrown inside ever escapes to the CMP or the page.
52
+ */
53
+ export function handleCmpState(state, preset, deps) {
54
+ try {
55
+ const categories = resolveCategories(preset, deps.categories);
56
+ if (categories === null) {
57
+ deps.debug?.("consent", { reason: "unparseable_mapping", preset });
58
+ return;
59
+ }
60
+ const known = categories.filter((c) => c in state.categories);
61
+ if (known.length === 0) {
62
+ deps.debug?.("consent", { reason: "unmappable_cmp_state", preset });
63
+ return;
64
+ }
65
+ const purposes = mapCategories(state, categories);
66
+ applyConsent(purposes, CMP_SOURCE_PREFIX + preset, {
67
+ document: deps.document,
68
+ gapBuffer: deps.gapBuffer,
69
+ now: deps.now,
70
+ });
71
+ deps.debug?.("consent", { reason: "written", preset, purposes });
72
+ }
73
+ catch (err) {
74
+ deps.debug?.("consent", {
75
+ reason: "consent_bridge_failed",
76
+ preset,
77
+ error: String(err),
78
+ });
79
+ }
80
+ }
81
+ export const CMP_POLL_INTERVAL_MS = 250;
82
+ export const CMP_POLL_TIMEOUT_MS = 10_000;
83
+ /**
84
+ * pollForGlobal waits for a CMP global to appear: an immediate read, then a
85
+ * poll every CMP_POLL_INTERVAL_MS for up to CMP_POLL_TIMEOUT_MS, then give
86
+ * up silently (D-ONB-8). A throwing read counts as a sabotaged CMP and
87
+ * stops the poll.
88
+ */
89
+ export function pollForGlobal(read, onReady, deps) {
90
+ const scheduler = deps?.scheduler ?? {
91
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
92
+ clearTimeout: (id) => clearTimeout(id),
93
+ };
94
+ let remaining = Math.floor(CMP_POLL_TIMEOUT_MS / CMP_POLL_INTERVAL_MS);
95
+ const attempt = () => {
96
+ let value;
97
+ try {
98
+ value = read();
99
+ }
100
+ catch (err) {
101
+ deps?.debug?.("consent", { reason: "cmp_read_failed", error: String(err) });
102
+ return;
103
+ }
104
+ if (value !== undefined) {
105
+ onReady(value);
106
+ return;
107
+ }
108
+ remaining -= 1;
109
+ if (remaining <= 0) {
110
+ deps?.debug?.("consent", { reason: "cmp_not_found" });
111
+ return;
112
+ }
113
+ scheduler.setTimeout(attempt, CMP_POLL_INTERVAL_MS);
114
+ };
115
+ attempt();
116
+ }
117
+ /**
118
+ * guard contains adapter callbacks: any exception from a CMP surface is
119
+ * swallowed after a debug record — no cookie write happens, nothing escapes
120
+ * to the page (the sabotaged-adapter contract).
121
+ */
122
+ export function guard(debug, fn) {
123
+ try {
124
+ fn();
125
+ }
126
+ catch (err) {
127
+ debug?.("consent", { reason: "adapter_failed", error: String(err) });
128
+ }
129
+ }
@@ -0,0 +1,35 @@
1
+ import type { PendingEvent } from "./batcher.js";
2
+ import type { DebugFn } from "./config.js";
3
+ interface ObserverEntryLike {
4
+ target: Element;
5
+ isIntersecting: boolean;
6
+ intersectionRatio: number;
7
+ }
8
+ interface ObserverLike {
9
+ observe(el: Element): void;
10
+ unobserve(el: Element): void;
11
+ disconnect(): void;
12
+ }
13
+ type ObserverCtor = new (callback: (entries: ObserverEntryLike[], observer: ObserverLike) => void, options?: {
14
+ threshold?: number | number[];
15
+ }) => ObserverLike;
16
+ export interface DeclarativeEnv {
17
+ document: Document;
18
+ /** IntersectionObserver constructor; absent means no visible triggers. */
19
+ intersectionObserver?: ObserverCtor | undefined;
20
+ debug?: DebugFn;
21
+ }
22
+ /**
23
+ * toCustomEventName normalizes a declared or API-supplied event name into
24
+ * the custom.* namespace: trimmed, lowercased, prefix enforced (never
25
+ * duplicated). Returns undefined for malformed names (empty, whitespace,
26
+ * slashes, non-strings), which callers ignore silently.
27
+ */
28
+ export declare function toCustomEventName(raw: unknown): string | undefined;
29
+ /**
30
+ * startDeclarative installs the delegated click listener and, when the
31
+ * environment has an IntersectionObserver, observes every visible-declared
32
+ * element present at start.
33
+ */
34
+ export declare function startDeclarative(emit: (event: PendingEvent) => void, env: DeclarativeEnv): void;
35
+ export {};
@@ -0,0 +1,100 @@
1
+ // Declarative events (design/04): elements opt in via data-aci-event, with
2
+ // data-aci-on=click|visible (visible = first intersection >= 50%, once per
3
+ // element per page view) and data-aci-prop-* string properties. One
4
+ // delegated click listener + one IntersectionObserver regardless of element
5
+ // count. Malformed declarations are ignored silently.
6
+ const EVENT_ATTR = "data-aci-event";
7
+ const ON_ATTR = "data-aci-on";
8
+ const PROP_ATTR_PREFIX = "data-aci-prop-";
9
+ const CUSTOM_PREFIX = "custom.";
10
+ const VISIBLE_THRESHOLD = 0.5;
11
+ /**
12
+ * toCustomEventName normalizes a declared or API-supplied event name into
13
+ * the custom.* namespace: trimmed, lowercased, prefix enforced (never
14
+ * duplicated). Returns undefined for malformed names (empty, whitespace,
15
+ * slashes, non-strings), which callers ignore silently.
16
+ */
17
+ export function toCustomEventName(raw) {
18
+ if (typeof raw !== "string")
19
+ return undefined;
20
+ const name = raw.trim().toLowerCase();
21
+ if (name === "" || /[\s/]/.test(name))
22
+ return undefined;
23
+ return name.startsWith(CUSTOM_PREFIX) ? name : CUSTOM_PREFIX + name;
24
+ }
25
+ function extractProps(el) {
26
+ let props;
27
+ for (const attr of Array.from(el.attributes)) {
28
+ if (!attr.name.startsWith(PROP_ATTR_PREFIX))
29
+ continue;
30
+ const key = attr.name.slice(PROP_ATTR_PREFIX.length);
31
+ if (key === "")
32
+ continue;
33
+ (props ??= {})[key] = attr.value;
34
+ }
35
+ return props;
36
+ }
37
+ function declaredEvent(el, debug) {
38
+ const eventName = toCustomEventName(el.getAttribute(EVENT_ATTR));
39
+ if (eventName === undefined) {
40
+ debug?.("capture", { reason: "malformed_declaration", attr: EVENT_ATTR });
41
+ return undefined;
42
+ }
43
+ return { eventName, properties: extractProps(el) };
44
+ }
45
+ function trigger(el) {
46
+ return el.getAttribute(ON_ATTR)?.trim().toLowerCase() ?? "click";
47
+ }
48
+ /**
49
+ * startDeclarative installs the delegated click listener and, when the
50
+ * environment has an IntersectionObserver, observes every visible-declared
51
+ * element present at start.
52
+ */
53
+ export function startDeclarative(emit, env) {
54
+ const { document: doc, debug } = env;
55
+ doc.addEventListener("click", (e) => {
56
+ try {
57
+ const target = e.target;
58
+ const el = target?.closest?.(`[${EVENT_ATTR}]`);
59
+ if (!el)
60
+ return;
61
+ if (trigger(el) !== "click")
62
+ return; // visible or malformed trigger
63
+ const event = declaredEvent(el, debug);
64
+ if (event)
65
+ emit(event);
66
+ }
67
+ catch (err) {
68
+ debug?.("capture", { reason: "click_handler_failed", error: String(err) });
69
+ }
70
+ });
71
+ const Observer = env.intersectionObserver;
72
+ if (typeof Observer !== "function")
73
+ return;
74
+ const visibleElements = Array.from(doc.querySelectorAll(`[${EVENT_ATTR}]`)).filter((el) => trigger(el) === "visible");
75
+ if (visibleElements.length === 0)
76
+ return;
77
+ const fired = new WeakSet();
78
+ const observer = new Observer((entries, obs) => {
79
+ try {
80
+ for (const entry of entries) {
81
+ if (!entry.isIntersecting)
82
+ continue;
83
+ if (entry.intersectionRatio < VISIBLE_THRESHOLD)
84
+ continue;
85
+ if (fired.has(entry.target))
86
+ continue;
87
+ fired.add(entry.target);
88
+ obs.unobserve(entry.target); // once per element per page view
89
+ const event = declaredEvent(entry.target, debug);
90
+ if (event)
91
+ emit(event);
92
+ }
93
+ }
94
+ catch (err) {
95
+ debug?.("capture", { reason: "visible_handler_failed", error: String(err) });
96
+ }
97
+ }, { threshold: VISIBLE_THRESHOLD });
98
+ for (const el of visibleElements)
99
+ observer.observe(el);
100
+ }
@@ -0,0 +1,55 @@
1
+ import type { CollectEvent } from "./batcher.js";
2
+ import type { DebugFn } from "./config.js";
3
+ export declare const GAP_BUFFER_KEY = "aci:buf";
4
+ export declare const GAP_BUFFER_CAP = 50;
5
+ export interface StorageLike {
6
+ getItem(key: string): string | null;
7
+ setItem(key: string, value: string): void;
8
+ removeItem(key: string): void;
9
+ }
10
+ export interface GapBufferEnv {
11
+ storage?: StorageLike | undefined;
12
+ debug?: DebugFn | undefined;
13
+ }
14
+ export declare class GapBuffer {
15
+ private events;
16
+ private storage;
17
+ private readonly debug;
18
+ constructor(env?: GapBufferEnv);
19
+ push(event: CollectEvent): void;
20
+ /** drain returns and clears all buffered events, preserving order and ids. */
21
+ drain(): CollectEvent[];
22
+ /** purge empties the buffer (consent withdrawal). */
23
+ purge(): void;
24
+ size(): number;
25
+ private restore;
26
+ private persist;
27
+ }
28
+ /** hasMintMarker reports whether the edge-set aci_m marker cookie exists. */
29
+ export declare function hasMintMarker(doc: {
30
+ cookie: string;
31
+ }): boolean;
32
+ export interface EventSink {
33
+ push(event: CollectEvent): void;
34
+ }
35
+ export interface RouteDeps {
36
+ /** Whether the consent envelope currently grants personalization. */
37
+ grants: () => boolean;
38
+ /** Whether the mint marker cookie is present. */
39
+ marker: () => boolean;
40
+ gapBuffer: GapBuffer;
41
+ batcher: EventSink;
42
+ }
43
+ /**
44
+ * routeEvent implements the emit routing (design/05): events captured in
45
+ * the consent gap — envelope grants personalization AND the marker is
46
+ * absent — are buffered; everything else goes straight to the batcher (the
47
+ * collector enforces consent server-side).
48
+ */
49
+ export declare function routeEvent(event: CollectEvent, deps: RouteDeps): void;
50
+ /**
51
+ * maybeFlushGapBuffer drains the buffer through the normal batcher once the
52
+ * mint marker appears. Runs at init and on every batcher flush tick, so a
53
+ * mint by another tab or navigation is noticed.
54
+ */
55
+ export declare function maybeFlushGapBuffer(gapBuffer: GapBuffer, batcher: EventSink, marker: () => boolean, debug?: DebugFn): void;