@luigisbox/luma 0.0.0-stage → 0.1.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 +488 -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 +498 -0
- package/chrome/run.mjs +145 -0
- package/docs/ACCESSIBILITY.md +502 -0
- package/docs/INTEGRATION-PERFORMANCE.md +214 -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/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 +311 -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 +120 -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/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 +443 -0
- package/src/types.ts +760 -0
- package/src/widgets/autocomplete/index.ts +1367 -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 +1769 -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 +250 -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/src/core/a11y.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Accessibility helpers shared by the widgets (docs/ACCESSIBILITY.md): live
|
|
2
|
+
// regions, attributes borrowed on the host's elements, focus moves and traps,
|
|
3
|
+
// colour contrast, and the high-contrast mode. Widget chunks only: never the
|
|
4
|
+
// entry, never the server renderer.
|
|
5
|
+
|
|
6
|
+
import { layered, setCss } from './styles';
|
|
7
|
+
|
|
8
|
+
/** Visually hidden, still read by screen readers (inline: no stylesheet needed). */
|
|
9
|
+
export const SR_ONLY =
|
|
10
|
+
'position:absolute!important;width:1px!important;height:1px!important;margin:-1px!important;padding:0!important;border:0!important;overflow:hidden!important;clip-path:inset(50%)!important;white-space:nowrap!important;';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A polite status region (WCAG 4.1.3). `say(text)` announces it once the
|
|
14
|
+
* caller has been quiet for `delay` ms (a burst of renders is one message);
|
|
15
|
+
* the same text twice is announced twice (it is cleared first).
|
|
16
|
+
*/
|
|
17
|
+
export const liveRegion = () => {
|
|
18
|
+
const el = document.createElement('div');
|
|
19
|
+
el.setAttribute('role', 'status');
|
|
20
|
+
el.setAttribute('aria-live', 'polite');
|
|
21
|
+
el.setAttribute('aria-atomic', 'true');
|
|
22
|
+
el.style.cssText = SR_ONLY;
|
|
23
|
+
let timer = 0;
|
|
24
|
+
return {
|
|
25
|
+
el,
|
|
26
|
+
say(text: string, delay = 150) {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
el.textContent = '';
|
|
29
|
+
// In a later task: a region whose text is replaced in the same task as it appears is not announced.
|
|
30
|
+
timer = window.setTimeout(() => void (el.textContent = text), delay);
|
|
31
|
+
},
|
|
32
|
+
clear() {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
el.textContent = '';
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Attributes we put on an element that is not ours (the host's search input).
|
|
41
|
+
* `restore()` puts back what was there, except where the host has changed an
|
|
42
|
+
* attribute since we set it: then the host's value is the current truth and
|
|
43
|
+
* stays (a framework that manages its own ARIA keeps it).
|
|
44
|
+
*/
|
|
45
|
+
export const borrow = (el: Element) => {
|
|
46
|
+
const saved = new Map<string, { was: string | null; ours: string | null }>();
|
|
47
|
+
return {
|
|
48
|
+
el,
|
|
49
|
+
set(name: string, value: string | null) {
|
|
50
|
+
const now = el.getAttribute(name);
|
|
51
|
+
const s = saved.get(name);
|
|
52
|
+
if (!s) saved.set(name, { was: now, ours: value });
|
|
53
|
+
else s.ours = value;
|
|
54
|
+
if (value == null) now != null && el.removeAttribute(name);
|
|
55
|
+
else if (now !== value) el.setAttribute(name, value);
|
|
56
|
+
},
|
|
57
|
+
restore() {
|
|
58
|
+
for (const [name, { was, ours }] of saved) {
|
|
59
|
+
if (el.getAttribute(name) !== ours) continue; // the host's own write since ours
|
|
60
|
+
if (was == null) el.removeAttribute(name);
|
|
61
|
+
else el.setAttribute(name, was);
|
|
62
|
+
}
|
|
63
|
+
saved.clear();
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Its palette, as custom properties on the root: every rule of ours reads
|
|
70
|
+
* them with its normal value as the fallback (`var(--lbx-c-line, #ccc)`), and
|
|
71
|
+
* so can an integration's CSS. On with the visitor's OS setting
|
|
72
|
+
* (`prefers-contrast: more`), unless they switched ours off, or with our
|
|
73
|
+
* toggle (`data-lbx-contrast` on <html>). Forced colours (Windows contrast
|
|
74
|
+
* themes) are the browser's: our CSS only keeps state visible there (system
|
|
75
|
+
* colours, outlines). Pure CSS: no script runs for the OS setting.
|
|
76
|
+
*/
|
|
77
|
+
// Written minified, a rule per line (the build joins the lines): every widget's.
|
|
78
|
+
const PALETTE =
|
|
79
|
+
'--lbx-c:1;--lbx-c-fg:#000;--lbx-c-bg:#fff;--lbx-c-line:#000;--lbx-c-muted:#000;--lbx-c-accent:#000;' +
|
|
80
|
+
'--lbx-c-sel-fg:#fff;--lbx-c-sel-bg:#000;--lbx-c-ring:3px;--lbx-c-border:2px;--lbx-c-dim:1;--lbx-c-underline:underline';
|
|
81
|
+
const OURS = ':where(lbx-search,lbx-plp,lbx-reco,lbx-reco-pop,[data-lbx="ac-overlay"]) :focus-visible';
|
|
82
|
+
export const CONTRAST_CSS =
|
|
83
|
+
`\n@media (prefers-contrast:more){:root:not([data-lbx-contrast="off"]){${PALETTE}}}` +
|
|
84
|
+
`:root[data-lbx-contrast="more"]{${PALETTE}}` +
|
|
85
|
+
`.lbx-sr-only:not(:focus):not(:focus-within){${SR_ONLY}}` +
|
|
86
|
+
'.lbx-contrast-toggle{font:inherit;margin:0 0 8px;padding:4px 10px;min-height:24px;border:2px solid currentColor;border-radius:4px;background:Canvas;color:CanvasText;cursor:pointer}' +
|
|
87
|
+
`@media (prefers-contrast:no-preference){:root:not([data-lbx-contrast="more"]) .lbx-contrast-toggle:not(:focus){${SR_ONLY}}}` +
|
|
88
|
+
`${OURS}{outline:var(--lbx-c-ring,2px) solid;outline-offset:2px}` +
|
|
89
|
+
`@media (forced-colors:active){${OURS}{outline:3px solid Highlight!important;outline-offset:2px!important}}` +
|
|
90
|
+
`@media (prefers-contrast:more){:root:not([data-lbx-contrast="off"]) ${OURS}{outline:3px solid!important;outline-offset:2px!important}}` +
|
|
91
|
+
`:root[data-lbx-contrast="more"] ${OURS}{outline:3px solid!important;outline-offset:2px!important}\n`;
|
|
92
|
+
|
|
93
|
+
const KEY = 'lbx:contrast';
|
|
94
|
+
const stored = () => {
|
|
95
|
+
try {
|
|
96
|
+
const v = localStorage.getItem(KEY);
|
|
97
|
+
return v === 'more' || v === 'off' ? v : null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const more = () => (typeof matchMedia === 'function' ? matchMedia('(prefers-contrast: more)') : null);
|
|
103
|
+
const prefersMore = () => !!more()?.matches;
|
|
104
|
+
|
|
105
|
+
/** The mode's state: the visitor's toggle, else their OS setting ('more'), else none. */
|
|
106
|
+
const state = () => stored() ?? (prefersMore() ? 'more' : null);
|
|
107
|
+
/** The high-contrast mode is on. */
|
|
108
|
+
export const contrastOn = () => state() === 'more';
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* On <html>, for integrations' CSS: `data-lbx-contrast="more"` while the mode
|
|
112
|
+
* is on (the toggle, or the OS setting: one selector for both), `"off"` when
|
|
113
|
+
* the visitor switched it off against their OS setting.
|
|
114
|
+
*/
|
|
115
|
+
const apply = () => {
|
|
116
|
+
const v = state();
|
|
117
|
+
const html = document.documentElement;
|
|
118
|
+
if (!v) html.removeAttribute('data-lbx-contrast');
|
|
119
|
+
else if (html.getAttribute('data-lbx-contrast') !== v) html.setAttribute('data-lbx-contrast', v);
|
|
120
|
+
for (const b of document.querySelectorAll('.lbx-contrast-toggle')) b.setAttribute('aria-pressed', String(v === 'more'));
|
|
121
|
+
};
|
|
122
|
+
let watching = false;
|
|
123
|
+
/** The mode's sheet and state, applied (every widget, at its start), and kept in step with the OS setting. */
|
|
124
|
+
export const useContrast = () => {
|
|
125
|
+
setCss('lbx:contrast', layered(CONTRAST_CSS));
|
|
126
|
+
if (!watching) {
|
|
127
|
+
watching = true;
|
|
128
|
+
more()?.addEventListener?.('change', apply);
|
|
129
|
+
}
|
|
130
|
+
apply();
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Switch it (our toggle): remembered for this browser; back to "as the OS says" when that matches. */
|
|
134
|
+
export const setContrast = (on: boolean) => {
|
|
135
|
+
const v = on === prefersMore() ? null : on ? 'more' : 'off';
|
|
136
|
+
try {
|
|
137
|
+
if (v) localStorage.setItem(KEY, v);
|
|
138
|
+
else localStorage.removeItem(KEY);
|
|
139
|
+
} catch {}
|
|
140
|
+
apply();
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Modelled on the previous-generation Luma's switch (`_global.contrastToggle`): a button that turns the
|
|
145
|
+
* mode on and off. Visually hidden until focused unless the visitor's OS asks
|
|
146
|
+
* for a contrast preference or the mode is on (as that one was), so a
|
|
147
|
+
* keyboard user who reaches it always sees it.
|
|
148
|
+
*/
|
|
149
|
+
export const contrastToggle = (label: string) => {
|
|
150
|
+
const b = document.createElement('button');
|
|
151
|
+
b.type = 'button';
|
|
152
|
+
b.className = 'lbx-contrast-toggle';
|
|
153
|
+
b.textContent = label;
|
|
154
|
+
b.setAttribute('aria-pressed', String(contrastOn()));
|
|
155
|
+
b.addEventListener('click', (e) => {
|
|
156
|
+
e.stopPropagation();
|
|
157
|
+
setContrast(!contrastOn());
|
|
158
|
+
});
|
|
159
|
+
return b;
|
|
160
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The page's state when the entry ran, for code that arrives later in a chunk
|
|
2
|
+
// (core/reconciler.ts, in lbx-place): a chunk's own module code runs when the
|
|
3
|
+
// chunk arrives, which may be after `load` even where the entry was not.
|
|
4
|
+
|
|
5
|
+
/** Booting after `load` (a script the host injected late): the framework has hydrated already. */
|
|
6
|
+
export const lateBoot = typeof document !== 'undefined' && document.readyState === 'complete';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Colour contrast (WCAG 1.4.3 / 1.4.11): the autocomplete's house design reads
|
|
2
|
+
// the site's colours with it. Only the autocomplete uses it: in its chunk
|
|
3
|
+
// (build/vite.mjs), not lbx-core, which every widget loads.
|
|
4
|
+
|
|
5
|
+
/** rgb()/rgba() (as getComputedStyle reports colours) or #rgb/#rrggbb → [r, g, b, a]; null otherwise. */
|
|
6
|
+
export const parseColor = (c: string): [number, number, number, number] | null => {
|
|
7
|
+
const s = c.trim().toLowerCase();
|
|
8
|
+
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(s);
|
|
9
|
+
if (hex) {
|
|
10
|
+
const h = hex[1].length === 3 ? [...hex[1]].map((x) => x + x).join('') : hex[1];
|
|
11
|
+
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), 1];
|
|
12
|
+
}
|
|
13
|
+
const m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+%?))?\s*\)$/.exec(s);
|
|
14
|
+
if (!m) return null;
|
|
15
|
+
const a = m[4] == null ? 1 : m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
|
|
16
|
+
return [+m[1], +m[2], +m[3], a];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const luminance = ([r, g, b]: number[]) => {
|
|
20
|
+
const ch = (v: number) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
|
|
21
|
+
return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** A colour's relative luminance (0 black .. 1 white); null for one we cannot read. */
|
|
25
|
+
export const luminanceOf = (c: string) => {
|
|
26
|
+
const p = parseColor(c);
|
|
27
|
+
return p ? luminance(p) : null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** WCAG contrast ratio of two colours (a translucent foreground over the background); 1 for one we cannot read. */
|
|
31
|
+
export const contrastRatio = (fg: string, bg: string) => {
|
|
32
|
+
const f = parseColor(fg);
|
|
33
|
+
const b = parseColor(bg);
|
|
34
|
+
if (!f || !b) return 1;
|
|
35
|
+
const over = f.slice(0, 3).map((x, i) => x * f[3] + b[i] * (1 - f[3]));
|
|
36
|
+
const [l1, l2] = [luminance(over), luminance(b)].sort((x, y) => y - x);
|
|
37
|
+
return (l1 + 0.05) / (l2 + 0.05);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** `color` when it reads on `bg` (at least `min`: 4.5 for text), else `fallback`. */
|
|
41
|
+
export const readable = (color: string, bg: string, fallback: string, min = 4.5) => (contrastRatio(color, bg) >= min ? color : fallback);
|
|
42
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// A widget's context (config, translator, templates, helpers) once its chunk
|
|
2
|
+
// has loaded. In the core chunk (core/support.ts re-exports it), not the
|
|
3
|
+
// entry: nothing on a page without a widget needs it. No DOM: the edge render
|
|
4
|
+
// service builds a server-rendered listing's context with it too.
|
|
5
|
+
|
|
6
|
+
import type { Templates } from './templates';
|
|
7
|
+
import type { LumaConfigType } from '../types';
|
|
8
|
+
import type { WidgetContext } from '../main';
|
|
9
|
+
import { nameOf, slot } from './integration';
|
|
10
|
+
|
|
11
|
+
type Support = typeof import('./support');
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
export const widgetContext = (
|
|
15
|
+
s: Support,
|
|
16
|
+
config: LumaConfigType,
|
|
17
|
+
w: { id: string; folders: string[]; app: any; scopes: string[] },
|
|
18
|
+
builtIn: Templates,
|
|
19
|
+
tpls: readonly (readonly [string, any])[],
|
|
20
|
+
css: readonly (readonly [string, any])[],
|
|
21
|
+
dict: any,
|
|
22
|
+
/** Server-side renders: the storefront origin the API calls are made for. */
|
|
23
|
+
origin?: string,
|
|
24
|
+
): WidgetContext => {
|
|
25
|
+
const g = config._global;
|
|
26
|
+
const locale = g.locale ?? 'en';
|
|
27
|
+
// Precedence: the widget's built-in templates, then _global/, then its folders in order.
|
|
28
|
+
const templates: Templates = { ...builtIn };
|
|
29
|
+
for (const [path, t] of tpls) templates[nameOf(path)] = t;
|
|
30
|
+
const t = s.translator(dict, locale, [g.translations, ...w.folders.map((f) => (f === '_global' ? undefined : slot(config, f).translations))], w.scopes);
|
|
31
|
+
// One formatter, made again only if the currency or its format changes (an
|
|
32
|
+
// _init.ts may set them per page): a new Intl.NumberFormat per call was the
|
|
33
|
+
// costliest call of a listing's render (a production storefront, 4x CPU: 125 ms of 670 for
|
|
34
|
+
// a page of tiles, each step of the first render calling it again per tile).
|
|
35
|
+
let fmt: Intl.NumberFormat | null = null;
|
|
36
|
+
let fmtFor: [unknown, unknown] = [undefined, undefined];
|
|
37
|
+
// And each amount once: a render re-renders its tiles, each with its prices.
|
|
38
|
+
const formatted = new Map<number, string>();
|
|
39
|
+
// Whether this widget's templates print prices with the helper, from the
|
|
40
|
+
// last page with it (localStorage; unknown on a first visit).
|
|
41
|
+
const USED = `lbx:price:${w.id}`;
|
|
42
|
+
let called = false;
|
|
43
|
+
const price = (n: unknown) => {
|
|
44
|
+
if (!called) {
|
|
45
|
+
called = true;
|
|
46
|
+
try {
|
|
47
|
+
localStorage.setItem(USED, '1');
|
|
48
|
+
} catch {}
|
|
49
|
+
}
|
|
50
|
+
const x = s.first(n);
|
|
51
|
+
if (x == null || x === '') return ''; // Number('') would be 0
|
|
52
|
+
const v = Number(x);
|
|
53
|
+
if (!Number.isFinite(v)) return '';
|
|
54
|
+
if (!fmt || fmtFor[0] !== g.currency || fmtFor[1] !== g.priceFormat) {
|
|
55
|
+
fmt = new Intl.NumberFormat(locale, { ...(g.currency ? { style: 'currency', currency: g.currency } : {}), ...g.priceFormat });
|
|
56
|
+
fmtFor = [g.currency, g.priceFormat];
|
|
57
|
+
formatted.clear();
|
|
58
|
+
}
|
|
59
|
+
let out = formatted.get(v);
|
|
60
|
+
if (out === undefined) {
|
|
61
|
+
if (formatted.size >= 1000) formatted.clear();
|
|
62
|
+
formatted.set(v, (out = fmt.format(v)));
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
};
|
|
66
|
+
// The page's first Intl.NumberFormat loads the locale's data (~50 ms at 4x
|
|
67
|
+
// CPU, a production storefront): made while the browser is idle (a widget's first request
|
|
68
|
+
// is out), not in the render that shows its first results. Not when the
|
|
69
|
+
// last page with this widget never called the helper: a storefront that formats its
|
|
70
|
+
// own prices loaded the locale's data in one long idle task for nothing on
|
|
71
|
+
// every page. Built-in templates that use it may be in the
|
|
72
|
+
// widget's set unused, so it is what the page did, not what the templates say.
|
|
73
|
+
if (typeof requestIdleCallback === 'function') {
|
|
74
|
+
let used: string | null = null;
|
|
75
|
+
try {
|
|
76
|
+
used = localStorage.getItem(USED);
|
|
77
|
+
} catch {}
|
|
78
|
+
if (used !== '0')
|
|
79
|
+
requestIdleCallback(() => {
|
|
80
|
+
const was = called;
|
|
81
|
+
called = true; // warming is not a use
|
|
82
|
+
price(0);
|
|
83
|
+
called = was;
|
|
84
|
+
});
|
|
85
|
+
globalThis.addEventListener?.(
|
|
86
|
+
'pagehide',
|
|
87
|
+
() => {
|
|
88
|
+
if (called) return;
|
|
89
|
+
try {
|
|
90
|
+
localStorage.setItem(USED, '0');
|
|
91
|
+
} catch {}
|
|
92
|
+
},
|
|
93
|
+
{ once: true },
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
id: w.id,
|
|
98
|
+
config: w.app,
|
|
99
|
+
global: g,
|
|
100
|
+
api: s.getApi(g, origin),
|
|
101
|
+
t,
|
|
102
|
+
templates,
|
|
103
|
+
styles: css.map(([, c]) => c),
|
|
104
|
+
result: s.resultFactory({ ...g.computedAttributes, ...w.app.computedAttributes }),
|
|
105
|
+
// Images: a listing's first row is eager; elsewhere (recommenders, the autocomplete) every image is lazy.
|
|
106
|
+
helpers: { t, price, plain: s.plain, first: s.first, ...s.imageHelpers(g.images, w.id !== 'search' && w.id !== 'plp') },
|
|
107
|
+
};
|
|
108
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Rules for the inline early-hold snippet (snippet/early-hold.js).
|
|
2
|
+
//
|
|
3
|
+
// The snippet is generic and identical for every client: inline in <head>, it
|
|
4
|
+
// reads these rules from localStorage and, on a matching URL, adds the route's
|
|
5
|
+
// CSS before first paint (the host's listing hidden, a loading bar), with a
|
|
6
|
+
// timeout; the bundle claims that hold at boot (core/takeover.ts). The
|
|
7
|
+
// bundle writes the rules on every page view (only when they changed), so they
|
|
8
|
+
// always match the build that is running; the first page view in a browser
|
|
9
|
+
// has none (no hold: the async behaviour).
|
|
10
|
+
//
|
|
11
|
+
// The matchers are a conservative copy of the routes (path, required and
|
|
12
|
+
// allowed params, no values): a URL they accept that the bundle does not is
|
|
13
|
+
// dropped at boot. The CSS must only hit elements specific to the page type
|
|
14
|
+
// (on sites where category and product URLs look alike).
|
|
15
|
+
|
|
16
|
+
import { holdCss } from './hold';
|
|
17
|
+
import { P, TRACKING } from './routes';
|
|
18
|
+
import type { PlpConfigType, SearchConfigType } from '../types';
|
|
19
|
+
|
|
20
|
+
const EARLY_KEY = 'lbx:early';
|
|
21
|
+
// Short: when the bundle stops running (switched off, blocked), holds end soon after.
|
|
22
|
+
const TTL = 2 * 24 * 3600e3;
|
|
23
|
+
|
|
24
|
+
export interface EarlyRoute {
|
|
25
|
+
id: string;
|
|
26
|
+
/** Pathname regex. */
|
|
27
|
+
path: string;
|
|
28
|
+
/** Params that must be present and non-empty. */
|
|
29
|
+
need?: string[];
|
|
30
|
+
/** Param-name regex every non-empty param must match. */
|
|
31
|
+
allow?: string;
|
|
32
|
+
css: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
36
|
+
// reserveSelector: set by loadConfig (default: the first anchor).
|
|
37
|
+
|
|
38
|
+
/** Only what the snippet can express: a path regex and a query param (no custom readQuery/active). */
|
|
39
|
+
export const searchRule = (c: SearchConfigType, hostCss: string): EarlyRoute | null =>
|
|
40
|
+
c.urlMatch && c.queryParam && !c.readQuery && !c.active ? { id: 'search', path: c.urlMatch, need: [c.queryParam], css: holdCss(hostCss, c.reserveSelector!) } : null;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Param names the category route can express (values are checked by the bundle).
|
|
44
|
+
* An _init.ts `active` cannot be expressed: the rule then matches on urlMatch
|
|
45
|
+
* alone, so it also holds pages `active` rejects (a production storefront's product pages share
|
|
46
|
+
* the category URL shape). That is harmless where style-host.css only hits
|
|
47
|
+
* elements of the category page (nothing is hidden there) and the bundle drops
|
|
48
|
+
* the unclaimed hold at boot; `earlyHold: false` opts out where it is not.
|
|
49
|
+
*/
|
|
50
|
+
export const plpRule = (c: PlpConfigType, hostCss: string): EarlyRoute | null => {
|
|
51
|
+
if (c.earlyHold === false) return null;
|
|
52
|
+
const pm = c.params ?? {};
|
|
53
|
+
// Their page param too: their page 1 is ours. A later page of theirs is
|
|
54
|
+
// theirs (plpAccepts), a value the snippet does not check: held until the
|
|
55
|
+
// bundle boots and drops the hold.
|
|
56
|
+
const names = [
|
|
57
|
+
pm.page,
|
|
58
|
+
pm.sort?.param,
|
|
59
|
+
...(pm.flags ?? []).map((f) => f.param),
|
|
60
|
+
...(pm.ranges ?? []).flatMap((r) => [r.min, r.max]),
|
|
61
|
+
...Object.values(pm.facetParams ?? {}).map((f) => f.param),
|
|
62
|
+
].filter((n): n is string => !!n);
|
|
63
|
+
const allow = `(?:${pm.ignore ?? TRACKING})|^${esc(P)}${names.length ? `|^(?:${[...new Set(names)].map(esc).join('|')})$` : ''}`;
|
|
64
|
+
return { id: 'plp', path: c.urlMatch, allow, css: holdCss(hostCss, c.reserveSelector!) };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Store the rules for the snippet's next hard page load (only when they changed, or to renew them). */
|
|
68
|
+
export const writeEarlyRules = (routes: EarlyRoute[], ms: number) => {
|
|
69
|
+
try {
|
|
70
|
+
if (!routes.length) return clearEarlyRules();
|
|
71
|
+
const rules = { v: 1, ms, routes };
|
|
72
|
+
const old = JSON.parse(localStorage.getItem(EARLY_KEY) || 'null');
|
|
73
|
+
// Unchanged and renewed within the last day: nothing to write.
|
|
74
|
+
if (old && JSON.stringify({ v: old.v, ms: old.ms, routes: old.routes }) === JSON.stringify(rules) && old.exp - Date.now() > TTL - 24 * 3600e3) return;
|
|
75
|
+
localStorage.setItem(EARLY_KEY, JSON.stringify({ ...rules, exp: Date.now() + TTL }));
|
|
76
|
+
} catch {}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** The integration is off: the snippet holds nothing any more. */
|
|
80
|
+
export const clearEarlyRules = () => {
|
|
81
|
+
try {
|
|
82
|
+
localStorage.removeItem(EARLY_KEY);
|
|
83
|
+
} catch {}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** An early hold the inline snippet started on this page view: what the bundle claims or drops. */
|
|
87
|
+
export interface EarlyHold {
|
|
88
|
+
t: number;
|
|
89
|
+
route: string;
|
|
90
|
+
snippet?: 1;
|
|
91
|
+
claim(id: string): boolean;
|
|
92
|
+
drop(): void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The snippet's early hold, if it started one: `window.__lbx.early` (Luma's one global). */
|
|
96
|
+
export const earlyHold = (): EarlyHold | undefined => (window as any).__lbx?.early;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Which pages of this document are ours: small enough for the entry (the
|
|
2
|
+
// takeover needs it on every URL change), shared with core/url.ts.
|
|
3
|
+
|
|
4
|
+
/** A URL as we compare it: routers re-encode (`|` or `%7C`) and reorder the query; the hash is not ours. */
|
|
5
|
+
export const canon = (href: string) => {
|
|
6
|
+
const u = new URL(href);
|
|
7
|
+
const ps = [...u.searchParams].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
8
|
+
return `${u.origin}${u.pathname}?${new URLSearchParams(ps)}`;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Pages of this document whose history entry we created (writeUrl). */
|
|
12
|
+
const own = new Set<string>();
|
|
13
|
+
export const markOwnEntry = (href: string) => void own.add(canon(href));
|
|
14
|
+
/** Did we create this page's history entry (our listing's state, a new query in place)? */
|
|
15
|
+
export const isOwnEntry = (href: string) => own.has(canon(href));
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A history entry of ours that only stands for something open (the
|
|
19
|
+
* autocomplete's phone sheet): Back closes it, and no router of the host's
|
|
20
|
+
* hears that popstate (the URL is the same). `back` returns true when the
|
|
21
|
+
* popstate was that entry's (set by the widget that pushed it).
|
|
22
|
+
*/
|
|
23
|
+
let back: (() => boolean) | null = null;
|
|
24
|
+
export const onOwnBack = (f: () => boolean) => void (back = f);
|
|
25
|
+
export const ownBack = () => !!back?.();
|
package/src/core/fold.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Text folding for matching typed text (a facet group's search): case- and
|
|
2
|
+
// accent-insensitive, offsets kept.
|
|
3
|
+
|
|
4
|
+
/** Letters with a stroke, which NFD keeps whole: Polish ł, Croatian đ, Danish ø (one unit for one). */
|
|
5
|
+
const STROKE: Record<string, string> = { ł: 'l', Ł: 'l', đ: 'd', Đ: 'd', ø: 'o', Ø: 'o', ħ: 'h', Ħ: 'h' };
|
|
6
|
+
/** Lowercase without diacritics, one UTF-16 unit per unit (so offsets stay valid): "Łąka" -> "laka", "Žluťoučký" -> "zlutoucky". */
|
|
7
|
+
export const fold = (s: string) => s.split('').map((c) => STROKE[c] ?? c.normalize('NFD')[0].toLowerCase()[0]).join('');
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Self-telemetry, exposed on window.__lbx.health (debugging, test harnesses):
|
|
2
|
+
// placement counters, API calls, errors and a timeline of what happened when.
|
|
3
|
+
|
|
4
|
+
export const health = {
|
|
5
|
+
reconciles: 0,
|
|
6
|
+
inserts: {} as Record<string, number>,
|
|
7
|
+
anchorMisses: {} as Record<string, number>,
|
|
8
|
+
fights: {} as Record<string, number>,
|
|
9
|
+
apiCalls: 0,
|
|
10
|
+
errors: [] as string[],
|
|
11
|
+
timeline: [] as [number, string][],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const mark = (what: string) => {
|
|
15
|
+
if (health.timeline.length < 200) health.timeline.push([Math.round(performance.now()), what]);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const bump = (bucket: Record<string, number>, key: string) => {
|
|
19
|
+
bucket[key] = (bucket[key] || 0) + 1;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const reportError = (where: string, e: unknown) => {
|
|
23
|
+
if (health.errors.length < 50) health.errors.push(`${where}: ${e instanceof Error ? e.message : String(e)}`);
|
|
24
|
+
// Never throw into the host page.
|
|
25
|
+
console.warn('[lbx]', where, e);
|
|
26
|
+
};
|
package/src/core/hold.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Hold-state helpers: remembered heights and the loading bar.
|
|
2
|
+
|
|
3
|
+
import { session } from './storage';
|
|
4
|
+
|
|
5
|
+
// Remembered heights: a per-viewer convenience only (storage may be unavailable).
|
|
6
|
+
const recall = (k: string) => Number(session.get(k)) || 0;
|
|
7
|
+
const remember = (k: string, h: number) => h > 0 && session.set(k, Math.round(h));
|
|
8
|
+
|
|
9
|
+
const heightKey = () => `lbx:h:${location.pathname}${location.search}`;
|
|
10
|
+
export const recallHeight = () => recall(heightKey());
|
|
11
|
+
/** The height ours had last time on this URL, reserved on `sel` (the browser restores scroll on reload/back before our results exist). */
|
|
12
|
+
export const reserveCss = (sel: string) => {
|
|
13
|
+
const h = recallHeight();
|
|
14
|
+
return h ? `\n${sel} { min-height: ${h}px !important; }` : '';
|
|
15
|
+
};
|
|
16
|
+
export const rememberHeight = (h: number) => remember(heightKey(), h);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `f(height)` of `el` once the browser has laid it out anyway: a ResizeObserver
|
|
20
|
+
* reports after the frame's own layout, where reading offsetHeight right after
|
|
21
|
+
* a render would force style and layout of the whole page (tens of ms on a
|
|
22
|
+
* large host DOM). Once: the first report (its size then, 0 while hidden).
|
|
23
|
+
*/
|
|
24
|
+
export const afterLayout = (el: Element, f: (h: number) => void) => {
|
|
25
|
+
const ro = new ResizeObserver((es) => {
|
|
26
|
+
ro.disconnect();
|
|
27
|
+
f(es[0]?.borderBoxSize?.[0]?.blockSize ?? es[0]?.contentRect.height ?? 0);
|
|
28
|
+
});
|
|
29
|
+
ro.observe(el);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The host CSS (`style-host.css`) as a hold applies it: what it hides with
|
|
34
|
+
* `display: none` is hidden with `visibility: hidden` instead, so it keeps
|
|
35
|
+
* its box and the page keeps its height until ours renders (then the CSS as
|
|
36
|
+
* written applies, in the same frame ours shows). Regression (two shops,
|
|
37
|
+
* 2026-10-02): the early hold collapsed the page (their listing gone, the
|
|
38
|
+
* footer up), then ours pushed it down: CLS 0.48 and 0.52. The cost: their
|
|
39
|
+
* lazy images in view may load meanwhile; nothing of theirs is seen,
|
|
40
|
+
* reachable or hit-testable (unless their own CSS makes a descendant
|
|
41
|
+
* `visibility: visible`).
|
|
42
|
+
*/
|
|
43
|
+
export const holding = (hostCss: string) => hostCss.replace(/display\s*:\s*none\s*(?:!\s*important)?/gi, 'visibility: hidden !important');
|
|
44
|
+
|
|
45
|
+
// Written minified, a rule per line (the build joins the lines): in the entry, every page.
|
|
46
|
+
export const LOADING = (sel: string) =>
|
|
47
|
+
`\n${sel}::before{content:"";display:block;height:3px;margin-bottom:-3px;opacity:.4;` +
|
|
48
|
+
'background:linear-gradient(90deg,transparent,currentColor,transparent) no-repeat;' +
|
|
49
|
+
'background-size:40% 100%;animation:lbx-loading 1.1s linear infinite}' +
|
|
50
|
+
'@keyframes lbx-loading{from{background-position:-60% 0}to{background-position:160% 0}}' +
|
|
51
|
+
`@media (prefers-reduced-motion:reduce){${sel}::before{animation:none;background-size:100% 100%}}`;
|
|
52
|
+
|
|
53
|
+
/** First paint already happened? (Paint Timing; readyState where unsupported.) */
|
|
54
|
+
export const painted = () =>
|
|
55
|
+
PerformanceObserver.supportedEntryTypes?.includes('paint')
|
|
56
|
+
? performance.getEntriesByType('paint').some((e) => e.name === 'first-contentful-paint')
|
|
57
|
+
: document.readyState !== 'loading';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Visits in a row that failed (timed out, errored) before a listing route
|
|
61
|
+
* stops holding: theirs shows at once. Counted per session, so a multi-page
|
|
62
|
+
* site stops too, not only an SPA.
|
|
63
|
+
*/
|
|
64
|
+
export const MAX_FAILURES = 2;
|
|
65
|
+
export const failKey = (id: string) => `lbx:fail:${id}`;
|
|
66
|
+
export const failures = (id: string) => recall(failKey(id));
|
|
67
|
+
|
|
68
|
+
/** The hold's CSS of a listing route: their listing kept in place but hidden, a loading bar on the reserve. */
|
|
69
|
+
export const holdCss = (hostCss: string, reserveSelector: string) => `${holding(hostCss)}\n${LOADING(reserveSelector)}`;
|
|
70
|
+
|
|
71
|
+
/** Height a widget measured last time at this viewport width (per session). */
|
|
72
|
+
const widgetKey = (id: string) => `lbx:rh:${id}:${Math.round(innerWidth / 100)}`;
|
|
73
|
+
export const recallWidgetHeight = (id: string) => recall(widgetKey(id));
|
|
74
|
+
export const rememberWidgetHeight = (id: string, h: number) => remember(widgetKey(id), h);
|
package/src/core/i18n.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Translations: JSON dictionaries per locale (src/locales/<locale>.json, loaded
|
|
2
|
+
// on demand, only the site's locale), overridden by the integration's
|
|
3
|
+
// `_translations.ts` files: `_global` for every widget, a widget folder's own
|
|
4
|
+
// for that widget. Keys are dotted paths, placeholders `{name}`.
|
|
5
|
+
//
|
|
6
|
+
// Plural forms: a key whose value is an object of `Intl.PluralRules`
|
|
7
|
+
// categories (`zero`, `one`, `two`, `few`, `many`, `other`; `other` required)
|
|
8
|
+
// picks the form for the `count` param in the dictionary's locale:
|
|
9
|
+
// "results": { "one": "{count} produkt", "few": "{count} produkty", "many": "{count} produktów", "other": "{count} produktu" }
|
|
10
|
+
// t('results', { count: 83 }) -> "83 produkty" in pl, t('results', { count: 85 }) -> "85 produktów".
|
|
11
|
+
// Without a numeric `count`, `other`. An override replaces a plural (or a
|
|
12
|
+
// string) as a whole, never one form of it.
|
|
13
|
+
|
|
14
|
+
import type { Dictionary, Translations } from '../types';
|
|
15
|
+
|
|
16
|
+
export { loadDictionary } from './locales';
|
|
17
|
+
|
|
18
|
+
const CATEGORIES = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
|
|
19
|
+
/** A plural: an object of plural categories with `other`, every form a string. */
|
|
20
|
+
export const isPlural = (v: unknown): v is Record<string, string> =>
|
|
21
|
+
!!v && typeof v === 'object' && typeof (v as Dictionary).other === 'string' && Object.entries(v).every(([k, s]) => CATEGORIES.has(k) && typeof s === 'string');
|
|
22
|
+
|
|
23
|
+
const merge = (a: Dictionary, b?: Dictionary): Dictionary => {
|
|
24
|
+
if (!b) return a;
|
|
25
|
+
const out: Dictionary = { ...a };
|
|
26
|
+
for (const [k, v] of Object.entries(b)) out[k] = typeof v === 'object' && typeof a[k] === 'object' && !isPlural(v) && !isPlural(a[k]) ? merge(a[k] as Dictionary, v) : v;
|
|
27
|
+
return out;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type T = (key: string, params?: Record<string, unknown>) => string;
|
|
31
|
+
|
|
32
|
+
const rules = new Map<string, Intl.PluralRules>();
|
|
33
|
+
const pluralOf = (locale: string) => {
|
|
34
|
+
let r = rules.get(locale);
|
|
35
|
+
if (!r) {
|
|
36
|
+
try {
|
|
37
|
+
r = new Intl.PluralRules(locale);
|
|
38
|
+
} catch {
|
|
39
|
+
r = new Intl.PluralRules('en'); // a locale tag Intl does not know
|
|
40
|
+
}
|
|
41
|
+
rules.set(locale, r);
|
|
42
|
+
}
|
|
43
|
+
return r;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A translate function. `scopes` are tried in order as key prefixes, then the
|
|
48
|
+
* bare key: with ['plp', 'search'], t('sort') reads plp.sort, then search.sort.
|
|
49
|
+
*/
|
|
50
|
+
export const translator = (base: Dictionary, locale: string, overrides: (Translations | undefined)[], scopes: string[] = []): T => {
|
|
51
|
+
const dict = overrides.reduce<Dictionary>((d, o) => merge(d, o?.[locale]), base);
|
|
52
|
+
const get = (path: string, params?: Record<string, unknown>) => {
|
|
53
|
+
let v: unknown = dict;
|
|
54
|
+
for (const k of path.split('.')) v = v && typeof v === 'object' ? (v as Dictionary)[k] : undefined;
|
|
55
|
+
if (typeof v === 'string') return v;
|
|
56
|
+
if (!isPlural(v)) return undefined;
|
|
57
|
+
const n = Number(params?.count);
|
|
58
|
+
return (params?.count != null && Number.isFinite(n) && v[pluralOf(locale).select(n)]) || v.other;
|
|
59
|
+
};
|
|
60
|
+
return (key, params) => {
|
|
61
|
+
let s: string | undefined;
|
|
62
|
+
for (const p of scopes) if ((s = get(`${p}.${key}`, params)) !== undefined) break;
|
|
63
|
+
s ??= get(key, params) ?? key;
|
|
64
|
+
return params ? s.replace(/\{(\w+)\}/g, (m, k) => (params[k] != null ? String(params[k]) : m)) : s;
|
|
65
|
+
};
|
|
66
|
+
};
|