@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.
- package/LICENSE.md +90 -0
- package/README.md +615 -2
- package/a11y/audit.mjs +735 -0
- package/a11y/axe.mjs +73 -0
- package/a11y/report.mjs +347 -0
- package/a11y/run.mjs +256 -0
- package/a11y/wcag.mjs +233 -0
- package/bin/config.mjs +60 -0
- package/bin/luma.mjs +99 -0
- package/bin/paths.mjs +11 -0
- package/bin/tools.mjs +43 -0
- package/build/baseline.d.mts +8 -0
- package/build/baseline.mjs +167 -0
- package/build/tooling.mjs +102 -0
- package/build/vite.mjs +507 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +150 -0
- package/docs/ACCESSIBILITY.md +501 -0
- package/docs/INTEGRATION-PERFORMANCE.md +213 -0
- package/docs/PARITY.md +946 -0
- package/footprint/model.mjs +184 -0
- package/footprint/run.mjs +331 -0
- package/package.json +58 -4
- package/parity/alignment.mjs +109 -0
- package/parity/archive.mjs +223 -0
- package/parity/args.mjs +137 -0
- package/parity/behaviour.mjs +379 -0
- package/parity/compare.mjs +985 -0
- package/parity/engine.mjs +848 -0
- package/parity/hints.mjs +132 -0
- package/parity/hold.mjs +54 -0
- package/parity/init.mjs +703 -0
- package/parity/map.mjs +640 -0
- package/parity/owner.mjs +170 -0
- package/parity/pairing.mjs +110 -0
- package/parity/pixels.mjs +33 -0
- package/parity/probe.mjs +1239 -0
- package/parity/report.mjs +236 -0
- package/parity/run.mjs +267 -0
- package/snippet/early-hold.js +64 -0
- package/snippet/early-hold.min.js +1 -0
- package/src/analytics/chain.ts +137 -0
- package/src/analytics/collect.ts +64 -0
- package/src/analytics/datalayer.ts +67 -0
- package/src/analytics/from-ga4.ts +68 -0
- package/src/analytics/from-luma.ts +21 -0
- package/src/analytics/index.ts +24 -0
- package/src/analytics/outbox.ts +118 -0
- package/src/analytics/page.ts +46 -0
- package/src/analytics/pageview.ts +82 -0
- package/src/analytics/ref.ts +19 -0
- package/src/analytics/setup.ts +41 -0
- package/src/analytics/start.ts +53 -0
- package/src/analytics/triggers.ts +239 -0
- package/src/analytics/user.ts +12 -0
- package/src/analytics/wire.ts +67 -0
- package/src/api/ai.ts +98 -0
- package/src/api/index.ts +87 -0
- package/src/api/token-key.ts +42 -0
- package/src/api/token.ts +312 -0
- package/src/core/a11y-keys.ts +60 -0
- package/src/core/a11y.ts +160 -0
- package/src/core/boot-state.ts +6 -0
- package/src/core/color.ts +42 -0
- package/src/core/context.ts +108 -0
- package/src/core/early-rules.ts +96 -0
- package/src/core/entries.ts +25 -0
- package/src/core/fold.ts +7 -0
- package/src/core/health.ts +26 -0
- package/src/core/hold.ts +74 -0
- package/src/core/i18n.ts +66 -0
- package/src/core/image-load.ts +102 -0
- package/src/core/image-tags.ts +26 -0
- package/src/core/images.ts +170 -0
- package/src/core/integration.ts +126 -0
- package/src/core/kept.ts +25 -0
- package/src/core/listings.ts +100 -0
- package/src/core/locales.ts +14 -0
- package/src/core/own.ts +49 -0
- package/src/core/page.ts +14 -0
- package/src/core/prefetch.ts +88 -0
- package/src/core/recommenders.ts +166 -0
- package/src/core/reconciler.ts +397 -0
- package/src/core/result.ts +73 -0
- package/src/core/routes.ts +115 -0
- package/src/core/scheduler.ts +83 -0
- package/src/core/sense.ts +107 -0
- package/src/core/speculation.ts +59 -0
- package/src/core/stash.ts +38 -0
- package/src/core/storage.ts +45 -0
- package/src/core/styles.ts +106 -0
- package/src/core/support.ts +12 -0
- package/src/core/takeover.ts +220 -0
- package/src/core/templates.ts +74 -0
- package/src/core/tiles.ts +137 -0
- package/src/core/turbo.ts +64 -0
- package/src/core/url.ts +164 -0
- package/src/core/widget-inits.ts +40 -0
- package/src/env.d.ts +3 -0
- package/src/lit.ts +11 -0
- package/src/locales/cs.json +94 -0
- package/src/locales/de.json +88 -0
- package/src/locales/en.json +88 -0
- package/src/main.ts +493 -0
- package/src/types.ts +778 -0
- package/src/widgets/autocomplete/index.ts +1387 -0
- package/src/widgets/autocomplete/style-autocomplete.css +266 -0
- package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
- package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
- package/src/widgets/listing/element.ts +1772 -0
- package/src/widgets/listing/flow.ts +30 -0
- package/src/widgets/listing/instant.ts +133 -0
- package/src/widgets/listing/intent.ts +193 -0
- package/src/widgets/listing/query.ts +298 -0
- package/src/widgets/listing/stash.ts +97 -0
- package/src/widgets/listing/state.ts +131 -0
- package/src/widgets/listing/view.ts +257 -0
- package/src/widgets/plp/index.ts +11 -0
- package/src/widgets/plp/route.ts +263 -0
- package/src/widgets/recommender/index.ts +261 -0
- package/src/widgets/recommender/style-recommender.css +26 -0
- package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
- package/src/widgets/recommender/template-recommender-popup.ts +12 -0
- package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
- package/src/widgets/search/assets.ts +7 -0
- package/src/widgets/search/first.ts +15 -0
- package/src/widgets/search/form-url.ts +59 -0
- package/src/widgets/search/in-place.ts +226 -0
- package/src/widgets/search/index.ts +16 -0
- package/src/widgets/search/range.ts +260 -0
- package/src/widgets/search/route.ts +32 -0
- package/src/widgets/search/speculate.ts +202 -0
- package/src/widgets/search/style-search.css +65 -0
- package/src/widgets/search/template-search-box.ts +17 -0
- package/src/widgets/search/template-search-facets-default.ts +73 -0
- package/src/widgets/search/template-search-header.ts +14 -0
- package/src/widgets/search/template-search-pagination.ts +12 -0
- package/src/widgets/search/template-search-range-default.ts +48 -0
- package/src/widgets/search/template-search-result-default.ts +13 -0
- package/src/widgets/search/template-search-results-default.ts +8 -0
- package/tasks/model.mjs +116 -0
- package/tasks/run.mjs +305 -0
- package/tsconfig.json +14 -0
package/parity/probe.mjs
ADDED
|
@@ -0,0 +1,1239 @@
|
|
|
1
|
+
// The in-page side of `yarn luma parity`: functions handed to page.evaluate /
|
|
2
|
+
// addInitScript (each one self-contained: Playwright sends its source, not
|
|
3
|
+
// its closure). Reading values follows the visual-review method: computed
|
|
4
|
+
// styles and boxes from the live DOM, every value whole, never a value read
|
|
5
|
+
// from a screenshot.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Before any script of the page (addInitScript): Math.random from a fixed
|
|
9
|
+
* seed (a carousel's random start, a shuffled banner), so two runs lay the
|
|
10
|
+
* page out alike. Luma's ids come from crypto, which stays random: nothing
|
|
11
|
+
* measured depends on them.
|
|
12
|
+
* @param {number} seed
|
|
13
|
+
*/
|
|
14
|
+
export function seedRandom(seed) {
|
|
15
|
+
let s = seed >>> 0;
|
|
16
|
+
Math.random = () => {
|
|
17
|
+
s = (s + 0x6d2b79f5) >>> 0;
|
|
18
|
+
let t = s;
|
|
19
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
20
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
21
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Before any script of the page (addInitScript): `window.__parityDeep`, the
|
|
27
|
+
* selector engine of every other probe here, which sees into open shadow
|
|
28
|
+
* roots. Luma's autocomplete renders in one (`[data-lbx="ac-overlay"]` and
|
|
29
|
+
* its `shadowRoot`; `dom: 'light'` is the opt-out), and a shop's header can be
|
|
30
|
+
* a web component. A selector matches across the boundaries on its path:
|
|
31
|
+
* `[data-lbx="ac-overlay"] [data-lbx-part~="ac-row"]` finds the rows inside
|
|
32
|
+
* the overlay's shadow root (descendant and child combinators go from an
|
|
33
|
+
* element in a shadow root to its host; sibling ones stay in one tree), and
|
|
34
|
+
* a part's selector finds it inside its root's shadow trees too.
|
|
35
|
+
*
|
|
36
|
+
* Deterministic: the elements of a scope in one order, the composed tree's
|
|
37
|
+
* (an element, then its shadow tree, then its light children). With no open
|
|
38
|
+
* shadow root under the document, it is `querySelectorAll` as it was.
|
|
39
|
+
* all(scope, selector) -> Element[] one(scope, selector) -> Element | null
|
|
40
|
+
* text(el, hidden) -> string what renders: shadow trees and slots' assigned nodes, the flat tree
|
|
41
|
+
* parent(el) -> Element | null the composed parent (a shadow root's host)
|
|
42
|
+
* Closed shadow roots stay closed: nothing in them is measured.
|
|
43
|
+
*/
|
|
44
|
+
export function deepInit() {
|
|
45
|
+
if (window.__parityDeep) return;
|
|
46
|
+
const parent = (e) => e.parentElement ?? (e.parentNode instanceof ShadowRoot ? e.parentNode.host : null);
|
|
47
|
+
/** Top-level splits: a selector list into selectors, a selector into compounds and combinators. */
|
|
48
|
+
const scan = (s, at) => {
|
|
49
|
+
const out = [];
|
|
50
|
+
let depth = 0;
|
|
51
|
+
let quote = '';
|
|
52
|
+
let start = 0;
|
|
53
|
+
for (let i = 0; i < s.length; i++) {
|
|
54
|
+
const c = s[i];
|
|
55
|
+
if (quote) {
|
|
56
|
+
if (c === '\\') i++;
|
|
57
|
+
else if (c === quote) quote = '';
|
|
58
|
+
} else if (c === '\\') i++;
|
|
59
|
+
else if (c === '"' || c === "'") quote = c;
|
|
60
|
+
else if (c === '(' || c === '[') depth++;
|
|
61
|
+
else if (c === ')' || c === ']') depth--;
|
|
62
|
+
else if (!depth) {
|
|
63
|
+
const n = at(s, i);
|
|
64
|
+
if (n) {
|
|
65
|
+
out.push(s.slice(start, i), n.op);
|
|
66
|
+
i += n.len - 1;
|
|
67
|
+
start = i + 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
out.push(s.slice(start));
|
|
72
|
+
return out;
|
|
73
|
+
};
|
|
74
|
+
const comma = (s, i) => (s[i] === ',' ? { op: ',', len: 1 } : null);
|
|
75
|
+
const combinator = (s, i) => {
|
|
76
|
+
if (!/[\s>+~]/.test(s[i])) return null;
|
|
77
|
+
let j = i;
|
|
78
|
+
let op = ' ';
|
|
79
|
+
while (j < s.length && /[\s>+~]/.test(s[j])) {
|
|
80
|
+
if (s[j] !== ' ' && !/\s/.test(s[j])) op = s[j];
|
|
81
|
+
j++;
|
|
82
|
+
}
|
|
83
|
+
return { op, len: j - i };
|
|
84
|
+
};
|
|
85
|
+
const cache = new Map();
|
|
86
|
+
/** A selector as [compound, combinator, compound, …] lists, one per selector of the list. */
|
|
87
|
+
const parse = (sel) => {
|
|
88
|
+
let p = cache.get(sel);
|
|
89
|
+
if (!p) {
|
|
90
|
+
p = scan(sel, comma)
|
|
91
|
+
.filter((_, i) => i % 2 === 0)
|
|
92
|
+
.map((x) => scan(x.trim(), combinator));
|
|
93
|
+
cache.set(sel, p);
|
|
94
|
+
}
|
|
95
|
+
return p;
|
|
96
|
+
};
|
|
97
|
+
const is = (el, compound) => {
|
|
98
|
+
try {
|
|
99
|
+
return el.matches(compound);
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
/** el matches the selector's compound at k, with the compounds before it on its composed ancestors (or siblings). */
|
|
105
|
+
const from = (el, t, k) => {
|
|
106
|
+
if (!is(el, t[k])) return false;
|
|
107
|
+
if (k === 0) return true;
|
|
108
|
+
const op = t[k - 1];
|
|
109
|
+
if (op === '>') {
|
|
110
|
+
const a = parent(el);
|
|
111
|
+
return !!a && from(a, t, k - 2);
|
|
112
|
+
}
|
|
113
|
+
if (op === '+') {
|
|
114
|
+
const a = el.previousElementSibling;
|
|
115
|
+
return !!a && from(a, t, k - 2);
|
|
116
|
+
}
|
|
117
|
+
if (op === '~') {
|
|
118
|
+
for (let a = el.previousElementSibling; a; a = a.previousElementSibling) if (from(a, t, k - 2)) return true;
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
for (let a = parent(el); a; a = parent(a)) if (from(a, t, k - 2)) return true;
|
|
122
|
+
return false;
|
|
123
|
+
};
|
|
124
|
+
/** Every element under a scope (document, element, shadow root) in composed-tree order; whether any is in a shadow tree. */
|
|
125
|
+
const walk = (scope, out) => {
|
|
126
|
+
const kids = scope.nodeType === 1 && scope.shadowRoot ? [scope.shadowRoot, ...scope.children] : [...scope.children];
|
|
127
|
+
for (const k of kids) {
|
|
128
|
+
if (k instanceof ShadowRoot) {
|
|
129
|
+
out.shadow = true;
|
|
130
|
+
walk(k, out);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
out.push(k);
|
|
134
|
+
walk(k, out);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
};
|
|
138
|
+
const all = (scope, sel) => {
|
|
139
|
+
const els = walk(scope, Object.assign([], { shadow: false }));
|
|
140
|
+
if (!els.shadow && scope.getRootNode() === document) {
|
|
141
|
+
try {
|
|
142
|
+
return [...scope.querySelectorAll(sel)];
|
|
143
|
+
} catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const list = parse(sel);
|
|
148
|
+
return els.filter((el) => list.some((t) => t.length && from(el, t, t.length - 1)));
|
|
149
|
+
};
|
|
150
|
+
const one = (scope, sel) => all(scope, sel)[0] ?? null;
|
|
151
|
+
/** The text a shopper sees in el (the flat tree), elements `hidden(e)` says are hidden left out. */
|
|
152
|
+
const text = (el, hidden) => {
|
|
153
|
+
const parts = [];
|
|
154
|
+
const visit = (e) => {
|
|
155
|
+
if (hidden(e)) return;
|
|
156
|
+
const kids = e.shadowRoot ? e.shadowRoot.childNodes : e instanceof HTMLSlotElement && e.assignedNodes().length ? e.assignedNodes() : e.childNodes;
|
|
157
|
+
for (const n of kids) {
|
|
158
|
+
if (n.nodeType === 3) parts.push(n.textContent);
|
|
159
|
+
else if (n.nodeType === 1) visit(n);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
visit(el);
|
|
163
|
+
return parts.join('').replace(/\s+/g, ' ').trim();
|
|
164
|
+
};
|
|
165
|
+
Object.defineProperty(window, '__parityDeep', { value: { all, one, text, parent }, enumerable: false });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Ours, before any script (addInitScript): the products their page shows,
|
|
170
|
+
* in their order, in our listing. Luma's product request (search or
|
|
171
|
+
* collections, `type` = the products' type) goes out as it is (its total,
|
|
172
|
+
* cursor and guid, so facets and the pager are the real query's); then
|
|
173
|
+
* requests for exactly their products (`field` in their ids: no scope or
|
|
174
|
+
* filter of our page, so their category listing's products reach our search
|
|
175
|
+
* page as well as our category page), and the answer Luma gets carries
|
|
176
|
+
* those hits in their order. A next page asked for gets none (the
|
|
177
|
+
* comparison is of the first page). `window.__parity.aligned` says what was
|
|
178
|
+
* asked and found, and with which queries; `window.__parity.hits` the
|
|
179
|
+
* catalog's records shown, per request ({ id: their id, hit }), in our order.
|
|
180
|
+
*
|
|
181
|
+
* The id requests (parity/pairing.mjs): with a collections `surface`, by id
|
|
182
|
+
* alone first (collections take no query); then the search endpoint with
|
|
183
|
+
* each of `queries` in turn (many channels answer an empty query with zero
|
|
184
|
+
* hits: their products are asked for with a query that finds them, the
|
|
185
|
+
* page's, their category's name, their titles' words), each asking only for
|
|
186
|
+
* the ids the ones before did not find, until every one is found or the
|
|
187
|
+
* queries are spent.
|
|
188
|
+
*
|
|
189
|
+
* Pairing (their id to our hit) never depends on the fields the build asks
|
|
190
|
+
* for: when the build lists `return_fields` without the pairing field, the
|
|
191
|
+
* id requests (only they) ask for that field too, and the hits Luma gets
|
|
192
|
+
* leave it out again, so what renders is what the build renders. With
|
|
193
|
+
* `pair: 'url'` their ids are links (relative, absolute, another host) and
|
|
194
|
+
* our hit's is its `url` (else `web_url`, else `field`), both compared as
|
|
195
|
+
* the decoded path without a trailing slash; the request asks for `field` in
|
|
196
|
+
* each id as found and in its path and absolute forms, so a catalog storing
|
|
197
|
+
* either form is found.
|
|
198
|
+
* @param {{ ids: string[], field: string, type: string, surface?: string, numeric?: boolean, pair?: 'field' | 'url', queries?: string[] }} cfg
|
|
199
|
+
*/
|
|
200
|
+
export function alignInit(cfg) {
|
|
201
|
+
const real = window.fetch;
|
|
202
|
+
const state = (window.__parity = window.__parity || { aligned: [] });
|
|
203
|
+
if (!cfg.ids.length) return;
|
|
204
|
+
const byUrl = cfg.pair === 'url';
|
|
205
|
+
const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
206
|
+
const first = (v) => (Array.isArray(v) ? v[0] : v);
|
|
207
|
+
const path = (v) => {
|
|
208
|
+
try {
|
|
209
|
+
let p = new URL(String(v), location.href).pathname;
|
|
210
|
+
try {
|
|
211
|
+
p = decodeURI(p);
|
|
212
|
+
} catch {}
|
|
213
|
+
return p.length > 1 ? p.replace(/\/+$/, '') : p;
|
|
214
|
+
} catch {
|
|
215
|
+
return '';
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
const keyOfId = (id) => (byUrl ? path(id) : String(id));
|
|
219
|
+
const valueOf = (h) => {
|
|
220
|
+
const v = byUrl ? (first(h.url) ?? first(h.web_url) ?? first(h[cfg.field])) : first(h[cfg.field]);
|
|
221
|
+
return v == null ? '' : byUrl ? path(v) : String(v);
|
|
222
|
+
};
|
|
223
|
+
/** The filter's values for some ids: the ids; for links, each one's path and absolute forms too. */
|
|
224
|
+
const values = (list) => {
|
|
225
|
+
if (!byUrl) return list.map((x) => (cfg.numeric ? Number(x) : String(x)));
|
|
226
|
+
const out = [];
|
|
227
|
+
for (const id of list) {
|
|
228
|
+
const p = path(id);
|
|
229
|
+
for (const v of [String(id), p, p && `${location.origin}${p}`]) if (v && !out.includes(v)) out.push(v);
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
};
|
|
233
|
+
const queries = Array.isArray(cfg.queries) && cfg.queries.length ? cfg.queries : [''];
|
|
234
|
+
window.fetch = async function (input, init) {
|
|
235
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
236
|
+
const m = /\/discovery\/v1\/(search|collections)\?/.exec(url);
|
|
237
|
+
let body = null;
|
|
238
|
+
try {
|
|
239
|
+
body = m && init && typeof init.body === 'string' ? JSON.parse(init.body) : null;
|
|
240
|
+
} catch {
|
|
241
|
+
body = null;
|
|
242
|
+
}
|
|
243
|
+
if (!body || body.type !== cfg.type) return real.call(this, input, init);
|
|
244
|
+
if (body.cursor) return json({ hits: [], total: 0, next_page_cursor: null, pagination_status: 'exhausted' });
|
|
245
|
+
const orig = await real.call(this, input, init);
|
|
246
|
+
if (!orig.ok) return orig;
|
|
247
|
+
const j = await orig.json();
|
|
248
|
+
// The pairing field, for the id requests only: the build's own request and what it renders stay as they are.
|
|
249
|
+
// A field the API returns unasked (`@id`, `@type`, `@title`, or one the build's own answer carries) stays.
|
|
250
|
+
const asked = Array.isArray(body.return_fields) ? body.return_fields : null;
|
|
251
|
+
const added = asked && !asked.includes(cfg.field) ? [cfg.field] : [];
|
|
252
|
+
const strip = added.filter((f) => !f.startsWith('@') && !(j.hits || []).some((h) => h && f in h));
|
|
253
|
+
const fields = added.length ? [...asked, ...added] : asked;
|
|
254
|
+
/** The id requests, in order: by id on a collections surface (no query), then the search endpoint with each query. */
|
|
255
|
+
const plan = [];
|
|
256
|
+
if (cfg.surface) plan.push({ endpoint: 'collections', surface: cfg.surface, query: null });
|
|
257
|
+
else if (m[1] === 'collections') plan.push({ endpoint: 'collections', surface: new URL(url).searchParams.get('surface_id'), query: null });
|
|
258
|
+
for (const q of queries) plan.push({ endpoint: 'search', surface: m[1] === 'search' ? new URL(url).searchParams.get('surface_id') : null, query: q });
|
|
259
|
+
const by = new Map();
|
|
260
|
+
const tried = [];
|
|
261
|
+
for (const step of plan) {
|
|
262
|
+
const wanted = cfg.ids.filter((id) => !by.has(keyOfId(id)));
|
|
263
|
+
if (!wanted.length) break;
|
|
264
|
+
const ids = values(wanted);
|
|
265
|
+
const u = new URL(url);
|
|
266
|
+
u.pathname = u.pathname.replace(/\/(search|collections)$/, `/${step.endpoint}`);
|
|
267
|
+
if (step.surface) u.searchParams.set('surface_id', step.surface);
|
|
268
|
+
else u.searchParams.delete('surface_id');
|
|
269
|
+
let b2;
|
|
270
|
+
if (step.endpoint === 'collections') {
|
|
271
|
+
b2 = { type: body.type, size: Math.min(wanted.length, 100), collection_filters: { [cfg.field]: { in: ids } } };
|
|
272
|
+
if (cfg.surface == null) b2 = { ...body, size: Math.min(wanted.length, 100), filters: { [cfg.field]: { in: ids } } };
|
|
273
|
+
if (cfg.surface == null) delete b2.collection_filters;
|
|
274
|
+
} else {
|
|
275
|
+
// Exactly their products, whatever our page asks (not our scope, filters or sort), with a query that finds them.
|
|
276
|
+
b2 = m[1] === 'search' ? { ...body } : { type: body.type };
|
|
277
|
+
Object.assign(b2, { size: Math.min(wanted.length, 100), query: step.query, filters: { [cfg.field]: { in: ids } } });
|
|
278
|
+
delete b2.collection_filters;
|
|
279
|
+
}
|
|
280
|
+
delete b2.sort;
|
|
281
|
+
delete b2.cursor;
|
|
282
|
+
if (fields) b2.return_fields = fields;
|
|
283
|
+
else delete b2.return_fields;
|
|
284
|
+
let hits = [];
|
|
285
|
+
try {
|
|
286
|
+
const r2 = await real.call(this, u.href, { ...init, body: JSON.stringify(b2) });
|
|
287
|
+
if (r2.ok) hits = (await r2.json()).hits || [];
|
|
288
|
+
} catch {
|
|
289
|
+
hits = [];
|
|
290
|
+
}
|
|
291
|
+
const before = by.size;
|
|
292
|
+
for (const h of hits) {
|
|
293
|
+
const k = valueOf(h);
|
|
294
|
+
if (k && !by.has(k) && wanted.some((id) => keyOfId(id) === k)) by.set(k, h);
|
|
295
|
+
}
|
|
296
|
+
tried.push({ endpoint: step.endpoint, query: step.query, asked: wanted.length, found: by.size - before });
|
|
297
|
+
}
|
|
298
|
+
const used = new Set();
|
|
299
|
+
const ordered = [];
|
|
300
|
+
const records = [];
|
|
301
|
+
for (const id of cfg.ids) {
|
|
302
|
+
const h = by.get(keyOfId(id));
|
|
303
|
+
if (!h || used.has(h)) continue;
|
|
304
|
+
used.add(h);
|
|
305
|
+
records.push({ id, hit: h });
|
|
306
|
+
ordered.push(strip.length ? Object.fromEntries(Object.entries(h).filter(([k]) => !strip.includes(k))) : h);
|
|
307
|
+
}
|
|
308
|
+
// The catalog's records, in the order Luma shows them: the evidence a text difference is the data's (owner.mjs).
|
|
309
|
+
(state.hits = state.hits || []).push(records);
|
|
310
|
+
state.aligned.push({ endpoint: m[1], pair: byUrl ? 'url' : cfg.field, added, asked: cfg.ids.length, found: ordered.length, missing: cfg.ids.filter((id) => !by.has(keyOfId(id))), queries: tried });
|
|
311
|
+
return json({ ...j, hits: ordered });
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Settles the page for measuring: the hidden regions out (`hide`: cookie
|
|
317
|
+
* banners, chat widgets), images in the measured roots loaded eagerly and
|
|
318
|
+
* decoded, webfonts loaded, every CSS animation and transition finished
|
|
319
|
+
* (infinite ones cancelled), two frames. Transitions are finished, not
|
|
320
|
+
* switched off: their computed `transition` is measured too.
|
|
321
|
+
* @param {{ roots: string[], hide: string[] }} arg
|
|
322
|
+
*/
|
|
323
|
+
export async function stabilize(arg) {
|
|
324
|
+
const D = window.__parityDeep;
|
|
325
|
+
const qa = (scope, sel) => {
|
|
326
|
+
if (D) return D.all(scope, sel);
|
|
327
|
+
try {
|
|
328
|
+
return [...scope.querySelectorAll(sel)];
|
|
329
|
+
} catch {
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
if (arg.hide.length && !document.querySelector('style[data-parity="hide"]')) {
|
|
334
|
+
const st = document.createElement('style');
|
|
335
|
+
st.setAttribute('data-parity', 'hide');
|
|
336
|
+
st.textContent = `${arg.hide.join(',\n')} { display: none !important; }`;
|
|
337
|
+
(document.head || document.documentElement).appendChild(st);
|
|
338
|
+
}
|
|
339
|
+
// A hidden region inside a shadow root (a web-component header's banner): the document's style does not reach it.
|
|
340
|
+
if (D) for (const sel of arg.hide) for (const el of qa(document, sel)) if (el.getRootNode() !== document) el.style.setProperty('display', 'none', 'important');
|
|
341
|
+
const within = (ms, p) => Promise.race([p, new Promise((r) => setTimeout(r, ms))]);
|
|
342
|
+
const imgs = new Set();
|
|
343
|
+
for (const sel of arg.roots) for (const r of qa(document, sel)) for (const img of qa(r, 'img')) imgs.add(img);
|
|
344
|
+
for (const img of imgs) if (img.loading === 'lazy') img.loading = 'eager';
|
|
345
|
+
await within(
|
|
346
|
+
8000,
|
|
347
|
+
Promise.all(
|
|
348
|
+
[...imgs].map((img) =>
|
|
349
|
+
(img.complete ? Promise.resolve() : new Promise((r) => (img.addEventListener('load', r, { once: true }), img.addEventListener('error', r, { once: true }))))
|
|
350
|
+
.then(() => (img.naturalWidth ? img.decode().catch(() => {}) : undefined)),
|
|
351
|
+
),
|
|
352
|
+
),
|
|
353
|
+
);
|
|
354
|
+
await within(8000, document.fonts.ready);
|
|
355
|
+
for (const a of document.getAnimations()) {
|
|
356
|
+
try {
|
|
357
|
+
const t = a.effect && a.effect.getComputedTiming();
|
|
358
|
+
if (t && t.endTime === Infinity) a.cancel();
|
|
359
|
+
else a.finish();
|
|
360
|
+
} catch {
|
|
361
|
+
try {
|
|
362
|
+
a.cancel();
|
|
363
|
+
} catch {}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The boxes of every root of `selectors` (rounded to 1/64 px, as layout is):
|
|
371
|
+
* equal on two reads a moment apart = the layout has settled.
|
|
372
|
+
* @param {string[]} selectors
|
|
373
|
+
*/
|
|
374
|
+
export function boxes(selectors) {
|
|
375
|
+
const D = window.__parityDeep;
|
|
376
|
+
const qa = (scope, sel) => {
|
|
377
|
+
if (D) return D.all(scope, sel);
|
|
378
|
+
try {
|
|
379
|
+
return [...scope.querySelectorAll(sel)];
|
|
380
|
+
} catch {
|
|
381
|
+
return [];
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
const out = [];
|
|
385
|
+
for (const sel of selectors) {
|
|
386
|
+
for (const el of qa(document, sel).slice(0, 60)) {
|
|
387
|
+
const r = el.getBoundingClientRect();
|
|
388
|
+
out.push([r.x, r.y + scrollY, r.width, r.height].map((v) => Math.round(v * 64) / 64).join(','));
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return out.join(';');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The keys their page lists its products under (ids for the API), in their
|
|
396
|
+
* order, visible elements only, each once.
|
|
397
|
+
* @param {{ selector: string, attr: string, child?: string, transform?: 'path' | 'absolute' | null, max: number }} arg
|
|
398
|
+
*/
|
|
399
|
+
export function collectIds(arg) {
|
|
400
|
+
const D = window.__parityDeep;
|
|
401
|
+
const qa = (scope, sel) => {
|
|
402
|
+
if (D) return D.all(scope, sel);
|
|
403
|
+
try {
|
|
404
|
+
return [...scope.querySelectorAll(sel)];
|
|
405
|
+
} catch {
|
|
406
|
+
return [];
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
const out = [];
|
|
410
|
+
for (const el of qa(document, arg.selector)) {
|
|
411
|
+
const r = el.getBoundingClientRect();
|
|
412
|
+
if (!r.width || !r.height) continue;
|
|
413
|
+
const src = arg.child ? (qa(el, arg.child)[0] ?? null) : el;
|
|
414
|
+
let v = src ? src.getAttribute(arg.attr) : null;
|
|
415
|
+
if (!v) continue;
|
|
416
|
+
if (arg.transform === 'path' || arg.transform === 'absolute') {
|
|
417
|
+
try {
|
|
418
|
+
const u = new URL(v, location.href);
|
|
419
|
+
v = arg.transform === 'path' ? u.pathname : `${u.origin}${u.pathname}`;
|
|
420
|
+
} catch {
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
v = v.trim();
|
|
425
|
+
if (v && !out.includes(v)) out.push(v);
|
|
426
|
+
if (out.length >= arg.max) break;
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* One component on the page: its roots (all of them for a repeated one,
|
|
433
|
+
* else the first visible), each root's box and computed style, its parts
|
|
434
|
+
* (relative selectors) with boxes relative to the root, their visible text,
|
|
435
|
+
* the grid when the component is one (columns per row, gaps, item width).
|
|
436
|
+
* Elements are kept in `window.__parityEls` (by `ref`) for the rendered-font
|
|
437
|
+
* lookup (CDP), no attribute is written into the page.
|
|
438
|
+
* @param {{ root: string, parts: Record<string, string>, key: { attr: string, child?: string, transform?: 'path' | 'absolute' } | null, all: boolean, max: number, grid: { item: string } | null, props: string[], ref?: number }} arg
|
|
439
|
+
*/
|
|
440
|
+
export function measure(arg) {
|
|
441
|
+
const els = (window.__parityEls = window.__parityEls || []);
|
|
442
|
+
const COLOR = /color$/;
|
|
443
|
+
const canvas = document.createElement('canvas');
|
|
444
|
+
canvas.width = canvas.height = 1;
|
|
445
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
446
|
+
const cache = new Map();
|
|
447
|
+
/** The browser's own sRGB reading of a colour in any space (oklch, lab, color-mix). */
|
|
448
|
+
const rgba = (v) => {
|
|
449
|
+
if (/^rgba?\(/.test(v) || v === 'transparent') return undefined;
|
|
450
|
+
if (cache.has(v)) return cache.get(v);
|
|
451
|
+
let out;
|
|
452
|
+
try {
|
|
453
|
+
ctx.clearRect(0, 0, 1, 1);
|
|
454
|
+
ctx.fillStyle = '#000';
|
|
455
|
+
ctx.fillStyle = v;
|
|
456
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
457
|
+
const d = ctx.getImageData(0, 0, 1, 1).data;
|
|
458
|
+
out = [d[0], d[1], d[2], Math.round((d[3] / 255) * 1000) / 1000];
|
|
459
|
+
} catch {
|
|
460
|
+
out = undefined;
|
|
461
|
+
}
|
|
462
|
+
cache.set(v, out);
|
|
463
|
+
return out;
|
|
464
|
+
};
|
|
465
|
+
const visible = (el) => {
|
|
466
|
+
if (!el) return false;
|
|
467
|
+
const r = el.getBoundingClientRect();
|
|
468
|
+
if (!(r.width > 0 && r.height > 0)) return false;
|
|
469
|
+
return getComputedStyle(el).visibility !== 'hidden';
|
|
470
|
+
};
|
|
471
|
+
const D = window.__parityDeep;
|
|
472
|
+
const qa = (scope, sel) => {
|
|
473
|
+
if (D) return D.all(scope, sel);
|
|
474
|
+
try {
|
|
475
|
+
return [...scope.querySelectorAll(sel)];
|
|
476
|
+
} catch {
|
|
477
|
+
return [];
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
const q = (root, sel, all) => (all ? qa(root, sel) : (qa(root, sel)[0] ?? null));
|
|
481
|
+
/** Text a sighted shopper reads: visually hidden text (sr-only: a 1 px clipped box) and hidden elements left out. */
|
|
482
|
+
const textOf = (el) => {
|
|
483
|
+
const parts = [];
|
|
484
|
+
const hidden = new Map();
|
|
485
|
+
const up = (e) => (D ? D.parent(e) : e.parentElement);
|
|
486
|
+
const isHidden = (node) => {
|
|
487
|
+
for (let e = node; e; e = up(e)) {
|
|
488
|
+
if (hidden.has(e)) return hidden.get(e);
|
|
489
|
+
const s = getComputedStyle(e);
|
|
490
|
+
const r = e.getBoundingClientRect();
|
|
491
|
+
const h = s.display === 'none' || s.visibility === 'hidden' || ((r.width <= 1 || r.height <= 1) && (s.overflow !== 'visible' || s.clip !== 'auto' || s.clipPath !== 'none'));
|
|
492
|
+
if (h) {
|
|
493
|
+
hidden.set(e, true);
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
if (e === el) break;
|
|
497
|
+
}
|
|
498
|
+
return false;
|
|
499
|
+
};
|
|
500
|
+
// Open shadow trees and slots too (the flat tree): what renders.
|
|
501
|
+
if (D) return D.text(el, isHidden);
|
|
502
|
+
const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
503
|
+
for (let n = w.nextNode(); n; n = w.nextNode()) {
|
|
504
|
+
if (isHidden(n.parentElement)) continue;
|
|
505
|
+
parts.push(n.textContent);
|
|
506
|
+
}
|
|
507
|
+
return parts.join('').replace(/\s+/g, ' ').trim();
|
|
508
|
+
};
|
|
509
|
+
const styleOf = (el) => {
|
|
510
|
+
const s = getComputedStyle(el);
|
|
511
|
+
const out = {};
|
|
512
|
+
for (const p of arg.props) {
|
|
513
|
+
const v = s.getPropertyValue(p);
|
|
514
|
+
out[p] = COLOR.test(p) ? { raw: v, rgba: rgba(v) } : v;
|
|
515
|
+
if (out[p] && out[p].rgba === undefined && typeof out[p] === 'object') delete out[p].rgba;
|
|
516
|
+
}
|
|
517
|
+
return out;
|
|
518
|
+
};
|
|
519
|
+
const ref = (el) => {
|
|
520
|
+
els.push(el);
|
|
521
|
+
return els.length - 1;
|
|
522
|
+
};
|
|
523
|
+
/**
|
|
524
|
+
* The controls in a part, by kind (docs/PARITY.md, "Parts of parts"): a slider (input[type=range],
|
|
525
|
+
* role=slider, jQuery UI's, noUiSlider's, ion.rangeSlider's), text inputs, selects, checkboxes, radios,
|
|
526
|
+
* buttons. Rendered ones (a visually hidden checkbox counts), not display: none.
|
|
527
|
+
*/
|
|
528
|
+
const controlsOf = (el) => {
|
|
529
|
+
const SLIDER = '[data-lbx-part~="range-slider"], input[type=range], [role=slider], .ui-slider, .noUi-target, .irs, .rc-slider, .MuiSlider-root';
|
|
530
|
+
const kinds = { slider: 0, input: 0, select: 0, checkbox: 0, radio: 0, button: 0 };
|
|
531
|
+
const all = [el, ...qa(el, '*')].filter((e) => e.getClientRects().length > 0);
|
|
532
|
+
const sliders = all.filter((e) => e.matches(SLIDER));
|
|
533
|
+
kinds.slider = sliders.filter((e) => !sliders.some((s) => s !== e && s.contains(e))).length;
|
|
534
|
+
for (const e of all) {
|
|
535
|
+
if (sliders.some((s) => s.contains(e))) continue;
|
|
536
|
+
const t = (e.getAttribute('type') || '').toLowerCase();
|
|
537
|
+
if (e.tagName === 'SELECT') kinds.select++;
|
|
538
|
+
else if (e.tagName === 'INPUT' && t === 'checkbox') kinds.checkbox++;
|
|
539
|
+
else if (e.tagName === 'INPUT' && t === 'radio') kinds.radio++;
|
|
540
|
+
else if (e.tagName === 'INPUT' && !/^(hidden|submit|button|reset|image|range)$/.test(t)) kinds.input++;
|
|
541
|
+
else if (e.tagName === 'TEXTAREA') kinds.input++;
|
|
542
|
+
else if (e.tagName === 'BUTTON' || (e.tagName === 'INPUT' && /^(submit|button|reset|image)$/.test(t))) kinds.button++;
|
|
543
|
+
}
|
|
544
|
+
return kinds;
|
|
545
|
+
};
|
|
546
|
+
/**
|
|
547
|
+
* Consecutive siblings of the first of `list` (the ones of its composed parent, in order, at most 12),
|
|
548
|
+
* as page boxes [x, y, width, height]: the space between one and the next (compare.mjs layoutOf).
|
|
549
|
+
* Null when it has none (one element, or the others elsewhere).
|
|
550
|
+
*/
|
|
551
|
+
const siblingsOf = (list) => {
|
|
552
|
+
if (list.length < 2) return null;
|
|
553
|
+
const up = (e) => (D ? D.parent(e) : e.parentElement);
|
|
554
|
+
const p = up(list[0]);
|
|
555
|
+
const same = list.filter((e) => up(e) === p).slice(0, 12);
|
|
556
|
+
if (same.length < 2) return null;
|
|
557
|
+
return same.map((e) => {
|
|
558
|
+
const r = e.getBoundingClientRect();
|
|
559
|
+
return [r.x + scrollX, r.y + scrollY, r.width, r.height].map((v) => Math.round(v * 64) / 64);
|
|
560
|
+
});
|
|
561
|
+
};
|
|
562
|
+
const one = (root) => {
|
|
563
|
+
const rb = root.getBoundingClientRect();
|
|
564
|
+
const box = (el) => {
|
|
565
|
+
const r = el.getBoundingClientRect();
|
|
566
|
+
return { x: r.x - rb.x, y: r.y - rb.y, width: r.width, height: r.height };
|
|
567
|
+
};
|
|
568
|
+
const parts = {};
|
|
569
|
+
for (const [name, sel] of Object.entries(arg.parts)) {
|
|
570
|
+
const all = q(root, sel, true).filter(visible);
|
|
571
|
+
const el = all[0] ?? null;
|
|
572
|
+
parts[name] = el ? { present: true, box: box(el), style: styleOf(el), text: textOf(el), ref: ref(el), font: null, siblings: siblingsOf(all), controls: controlsOf(el) } : { present: false };
|
|
573
|
+
}
|
|
574
|
+
let key = null;
|
|
575
|
+
if (arg.key) {
|
|
576
|
+
const src = arg.key.child ? q(root, arg.key.child, false) : root;
|
|
577
|
+
key = src ? src.getAttribute(arg.key.attr) : null;
|
|
578
|
+
if (key && arg.key.transform)
|
|
579
|
+
try {
|
|
580
|
+
const u = new URL(key, location.href);
|
|
581
|
+
key = arg.key.transform === 'path' ? u.pathname : `${u.origin}${u.pathname}`;
|
|
582
|
+
} catch {}
|
|
583
|
+
}
|
|
584
|
+
let grid = null;
|
|
585
|
+
if (arg.grid) {
|
|
586
|
+
const items = q(root, arg.grid.item, true).filter(visible).map((e) => e.getBoundingClientRect());
|
|
587
|
+
const s = getComputedStyle(root);
|
|
588
|
+
grid = { columns: 0, columnGap: null, rowGap: null, itemWidth: null, items: items.length, padding: ['padding-top', 'padding-right', 'padding-bottom', 'padding-left'].map((p) => parseFloat(s.getPropertyValue(p)) || 0) };
|
|
589
|
+
if (items.length) {
|
|
590
|
+
const top = items[0].top;
|
|
591
|
+
const row1 = items.filter((r) => Math.abs(r.top - top) < 2);
|
|
592
|
+
grid.columns = row1.length;
|
|
593
|
+
grid.itemWidth = items[0].width;
|
|
594
|
+
if (row1.length > 1) grid.columnGap = row1[1].left - row1[0].right;
|
|
595
|
+
const next = items.find((r) => r.top > top + 2);
|
|
596
|
+
if (next) grid.rowGap = next.top - Math.max(...row1.map((r) => r.bottom));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return { present: true, key, box: { x: 0, y: 0, width: rb.width, height: rb.height }, style: styleOf(root), text: textOf(root), ref: ref(root), font: null, parts, grid };
|
|
600
|
+
};
|
|
601
|
+
if (arg.ref != null) return [els[arg.ref]].filter(visible).map(one);
|
|
602
|
+
const roots = q(document, arg.root, true).filter(visible);
|
|
603
|
+
const chosen = arg.all ? roots.slice(0, arg.max) : roots.slice(0, 1);
|
|
604
|
+
const out = chosen.map(one);
|
|
605
|
+
// A repeated component's instances side by side (tiles in a row, facet groups in a column): their gaps, their columns.
|
|
606
|
+
if (arg.all && out.length) out[0].siblings = siblingsOf(roots);
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Their page, for the id requests' queries (parity/pairing.mjs): its
|
|
612
|
+
* heading (their category's name: the first visible h1, else the
|
|
613
|
+
* breadcrumb's last item) and their tiles' titles (a heading or an element
|
|
614
|
+
* named title/name in each tile, else its longest link text).
|
|
615
|
+
* @param {{ selector: string | null }} arg
|
|
616
|
+
*/
|
|
617
|
+
export function theirHints(arg) {
|
|
618
|
+
const D = window.__parityDeep;
|
|
619
|
+
const qa = (scope, sel) => {
|
|
620
|
+
if (D) return D.all(scope, sel);
|
|
621
|
+
try {
|
|
622
|
+
return [...scope.querySelectorAll(sel)];
|
|
623
|
+
} catch {
|
|
624
|
+
return [];
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
const vis = (el) => {
|
|
628
|
+
const r = el.getBoundingClientRect();
|
|
629
|
+
return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden';
|
|
630
|
+
};
|
|
631
|
+
const text = (el) => (el?.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
632
|
+
const h1 = qa(document, 'h1').find(vis);
|
|
633
|
+
const crumb = qa(document, '[aria-label*="breadcrumb" i] li, .breadcrumb li, .breadcrumbs li, [itemtype*="BreadcrumbList"] [itemprop="itemListElement"]').filter(vis).pop();
|
|
634
|
+
const heading = text(h1) || text(crumb) || '';
|
|
635
|
+
const titles = [];
|
|
636
|
+
for (const t of arg.selector ? qa(document, arg.selector).filter(vis).slice(0, 24) : []) {
|
|
637
|
+
const named = qa(t, 'h1, h2, h3, h4, h5, h6, [itemprop="name"], [class*="title" i], [class*="name" i]').find(vis);
|
|
638
|
+
const link = qa(t, 'a[href]').map(text).sort((a, b) => b.length - a.length)[0];
|
|
639
|
+
const v = text(named) || link || text(t);
|
|
640
|
+
if (v) titles.push(v.slice(0, 200));
|
|
641
|
+
}
|
|
642
|
+
return { heading: heading.length <= 80 ? heading : '', titles };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Before any script (addInitScript): the page's storage set as the map says
|
|
647
|
+
* (`pages.<name>.storage`: a view mode their page reads), and every storage
|
|
648
|
+
* key the page reads recorded (`window.__parityStorage`): a listing whose
|
|
649
|
+
* view mode (grid or list, its columns) comes from storage says so in the
|
|
650
|
+
* report.
|
|
651
|
+
* @param {{ set: { localStorage?: Record<string, string>, sessionStorage?: Record<string, string> } | null }} cfg
|
|
652
|
+
*/
|
|
653
|
+
export function storageInit(cfg) {
|
|
654
|
+
const reads = (window.__parityStorage = window.__parityStorage || []);
|
|
655
|
+
try {
|
|
656
|
+
for (const area of ['localStorage', 'sessionStorage']) for (const [k, v] of Object.entries(cfg.set?.[area] ?? {})) window[area].setItem(k, v);
|
|
657
|
+
} catch {}
|
|
658
|
+
try {
|
|
659
|
+
const get = Storage.prototype.getItem;
|
|
660
|
+
Storage.prototype.getItem = function (k) {
|
|
661
|
+
const v = get.call(this, k);
|
|
662
|
+
try {
|
|
663
|
+
const area = this === window.localStorage ? 'localStorage' : 'sessionStorage';
|
|
664
|
+
if (!reads.some((r) => r.area === area && r.key === String(k))) reads.push({ area, key: String(k), value: v });
|
|
665
|
+
} catch {}
|
|
666
|
+
return v;
|
|
667
|
+
};
|
|
668
|
+
} catch {}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Before any script (addInitScript): the page's layout shifts (the Layout
|
|
673
|
+
* Instability API; shifts right after an input left out, as CLS does), each
|
|
674
|
+
* with the elements that moved and where they were: `window.__parityCls`.
|
|
675
|
+
* `blocksOf` counts the ones of their elements above our components.
|
|
676
|
+
*/
|
|
677
|
+
export function clsInit() {
|
|
678
|
+
const out = (window.__parityCls = { entries: [] });
|
|
679
|
+
const rect = (r) => (r ? { x: r.x, y: r.y + scrollY, width: r.width, height: r.height } : null);
|
|
680
|
+
try {
|
|
681
|
+
new PerformanceObserver((list) => {
|
|
682
|
+
for (const e of list.getEntries()) {
|
|
683
|
+
if (e.hadRecentInput || out.entries.length >= 200) continue;
|
|
684
|
+
const sources = (e.sources ?? []).filter((s) => s.node && s.node.nodeType === 1).map((s) => ({ node: s.node, before: rect(s.previousRect) }));
|
|
685
|
+
out.entries.push({ value: e.value, sources });
|
|
686
|
+
}
|
|
687
|
+
}).observe({ type: 'layout-shift', buffered: true });
|
|
688
|
+
} catch {}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* The collateral check's view of a page (docs/PARITY.md, "The collateral
|
|
693
|
+
* check"): every block of the page outside the regions (our components',
|
|
694
|
+
* Luma's own elements, the map's `collateral.ignore`), by its path from the
|
|
695
|
+
* body, in page coordinates. The walk goes down only into the ancestors of a
|
|
696
|
+
* region (`container`: the body, a column wrapping the listing), so a block
|
|
697
|
+
* is the largest element beside a region: their header, a sidebar, a
|
|
698
|
+
* banner, the footer (`outside`, with its direct children, for their
|
|
699
|
+
* presence). A path is tag, id and classes (Luma's `lbx-` ones aside), with
|
|
700
|
+
* the occurrence among alike siblings: the same element on their page
|
|
701
|
+
* without us and on ours. Elements are kept in `window.__parityEls` for the
|
|
702
|
+
* crops; `top` is the first region's top (what is above it keeps its place).
|
|
703
|
+
* @param {{ regions: string[], ignore: string[] }} arg
|
|
704
|
+
*/
|
|
705
|
+
export function blocksOf(arg) {
|
|
706
|
+
const els = (window.__parityEls = window.__parityEls || []);
|
|
707
|
+
const D = window.__parityDeep;
|
|
708
|
+
const qa = (scope, sel) => {
|
|
709
|
+
if (D) return D.all(scope, sel);
|
|
710
|
+
try {
|
|
711
|
+
return [...scope.querySelectorAll(sel)];
|
|
712
|
+
} catch {
|
|
713
|
+
return [];
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
const up = (e) => (D ? D.parent(e) : e.parentElement);
|
|
717
|
+
const vis = (el) => {
|
|
718
|
+
const r = el.getBoundingClientRect();
|
|
719
|
+
return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden';
|
|
720
|
+
};
|
|
721
|
+
const SKIP = new Set(['SCRIPT', 'STYLE', 'LINK', 'META', 'NOSCRIPT', 'TEMPLATE', 'BR', 'BASE', 'TITLE']);
|
|
722
|
+
const luma = (el) => el.tagName.startsWith('LBX-') || el.hasAttribute('data-lbx') || el.hasAttribute('data-lbx-part');
|
|
723
|
+
const regions = new Set();
|
|
724
|
+
for (const sel of arg.regions) for (const el of qa(document, sel)) regions.add(el);
|
|
725
|
+
for (const el of document.querySelectorAll('body *')) if (luma(el)) regions.add(el);
|
|
726
|
+
const ignored = new Set();
|
|
727
|
+
for (const sel of arg.ignore) for (const el of qa(document, sel)) ignored.add(el);
|
|
728
|
+
// The ancestors of every region: walked into; what is beside them is a block.
|
|
729
|
+
const holds = new Set();
|
|
730
|
+
let top = null;
|
|
731
|
+
for (const r of regions) {
|
|
732
|
+
// A hidden region (an overlay not open) holds nothing: its ancestors stay blocks.
|
|
733
|
+
if (!vis(r)) continue;
|
|
734
|
+
const y = r.getBoundingClientRect().y + scrollY;
|
|
735
|
+
top = top == null ? y : Math.min(top, y);
|
|
736
|
+
for (let a = up(r); a; a = up(a)) holds.add(a);
|
|
737
|
+
}
|
|
738
|
+
const r64 = (v) => Math.round(v * 64) / 64;
|
|
739
|
+
const boxOf = (el) => {
|
|
740
|
+
const r = el.getBoundingClientRect();
|
|
741
|
+
return { x: r64(r.x + scrollX), y: r64(r.y + scrollY), width: r64(r.width), height: r64(r.height) };
|
|
742
|
+
};
|
|
743
|
+
const sig = (el) => {
|
|
744
|
+
const id = el.id && !/\d{3,}|^lbx/i.test(el.id) ? `#${el.id}` : '';
|
|
745
|
+
const cls = [...el.classList].filter((c) => !/^lbx/i.test(c)).sort().slice(0, 3).map((c) => `.${c}`).join('');
|
|
746
|
+
return `${el.tagName.toLowerCase()}${id}${cls}`;
|
|
747
|
+
};
|
|
748
|
+
/** An element's children with their paths (the occurrence of a signature among its siblings: `:2`). */
|
|
749
|
+
const kids = (el, path) => {
|
|
750
|
+
const seen = new Map();
|
|
751
|
+
const out = [];
|
|
752
|
+
for (const k of el.children) {
|
|
753
|
+
if (SKIP.has(k.tagName)) continue;
|
|
754
|
+
const s = sig(k);
|
|
755
|
+
const n = (seen.get(s) ?? 0) + 1;
|
|
756
|
+
seen.set(s, n);
|
|
757
|
+
out.push([k, `${path} > ${s}${n > 1 ? `:${n}` : ''}`]);
|
|
758
|
+
}
|
|
759
|
+
return out;
|
|
760
|
+
};
|
|
761
|
+
const blocks = [];
|
|
762
|
+
const walk = (el, path, depth) => {
|
|
763
|
+
for (const [k, p] of kids(el, path)) {
|
|
764
|
+
if (blocks.length >= 80) return;
|
|
765
|
+
if (regions.has(k) || ignored.has(k)) continue;
|
|
766
|
+
const present = vis(k);
|
|
767
|
+
if (holds.has(k)) {
|
|
768
|
+
blocks.push({ path: p, kind: 'container', present, box: present ? boxOf(k) : null, ref: null, children: [] });
|
|
769
|
+
if (depth < 14) walk(k, p, depth + 1);
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (!present) {
|
|
773
|
+
blocks.push({ path: p, kind: 'outside', present: false, box: null, ref: null, children: [] });
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
els.push(k);
|
|
777
|
+
const children = kids(k, p)
|
|
778
|
+
.filter(([c]) => !regions.has(c) && !ignored.has(c) && vis(c) && c.getBoundingClientRect().width >= 4 && c.getBoundingClientRect().height >= 4)
|
|
779
|
+
.map(([, cp]) => cp);
|
|
780
|
+
blocks.push({ path: p, kind: 'outside', present: true, box: boxOf(k), ref: els.length - 1, children });
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
walk(document.body, 'body', 0);
|
|
784
|
+
// The layout shifts of their elements above our components (clsInit): what our render moved of their page.
|
|
785
|
+
// Below our components an element moves with their height (compared as theirs); in them, it is ours.
|
|
786
|
+
const inRegion = (n) => {
|
|
787
|
+
for (let e = n; e; e = up(e)) if (regions.has(e)) return true;
|
|
788
|
+
return false;
|
|
789
|
+
};
|
|
790
|
+
const name = (n) => `${n.tagName.toLowerCase()}${n.id ? `#${n.id}` : ''}${[...n.classList].slice(0, 2).map((c) => `.${c}`).join('')}`;
|
|
791
|
+
let cls = null;
|
|
792
|
+
if (window.__parityCls) {
|
|
793
|
+
cls = { value: 0, shifts: [] };
|
|
794
|
+
for (const e of window.__parityCls.entries) {
|
|
795
|
+
const theirs = e.sources.filter((x) => x.node.isConnected !== false && !inRegion(x.node) && x.before && (top == null || x.before.y + x.before.height <= top + 1));
|
|
796
|
+
if (!theirs.length) continue;
|
|
797
|
+
cls.value += e.value;
|
|
798
|
+
if (cls.shifts.length < 10) cls.shifts.push({ value: Math.round(e.value * 10000) / 10000, nodes: theirs.map((x) => name(x.node)) });
|
|
799
|
+
}
|
|
800
|
+
cls.value = Math.round(cls.value * 10000) / 10000;
|
|
801
|
+
}
|
|
802
|
+
return { blocks, top: top == null ? null : r64(top), cls };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* The vertical layout around the listing (docs/PARITY.md, "The layout around
|
|
807
|
+
* the listing"): the listing's box (the union of the regions shown: the
|
|
808
|
+
* map's components, Luma's `lbx-search` / `lbx-plp`), the nearest element
|
|
809
|
+
* that renders something above it (`above`: its bottom, the heading, a
|
|
810
|
+
* toolbar) and below it (`below`: its top, the next block, the footer), the
|
|
811
|
+
* gap to each; the elements filling each gap (`fillers`, outermost, with
|
|
812
|
+
* their min-height, height, padding and margin: the fix hint); and the
|
|
813
|
+
* elements between the listing and what follows it that render nothing yet
|
|
814
|
+
* take more than `empty` px (`empty`: an empty column a theme gives a
|
|
815
|
+
* min-height). Elements are named by their path from the body, as
|
|
816
|
+
* `blocksOf` names them. Their elements only: never in a region, never
|
|
817
|
+
* Luma's, never fixed (a cookie bar), never in `ignore`.
|
|
818
|
+
* @param {{ regions: string[], ignore: string[], empty?: number }} arg
|
|
819
|
+
*/
|
|
820
|
+
export function aroundOf(arg) {
|
|
821
|
+
const D = window.__parityDeep;
|
|
822
|
+
const qa = (scope, sel) => {
|
|
823
|
+
if (D) return D.all(scope, sel);
|
|
824
|
+
try {
|
|
825
|
+
return [...scope.querySelectorAll(sel)];
|
|
826
|
+
} catch {
|
|
827
|
+
return [];
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
const up = (e) => (D ? D.parent(e) : e.parentElement);
|
|
831
|
+
const shown = (el) => {
|
|
832
|
+
const r = el.getBoundingClientRect();
|
|
833
|
+
if (!(r.width > 0 && r.height > 0)) return false;
|
|
834
|
+
if (el.checkVisibility) return el.checkVisibility({ opacityProperty: true, visibilityProperty: true });
|
|
835
|
+
return getComputedStyle(el).visibility !== 'hidden';
|
|
836
|
+
};
|
|
837
|
+
const r64 = (v) => Math.round(v * 64) / 64;
|
|
838
|
+
const SKIP = new Set(['SCRIPT', 'STYLE', 'LINK', 'META', 'NOSCRIPT', 'TEMPLATE', 'BR', 'BASE', 'TITLE', 'HEAD']);
|
|
839
|
+
const MEDIA = new Set(['IMG', 'SVG', 'svg', 'VIDEO', 'CANVAS', 'IFRAME', 'INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'PICTURE', 'OBJECT', 'EMBED', 'HR']);
|
|
840
|
+
const luma = (el) => el.tagName.startsWith('LBX-') || el.hasAttribute('data-lbx') || el.hasAttribute('data-lbx-part');
|
|
841
|
+
const regions = new Set();
|
|
842
|
+
for (const sel of arg.regions) for (const el of qa(document, sel)) regions.add(el);
|
|
843
|
+
// The listing: the map's regions and Luma's listing hosts (their "show more" and pager are in them).
|
|
844
|
+
const listing = [...regions, ...document.querySelectorAll('lbx-search, lbx-plp')].filter((el) => el.isConnected && shown(el));
|
|
845
|
+
if (!listing.length) return null;
|
|
846
|
+
let top = Infinity;
|
|
847
|
+
let bottom = -Infinity;
|
|
848
|
+
for (const el of listing) {
|
|
849
|
+
const r = el.getBoundingClientRect();
|
|
850
|
+
top = Math.min(top, r.top + scrollY);
|
|
851
|
+
bottom = Math.max(bottom, r.bottom + scrollY);
|
|
852
|
+
}
|
|
853
|
+
const ignored = new Set();
|
|
854
|
+
for (const sel of arg.ignore) for (const el of qa(document, sel)) ignored.add(el);
|
|
855
|
+
// Not theirs to measure: in a region, Luma's, ignored by the map.
|
|
856
|
+
const ours = (el) => {
|
|
857
|
+
for (let e = el; e && e !== document.body; e = up(e)) if (regions.has(e) || luma(e) || ignored.has(e)) return true;
|
|
858
|
+
return false;
|
|
859
|
+
};
|
|
860
|
+
const holds = new Set();
|
|
861
|
+
for (const el of listing) for (let a = up(el); a; a = up(a)) holds.add(a);
|
|
862
|
+
const fixed = (el) => {
|
|
863
|
+
for (let e = el; e && e !== document.body; e = up(e)) if (getComputedStyle(e).position === 'fixed') return true;
|
|
864
|
+
return false;
|
|
865
|
+
};
|
|
866
|
+
const alpha = (c) => {
|
|
867
|
+
const m = /rgba?\(([^)]+)\)/.exec(c || '');
|
|
868
|
+
if (!m) return c && c !== 'transparent' ? 1 : 0;
|
|
869
|
+
const p = m[1].split(/[\s,/]+/).filter(Boolean);
|
|
870
|
+
return p.length > 3 ? Number(p[3]) : 1;
|
|
871
|
+
};
|
|
872
|
+
/** What the element itself draws: its own text, a medium, a background, a border, a shadow, an outline, a pseudo-element's content. */
|
|
873
|
+
const paints = (el) => {
|
|
874
|
+
if (MEDIA.has(el.tagName)) return true;
|
|
875
|
+
for (const n of el.childNodes) if (n.nodeType === 3 && n.textContent.trim()) return true;
|
|
876
|
+
const s = getComputedStyle(el);
|
|
877
|
+
if (s.backgroundImage !== 'none' || alpha(s.backgroundColor) > 0) return true;
|
|
878
|
+
for (const side of ['Top', 'Right', 'Bottom', 'Left']) if (parseFloat(s[`border${side}Width`]) > 0 && s[`border${side}Style`] !== 'none' && s[`border${side}Style`] !== 'hidden' && alpha(s[`border${side}Color`]) > 0) return true;
|
|
879
|
+
if (s.boxShadow !== 'none') return true;
|
|
880
|
+
if (s.outlineStyle !== 'none' && parseFloat(s.outlineWidth) > 0) return true;
|
|
881
|
+
for (const p of ['::before', '::after']) {
|
|
882
|
+
const c = getComputedStyle(el, p).content;
|
|
883
|
+
if (c && c !== 'none' && c !== 'normal' && c !== '""') return true;
|
|
884
|
+
}
|
|
885
|
+
return false;
|
|
886
|
+
};
|
|
887
|
+
const sig = (el) => {
|
|
888
|
+
const id = el.id && !/\d{3,}|^lbx/i.test(el.id) ? `#${el.id}` : '';
|
|
889
|
+
const cls = [...el.classList].filter((c) => !/^lbx/i.test(c)).sort().slice(0, 3).map((c) => `.${c}`).join('');
|
|
890
|
+
return `${el.tagName.toLowerCase()}${id}${cls}`;
|
|
891
|
+
};
|
|
892
|
+
/** Its path from the body, as blocksOf names a block (`:2` the second alike sibling). */
|
|
893
|
+
const pathOf = (el) => {
|
|
894
|
+
const segs = [];
|
|
895
|
+
for (let e = el; e && e !== document.body && e.parentElement; e = e.parentElement) {
|
|
896
|
+
const s = sig(e);
|
|
897
|
+
let n = 0;
|
|
898
|
+
for (const k of e.parentElement.children) {
|
|
899
|
+
if (SKIP.has(k.tagName) || sig(k) !== s) continue;
|
|
900
|
+
n++;
|
|
901
|
+
if (k === e) break;
|
|
902
|
+
}
|
|
903
|
+
segs.unshift(`${s}${n > 1 ? `:${n}` : ''}`);
|
|
904
|
+
}
|
|
905
|
+
return segs.join(' > ');
|
|
906
|
+
};
|
|
907
|
+
const px = (v) => Math.round(parseFloat(v) * 100) / 100 || 0;
|
|
908
|
+
const styleOf = (el) => {
|
|
909
|
+
const s = getComputedStyle(el);
|
|
910
|
+
const r = el.getBoundingClientRect();
|
|
911
|
+
return { height: r64(r.height), minHeight: s.minHeight, paddingTop: px(s.paddingTop), paddingBottom: px(s.paddingBottom), marginTop: px(s.marginTop), marginBottom: px(s.marginBottom) };
|
|
912
|
+
};
|
|
913
|
+
// Their elements, in document order, with their boxes in page coordinates.
|
|
914
|
+
const all = [];
|
|
915
|
+
for (const el of document.body.querySelectorAll('*')) {
|
|
916
|
+
if (SKIP.has(el.tagName) || regions.has(el) || holds.has(el) || ours(el) || !shown(el)) continue;
|
|
917
|
+
const r = el.getBoundingClientRect();
|
|
918
|
+
all.push({ el, top: r.top + scrollY, bottom: r.bottom + scrollY });
|
|
919
|
+
}
|
|
920
|
+
let above = null;
|
|
921
|
+
let below = null;
|
|
922
|
+
for (const x of all) {
|
|
923
|
+
if (x.top >= bottom - 1 && (!below || x.top < below.top) && paints(x.el) && !fixed(x.el)) below = x;
|
|
924
|
+
if (x.bottom <= top + 1 && (!above || x.bottom > above.bottom) && paints(x.el) && !fixed(x.el)) above = x;
|
|
925
|
+
}
|
|
926
|
+
/** The outermost elements in a band (y from a to b), the tallest first. */
|
|
927
|
+
const within = (a, b) => {
|
|
928
|
+
const out = [];
|
|
929
|
+
for (const x of all) {
|
|
930
|
+
if (x.top < a - 1 || x.bottom > b + 1 || x.bottom - x.top < 1 || fixed(x.el)) continue;
|
|
931
|
+
if (out.some((o) => o.el.contains(x.el))) continue;
|
|
932
|
+
out.push(x);
|
|
933
|
+
}
|
|
934
|
+
return out.sort((p, q) => q.bottom - q.top - (p.bottom - p.top) || p.top - q.top).slice(0, 4).map((x) => ({ path: pathOf(x.el), ...styleOf(x.el) }));
|
|
935
|
+
};
|
|
936
|
+
// Renders nothing: no text shown, nothing in it (itself included) that paints.
|
|
937
|
+
const blank = (el) => !(el.innerText ?? '').trim() && !paints(el) && ![...el.querySelectorAll('*')].some((k) => shown(k) && paints(k));
|
|
938
|
+
const limit = arg.empty ?? 100;
|
|
939
|
+
const edge = below ? below.top : Infinity;
|
|
940
|
+
const empty = [];
|
|
941
|
+
const seen = [];
|
|
942
|
+
for (const x of all) {
|
|
943
|
+
if (x.top < bottom - 1 || x.bottom > edge + 1 || x.bottom - x.top <= limit || seen.some((s) => s.contains(x.el))) continue;
|
|
944
|
+
if (!blank(x.el) || fixed(x.el)) continue;
|
|
945
|
+
seen.push(x.el);
|
|
946
|
+
empty.push({ path: pathOf(x.el), ...styleOf(x.el) });
|
|
947
|
+
}
|
|
948
|
+
const end = (x, at) => (x ? { path: pathOf(x.el), at: r64(at), ...styleOf(x.el) } : null);
|
|
949
|
+
return {
|
|
950
|
+
listing: { top: r64(top), bottom: r64(bottom) },
|
|
951
|
+
above: above ? { ...end(above, above.bottom), gap: r64(top - above.bottom) } : null,
|
|
952
|
+
below: below ? { ...end(below, below.top), gap: r64(below.top - bottom) } : null,
|
|
953
|
+
fillers: { above: above ? within(above.bottom, top) : [], below: below ? within(bottom, below.top) : [] },
|
|
954
|
+
empty,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Before any script (addInitScript): what each frame of the page painted
|
|
960
|
+
* (docs/PARITY.md, "The hold"), the technique of Luma's
|
|
961
|
+
* browser tests: a ResizeObserver on a 1 px probe whose
|
|
962
|
+
* width flips every frame samples each frame after its layout and before its
|
|
963
|
+
* paint. A frame shows `theirs` when one of their listing's elements
|
|
964
|
+
* (`theirs`, their components' selectors) is visible outside Luma's own
|
|
965
|
+
* elements, `ours` when Luma's listing shows content. Consecutive equal
|
|
966
|
+
* frames are counted: `window.__parityFrames` [{ n, theirs, ours }].
|
|
967
|
+
* @param {{ theirs: string[] }} spec
|
|
968
|
+
*/
|
|
969
|
+
export function framesInit(spec) {
|
|
970
|
+
if (window.__parityFrames || window.top !== window) return;
|
|
971
|
+
const F = (window.__parityFrames = []);
|
|
972
|
+
const luma = (el) => !!el.closest('lbx-search, lbx-plp, [data-lbx]');
|
|
973
|
+
const vis = (el) => {
|
|
974
|
+
if (!el.isConnected) return false;
|
|
975
|
+
const r = el.getBoundingClientRect();
|
|
976
|
+
if (!(r.width > 0 && r.height > 0)) return false;
|
|
977
|
+
return el.checkVisibility ? el.checkVisibility({ opacityProperty: true, visibilityProperty: true }) : getComputedStyle(el).visibility !== 'hidden';
|
|
978
|
+
};
|
|
979
|
+
const q = (sel) => {
|
|
980
|
+
try {
|
|
981
|
+
return [...document.querySelectorAll(sel)];
|
|
982
|
+
} catch {
|
|
983
|
+
return [];
|
|
984
|
+
}
|
|
985
|
+
};
|
|
986
|
+
const sample = () => {
|
|
987
|
+
const theirs = spec.theirs.some((s) => q(s).some((el) => !luma(el) && vis(el)));
|
|
988
|
+
const ours = q('lbx-search, lbx-plp').some((el) => vis(el) && !!el.querySelector('[data-lbx-list] > *, [data-lbx-part~="tile"]'));
|
|
989
|
+
const last = F[F.length - 1];
|
|
990
|
+
if (last && last.theirs === theirs && last.ours === ours) last.n++;
|
|
991
|
+
else if (F.length < 400) F.push({ n: 1, theirs, ours });
|
|
992
|
+
};
|
|
993
|
+
let probe = null;
|
|
994
|
+
let flip = false;
|
|
995
|
+
const ro = new ResizeObserver(() => sample());
|
|
996
|
+
const tick = () => {
|
|
997
|
+
if (!probe && document.documentElement) {
|
|
998
|
+
probe = document.createElement('lbx-parity-probe');
|
|
999
|
+
probe.style.cssText = 'position:fixed;top:0;left:0;height:1px;width:1px;pointer-events:none;opacity:0;contain:strict;';
|
|
1000
|
+
document.documentElement.appendChild(probe);
|
|
1001
|
+
ro.observe(probe);
|
|
1002
|
+
}
|
|
1003
|
+
if (probe) probe.style.width = (flip = !flip) ? '2px' : '1px';
|
|
1004
|
+
requestAnimationFrame(tick);
|
|
1005
|
+
};
|
|
1006
|
+
requestAnimationFrame(tick);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* The boxes the alignment compares (docs/PARITY.md, "Alignment"), in page
|
|
1011
|
+
* coordinates: per item, the first visible match of `root` (and of `part`
|
|
1012
|
+
* inside it), else null. An item with `field` is a search field: its visible
|
|
1013
|
+
* box is the wrapper that draws it (the outermost ancestor of the input, or
|
|
1014
|
+
* the input, with a border, a background or a shadow, no taller than the
|
|
1015
|
+
* input by more than 24 px and no wider than 3 times it), not the input
|
|
1016
|
+
* inset in it.
|
|
1017
|
+
* @param {{ items: { key: string, root?: string, part?: string | null, field?: string }[] }} arg
|
|
1018
|
+
*/
|
|
1019
|
+
export function edgesOf(arg) {
|
|
1020
|
+
const D = window.__parityDeep;
|
|
1021
|
+
const qa = (scope, sel) => {
|
|
1022
|
+
if (D) return D.all(scope, sel);
|
|
1023
|
+
try {
|
|
1024
|
+
return [...scope.querySelectorAll(sel)];
|
|
1025
|
+
} catch {
|
|
1026
|
+
return [];
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
const up = (e) => (D ? D.parent(e) : e.parentElement);
|
|
1030
|
+
const vis = (el) => {
|
|
1031
|
+
const r = el.getBoundingClientRect();
|
|
1032
|
+
return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden';
|
|
1033
|
+
};
|
|
1034
|
+
const r64 = (v) => Math.round(v * 64) / 64;
|
|
1035
|
+
const boxOf = (el) => {
|
|
1036
|
+
const r = el.getBoundingClientRect();
|
|
1037
|
+
return { x: r64(r.x + scrollX), y: r64(r.y + scrollY), width: r64(r.width), height: r64(r.height) };
|
|
1038
|
+
};
|
|
1039
|
+
const alpha = (c) => {
|
|
1040
|
+
const m = /rgba?\(([^)]+)\)/.exec(c || '');
|
|
1041
|
+
if (!m) return c && c !== 'transparent' ? 1 : 0;
|
|
1042
|
+
const p = m[1].split(/[\s,/]+/).filter(Boolean);
|
|
1043
|
+
return p.length > 3 ? Number(p[3]) : 1;
|
|
1044
|
+
};
|
|
1045
|
+
const draws = (el) => {
|
|
1046
|
+
const s = getComputedStyle(el);
|
|
1047
|
+
for (const side of ['Top', 'Right', 'Bottom', 'Left']) if (parseFloat(s[`border${side}Width`]) > 0 && s[`border${side}Style`] !== 'none' && alpha(s[`border${side}Color`]) > 0) return true;
|
|
1048
|
+
return s.boxShadow !== 'none' || alpha(s.backgroundColor) > 0 || s.backgroundImage !== 'none';
|
|
1049
|
+
};
|
|
1050
|
+
/** The box a shopper sees as the field: the outermost wrapper close around the input that draws something. */
|
|
1051
|
+
const fieldOf = (input) => {
|
|
1052
|
+
const ri = input.getBoundingClientRect();
|
|
1053
|
+
let best = draws(input) ? input : null;
|
|
1054
|
+
for (let e = up(input); e && e !== document.body; e = up(e)) {
|
|
1055
|
+
const r = e.getBoundingClientRect();
|
|
1056
|
+
if (r.height > ri.height + 24 || r.width > ri.width * 3 + 8) break;
|
|
1057
|
+
if (draws(e)) best = e;
|
|
1058
|
+
}
|
|
1059
|
+
return best ?? input;
|
|
1060
|
+
};
|
|
1061
|
+
const out = {};
|
|
1062
|
+
for (const it of arg.items) {
|
|
1063
|
+
let el = null;
|
|
1064
|
+
if (it.field) {
|
|
1065
|
+
const input = qa(document, it.field).find(vis);
|
|
1066
|
+
el = input ? fieldOf(input) : null;
|
|
1067
|
+
} else if (it.root) {
|
|
1068
|
+
const root = qa(document, it.root).find(vis) ?? null;
|
|
1069
|
+
el = root && it.part ? (qa(root, it.part).find(vis) ?? null) : root;
|
|
1070
|
+
}
|
|
1071
|
+
out[it.key] = el ? boxOf(el) : null;
|
|
1072
|
+
}
|
|
1073
|
+
return out;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Before any script (addInitScript): every DOM change of the page counted
|
|
1078
|
+
* (`window.__parityMutations.n`: children, attributes, text), the frame
|
|
1079
|
+
* probe's aside: what an action changed when it did not navigate
|
|
1080
|
+
* (docs/PARITY.md, "Behaviour").
|
|
1081
|
+
*/
|
|
1082
|
+
export function mutationsInit() {
|
|
1083
|
+
if (window.__parityMutations) return;
|
|
1084
|
+
const m = (window.__parityMutations = { n: 0 });
|
|
1085
|
+
const mine = (n) => !!n && n.nodeType === 1 && n.tagName === 'LBX-PARITY-PROBE';
|
|
1086
|
+
const start = () => {
|
|
1087
|
+
new MutationObserver((list) => {
|
|
1088
|
+
for (const r of list) {
|
|
1089
|
+
if (mine(r.target)) continue;
|
|
1090
|
+
if (r.type === 'childList' && [...r.addedNodes, ...r.removedNodes].every(mine)) continue;
|
|
1091
|
+
m.n++;
|
|
1092
|
+
}
|
|
1093
|
+
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, characterData: true });
|
|
1094
|
+
};
|
|
1095
|
+
if (document.documentElement) start();
|
|
1096
|
+
else
|
|
1097
|
+
new MutationObserver((_, o) => {
|
|
1098
|
+
if (!document.documentElement) return;
|
|
1099
|
+
o.disconnect();
|
|
1100
|
+
start();
|
|
1101
|
+
}).observe(document, { childList: true });
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* The element an interaction acts on (docs/PARITY.md, "Behaviour"): the
|
|
1106
|
+
* component's instance showing the paired product (`value`: its key
|
|
1107
|
+
* attribute's value; else the first visible instance), the part in it (else
|
|
1108
|
+
* the root itself), and in that the control (a label's checkbox, a
|
|
1109
|
+
* wrapper's select). Kept as `window.__parityTarget`; returns what it is and
|
|
1110
|
+
* the ids the instance carries (its data attributes' values: the product's
|
|
1111
|
+
* ids on that side).
|
|
1112
|
+
* @param {{ root: string, part: string | null, key: { attr: string, child?: string } | null, value: string | null }} arg
|
|
1113
|
+
*/
|
|
1114
|
+
export function targetOf(arg) {
|
|
1115
|
+
const D = window.__parityDeep;
|
|
1116
|
+
const qa = (scope, sel) => {
|
|
1117
|
+
if (D) return D.all(scope, sel);
|
|
1118
|
+
try {
|
|
1119
|
+
return [...scope.querySelectorAll(sel)];
|
|
1120
|
+
} catch {
|
|
1121
|
+
return [];
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
const vis = (el) => {
|
|
1125
|
+
const r = el.getBoundingClientRect();
|
|
1126
|
+
return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden';
|
|
1127
|
+
};
|
|
1128
|
+
const keyOf = (r) => {
|
|
1129
|
+
if (!arg.key) return null;
|
|
1130
|
+
const src = arg.key.child ? (qa(r, arg.key.child)[0] ?? null) : r;
|
|
1131
|
+
return src ? src.getAttribute(arg.key.attr) : null;
|
|
1132
|
+
};
|
|
1133
|
+
const roots = qa(document, arg.root).filter(vis);
|
|
1134
|
+
const root = (arg.value != null ? roots.find((r) => keyOf(r) === arg.value) : null) ?? roots[0] ?? null;
|
|
1135
|
+
window.__parityTarget = null;
|
|
1136
|
+
if (!root) return { found: false, why: `no ${arg.root} on the page` };
|
|
1137
|
+
const el = arg.part ? (qa(root, arg.part).find((e) => e.getClientRects().length > 0) ?? null) : root;
|
|
1138
|
+
if (!el) return { found: false, why: `no ${arg.part} in ${arg.root}` };
|
|
1139
|
+
const CONTROL = 'select, input:not([type=hidden]), button, a[href], [role=checkbox], [role=button], [role=slider]';
|
|
1140
|
+
const control = el.matches(CONTROL) ? el : (qa(el, CONTROL)[0] ?? el);
|
|
1141
|
+
window.__parityTarget = control;
|
|
1142
|
+
const ids = [];
|
|
1143
|
+
for (const e of [root, ...qa(root, '*')].slice(0, 200))
|
|
1144
|
+
for (const a of e.attributes) if (/^data-/.test(a.name) && a.value && a.value.length <= 64 && /\d/.test(a.value) && !ids.includes(a.value)) ids.push(a.value);
|
|
1145
|
+
return {
|
|
1146
|
+
found: true,
|
|
1147
|
+
key: keyOf(root),
|
|
1148
|
+
tag: control.tagName.toLowerCase(),
|
|
1149
|
+
type: control.getAttribute('type'),
|
|
1150
|
+
href: control.getAttribute('href'),
|
|
1151
|
+
checked: 'checked' in control ? !!control.checked : null,
|
|
1152
|
+
options: control.tagName === 'SELECT' ? [...control.options].map((o) => ({ value: o.value, label: o.textContent.trim(), selected: o.selected })) : null,
|
|
1153
|
+
ids: ids.slice(0, 20),
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* The state an interaction's watches read (docs/PARITY.md, "Behaviour"): per
|
|
1159
|
+
* watch, how many elements match, the first rendered one's text, its checked
|
|
1160
|
+
* state (a checkbox's, aria-checked, aria-pressed) and value; the target's
|
|
1161
|
+
* own checked state and value; the DOM changes so far (mutationsInit).
|
|
1162
|
+
* @param {{ watch: { name: string, sel: string | null }[] }} arg
|
|
1163
|
+
*/
|
|
1164
|
+
export function readWatches(arg) {
|
|
1165
|
+
const D = window.__parityDeep;
|
|
1166
|
+
const qa = (sel) => {
|
|
1167
|
+
if (D) return D.all(document, sel);
|
|
1168
|
+
try {
|
|
1169
|
+
return [...document.querySelectorAll(sel)];
|
|
1170
|
+
} catch {
|
|
1171
|
+
return [];
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
const checked = (e) => {
|
|
1175
|
+
if (!e) return null;
|
|
1176
|
+
if (e.tagName === 'INPUT' && /^(checkbox|radio)$/i.test(e.type)) return !!e.checked;
|
|
1177
|
+
if (e.hasAttribute('aria-checked')) return e.getAttribute('aria-checked') === 'true';
|
|
1178
|
+
if (e.hasAttribute('aria-pressed')) return e.getAttribute('aria-pressed') === 'true';
|
|
1179
|
+
return null;
|
|
1180
|
+
};
|
|
1181
|
+
const value = (e) => (e && (e.tagName === 'INPUT' || e.tagName === 'SELECT' || e.tagName === 'TEXTAREA') ? e.value : null);
|
|
1182
|
+
const out = {};
|
|
1183
|
+
for (const w of arg.watch) {
|
|
1184
|
+
if (!w.sel) {
|
|
1185
|
+
out[w.name] = null;
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
const all = qa(w.sel);
|
|
1189
|
+
const e = all.find((x) => x.getClientRects().length > 0) ?? all[0] ?? null;
|
|
1190
|
+
out[w.name] = { count: all.length, text: e ? (e.innerText ?? e.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 200) : null, checked: checked(e), value: value(e) };
|
|
1191
|
+
}
|
|
1192
|
+
const t = window.__parityTarget;
|
|
1193
|
+
return { watches: out, target: t && t.isConnected ? { checked: checked(t), value: value(t) } : null, mutations: window.__parityMutations ? window.__parityMutations.n : null };
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Our controls without a counterpart (docs/PARITY.md, "Behaviour"): in the
|
|
1198
|
+
* instance of each root (a component's: the paired product's, else the
|
|
1199
|
+
* first), the links, buttons, checkboxes, radios and selects that are none of
|
|
1200
|
+
* the mapped elements (`mapped`: ours of the parts theirs has too), hold none
|
|
1201
|
+
* and are in none. Each as a path from its root, to find it again on a fresh
|
|
1202
|
+
* page.
|
|
1203
|
+
* @param {{ items: { component: string, root: string, mapped: string[], key: { attr: string } | null, value: string | null }[], max?: number }} arg
|
|
1204
|
+
*/
|
|
1205
|
+
export function strayControls(arg) {
|
|
1206
|
+
const D = window.__parityDeep;
|
|
1207
|
+
const qa = (scope, sel) => {
|
|
1208
|
+
if (D) return D.all(scope, sel);
|
|
1209
|
+
try {
|
|
1210
|
+
return [...scope.querySelectorAll(sel)];
|
|
1211
|
+
} catch {
|
|
1212
|
+
return [];
|
|
1213
|
+
}
|
|
1214
|
+
};
|
|
1215
|
+
const vis = (el) => {
|
|
1216
|
+
const r = el.getBoundingClientRect();
|
|
1217
|
+
return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden';
|
|
1218
|
+
};
|
|
1219
|
+
const CONTROL = 'a[href], button, [role=button], input[type=checkbox], input[type=radio], [role=checkbox], select';
|
|
1220
|
+
const out = [];
|
|
1221
|
+
for (const it of arg.items) {
|
|
1222
|
+
const roots = qa(document, it.root).filter(vis);
|
|
1223
|
+
const root = (it.value != null && it.key ? roots.find((r) => r.getAttribute(it.key.attr) === it.value) : null) ?? roots[0];
|
|
1224
|
+
if (!root) continue;
|
|
1225
|
+
const mapped = it.mapped.flatMap((s) => qa(root, s));
|
|
1226
|
+
const covered = (e) => mapped.some((m) => m === e || m.contains(e) || e.contains(m));
|
|
1227
|
+
const controls = qa(root, CONTROL).filter((e) => e.getClientRects().length > 0 && !covered(e));
|
|
1228
|
+
for (const e of controls.slice(0, arg.max ?? 6)) {
|
|
1229
|
+
const path = [];
|
|
1230
|
+
for (let x = e; x && x !== root; x = x.parentElement) {
|
|
1231
|
+
const same = x.parentElement ? [...x.parentElement.children].filter((y) => y.tagName === x.tagName) : [x];
|
|
1232
|
+
path.unshift(`${x.tagName.toLowerCase()}${same.length > 1 ? `:nth-of-type(${same.indexOf(x) + 1})` : ''}`);
|
|
1233
|
+
}
|
|
1234
|
+
const label = (e.getAttribute('aria-label') || e.textContent || e.getAttribute('title') || e.getAttribute('href') || '').replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
1235
|
+
out.push({ component: it.component, path: `:scope > ${path.join(' > ')}`, tag: e.tagName.toLowerCase(), label, href: e.getAttribute('href') });
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
return out;
|
|
1239
|
+
}
|