@luigisbox/luma 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/LICENSE.md +90 -0
  2. package/README.md +615 -2
  3. package/a11y/audit.mjs +735 -0
  4. package/a11y/axe.mjs +73 -0
  5. package/a11y/report.mjs +347 -0
  6. package/a11y/run.mjs +256 -0
  7. package/a11y/wcag.mjs +233 -0
  8. package/bin/config.mjs +60 -0
  9. package/bin/luma.mjs +99 -0
  10. package/bin/paths.mjs +11 -0
  11. package/bin/tools.mjs +43 -0
  12. package/build/baseline.d.mts +8 -0
  13. package/build/baseline.mjs +167 -0
  14. package/build/tooling.mjs +102 -0
  15. package/build/vite.mjs +507 -0
  16. package/chrome/block.mjs +8 -0
  17. package/chrome/run.mjs +150 -0
  18. package/docs/ACCESSIBILITY.md +501 -0
  19. package/docs/INTEGRATION-PERFORMANCE.md +213 -0
  20. package/docs/PARITY.md +946 -0
  21. package/footprint/model.mjs +184 -0
  22. package/footprint/run.mjs +331 -0
  23. package/package.json +58 -4
  24. package/parity/alignment.mjs +109 -0
  25. package/parity/archive.mjs +223 -0
  26. package/parity/args.mjs +137 -0
  27. package/parity/behaviour.mjs +379 -0
  28. package/parity/compare.mjs +985 -0
  29. package/parity/engine.mjs +848 -0
  30. package/parity/hints.mjs +132 -0
  31. package/parity/hold.mjs +54 -0
  32. package/parity/init.mjs +703 -0
  33. package/parity/map.mjs +640 -0
  34. package/parity/owner.mjs +170 -0
  35. package/parity/pairing.mjs +110 -0
  36. package/parity/pixels.mjs +33 -0
  37. package/parity/probe.mjs +1239 -0
  38. package/parity/report.mjs +236 -0
  39. package/parity/run.mjs +267 -0
  40. package/snippet/early-hold.js +64 -0
  41. package/snippet/early-hold.min.js +1 -0
  42. package/src/analytics/chain.ts +137 -0
  43. package/src/analytics/collect.ts +64 -0
  44. package/src/analytics/datalayer.ts +67 -0
  45. package/src/analytics/from-ga4.ts +68 -0
  46. package/src/analytics/from-luma.ts +21 -0
  47. package/src/analytics/index.ts +24 -0
  48. package/src/analytics/outbox.ts +118 -0
  49. package/src/analytics/page.ts +46 -0
  50. package/src/analytics/pageview.ts +82 -0
  51. package/src/analytics/ref.ts +19 -0
  52. package/src/analytics/setup.ts +41 -0
  53. package/src/analytics/start.ts +53 -0
  54. package/src/analytics/triggers.ts +239 -0
  55. package/src/analytics/user.ts +12 -0
  56. package/src/analytics/wire.ts +67 -0
  57. package/src/api/ai.ts +98 -0
  58. package/src/api/index.ts +87 -0
  59. package/src/api/token-key.ts +42 -0
  60. package/src/api/token.ts +312 -0
  61. package/src/core/a11y-keys.ts +60 -0
  62. package/src/core/a11y.ts +160 -0
  63. package/src/core/boot-state.ts +6 -0
  64. package/src/core/color.ts +42 -0
  65. package/src/core/context.ts +108 -0
  66. package/src/core/early-rules.ts +96 -0
  67. package/src/core/entries.ts +25 -0
  68. package/src/core/fold.ts +7 -0
  69. package/src/core/health.ts +26 -0
  70. package/src/core/hold.ts +74 -0
  71. package/src/core/i18n.ts +66 -0
  72. package/src/core/image-load.ts +102 -0
  73. package/src/core/image-tags.ts +26 -0
  74. package/src/core/images.ts +170 -0
  75. package/src/core/integration.ts +126 -0
  76. package/src/core/kept.ts +25 -0
  77. package/src/core/listings.ts +100 -0
  78. package/src/core/locales.ts +14 -0
  79. package/src/core/own.ts +49 -0
  80. package/src/core/page.ts +14 -0
  81. package/src/core/prefetch.ts +88 -0
  82. package/src/core/recommenders.ts +166 -0
  83. package/src/core/reconciler.ts +397 -0
  84. package/src/core/result.ts +73 -0
  85. package/src/core/routes.ts +115 -0
  86. package/src/core/scheduler.ts +83 -0
  87. package/src/core/sense.ts +107 -0
  88. package/src/core/speculation.ts +59 -0
  89. package/src/core/stash.ts +38 -0
  90. package/src/core/storage.ts +45 -0
  91. package/src/core/styles.ts +106 -0
  92. package/src/core/support.ts +12 -0
  93. package/src/core/takeover.ts +220 -0
  94. package/src/core/templates.ts +74 -0
  95. package/src/core/tiles.ts +137 -0
  96. package/src/core/turbo.ts +64 -0
  97. package/src/core/url.ts +164 -0
  98. package/src/core/widget-inits.ts +40 -0
  99. package/src/env.d.ts +3 -0
  100. package/src/lit.ts +11 -0
  101. package/src/locales/cs.json +94 -0
  102. package/src/locales/de.json +88 -0
  103. package/src/locales/en.json +88 -0
  104. package/src/main.ts +493 -0
  105. package/src/types.ts +778 -0
  106. package/src/widgets/autocomplete/index.ts +1387 -0
  107. package/src/widgets/autocomplete/style-autocomplete.css +266 -0
  108. package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
  109. package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
  110. package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
  111. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
  112. package/src/widgets/listing/element.ts +1772 -0
  113. package/src/widgets/listing/flow.ts +30 -0
  114. package/src/widgets/listing/instant.ts +133 -0
  115. package/src/widgets/listing/intent.ts +193 -0
  116. package/src/widgets/listing/query.ts +298 -0
  117. package/src/widgets/listing/stash.ts +97 -0
  118. package/src/widgets/listing/state.ts +131 -0
  119. package/src/widgets/listing/view.ts +257 -0
  120. package/src/widgets/plp/index.ts +11 -0
  121. package/src/widgets/plp/route.ts +263 -0
  122. package/src/widgets/recommender/index.ts +261 -0
  123. package/src/widgets/recommender/style-recommender.css +26 -0
  124. package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
  125. package/src/widgets/recommender/template-recommender-popup.ts +12 -0
  126. package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
  127. package/src/widgets/search/assets.ts +7 -0
  128. package/src/widgets/search/first.ts +15 -0
  129. package/src/widgets/search/form-url.ts +59 -0
  130. package/src/widgets/search/in-place.ts +226 -0
  131. package/src/widgets/search/index.ts +16 -0
  132. package/src/widgets/search/range.ts +260 -0
  133. package/src/widgets/search/route.ts +32 -0
  134. package/src/widgets/search/speculate.ts +202 -0
  135. package/src/widgets/search/style-search.css +65 -0
  136. package/src/widgets/search/template-search-box.ts +17 -0
  137. package/src/widgets/search/template-search-facets-default.ts +73 -0
  138. package/src/widgets/search/template-search-header.ts +14 -0
  139. package/src/widgets/search/template-search-pagination.ts +12 -0
  140. package/src/widgets/search/template-search-range-default.ts +48 -0
  141. package/src/widgets/search/template-search-result-default.ts +13 -0
  142. package/src/widgets/search/template-search-results-default.ts +8 -0
  143. package/tasks/model.mjs +116 -0
  144. package/tasks/run.mjs +305 -0
  145. package/tsconfig.json +14 -0
