@tokenoftrust/storefront-runner 2.0.4 → 2.0.6
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/apps/storefront/migrations-apps/0011_dt_tracking_tenant_databases.sql +23 -0
- package/apps/storefront/node_modules/.bin/vitest +2 -2
- package/apps/storefront/node_modules/.vite/deps/_metadata.json +19 -19
- package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +3 -3
- package/apps/storefront/src/components/chrome/MegaMenu.astro +11 -1
- package/apps/storefront/src/components/chrome/NavDropdown.astro +49 -7
- package/apps/storefront/src/components/chrome/SiteFooter.astro +29 -4
- package/apps/storefront/src/components/chrome/SiteHeader.astro +39 -6
- package/apps/storefront/src/components/chrome/UtilityNav.astro +13 -1
- package/apps/storefront/src/components/islands/MobileNav.tsx +42 -2
- package/apps/storefront/src/layouts/Layout.astro +64 -6
- package/apps/storefront/src/lib/chrome/ctaIdentity.ts +21 -0
- package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +22 -22
- package/apps/storefront/src/lib/chrome/trackingPlacements.ts +36 -0
- package/apps/storefront/src/lib/d1/schema-apps.ts +57 -0
- package/apps/storefront/src/lib/rawChrome.ts +73 -14
- package/apps/storefront/src/lib/rawMarketingHtml.ts +30 -0
- package/apps/storefront/src/lib/shared/developerInvites.ts +6 -0
- package/apps/storefront/src/lib/storyblok/provider.ts +21 -5
- package/apps/storefront/src/lib/tracking/collectorAllowlist.ts +40 -0
- package/apps/storefront/src/lib/tracking/collectorIngest.ts +188 -0
- package/apps/storefront/src/lib/tracking/d1Control.ts +180 -0
- package/apps/storefront/src/lib/tracking/interactionCollector.ts +202 -0
- package/apps/storefront/src/lib/tracking/interactionHealth.ts +252 -0
- package/apps/storefront/src/lib/tracking/interactionSchema.ts +107 -0
- package/apps/storefront/src/lib/tracking/interactionStore.ts +0 -0
- package/apps/storefront/src/lib/tracking/pruneRollup.ts +260 -0
- package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +162 -0
- package/apps/storefront/src/lib/tracking/tenantD1Registry.ts +214 -0
- package/apps/storefront/src/lib/tracking/tenantReadAccess.ts +138 -0
- package/apps/storefront/src/lib/tracking-writeback/rewriteTrackingIds.ts +175 -0
- package/apps/storefront/src/lib/tracking-writeback/writebackCandidate.ts +110 -0
- package/apps/storefront/src/pages/api/admin/interaction-insights.ts +101 -0
- package/apps/storefront/src/pages/api/tracking/interaction.ts +123 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +33 -14
- package/apps/storefront/src/workers/pruneRollupWorker.ts +120 -0
- package/package.json +1 -1
- package/packages/public-runtime/node_modules/.bin/vitest +2 -2
- package/packages/public-runtime/src/chrome.ts +18 -7
- package/packages/public-runtime/src/index.ts +10 -0
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +142 -0
- package/packages/public-runtime/src/interaction-tracking.ts +364 -0
- package/packages/public-runtime/src/tracking-id-derivation.ts +1008 -0
- package/scripts/tenant/validate.mjs +306 -1
- package/scripts/tot-dev.mjs +59 -3
- package/tenants/home/public/js/storefront.js +6 -87
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* `parseConfig.ts` re-export from here so every existing renderer import
|
|
29
29
|
* keeps resolving unchanged.
|
|
30
30
|
*/
|
|
31
|
+
import { validateTracking, type TrackingDeclaration } from "./interaction-tracking.js";
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* A navigation/footer/CTA link. `id` is a STABLE identifier, independent of
|
|
@@ -35,14 +36,20 @@
|
|
|
35
36
|
* registry (spec: no runtime registry, no DOM observer; just a schema-
|
|
36
37
|
* validated identifier + a real `href` fallback, always present on every
|
|
37
38
|
* actionable item). `external` opens off-site; `badge` shows e.g. "NEW".
|
|
39
|
+
*
|
|
40
|
+
* Every actionable item also carries the declarative-interaction-tracking
|
|
41
|
+
* declaration ({@link TrackingDeclaration}): a stable `action` naming the
|
|
42
|
+
* INTENT, or `tracking: "none"` to stamp nothing at all. `id` (identity) and
|
|
43
|
+
* `action` (intent) are different axes and are never derived from each other —
|
|
44
|
+
* see docs/architecture/interaction-tracking-attribute-contract.md.
|
|
38
45
|
*/
|
|
39
|
-
export
|
|
46
|
+
export type ChromeLink = {
|
|
40
47
|
id: string;
|
|
41
48
|
label: string;
|
|
42
49
|
href: string;
|
|
43
50
|
external?: boolean;
|
|
44
51
|
badge?: string;
|
|
45
|
-
}
|
|
52
|
+
} & TrackingDeclaration;
|
|
46
53
|
|
|
47
54
|
/** One column of a mega-menu panel. */
|
|
48
55
|
export interface MegaColumn {
|
|
@@ -56,7 +63,7 @@ export interface MegaColumn {
|
|
|
56
63
|
* marks it (e.g. "NEW"); `flag` gives it the emphasized/highlighted
|
|
57
64
|
* treatment. See {@link ChromeLink} for `id`.
|
|
58
65
|
*/
|
|
59
|
-
export
|
|
66
|
+
export type ChromeNavItem = {
|
|
60
67
|
id: string;
|
|
61
68
|
label: string;
|
|
62
69
|
href: string;
|
|
@@ -64,7 +71,7 @@ export interface ChromeNavItem {
|
|
|
64
71
|
flag?: boolean;
|
|
65
72
|
children?: ChromeLink[];
|
|
66
73
|
columns?: MegaColumn[];
|
|
67
|
-
}
|
|
74
|
+
} & TrackingDeclaration;
|
|
68
75
|
|
|
69
76
|
export interface SocialLink {
|
|
70
77
|
label: string;
|
|
@@ -81,16 +88,16 @@ export interface ChromeLogo {
|
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
/** A marketing conversion CTA (header-actions / footer, non-commerce tenants). */
|
|
84
|
-
export
|
|
91
|
+
export type ChromeCta = {
|
|
85
92
|
id: string;
|
|
86
93
|
label: string;
|
|
87
94
|
href: string;
|
|
88
95
|
variant?: "primary" | "secondary" | "ghost";
|
|
89
|
-
}
|
|
96
|
+
} & TrackingDeclaration;
|
|
90
97
|
|
|
91
98
|
/** A stable link-with-a-name-and-href, minus badge/external — used for the
|
|
92
99
|
* small number of single-purpose action links (e.g. a member-price cue). */
|
|
93
|
-
export type ChromeActionLink =
|
|
100
|
+
export type ChromeActionLink = { id: string; label: string; href: string } & TrackingDeclaration;
|
|
94
101
|
|
|
95
102
|
export interface FooterColumn {
|
|
96
103
|
title: string;
|
|
@@ -371,6 +378,7 @@ function validateLink(v: unknown, path: string, errors: string[]): v is ChromeLi
|
|
|
371
378
|
if (!isNonEmptyString(v.href)) errors.push(`${path}.href: required, non-empty string`);
|
|
372
379
|
checkOptionalBoolean(v.external, `${path}.external`, errors);
|
|
373
380
|
checkOptionalString(v.badge, `${path}.badge`, errors);
|
|
381
|
+
validateTracking(v, path, errors);
|
|
374
382
|
return errors.length === before;
|
|
375
383
|
}
|
|
376
384
|
|
|
@@ -384,6 +392,7 @@ function validateActionLink(v: unknown, path: string, errors: string[]): boolean
|
|
|
384
392
|
if (!isNonEmptyString(v.id)) errors.push(`${path}.id: required, non-empty string`);
|
|
385
393
|
if (!isNonEmptyString(v.label)) errors.push(`${path}.label: required, non-empty string`);
|
|
386
394
|
if (!isNonEmptyString(v.href)) errors.push(`${path}.href: required, non-empty string`);
|
|
395
|
+
validateTracking(v, path, errors);
|
|
387
396
|
return errors.length === before;
|
|
388
397
|
}
|
|
389
398
|
|
|
@@ -415,6 +424,7 @@ function validateNavItem(v: unknown, path: string, errors: string[]): v is Chrom
|
|
|
415
424
|
if (!isNonEmptyString(v.href)) errors.push(`${path}.href: required, non-empty string`);
|
|
416
425
|
checkOptionalString(v.badge, `${path}.badge`, errors);
|
|
417
426
|
checkOptionalBoolean(v.flag, `${path}.flag`, errors);
|
|
427
|
+
validateTracking(v, path, errors);
|
|
418
428
|
if (v.children !== undefined) {
|
|
419
429
|
if (!Array.isArray(v.children)) errors.push(`${path}.children: must be an array`);
|
|
420
430
|
else v.children.forEach((c, i) => validateLink(c, `${path}.children[${i}]`, errors));
|
|
@@ -438,6 +448,7 @@ function validateCta(v: unknown, path: string, errors: string[]): v is ChromeCta
|
|
|
438
448
|
if (v.variant !== undefined && !["primary", "secondary", "ghost"].includes(v.variant as string)) {
|
|
439
449
|
errors.push(`${path}.variant: must be one of primary, secondary, ghost`);
|
|
440
450
|
}
|
|
451
|
+
validateTracking(v, path, errors);
|
|
441
452
|
return errors.length === before;
|
|
442
453
|
}
|
|
443
454
|
|
|
@@ -33,6 +33,16 @@ export * from "./customization-versioning.js";
|
|
|
33
33
|
export * from "./customization-scripts.js";
|
|
34
34
|
export * from "./customization-reconcile.js";
|
|
35
35
|
export * from "./chrome.js";
|
|
36
|
+
// Declarative interaction tracking: the data-tot-el / -action / -placement
|
|
37
|
+
// attribute contract, its single emit path, and the ONE delegated click
|
|
38
|
+
// listener both document mounts install. Reads declared attributes only —
|
|
39
|
+
// never rendered content, never form values.
|
|
40
|
+
// See docs/architecture/interaction-tracking-attribute-contract.md.
|
|
41
|
+
export * from "./interaction-tracking.js";
|
|
42
|
+
// `data-tot-el` id derivation: the authoring-time engine that proposes a
|
|
43
|
+
// stable, human-readable IDENTITY from fragment STRUCTURE alone. A proposal
|
|
44
|
+
// engine only — never a runtime fallback, and it derives no `action`.
|
|
45
|
+
export * from "./tracking-id-derivation.js";
|
|
36
46
|
export * from "./candidate-index.js";
|
|
37
47
|
export * from "./extension-contract.js";
|
|
38
48
|
export * from "./integration-capabilities.js";
|
|
@@ -65,6 +65,7 @@ export * from "./consent-broker.js";
|
|
|
65
65
|
export * from "./consent-cmp-demand.js";
|
|
66
66
|
export * from "./consent-config.js";
|
|
67
67
|
export * from "./consent-preview.js";
|
|
68
|
+
export * from "./interaction-tracking-gate.js";
|
|
68
69
|
export * from "./candidate-evidence.js";
|
|
69
70
|
export * from "./lifecycle.js";
|
|
70
71
|
export * from "./lifecycle-runtime.js";
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consent + environment gate in front of interaction-tracking delivery
|
|
3
|
+
* (workstream `tot-storefront-declarative-tracking`, unit
|
|
4
|
+
* `dt-consent-environment-gate`).
|
|
5
|
+
*
|
|
6
|
+
* `interaction-tracking.ts`'s listener deliberately knows nothing about consent,
|
|
7
|
+
* environment, or tier — it hands every extracted {@link TotInteraction} straight
|
|
8
|
+
* to whatever `deliver` hook it was installed with. This module is that gate,
|
|
9
|
+
* wrapped IN FRONT of a caller-supplied `deliver`, never inside the listener:
|
|
10
|
+
*
|
|
11
|
+
* - CONSENT: the `analytics` purpose must be eligible under the current
|
|
12
|
+
* snapshot the neutral consent broker maintains
|
|
13
|
+
* ({@link isPurposeEligible}, `consent-broker.ts`). A missing or tampered
|
|
14
|
+
* `window.__TOT_INTEGRATION_CONSENT__` global fails closed to the
|
|
15
|
+
* default-deny snapshot, same as every other consumer of that global
|
|
16
|
+
* (`lifecycle-boot.ts`, the Cloudflare Web Analytics loader in
|
|
17
|
+
* `Layout.astro`).
|
|
18
|
+
* - ENVIRONMENT: the renderer-published four-stage environment
|
|
19
|
+
* (`resolveBrowserEnvironment`, `environment.ts`) must resolve AND must not
|
|
20
|
+
* be `candidate`. A per-change candidate render is a preview — it is never
|
|
21
|
+
* live, and its clicks are not real interaction data, so they are never
|
|
22
|
+
* delivered. An unresolved/invalid `window.__TOT_INTEGRATION_ENV__` global
|
|
23
|
+
* fails closed exactly like an unresolved consent snapshot: no delivery.
|
|
24
|
+
*
|
|
25
|
+
* Both checks are read fresh on EVERY interaction, never cached at wrap time —
|
|
26
|
+
* so a later consent grant (a CMP update, a native banner decision) or a stage
|
|
27
|
+
* change is honored on the very next click with no rebinding, the same posture
|
|
28
|
+
* `managed-analytics-bridge.ts` documents for its own consent handling
|
|
29
|
+
* ("Consent changes are intentionally an in-place no-op for bridge lifecycle").
|
|
30
|
+
*
|
|
31
|
+
* This module does not deliver, batch, transport, or allowlist actions — that is
|
|
32
|
+
* `dt-collector-v1`'s job. It answers exactly one question: should this
|
|
33
|
+
* interaction reach the deliver hook at all?
|
|
34
|
+
*/
|
|
35
|
+
import type { InteractionDeliver, TotInteraction } from "../interaction-tracking.js";
|
|
36
|
+
import {
|
|
37
|
+
defaultConsentSnapshot,
|
|
38
|
+
isPurposeEligible,
|
|
39
|
+
parseConsentSnapshot,
|
|
40
|
+
type ConsentSnapshot,
|
|
41
|
+
} from "./consent-broker.js";
|
|
42
|
+
import { INTEGRATION_CONSENT_GLOBAL } from "./render-phases.js";
|
|
43
|
+
import {
|
|
44
|
+
validateBrowserEnvironment,
|
|
45
|
+
BROWSER_ENVIRONMENT_GLOBAL,
|
|
46
|
+
type BrowserEnvironment,
|
|
47
|
+
} from "./environment.js";
|
|
48
|
+
|
|
49
|
+
/** The consent purpose interaction tracking declares. A closed-vocabulary member. */
|
|
50
|
+
export const INTERACTION_TRACKING_CONSENT_PURPOSE = "analytics";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The narrow slice of `window` this gate reads — structural, so a test can pass a
|
|
54
|
+
* plain object instead of a real `window` and the gate stays edge-safe typed.
|
|
55
|
+
*/
|
|
56
|
+
export interface InteractionTrackingGateWindow {
|
|
57
|
+
[key: string]: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface InteractionTrackingGateOptions {
|
|
61
|
+
/**
|
|
62
|
+
* The window to read the consent/environment globals off. Defaults to the
|
|
63
|
+
* global `window` when present; off the browser (no global `window`, and none
|
|
64
|
+
* supplied) both reads resolve to their fail-closed defaults, so delivery is
|
|
65
|
+
* always denied rather than throwing.
|
|
66
|
+
*/
|
|
67
|
+
win?: InteractionTrackingGateWindow;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveWindow(win?: InteractionTrackingGateWindow): InteractionTrackingGateWindow | null {
|
|
71
|
+
if (win) return win;
|
|
72
|
+
return typeof window !== "undefined" ? (window as unknown as InteractionTrackingGateWindow) : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Read the current consent snapshot off the renderer-inlined global. Fail-closed default-deny. */
|
|
76
|
+
function readConsentSnapshot(win: InteractionTrackingGateWindow | null): ConsentSnapshot {
|
|
77
|
+
return parseConsentSnapshot(win?.[INTEGRATION_CONSENT_GLOBAL]) ?? defaultConsentSnapshot();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read the renderer-published environment. `null` when the global is
|
|
82
|
+
* absent/invalid — the same fail-closed posture `lifecycle-boot.ts` uses, except
|
|
83
|
+
* this gate has no descriptor-derived fallback to fall back to: an unresolved
|
|
84
|
+
* environment simply denies delivery.
|
|
85
|
+
*/
|
|
86
|
+
function readBrowserEnvironment(win: InteractionTrackingGateWindow | null): BrowserEnvironment | null {
|
|
87
|
+
const result = validateBrowserEnvironment(win?.[BROWSER_ENVIRONMENT_GLOBAL]);
|
|
88
|
+
return result.ok ? result.value : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether an interaction may be delivered RIGHT NOW: consent AND environment
|
|
93
|
+
* must both permit it.
|
|
94
|
+
*
|
|
95
|
+
* - Consent: {@link INTERACTION_TRACKING_CONSENT_PURPOSE} must be eligible
|
|
96
|
+
* under the live snapshot.
|
|
97
|
+
* - Environment: must resolve, and must not be `"candidate"`.
|
|
98
|
+
*
|
|
99
|
+
* Exported standalone (in addition to {@link gateInteractionDelivery}) so a
|
|
100
|
+
* caller that needs the yes/no without wrapping a hook — e.g. a health/
|
|
101
|
+
* diagnostics assertion — can ask the same question the gate itself asks.
|
|
102
|
+
*/
|
|
103
|
+
export function isInteractionDeliveryPermitted(
|
|
104
|
+
options: InteractionTrackingGateOptions = {},
|
|
105
|
+
): boolean {
|
|
106
|
+
const win = resolveWindow(options.win);
|
|
107
|
+
const consent = readConsentSnapshot(win);
|
|
108
|
+
if (!isPurposeEligible(consent, INTERACTION_TRACKING_CONSENT_PURPOSE)) return false;
|
|
109
|
+
const environment = readBrowserEnvironment(win);
|
|
110
|
+
if (!environment) return false;
|
|
111
|
+
return environment.environment !== "candidate";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Wrap a delivery hook with the consent+environment gate. The returned function
|
|
116
|
+
* has the exact {@link InteractionDeliver} shape `installInteractionTracking`
|
|
117
|
+
* and `onInteraction` expect, so it drops in wherever an unwrapped `deliver`
|
|
118
|
+
* would go:
|
|
119
|
+
*
|
|
120
|
+
* ```ts
|
|
121
|
+
* import { onInteraction, gateInteractionDelivery } from "@tot/public-runtime";
|
|
122
|
+
*
|
|
123
|
+
* onInteraction(gateInteractionDelivery((interaction) => {
|
|
124
|
+
* // dt-collector-v1's transport — only ever called when consent AND
|
|
125
|
+
* // environment both currently permit it.
|
|
126
|
+
* }));
|
|
127
|
+
* ```
|
|
128
|
+
*
|
|
129
|
+
* A gated-out interaction is silently dropped: `deliver` is simply never
|
|
130
|
+
* called for it, there is no error, no queuing, and no retry. `options.win`
|
|
131
|
+
* is read at CALL TIME (never captured once at wrap time), so gating tracks
|
|
132
|
+
* live consent/environment changes across the wrapped hook's whole lifetime.
|
|
133
|
+
*/
|
|
134
|
+
export function gateInteractionDelivery(
|
|
135
|
+
deliver: InteractionDeliver,
|
|
136
|
+
options: InteractionTrackingGateOptions = {},
|
|
137
|
+
): InteractionDeliver {
|
|
138
|
+
return (interaction: TotInteraction): void => {
|
|
139
|
+
if (!isInteractionDeliveryPermitted(options)) return;
|
|
140
|
+
deliver(interaction);
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative interaction tracking — the attribute contract, its emit side, and
|
|
3
|
+
* the ONE delegated click listener that reads it.
|
|
4
|
+
*
|
|
5
|
+
* The contract is three attributes with three DIFFERENT stability properties,
|
|
6
|
+
* plus an explicit opt-out. Nothing here is inferred: no DOM-position
|
|
7
|
+
* heuristics, no id-prefix stripping, no derived defaults. A required field
|
|
8
|
+
* that is absent is a validation failure (chrome.ts) or a stamp-time throw
|
|
9
|
+
* ({@link InvalidTrackingStampError}) — never something papered over.
|
|
10
|
+
*
|
|
11
|
+
* data-tot-el IDENTITY. This specific element. Assigned once,
|
|
12
|
+
* permanent, travels with the element through
|
|
13
|
+
* restructuring. A change here is drift = defect.
|
|
14
|
+
* data-tot-action ACTION / INTENT ("book-demo", "nav-pricing"). Stable,
|
|
15
|
+
* and deliberately SHARED across every placement of the
|
|
16
|
+
* same intent.
|
|
17
|
+
* data-tot-placement PLACEMENT / CONTEXT ("header", "hero", "footer",
|
|
18
|
+
* "mobile-menu"). Expected to change across redesigns; a
|
|
19
|
+
* change here is DATA, not drift. Emitted EXPLICITLY by
|
|
20
|
+
* the renderer — never inferred from DOM position or
|
|
21
|
+
* ancestry.
|
|
22
|
+
*
|
|
23
|
+
* Repeat containers carry the instance pair, resolved by nearest ancestor:
|
|
24
|
+
* data-tot-scope the repeat container ("nav-primary", "product-grid")
|
|
25
|
+
* data-tot-i the zero-based instance index within that scope
|
|
26
|
+
*
|
|
27
|
+
* OPT-OUT: `tracking: "none"` on a chrome item stamps NOTHING on the element —
|
|
28
|
+
* no `data-tot-el`, no action, no placement. ABSENCE is the signal; there is
|
|
29
|
+
* deliberately no sentinel value, because a sentinel would still match the
|
|
30
|
+
* listener's `[data-tot-el]` selector.
|
|
31
|
+
*
|
|
32
|
+
* PAYLOAD GUARDRAIL: this module never reads rendered content. No
|
|
33
|
+
* `textContent`, `innerText`, `innerHTML`, `alt`, `title`, `value`,
|
|
34
|
+
* `placeholder`, and no form-field reads — only `getAttribute` and `closest`
|
|
35
|
+
* against the declared attributes above. Machine-enforced by a source sweep in
|
|
36
|
+
* tests/interaction-tracking.test.ts, so the guarantee survives edits.
|
|
37
|
+
*
|
|
38
|
+
* Full contract, including how downstream units plug in:
|
|
39
|
+
* docs/declarative-interaction-tracking-contract.md
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// The attribute names — one source of truth for every emitter and reader.
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export const TRACKING_ATTR_EL = "data-tot-el";
|
|
47
|
+
export const TRACKING_ATTR_ACTION = "data-tot-action";
|
|
48
|
+
export const TRACKING_ATTR_PLACEMENT = "data-tot-placement";
|
|
49
|
+
export const TRACKING_ATTR_SCOPE = "data-tot-scope";
|
|
50
|
+
export const TRACKING_ATTR_INDEX = "data-tot-i";
|
|
51
|
+
|
|
52
|
+
/** The DOM event the installed listener dispatches when given no `deliver` hook. */
|
|
53
|
+
export const TOT_INTERACTION_EVENT = "tot:interaction";
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Declaration — the authored half of the contract.
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* An element either declares a tracked ACTION or explicitly opts out. The union
|
|
61
|
+
* makes "neither" and "both" unrepresentable in TypeScript; `validateTracking`
|
|
62
|
+
* is the runtime oracle for authored JSON, which TypeScript never sees.
|
|
63
|
+
*/
|
|
64
|
+
export type TrackingDeclaration =
|
|
65
|
+
| { action: string; tracking?: undefined }
|
|
66
|
+
| { action?: undefined; tracking: "none" };
|
|
67
|
+
|
|
68
|
+
/** Everything the renderer must know to stamp one element. */
|
|
69
|
+
export interface TrackingStamp {
|
|
70
|
+
/** IDENTITY — assigned once, permanent. */
|
|
71
|
+
el: string;
|
|
72
|
+
/** ACTION / INTENT — shared across placements. Absent iff opted out. */
|
|
73
|
+
action?: string | undefined;
|
|
74
|
+
/** Explicit opt-out. When "none", NOTHING is stamped. */
|
|
75
|
+
tracking?: "none" | undefined;
|
|
76
|
+
/** PLACEMENT / CONTEXT — always passed explicitly by the renderer. */
|
|
77
|
+
placement: string;
|
|
78
|
+
/** Repeat-container identity, when this element sits in one. */
|
|
79
|
+
scope?: string | undefined;
|
|
80
|
+
/** Zero-based instance index within `scope`. */
|
|
81
|
+
index?: number | undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Thrown when a renderer asks to stamp an element without the fields the
|
|
86
|
+
* contract requires. Fails closed at render time with an actionable message
|
|
87
|
+
* rather than emitting a partial, un-analyzable element — a missing `el`,
|
|
88
|
+
* `action`, or `placement` is a bug in the caller, not a degraded-but-safe
|
|
89
|
+
* state.
|
|
90
|
+
*/
|
|
91
|
+
export class InvalidTrackingStampError extends Error {
|
|
92
|
+
constructor(message: string) {
|
|
93
|
+
super(
|
|
94
|
+
`${message} — declarative interaction tracking requires an explicit ${TRACKING_ATTR_EL} ` +
|
|
95
|
+
`(identity), ${TRACKING_ATTR_ACTION} (intent) and ${TRACKING_ATTR_PLACEMENT} (context) on ` +
|
|
96
|
+
`every tracked element, or \`tracking: "none"\` to stamp nothing at all. There is no derived ` +
|
|
97
|
+
`default and no inference from DOM position.`,
|
|
98
|
+
);
|
|
99
|
+
this.name = "InvalidTrackingStampError";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function nonEmpty(v: unknown): v is string {
|
|
104
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The attributes to stamp on one element, or `{}` when the element opted out.
|
|
109
|
+
* The single emit path for every renderer — component-rendered chrome and
|
|
110
|
+
* string-built chrome alike — so no path can invent its own attribute set.
|
|
111
|
+
*/
|
|
112
|
+
export function trackingAttributes(stamp: TrackingStamp): Record<string, string> {
|
|
113
|
+
if (stamp.tracking === "none") {
|
|
114
|
+
if (nonEmpty(stamp.action)) {
|
|
115
|
+
throw new InvalidTrackingStampError(
|
|
116
|
+
`${JSON.stringify(stamp.el)} declares both an \`action\` and \`tracking: "none"\``,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
if (!nonEmpty(stamp.el)) throw new InvalidTrackingStampError("a tracked element has no `el` identity");
|
|
122
|
+
if (!nonEmpty(stamp.action)) {
|
|
123
|
+
throw new InvalidTrackingStampError(`${JSON.stringify(stamp.el)} has no \`action\``);
|
|
124
|
+
}
|
|
125
|
+
if (!nonEmpty(stamp.placement)) {
|
|
126
|
+
throw new InvalidTrackingStampError(`${JSON.stringify(stamp.el)} has no \`placement\``);
|
|
127
|
+
}
|
|
128
|
+
const attrs: Record<string, string> = {
|
|
129
|
+
[TRACKING_ATTR_EL]: stamp.el,
|
|
130
|
+
[TRACKING_ATTR_ACTION]: stamp.action,
|
|
131
|
+
[TRACKING_ATTR_PLACEMENT]: stamp.placement,
|
|
132
|
+
};
|
|
133
|
+
if (nonEmpty(stamp.scope)) attrs[TRACKING_ATTR_SCOPE] = stamp.scope;
|
|
134
|
+
if (stamp.index !== undefined) {
|
|
135
|
+
if (!Number.isInteger(stamp.index) || stamp.index < 0) {
|
|
136
|
+
throw new InvalidTrackingStampError(`${JSON.stringify(stamp.el)} has a non-integer \`index\``);
|
|
137
|
+
}
|
|
138
|
+
attrs[TRACKING_ATTR_INDEX] = String(stamp.index);
|
|
139
|
+
}
|
|
140
|
+
return attrs;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function escAttr(value: string): string {
|
|
144
|
+
return value
|
|
145
|
+
.replaceAll("&", "&")
|
|
146
|
+
.replaceAll("<", "<")
|
|
147
|
+
.replaceAll(">", ">")
|
|
148
|
+
.replaceAll('"', """);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* {@link trackingAttributes} serialized for the string-building renderers
|
|
153
|
+
* (rawChrome.ts). Returns "" for an opted-out element, otherwise a
|
|
154
|
+
* LEADING-SPACE-prefixed attribute run ready to splice inside a start tag.
|
|
155
|
+
*/
|
|
156
|
+
export function trackingAttributesHtml(stamp: TrackingStamp): string {
|
|
157
|
+
const attrs = trackingAttributes(stamp);
|
|
158
|
+
return Object.keys(attrs)
|
|
159
|
+
.map((name) => ` ${name}="${escAttr(attrs[name] as string)}"`)
|
|
160
|
+
.join("");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Validate the tracking half of one authored chrome item: EXACTLY ONE of a
|
|
165
|
+
* non-empty `action` or `tracking: "none"`. Neither is a failure (an item
|
|
166
|
+
* nobody declared an intent for is un-analyzable); both is a failure (the
|
|
167
|
+
* author contradicted themselves). Push-into-`errors` shape so it composes
|
|
168
|
+
* with chrome.ts's collect-every-violation validators.
|
|
169
|
+
*/
|
|
170
|
+
export function validateTracking(v: Record<string, unknown>, path: string, errors: string[]): void {
|
|
171
|
+
const hasAction = nonEmpty(v.action);
|
|
172
|
+
const optedOut = v.tracking === "none";
|
|
173
|
+
if (v.action !== undefined && !hasAction) {
|
|
174
|
+
errors.push(`${path}.action: must be a non-empty string`);
|
|
175
|
+
}
|
|
176
|
+
if (v.tracking !== undefined && !optedOut) {
|
|
177
|
+
errors.push(`${path}.tracking: the only accepted value is "none"`);
|
|
178
|
+
}
|
|
179
|
+
if (hasAction && optedOut) {
|
|
180
|
+
errors.push(
|
|
181
|
+
`${path}: declares both \`action\` and \`tracking: "none"\` — an element is either tracked ` +
|
|
182
|
+
`under one stable intent or explicitly untracked, never both`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (!hasAction && !optedOut && v.tracking === undefined) {
|
|
186
|
+
errors.push(
|
|
187
|
+
`${path}.action: required, non-empty string — the stable ACTION/INTENT this element performs ` +
|
|
188
|
+
`(e.g. "book-demo", "nav-pricing"), shared across every placement of that intent. Set ` +
|
|
189
|
+
`\`"tracking": "none"\` instead to stamp nothing on this element.`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Read side — the ONE delegated click listener.
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/** What the listener extracts. Attribute values only; never rendered content. */
|
|
199
|
+
export interface TotInteraction {
|
|
200
|
+
/** The interaction kind. Only "click" exists in V1. */
|
|
201
|
+
type: "click";
|
|
202
|
+
/** `data-tot-el` — the element's permanent identity. */
|
|
203
|
+
el: string;
|
|
204
|
+
/** `data-tot-action`, or null when the element carries no action attribute. */
|
|
205
|
+
action: string | null;
|
|
206
|
+
/** `data-tot-placement`, or null when unstamped. */
|
|
207
|
+
placement: string | null;
|
|
208
|
+
/** `data-tot-scope` from the nearest ancestor repeat container, else null. */
|
|
209
|
+
scope: string | null;
|
|
210
|
+
/** `data-tot-i` from the nearest ancestor carrying it, else null. */
|
|
211
|
+
index: number | null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The delivery hook. Downstream units supply this; this module never delivers. */
|
|
215
|
+
export type InteractionDeliver = (interaction: TotInteraction) => void;
|
|
216
|
+
|
|
217
|
+
/** A node as this module reads it: declared attributes and ancestor lookup only. */
|
|
218
|
+
export interface TrackingNode {
|
|
219
|
+
getAttribute?(name: string): string | null;
|
|
220
|
+
closest?(selector: string): TrackingNode | null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The event target the listener attaches to (`document` in a browser). */
|
|
224
|
+
export interface InteractionTarget {
|
|
225
|
+
addEventListener(type: string, handler: (event: unknown) => void, capture?: boolean): void;
|
|
226
|
+
removeEventListener(type: string, handler: (event: unknown) => void, capture?: boolean): void;
|
|
227
|
+
dispatchEvent?(event: unknown): unknown;
|
|
228
|
+
/** Install-once marker, so the three injection paths can all install safely. */
|
|
229
|
+
__totInteractionTracking?: boolean;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The listener itself. SELF-CONTAINED by construction: it references no
|
|
234
|
+
* module-scope binding, so {@link INTERACTION_TRACKING_BOOT_JS} can serialize it
|
|
235
|
+
* verbatim for the string-injection render paths and there is exactly ONE
|
|
236
|
+
* implementation of the read side in the repo. Type annotations are erased at
|
|
237
|
+
* build time and are therefore free.
|
|
238
|
+
*
|
|
239
|
+
* Keep it that way — a reference to an outer constant would survive
|
|
240
|
+
* minification as a mangled, undefined name in the serialized copy. The
|
|
241
|
+
* duplicated attribute-name literals are pinned to the exported constants by a
|
|
242
|
+
* test.
|
|
243
|
+
*/
|
|
244
|
+
function interactionTrackingBoot(
|
|
245
|
+
deliver: InteractionDeliver | null,
|
|
246
|
+
target: InteractionTarget | null,
|
|
247
|
+
): () => void {
|
|
248
|
+
const noop = function (): void {};
|
|
249
|
+
if (!target || typeof target.addEventListener !== "function") return noop;
|
|
250
|
+
if (target.__totInteractionTracking) return noop;
|
|
251
|
+
target.__totInteractionTracking = true;
|
|
252
|
+
|
|
253
|
+
const EL = "data-tot-el";
|
|
254
|
+
const ACTION = "data-tot-action";
|
|
255
|
+
const PLACEMENT = "data-tot-placement";
|
|
256
|
+
const SCOPE = "data-tot-scope";
|
|
257
|
+
const INDEX = "data-tot-i";
|
|
258
|
+
const EVENT = "tot:interaction";
|
|
259
|
+
|
|
260
|
+
const attr = function (node: TrackingNode | null, name: string): string | null {
|
|
261
|
+
return node && typeof node.getAttribute === "function" ? node.getAttribute(name) : null;
|
|
262
|
+
};
|
|
263
|
+
// Nearest ancestor-or-self carrying `name`. A tracked CTA commonly wraps child
|
|
264
|
+
// markup (an icon, a nested span), so the click target is often NOT the
|
|
265
|
+
// tracked element — closest-match, never exact-target-only.
|
|
266
|
+
const nearest = function (node: TrackingNode | null, name: string): TrackingNode | null {
|
|
267
|
+
return node && typeof node.closest === "function" ? node.closest("[" + name + "]") : null;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const handler = function (event: unknown): void {
|
|
271
|
+
const origin = (event ? (event as { target?: TrackingNode | null }).target : null) ?? null;
|
|
272
|
+
const el = nearest(origin, EL);
|
|
273
|
+
const id = attr(el, EL);
|
|
274
|
+
// No identity anywhere up the ancestor chain: nothing to report.
|
|
275
|
+
if (!el || !id) return;
|
|
276
|
+
const rawIndex = attr(nearest(el, INDEX), INDEX);
|
|
277
|
+
const parsedIndex = rawIndex === null || rawIndex.trim() === "" ? Number.NaN : Number(rawIndex);
|
|
278
|
+
const interaction: TotInteraction = {
|
|
279
|
+
type: "click",
|
|
280
|
+
el: id,
|
|
281
|
+
action: attr(el, ACTION),
|
|
282
|
+
placement: attr(el, PLACEMENT),
|
|
283
|
+
scope: attr(nearest(el, SCOPE), SCOPE),
|
|
284
|
+
index: Number.isInteger(parsedIndex) ? parsedIndex : null,
|
|
285
|
+
};
|
|
286
|
+
if (typeof deliver === "function") {
|
|
287
|
+
deliver(interaction);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (typeof target.dispatchEvent === "function" && typeof CustomEvent === "function") {
|
|
291
|
+
target.dispatchEvent(new CustomEvent(EVENT, { detail: interaction }));
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// Capture phase: a tracked click still reports even when intervening markup
|
|
296
|
+
// calls stopPropagation().
|
|
297
|
+
target.addEventListener("click", handler, true);
|
|
298
|
+
return function (): void {
|
|
299
|
+
target.removeEventListener("click", handler, true);
|
|
300
|
+
target.__totInteractionTracking = false;
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Install the delegated click listener on `target` (default: `document`).
|
|
306
|
+
* Idempotent — the second install on the same target is a no-op, which is what
|
|
307
|
+
* lets all three render paths install unconditionally.
|
|
308
|
+
*
|
|
309
|
+
* `deliver` is the ONLY extension point: consent gating, environment gating,
|
|
310
|
+
* ingest allowlisting and transport all live in the hook's implementation
|
|
311
|
+
* (other units), never here. Omit it and each interaction is dispatched as a
|
|
312
|
+
* {@link TOT_INTERACTION_EVENT} CustomEvent instead, which is how the
|
|
313
|
+
* string-injected render paths hand off.
|
|
314
|
+
*
|
|
315
|
+
* @returns an uninstall function.
|
|
316
|
+
*/
|
|
317
|
+
export function installInteractionTracking(
|
|
318
|
+
options: { deliver?: InteractionDeliver; target?: InteractionTarget } = {},
|
|
319
|
+
): () => void {
|
|
320
|
+
const target =
|
|
321
|
+
options.target ??
|
|
322
|
+
(typeof document !== "undefined" ? (document as unknown as InteractionTarget) : null);
|
|
323
|
+
return interactionTrackingBoot(options.deliver ?? null, target);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Subscribe to interactions delivered as {@link TOT_INTERACTION_EVENT} events —
|
|
328
|
+
* the hand-off for code that cannot pass a `deliver` hook at install time
|
|
329
|
+
* (anything running on a string-injected page). Returns an unsubscribe function.
|
|
330
|
+
*/
|
|
331
|
+
export function onInteraction(
|
|
332
|
+
handler: InteractionDeliver,
|
|
333
|
+
target?: InteractionTarget,
|
|
334
|
+
): () => void {
|
|
335
|
+
const t =
|
|
336
|
+
target ?? (typeof document !== "undefined" ? (document as unknown as InteractionTarget) : null);
|
|
337
|
+
if (!t || typeof t.addEventListener !== "function") return () => {};
|
|
338
|
+
const listener = (event: unknown): void => {
|
|
339
|
+
const detail = (event as { detail?: TotInteraction } | null)?.detail;
|
|
340
|
+
if (detail) handler(detail);
|
|
341
|
+
};
|
|
342
|
+
t.addEventListener(TOT_INTERACTION_EVENT, listener);
|
|
343
|
+
return () => t.removeEventListener(TOT_INTERACTION_EVENT, listener);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* {@link interactionTrackingBoot}'s own compiled source — the anchor that makes
|
|
348
|
+
* "one implementation, every render path" checkable: the imported path calls the
|
|
349
|
+
* function, the string-injected paths embed exactly this text. Exported so a
|
|
350
|
+
* test can evaluate the serialized copy and assert it behaves identically to the
|
|
351
|
+
* imported one.
|
|
352
|
+
*/
|
|
353
|
+
export const INTERACTION_TRACKING_LISTENER_SOURCE = String(interactionTrackingBoot);
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The listener as an inline `<script>` body, for the render paths that build
|
|
357
|
+
* HTML as strings and have no module graph to import from. Serialized from
|
|
358
|
+
* {@link interactionTrackingBoot} itself, so the string-injected paths and the
|
|
359
|
+
* imported path can never drift apart.
|
|
360
|
+
*
|
|
361
|
+
* Contains no `</script>` sequence, and is safe to embed more than once per
|
|
362
|
+
* document (the install-once marker makes the later copies no-ops).
|
|
363
|
+
*/
|
|
364
|
+
export const INTERACTION_TRACKING_BOOT_JS = `(${INTERACTION_TRACKING_LISTENER_SOURCE})(null, typeof document !== "undefined" ? document : null);`;
|