@tokenoftrust/storefront-runner 2.0.3 → 2.0.5
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/.astro/content.d.ts +159 -0
- package/apps/storefront/.astro/integrations/_astrojs_cloudflare/cloudflare.d.ts +1 -0
- package/apps/storefront/.astro/types.d.ts +3 -0
- package/apps/storefront/migrations-apps/0011_dt_tracking_tenant_databases.sql +23 -0
- package/apps/storefront/node_modules/.bin/acorn +43 -0
- package/apps/storefront/node_modules/.bin/astro +43 -0
- package/apps/storefront/node_modules/.bin/astro-check +43 -0
- package/apps/storefront/node_modules/.bin/browserslist +43 -0
- package/apps/storefront/node_modules/.bin/cf-wrangler +43 -0
- package/apps/storefront/node_modules/.bin/depcruise +43 -0
- package/apps/storefront/node_modules/.bin/depcruise-baseline +43 -0
- package/apps/storefront/node_modules/.bin/depcruise-fmt +43 -0
- package/apps/storefront/node_modules/.bin/depcruise-wrap-stream-in-html +43 -0
- package/apps/storefront/node_modules/.bin/dependency-cruise +43 -0
- package/apps/storefront/node_modules/.bin/dependency-cruiser +43 -0
- package/apps/storefront/node_modules/.bin/drizzle-kit +43 -0
- package/apps/storefront/node_modules/.bin/esbuild +43 -0
- package/apps/storefront/node_modules/.bin/jiti +43 -0
- package/apps/storefront/node_modules/.bin/playwright +43 -0
- package/apps/storefront/node_modules/.bin/playwright-core +43 -0
- package/apps/storefront/node_modules/.bin/prettier +43 -0
- package/apps/storefront/node_modules/.bin/rollup +43 -0
- package/apps/storefront/node_modules/.bin/tsc +43 -0
- package/apps/storefront/node_modules/.bin/tsserver +43 -0
- package/apps/storefront/node_modules/.bin/tsx +43 -0
- package/apps/storefront/node_modules/.bin/vite +43 -0
- package/apps/storefront/node_modules/.bin/vitest +43 -0
- package/apps/storefront/node_modules/.bin/workerd +43 -0
- package/apps/storefront/node_modules/.bin/wrangler +43 -0
- package/apps/storefront/node_modules/.bin/wrangler2 +43 -0
- package/apps/storefront/node_modules/.bin/yaml +43 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js +58 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js +741 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js +2549 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/_metadata.json +150 -0
- package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js +434 -0
- package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js +2705 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
- package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js +1535 -0
- package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js +48 -0
- package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js +42 -0
- package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/package.json +3 -0
- package/apps/storefront/node_modules/.vite/deps/preact.js +310 -0
- package/apps/storefront/node_modules/.vite/deps/preact.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_compat.js +420 -0
- package/apps/storefront/node_modules/.vite/deps/preact_compat.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_compat_jsx-runtime.js +4 -0
- package/apps/storefront/node_modules/.vite/deps/preact_debug.js +236 -0
- package/apps/storefront/node_modules/.vite/deps/preact_debug.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_devtools.js +12 -0
- package/apps/storefront/node_modules/.vite/deps/preact_devtools.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_hooks.js +192 -0
- package/apps/storefront/node_modules/.vite/deps/preact_hooks.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_jsx-dev-runtime.js +3 -0
- package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js +92 -0
- package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js +59 -0
- package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/rolldown-runtime-BPOCksWG.js +24 -0
- package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js +189 -0
- package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js +13 -0
- package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js +458 -0
- package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js +1016 -0
- package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js +32 -0
- package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js +113 -0
- package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +8 -0
- package/apps/storefront/node_modules/.vite/deps_ssr/package.json +3 -0
- 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 +65 -7
- package/apps/storefront/src/lib/apps/registryAccess.ts +45 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +4 -4
- 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/capability.ts +68 -0
- package/apps/storefront/src/lib/shared/developerInvites.ts +1250 -0
- package/apps/storefront/src/lib/shared/invitePreflight.ts +123 -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/lib/webhooks/deliveryEngine.ts +38 -4
- package/apps/storefront/src/lib/webhooks/emit.ts +1 -1
- package/apps/storefront/src/lib/webhooks/getDispatcher.ts +1 -1
- package/apps/storefront/src/pages/api/admin/interaction-insights.ts +101 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -1
- package/apps/storefront/src/pages/api/tracking/interaction.ts +123 -0
- package/apps/storefront/src/pages/index.astro +1 -1
- package/apps/storefront/src/pages/products/[handle].astro +1 -1
- 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/tsc +43 -0
- package/packages/public-runtime/node_modules/.bin/tsserver +43 -0
- package/packages/public-runtime/node_modules/.bin/vitest +43 -0
- 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/pnpm-workspace.yaml +3 -0
- package/scripts/tenant/validate.mjs +306 -1
- package/scripts/tot-dev.mjs +59 -3
- package/tenants/home/public/js/storefront.js +6 -87
- package/apps/storefront/src/lib/apps/adminService.ts +0 -116
- package/apps/storefront/src/lib/apps/adminSession.ts +0 -214
- package/apps/storefront/src/lib/apps/apiAuth.ts +0 -91
- package/apps/storefront/src/lib/apps/apiRoute.ts +0 -35
- package/apps/storefront/src/lib/apps/catalogMapper.ts +0 -39
- package/apps/storefront/src/lib/apps/consentConfigStore.ts +0 -228
- package/apps/storefront/src/lib/apps/gtmMigrationInstall.ts +0 -447
- package/apps/storefront/src/lib/apps/gtmMigrationReport.ts +0 -367
- package/apps/storefront/src/lib/apps/gtmMigrationTab.ts +0 -315
- package/apps/storefront/src/lib/apps/healthAggregate.ts +0 -66
- package/apps/storefront/src/lib/apps/integrationLifecycle.ts +0 -614
- package/apps/storefront/src/lib/apps/integrationLifecycleController.ts +0 -125
- package/apps/storefront/src/lib/apps/integrationReadModel.ts +0 -563
- package/apps/storefront/src/lib/apps/integrationsTab.ts +0 -547
- package/apps/storefront/src/lib/apps/integrationsView.ts +0 -106
- package/apps/storefront/src/lib/apps/managedAnalyticsService.ts +0 -599
- package/apps/storefront/src/lib/apps/tenantIntegrationMaterialization.ts +0 -170
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +0 -121
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +0 -148
- package/apps/storefront/src/lib/apps/v84CleanCloneReplayContext.ts +0 -16
- package/apps/storefront/src/lib/apps/v84CloneLifecycleCacheEvidence.ts +0 -105
- package/apps/storefront/src/lib/apps/v84CloneLifecycleEvidence.ts +0 -177
- package/apps/storefront/src/lib/apps/v84CloneLifecycleMaterialization.ts +0 -251
- package/apps/storefront/src/lib/apps/v84CloneLifecycleSemantics.ts +0 -241
- package/apps/storefront/src/lib/apps/v84CloneMigrationEvidence.ts +0 -211
- package/apps/storefront/src/lib/apps/v84CloneNativeInstallEvidence.ts +0 -409
- package/apps/storefront/src/lib/apps/v84NonportableCodeCoaching.ts +0 -368
- package/apps/storefront/src/lib/apps/v84NonportableRouteContract.ts +0 -188
- package/apps/storefront/src/lib/apps/v84NonportableRouteDogfood.ts +0 -291
- package/apps/storefront/src/lib/apps/ws1ManagedAnalyticsClient.ts +0 -95
- package/apps/storefront/src/lib/content-edit/applyDiff.ts +0 -91
- package/apps/storefront/src/lib/content-edit/client.ts +0 -286
- package/apps/storefront/src/lib/content-edit/index.ts +0 -14
- package/apps/storefront/src/lib/content-edit/route.ts +0 -25
- package/apps/storefront/src/lib/content-edit/types.ts +0 -19
- package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +0 -164
|
@@ -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
|
+
}
|