@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
@@ -0,0 +1,703 @@
1
+ // `yarn luma parity --init`: a draft map from the site's own pages, for an agent
2
+ // or a person to confirm.
3
+ //
4
+ // Their side, by structure (not by class names alone): the product tile is
5
+ // the element repeated as siblings with an image, a link and a price; its
6
+ // parts by role (the largest image, the price-like text, the crossed one as
7
+ // the old price, the heading or the longest link text as the title, a short
8
+ // label as the badge); the facet container is the element whose children are
9
+ // groups of options (checkboxes, role=checkbox, links with counts); the sort
10
+ // control a select (or a menu) of sort orders; the count the shortest text
11
+ // with a number of products. Our side: Luma's `data-lbx-part` markers (the
12
+ // built-in templates carry them, and so should an integration's own
13
+ // templates, docs/PARITY.md), else their selector when a 1:1 port has it.
14
+ // The product pairing comes from evidence: their ids (or links) matched
15
+ // against the catalog records our page received.
16
+ //
17
+ // Every pick the heuristics were not sure of is listed in "confirm"; the
18
+ // mandatory components (map.mjs MANDATORY) not found on their pages too.
19
+ //
20
+ // What is not theirs is never drafted as theirs: every Luigi's Box script
21
+ // (*.luigisbox.{tech,com,ai}: a previous generation's dropdown, the
22
+ // platform's loader) and the vendor scripts `--block` names are blocked on
23
+ // both sides, and previous-generation or vendor markup (map.mjs LEGACY_MARKUP:
24
+ // `#lbx-ac-element`, `.luigi-ac`, a server-rendered remnant) is never picked.
25
+ // The pairing found is checked as a run makes it (their products by id with
26
+ // the queries of parity/pairing.mjs): a pairing that finds none of them is
27
+ // listed to confirm, and a query a run needs is drafted as the page's.
28
+ import { BLOCK_LUIGIS, LEGACY_MARKUP, MANDATORY, PARTS, RANGE_GROUP, RANGE_PARTS, kebab, partMarker } from './map.mjs';
29
+ import { formatQueries, pairingFailure, queryCandidates, queryOfPath } from './pairing.mjs';
30
+ import { alignInit, deepInit, stabilize, theirHints } from './probe.mjs';
31
+
32
+ /**
33
+ * In the page: their components, as selectors (page-level roots,
34
+ * root-relative parts), with the picks of low confidence (`low`).
35
+ * @param {{ empty: boolean, legacy?: string[] }} arg `legacy`: markup never theirs (map.mjs LEGACY_MARKUP)
36
+ */
37
+ export function proposeTheirs(arg) {
38
+ // A previous Luigi's Box generation's widgets and vendors' are not their design: never picked.
39
+ const legacy = (el) =>
40
+ (arg.legacy ?? []).some((s) => {
41
+ try {
42
+ return !!el.closest(s);
43
+ } catch {
44
+ return false;
45
+ }
46
+ });
47
+ const PRICE = /(\d[\d\s.,]*\s?(€|\$|£|kč|zł|kr\b|ft\b|lei\b|chf\b|eur\b|usd\b|,-))|((€|\$|£)\s?\d)/i;
48
+ const UTIL = /\d|^([a-z]+-)?(d|m[trblxyse]?|p[trblxyse]?|w|h|col|row|g|gap|flex|align|justify|order|text|font|fw|fs|bg|border|rounded|shadow|position|top|bottom|start|end|float|overflow|visible|invisible|hidden|show|is|has|js)(-|$)/;
49
+ const vis = (el) => {
50
+ const r = el.getBoundingClientRect();
51
+ return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden';
52
+ };
53
+ const area = (el) => {
54
+ const r = el.getBoundingClientRect();
55
+ return r.width * r.height;
56
+ };
57
+ const classes = (el) => [...el.classList].filter((c) => !UTIL.test(c) && /^[a-z_-][\w-]*$/i.test(c));
58
+ const cssEsc = (s) => CSS.escape(s);
59
+ const text = (el) => (el.textContent ?? '').replace(/\s+/g, ' ').trim();
60
+ /** A document-level selector: an id, a unique class, or the parent's selector and a child step. */
61
+ const docSel = (el, depth = 0) => {
62
+ if (el.id && /^[a-z][\w-]*$/i.test(el.id) && !/\d{3,}/.test(el.id)) return `#${cssEsc(el.id)}`;
63
+ for (const c of classes(el)) if (document.querySelectorAll(`.${cssEsc(c)}`).length === 1) return `.${cssEsc(c)}`;
64
+ const cs = classes(el);
65
+ const self = `${el.tagName.toLowerCase()}${cs.slice(0, 2).map((c) => `.${cssEsc(c)}`).join('')}`;
66
+ if (!el.parentElement || el.parentElement === document.body || depth > 4) return self;
67
+ if (cs.length && document.querySelectorAll(self).length === 1) return self;
68
+ return `${docSel(el.parentElement, depth + 1)} > ${self}`;
69
+ };
70
+ /** A selector of `el` inside `root` that finds it first. */
71
+ const relSel = (el, root) => {
72
+ for (const c of classes(el)) if (root.querySelector(`.${cssEsc(c)}`) === el) return `.${cssEsc(c)}`;
73
+ const t = el.tagName.toLowerCase();
74
+ for (const c of classes(el)) if (root.querySelector(`${t}.${cssEsc(c)}`) === el) return `${t}.${cssEsc(c)}`;
75
+ if (root.querySelector(t) === el) return t;
76
+ for (const a of ['itemprop', 'data-testid', 'role']) {
77
+ const v = el.getAttribute(a);
78
+ if (v && root.querySelector(`${t}[${a}="${cssEsc(v)}"]`) === el) return `${t}[${a}="${v}"]`;
79
+ }
80
+ const path = [];
81
+ for (let e = el; e && e !== root; e = e.parentElement) {
82
+ const sibs = e.parentElement ? [...e.parentElement.children].filter((x) => x.tagName === e.tagName) : [e];
83
+ path.unshift(`${e.tagName.toLowerCase()}${sibs.length > 1 ? `:nth-of-type(${sibs.indexOf(e) + 1})` : ''}`);
84
+ }
85
+ return `:scope > ${path.join(' > ')}`;
86
+ };
87
+ const sig = (el) => `${el.tagName}.${classes(el).sort().join('.')}`;
88
+ /** The innermost elements whose text is a price (no child holds the whole price). */
89
+ const prices = (scope) =>
90
+ [...scope.querySelectorAll('*')].filter((e) => vis(e) && text(e).length < 40 && PRICE.test(text(e)) && ![...e.children].some((c) => PRICE.test(text(c))));
91
+ const crossed = (e, stop) => {
92
+ for (let x = e; x && x !== stop; x = x.parentElement) {
93
+ if (getComputedStyle(x).textDecorationLine.includes('line-through') || /^(DEL|S|STRIKE)$/.test(x.tagName) || /old|crossed|strike|was|before|original|regular/i.test(String(x.className))) return true;
94
+ }
95
+ return false;
96
+ };
97
+ const out = { components: {}, ids: null, links: null, notes: [] };
98
+ const low = (comp, part, why) => {
99
+ const c = out.components[comp];
100
+ if (!c) return;
101
+ c.low = c.low ?? {};
102
+ c.low[part ?? '.'] = why;
103
+ };
104
+
105
+ // ---- the product tile: siblings alike, each with an image and a link, most with a price.
106
+ let best = null;
107
+ for (const parent of document.querySelectorAll('body *')) {
108
+ if (parent.children.length < 3 || parent.closest('header, nav, footer') || legacy(parent)) continue;
109
+ const by = new Map();
110
+ for (const ch of parent.children) {
111
+ if (!vis(ch)) continue;
112
+ const k = sig(ch);
113
+ if (!by.has(k)) by.set(k, []);
114
+ by.get(k).push(ch);
115
+ }
116
+ for (const members of by.values()) {
117
+ const ok = members.filter((m) => m.querySelector('img') && (m.matches('a[href]') || m.querySelector('a[href]')));
118
+ const priced = ok.filter((m) => PRICE.test(text(m)));
119
+ if (ok.length >= 3 && priced.length >= ok.length * 0.6 && (!best || ok.length > best.members.length)) best = { parent, members: ok };
120
+ }
121
+ }
122
+ if (best) {
123
+ let tiles = best.members;
124
+ // Wrappers (grid columns) around the tile: down to the element that is the tile.
125
+ while (tiles.every((t) => t.children.length === 1 && sig(t.children[0]) === sig(tiles[0].children[0]))) tiles = tiles.map((t) => t.children[0]);
126
+ const t0 = tiles[0];
127
+ const common = classes(t0).filter((c) => tiles.every((t) => t.classList.contains(c)));
128
+ const tileStep = common.length ? `.${cssEsc(common[0])}` : t0.tagName.toLowerCase();
129
+ const list = docSel(best.parent);
130
+ const tileSel = `${list} ${tileStep}`;
131
+ // Their product ids: attributes whose value differs on every tile (the init matches them against the catalog).
132
+ const attrs = [...t0.attributes].map((a) => a.name).filter((n) => !/^(class|style|href|src|title|alt|role|tabindex|aria-.*)$/.test(n));
133
+ const distinct = attrs.filter((n) => {
134
+ const vs = tiles.map((t) => t.getAttribute(n));
135
+ return vs.every((v) => v && /^[\w:./-]{1,128}$/.test(v)) && new Set(vs).size === vs.length;
136
+ });
137
+ distinct.sort((a, b) => (/product|sku|code|item|id/.test(b) ? 1 : 0) - (/product|sku|code|item|id/.test(a) ? 1 : 0));
138
+ out.ids = { selector: tileSel, candidates: distinct.map((n) => ({ attr: n, values: tiles.slice(0, 60).map((t) => t.getAttribute(n)) })) };
139
+ out.links = tiles.slice(0, 60).map((t) => (t.matches('a[href]') ? t : t.querySelector('a[href]'))?.href ?? null);
140
+ // The parts, by role, over the first tiles: a part one tile lacks (an old price, a badge) another has.
141
+ const parts = {};
142
+ const lowParts = {};
143
+ // The tile's controls with an interaction (docs/PARITY.md, "Behaviour"), by what they are named or link to.
144
+ const interact = {};
145
+ const CONTROL_ROLES = [
146
+ ['compare', /compar|vergelijk|vergleich|porovn|porówn|confront/i],
147
+ ['wishlist', /wish|favou?rit|heart|merkzettel|merkliste|verlanglijst|oblíben|oblúben|ulubion/i],
148
+ ['addToCart', /cart|basket|\bbuy\b|winkelwagen|warenkorb|ko[sš][ií]k|koszyk|panier|carrello/i],
149
+ ];
150
+ /** The counter an action of that role changes, outside the listing (a header's compare count): the interaction's watch. */
151
+ const counterOf = (role) => {
152
+ const re = CONTROL_ROLES.find(([r]) => r === role)[1];
153
+ const el = [...document.querySelectorAll('body *')].find((e) => vis(e) && !best.parent.contains(e) && re.test(`${e.className} ${e.id}`) && /^\(?\d{1,4}\)?$/.test(text(e)));
154
+ return el ? docSel(el) : null;
155
+ };
156
+ for (const tl of tiles.slice(0, 12)) {
157
+ const pick = (name, el, why) => {
158
+ if (!el || el === tl || !tl.contains(el) || parts[name]) return;
159
+ parts[name] = relSel(el, tl);
160
+ if (why) lowParts[name] = why;
161
+ };
162
+ const within = [...tl.querySelectorAll('*')].filter(vis);
163
+ // The image: the largest, with its frame when the frame is only the image.
164
+ const img = [...tl.querySelectorAll('img')].filter(vis).sort((a, b) => area(b) - area(a))[0];
165
+ if (img) {
166
+ let frame = img;
167
+ for (let e = img.parentElement; e && e !== tl && area(e) <= area(img) * 1.15 && !text(e); e = e.parentElement) frame = e;
168
+ pick('image', frame);
169
+ }
170
+ // The price: the largest uncrossed price-like text; the old price: a crossed one.
171
+ const ps = prices(tl);
172
+ const fs = (e) => parseFloat(getComputedStyle(e).fontSize);
173
+ const now = ps.filter((e) => !crossed(e, tl)).sort((a, b) => fs(b) - fs(a));
174
+ pick('price', now[0], now.length > 1 && fs(now[0]) === fs(now[1]) ? 'two prices of one size: the first taken as the price' : null);
175
+ const was = ps.find((e) => crossed(e, tl));
176
+ if (was) pick('oldPrice', was);
177
+ else if (now.length > 1 && now[1] !== now[0]) pick('oldPrice', now[1], 'a second, smaller price taken as the old price (not crossed out)');
178
+ // The title: a heading, else an element named title/name, else the longest link text.
179
+ const titled = within.filter((e) => text(e).length >= 3 && text(e).length < 200 && !PRICE.test(text(e)) && !e.querySelector('img'));
180
+ const heading = titled.filter((e) => /^H[1-6]$/.test(e.tagName))[0];
181
+ const named = titled.filter((e) => /title|name|heading/i.test(String(e.className)) || e.getAttribute('itemprop') === 'name').sort((a, b) => text(b).length - text(a).length)[0];
182
+ const linked = titled.filter((e) => e.closest('a[href]') && ![...e.children].some((c) => text(c) === text(e))).sort((a, b) => text(b).length - text(a).length)[0];
183
+ if (heading) pick('title', heading);
184
+ else if (named) pick('title', named);
185
+ else if (linked) pick('title', linked, 'the longest link text (no heading, no element named title)');
186
+ const title = heading ?? named ?? linked ?? null;
187
+ const discount = within.find((e) => /^-\s?\d+\s?%$/.test(text(e)) && e.children.length <= 1);
188
+ if (discount) pick('discount', discount);
189
+ // The badge: a short label, by its name, else one laid over the image.
190
+ const short = within.filter((e) => text(e).length >= 1 && text(e).length <= 24 && !PRICE.test(text(e)) && !e.querySelector('img') && e !== discount && (!title || (!title.contains(e) && !e.contains(title))) && ![...e.children].some((c) => text(c) === text(e)));
191
+ const byName = short.find((e) => /badge|label|flag|ribbon|sticker|tag|promo/i.test(String(e.className)));
192
+ const over = img ? short.find((e) => {
193
+ const r = e.getBoundingClientRect();
194
+ const i = img.getBoundingClientRect();
195
+ return getComputedStyle(e).position === 'absolute' && r.left < i.right && r.right > i.left && r.top < i.bottom && r.bottom > i.top;
196
+ }) : null;
197
+ if (byName) pick('badge', byName);
198
+ else if (over) pick('badge', over, 'a short label laid over the image');
199
+ const rating = within.find((e) => /rating|stars|rated|review/i.test(String(e.className)) || /out of 5|stars/i.test(e.getAttribute('aria-label') ?? ''));
200
+ if (rating) pick('rating', rating);
201
+ const stock = within.find((e) => /stock|avail|delivery/i.test(String(e.className)) && text(e));
202
+ if (stock) pick('availability', stock, 'by its class');
203
+ const cart = within.find((e) => (e.tagName === 'BUTTON' || e.tagName === 'A') && /cart|basket|buy/i.test(`${e.className} ${e.getAttribute('aria-label') ?? ''} ${e.title}`));
204
+ if (cart) pick('addToCart', cart);
205
+ // Its other controls (a compare link, a wishlist heart, add to cart): parts with an interaction, each to confirm.
206
+ const main = (title ?? img)?.closest('a[href]') ?? null;
207
+ for (const c of within.filter((e) => e.matches('a[href], button, input[type=checkbox], [role=button], [role=checkbox]') && e !== main && !e.contains(img ?? null) && !(title && e.contains(title)))) {
208
+ const what = `${c.className} ${c.id} ${c.getAttribute('aria-label') ?? ''} ${c.getAttribute('title') ?? ''} ${c.getAttribute('href') ?? ''} ${text(c)}`;
209
+ const role = CONTROL_ROLES.find(([, re]) => re.test(what))?.[0];
210
+ if (!role || interact[role]) continue;
211
+ pick(role, c);
212
+ interact[role] = { action: c.matches('input[type=checkbox], [role=checkbox]') ? 'check' : 'click', ...(counterOf(role) ? { watch: counterOf(role) } : {}) };
213
+ }
214
+ }
215
+ out.components.tile = { theirs: tileSel, parts, keyCandidates: distinct, ...(Object.keys(interact).length ? { interact } : {}) };
216
+ if (Object.keys(lowParts).length) out.components.tile.low = Object.fromEntries(Object.entries(lowParts));
217
+ out.components.grid = { theirs: list, item: tileStep };
218
+ } else out.notes.push('tile: no repeated element with an image, a link and a price on this page');
219
+
220
+ // ---- facets: a container whose children are groups of options.
221
+ const tileRoot = best?.parent ?? null;
222
+ const hostOf = (o) => (o.tagName === 'INPUT' ? (o.closest('label') ?? (o.id ? document.querySelector(`label[for="${cssEsc(o.id)}"]`) : null) ?? o) : o);
223
+ const options = [
224
+ ...document.querySelectorAll('input[type=checkbox], input[type=radio], [role=checkbox], [role=menuitemcheckbox]'),
225
+ ...[...document.querySelectorAll('a[href], button, li')].filter((e) => /\(\s*\d[\d\s.,]*\)\s*$/.test(text(e)) && text(e).length < 80),
226
+ ]
227
+ .map(hostOf)
228
+ .filter((e, i, all) => e && all.indexOf(e) === i && vis(e) && !(tileRoot && tileRoot.contains(e)) && !e.closest('header, footer, [role=dialog]') && !legacy(e));
229
+ let container = null;
230
+ for (const o of options) {
231
+ for (let c = o.parentElement; c && c !== document.body; c = c.parentElement) {
232
+ const groups = [...c.children].filter((g) => vis(g) && options.filter((x) => g.contains(x)).length >= 2);
233
+ if (groups.length >= 2) {
234
+ if (!container || container.contains(c)) container = c;
235
+ break;
236
+ }
237
+ }
238
+ if (container) break;
239
+ }
240
+ if (container) {
241
+ const groups = [...container.children].filter((g) => vis(g) && options.filter((x) => g.contains(x)).length >= 2);
242
+ const g = groups[0];
243
+ const value = options.find((x) => g.contains(x));
244
+ const title = [...g.querySelectorAll('*')].find((e) => vis(e) && text(e) && text(e).length < 60 && !options.some((x) => e.contains(x) || x.contains(e)));
245
+ const parts = { facetGroup: relSel(g, container), facetValue: relSel(value, container) };
246
+ if (title) parts.facetTitle = relSel(title, container);
247
+ out.components.facets = { theirs: docSel(container), parts, interact: { facetValue: { action: value.matches('input[type=checkbox], [role=checkbox]') || value.querySelector('input[type=checkbox], [role=checkbox]') ? 'check' : 'click' } } };
248
+ if (!title) low('facets', null, 'no title found in the first group (parts.facetTitle)');
249
+ else if (!/^(H[1-6]|LEGEND|SUMMARY|DT|BUTTON|STRONG)$/.test(title.tagName)) out.components.facets.low = { facetTitle: `the first text of the group (a ${title.tagName.toLowerCase()}, not a heading)` };
250
+ } else out.notes.push('facets: no container of option groups (checkboxes, role=checkbox, links with counts)');
251
+
252
+ // ---- a range filter: a slider (input[type=range], role=slider, jQuery UI's, noUiSlider's) and its inputs, as parts of a part.
253
+ const SLIDER = '[data-lbx-part~="range-slider"], input[type=range], [role=slider], .ui-slider, .noUi-target, .irs, .rc-slider';
254
+ const slider = [...document.querySelectorAll(SLIDER)].find((e) => vis(e) && !legacy(e) && !(tileRoot && tileRoot.contains(e)) && !e.parentElement?.closest(SLIDER));
255
+ if (slider) {
256
+ const host = container && container.contains(slider) ? container : null;
257
+ // Its group: the container's child that holds it, else the nearest ancestor with inputs or a title beside it.
258
+ let g = host ? [...host.children].find((c) => c.contains(slider)) : null;
259
+ if (!g) for (let e = slider.parentElement; e && e !== document.body; e = e.parentElement) if (e.querySelector('input:not([type=range]):not([type=hidden]), h2, h3, h4, h5, legend, summary') && (g = e)) break;
260
+ if (g) {
261
+ const scope = host ?? null;
262
+ const sub = { slider: relSel(slider, g) };
263
+ const inputs = [...g.querySelectorAll('input:not([type=range]):not([type=hidden]):not([type=checkbox]):not([type=radio])')].filter((e) => vis(e) && !slider.contains(e));
264
+ if (inputs[0]) sub.min = relSel(inputs[0], g);
265
+ if (inputs[1]) sub.max = relSel(inputs[1], g);
266
+ const range = { theirs: scope ? relSel(g, scope) : docSel(g), parts: sub };
267
+ if (scope && out.components.facets) out.components.facets.parts.rangeFilter = range;
268
+ else out.components.rangeFilter = { theirs: range.theirs, parts: sub, role: 'facet-group' };
269
+ }
270
+ }
271
+
272
+ // ---- sort: a select of sort orders, else a menu named sort.
273
+ const SORT = /price|name|new|popular|relevan|rating|cheap|expens|best|sort|order|cen|preis|prix/i;
274
+ const select = [...document.querySelectorAll('select')].find((s) => vis(s) && !legacy(s) && [...s.options].filter((o) => SORT.test(o.text)).length >= 2);
275
+ const menu = [...document.querySelectorAll('[class*=sort i], [id*=sort i], [class*=order i]')].find((e) => vis(e) && !legacy(e) && e.querySelectorAll('a, button, li, option').length >= 2 && !(tileRoot && tileRoot.contains(e)));
276
+ const labelled = [...document.querySelectorAll('label, button, span, div')].find((e) => vis(e) && !legacy(e) && text(e).length < 40 && /^(sort|order by|sort by|řadit|zoradiť|sortieren|trier)/i.test(text(e)));
277
+ const sortEl = select ?? menu ?? labelled ?? null;
278
+ if (sortEl) {
279
+ out.components.sort = { theirs: docSel(sortEl), interact: { root: { action: select && sortEl === select ? 'select' : 'click' } } };
280
+ if (!select && !menu) low('sort', null, 'a control labelled "sort" (no select of sort orders, no element named sort)');
281
+ }
282
+ // ---- the count: the shortest text with a number of products.
283
+ const COUNT = /\d[\d\s.,]*\s+(products?|results?|items?|articles?|pieces?|produkt|výsled|položi|artikel|ergebnis)/i;
284
+ const count = [...document.querySelectorAll('body *')].filter((e) => vis(e) && !legacy(e) && text(e).length < 80 && (COUNT.test(text(e)) || /\d+\s*(of|from|z|von|de)\s*\d+/i.test(text(e))) && !(tileRoot && tileRoot.contains(e))).sort((a, b) => text(a).length - text(b).length)[0];
285
+ if (count) {
286
+ out.components.count = { theirs: docSel(count) };
287
+ if (!COUNT.test(text(count))) low('count', null, `"${text(count)}" has no word for products or results`);
288
+ }
289
+ // ---- pagination: a pager (page links) or a "show more".
290
+ const pager = [...document.querySelectorAll('nav, ul, ol, div')].find((e) => vis(e) && !legacy(e) && (/pagina|pager|pages/i.test(String(e.className)) || /pag/i.test(e.getAttribute('aria-label') ?? '')) && !(tileRoot && tileRoot.contains(e)));
291
+ const numbered = pager ? null : [...document.querySelectorAll('nav, ul, ol, div')].find((e) => vis(e) && [...e.querySelectorAll(':scope > a, :scope > li > a')].filter((a) => /^\d+$/.test(text(a))).length >= 2);
292
+ const more = [...document.querySelectorAll('a, button')].find((e) => vis(e) && !legacy(e) && /show more|load more|more products|next page|další|viac|mehr|weitere/i.test(text(e)));
293
+ const pg = pager ?? numbered ?? more ?? null;
294
+ if (pg) {
295
+ out.components.pagination = { theirs: docSel(pg) };
296
+ if (!pager) low('pagination', null, numbered ? 'a group of numbered links' : 'a "show more" button');
297
+ }
298
+ if (arg.empty) {
299
+ const e = [...document.querySelectorAll('h1, h2, h3, h4, h5, p, div')].filter((x) => vis(x) && !legacy(x) && /no results|couldn.t find|nothing found|sorry|no products|0 results|nenašl|nichts gefunden|aucun/i.test(text(x)) && text(x).length < 400).sort((a, b) => text(a).length - text(b).length)[0];
300
+ if (e) out.components.empty = { theirs: docSel(e) };
301
+ }
302
+ return out;
303
+ }
304
+
305
+ /** In the page, before typing: what is visible now (the autocomplete is what appears after). */
306
+ export function markVisible() {
307
+ const s = (window.__parityBefore = new WeakSet());
308
+ for (const el of document.querySelectorAll('body *')) {
309
+ const r = el.getBoundingClientRect();
310
+ if (r.width > 0 && r.height > 0) s.add(el);
311
+ }
312
+ }
313
+
314
+ /**
315
+ * In the page, after typing: the panel that appeared, its rows (repeated children with a link), a section and its title.
316
+ * Never a previous-generation Luigi's Box dropdown or a vendor's, nor what holds one (`legacy`: map.mjs LEGACY_MARKUP).
317
+ * @param {{ legacy?: string[] }} [arg]
318
+ */
319
+ export function proposeAutocomplete(arg = {}) {
320
+ const before = window.__parityBefore ?? new WeakSet();
321
+ const legacy = (el) =>
322
+ (arg.legacy ?? []).some((s) => {
323
+ try {
324
+ return !!el.closest(s) || !!el.querySelector(s);
325
+ } catch {
326
+ return false;
327
+ }
328
+ });
329
+ const UTIL = /\d|^([a-z]+-)?(d|m[trblxy]?|p[trblxy]?|w|h|col|row|flex|align|justify|text|is|has|js)(-|$)/;
330
+ const classes = (el) => [...el.classList].filter((c) => !UTIL.test(c));
331
+ const vis = (el) => {
332
+ const r = el.getBoundingClientRect();
333
+ return r.width > 0 && r.height > 0;
334
+ };
335
+ const fresh = [...document.querySelectorAll('body *')].filter((e) => vis(e) && !before.has(e) && !legacy(e) && e.querySelectorAll('a[href], [role=option]').length >= 2);
336
+ const area = (e) => e.getBoundingClientRect().width * e.getBoundingClientRect().height;
337
+ const panel = fresh.filter((e) => !fresh.some((o) => o !== e && o.contains(e) && area(o) <= area(e) * 1.05)).sort((a, b) => area(b) - area(a))[0];
338
+ if (!panel) return null;
339
+ const sel = (el) => (el.id && !/\d{3,}/.test(el.id) ? `#${CSS.escape(el.id)}` : `${el.tagName.toLowerCase()}${classes(el).slice(0, 2).map((c) => `.${CSS.escape(c)}`).join('')}`);
340
+ const rows = [...panel.querySelectorAll('a[href], [role=option]')].filter(vis);
341
+ const rowSig = new Map();
342
+ for (const r of rows) {
343
+ const k = sel(r);
344
+ rowSig.set(k, (rowSig.get(k) ?? 0) + 1);
345
+ }
346
+ const row = [...rowSig.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
347
+ const head = [...panel.querySelectorAll('h1, h2, h3, h4, h5, h6, [class*=title], [class*=head]')].find((e) => vis(e) && e.textContent.trim() && !e.closest('a'));
348
+ // A section: the title's nearest ancestor that holds rows too.
349
+ let section = null;
350
+ for (let e = head?.parentElement; e && e !== panel; e = e.parentElement) if (e.querySelector('a[href], [role=option]')) { section = e; break; }
351
+ return { panel: sel(panel), row, title: head ? sel(head) : null, section: section ? sel(section) : null };
352
+ }
353
+
354
+ /** In the page (ours): the first of the candidate selectors that finds a visible element (inside `within` when given), open shadow roots seen into. */
355
+ export function firstMatch(arg) {
356
+ const D = window.__parityDeep;
357
+ const qa = (scope, sel) => {
358
+ if (D) return D.all(scope, sel);
359
+ try {
360
+ return [...scope.querySelectorAll(sel)];
361
+ } catch {
362
+ return [];
363
+ }
364
+ };
365
+ const vis = (el) => {
366
+ const r = el.getBoundingClientRect();
367
+ return r.width > 0 && r.height > 0;
368
+ };
369
+ for (const c of arg.candidates) {
370
+ const scope = arg.within ? qa(document, arg.within).find(vis) : document;
371
+ if (!scope) return null;
372
+ if (qa(scope, c).find(vis)) return c;
373
+ }
374
+ return null;
375
+ }
376
+
377
+ /** In the page (ours): whether the first visible element of `sel` has attribute `attr`. */
378
+ export function hasAttr(arg) {
379
+ const D = window.__parityDeep;
380
+ const el = (D ? D.all(document, arg.sel) : [...document.querySelectorAll(arg.sel)]).find((e) => e.getBoundingClientRect().width > 0);
381
+ return !!el?.hasAttribute(arg.attr);
382
+ }
383
+
384
+ /** The last compound of a selector (`.list > .tile` -> `.tile`): where a 1:1 port has the same element under our root. */
385
+ const lastStep = (s) => s.trim().split(/\s+(?:>\s*)?/).pop();
386
+ const LISTING = ':is(lbx-search, lbx-plp)';
387
+ const OVERLAY = '[data-lbx="ac-overlay"]';
388
+ /** Our marker for a role, scoped to the listing or the autocomplete overlay. */
389
+ const ourMarker = (role) => `${/^ac-/.test(role) ? OVERLAY : LISTING} ${partMarker(role)}`;
390
+
391
+ /** Path of a URL (decoded, no trailing slash), for pairing by link. */
392
+ const pathOf = (v) => {
393
+ try {
394
+ let p = new URL(String(v), 'https://x.invalid/').pathname;
395
+ try {
396
+ p = decodeURI(p);
397
+ } catch {}
398
+ return p.length > 1 ? p.replace(/\/+$/, '') : p;
399
+ } catch {
400
+ return '';
401
+ }
402
+ };
403
+
404
+ /**
405
+ * Their product ids against the catalog records our page received: the
406
+ * attribute and the catalog field that hold the same values (`field`
407
+ * pairing), else their links against the records' URLs (`url`), else null.
408
+ * @param {{ candidates: { attr: string, values: string[] }[] }} ids @param {(string | null)[]} links @param {any[]} hits
409
+ */
410
+ export const pairingOf = (ids, links, hits) => {
411
+ const first = (v) => (Array.isArray(v) ? v[0] : v);
412
+ let best = null;
413
+ for (const c of ids.candidates) {
414
+ const vs = new Set(c.values.map(String));
415
+ const fields = new Map();
416
+ for (const h of hits)
417
+ for (const [k, v] of Object.entries(h ?? {})) {
418
+ const x = first(v);
419
+ if ((typeof x === 'string' || typeof x === 'number') && vs.has(String(x))) fields.set(k, (fields.get(k) ?? 0) + 1);
420
+ }
421
+ for (const [field, n] of fields) if (!best || n > best.n || (n === best.n && field === '@id')) best = { pair: 'field', attr: c.attr, field, n };
422
+ }
423
+ if (best) return best;
424
+ const paths = new Set(links.filter(Boolean).map(pathOf));
425
+ let n = 0;
426
+ for (const h of hits) if (paths.has(pathOf(first(h?.url) ?? first(h?.web_url) ?? ''))) n++;
427
+ return n ? { pair: 'url', attr: 'href', field: 'web_url', n } : null;
428
+ };
429
+
430
+ /**
431
+ * The pages `--init` drafts: the given paths, else the fixtures' category page,
432
+ * a search, an empty search and the autocomplete. With `theirPlp` (their own
433
+ * search is disabled: empty, or a previous Luigi's Box generation not to
434
+ * copy) the search page is ours against that category listing of theirs,
435
+ * and their empty search is not drafted (it is no design of theirs).
436
+ * @param {{ paths?: string[], category?: string | null, query?: string | null, theirPlp?: string | null, ourSearch?: string | null }} o
437
+ * @returns {Record<string, string | { theirs: string, ours: string }>}
438
+ */
439
+ export const initPages = ({ paths = [], category = null, query = null, theirPlp = null, ourSearch = null }) => {
440
+ const search = ourSearch ?? `/search?q=${encodeURIComponent(query ?? 'a')}`;
441
+ const out = paths.length
442
+ ? Object.fromEntries(paths.map((p, i) => [`page${i + 1}`, p]))
443
+ : theirPlp
444
+ ? { category: category ?? theirPlp, autocomplete: '/' }
445
+ : { category, search: query ? `/search?q=${encodeURIComponent(query)}` : null, empty: '/search?q=zzqxvwj', autocomplete: '/' };
446
+ if (theirPlp) out.search = { theirs: theirPlp, ours: search };
447
+ return Object.fromEntries(Object.entries(out).filter(([, p]) => p));
448
+ };
449
+
450
+ /**
451
+ * Loads their pages and ours, proposes the map. A page is a path (both
452
+ * sides), or `{ theirs, ours }`: ours drafted against another page of theirs
453
+ * (our search against their category listing, `--their-plp`).
454
+ * `block`: script URLs (regular expressions' sources) blocked on both sides: every Luigi's Box script by
455
+ * default (map.mjs BLOCK_LUIGIS), and the vendors' `--block` adds.
456
+ * @param {{ driver: any, host: string, pages: Record<string, string | { theirs: string, ours: string }>, query: string, input: string | null, log: (s: string) => void, block?: string[] }} o
457
+ */
458
+ export const initMap = async ({ driver, host, pages, query, input, log, block = BLOCK_LUIGIS }) => {
459
+ const blocked = block.map((x) => new RegExp(x));
460
+ const draft = {
461
+ draft: true,
462
+ notes: ['A draft from `yarn luma parity --init`: confirm every pick in "confirm" on both sides (the HTML report shows the crops), then remove "draft", "confirm" and "notes". docs/PARITY.md.'],
463
+ confirm: /** @type {string[]} */ ([]),
464
+ host,
465
+ viewports: [390, 1024, 1440],
466
+ hide: [],
467
+ products: /** @type {any} */ ({ type: 'product' }),
468
+ pages: /** @type {Record<string, any>} */ ({}),
469
+ components: /** @type {Record<string, any>} */ ({}),
470
+ };
471
+ const confirm = (s) => {
472
+ if (!draft.confirm.includes(s)) draft.confirm.push(s);
473
+ };
474
+ const open = async (side, path, onHits, o = {}) => {
475
+ await driver.wait?.(side);
476
+ const page = await driver.page(side, { width: 1440, device: {} });
477
+ // A previous Luigi's Box generation's scripts and the vendors': their widgets are never drafted as theirs.
478
+ if (blocked.length) await page.route((u) => blocked.some((re) => re.test(u.href)), (r) => (r.request().resourceType() === 'script' ? r.abort('blockedbyclient') : r.fallback()));
479
+ await page.setViewportSize({ width: 1440, height: 900 });
480
+ await page.addInitScript(deepInit);
481
+ if (o.align) await page.addInitScript(alignInit, o.align);
482
+ if (onHits)
483
+ page.on('response', async (res) => {
484
+ const m = /\/discovery\/v1\/(search|collections)\?/.exec(res.url());
485
+ if (!m) return;
486
+ try {
487
+ const body = JSON.parse(res.request().postData() ?? '{}');
488
+ const j = await res.json();
489
+ onHits({ type: body.type ?? null, surface: new URL(res.url()).searchParams.get('surface_id'), endpoint: m[1], hits: j.hits ?? [] });
490
+ } catch {}
491
+ });
492
+ await page.goto(new URL(path, driver.origin).href, { waitUntil: 'load', timeout: 60000 });
493
+ await page.waitForTimeout(2500);
494
+ await page.evaluate(stabilize, { roots: [], hide: [] });
495
+ return page;
496
+ };
497
+ /** A component of the draft: merged into one of the same selector on another page, else its own (named after the page). */
498
+ const add = (cn, name, comp) => {
499
+ const same = draft.components[cn];
500
+ if (same && JSON.stringify({ ...same, pages: [] }) === JSON.stringify({ ...comp, pages: [] })) {
501
+ same.pages.push(name);
502
+ return cn;
503
+ }
504
+ const key = same ? `${cn}${name[0].toUpperCase()}${name.slice(1)}` : cn;
505
+ draft.components[key] = same ? { role: kebab(cn), ...comp } : comp;
506
+ return key;
507
+ };
508
+ let pairing = null;
509
+ /** Per page: their ids, links, heading and titles, our path: the pairing's check (below). */
510
+ const proposals = {};
511
+ /** Pages compared against another page of theirs whose ids no record of our page matched: paired at the end, by the pairing found. */
512
+ const pending = [];
513
+ for (const [name, at] of Object.entries(pages)) {
514
+ const path = typeof at === 'string' ? at : at.theirs;
515
+ const oursPath = typeof at === 'string' ? at : at.ours;
516
+ const cross = path !== oursPath;
517
+ log(`--init: ${name} ${cross ? `ours ${oursPath} against theirs ${path}` : path}`);
518
+ if (name === 'autocomplete') {
519
+ const t = await open('theirs', path);
520
+ const inSel =
521
+ input ??
522
+ (await t.evaluate(() => {
523
+ const i = [...document.querySelectorAll('input[type=search], input[name=q], input[name=query], input[type=text]')].find((e) => e.getBoundingClientRect().width > 0);
524
+ return i ? (i.id ? `input#${CSS.escape(i.id)}` : `input[name="${i.name}"]`) : null;
525
+ }));
526
+ if (!inSel) {
527
+ draft.notes.push('autocomplete: no search input found');
528
+ await t.close();
529
+ continue;
530
+ }
531
+ await t.evaluate(markVisible);
532
+ const loc = t.locator(inSel).first();
533
+ await loc.focus();
534
+ await loc.fill('');
535
+ await loc.pressSequentially(query, { delay: 60 });
536
+ await t.waitForTimeout(2500);
537
+ const ac = await t.evaluate(proposeAutocomplete, { legacy: [...LEGACY_MARKUP] });
538
+ await t.close();
539
+ if (!ac) {
540
+ draft.notes.push('autocomplete: no panel appeared after typing (their dropdown may need a click, or a longer wait)');
541
+ continue;
542
+ }
543
+ const o = await open('ours', path);
544
+ const oi = o.locator(inSel).first();
545
+ await oi.focus();
546
+ await oi.fill('');
547
+ await oi.pressSequentially(query, { delay: 60 });
548
+ await o.waitForTimeout(2500);
549
+ const ours = {};
550
+ for (const role of ['ac-panel', 'ac-section', 'ac-row']) ours[role] = await o.evaluate(firstMatch, { candidates: [ourMarker(role)] });
551
+ await o.close();
552
+ draft.pages.autocomplete = { path, type: { input: inSel, text: query }, ready: { theirs: ac.panel, ours: ours['ac-panel'] ?? OVERLAY } };
553
+ draft.components.acPanel = { pages: ['autocomplete'], theirs: ac.panel, ours: ourMarker('ac-panel') };
554
+ if (ac.section) draft.components.acSection = { pages: ['autocomplete'], theirs: `${ac.panel} ${ac.section}`, ours: ourMarker('ac-section'), ...(ac.title ? { parts: { acTitle: { theirs: ac.title } } } : {}) };
555
+ if (ac.row) draft.components.acRow = { pages: ['autocomplete'], each: { sample: 4 }, theirs: `${ac.panel} ${ac.row}`, ours: ourMarker('ac-row'), text: 'exact' };
556
+ confirm('components.acPanel, acSection, acRow: guessed from what appeared after typing; ids for the rows (pages.autocomplete.ids) are not proposed');
557
+ for (const role of ['ac-panel', 'ac-section', 'ac-row']) if (!ours[role]) confirm(`components.${role === 'ac-panel' ? 'acPanel' : role === 'ac-section' ? 'acSection' : 'acRow'}.ours: ${ourMarker(role)} not found on our page`);
558
+ continue;
559
+ }
560
+ const t = await open('theirs', path);
561
+ const p = await t.evaluate(proposeTheirs, { empty: name === 'empty', legacy: [...LEGACY_MARKUP] });
562
+ // Their category's name and their titles: the queries their products are asked for with (pairing.mjs).
563
+ const hints = p.ids ? await t.evaluate(theirHints, { selector: p.ids.selector }) : null;
564
+ proposals[name] = { ids: p.ids, links: p.links ?? [], hints, oursPath, cross };
565
+ await t.close();
566
+ draft.notes.push(...p.notes.map((n) => `${name}: ${n}`));
567
+ draft.pages[name] = cross ? { theirs: { path }, ours: { path: oursPath } } : { path };
568
+ // Their ids: on their side of the page (another page of theirs: "theirs": { "path", "ids" }).
569
+ const idsAt = cross ? draft.pages[name].theirs : draft.pages[name];
570
+ const received = [];
571
+ const o = await open('ours', oursPath, (r) => received.push(r));
572
+ // Their products against the catalog records our page received: the pairing, by evidence.
573
+ if (p.ids) {
574
+ // The request whose records their products are (not the category lookup's, not a recommender's).
575
+ let found = null;
576
+ let req = null;
577
+ for (const r of received) {
578
+ const f = pairingOf(p.ids, p.links ?? [], r.hits);
579
+ if (f && (!found || f.n > found.n)) [found, req] = [f, r];
580
+ }
581
+ if (req?.type) draft.products.type = req.type;
582
+ if (req?.surface && req.endpoint === 'collections') draft.products.surface ??= req.surface;
583
+ if (found && (!pairing || found.n > pairing.n)) pairing = found;
584
+ idsAt.ids = found?.pair === 'url' ? { selector: p.ids.selector, child: 'a[href]' } : { selector: p.ids.selector, ...(found ? { attr: found.attr } : p.ids.candidates[0] ? { attr: p.ids.candidates[0].attr } : { child: 'a[href]', attr: 'href' }) };
585
+ // Our search page received our query's records, not their category's: the pairing another page finds is this one's too.
586
+ if (!found && cross) pending.push({ name, ids: p.ids });
587
+ else if (!found) confirm(`pages.${name}.ids: none of their tiles' ids (${p.ids.candidates.map((c) => c.attr).join(', ') || 'no id attribute'}) nor links matched a catalog record our page received: set products.attr and products.field (the catalog field with their values), or products.pair "url"`);
588
+ draft.pages[name].ready = { theirs: p.ids.selector, ours: ourMarker('tile') };
589
+ } else if (cross) {
590
+ // No tiles on their page: nothing to pair (the map then needs "ids", or "ids": false to compare by structure).
591
+ idsAt.ids = false;
592
+ confirm(`pages.${name}.theirs.ids: no product tiles found on their ${path}: point "ids" at their tiles, or keep "ids": false (compared by structure only)`);
593
+ }
594
+ if (cross) draft.notes.push(`${name}: our ${oursPath} is drafted against their ${path} (their own search disabled: their category listing is the design source; docs/PARITY.md)`);
595
+ for (const [cn, c] of Object.entries(p.components)) {
596
+ const role = cn === 'grid' ? 'list' : kebab(cn);
597
+ const marker = ourMarker(role);
598
+ const port = `${LISTING} ${lastStep(c.theirs)}`;
599
+ const oursRoot = await o.evaluate(firstMatch, { candidates: [marker, port] });
600
+ const comp = { pages: [name], theirs: c.theirs, ours: oursRoot ?? marker };
601
+ const lows = [];
602
+ if (!oursRoot) lows.push(`ours: ${marker} not on our page (put data-lbx-part="${role}" on the integration's element)`);
603
+ else if (oursRoot === port) lows.push(`ours: by their selector (${port}), no data-lbx-part="${role}" marker`);
604
+ if (c.low?.['.']) lows.push(`theirs: ${c.low['.']}`);
605
+ if (cn === 'tile') {
606
+ const key = c.keyCandidates.find((k) => k === pairing?.attr) ?? c.keyCandidates[0];
607
+ const oursHas = key && oursRoot ? await o.evaluate(hasAttr, { sel: oursRoot, attr: key }) : false;
608
+ // Keyed when both sides carry it; else by position (our listing shows their products in their order).
609
+ comp.each = { ...(oursHas ? { key } : {}), sample: 4 };
610
+ }
611
+ if (cn === 'grid') comp.grid = { item: { theirs: c.item, ours: partMarker('tile') } };
612
+ if (c.parts) {
613
+ comp.parts = {};
614
+ for (const [pn, spec] of Object.entries(c.parts)) {
615
+ // A part with parts of its own (a range filter's slider and inputs): { theirs, parts }.
616
+ const rel = typeof spec === 'string' ? spec : spec.theirs;
617
+ const pm = partMarker(pn);
618
+ const ranged = typeof spec === 'object' && Object.keys(spec.parts ?? {}).some((k) => k in RANGE_PARTS);
619
+ const ours = oursRoot ? await o.evaluate(firstMatch, { candidates: ranged ? [pm, RANGE_GROUP, rel] : [pm, rel], within: oursRoot }) : null;
620
+ // The marker is ours by default: only their selector is written then.
621
+ comp.parts[pn] = ours === pm || ours === RANGE_GROUP || !ours ? { theirs: rel } : rel;
622
+ if (!ours) lows.push(`parts.${pn}: not found in ours (${pm}, or ${rel})`);
623
+ else if (ours === rel && rel !== pm) lows.push(`parts.${pn}: ours by their selector, no ${pm} marker`);
624
+ if (c.low?.[pn]) lows.push(`parts.${pn}: theirs ${rel} is ${c.low[pn]}`);
625
+ const sub = typeof spec === 'object' ? spec.parts : null;
626
+ const ia = c.interact?.[pn] ?? null;
627
+ if (!sub && !ia) continue;
628
+ const at = typeof comp.parts[pn] === 'string' ? { theirs: rel, ours: rel } : comp.parts[pn];
629
+ if (sub) {
630
+ at.parts = {};
631
+ const inOurs = oursRoot && ours ? `${oursRoot} ${ours}` : null;
632
+ for (const [sn, srel] of Object.entries(sub)) {
633
+ const sm = partMarker(RANGE_PARTS[sn] ?? sn);
634
+ const found = inOurs ? await o.evaluate(firstMatch, { candidates: [sm, srel], within: inOurs }) : null;
635
+ at.parts[sn] = found && found !== sm ? srel : { theirs: srel };
636
+ if (!found) lows.push(`parts.${pn}.parts.${sn}: their ${sn} (${srel}) is not in ours (${sm}): a run finds it missing, CRITICAL, until our template renders it`);
637
+ }
638
+ }
639
+ if (ia) {
640
+ at.interact = ia;
641
+ lows.push(`parts.${pn}.interact: a real ${ia.action} on their live site: confirm the element, "watch" (the counter or state it changes${ia.watch ? `; drafted: ${ia.watch}` : ''}) and "safe" (a POST to a cart, a compare list or a wishlist is recorded, never sent, unless "safe": true)`);
642
+ }
643
+ comp.parts[pn] = at;
644
+ }
645
+ }
646
+ if (c.interact?.root) {
647
+ comp.interact = c.interact.root;
648
+ lows.push(`interact: a real ${c.interact.root.action} on their live site: confirm it, and "watch" what it changes`);
649
+ }
650
+ if (['count', 'sort', 'pagination', 'empty', 'facets'].includes(cn)) comp.text = 'shape';
651
+ const key = add(cn, name, comp);
652
+ for (const l of lows) confirm(`components.${key}.${l}`);
653
+ }
654
+ await o.close();
655
+ }
656
+ for (const { name, ids } of pending) {
657
+ const at = draft.pages[name].theirs;
658
+ const c = pairing?.pair === 'field' ? ids.candidates.find((x) => x.attr === pairing.attr) : null;
659
+ if (pairing?.pair === 'url') at.ids = { selector: ids.selector, child: 'a[href]' };
660
+ else if (c) at.ids = { selector: ids.selector, attr: c.attr };
661
+ else confirm(`pages.${name}.theirs.ids: their tiles' ids (${ids.candidates.map((x) => x.attr).join(', ') || 'no id attribute'}) matched no catalog record (our ${draft.pages[name].ours.path} shows our query's products, not theirs; another page, e.g. their category page on both sides, gives the evidence): set products.attr and products.field, or products.pair "url"`);
662
+ }
663
+ if (pairing) Object.assign(draft.products, pairing.pair === 'url' ? { pair: 'url' } : { attr: pairing.attr, field: pairing.field });
664
+ else if (Object.values(draft.pages).some((pg) => pg.ids || pg.theirs?.ids)) Object.assign(draft.products, { field: '@id' });
665
+ // The pairing checked as a run makes it: their products by id, with the queries a run asks with (pairing.mjs).
666
+ for (const [name, pr] of Object.entries(proposals)) {
667
+ const at = pr.cross ? draft.pages[name]?.theirs : draft.pages[name];
668
+ if (!pr.ids || !at?.ids) continue;
669
+ const byUrl = draft.products.pair === 'url';
670
+ const attr = at.ids.attr ?? draft.products.attr;
671
+ const ids = (byUrl ? pr.links.filter(Boolean) : (pr.ids.candidates.find((c) => c.attr === attr)?.values ?? [])).slice(0, 60);
672
+ const field = byUrl ? (draft.products.field ?? 'web_url') : draft.products.field;
673
+ if (!ids.length || !field) continue;
674
+ const queries = queryCandidates({ oursPath: pr.oursPath, cross: pr.cross, heading: pr.hints?.heading ?? null, titles: pr.hints?.titles ?? [] });
675
+ const o = await open('ours', pr.oursPath, null, { align: { ids, field, type: draft.products.type, surface: draft.products.surface, pair: byUrl ? 'url' : 'field', queries } });
676
+ const al = await o.evaluate(() => window.__parity?.aligned ?? []);
677
+ await o.close();
678
+ const failed = pairingFailure(ids, al, { selector: pr.ids.selector, type: draft.products.type });
679
+ if (failed) {
680
+ confirm(`pages.${name}: pairing failed: ${failed.why}`);
681
+ continue;
682
+ }
683
+ const best = al.reduce((a, b) => (b.found > a.found ? b : a));
684
+ // A query that found them and that the page does not give itself (our path's q): drafted as the page's, asked first.
685
+ const own = queryOfPath(pr.oursPath).toLowerCase();
686
+ const need = (best.queries ?? []).filter((q) => q.found > 0 && q.query && q.query.toLowerCase() !== own).map((q) => q.query);
687
+ if (need.length) draft.pages[name].query = need.length === 1 ? need[0] : need;
688
+ if (best.found < ids.length) draft.notes.push(`${name}: ${ids.length - best.found} of their ${ids.length} products not found by the API (asked with ${formatQueries(best.queries)}): not in the catalog, or another query finds them (pages.${name}.query)`);
689
+ }
690
+ for (const pg of Object.values(draft.pages)) for (const at of [pg, pg.theirs]) if (at?.ids?.attr && at.ids.attr === draft.products.attr) delete at.ids.attr;
691
+ // The mandatory components their pages did not show: map them, or exclude them with a reason.
692
+ const have = new Set();
693
+ for (const [cn, c] of Object.entries(draft.components)) {
694
+ const r = kebab(c.role ?? cn);
695
+ have.add(r);
696
+ if (r === 'facets') for (const pn of Object.keys(c.parts ?? {})) have.add(kebab(pn));
697
+ if (r === 'tile') for (const pn of Object.keys(c.parts ?? {})) have.add(`tile.${kebab(pn)}`);
698
+ }
699
+ const missing = MANDATORY.filter((m) => !have.has(m));
700
+ if (missing.length) confirm(`not found on their pages: ${missing.join(', ')}. Map each (another page, a state), or list it in "excluded" with a reason`);
701
+ draft.notes.push(`Part names Luma's built-in templates mark (data-lbx-part): ${PARTS.join(', ')}.`);
702
+ return draft;
703
+ };