@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.
Files changed (47) hide show
  1. package/apps/storefront/migrations-apps/0011_dt_tracking_tenant_databases.sql +23 -0
  2. package/apps/storefront/node_modules/.bin/vitest +2 -2
  3. package/apps/storefront/node_modules/.vite/deps/_metadata.json +19 -19
  4. package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +3 -3
  5. package/apps/storefront/src/components/chrome/MegaMenu.astro +11 -1
  6. package/apps/storefront/src/components/chrome/NavDropdown.astro +49 -7
  7. package/apps/storefront/src/components/chrome/SiteFooter.astro +29 -4
  8. package/apps/storefront/src/components/chrome/SiteHeader.astro +39 -6
  9. package/apps/storefront/src/components/chrome/UtilityNav.astro +13 -1
  10. package/apps/storefront/src/components/islands/MobileNav.tsx +42 -2
  11. package/apps/storefront/src/layouts/Layout.astro +64 -6
  12. package/apps/storefront/src/lib/chrome/ctaIdentity.ts +21 -0
  13. package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +22 -22
  14. package/apps/storefront/src/lib/chrome/trackingPlacements.ts +36 -0
  15. package/apps/storefront/src/lib/d1/schema-apps.ts +57 -0
  16. package/apps/storefront/src/lib/rawChrome.ts +73 -14
  17. package/apps/storefront/src/lib/rawMarketingHtml.ts +30 -0
  18. package/apps/storefront/src/lib/shared/developerInvites.ts +6 -0
  19. package/apps/storefront/src/lib/storyblok/provider.ts +21 -5
  20. package/apps/storefront/src/lib/tracking/collectorAllowlist.ts +40 -0
  21. package/apps/storefront/src/lib/tracking/collectorIngest.ts +188 -0
  22. package/apps/storefront/src/lib/tracking/d1Control.ts +180 -0
  23. package/apps/storefront/src/lib/tracking/interactionCollector.ts +202 -0
  24. package/apps/storefront/src/lib/tracking/interactionHealth.ts +252 -0
  25. package/apps/storefront/src/lib/tracking/interactionSchema.ts +107 -0
  26. package/apps/storefront/src/lib/tracking/interactionStore.ts +0 -0
  27. package/apps/storefront/src/lib/tracking/pruneRollup.ts +260 -0
  28. package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +162 -0
  29. package/apps/storefront/src/lib/tracking/tenantD1Registry.ts +214 -0
  30. package/apps/storefront/src/lib/tracking/tenantReadAccess.ts +138 -0
  31. package/apps/storefront/src/lib/tracking-writeback/rewriteTrackingIds.ts +175 -0
  32. package/apps/storefront/src/lib/tracking-writeback/writebackCandidate.ts +110 -0
  33. package/apps/storefront/src/pages/api/admin/interaction-insights.ts +101 -0
  34. package/apps/storefront/src/pages/api/tracking/interaction.ts +123 -0
  35. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +33 -14
  36. package/apps/storefront/src/workers/pruneRollupWorker.ts +120 -0
  37. package/package.json +1 -1
  38. package/packages/public-runtime/node_modules/.bin/vitest +2 -2
  39. package/packages/public-runtime/src/chrome.ts +18 -7
  40. package/packages/public-runtime/src/index.ts +10 -0
  41. package/packages/public-runtime/src/integration/index.ts +1 -0
  42. package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +142 -0
  43. package/packages/public-runtime/src/interaction-tracking.ts +364 -0
  44. package/packages/public-runtime/src/tracking-id-derivation.ts +1008 -0
  45. package/scripts/tenant/validate.mjs +306 -1
  46. package/scripts/tot-dev.mjs +59 -3
  47. package/tenants/home/public/js/storefront.js +6 -87