@@ -0,0 +1,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?.();
@@ -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
+ };
@@ -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);
@@ -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
+ };
@@ -0,0 +1,102 @@
1
+ // The page side of product images (./images.ts builds the attributes):
2
+ // - load marks: `data-lbx-loaded` on an `img[data-lbx-img]` and its
3
+ // `[data-lbx-ph]` box once it has loaded (or failed: the alt text shows), so
4
+ // the placeholder goes and a lazy image fades in (style-search.css). One
5
+ // capture listener on the document for every widget; an image already
6
+ // complete when a render puts it in (the memory cache, a persisted tile, a
7
+ // server-rendered one loaded before hydration) is marked in that task:
8
+ // no placeholder flash, no fade;
9
+ // - preloads of a listing's first row (<link rel=preload as=image>), as soon
10
+ // as the bundle has the result;
11
+ // - warming the first images of results fetched ahead (typing, a hovered
12
+ // filter), at low priority, behind a cap.
13
+ // Browser only, in the widgets' chunks: never in the entry or the server renderer.
14
+
15
+ import type { ImageInfo } from './images';
16
+
17
+ const SEL = 'img[data-lbx-img]:not([data-lbx-loaded])';
18
+ /** A load this soon after the render that put the image in came from a cache: shown at once, not faded. */
19
+ const CACHED_MS = 50;
20
+ let renderedAt = -Infinity;
21
+
22
+ const loaded = (img: Element, how: '' | 'fade') => {
23
+ img.setAttribute('data-lbx-loaded', how);
24
+ img.closest('[data-lbx-ph]')?.setAttribute('data-lbx-loaded', how);
25
+ };
26
+
27
+ let watching = false;
28
+ const watch = () => {
29
+ if (watching) return;
30
+ watching = true;
31
+ const on = (e: Event) => {
32
+ const t = e.target as Element;
33
+ if (t?.nodeType !== 1 || t.localName !== 'img' || !t.hasAttribute('data-lbx-img') || t.hasAttribute('data-lbx-loaded')) return;
34
+ loaded(t, performance.now() - renderedAt < CACHED_MS ? '' : 'fade');
35
+ };
36
+ // load and error do not bubble: capture sees every image of the document.
37
+ document.addEventListener('load', on, true);
38
+ document.addEventListener('error', on, true);
39
+ };
40
+
41
+ /**
42
+ * After a render into `root`: images that are already there are marked
43
+ * (complete: no layout read), then the root opts into fading (`data-lbx-fade`)
44
+ * when `fade` is on. Server-rendered markup without JavaScript has no fade.
45
+ */
46
+ export const settleImages = (root: Element, fade = true) => {
47
+ watch();
48
+ renderedAt = performance.now();
49
+ for (const img of root.querySelectorAll<HTMLImageElement>(SEL)) if (img.complete && img.getAttribute('src')) loaded(img, '');
50
+ if (fade && !root.hasAttribute('data-lbx-fade')) root.setAttribute('data-lbx-fade', '');
51
+ };
52
+
53
+ const preloaded = new Set<string>();
54
+ /** <link rel=preload as=image> for these images (their srcset and sizes, so the candidate the <img> picks is the one fetched), at high priority. */
55
+ export const preloadImages = (list: ImageInfo[]) => {
56
+ try {
57
+ for (const im of list) {
58
+ const key = im.srcset || im.src;
59
+ if (!im.src || preloaded.has(key)) continue;
60
+ preloaded.add(key);
61
+ const l = document.createElement('link');
62
+ l.rel = 'preload';
63
+ l.setAttribute('as', 'image');
64
+ l.href = im.src;
65
+ if (im.srcset) {
66
+ l.setAttribute('imagesrcset', im.srcset);
67
+ if (im.sizes) l.setAttribute('imagesizes', im.sizes);
68
+ }
69
+ l.setAttribute('fetchpriority', 'high');
70
+ l.setAttribute('data-lbx', 'preload');
71
+ document.head.append(l);
72
+ }
73
+ // Bounded: the oldest of ours go (a long session of filters and queries).
74
+ const ours = document.head.querySelectorAll('link[data-lbx="preload"]');
75
+ for (let k = 0; k < ours.length - 24; k++) ours[k].remove();
76
+ } catch {}
77
+ };
78
+
79
+ const warmed = new Set<string>();
80
+ /**
81
+ * The first `cap` images of results fetched ahead, at low priority (the
82
+ * shopper may never ask for them): an image element that is never attached
83
+ * picks its candidate from srcset and sizes as the tile will, so the request
84
+ * is the tile's.
85
+ */
86
+ export const warmImages = (list: ImageInfo[], cap: number) => {
87
+ try {
88
+ for (const im of list.slice(0, Math.max(0, cap))) {
89
+ const key = im.srcset || im.src;
90
+ if (!im.src || warmed.has(key)) continue;
91
+ warmed.add(key);
92
+ const img = new Image();
93
+ img.setAttribute('fetchpriority', 'low');
94
+ img.decoding = 'async';
95
+ if (im.srcset) {
96
+ img.sizes = im.sizes;
97
+ img.srcset = im.srcset;
98
+ }
99
+ img.src = im.src;
100
+ }
101
+ } catch {}
102
+ };
@@ -0,0 +1,26 @@
1
+ // The image template helpers every scope gets (`image()`, `img()`), from
2
+ // `_global.images` (./images.ts builds the attributes). Shared by the bundle
3
+ // and the server renderer: no DOM.
4
+
5
+ import { html, nothing } from 'lit-html';
6
+ import { imageOf, type ImageInfo, type ImageOptions } from './images';
7
+ import type { ImagesConfig, Result } from '../types';
8
+
9
+ /**
10
+ * A complete `<img>` (data-lbx-img: load marks, the fade-in). The placeholder
11
+ * goes on its box: put `data-lbx-ph` and `style=${image(r, i).ph}` on the
12
+ * element around it (the `img` helper does).
13
+ */
14
+ export const imgOf = (im: ImageInfo, o: ImageOptions = {}) =>
15
+ html`<img data-lbx-img src=${im.src} srcset=${im.srcset || nothing} sizes=${im.sizes || nothing} width=${im.width} height=${im.height} alt=${o.alt ?? ''} class=${o.class ?? nothing} title=${o.title ?? nothing} loading=${im.loading} fetchpriority=${im.fetchpriority ?? nothing} decoding="async" />`;
16
+
17
+ /** The template helpers: `image(r, i, opts)` (the attributes) and `img(r, i, opts)` (an `<img>` in its placeholder box). */
18
+ export const imageHelpers = (cfg: ImagesConfig = {}, lazy = false) => ({
19
+ image: (r: Result, i: number, o?: ImageOptions) => imageOf(cfg, r, i, o, lazy),
20
+ img: (r: Result, i: number, o?: ImageOptions) => {
21
+ const im = imageOf(cfg, r, i, o, lazy);
22
+ // The box's aspect ratio from the configured size: fixed before and after the image arrives (object-fit: contain).
23
+ return im.src ? html`<span class="lbx-img" data-lbx-part="image" data-lbx-ph style=${`aspect-ratio:${im.width}/${im.height}${im.ph ? `;${im.ph}` : ''}`}>${imgOf(im, o)}</span>` : nothing;
24
+ },
25
+ });
26
+