@luigisbox/luma 0.0.0-stage → 0.2.0

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 (145) hide show
  1. package/LICENSE.md +90 -0
  2. package/README.md +615 -2
  3. package/a11y/audit.mjs +735 -0
  4. package/a11y/axe.mjs +73 -0
  5. package/a11y/report.mjs +347 -0
  6. package/a11y/run.mjs +256 -0
  7. package/a11y/wcag.mjs +233 -0
  8. package/bin/config.mjs +60 -0
  9. package/bin/luma.mjs +99 -0
  10. package/bin/paths.mjs +11 -0
  11. package/bin/tools.mjs +43 -0
  12. package/build/baseline.d.mts +8 -0
  13. package/build/baseline.mjs +167 -0
  14. package/build/tooling.mjs +102 -0
  15. package/build/vite.mjs +507 -0
  16. package/chrome/block.mjs +8 -0
  17. package/chrome/run.mjs +150 -0
  18. package/docs/ACCESSIBILITY.md +501 -0
  19. package/docs/INTEGRATION-PERFORMANCE.md +213 -0
  20. package/docs/PARITY.md +946 -0
  21. package/footprint/model.mjs +184 -0
  22. package/footprint/run.mjs +331 -0
  23. package/package.json +58 -4
  24. package/parity/alignment.mjs +109 -0
  25. package/parity/archive.mjs +223 -0
  26. package/parity/args.mjs +137 -0
  27. package/parity/behaviour.mjs +379 -0
  28. package/parity/compare.mjs +985 -0
  29. package/parity/engine.mjs +848 -0
  30. package/parity/hints.mjs +132 -0
  31. package/parity/hold.mjs +54 -0
  32. package/parity/init.mjs +703 -0
  33. package/parity/map.mjs +640 -0
  34. package/parity/owner.mjs +170 -0
  35. package/parity/pairing.mjs +110 -0
  36. package/parity/pixels.mjs +33 -0
  37. package/parity/probe.mjs +1239 -0
  38. package/parity/report.mjs +236 -0
  39. package/parity/run.mjs +267 -0
  40. package/snippet/early-hold.js +64 -0
  41. package/snippet/early-hold.min.js +1 -0
  42. package/src/analytics/chain.ts +137 -0
  43. package/src/analytics/collect.ts +64 -0
  44. package/src/analytics/datalayer.ts +67 -0
  45. package/src/analytics/from-ga4.ts +68 -0
  46. package/src/analytics/from-luma.ts +21 -0
  47. package/src/analytics/index.ts +24 -0
  48. package/src/analytics/outbox.ts +118 -0
  49. package/src/analytics/page.ts +46 -0
  50. package/src/analytics/pageview.ts +82 -0
  51. package/src/analytics/ref.ts +19 -0
  52. package/src/analytics/setup.ts +41 -0
  53. package/src/analytics/start.ts +53 -0
  54. package/src/analytics/triggers.ts +239 -0
  55. package/src/analytics/user.ts +12 -0
  56. package/src/analytics/wire.ts +67 -0
  57. package/src/api/ai.ts +98 -0
  58. package/src/api/index.ts +87 -0
  59. package/src/api/token-key.ts +42 -0
  60. package/src/api/token.ts +312 -0
  61. package/src/core/a11y-keys.ts +60 -0
  62. package/src/core/a11y.ts +160 -0
  63. package/src/core/boot-state.ts +6 -0
  64. package/src/core/color.ts +42 -0
  65. package/src/core/context.ts +108 -0
  66. package/src/core/early-rules.ts +96 -0
  67. package/src/core/entries.ts +25 -0
  68. package/src/core/fold.ts +7 -0
  69. package/src/core/health.ts +26 -0
  70. package/src/core/hold.ts +74 -0
  71. package/src/core/i18n.ts +66 -0
  72. package/src/core/image-load.ts +102 -0
  73. package/src/core/image-tags.ts +26 -0
  74. package/src/core/images.ts +170 -0
  75. package/src/core/integration.ts +126 -0
  76. package/src/core/kept.ts +25 -0
  77. package/src/core/listings.ts +100 -0
  78. package/src/core/locales.ts +14 -0
  79. package/src/core/own.ts +49 -0
  80. package/src/core/page.ts +14 -0
  81. package/src/core/prefetch.ts +88 -0
  82. package/src/core/recommenders.ts +166 -0
  83. package/src/core/reconciler.ts +397 -0
  84. package/src/core/result.ts +73 -0
  85. package/src/core/routes.ts +115 -0
  86. package/src/core/scheduler.ts +83 -0
  87. package/src/core/sense.ts +107 -0
  88. package/src/core/speculation.ts +59 -0
  89. package/src/core/stash.ts +38 -0
  90. package/src/core/storage.ts +45 -0
  91. package/src/core/styles.ts +106 -0
  92. package/src/core/support.ts +12 -0
  93. package/src/core/takeover.ts +220 -0
  94. package/src/core/templates.ts +74 -0
  95. package/src/core/tiles.ts +137 -0
  96. package/src/core/turbo.ts +64 -0
  97. package/src/core/url.ts +164 -0
  98. package/src/core/widget-inits.ts +40 -0
  99. package/src/env.d.ts +3 -0
  100. package/src/lit.ts +11 -0
  101. package/src/locales/cs.json +94 -0
  102. package/src/locales/de.json +88 -0
  103. package/src/locales/en.json +88 -0
  104. package/src/main.ts +493 -0
  105. package/src/types.ts +778 -0
  106. package/src/widgets/autocomplete/index.ts +1387 -0
  107. package/src/widgets/autocomplete/style-autocomplete.css +266 -0
  108. package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
  109. package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
  110. package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
  111. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
  112. package/src/widgets/listing/element.ts +1772 -0
  113. package/src/widgets/listing/flow.ts +30 -0
  114. package/src/widgets/listing/instant.ts +133 -0
  115. package/src/widgets/listing/intent.ts +193 -0
  116. package/src/widgets/listing/query.ts +298 -0
  117. package/src/widgets/listing/stash.ts +97 -0
  118. package/src/widgets/listing/state.ts +131 -0
  119. package/src/widgets/listing/view.ts +257 -0
  120. package/src/widgets/plp/index.ts +11 -0
  121. package/src/widgets/plp/route.ts +263 -0
  122. package/src/widgets/recommender/index.ts +261 -0
  123. package/src/widgets/recommender/style-recommender.css +26 -0
  124. package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
  125. package/src/widgets/recommender/template-recommender-popup.ts +12 -0
  126. package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
  127. package/src/widgets/search/assets.ts +7 -0
  128. package/src/widgets/search/first.ts +15 -0
  129. package/src/widgets/search/form-url.ts +59 -0
  130. package/src/widgets/search/in-place.ts +226 -0
  131. package/src/widgets/search/index.ts +16 -0
  132. package/src/widgets/search/range.ts +260 -0
  133. package/src/widgets/search/route.ts +32 -0
  134. package/src/widgets/search/speculate.ts +202 -0
  135. package/src/widgets/search/style-search.css +65 -0
  136. package/src/widgets/search/template-search-box.ts +17 -0
  137. package/src/widgets/search/template-search-facets-default.ts +73 -0
  138. package/src/widgets/search/template-search-header.ts +14 -0
  139. package/src/widgets/search/template-search-pagination.ts +12 -0
  140. package/src/widgets/search/template-search-range-default.ts +48 -0
  141. package/src/widgets/search/template-search-result-default.ts +13 -0
  142. package/src/widgets/search/template-search-results-default.ts +8 -0
  143. package/tasks/model.mjs +116 -0
  144. package/tasks/run.mjs +305 -0
  145. package/tsconfig.json +14 -0
