@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/owner.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Who owns a difference `yarn luma parity` found: the integration's template (our
|
|
2
|
+
// markup and CSS: geometry, computed styles, fonts, structure, missing parts,
|
|
3
|
+
// order) or the data (the catalog, the API). Pure functions, no browser,
|
|
4
|
+
// unit-tested.
|
|
5
|
+
//
|
|
6
|
+
// A difference is the data's when it follows from the product data or the
|
|
7
|
+
// API, not from our markup or CSS:
|
|
8
|
+
// - a text that differs because the paired product's catalog value differs:
|
|
9
|
+
// ours shows the catalog's value (its title, its price), theirs shows
|
|
10
|
+
// another one that is no catalog value of the product (evidence: the
|
|
11
|
+
// field and the catalog's value);
|
|
12
|
+
// - their product missing from the catalog (unpaired: compare.mjs);
|
|
13
|
+
// - what the map declares the API cannot supply ("dataGaps", map.mjs GAPS:
|
|
14
|
+
// facet value counts, an empty state that cannot occur, autocomplete
|
|
15
|
+
// sections the channel lacks; or a component / "component.part");
|
|
16
|
+
// - on an instance whose text is the data's, its boxes and its pixels (a
|
|
17
|
+
// longer title is a wider, taller box).
|
|
18
|
+
// Everything else is the template's: an agent fixes those, the client the others.
|
|
19
|
+
import { GAPS } from './map.mjs';
|
|
20
|
+
|
|
21
|
+
/** Scalars of a catalog record as [field, value] (lists: each value; objects: dotted fields). @param {any} hit */
|
|
22
|
+
export const catalogValues = (hit) => {
|
|
23
|
+
const out = /** @type {[string, string | number][]} */ ([]);
|
|
24
|
+
const walk = (v, k, depth) => {
|
|
25
|
+
if (v == null || depth > 3) return;
|
|
26
|
+
if (typeof v === 'string' || typeof v === 'number') out.push([k, v]);
|
|
27
|
+
else if (Array.isArray(v)) for (const x of v.slice(0, 20)) walk(x, k, depth + 1);
|
|
28
|
+
else if (typeof v === 'object') for (const [kk, x] of Object.entries(v)) walk(x, `${k}.${kk}`, depth + 1);
|
|
29
|
+
};
|
|
30
|
+
if (hit && typeof hit === 'object') for (const [k, v] of Object.entries(hit)) walk(v, k, 0);
|
|
31
|
+
return out;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const normText = (s) => String(s ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The numbers a text can mean, whatever its separators: `€1,199.50`,
|
|
38
|
+
* `1 199,50 €`, `1.199,-` (each grouping read both ways).
|
|
39
|
+
* @param {string} text
|
|
40
|
+
*/
|
|
41
|
+
export const numbersIn = (text) => {
|
|
42
|
+
const out = new Set();
|
|
43
|
+
for (const m of String(text).matchAll(/\d[\d\s.,'’]*/g)) {
|
|
44
|
+
const t = m[0].replace(/[\s'’]/g, '').replace(/[.,]+$/, '');
|
|
45
|
+
if (!t) continue;
|
|
46
|
+
const seps = [...t.matchAll(/[.,]/g)].map((x) => x.index);
|
|
47
|
+
const add = (s) => {
|
|
48
|
+
const n = Number(s);
|
|
49
|
+
if (Number.isFinite(n)) out.add(n);
|
|
50
|
+
};
|
|
51
|
+
add(t.replace(/[.,]/g, ''));
|
|
52
|
+
if (seps.length) {
|
|
53
|
+
// The last separator as the decimal point, the others as grouping.
|
|
54
|
+
const last = seps[seps.length - 1];
|
|
55
|
+
add(`${t.slice(0, last).replace(/[.,]/g, '')}.${t.slice(last + 1)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return [...out];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** A text that is a price (or a number) and little else: digits, separators and at most a currency's few letters. @param {string} text */
|
|
62
|
+
const priceLike = (text) => /\d/.test(text) && String(text).replace(/[\d\s.,'’()+-]/g, '').length <= 5;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The catalog fields a text shows as they are: a string field equal to it
|
|
66
|
+
* (case and whitespace aside), a number field when the text is a price-like
|
|
67
|
+
* number of that value.
|
|
68
|
+
* @param {string} text @param {[string, string | number][]} values
|
|
69
|
+
*/
|
|
70
|
+
export const fieldsShown = (text, values) => {
|
|
71
|
+
const t = normText(text);
|
|
72
|
+
if (!t) return [];
|
|
73
|
+
const nums = priceLike(text) ? numbersIn(text) : [];
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const [k, v] of values) {
|
|
76
|
+
const hit = typeof v === 'number' ? nums.some((n) => Math.abs(n - v) < 0.005) : normText(v) === t || (priceLike(text) && priceLike(v) && numbersIn(v).some((x) => nums.some((n) => Math.abs(n - x) < 0.005)));
|
|
77
|
+
if (hit && !out.includes(k)) out.push(k);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A text difference on a paired product: the data's when ours shows a
|
|
84
|
+
* catalog value as it is and theirs shows no catalog value of the product
|
|
85
|
+
* (nor ours' value with more around it: that is their template composing).
|
|
86
|
+
* @param {string} theirs @param {string} ours @param {any} hit the paired catalog record
|
|
87
|
+
* @returns {{ owner: 'data', evidence: { field: string, catalog: string | number, theirs: string } } | { owner: 'template', why: string }}
|
|
88
|
+
*/
|
|
89
|
+
export const textOwner = (theirs, ours, hit) => {
|
|
90
|
+
if (!hit) return { owner: 'template', why: 'no catalog record paired' };
|
|
91
|
+
const values = catalogValues(hit);
|
|
92
|
+
const o = fieldsShown(ours, values);
|
|
93
|
+
if (!o.length) return { owner: 'template', why: 'ours shows no catalog value as it is' };
|
|
94
|
+
const t = fieldsShown(theirs, values);
|
|
95
|
+
if (t.some((f) => o.includes(f))) return { owner: 'template', why: `both show ${t.find((f) => o.includes(f))}, formatted differently` };
|
|
96
|
+
if (t.length) return { owner: 'template', why: `theirs shows ${t[0]}, ours ${o[0]}` };
|
|
97
|
+
const tt = normText(theirs);
|
|
98
|
+
if (o.some((f) => values.some(([k, v]) => k === f && typeof v === 'string' && normText(v).length >= 3 && tt.includes(normText(v))))) return { owner: 'template', why: `theirs adds to ${o[0]}` };
|
|
99
|
+
const catalog = values.find(([k]) => k === o[0])[1];
|
|
100
|
+
return { owner: 'data', evidence: { field: o[0], catalog, theirs } };
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** A facet value's text without its count: `Red (12)`, `Red 12`, `Red [12]`. @param {string} s */
|
|
104
|
+
export const withoutCounts = (s) => normText(String(s ?? '').replace(/[([]\s*\d[\d\s.,]*[)\]]/g, ' ').replace(/(^|\s)\d[\d\s.,]*(?=\s|$)/g, ' '));
|
|
105
|
+
|
|
106
|
+
const BOX = new Set(['x', 'y', 'width', 'height']);
|
|
107
|
+
const FACET = /^facet/;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Every difference of one instance with its owner (`owner`, and `evidence`
|
|
111
|
+
* or `why` for the data's).
|
|
112
|
+
* @param {any[]} diffs one instance's (compare.mjs Diff)
|
|
113
|
+
* @param {{ component?: string, role?: string | null, partRoles?: Record<string, string | null>, hit?: any, gaps?: Record<string, string>, text?: string | false }} ctx
|
|
114
|
+
*/
|
|
115
|
+
export const attribute = (diffs, ctx = {}) => {
|
|
116
|
+
const gaps = ctx.gaps ?? {};
|
|
117
|
+
const role = ctx.role ?? null;
|
|
118
|
+
const roleOfPart = (part) => (part === 'root' || part === 'grid' ? role : (ctx.partRoles?.[part] ?? null));
|
|
119
|
+
const dataText = new Set();
|
|
120
|
+
const out = diffs.map((d) => {
|
|
121
|
+
// A column count is the template's at any viewport, whatever the map declares: never the data's.
|
|
122
|
+
if (d.prop === 'columns') return { ...d, owner: 'template' };
|
|
123
|
+
const pr = roleOfPart(d.part);
|
|
124
|
+
const declared = gaps[`${ctx.component}.${d.part}`] ?? (d.part === 'root' || d.part === 'grid' ? gaps[`${ctx.component}.root`] : undefined) ?? (ctx.component in GAPS ? undefined : gaps[ctx.component]);
|
|
125
|
+
if (declared) return { ...d, owner: 'data', why: `dataGaps: ${declared}` };
|
|
126
|
+
if (gaps.empty && (role === 'empty' || role === 'ac-empty' || pr === 'empty' || pr === 'ac-empty')) return { ...d, owner: 'data', why: `dataGaps.empty: ${gaps.empty}` };
|
|
127
|
+
if (gaps.acSections && (['ac-section', 'ac-title'].includes(role) || ['ac-section', 'ac-title'].includes(pr) || (role === 'ac-panel' && d.part === 'root' && (d.prop === 'height' || d.prop === 'pixels'))))
|
|
128
|
+
return { ...d, owner: 'data', why: `dataGaps.acSections: ${gaps.acSections}` };
|
|
129
|
+
if (gaps.facetCounts && (FACET.test(role ?? '') || FACET.test(pr ?? ''))) {
|
|
130
|
+
if (d.prop === 'text' && withoutCounts(d.theirs) === withoutCounts(d.ours)) {
|
|
131
|
+
dataText.add(d.part);
|
|
132
|
+
return { ...d, owner: 'data', why: `dataGaps.facetCounts: ${gaps.facetCounts}` };
|
|
133
|
+
}
|
|
134
|
+
if (d.prop === 'presence' && /count/i.test(`${d.part} ${pr ?? ''}`)) return { ...d, owner: 'data', why: `dataGaps.facetCounts: ${gaps.facetCounts}` };
|
|
135
|
+
}
|
|
136
|
+
// The text of a paired product: by the catalog's evidence (shape-compared texts, counts, are not values).
|
|
137
|
+
if (d.prop === 'text' && d.delta !== 'different wording' && ctx.hit) {
|
|
138
|
+
const r = textOwner(d.theirs, d.ours, ctx.hit);
|
|
139
|
+
if (r.owner === 'data') {
|
|
140
|
+
dataText.add(d.part);
|
|
141
|
+
return { ...d, owner: 'data', evidence: r.evidence, why: `the catalog's ${r.evidence.field} is ${JSON.stringify(r.evidence.catalog)}; theirs shows another value` };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { ...d, owner: 'template' };
|
|
145
|
+
});
|
|
146
|
+
if (!dataText.size) return out;
|
|
147
|
+
// What a data text moves: the boxes of this instance and its pixels.
|
|
148
|
+
const from = [...dataText].join(', ');
|
|
149
|
+
return out.map((d) => (d.owner === 'template' && (BOX.has(d.prop) || d.prop === 'pixels') ? { ...d, owner: 'data', why: `follows from the data's text in ${from}` } : d));
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Recorded product decisions (the map's `excluded` with a `decision`, map.mjs
|
|
154
|
+
* `decided`): a template difference on a decided component, part or
|
|
155
|
+
* property is the decision's (`owner: 'decision'`, with it), not the
|
|
156
|
+
* template's: it counts as passed in the template score and is listed apart.
|
|
157
|
+
* Keys: `tile`, `tile.title`, `tile color`, `tile.title color`; `collateral`,
|
|
158
|
+
* `collateral.<block path>`, either with a property.
|
|
159
|
+
* @param {any[]} diffs @param {{ component?: string, decided?: Record<string, { reason: string, decision: string }> }} ctx
|
|
160
|
+
*/
|
|
161
|
+
export const decide = (diffs, { component = '', decided = {} } = {}) => {
|
|
162
|
+
if (!Object.keys(decided).length) return diffs;
|
|
163
|
+
return diffs.map((d) => {
|
|
164
|
+
if ((d.owner ?? 'template') !== 'template') return d;
|
|
165
|
+
const at = d.part === 'root' || d.part === 'grid' ? [component, `${component}.root`] : [component, `${component}.${d.part}`];
|
|
166
|
+
const keys = at.flatMap((w) => [`${w} ${d.prop}`, w]);
|
|
167
|
+
const k = keys.find((x) => decided[x]);
|
|
168
|
+
return k ? { ...d, owner: 'decision', decision: decided[k].decision, why: `decided (${k}): ${decided[k].reason}` } : d;
|
|
169
|
+
});
|
|
170
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// What `yarn luma parity` asks the API for their products with (parity/probe.mjs
|
|
2
|
+
// `alignInit`, and `--init`'s check of its pairing), and when the pairing has
|
|
3
|
+
// failed. Pure functions, unit-tested.
|
|
4
|
+
//
|
|
5
|
+
// Their products are fetched by id (`products.field` in their ids) together
|
|
6
|
+
// with a query: many channels answer the search endpoint's empty query with
|
|
7
|
+
// zero hits, so an id request with an empty query paired nothing and a run
|
|
8
|
+
// compared structure only, silently. The queries, in order, at most
|
|
9
|
+
// `MAX_QUERIES`: the map's page query (`pages.<name>.query`), our page's own
|
|
10
|
+
// query (its `q`, or what the page types; not on our search page against
|
|
11
|
+
// another page of theirs: our query is not their products'), their category's
|
|
12
|
+
// name (their page's heading), the most frequent words of their tiles'
|
|
13
|
+
// titles. Each next query asks only for the products the ones before did not
|
|
14
|
+
// find. When none finds any, the pairing has failed: a CRITICAL difference,
|
|
15
|
+
// never a silent fallback (`pairingFailure`).
|
|
16
|
+
|
|
17
|
+
/** Queries tried at most, besides a collections surface's request (which takes none). */
|
|
18
|
+
export const MAX_QUERIES = 4;
|
|
19
|
+
|
|
20
|
+
/** Query-string params a search page's query is in. */
|
|
21
|
+
export const QUERY_PARAMS = Object.freeze(['q', 'query', 'search', 's', 'term', 'text', 'phrase', 'keyword', 'keywords', 'searchTerm', 'search_query']);
|
|
22
|
+
|
|
23
|
+
/** Words that find nothing on their own (articles, prepositions; English and the shops' usual languages). */
|
|
24
|
+
const STOP = new Set(
|
|
25
|
+
'the and for with from new set pcs pack size der die das und mit fur für les des pour avec una uno con per del della pro pre ako alebo bez na za od do pod nad'.split(' '),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/** Words of a text, lower-cased, in order. @param {string} s */
|
|
29
|
+
const wordsOf = (s) => String(s ?? '').toLowerCase().normalize('NFC').split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The words most of their titles share, most frequent first (each title
|
|
33
|
+
* counted once per word; ties by the word): `["shoe", "leather"]`. Numbers,
|
|
34
|
+
* words of fewer than 3 letters and stop words left out.
|
|
35
|
+
* @param {string[]} titles @param {number} [n]
|
|
36
|
+
*/
|
|
37
|
+
export const frequentWords = (titles, n = 2) => {
|
|
38
|
+
const freq = new Map();
|
|
39
|
+
for (const t of titles) {
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
for (const w of wordsOf(t)) {
|
|
42
|
+
if (w.length < 3 || /\d/.test(w) || STOP.has(w) || seen.has(w)) continue;
|
|
43
|
+
seen.add(w);
|
|
44
|
+
freq.set(w, (freq.get(w) ?? 0) + 1);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return [...freq]
|
|
48
|
+
.filter(([, c]) => c >= 2 || titles.length < 2)
|
|
49
|
+
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
|
|
50
|
+
.slice(0, n)
|
|
51
|
+
.map(([w]) => w);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** A page path's search query (`/search?q=lamp` -> `lamp`), or ''. @param {string | null | undefined} path */
|
|
55
|
+
export const queryOfPath = (path) => {
|
|
56
|
+
try {
|
|
57
|
+
const u = new URL(String(path ?? ''), 'https://x.invalid/');
|
|
58
|
+
for (const k of QUERY_PARAMS) {
|
|
59
|
+
const v = u.searchParams.get(k);
|
|
60
|
+
if (v && v.trim()) return v.trim();
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
return '';
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The queries their products are asked for with, in order, each once (case
|
|
68
|
+
* aside), at most `max`; never empty: `['']` when nothing gives one.
|
|
69
|
+
* @param {{ queries?: string[], oursPath?: string | null, typed?: string | null, cross?: boolean, heading?: string | null, titles?: string[], max?: number }} o
|
|
70
|
+
*/
|
|
71
|
+
export const queryCandidates = ({ queries = [], oursPath = null, typed = null, cross = false, heading = null, titles = [], max = MAX_QUERIES }) => {
|
|
72
|
+
const out = [];
|
|
73
|
+
const add = (q) => {
|
|
74
|
+
const v = String(q ?? '').replace(/\s+/g, ' ').trim();
|
|
75
|
+
if (v && v.length <= 80 && !out.some((x) => x.toLowerCase() === v.toLowerCase())) out.push(v);
|
|
76
|
+
};
|
|
77
|
+
for (const q of queries) add(q);
|
|
78
|
+
// Our own query: their products' on the same page; on another page of theirs it is ours alone.
|
|
79
|
+
if (!cross) {
|
|
80
|
+
add(queryOfPath(oursPath));
|
|
81
|
+
add(typed);
|
|
82
|
+
}
|
|
83
|
+
add(heading);
|
|
84
|
+
for (const w of frequentWords(titles)) add(w);
|
|
85
|
+
return out.length ? out.slice(0, max) : [''];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether their products' pairing failed on a page: their page showed none
|
|
90
|
+
* (no ids), our build asked for none of their type, or every request found
|
|
91
|
+
* none of them (`aligned`: probe.mjs `window.__parity.aligned`). Null when it
|
|
92
|
+
* paired (some products unpaired are the data's: not in the catalog).
|
|
93
|
+
* @param {string[] | null} ids their ids (null: the page pairs nothing)
|
|
94
|
+
* @param {{ asked: number, found: number, endpoint: string, queries?: { endpoint: string, query: string | null, asked: number, found: number }[] }[]} aligned
|
|
95
|
+
* @param {{ selector?: string, type?: string }} [o]
|
|
96
|
+
* @returns {{ why: string, queries: { endpoint: string, query: string | null, asked: number, found: number }[] } | null}
|
|
97
|
+
*/
|
|
98
|
+
export const pairingFailure = (ids, aligned, o = {}) => {
|
|
99
|
+
if (ids == null) return null;
|
|
100
|
+
if (!ids.length) return { why: `no product ids on their page ("${o.selector ?? ''}"): nothing to pair`, queries: [] };
|
|
101
|
+
if (!aligned.length) return { why: `our page made no request for products of type "${o.type ?? 'product'}": nothing to pair (products.type, or the build's own request failed)`, queries: [] };
|
|
102
|
+
const best = aligned.reduce((a, b) => (b.found > a.found ? b : a));
|
|
103
|
+
if (best.found > 0) return null;
|
|
104
|
+
const queries = best.queries ?? [];
|
|
105
|
+
const tried = queries.map((q) => (q.query == null ? `${q.endpoint} (no query)` : `"${q.query}"`)).join(', ');
|
|
106
|
+
return { why: `none of their ${ids.length} products found by the API${tried ? ` (tried ${tried})` : ''}: set pages.<page>.query to a query that finds them, or products.field / products.pair`, queries };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** The queries tried, as the report shows them: `"lamp" 6/6`, `collections 0/6`. @param {{ endpoint: string, query: string | null, asked: number, found: number }[]} queries */
|
|
110
|
+
export const formatQueries = (queries = []) => queries.map((q) => `${q.query == null ? `${q.endpoint} (by id, no query)` : `"${q.query}"`} ${q.found}/${q.asked}`).join(', ');
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The pixel diff of `yarn luma parity`: a secondary signal next to the property
|
|
2
|
+
// comparison (parity/compare.mjs), never a measurement of a value. Two crops
|
|
3
|
+
// of one component (theirs, ours, PNG from the same DPR and viewport), padded
|
|
4
|
+
// to the same size (the padding counts as differing: a component 4 px taller
|
|
5
|
+
// is 4 px rows of difference), compared with pixelmatch (anti-aliasing
|
|
6
|
+
// ignored), and the diff image.
|
|
7
|
+
import pixelmatch from 'pixelmatch';
|
|
8
|
+
import { PNG } from 'pngjs';
|
|
9
|
+
|
|
10
|
+
/** Pads a decoded PNG to w x h with a colour no page paints (magenta), top-left aligned. */
|
|
11
|
+
const pad = (png, w, h) => {
|
|
12
|
+
if (png.width === w && png.height === h) return png.data;
|
|
13
|
+
const out = Buffer.alloc(w * h * 4);
|
|
14
|
+
for (let i = 0; i < w * h; i++) out.set([255, 0, 255, 255], i * 4);
|
|
15
|
+
for (let y = 0; y < png.height; y++) png.data.copy(out, y * w * 4, y * png.width * 4, (y + 1) * png.width * 4);
|
|
16
|
+
return out;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {Buffer} theirs PNG
|
|
21
|
+
* @param {Buffer} ours PNG
|
|
22
|
+
* @param {{ threshold?: number }} [opts] pixelmatch's per-pixel colour threshold (0-1)
|
|
23
|
+
* @returns {{ ratio: number, differing: number, total: number, size: { theirs: string, ours: string }, diff: Buffer }}
|
|
24
|
+
*/
|
|
25
|
+
export const diffPng = (theirs, ours, opts = {}) => {
|
|
26
|
+
const a = PNG.sync.read(theirs);
|
|
27
|
+
const b = PNG.sync.read(ours);
|
|
28
|
+
const w = Math.max(a.width, b.width);
|
|
29
|
+
const h = Math.max(a.height, b.height);
|
|
30
|
+
const out = new PNG({ width: w, height: h });
|
|
31
|
+
const differing = w && h ? pixelmatch(pad(a, w, h), pad(b, w, h), out.data, w, h, { threshold: opts.threshold ?? 0.1, includeAA: false, alpha: 0.2 }) : 0;
|
|
32
|
+
return { ratio: w * h ? differing / (w * h) : 0, differing, total: w * h, size: { theirs: `${a.width}x${a.height}`, ours: `${b.width}x${b.height}` }, diff: PNG.sync.write(out) };
|
|
33
|
+
};
|