@@ -0,0 +1,1008 @@
1
+ /**
2
+ * `data-tot-el` id derivation — the authoring-time engine that proposes a
3
+ * stable, human-readable IDENTITY for an element in a tenant-authored body
4
+ * fragment.
5
+ *
6
+ * SCOPE. This module is a pure proposal engine and NOTHING else. It is not
7
+ * wired into any render path, publish pipeline, or validator, and it is never a
8
+ * runtime fallback: `validateChromeConfig` still fails an item that declares
9
+ * neither an `action` nor `tracking: "none"`, and `trackingAttributes()` still
10
+ * throws on a missing field. Derivation happens at authoring / migration time,
11
+ * writes an EXPLICIT value into the source, and then has no further say. The
12
+ * consumers are separate units: one writes proposals back to source as a
13
+ * reviewable diff, one stamps non-git content at publish time, one raises
14
+ * validator warnings.
15
+ *
16
+ * It derives ONE axis. `data-tot-el` (identity) and `data-tot-action` (intent)
17
+ * are independent axes, and this engine deliberately contains no path between
18
+ * them in either direction.
19
+ *
20
+ * ---------------------------------------------------------------------------
21
+ * THE PAYLOAD GUARDRAIL — the constraint that shapes everything below
22
+ * ---------------------------------------------------------------------------
23
+ *
24
+ * An id is derived from STRUCTURE ONLY. This module never reads rendered
25
+ * content: no character data, no `alt`/`value`/`placeholder`/heading text, no
26
+ * DOM traversal, no node enumeration. What an element visually SAYS has no
27
+ * influence on its derived id.
28
+ *
29
+ * That is why the input is a plain structural descriptor — a tag name, an
30
+ * attribute bag, and the chain of open ancestors — rather than a DOM node: a
31
+ * descriptor that carries no children and no character data cannot leak
32
+ * content even by accident. The shape is deliberately what a STREAMING rewriter
33
+ * can supply from an open tag plus the tag stack it already maintains, so the
34
+ * git-time rewriter and the publish-time streaming rewriter feed the same
35
+ * engine.
36
+ *
37
+ * Two mechanical enforcements, in tests/tracking-id-derivation.test.ts:
38
+ * 1. a source sweep for every DOM-text and form-value API (the same sweep
39
+ * interaction-tracking.ts carries);
40
+ * 2. a CLOSED ALLOWLIST of readable attributes ({@link READABLE_ATTRIBUTES}
41
+ * plus {@link DEFAULT_NAMING_ATTRIBUTES}), asserted to exclude every
42
+ * text-bearing attribute — so a fragment whose only difference is its
43
+ * visible copy derives byte-identical ids.
44
+ *
45
+ * Note the contrast with `content/prose.ts`, which derives heading ANCHORS from
46
+ * heading text. That is a URL fragment for a human to link to. This is an
47
+ * analytics identity, bound by the payload guardrail, and the two must not
48
+ * borrow each other's approach.
49
+ *
50
+ * ---------------------------------------------------------------------------
51
+ * THE THREE PROPERTIES
52
+ * ---------------------------------------------------------------------------
53
+ *
54
+ * HUMAN-READABLE. A hash fails this module's purpose. Every token in a derived
55
+ * id comes from the author's own vocabulary — their `id`, their `data-section`,
56
+ * their role class, their link destination — composed the way the hand-authored
57
+ * governed-chrome ids compose (`nav-pricing`, `ftr-company-pricing`,
58
+ * `nav-shop-pods`): an outermost-first region path, then the element's role.
59
+ *
60
+ * DETERMINISTIC. A pure function of the descriptor. No randomness, no clock, no
61
+ * hashing, no iteration over unordered maps.
62
+ *
63
+ * STABLE UNDER UNRELATED EDITS. An id is built from the element's OWN naming
64
+ * attributes plus the names of its ancestor regions. Editing, restyling,
65
+ * reordering or deleting an unrelated element elsewhere in the document touches
66
+ * neither, so the id does not move. Raw sibling position is the LAST resort,
67
+ * never a preference — and when it is used the result is reported as
68
+ * `confidence: "positional"` with `needsReview: true`, because that is exactly
69
+ * the id a human should look at before it is committed.
70
+ *
71
+ * PRESERVED PROPERTY. The same intent at two placements derives two DIFFERENT
72
+ * ids, because the region path differs (`nav-pricing` in the header nav,
73
+ * `ftr-pricing` in the footer) — which is the property the governed-chrome
74
+ * convention depends on, since those two share one `data-tot-action`.
75
+ *
76
+ * ---------------------------------------------------------------------------
77
+ * FEEDING THE ENGINE
78
+ * ---------------------------------------------------------------------------
79
+ *
80
+ * This module owns no parser and imports none — `@tot/public-runtime` is
81
+ * Node-free and edge-shipped, and a parser choice belongs to the caller. Each
82
+ * consumer builds {@link DerivationTarget}s from whatever it already has:
83
+ *
84
+ * - a git-time rewriter walks `parse5`'s `parseFragment` tree keeping a tag
85
+ * stack (the pattern `apps/storefront/src/lib/the-build/toc.ts` already uses
86
+ * to add heading anchors and re-`serialize()` byte-preserving when nothing
87
+ * changed);
88
+ * - a publish-time `HTMLRewriter` handler keeps the same stack across
89
+ * element/end-tag callbacks and derives at the open tag;
90
+ * - a validator reads authored JSON and needs no parser at all.
91
+ *
92
+ * Iterate in DOCUMENT ORDER and the ordinals from
93
+ * {@link createTrackingIdDeriver} are identical across all three.
94
+ *
95
+ * Contract: docs/architecture/interaction-tracking-attribute-contract.md
96
+ */
97
+
98
+ import { TRACKING_ATTR_EL } from "./interaction-tracking.js";
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Limits
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /** Longest derived id. Past this a name stops being something a human reads. */
105
+ export const TRACKING_ID_MAX_LENGTH = 64;
106
+
107
+ /** Most `-`-joined tokens in a derived id. Region tokens are trimmed first. */
108
+ export const TRACKING_ID_MAX_TOKENS = 6;
109
+
110
+ /** Longest single token taken from an authored name. */
111
+ const MAX_TOKEN_SOURCE_LENGTH = 40;
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // The closed attribute allowlist — the guardrail's positive half
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Non-`data-` attributes this engine may read. A CLOSED list, asserted by test
119
+ * to contain nothing text-bearing: no `alt`, no `title`, no `value`, no
120
+ * `placeholder`, no `aria-label`. Each is a declaration of structure — what the
121
+ * element IS or where it POINTS — never of what it displays.
122
+ */
123
+ export const READABLE_ATTRIBUTES: readonly string[] = ["id", "class", "href", "name", "type", "role"];
124
+
125
+ /**
126
+ * `data-` attributes that carry an author-chosen NAME for a block, section or
127
+ * interaction. Closed by default rather than an open `data-*` sweep, so a
128
+ * tenant attribute that happens to hold display copy can never become an id
129
+ * source. Extend per-call with {@link DerivationOptions.namingAttributes}.
130
+ */
131
+ export const DEFAULT_NAMING_ATTRIBUTES: readonly string[] = [
132
+ // An authored interaction name. Already in use in tenant fragments
133
+ // (`data-ev="request_access_hero"`) and the single best identity seed there
134
+ // is: the author has already named the interaction AND its placement.
135
+ "data-ev",
136
+ "data-event",
137
+ "data-section",
138
+ "data-region",
139
+ "data-block",
140
+ "data-component",
141
+ "data-cmp",
142
+ "data-card",
143
+ "data-slot",
144
+ "data-name",
145
+ "data-key",
146
+ "data-testid",
147
+ "data-test-id",
148
+ "data-qa",
149
+ ];
150
+
151
+ /**
152
+ * Attributes that hold display copy. Never read, never accepted through
153
+ * {@link DerivationOptions.namingAttributes}, and asserted absent from both
154
+ * allowlists by test. Reading any of these would be a payload-guardrail
155
+ * violation, not a naming shortcut.
156
+ *
157
+ * The whole `data-tot-*` namespace is refused too, by prefix rather than by
158
+ * name — it is the contract's own namespace, so nothing an emitter stamps
159
+ * there may ever seed an identity.
160
+ */
161
+ export const TEXT_BEARING_ATTRIBUTES: readonly string[] = [
162
+ "alt",
163
+ "aria-description",
164
+ "aria-label",
165
+ "aria-placeholder",
166
+ "aria-roledescription",
167
+ "aria-valuetext",
168
+ "content",
169
+ "download",
170
+ "label",
171
+ "placeholder",
172
+ "srcdoc",
173
+ "title",
174
+ "value",
175
+ ];
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Vocabulary tables
179
+ // ---------------------------------------------------------------------------
180
+
181
+ /**
182
+ * Landmark tags that name a region. Abbreviated to match the hand-authored
183
+ * convention in the migrated configs (`ftr-company-pricing`, not
184
+ * `footer-company-pricing`, in the tokenoftrust chrome).
185
+ *
186
+ * The in-repo configs are not uniform about this — `ftr-` and `footer-`,
187
+ * `utility-` and `util-` both occur across tenants — so a tenant that settled
188
+ * on the other spelling overrides the table via
189
+ * {@link DerivationOptions.landmarkRegions} rather than fighting the default.
190
+ */
191
+ export const DEFAULT_LANDMARK_REGIONS: Readonly<Record<string, string>> = {
192
+ header: "header",
193
+ nav: "nav",
194
+ footer: "ftr",
195
+ aside: "aside",
196
+ form: "form",
197
+ table: "table",
198
+ thead: "head",
199
+ tfoot: "foot",
200
+ dialog: "dialog",
201
+ fieldset: "fieldset",
202
+ };
203
+
204
+ /**
205
+ * The page-level landmarks. Two special properties:
206
+ *
207
+ * 1. They TERMINATE the upward region walk. `header` above `nav` adds nothing —
208
+ * the hand-authored convention is `nav-pricing`, not `header-nav-pricing`.
209
+ * 2. Their CLASS is ignored. A landmark's class is reliably a styling handle
210
+ * (`nav-links`, `site-header`, `site-footer`), not a region name, so the
211
+ * landmark word wins. An `id` or a naming attribute still beats it — that is
212
+ * an author naming the region deliberately.
213
+ *
214
+ * `form`, `table` and `fieldset` are deliberately NOT here: they nest, they
215
+ * carry genuinely-naming classes (`pricing-table`), and the region above them
216
+ * is what tells two of them apart.
217
+ */
218
+ const DOCUMENT_LANDMARKS: readonly string[] = ["header", "nav", "footer", "aside", "dialog"];
219
+
220
+ /**
221
+ * Tags whose region name, absent a naming attribute, is not worth a token —
222
+ * every page has them, so they discriminate nothing.
223
+ */
224
+ const GENERIC_CONTAINER_TAGS: readonly string[] = [
225
+ "html",
226
+ "body",
227
+ "main",
228
+ "div",
229
+ "span",
230
+ "p",
231
+ "tbody",
232
+ "tr",
233
+ "ul",
234
+ "ol",
235
+ "dl",
236
+ "picture",
237
+ ];
238
+
239
+ /**
240
+ * The role word for an element with no authored name of its own. Generic by
241
+ * construction, which is why a tag-only role is reported as `positional`.
242
+ */
243
+ const DEFAULT_TAG_ROLES: Readonly<Record<string, string>> = {
244
+ a: "link",
245
+ button: "btn",
246
+ summary: "toggle",
247
+ details: "details",
248
+ input: "field",
249
+ textarea: "field",
250
+ select: "select",
251
+ option: "option",
252
+ label: "field-label",
253
+ form: "form",
254
+ tr: "row",
255
+ td: "cell",
256
+ th: "cell",
257
+ li: "item",
258
+ dt: "term",
259
+ dd: "def",
260
+ img: "image",
261
+ video: "video",
262
+ audio: "audio",
263
+ iframe: "frame",
264
+ section: "section",
265
+ article: "article",
266
+ figure: "figure",
267
+ };
268
+
269
+ /** Tags an interaction can originate from — the derivation candidates. */
270
+ const TRACKABLE_TAGS: readonly string[] = [
271
+ "a",
272
+ "area",
273
+ "button",
274
+ "details",
275
+ "summary",
276
+ "form",
277
+ "input",
278
+ "select",
279
+ "textarea",
280
+ "label",
281
+ ];
282
+
283
+ /**
284
+ * Class names that describe LAYOUT or STATE rather than role. Rejected as id
285
+ * sources: they recur everywhere, so they name nothing, and state classes move
286
+ * at runtime. Drawn from the vocabulary actually present in the in-repo tenant
287
+ * fragments (`wrap`, `center`, `blk`, `panel`, `stage`, `big`, `top`) plus the
288
+ * usual utility words.
289
+ */
290
+ const DEFAULT_UTILITY_CLASSES: readonly string[] = [
291
+ "active",
292
+ "background",
293
+ "bg",
294
+ "big",
295
+ "blk",
296
+ "block",
297
+ "body",
298
+ "bottom",
299
+ "box",
300
+ "center",
301
+ "centre",
302
+ "clearfix",
303
+ "col",
304
+ "column",
305
+ "container",
306
+ "content",
307
+ "current",
308
+ "disabled",
309
+ "even",
310
+ "first",
311
+ "flex",
312
+ "fluid",
313
+ "full",
314
+ "grid",
315
+ "group",
316
+ "half",
317
+ "hidden",
318
+ "holder",
319
+ "inner",
320
+ "invisible",
321
+ "is-active",
322
+ "is-open",
323
+ "large",
324
+ "last",
325
+ "layout",
326
+ "left",
327
+ "lg",
328
+ "main",
329
+ "md",
330
+ "middle",
331
+ "mini",
332
+ "narrow",
333
+ "odd",
334
+ "off",
335
+ "on",
336
+ "open",
337
+ "outer",
338
+ "panel",
339
+ "relative",
340
+ "right",
341
+ "row",
342
+ "screen-reader-text",
343
+ "section",
344
+ "selected",
345
+ "sm",
346
+ "small",
347
+ "sr-only",
348
+ "stack",
349
+ "stage",
350
+ "sticky",
351
+ "text",
352
+ "top",
353
+ "visible",
354
+ "visually-hidden",
355
+ "wide",
356
+ "wrap",
357
+ "wrapper",
358
+ "xl",
359
+ "xs",
360
+ ];
361
+
362
+ /**
363
+ * Suffixes that turn a role class into a layout class. Stripped so an author's
364
+ * `class="cmp-wrap blk"` yields `cmp` and `class="hero hero-grid"` yields
365
+ * `hero`, matching how they would have named it by hand.
366
+ */
367
+ const LAYOUT_CLASS_SUFFIXES: readonly string[] = [
368
+ "-wrap",
369
+ "-wrapper",
370
+ "-container",
371
+ "-inner",
372
+ "-outer",
373
+ "-grid",
374
+ "-flex",
375
+ "-row",
376
+ "-col",
377
+ "-cols",
378
+ "-column",
379
+ "-box",
380
+ "-left",
381
+ "-right",
382
+ "-top",
383
+ "-bottom",
384
+ "-center",
385
+ "-holder",
386
+ ];
387
+
388
+ /** URL schemes that address something other than a page. Never an id source. */
389
+ const NON_PAGE_SCHEMES: readonly string[] = ["mailto:", "tel:", "sms:", "javascript:", "data:", "blob:"];
390
+
391
+ // ---------------------------------------------------------------------------
392
+ // Input / output shapes
393
+ // ---------------------------------------------------------------------------
394
+
395
+ /**
396
+ * One element as this engine sees it: a tag name and an attribute bag. No
397
+ * children, no character data — structurally incapable of carrying content, and
398
+ * exactly what a streaming rewriter has at an open tag.
399
+ */
400
+ export interface StructuralNode {
401
+ /** Tag name. Case-insensitive; normalized internally. */
402
+ tag: string;
403
+ /** Attribute bag. Only allowlisted keys are ever read. */
404
+ attrs?: Readonly<Record<string, string | undefined>> | undefined;
405
+ }
406
+
407
+ /** The element to derive an id for, plus the structure it sits in. */
408
+ export interface DerivationTarget {
409
+ /** The element itself. */
410
+ node: StructuralNode;
411
+ /**
412
+ * The chain of open ancestors, **OUTERMOST FIRST** — i.e. an open-tag stack
413
+ * in push order. A streaming rewriter passes its stack as-is.
414
+ */
415
+ ancestors?: readonly StructuralNode[] | undefined;
416
+ /**
417
+ * The repeat container this element instantiates, when the caller knows it
418
+ * (the `data-tot-scope` axis). Used as a region token when the ancestors
419
+ * carry no better name.
420
+ */
421
+ scope?: string | undefined;
422
+ /**
423
+ * Zero-based instance index within `scope` (the `data-tot-i` axis). When
424
+ * given, it supplies the ordinal instead of the collision counter — a
425
+ * caller-known index is the more stable of the two.
426
+ */
427
+ index?: number | undefined;
428
+ }
429
+
430
+ /**
431
+ * How much author intent the id carries. Descending stability, and the signal
432
+ * downstream units gate on.
433
+ */
434
+ export type DerivationConfidence =
435
+ /** The element already declared `data-tot-el`. Returned verbatim; derive nothing. */
436
+ | "declared"
437
+ /** From a name the author chose: `id`, `data-section`, `data-ev`, `name`. */
438
+ | "authored"
439
+ /** From structure the author shaped: a link destination, a role class. */
440
+ | "structural"
441
+ /** Needed the element's position — a tag-only role, or an ordinal. Review it. */
442
+ | "positional";
443
+
444
+ /** One derived id, and everything a reviewer or a validator needs about it. */
445
+ export interface DerivedTrackingId {
446
+ /** The proposed `data-tot-el` value. Always `[a-z0-9-]`, never empty. */
447
+ el: string;
448
+ /** The id before any ordinal was appended. */
449
+ base: string;
450
+ confidence: DerivationConfidence;
451
+ /**
452
+ * What produced it, most significant first — e.g.
453
+ * `["attr:data-ev", "region:section#pricing", "ordinal:2"]`. For a review
454
+ * diff and for a validator message; not machine-contracted beyond being
455
+ * stable for a given input.
456
+ */
457
+ signals: readonly string[];
458
+ /**
459
+ * True iff the id leans on position (`confidence: "positional"`). A human
460
+ * should name this element explicitly instead of accepting the proposal.
461
+ */
462
+ needsReview: boolean;
463
+ }
464
+
465
+ export interface DerivationOptions {
466
+ /** Extra author-name `data-` attributes, appended to {@link DEFAULT_NAMING_ATTRIBUTES}. */
467
+ namingAttributes?: readonly string[] | undefined;
468
+ /** Extra layout/state class names, appended to the built-in stoplist. */
469
+ utilityClasses?: readonly string[] | undefined;
470
+ /** Extra or overriding tag→role words. */
471
+ tagRoles?: Readonly<Record<string, string>> | undefined;
472
+ /**
473
+ * Extra or overriding landmark-tag→region names — e.g. `{ footer: "footer" }`
474
+ * for a tenant whose hand-authored ids spell the footer out in full.
475
+ */
476
+ landmarkRegions?: Readonly<Record<string, string>> | undefined;
477
+ /** Default {@link TRACKING_ID_MAX_LENGTH}. */
478
+ maxLength?: number | undefined;
479
+ /** Default {@link TRACKING_ID_MAX_TOKENS}. */
480
+ maxTokens?: number | undefined;
481
+ /**
482
+ * Whether a link's destination PATH may seed the role token. Default true —
483
+ * it is how the hand-authored ids read (`nav-pricing` for `/pricing`).
484
+ * Only the last path segment or a same-document `#fragment` is ever used;
485
+ * query strings, hashes-with-queries, credentials and opaque tokens are
486
+ * discarded before anything is considered. Set false for a tenant whose URLs
487
+ * are not stable names.
488
+ */
489
+ useHrefPath?: boolean | undefined;
490
+ }
491
+
492
+ // ---------------------------------------------------------------------------
493
+ // Slug plumbing
494
+ // ---------------------------------------------------------------------------
495
+
496
+ /**
497
+ * An authored name reduced to id tokens. Underscores, camelCase and separators
498
+ * all collapse to `-`, so `data-ev="request_access_hero"` and
499
+ * `data-section="requestAccessHero"` derive the same tokens.
500
+ */
501
+ function tokenize(raw: string | undefined): string[] {
502
+ if (typeof raw !== "string") return [];
503
+ const trimmed = raw.trim();
504
+ if (!trimmed || trimmed.length > MAX_TOKEN_SOURCE_LENGTH) return [];
505
+ return trimmed
506
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
507
+ .toLowerCase()
508
+ .split(/[^a-z0-9]+/)
509
+ .filter((t) => t.length > 0);
510
+ }
511
+
512
+ /** Join tokens into an id, dropping duplicates and trimming region tokens first. */
513
+ function assemble(regionTokens: readonly string[], roleTokens: readonly string[], maxTokens: number, maxLength: number): string {
514
+ // Dedupe globally, keeping first occurrence: an author who already wrote the
515
+ // region into the element's own name ("request_access_hero" inside the hero)
516
+ // must not get it twice.
517
+ const seen = new Set<string>();
518
+ const region: string[] = [];
519
+ for (const t of regionTokens) {
520
+ if (!seen.has(t)) {
521
+ seen.add(t);
522
+ region.push(t);
523
+ }
524
+ }
525
+ const role: string[] = [];
526
+ for (const t of roleTokens) {
527
+ if (!seen.has(t)) {
528
+ seen.add(t);
529
+ role.push(t);
530
+ }
531
+ }
532
+ // Over budget: the role is the specific half, so trim region tokens from the
533
+ // OUTSIDE in. Never trim the role.
534
+ let tokens = [...region, ...role];
535
+ while (tokens.length > maxTokens && region.length > 0) {
536
+ region.shift();
537
+ tokens = [...region, ...role];
538
+ }
539
+ let id = tokens.join("-");
540
+ while (id.length > maxLength && region.length > 0) {
541
+ region.shift();
542
+ id = [...region, ...role].join("-");
543
+ }
544
+ if (id.length > maxLength) {
545
+ // Role alone is still too long: drop whole trailing role tokens rather
546
+ // than cutting a word in half, which would read as a typo.
547
+ const kept = [...role];
548
+ while (kept.length > 1 && kept.join("-").length > maxLength) kept.pop();
549
+ id = kept.join("-").slice(0, maxLength);
550
+ }
551
+ return id.replace(/^-+|-+$/g, "");
552
+ }
553
+
554
+ // ---------------------------------------------------------------------------
555
+ // Attribute reads — every one goes through here
556
+ // ---------------------------------------------------------------------------
557
+
558
+ /**
559
+ * The ONLY attribute read in this module. Refuses anything outside the closed
560
+ * allowlist, so a new call site cannot widen the guardrail by accident.
561
+ */
562
+ function readAttr(node: StructuralNode, name: string, naming: readonly string[]): string | undefined {
563
+ if (!READABLE_ATTRIBUTES.includes(name) && !naming.includes(name) && name !== TRACKING_ATTR_EL) {
564
+ return undefined;
565
+ }
566
+ const bag = node.attrs;
567
+ if (!bag) return undefined;
568
+ const raw = bag[name];
569
+ return typeof raw === "string" ? raw : undefined;
570
+ }
571
+
572
+ function tagOf(node: StructuralNode): string {
573
+ return typeof node.tag === "string" ? node.tag.trim().toLowerCase() : "";
574
+ }
575
+
576
+ // ---------------------------------------------------------------------------
577
+ // Class-name filtering
578
+ // ---------------------------------------------------------------------------
579
+
580
+ const TAILWINDISH =
581
+ /^-?[a-z]{1,5}-(?:\d+(?:\.\d+)?|px|full|auto|screen|min|max|fit|none|dvh|svh|xs|sm|md|lg|xl|\d?xl)$/;
582
+
583
+ function isUtilityClass(cls: string, stoplist: readonly string[]): boolean {
584
+ if (cls.length < 3 || cls.length > 32) return true;
585
+ // Variant / arbitrary-value / opacity syntax — never a role name.
586
+ if (/[:[\]/()!]/.test(cls)) return true;
587
+ if (stoplist.includes(cls)) return true;
588
+ if (TAILWINDISH.test(cls)) return true;
589
+ // Generated / hashed module class (`css-1x2y3z`, `_hero_1a2b3c`): a segment
590
+ // that leads with a digit, or interleaves digits and letters, is a build
591
+ // artifact and not a name a human wrote.
592
+ if (cls.startsWith("_")) return true;
593
+ for (const segment of cls.split(/[-_]/)) {
594
+ if (/^\d/.test(segment) || /\d[a-z]/.test(segment)) return true;
595
+ }
596
+ return false;
597
+ }
598
+
599
+ /**
600
+ * The role-bearing class on an element, or undefined. Authors write role-first
601
+ * (`class="btn btn-grad"`, `class="cmp-wrap blk"`), so the first surviving
602
+ * class wins; a layout suffix is then stripped off it.
603
+ */
604
+ function roleClass(node: StructuralNode, naming: readonly string[], stoplist: readonly string[]): string | undefined {
605
+ const raw = readAttr(node, "class", naming);
606
+ if (!raw) return undefined;
607
+ for (const cls of raw.trim().toLowerCase().split(/\s+/)) {
608
+ if (!cls || isUtilityClass(cls, stoplist)) continue;
609
+ let name = cls;
610
+ for (const suffix of LAYOUT_CLASS_SUFFIXES) {
611
+ if (name.length > suffix.length && name.endsWith(suffix)) {
612
+ name = name.slice(0, -suffix.length);
613
+ break;
614
+ }
615
+ }
616
+ if (isUtilityClass(name, stoplist)) continue;
617
+ return name;
618
+ }
619
+ return undefined;
620
+ }
621
+
622
+ // ---------------------------------------------------------------------------
623
+ // Link destinations
624
+ // ---------------------------------------------------------------------------
625
+
626
+ /**
627
+ * The stable name inside a link destination, or undefined.
628
+ *
629
+ * Query strings, fragments-after-a-path, credentials and hosts are discarded
630
+ * FIRST and never considered, so a URL that carries session state, a token or a
631
+ * redirect target contributes nothing. What survives is a single path segment
632
+ * or a same-document `#fragment`, and only when it reads like a name a human
633
+ * chose: no all-digit ids, no hex blobs, no template placeholders.
634
+ */
635
+ function hrefToken(raw: string | undefined): string | undefined {
636
+ if (!raw) return undefined;
637
+ const href = raw.trim();
638
+ if (!href) return undefined;
639
+ const lower = href.toLowerCase();
640
+ for (const scheme of NON_PAGE_SCHEMES) {
641
+ if (lower.startsWith(scheme)) return undefined;
642
+ }
643
+ let candidate: string;
644
+ if (href.startsWith("#")) {
645
+ // Same-document target: pure structure, the author named an anchor.
646
+ candidate = href.slice(1).split(/[?&]/)[0] ?? "";
647
+ } else {
648
+ // Everything from the first `?` or `#` onward is state, not structure.
649
+ let path = href.split(/[?#]/)[0] ?? "";
650
+ // A scheme and authority name a HOST, never an element. Drop both before
651
+ // anything is considered, so credentials and hostnames cannot leak in.
652
+ path = path.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i, "").replace(/^\/\/[^/]*/, "");
653
+ const segments = path.split("/").filter((s) => s.length > 0);
654
+ candidate = segments[segments.length - 1] ?? "";
655
+ }
656
+ candidate = candidate.replace(/\.(?:html?|php|aspx?|jsp)$/i, "");
657
+ if (!candidate || candidate.length > MAX_TOKEN_SOURCE_LENGTH) return undefined;
658
+ if (!/^[A-Za-z][A-Za-z0-9._-]*$/.test(candidate)) return undefined;
659
+ // An opaque id, not a name: a hex blob or a uuid-shaped token.
660
+ if (/^[0-9a-f-]{16,}$/i.test(candidate)) return undefined;
661
+ const tokens = tokenize(candidate);
662
+ if (tokens.length === 0) return undefined;
663
+ if (tokens.every((t) => /^\d+$/.test(t))) return undefined;
664
+ return tokens.join("-");
665
+ }
666
+
667
+ // ---------------------------------------------------------------------------
668
+ // Region path
669
+ // ---------------------------------------------------------------------------
670
+
671
+ interface RegionToken {
672
+ tokens: string[];
673
+ signal: string;
674
+ /** True for a page-level landmark: nothing above it adds information. */
675
+ terminal: boolean;
676
+ }
677
+
678
+ /** The region name one ancestor contributes, or undefined if it names nothing. */
679
+ function regionOf(
680
+ node: StructuralNode,
681
+ naming: readonly string[],
682
+ stoplist: readonly string[],
683
+ landmarks: Readonly<Record<string, string>>,
684
+ ): RegionToken | undefined {
685
+ const tag = tagOf(node);
686
+ const terminal = DOCUMENT_LANDMARKS.includes(tag);
687
+ // An author-chosen block name beats the tag every time.
688
+ for (const attr of naming) {
689
+ const tokens = tokenize(readAttr(node, attr, naming));
690
+ if (tokens.length > 0) return { tokens, signal: `region:${tag}[${attr}]`, terminal };
691
+ }
692
+ const own = tokenize(readAttr(node, "id", naming));
693
+ if (own.length > 0) return { tokens: own, signal: `region:${tag}#${own.join("-")}`, terminal };
694
+ const landmark = landmarks[tag];
695
+ // A landmark's class is a styling handle, not a region name — skip it.
696
+ if (landmark && terminal) return { tokens: [landmark], signal: `region:<${tag}>`, terminal };
697
+ const cls = roleClass(node, naming, stoplist);
698
+ if (cls) return { tokens: tokenize(cls), signal: `region:${tag}.${cls}`, terminal };
699
+ if (landmark) return { tokens: [landmark], signal: `region:<${tag}>`, terminal };
700
+ if (GENERIC_CONTAINER_TAGS.includes(tag)) return undefined;
701
+ const role = readAttr(node, "role", naming);
702
+ const roleTokens = tokenize(role);
703
+ if (roleTokens.length > 0) return { tokens: roleTokens, signal: `region:${tag}[role]`, terminal };
704
+ return undefined;
705
+ }
706
+
707
+ // ---------------------------------------------------------------------------
708
+ // The element's own role token
709
+ // ---------------------------------------------------------------------------
710
+
711
+ interface RoleToken {
712
+ tokens: string[];
713
+ confidence: Exclude<DerivationConfidence, "declared">;
714
+ signal: string;
715
+ }
716
+
717
+ function roleOf(
718
+ node: StructuralNode,
719
+ naming: readonly string[],
720
+ stoplist: readonly string[],
721
+ tagRoles: Readonly<Record<string, string>>,
722
+ useHrefPath: boolean,
723
+ ): RoleToken {
724
+ const tag = tagOf(node);
725
+
726
+ // 1. An author-chosen interaction / block name. The strongest signal there
727
+ // is, and the one that already encodes placement in real fragments
728
+ // (`data-ev="request_access_hero"` vs `..._header"`).
729
+ for (const attr of naming) {
730
+ const tokens = tokenize(readAttr(node, attr, naming));
731
+ if (tokens.length > 0) return { tokens, confidence: "authored", signal: `attr:${attr}` };
732
+ }
733
+
734
+ // 2. An authored element id.
735
+ const own = tokenize(readAttr(node, "id", naming));
736
+ if (own.length > 0) return { tokens: own, confidence: "authored", signal: "attr:id" };
737
+
738
+ // 3. A form control's submitted name — authored, and the field's real identity.
739
+ const name = tokenize(readAttr(node, "name", naming));
740
+ if (name.length > 0) return { tokens: name, confidence: "authored", signal: "attr:name" };
741
+
742
+ // 4. Where the link goes. Structure the author shaped, and how the
743
+ // hand-authored chrome ids read.
744
+ if (useHrefPath) {
745
+ const dest = hrefToken(readAttr(node, "href", naming));
746
+ if (dest) return { tokens: tokenize(dest), confidence: "structural", signal: "attr:href" };
747
+ }
748
+
749
+ // 5. A role-bearing class.
750
+ const cls = roleClass(node, naming, stoplist);
751
+ if (cls) return { tokens: tokenize(cls), confidence: "structural", signal: `attr:class(${cls})` };
752
+
753
+ // 6. A submit/reset button's kind, or an explicit ARIA role. Structural, and
754
+ // more specific than the bare tag.
755
+ const type = tokenize(readAttr(node, "type", naming));
756
+ if ((tag === "button" || tag === "input") && type.length > 0 && type[0] !== "text") {
757
+ return { tokens: type, confidence: "structural", signal: "attr:type" };
758
+ }
759
+ const role = tokenize(readAttr(node, "role", naming));
760
+ if (role.length > 0) return { tokens: role, confidence: "structural", signal: "attr:role" };
761
+
762
+ // 7. Nothing authored anywhere. The tag's generic role word — which
763
+ // discriminates only by position, hence `positional`.
764
+ const word = tagRoles[tag] ?? (tag ? tokenize(tag).join("-") : "el");
765
+ return { tokens: tokenize(word), confidence: "positional", signal: `tag:<${tag || "unknown"}>` };
766
+ }
767
+
768
+ // ---------------------------------------------------------------------------
769
+ // The single-element engine
770
+ // ---------------------------------------------------------------------------
771
+
772
+ function resolveOptions(options: DerivationOptions | undefined): {
773
+ naming: readonly string[];
774
+ stoplist: readonly string[];
775
+ tagRoles: Readonly<Record<string, string>>;
776
+ landmarks: Readonly<Record<string, string>>;
777
+ maxLength: number;
778
+ maxTokens: number;
779
+ useHrefPath: boolean;
780
+ } {
781
+ const extraNaming = (options?.namingAttributes ?? []).filter(
782
+ // A caller cannot widen the guardrail: only `data-` names are accepted at
783
+ // all, a text-bearing attribute is refused even when explicitly asked for,
784
+ // and the contract's own `data-tot-*` namespace is refused by prefix.
785
+ (a) =>
786
+ typeof a === "string" &&
787
+ a.startsWith("data-") &&
788
+ !a.startsWith("data-tot-") &&
789
+ !TEXT_BEARING_ATTRIBUTES.includes(a),
790
+ );
791
+ return {
792
+ naming: [...DEFAULT_NAMING_ATTRIBUTES, ...extraNaming],
793
+ stoplist: [...DEFAULT_UTILITY_CLASSES, ...(options?.utilityClasses ?? [])],
794
+ tagRoles: { ...DEFAULT_TAG_ROLES, ...(options?.tagRoles ?? {}) },
795
+ landmarks: { ...DEFAULT_LANDMARK_REGIONS, ...(options?.landmarkRegions ?? {}) },
796
+ maxLength:
797
+ typeof options?.maxLength === "number" && options.maxLength > 0
798
+ ? Math.floor(options.maxLength)
799
+ : TRACKING_ID_MAX_LENGTH,
800
+ maxTokens:
801
+ typeof options?.maxTokens === "number" && options.maxTokens > 0
802
+ ? Math.floor(options.maxTokens)
803
+ : TRACKING_ID_MAX_TOKENS,
804
+ useHrefPath: options?.useHrefPath !== false,
805
+ };
806
+ }
807
+
808
+ /**
809
+ * Derive the BASE id for one element — no ordinal, no collision handling.
810
+ *
811
+ * Pure and self-contained: the same {@link DerivationTarget} always yields the
812
+ * same result, independent of anything else in the document. Use
813
+ * {@link createTrackingIdDeriver} or {@link deriveTrackingIds} when ids must be
814
+ * unique across a fragment; this function is the right one when re-deriving a
815
+ * single element to check it has not drifted.
816
+ *
817
+ * An element already carrying `data-tot-el` is returned verbatim with
818
+ * `confidence: "declared"` — a declared identity is permanent and this engine
819
+ * never proposes a replacement.
820
+ */
821
+ export function deriveTrackingId(target: DerivationTarget, options?: DerivationOptions): DerivedTrackingId {
822
+ const opts = resolveOptions(options);
823
+ const node = target.node;
824
+
825
+ const declared = readAttr(node, TRACKING_ATTR_EL, opts.naming);
826
+ if (typeof declared === "string" && declared.trim().length > 0) {
827
+ const el = declared.trim();
828
+ return { el, base: el, confidence: "declared", signals: [`declared:${TRACKING_ATTR_EL}`], needsReview: false };
829
+ }
830
+
831
+ const role = roleOf(node, opts.naming, opts.stoplist, opts.tagRoles, opts.useHrefPath);
832
+
833
+ // Region path, NEAREST first while we pick, emitted outermost-first.
834
+ const ancestors = target.ancestors ?? [];
835
+ const nearestFirst: RegionToken[] = [];
836
+ for (let i = ancestors.length - 1; i >= 0; i -= 1) {
837
+ const a = ancestors[i];
838
+ if (!a) continue;
839
+ const region = regionOf(a, opts.naming, opts.stoplist, opts.landmarks);
840
+ if (region) {
841
+ nearestFirst.push(region);
842
+ // A page-level landmark ends the walk: `header` above `nav` would only
843
+ // lengthen the id without telling two elements apart.
844
+ if (region.terminal) break;
845
+ }
846
+ }
847
+ if (nearestFirst.length === 0 && target.scope) {
848
+ const tokens = tokenize(target.scope);
849
+ if (tokens.length > 0) nearestFirst.push({ tokens, signal: "scope", terminal: false });
850
+ }
851
+
852
+ // Adaptive budget. A role name the author already PLACED — `request_access_hero`
853
+ // inside the hero, `nav_why` inside the nav — needs no region at all; re-adding
854
+ // it is the noise that makes derived ids read like machine output. Otherwise a
855
+ // name-shaped role takes one region token for placement (`nav` + `pricing`) and
856
+ // a generic one takes two to say anything (`pricing` + `table` + `row`).
857
+ const placedTokens = new Set(nearestFirst.flatMap((r) => r.tokens));
858
+ const alreadyPlaced = role.tokens.some((t) => placedTokens.has(t));
859
+ const budget = alreadyPlaced ? 0 : role.tokens.length >= 2 ? 1 : 2;
860
+ const picked = nearestFirst.slice(0, budget);
861
+ const regionTokens = [...picked].reverse().flatMap((r) => r.tokens);
862
+
863
+ const base = assemble(regionTokens, role.tokens, opts.maxTokens, opts.maxLength);
864
+ const signals = [role.signal, ...[...picked].reverse().map((r) => r.signal)];
865
+
866
+ // Degenerate input (empty tag, no attributes at all): still return something
867
+ // valid and obviously-review-worthy rather than an empty attribute value.
868
+ const el = base || "el";
869
+ return {
870
+ el,
871
+ base: el,
872
+ confidence: base ? role.confidence : "positional",
873
+ signals: base ? signals : [...signals, "fallback:empty"],
874
+ needsReview: (base ? role.confidence : "positional") === "positional",
875
+ };
876
+ }
877
+
878
+ // ---------------------------------------------------------------------------
879
+ // Fragment-wide uniqueness
880
+ // ---------------------------------------------------------------------------
881
+
882
+ /** A stateful deriver that keeps ids unique across one fragment. */
883
+ export interface TrackingIdDeriver {
884
+ /**
885
+ * Derive an id, appending the smallest ordinal needed to keep it unique
886
+ * within this deriver. Call in DOCUMENT ORDER — that is what makes the
887
+ * ordinals deterministic, and it is the order both a git-time rewriter and a
888
+ * streaming rewriter already have.
889
+ */
890
+ derive(target: DerivationTarget): DerivedTrackingId;
891
+ /**
892
+ * Claim an id without deriving one — for ids already present in the source,
893
+ * so a proposal never collides with a declared identity. Seed these BEFORE
894
+ * deriving.
895
+ */
896
+ reserve(el: string): void;
897
+ /** Every id this deriver has issued or reserved, in claim order. */
898
+ claimed(): readonly string[];
899
+ }
900
+
901
+ /**
902
+ * A deriver scoped to one fragment.
903
+ *
904
+ * Ordinals are counted PER BASE ID, not per document, so adding an element that
905
+ * derives a different base leaves every other id untouched. Only an element
906
+ * inserted ahead of a same-base sibling renumbers anything — which is why any
907
+ * id that took an ordinal comes back `needsReview: true`.
908
+ */
909
+ export function createTrackingIdDeriver(options?: DerivationOptions): TrackingIdDeriver {
910
+ const counts = new Map<string, number>();
911
+ const claimedIds: string[] = [];
912
+ const taken = new Set<string>();
913
+
914
+ const claim = (el: string): void => {
915
+ if (!taken.has(el)) {
916
+ taken.add(el);
917
+ claimedIds.push(el);
918
+ }
919
+ };
920
+
921
+ return {
922
+ reserve(el: string): void {
923
+ if (typeof el === "string" && el.trim()) claim(el.trim());
924
+ },
925
+ claimed(): readonly string[] {
926
+ return claimedIds;
927
+ },
928
+ derive(target: DerivationTarget): DerivedTrackingId {
929
+ const derived = deriveTrackingId(target, options);
930
+ if (derived.confidence === "declared") {
931
+ claim(derived.el);
932
+ return derived;
933
+ }
934
+ const base = derived.base;
935
+ // A caller-known repeat index is the more stable ordinal — prefer it.
936
+ const explicit =
937
+ typeof target.index === "number" && Number.isInteger(target.index) && target.index > 0
938
+ ? target.index + 1
939
+ : undefined;
940
+ let ordinal = explicit ?? (counts.get(base) ?? 0) + 1;
941
+ let el = ordinal === 1 ? base : `${base}-${ordinal}`;
942
+ while (taken.has(el)) {
943
+ ordinal += 1;
944
+ el = `${base}-${ordinal}`;
945
+ }
946
+ counts.set(base, Math.max(counts.get(base) ?? 0, ordinal));
947
+ claim(el);
948
+ const ordinaled = el !== base;
949
+ return {
950
+ el,
951
+ base,
952
+ confidence: ordinaled ? "positional" : derived.confidence,
953
+ signals: ordinaled ? [...derived.signals, `ordinal:${ordinal}`] : derived.signals,
954
+ needsReview: ordinaled || derived.needsReview,
955
+ };
956
+ },
957
+ };
958
+ }
959
+
960
+ /**
961
+ * Derive unique ids for a whole fragment in one call — {@link
962
+ * createTrackingIdDeriver} over an array already in document order. `reserved`
963
+ * seeds ids that exist in the source and must not be re-proposed.
964
+ */
965
+ export function deriveTrackingIds(
966
+ targets: readonly DerivationTarget[],
967
+ options?: DerivationOptions & { reserved?: readonly string[] | undefined },
968
+ ): DerivedTrackingId[] {
969
+ const deriver = createTrackingIdDeriver(options);
970
+ for (const el of options?.reserved ?? []) deriver.reserve(el);
971
+ return targets.map((t) => deriver.derive(t));
972
+ }
973
+
974
+ // ---------------------------------------------------------------------------
975
+ // Candidate selection — shared by every consumer
976
+ // ---------------------------------------------------------------------------
977
+
978
+ /**
979
+ * Whether an element is one an interaction can originate from, and therefore a
980
+ * derivation candidate. Structural test only: the tag, an interactive ARIA
981
+ * role, or an `href`. An `<a>` with no `href` is not actionable and is skipped.
982
+ */
983
+ export function isTrackableNode(node: StructuralNode): boolean {
984
+ const tag = tagOf(node);
985
+ if (tag === "a" || tag === "area") {
986
+ const href = node.attrs?.["href"];
987
+ return typeof href === "string" && href.trim().length > 0;
988
+ }
989
+ if (TRACKABLE_TAGS.includes(tag)) return true;
990
+ const role = node.attrs?.["role"];
991
+ if (typeof role !== "string") return false;
992
+ const r = role.trim().toLowerCase();
993
+ return r === "button" || r === "link" || r === "tab" || r === "menuitem" || r === "switch" || r === "checkbox";
994
+ }
995
+
996
+ /**
997
+ * Whether `el` is shaped like a derived / hand-authored identity: lowercase
998
+ * kebab tokens, within the length budget. The validator unit's cheap check on
999
+ * an authored value, and a post-condition on everything this module returns.
1000
+ */
1001
+ export function isWellFormedTrackingId(el: string, maxLength: number = TRACKING_ID_MAX_LENGTH): boolean {
1002
+ return (
1003
+ typeof el === "string" &&
1004
+ el.length > 0 &&
1005
+ el.length <= maxLength &&
1006
+ /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(el)
1007
+ );
1008
+ }