package/parity/map.mjs ADDED
@@ -0,0 +1,640 @@
1
+ // The parity map (`integration/parity.json` in the integration repository):
2
+ // which pages to load, which of their elements is which of ours, what to
3
+ // align and what to tolerate. JSON only, committed with the integration, read
4
+ // by `yarn luma parity` and never written by it (`--init` writes a draft next to it).
5
+ //
6
+ // {
7
+ // "host": "www.shop.example", the storefront (LBX_HOST overrides it)
8
+ // "viewports": [390, 1024, 1440], "height": 900,
9
+ // "locale": "en-GB", "timezone": "Europe/London",
10
+ // "tolerance": { "px": 0.5, "deltaE": 2.3, "alpha": 0.02, "pixel": 0.1, "pixels": 0.02 },
11
+ // "thresholds": { "score": 90, "components": { "tile": 95 } },
12
+ // "hide": [".cookie-banner"], both sides, before measuring (never clicked)
13
+ // "block": { "theirs": ["^https://cdn\\.eu1\\.luigisbox\\.ai/"], "ours": [...] },
14
+ // "products": { "attr": "data-product-id", "field": "id", "type": "product", "surface": "lbs_collection_x" },
15
+ // "pair": "field" (default: their id = our hit's "field", fetched whatever the build's return_fields)
16
+ // or "url" (their links = our hits' url/web_url, by path; attr "href", field "web_url" by default)
17
+ // "pages": {
18
+ // "category": { "path": "/shoes", "ids": ".listing .tile",
19
+ // "ready": { "theirs": ".listing .tile", "ours": "lbx-plp .tile" } },
20
+ // "autocomplete": { "path": "/", "type": { "input": "input[name=q]", "text": "shoe" },
21
+ // "ids": { "selector": ".ac-row", "attr": "href", "transform": "absolute", "field": "web_url" } },
22
+ // "search": { "theirs": { "path": "/shoes", "ids": ".listing .tile" }, "ours": { "path": "/search?q=shoe" },
23
+ // "query": "shoe" }
24
+ // a path per side ("path" is both sides' default): our search page against
25
+ // their category listing (their own search disabled); another page of theirs
26
+ // needs "ids" on their side (or "ids": false: by structure only)
27
+ // "query" (or a list): what their products are asked for with, by id (the API's
28
+ // search answers an empty query with nothing on many channels; docs/PARITY.md)
29
+ // "listing": { "path": "/shoes", "storage": { "theirs": { "localStorage": { "viewMode": "grid" } } },
30
+ // "states": [{ "name": "drawer-open", "viewport": 390,
31
+ // "steps": [{ "click": { "theirs": ".filters-toggle", "ours": "lbx-plp .filters-toggle" } },
32
+ // { "wait-for": { "theirs": ".drawer", "ours": "lbx-plp [data-lbx-part~=facets]" } }] }] }
33
+ // "storage": set before every load, per side (a view mode their page reads);
34
+ // "states": scripted steps (click, type, hover, press, wait-for, scroll), each
35
+ // side its own selectors; the page's components measured again in each state
36
+ // },
37
+ // "components": {
38
+ // "tile": { "pages": ["category"], "each": { "key": "data-product-id", "sample": 4 },
39
+ // "theirs": ".listing .tile", "ours": "lbx-plp .tile",
40
+ // "parts": { "title": ".tile__title", "price": { "theirs": ".price", "ours": "[data-lbx-part~=price]" } },
41
+ // "states": ["hover"], "mask": [".tile__image"], "ignore": ["transition"] },
42
+ // "grid": { "pages": ["category"], "theirs": ".listing", "ours": "lbx-plp [data-lbx-list]", "grid": { "item": ".tile" } },
43
+ // "count": { "pages": ["category"], "theirs": ".result-count", "ours": "[data-lbx-part~=count]", "text": "shape" }
44
+ // },
45
+ // "excluded": { "tile.badge": "their tiles show no badge" },
46
+ // mandatory components (MANDATORY) not compared, each with its reason
47
+ // or { "reason": …, "decision": "2026-09-30: …", "by": …, "date": … }: a recorded
48
+ // product decision; on a mapped component, part or property ("tile.badge color",
49
+ // "collateral") its differences are decided, not the template's
50
+ // "dataGaps": { "facetCounts": "the API returns no facet value counts" }
51
+ // differences owned by the data (GAPS, a component or "component.part"), each with its reason
52
+ // a part's "interact" (or a component's): true | "click" | "tap" | "check" | "select" | { "action", "watch": { "<name>": "<selector>" },
53
+ // "safe": true|false, "option", "viewport" }: the action taken on both sides for the paired product,
54
+ // its outcome compared (docs/PARITY.md, "Behaviour"; parity/behaviour.mjs)
55
+ // a part's "parts": its sub-controls ({ "slider": ".ui-slider", "min": "input.min" }), flattened as "<part>.<sub>" ("Parts of parts")
56
+ // "behaviour": { "viewport": 1440, "scan": true } where interactions run; our controls without a counterpart probed
57
+ // "alignment": { "px": 1, "ignore": ["grid~sort align-right"] } the edges their components share, required of ours; false: off
58
+ // "search": { "field": "input[name=q]" } their search field (its visible box: the wrapper that draws it), for the alignment
59
+ // "hold": true a reload of our page paints no frame of their listing (also per page); false: off
60
+ // "phone": 767, widths up to this are a phone (touch, isMobile, a phone's user agent); false: never
61
+ // "collateral": { "ignore": [".rotating-banner"], "cls": 0.02, "gap": 4 }
62
+ // everything outside our components as on their page without us (false: off);
63
+ // `gap`: the px the space around the listing may differ from their listing page's
64
+ // }
65
+ //
66
+ // A component's "role" (one of MANDATORY's, e.g. "tile", "facet-group") says
67
+ // which mandatory component it is when its name does not: else its name
68
+ // (kebab-cased: facetGroup is facet-group), else its ours selector's marker.
69
+ //
70
+ // A selector is a string (both sides), { "theirs": …, "ours": … }, and each
71
+ // of those a string or { "<page name>": …, "default": … }.
72
+
73
+ export const DEFAULTS = Object.freeze({
74
+ viewports: [390, 1024, 1440],
75
+ height: 900,
76
+ locale: 'en-US',
77
+ timezone: 'UTC',
78
+ thresholds: { score: 90, components: {} },
79
+ sample: 4,
80
+ gapMs: 2000,
81
+ /** Widths up to this are measured as a phone (touch, isMobile, a phone's user agent): a site's phone layout can follow the device, not the width. */
82
+ phone: 767,
83
+ });
84
+
85
+ /** Every Luigi's Box script (previous-generation widgets, the platform's loader): `--init` blocks them on both sides, so it never drafts such a widget as theirs. */
86
+ export const BLOCK_LUIGIS = ['^https?://([a-z0-9-]+\\.)*luigisbox\\.(tech|com|ai)/'];
87
+ /**
88
+ * Markup `--init` never takes as theirs: a previous Luigi's Box generation's
89
+ * widgets (rendered by its script, or server-side) and known search vendors'.
90
+ * Their design is no design of the client's to copy.
91
+ */
92
+ export const LEGACY_MARKUP = Object.freeze([
93
+ '#lbx-ac-element', '.luigi-ac', '[class*="luigi-ac"]', '[id^="luigi"]', '[class*="luigi-search"]', '[class*="luigisbox"]', '[id^="lbx-"]',
94
+ '[class*="doofinder"]', '[class^="dfd-"]', '.aa-Panel', '.algolia-autocomplete', '[class^="ais-"]', '[class*="klevu"]', '[class*="snize"]', '[class^="boost-sd"]',
95
+ ]);
96
+
97
+ /** Our side's default blocks: Luigi's Box scripts of the site (previous-generation widgets, the platform's loader): only the build under test runs. */
98
+ export const BLOCK_OURS = ['^https://(cdn\\.luigisbox\\.(com|tech)|scripts\\.luigisbox\\.(com|tech)|cdn\\.[a-z0-9]+\\.luigisbox\\.ai)/'];
99
+ /** Their side's: the platform's loader (Luma itself, if the site already includes it); their previous-generation Luigi's Box widgets are theirs. */
100
+ export const BLOCK_THEIRS = ['^https://cdn\\.[a-z0-9]+\\.luigisbox\\.ai/'];
101
+
102
+ /** Part names Luma's built-in templates mark with `data-lbx-part` (the default for ours when a part names no selector of ours). */
103
+ export const PARTS = Object.freeze([
104
+ 'tile', 'image', 'title', 'price', 'old-price', 'discount', 'badge', 'rating', 'availability', 'add-to-cart', 'compare', 'wishlist', 'slider', 'range-slider', 'range-min', 'range-max',
105
+ 'list', 'count', 'sort', 'facets', 'facet-group', 'facet-title', 'facet-value', 'chip', 'pagination', 'more', 'empty',
106
+ 'ac-panel', 'ac-section', 'ac-title', 'ac-row', 'ac-all', 'ac-empty',
107
+ ]);
108
+
109
+ /**
110
+ * A range filter's sub-parts (a slider and its two inputs: "parts" of a part)
111
+ * and the markers Luma's range template gives ours
112
+ * (src/widgets/search/template-search-range-default.ts): the default of our
113
+ * side when the map names none.
114
+ */
115
+ export const RANGE_PARTS = Object.freeze({ slider: 'range-slider', min: 'range-min', max: 'range-max' });
116
+ /** Ours of a range filter whose sub-parts are RANGE_PARTS: Luma's facet group that holds the range. */
117
+ export const RANGE_GROUP = '[data-lbx-part~="facet-group"]:has([data-lbx-range])';
118
+
119
+ /**
120
+ * The components every map covers, or lists in "excluded" with a reason:
121
+ * `yarn luma parity` prints the coverage and does not converge while one is
122
+ * neither. `tile.<part>` is a part of the tile; `pagination` is covered by a
123
+ * pager or a "show more". docs/PARITY.md, "Coverage".
124
+ */
125
+ export const MANDATORY = Object.freeze([
126
+ 'tile', 'tile.image', 'tile.title', 'tile.price', 'tile.old-price', 'tile.badge',
127
+ 'grid', 'facets', 'facet-group', 'facet-title', 'facet-value', 'sort', 'count', 'pagination', 'empty',
128
+ 'ac-panel', 'ac-section', 'ac-row',
129
+ ]);
130
+ /** Roles that cover a mandatory component of another name. */
131
+ const ALIAS = Object.freeze({ list: 'grid', more: 'pagination', 'ac-empty': 'empty', 'ac-title': 'ac-section' });
132
+ const TILE_PARTS = ['image', 'title', 'price', 'old-price', 'badge'];
133
+
134
+ /**
135
+ * Known gaps of the API ("dataGaps"): their differences are the data's, not
136
+ * the template's (parity/owner.mjs). Besides these, a component's name or
137
+ * "component.part" declares all of its differences the data's.
138
+ */
139
+ export const GAPS = Object.freeze({
140
+ facetCounts: "facet value counts the API does not return: a facet value's text that differs only by its count, a count element missing in ours",
141
+ empty: 'an empty state that cannot occur on the API: every difference of the empty state',
142
+ acSections: 'autocomplete sections the channel lacks (query suggestions, …): the sections, their titles, the panel height they add',
143
+ });
144
+
145
+ const isObj = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
146
+
147
+ /** The steps a state can take (docs/PARITY.md, "States"): each one's target a selector, both sides' or { "theirs", "ours" }. */
148
+ export const STEPS = Object.freeze(['click', 'type', 'hover', 'press', 'wait-for', 'scroll']);
149
+ const STEP_ALIAS = Object.freeze({ waitFor: 'wait-for' });
150
+
151
+ /**
152
+ * One step of a state, normalized: { kind, theirs, ours, side, text?, delay?,
153
+ * key?, y? } (a side's selector null: the target is not on that side), or the
154
+ * error with it.
155
+ * @param {any} st @param {string} at where it is, for the error
156
+ * @returns {{ step?: any, error?: string }}
157
+ */
158
+ export const normalizeStep = (st, at) => {
159
+ if (!isObj(st)) return { error: `${at}: { "<${STEPS.join('|')}>": "<selector>" | { "theirs": …, "ours": … } }` };
160
+ const keys = Object.keys(st).filter((k) => STEPS.includes(STEP_ALIAS[k] ?? k));
161
+ if (keys.length !== 1) return { error: `${at}: one of ${STEPS.join(', ')} per step (got ${Object.keys(st).join(', ') || 'nothing'})` };
162
+ const kind = STEP_ALIAS[keys[0]] ?? keys[0];
163
+ const raw = st[keys[0]];
164
+ const side = st.side ?? null;
165
+ if (side != null && side !== 'theirs' && side !== 'ours') return { error: `${at}.side: "theirs" or "ours"` };
166
+ const step = { kind, theirs: /** @type {string | null} */ (null), ours: /** @type {string | null} */ (null), side };
167
+ const byY = kind === 'scroll' && isObj(raw) && typeof raw.y === 'number';
168
+ // press: the key, on a selector given as "on" (else the focused element); scroll: a selector or { "y": px }.
169
+ const target = kind === 'press' ? st.on : byY ? null : raw;
170
+ if (byY) step.y = raw.y;
171
+ if (target != null) {
172
+ if (typeof target === 'string') step.theirs = step.ours = target;
173
+ else if (isObj(target) && ('theirs' in target || 'ours' in target)) {
174
+ step.theirs = typeof target.theirs === 'string' ? target.theirs : null;
175
+ step.ours = typeof target.ours === 'string' ? target.ours : null;
176
+ } else return { error: `${at}.${kind === 'press' ? 'on' : kind}: a selector, or { "theirs": "<selector>", "ours": "<selector>" }` };
177
+ }
178
+ if (kind === 'press') {
179
+ if (typeof raw !== 'string' || !raw) return { error: `${at}.press: a key ("Enter", "Escape", "ArrowDown")` };
180
+ step.key = raw;
181
+ } else if (kind === 'type') {
182
+ if (typeof st.text !== 'string') return { error: `${at}: "type" needs a "text"` };
183
+ if (step.theirs == null && step.ours == null) return { error: `${at}.type: the input's selector` };
184
+ step.text = st.text;
185
+ step.delay = Number(st.delay ?? 40);
186
+ } else if (kind === 'scroll') {
187
+ if (step.y == null && step.theirs == null && step.ours == null) return { error: `${at}.scroll: a selector, or { "y": <px> }` };
188
+ } else if (step.theirs == null && step.ours == null) return { error: `${at}.${kind}: a selector for at least one side` };
189
+ // "side": the step is taken on that side only.
190
+ if (side === 'theirs') step.ours = null;
191
+ if (side === 'ours') step.theirs = null;
192
+ return { step };
193
+ };
194
+
195
+ /** The actions an interaction takes (docs/PARITY.md, "Behaviour"): `tap` is a click on a phone width. */
196
+ export const ACTIONS = Object.freeze(['click', 'tap', 'check', 'select']);
197
+
198
+ /** A selector both sides share, or { theirs, ours } (either null), as { theirs, ours }; null when it is neither. */
199
+ const sidesOf = (v) => {
200
+ if (typeof v === 'string') return { theirs: v, ours: v };
201
+ if (isObj(v) && ('theirs' in v || 'ours' in v)) return { theirs: typeof v.theirs === 'string' ? v.theirs : null, ours: typeof v.ours === 'string' ? v.ours : null };
202
+ return null;
203
+ };
204
+
205
+ /**
206
+ * A part's (or a component's) interactions, normalized (docs/PARITY.md,
207
+ * "Behaviour"): `"interact": true | "<action>" | { … } | [{ … }]`, each
208
+ * { action, option, watch, safe, wait, viewports }. `watch`: a selector, a
209
+ * list, or { "<name>": <selector or { theirs, ours }> }: the state read before
210
+ * and after the action (a counter, a checked state, a text). `safe`: true
211
+ * lets the action's requests through, false only records them (aborted:
212
+ * compared, never sent); unset, a POST to a cart, a compare list or a
213
+ * wishlist is not sent. `option` (select): the option to choose, by its
214
+ * label or value, both sides' or per side (default: the first one not
215
+ * selected).
216
+ * @param {any} raw @param {string} at where it is, for the errors
217
+ * @returns {{ list: any[], errors: string[] }}
218
+ */
219
+ export const normalizeInteract = (raw, at) => {
220
+ const errors = [];
221
+ const list = [];
222
+ if (raw == null || raw === false) return { list, errors };
223
+ const items = Array.isArray(raw) ? raw : [raw];
224
+ for (const [i, x] of items.entries()) {
225
+ const where = Array.isArray(raw) ? `${at}[${i}]` : at;
226
+ const o = x === true ? {} : typeof x === 'string' ? { action: x } : isObj(x) ? x : null;
227
+ if (!o) {
228
+ errors.push(`${where}: true, an action (${ACTIONS.join(', ')}), or { "action", "watch", "safe" }`);
229
+ continue;
230
+ }
231
+ const action = o.action ?? 'click';
232
+ if (!ACTIONS.includes(action)) errors.push(`${where}.action: one of ${ACTIONS.join(', ')}`);
233
+ const watch = [];
234
+ const w = o.watch;
235
+ if (w != null) {
236
+ const entries = typeof w === 'string' ? [[w, w]] : Array.isArray(w) ? w.map((s) => [String(s), s]) : isObj(w) && !('theirs' in w || 'ours' in w) ? Object.entries(w) : [['watch', w]];
237
+ for (const [name, sel] of entries) {
238
+ const s = sidesOf(sel);
239
+ if (!s || (s.theirs == null && s.ours == null)) errors.push(`${where}.watch: a selector, a list of them, or { "<name>": "<selector>" | { "theirs", "ours" } }`);
240
+ else watch.push({ name, ...s });
241
+ }
242
+ }
243
+ if (o.safe != null && typeof o.safe !== 'boolean') errors.push(`${where}.safe: true (let it through) or false (record the request, never send it)`);
244
+ const option = o.option == null ? null : typeof o.option === 'number' ? { index: o.option } : sidesOf(o.option);
245
+ if (o.option != null && !option) errors.push(`${where}.option: the option's label or value (or its index), or { "theirs", "ours" }`);
246
+ const vps = o.viewport ?? o.viewports;
247
+ const viewports = vps == null ? null : (Array.isArray(vps) ? vps : [vps]).map(Number);
248
+ if (viewports && !viewports.every((v) => Number.isInteger(v) && v >= 200 && v <= 3840)) errors.push(`${where}.viewport: a width (or a list) in CSS px`);
249
+ list.push({ action, option, watch, safe: typeof o.safe === 'boolean' ? o.safe : null, wait: Number(o.wait ?? 500), viewports });
250
+ }
251
+ return { list, errors };
252
+ };
253
+
254
+ /** Whether a step is taken on a side. @param {any} step @param {'theirs' | 'ours'} side */
255
+ export const stepOn = (step, side) => (step.side ? step.side === side : step[side] != null || (step.kind === 'press' && step.theirs == null && step.ours == null) || (step.kind === 'scroll' && step.y != null));
256
+
257
+ /** Storage a page is loaded with: { localStorage: { k: v }, sessionStorage: { k: v } } (values as strings), or null. */
258
+ const storageOf = (v) => {
259
+ if (!isObj(v)) return null;
260
+ const out = {};
261
+ for (const area of ['localStorage', 'sessionStorage']) if (isObj(v[area])) out[area] = Object.fromEntries(Object.entries(v[area]).map(([k, x]) => [k, typeof x === 'string' ? x : JSON.stringify(x)]));
262
+ return Object.keys(out).length ? out : null;
263
+ };
264
+ /** facetGroup -> facet-group. @param {string} s */
265
+ export const kebab = (s) => String(s).replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`).replace(/^-/, '');
266
+ /** The marker a selector ends with (`[data-lbx-part~="title"]`, `[data-lbx-part=title]`), or null. @param {unknown} sel */
267
+ export const markerOf = (sel) => (typeof sel === 'string' ? (/\[data-lbx-part[~*]?=["']?([a-z-]+)["']?\]\s*$/.exec(sel)?.[1] ?? null) : null);
268
+ /** Some selector of ours (a per-page one's default or first). */
269
+ const oursOf = (sel) => {
270
+ const v = pick(sel, 'ours');
271
+ if (v != null || !isObj(sel) || !isObj(sel.ours)) return v;
272
+ return sel.ours.default ?? Object.values(sel.ours).find((x) => typeof x === 'string') ?? null;
273
+ };
274
+ const ROLES = new Set([...MANDATORY.map((m) => m.replace(/^tile\./, '')), ...Object.keys(ALIAS)]);
275
+ /** A component's role: its "role", else its name (kebab-cased) when it is one, else its ours selector's marker. @param {string} name @param {any} c */
276
+ export const roleOf = (name, c) => {
277
+ if (typeof c?.role === 'string') return kebab(c.role);
278
+ const n = kebab(String(name).replace(/:hover$/, ''));
279
+ if (ROLES.has(n) || PARTS.includes(n)) return n;
280
+ return markerOf(oursOf(c?.ours)) ?? (c?.grid ? 'grid' : null);
281
+ };
282
+ /** A part's role: its "role", else its name (kebab-cased) when it is one, else its ours selector's marker. @param {string} name @param {any} p */
283
+ export const partRoleOf = (name, p) => {
284
+ if (typeof p?.role === 'string') return kebab(p.role);
285
+ const n = kebab(name);
286
+ if (ROLES.has(n) || PARTS.includes(n)) return n;
287
+ return markerOf(oursOf(p?.ours)) ?? null;
288
+ };
289
+
290
+ /**
291
+ * A selector for one side on one page, or null.
292
+ * @param {any} sel @param {'theirs' | 'ours'} side @param {string} [page]
293
+ * @returns {string | null}
294
+ */
295
+ export const pick = (sel, side, page) => {
296
+ if (sel == null) return null;
297
+ if (typeof sel === 'string') return sel;
298
+ if (!isObj(sel)) return null;
299
+ const v = 'theirs' in sel || 'ours' in sel ? sel[side] : sel;
300
+ if (v == null) return null;
301
+ if (typeof v === 'string') return v;
302
+ if (isObj(v)) return (page != null ? v[page] : undefined) ?? v.default ?? null;
303
+ return null;
304
+ };
305
+
306
+ /** Ours for a part that names none: the built-in templates' marker. @param {string} name */
307
+ export const partMarker = (name) => `[data-lbx-part~="${kebab(name)}"]`;
308
+
309
+ /**
310
+ * The map with defaults filled in, or the problems with it.
311
+ * @param {any} raw
312
+ * @returns {{ map: any, errors: string[] }}
313
+ */
314
+ export const normalize = (raw) => {
315
+ const errors = [];
316
+ if (!isObj(raw)) return { map: null, errors: ['the map is not a JSON object'] };
317
+ const map = {
318
+ host: raw.host ?? null,
319
+ viewports: Array.isArray(raw.viewports) && raw.viewports.length ? raw.viewports.map(Number) : [...DEFAULTS.viewports],
320
+ height: Number(raw.height ?? DEFAULTS.height),
321
+ locale: raw.locale ?? DEFAULTS.locale,
322
+ timezone: raw.timezone ?? DEFAULTS.timezone,
323
+ tolerance: { ...(isObj(raw.tolerance) ? raw.tolerance : {}) },
324
+ thresholds: { score: raw.thresholds?.score ?? DEFAULTS.thresholds.score, components: { ...(raw.thresholds?.components ?? {}) } },
325
+ hide: { theirs: [], ours: [] },
326
+ block: { theirs: [...BLOCK_THEIRS], ours: [...BLOCK_OURS] },
327
+ products: isObj(raw.products) ? { type: 'product', ...raw.products, pair: raw.products.pair ?? 'field' } : null,
328
+ archive: { ignore: raw.archive?.ignore ?? null },
329
+ excluded: /** @type {Record<string, string>} */ ({}),
330
+ dataGaps: /** @type {Record<string, string>} */ ({}),
331
+ // A draft from --init, and the picks it was least sure of.
332
+ draft: raw.draft === true,
333
+ confirm: Array.isArray(raw.confirm) ? raw.confirm.filter((x) => typeof x === 'string') : [],
334
+ gapMs: Number(raw.gapMs ?? DEFAULTS.gapMs),
335
+ phone: raw.phone === false ? 0 : Number(raw.phone ?? DEFAULTS.phone),
336
+ // Everything outside our components, as on their page without us (docs/PARITY.md, "The collateral check").
337
+ collateral: raw.collateral === false ? null : { ignore: Array.isArray(raw.collateral?.ignore) ? raw.collateral.ignore.filter((x) => typeof x === 'string') : [], cls: Number(raw.collateral?.cls ?? 0.02), gap: Number(raw.collateral?.gap ?? 4) },
338
+ /** Recorded product decisions on what the map compares ("excluded" with a "decision"): their differences are decided, not the template's. */
339
+ decided: /** @type {Record<string, { reason: string, decision: string, by?: string, date?: string }>} */ ({}),
340
+ /** The product decisions recorded with the exclusion of a mandatory component. */
341
+ decisions: /** @type {Record<string, { decision: string, by?: string, date?: string }>} */ ({}),
342
+ // A reload of our page, the rules stored, paints no frame of their listing (docs/PARITY.md, "The hold"); false: off.
343
+ hold: raw.hold !== false,
344
+ // The edges their components line up on, required of ours (docs/PARITY.md, "Alignment"); false: off.
345
+ alignment: raw.alignment === false ? null : { px: Number(raw.alignment?.px ?? 1), ignore: Array.isArray(raw.alignment?.ignore) ? raw.alignment.ignore.filter((x) => typeof x === 'string') : [] },
346
+ // Their search field (the input; its visible box is the wrapper that draws it): default, a typing page's input.
347
+ search: { field: sidesOf(raw.search?.field) },
348
+ // The interactions (docs/PARITY.md, "Behaviour"): at which width (default the widest), our controls without a counterpart probed.
349
+ behaviour: { viewport: raw.behaviour?.viewport == null ? null : Number(raw.behaviour.viewport), scan: raw.behaviour?.scan !== false },
350
+ /** Every declared interaction: { id, component, part, …normalizeInteract }. */
351
+ interactions: /** @type {any[]} */ ([]),
352
+ pages: {},
353
+ components: {},
354
+ };
355
+ if (raw.alignment != null && raw.alignment !== false && !isObj(raw.alignment)) errors.push('alignment: false, or { "px": 1, "ignore": ["grid~sort align-right"] }');
356
+ if (map.alignment && !(map.alignment.px >= 0)) errors.push('alignment.px: how far an edge may be off and still line up, in CSS px (default 1)');
357
+ if (raw.search != null && (!isObj(raw.search) || (raw.search.field != null && !map.search.field))) errors.push('search: { "field": "<their search input>" | { "theirs", "ours" } }');
358
+ if (raw.behaviour != null && (!isObj(raw.behaviour) || (map.behaviour.viewport != null && !Number.isInteger(map.behaviour.viewport)))) errors.push('behaviour: { "viewport": <the width interactions run at>, "scan": true }');
359
+ if (!Number.isFinite(map.phone) || map.phone < 0) errors.push('phone: the widest viewport measured as a phone, in CSS px (default 767), or false');
360
+ if (raw.collateral != null && raw.collateral !== false && raw.collateral !== true && !isObj(raw.collateral)) errors.push('collateral: false, or { "ignore": ["<selector>"], "cls": 0.02 }');
361
+ if (map.collateral && !(map.collateral.cls >= 0)) errors.push('collateral.cls: the layout shift ours may add, 0-1 (default 0.02)');
362
+ if (map.collateral && !(map.collateral.gap >= 0)) errors.push('collateral.gap: the px the space around the listing may differ from their listing page\'s (default 4)');
363
+ const byUrl = map.products?.pair === 'url';
364
+ if (map.products && map.products.pair !== 'field' && !byUrl) errors.push('products.pair: "field" (by products.field, the default) or "url" (by their links)');
365
+ for (const side of /** @type {const} */ (['theirs', 'ours'])) {
366
+ const h = Array.isArray(raw.hide) ? raw.hide : isObj(raw.hide) ? (raw.hide[side] ?? []) : [];
367
+ map.hide[side] = h.filter((x) => typeof x === 'string');
368
+ if (isObj(raw.block) && Array.isArray(raw.block[side])) map.block[side] = raw.block[side];
369
+ }
370
+ for (const [side, list] of Object.entries(map.block))
371
+ for (const re of list)
372
+ try {
373
+ new RegExp(re);
374
+ } catch {
375
+ errors.push(`block.${side}: "${re}" is not a regular expression`);
376
+ }
377
+ if (!map.viewports.every((v) => Number.isInteger(v) && v >= 200 && v <= 3840)) errors.push('viewports: widths in CSS px (200-3840)');
378
+ if (!isObj(raw.pages) || !Object.keys(raw.pages).length) errors.push('pages: at least one page ({ "name": { "path": "/…" } })');
379
+ for (const [name, p] of Object.entries(isObj(raw.pages) ? raw.pages : {})) {
380
+ // Each side's path: its own ("theirs": { "path" }, "ours": { "path" }), else the shared "path".
381
+ // Their page can be another one than ours: our search against their category listing (docs/PARITY.md).
382
+ const sideDef = (s) => (isObj(p?.[s]) ? p[s] : {});
383
+ const paths = isObj(p) ? { theirs: sideDef('theirs').path ?? p.path, ours: sideDef('ours').path ?? p.path } : { theirs: undefined, ours: undefined };
384
+ const badPath = (v) => typeof v !== 'string' || !v.startsWith('/');
385
+ if (!isObj(p) || badPath(paths.theirs) || badPath(paths.ours)) {
386
+ errors.push(isObj(p) && (p.theirs != null || p.ours != null) ? `pages.${name}: a path per side starting with "/" ("theirs": { "path" }, "ours": { "path" }, or a shared "path")` : `pages.${name}: a "path" starting with "/"`);
387
+ continue;
388
+ }
389
+ for (const s of ['theirs', 'ours']) if (p[s] != null && !isObj(p[s])) errors.push(`pages.${name}.${s}: { "path": "/…" }`);
390
+ if (sideDef('ours').ids !== undefined) errors.push(`pages.${name}.ours.ids: their products are read on their page ("ids", or "theirs": { "ids" })`);
391
+ const rawIds = sideDef('theirs').ids !== undefined ? sideDef('theirs').ids : p.ids;
392
+ const cross = paths.theirs !== paths.ours;
393
+ // "ids": false: not paired (another page's products): compared by structure.
394
+ const unpaired = rawIds === false;
395
+ if (cross && rawIds == null) errors.push(`pages.${name}: our ${paths.ours} is compared against their ${paths.theirs}, another page: declare "ids" on their side ("theirs": { "path": "${paths.theirs}", "ids": "<their tiles>" }), so ours shows their products; or "ids": false to compare by structure only`);
396
+ const ids = typeof rawIds === 'string' ? { selector: rawIds } : isObj(rawIds) ? rawIds : null;
397
+ if (ids && typeof ids.selector !== 'string') errors.push(`pages.${name}.ids: a "selector"`);
398
+ map.pages[name] = {
399
+ path: paths.theirs,
400
+ paths,
401
+ cross,
402
+ unpaired,
403
+ ready: { theirs: pick(p.ready, 'theirs', name), ours: pick(p.ready, 'ours', name) },
404
+ type: isObj(p.type) && typeof p.type.input === 'string' && typeof p.type.text === 'string' ? { input: p.type.input, text: p.type.text, delay: Number(p.type.delay ?? 40) } : null,
405
+ ids: ids ? { attr: ids.attr ?? map.products?.attr ?? (byUrl ? 'href' : null), field: ids.field ?? map.products?.field ?? (byUrl ? 'web_url' : null), child: ids.child ?? null, transform: ids.transform ?? null, selector: ids.selector, max: Number(ids.max ?? 60) } : null,
406
+ settleMs: Number(p.settleMs ?? 0),
407
+ hold: p.hold !== false,
408
+ // What their products are asked for with, by id, first (an empty query finds nothing on many channels): pairing.mjs.
409
+ queries: (typeof p.query === 'string' ? [p.query] : Array.isArray(p.query) ? p.query : []).filter((q) => typeof q === 'string' && q.trim()).map((q) => q.trim()),
410
+ // Storage set before every load (their page's view mode, …): both sides', or per side.
411
+ storage: { theirs: storageOf(isObj(p.storage?.theirs) ? p.storage.theirs : p.storage), ours: storageOf(isObj(p.storage?.ours) ? p.storage.ours : p.storage) },
412
+ states: /** @type {{ name: string, viewports: number[] | null, components: string[] | null, steps: any[] }[]} */ ([]),
413
+ };
414
+ if (p.query != null && typeof p.query !== 'string' && !Array.isArray(p.query)) errors.push(`pages.${name}.query: a query (or a list) that finds their products`);
415
+ if (p.storage != null && !isObj(p.storage)) errors.push(`pages.${name}.storage: { "localStorage": { "<key>": "<value>" } } (or per side: "theirs", "ours")`);
416
+ if (p.states != null && !Array.isArray(p.states)) errors.push(`pages.${name}.states: a list of { "name", "steps": [...] }`);
417
+ for (const [i, st] of (Array.isArray(p.states) ? p.states : []).entries()) {
418
+ const at = `pages.${name}.states[${i}]`;
419
+ if (!isObj(st) || typeof st.name !== 'string' || !/^[\w-]+$/.test(st.name)) {
420
+ errors.push(`${at}: a "name" (letters, digits, - or _)`);
421
+ continue;
422
+ }
423
+ if (map.pages[name].states.some((x) => x.name === st.name)) errors.push(`${at}: another state is named "${st.name}"`);
424
+ const vps = st.viewport ?? st.viewports;
425
+ const viewports = vps == null ? null : (Array.isArray(vps) ? vps : [vps]).map(Number);
426
+ if (viewports && !viewports.every((v) => Number.isInteger(v) && v >= 200 && v <= 3840)) errors.push(`${at}.viewport: a width (or a list) in CSS px`);
427
+ if (!Array.isArray(st.steps) || !st.steps.length) errors.push(`${at}.steps: at least one step`);
428
+ const steps = [];
429
+ for (const [j, x] of (Array.isArray(st.steps) ? st.steps : []).entries()) {
430
+ const r = normalizeStep(x, `${at}.steps[${j}]`);
431
+ if (r.error) errors.push(r.error);
432
+ else steps.push(r.step);
433
+ }
434
+ map.pages[name].states.push({ name: st.name, viewports, steps, components: Array.isArray(st.components) ? st.components.map(String) : null });
435
+ }
436
+ if (p.type != null && !map.pages[name].type) errors.push(`pages.${name}.type: { "input": "<selector>", "text": "<what to type>" }`);
437
+ if (ids && (!map.pages[name].ids.attr || !map.pages[name].ids.field)) errors.push(`pages.${name}.ids: "attr" and "field" (or "products.attr" / "products.field")`);
438
+ if (ids && !map.products) errors.push(`pages.${name}.ids: needs "products" (the products' "type", and optionally a collections "surface" to fetch them by id)`);
439
+ }
440
+ if (!isObj(raw.components) || !Object.keys(raw.components).length) errors.push('components: at least one component');
441
+ for (const [name, c] of Object.entries(isObj(raw.components) ? raw.components : {})) {
442
+ if (!isObj(c)) {
443
+ errors.push(`components.${name}: an object`);
444
+ continue;
445
+ }
446
+ const pages = (Array.isArray(c.pages) ? c.pages : typeof c.pages === 'string' ? [c.pages] : Object.keys(map.pages)).filter((p) => {
447
+ if (map.pages[p]) return true;
448
+ errors.push(`components.${name}.pages: no page "${p}"`);
449
+ return false;
450
+ });
451
+ if (pick(c.theirs ?? c.root, 'theirs', pages[0]) == null && !pages.some((p) => pick(c.theirs ?? c.root, 'theirs', p))) errors.push(`components.${name}: "theirs" (their element's selector)`);
452
+ const parts = {};
453
+ /**
454
+ * A part, and its own parts (a price filter's slider and inputs: "parts" inside a part), flattened as
455
+ * `<part>.<sub-part>`, each one's selectors inside its parent's on its side (docs/PARITY.md, "Parts of
456
+ * parts"): a sub-control theirs has and ours lacks is a missing element, CRITICAL.
457
+ */
458
+ const addPart = (pn, ps, parent) => {
459
+ const spec = isObj(ps) && ('theirs' in ps || 'ours' in ps || 'selector' in ps || 'parts' in ps || 'interact' in ps) ? ps : { selector: ps };
460
+ const name = parent ? `${parent.name}.${pn}` : pn;
461
+ const own = {
462
+ theirs: spec.selector ?? spec.theirs ?? null,
463
+ ours: spec.ours !== undefined ? spec.ours : (spec.selector ?? null),
464
+ };
465
+ // A range filter's (a part whose parts are a slider and its inputs): ours is Luma's range group (template-search-range-default.ts).
466
+ if (own.ours == null && spec.selector == null && isObj(spec.parts) && Object.keys(spec.parts).some((k) => k in RANGE_PARTS)) own.ours = RANGE_GROUP;
467
+ // Ours when the map names none: the built-in marker (engine.mjs). A sub-part's, inside its parent's.
468
+ const within = (side, sel) => {
469
+ const up = side === 'ours' ? (parent.ours ?? partMarker(parent.leaf)) : parent.theirs;
470
+ const s = sel ?? (side === 'ours' ? partMarker(RANGE_PARTS[pn] ?? pn) : null);
471
+ if (s == null) return null;
472
+ if (typeof up !== 'string') return side === 'theirs' && up == null ? null : s;
473
+ return `${up} ${s}`;
474
+ };
475
+ parts[name] = {
476
+ theirs: parent ? within('theirs', typeof own.theirs === 'string' ? own.theirs : null) : own.theirs,
477
+ ours: parent ? within('ours', typeof own.ours === 'string' ? own.ours : null) : own.ours,
478
+ text: spec.text === undefined ? undefined : spec.text,
479
+ ignore: Array.isArray(spec.ignore) ? spec.ignore : [],
480
+ role: null,
481
+ parent: parent?.name ?? null,
482
+ };
483
+ // Its role follows from its marker.
484
+ parts[name].role = partRoleOf(pn, { role: spec.role, ours: (parent ? null : parts[name].ours) ?? partMarker(pn) });
485
+ if (parent && parts[name].theirs == null) errors.push(`components.${cn}.parts.${name}: "theirs" (their sub-control's selector, inside ${parent.name})`);
486
+ const ia = normalizeInteract(spec.interact, `components.${cn}.parts.${name}.interact`);
487
+ errors.push(...ia.errors);
488
+ for (const x of ia.list) map.interactions.push({ id: `${cn}.${name}`, component: cn, part: name, ...x });
489
+ if (spec.parts != null && !isObj(spec.parts)) errors.push(`components.${cn}.parts.${name}.parts: { "<sub-part>": "<selector>" }`);
490
+ for (const [sn, ss] of Object.entries(isObj(spec.parts) ? spec.parts : {})) addPart(sn, ss, { name, leaf: pn, theirs: parts[name].theirs, ours: parent ? parts[name].ours : own.ours });
491
+ };
492
+ const cn = name;
493
+ for (const [pn, ps] of Object.entries(isObj(c.parts) ? c.parts : {})) addPart(pn, ps, null);
494
+ {
495
+ const ia = normalizeInteract(c.interact, `components.${name}.interact`);
496
+ errors.push(...ia.errors);
497
+ for (const x of ia.list) map.interactions.push({ id: name, component: name, part: 'root', ...x });
498
+ }
499
+ const each = c.each === true ? {} : isObj(c.each) ? c.each : null;
500
+ map.components[name] = {
501
+ role: roleOf(name, c),
502
+ pages,
503
+ theirs: c.theirs ?? c.root ?? null,
504
+ ours: c.ours ?? null,
505
+ parts,
506
+ each: each ? { key: typeof each.key === 'string' ? { attr: each.key } : isObj(each.key) ? each.key : null, sample: Number(each.sample ?? DEFAULTS.sample), max: Number(each.max ?? 60) } : null,
507
+ grid: isObj(c.grid) ? { item: c.grid.item } : null,
508
+ text: c.text === undefined ? (c.each ? 'exact' : 'shape') : c.text,
509
+ ignore: Array.isArray(c.ignore) ? c.ignore : [],
510
+ mask: Array.isArray(c.mask) ? c.mask : [],
511
+ states: Array.isArray(c.states) ? c.states.filter((s) => s === 'hover') : [],
512
+ };
513
+ if (c.text != null && c.text !== false && c.text !== 'exact' && c.text !== 'shape') errors.push(`components.${name}.text: "exact", "shape" or false`);
514
+ if (c.grid && !isObj(c.grid)) errors.push(`components.${name}.grid: { "item": "<selector>" }`);
515
+ if (c.role != null && (typeof c.role !== 'string' || !(ROLES.has(kebab(c.role)) || PARTS.includes(kebab(c.role))))) errors.push(`components.${name}.role: a mandatory component (${MANDATORY.filter((m) => !m.includes('.')).join(', ')}) or a part marker (${PARTS.join(', ')})`);
516
+ }
517
+ for (const [pn, pg] of Object.entries(map.pages))
518
+ for (const st of pg.states) for (const c of st.components ?? []) if (!map.components[c]?.pages.includes(pn)) errors.push(`pages.${pn}.states "${st.name}".components: "${c}" is no component of the page`);
519
+ // { "<name>": "<reason>" } (or [{ "name": …, "reason": … }]): every entry with its reason; "excluded"
520
+ // also takes a recorded product decision: { "reason": "<why>", "decision": "<who decided what>", "by", "date" }.
521
+ const decisions = /** @type {Record<string, { decision: string, by?: string, date?: string }>} */ ({});
522
+ const reasons = (key, v, idOf, what) => {
523
+ const out = /** @type {Record<string, string>} */ ({});
524
+ if (v == null) return out;
525
+ if (!isObj(v) && !Array.isArray(v)) {
526
+ errors.push(`${key}: { "<name>": "<reason>" }`);
527
+ return out;
528
+ }
529
+ const list = isObj(v) ? Object.entries(v) : v.map((x) => (isObj(x) ? [x.name ?? x.gap ?? x.component, x] : [x, undefined]));
530
+ for (const [k, raw] of list) {
531
+ const rec = isObj(raw) ? raw : null;
532
+ const r = rec ? (typeof rec.reason === 'string' && rec.reason.trim() ? rec.reason : rec.decision) : raw;
533
+ const decision = rec && typeof rec.decision === 'string' && rec.decision.trim() ? { decision: rec.decision.trim(), ...(typeof rec.by === 'string' ? { by: rec.by } : {}), ...(rec.date != null ? { date: String(rec.date) } : {}) } : null;
534
+ const id = typeof k === 'string' ? idOf(k, decision) : null;
535
+ if (!id) errors.push(`${key}: "${k}" is not ${what}`);
536
+ else if (typeof r !== 'string' || !r.trim()) errors.push(`${key}: "${k}" needs a reason ({ "${k}": "<why>" }${key === 'excluded' ? ', or { "reason": "<why>", "decision": "<the product decision>" }' : ''})`);
537
+ else {
538
+ out[id] = r.trim();
539
+ if (decision) decisions[id] = decision;
540
+ }
541
+ }
542
+ return out;
543
+ };
544
+ /** What a product decision can be on: "tile", "tile.title", either with a property ("tile.title color"), "collateral[.<block>]", "alignment[.<a>~<b>]", "behaviour[.<component>.<part>]", "hold". */
545
+ const decidable = (k) => {
546
+ const [where, prop, ...more] = k.trim().split(/\s+/);
547
+ if (more.length || (prop != null && !/^[a-z-]+$/i.test(prop))) return false;
548
+ // The page-level checks: the collateral check, the alignment, the behaviour, the hold ("alignment.sort~grid align-right", "behaviour.tile.compare effect").
549
+ if (/^(collateral|alignment|behaviour|hold)(\.|$)/.test(where)) return true;
550
+ const [cn, ...rest] = where.split('.');
551
+ const pn = rest.length ? rest.join('.') : null;
552
+ const c = map.components[cn];
553
+ return !!c && (pn == null || pn === 'root' || pn === 'grid' || !!c.parts[pn]);
554
+ };
555
+ const excluded = reasons(
556
+ 'excluded',
557
+ raw.excluded,
558
+ (k, decision) => {
559
+ const [head, ...rest] = k.split('.');
560
+ const id = rest.length ? `${head}.${kebab(rest.join('.'))}` : kebab(head);
561
+ if (MANDATORY.includes(id)) return id;
562
+ if (MANDATORY.includes(`tile.${id}`)) return `tile.${id}`;
563
+ return decision && decidable(k) ? `decided:${k.trim()}` : null;
564
+ },
565
+ `a mandatory component (${MANDATORY.join(', ')}), or, with a "decision", a mapped component, "component.part" (either with a property: "tile.title color") or "collateral"`,
566
+ );
567
+ for (const [id, reason] of Object.entries(excluded)) {
568
+ if (id.startsWith('decided:')) map.decided[id.slice(8)] = { reason, ...decisions[id] };
569
+ else {
570
+ map.excluded[id] = reason;
571
+ if (decisions[id]) map.decisions[id] = decisions[id];
572
+ }
573
+ }
574
+ map.dataGaps = reasons(
575
+ 'dataGaps',
576
+ raw.dataGaps,
577
+ (k) => {
578
+ if (k in GAPS) return k;
579
+ const [cn, pn] = k.split('.');
580
+ const c = map.components[cn];
581
+ return c && (pn == null || pn === 'root' || c.parts[pn]) ? k : null;
582
+ },
583
+ `a known gap (${Object.keys(GAPS).join(', ')}), a component or "component.part"`,
584
+ );
585
+ return { map, errors };
586
+ };
587
+
588
+ /**
589
+ * Which mandatory components (MANDATORY) the map covers: per one, "mapped"
590
+ * (by which component or part), "excluded" (and why) or "missing"; `ok`
591
+ * when none is missing. A component covers its role; a part covers its role
592
+ * too (a facets component's facetGroup part), and a tile's parts its parts.
593
+ * @param {any} map a normalized map
594
+ * @returns {{ ok: boolean, items: { id: string, status: 'mapped' | 'excluded' | 'missing', by?: string, reason?: string, decision?: { decision: string, by?: string, date?: string } }[], missing: string[] }}
595
+ */
596
+ export const coverage = (map) => {
597
+ const as = (role) => (role == null ? null : (ALIAS[role] ?? role));
598
+ const found = new Map();
599
+ const note = (id, by) => {
600
+ if (id && MANDATORY.includes(id) && !found.has(id)) found.set(id, by);
601
+ };
602
+ for (const [cn, c] of Object.entries(map.components ?? {})) {
603
+ const r = as(c.role);
604
+ note(r, `components.${cn}`);
605
+ for (const [pn, p] of Object.entries(c.parts ?? {})) {
606
+ const pr = as(p.role);
607
+ if (r === 'tile' && TILE_PARTS.includes(pr)) note(`tile.${pr}`, `components.${cn}.parts.${pn}`);
608
+ else if (!TILE_PARTS.includes(pr)) note(pr, `components.${cn}.parts.${pn}`);
609
+ }
610
+ }
611
+ const items = MANDATORY.map((id) =>
612
+ found.has(id)
613
+ ? { id, status: /** @type {const} */ ('mapped'), by: found.get(id) }
614
+ : map.excluded?.[id]
615
+ ? { id, status: /** @type {const} */ ('excluded'), reason: map.excluded[id], ...(map.decisions?.[id] ? { decision: map.decisions[id] } : {}) }
616
+ : { id, status: /** @type {const} */ ('missing') },
617
+ );
618
+ const missing = items.filter((i) => i.status === 'missing').map((i) => i.id);
619
+ return { ok: !missing.length, items, missing };
620
+ };
621
+
622
+ /**
623
+ * Every page's two paths (`--record` keeps them in the recording's index):
624
+ * ours, and the page of theirs it is compared against, its design source.
625
+ * @param {any} map a normalized map
626
+ * @returns {Record<string, { theirs: string, ours: string }>}
627
+ */
628
+ export const recordedPages = (map) => Object.fromEntries(Object.entries(map.pages ?? {}).map(([k, v]) => [k, { theirs: v.paths?.theirs ?? v.path, ours: v.paths?.ours ?? v.path }]));
629
+
630
+ /**
631
+ * Per page: ours, the page of theirs it is compared against (the design
632
+ * source), whether that is another page (`cross`) and whether their products
633
+ * are paired with ours (`paired`). The report states it for every page.
634
+ * @param {any} map a normalized map
635
+ */
636
+ export const designSources = (map) =>
637
+ Object.entries(map.pages ?? {}).map(([page, v]) => ({ page, theirs: v.paths?.theirs ?? v.path, ours: v.paths?.ours ?? v.path, cross: !!v.cross, paired: !!v.ids }));
638
+
639
+ /** The pages each component is measured on, as [page, component] pairs, in the map's order. @param {any} map */
640
+ export const plan = (map) => Object.keys(map.pages).map((page) => ({ page, components: Object.entries(map.components).filter(([, c]) => c.pages.includes(page)).map(([n]) => n) }));