@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.
Files changed (127) hide show
  1. package/LICENSE.md +90 -0
  2. package/README.md +488 -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 +498 -0
  16. package/chrome/run.mjs +145 -0
  17. package/docs/ACCESSIBILITY.md +502 -0
  18. package/docs/INTEGRATION-PERFORMANCE.md +214 -0
  19. package/docs/PARITY.md +946 -0
  20. package/footprint/model.mjs +184 -0
  21. package/footprint/run.mjs +331 -0
  22. package/package.json +58 -4
  23. package/parity/alignment.mjs +109 -0
  24. package/parity/archive.mjs +223 -0
  25. package/parity/args.mjs +137 -0
  26. package/parity/behaviour.mjs +379 -0
  27. package/parity/compare.mjs +985 -0
  28. package/parity/engine.mjs +848 -0
  29. package/parity/hints.mjs +132 -0
  30. package/parity/hold.mjs +54 -0
  31. package/parity/init.mjs +703 -0
  32. package/parity/map.mjs +640 -0
  33. package/parity/owner.mjs +170 -0
  34. package/parity/pairing.mjs +110 -0
  35. package/parity/pixels.mjs +33 -0
  36. package/parity/probe.mjs +1239 -0
  37. package/parity/report.mjs +236 -0
  38. package/parity/run.mjs +267 -0
  39. package/snippet/early-hold.js +64 -0
  40. package/snippet/early-hold.min.js +1 -0
  41. package/src/api/ai.ts +98 -0
  42. package/src/api/index.ts +87 -0
  43. package/src/api/token-key.ts +42 -0
  44. package/src/api/token.ts +311 -0
  45. package/src/core/a11y-keys.ts +60 -0
  46. package/src/core/a11y.ts +160 -0
  47. package/src/core/boot-state.ts +6 -0
  48. package/src/core/color.ts +42 -0
  49. package/src/core/context.ts +108 -0
  50. package/src/core/early-rules.ts +96 -0
  51. package/src/core/entries.ts +25 -0
  52. package/src/core/fold.ts +7 -0
  53. package/src/core/health.ts +26 -0
  54. package/src/core/hold.ts +74 -0
  55. package/src/core/i18n.ts +66 -0
  56. package/src/core/image-load.ts +102 -0
  57. package/src/core/image-tags.ts +26 -0
  58. package/src/core/images.ts +170 -0
  59. package/src/core/integration.ts +120 -0
  60. package/src/core/listings.ts +100 -0
  61. package/src/core/locales.ts +14 -0
  62. package/src/core/own.ts +49 -0
  63. package/src/core/page.ts +14 -0
  64. package/src/core/prefetch.ts +88 -0
  65. package/src/core/recommenders.ts +166 -0
  66. package/src/core/reconciler.ts +397 -0
  67. package/src/core/result.ts +73 -0
  68. package/src/core/routes.ts +115 -0
  69. package/src/core/scheduler.ts +83 -0
  70. package/src/core/sense.ts +107 -0
  71. package/src/core/speculation.ts +59 -0
  72. package/src/core/stash.ts +38 -0
  73. package/src/core/storage.ts +45 -0
  74. package/src/core/styles.ts +106 -0
  75. package/src/core/support.ts +12 -0
  76. package/src/core/takeover.ts +220 -0
  77. package/src/core/templates.ts +74 -0
  78. package/src/core/turbo.ts +64 -0
  79. package/src/core/url.ts +164 -0
  80. package/src/core/widget-inits.ts +40 -0
  81. package/src/env.d.ts +3 -0
  82. package/src/lit.ts +11 -0
  83. package/src/locales/cs.json +94 -0
  84. package/src/locales/de.json +88 -0
  85. package/src/locales/en.json +88 -0
  86. package/src/main.ts +443 -0
  87. package/src/types.ts +760 -0
  88. package/src/widgets/autocomplete/index.ts +1367 -0
  89. package/src/widgets/autocomplete/style-autocomplete.css +266 -0
  90. package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
  91. package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
  92. package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
  93. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
  94. package/src/widgets/listing/element.ts +1769 -0
  95. package/src/widgets/listing/flow.ts +30 -0
  96. package/src/widgets/listing/instant.ts +133 -0
  97. package/src/widgets/listing/intent.ts +193 -0
  98. package/src/widgets/listing/query.ts +298 -0
  99. package/src/widgets/listing/stash.ts +97 -0
  100. package/src/widgets/listing/state.ts +131 -0
  101. package/src/widgets/listing/view.ts +257 -0
  102. package/src/widgets/plp/index.ts +11 -0
  103. package/src/widgets/plp/route.ts +263 -0
  104. package/src/widgets/recommender/index.ts +250 -0
  105. package/src/widgets/recommender/style-recommender.css +26 -0
  106. package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
  107. package/src/widgets/recommender/template-recommender-popup.ts +12 -0
  108. package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
  109. package/src/widgets/search/assets.ts +7 -0
  110. package/src/widgets/search/first.ts +15 -0
  111. package/src/widgets/search/form-url.ts +59 -0
  112. package/src/widgets/search/in-place.ts +226 -0
  113. package/src/widgets/search/index.ts +16 -0
  114. package/src/widgets/search/range.ts +260 -0
  115. package/src/widgets/search/route.ts +32 -0
  116. package/src/widgets/search/speculate.ts +202 -0
  117. package/src/widgets/search/style-search.css +65 -0
  118. package/src/widgets/search/template-search-box.ts +17 -0
  119. package/src/widgets/search/template-search-facets-default.ts +73 -0
  120. package/src/widgets/search/template-search-header.ts +14 -0
  121. package/src/widgets/search/template-search-pagination.ts +12 -0
  122. package/src/widgets/search/template-search-range-default.ts +48 -0
  123. package/src/widgets/search/template-search-result-default.ts +13 -0
  124. package/src/widgets/search/template-search-results-default.ts +8 -0
  125. package/tasks/model.mjs +116 -0
  126. package/tasks/run.mjs +305 -0
  127. package/tsconfig.json +14 -0
@@ -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
+
@@ -0,0 +1,170 @@
1
+ // Product images in templates, right by default. DOM-free: the server renderer builds the same
2
+ // attributes, so a server-rendered tile hydrates as it was rendered.
3
+ //
4
+ // From `_global.images` (JSON, `_config.ts`), per image:
5
+ // - `srcset` from a URL pattern of the site's image CDN (named sizes, or a
6
+ // resizer's width parameter), `sizes` from the config;
7
+ // - the first row (its columns at the viewport's width, from `firstRow`) eager
8
+ // with fetchpriority=high, the rest lazy; decoding=async always;
9
+ // - width/height always (the box's aspect ratio before the image arrives);
10
+ // - a placeholder style for the image's box: a dominant colour or a tiny image
11
+ // when the catalog has one, else the neutral skeleton (style-search.css).
12
+ // The page side (load marks, preloads, warming) is ./image-load.ts, the
13
+ // template helpers (`img()`, lit) ./image-tags.ts: this file has no lit, so a
14
+ // chunk that only warms images (search/speculate.ts) stays small.
15
+
16
+ import type { ImagesConfig, Result } from '../types';
17
+ import { first, safeUrl } from './result';
18
+
19
+ /** What a template binds on an `<img>` (or its `<source>`), and on the image's box (`ph`). */
20
+ export interface ImageInfo {
21
+ src: string;
22
+ /** '' when the URL does not match `images.srcset`. */
23
+ srcset: string;
24
+ sizes: string;
25
+ width: number;
26
+ height: number;
27
+ loading: 'eager' | 'lazy';
28
+ /** 'high' for the first row, else undefined (no attribute). */
29
+ fetchpriority: 'high' | undefined;
30
+ decoding: 'async';
31
+ /** In the first row: eager, high priority, preloaded, never faded in. */
32
+ eager: boolean;
33
+ /** Style for the box carrying `data-lbx-ph`: the catalog's placeholder (`--lbx-ph`), '' for the neutral one. */
34
+ ph: string;
35
+ }
36
+
37
+ export interface ImageOptions {
38
+ /** The image URL (default `r.c.image`). */
39
+ src?: string;
40
+ /** Overrides `images.sizes` (a smaller tile: a recommender). */
41
+ sizes?: string;
42
+ /** Force eager (true) or lazy (false); default: the first row is eager. */
43
+ eager?: boolean;
44
+ alt?: string;
45
+ class?: string;
46
+ title?: string;
47
+ }
48
+
49
+ const DEFAULT_ROW: Record<string, number> = { 0: 2, 768: 3, 1200: 4 };
50
+ const res = new Map<string, RegExp | null>();
51
+ const regex = (s: string) => {
52
+ if (!res.has(s))
53
+ try {
54
+ res.set(s, new RegExp(s));
55
+ } catch {
56
+ res.set(s, null);
57
+ }
58
+ return res.get(s)!;
59
+ };
60
+
61
+ /** The first-row breakpoints, ascending: [min-width px, columns]. */
62
+ const rows = (cfg: ImagesConfig = {}): [number, number][] => {
63
+ const r = cfg.firstRow ?? DEFAULT_ROW;
64
+ return typeof r === 'number' ? [[0, r]] : Object.entries(r).map(([w, n]) => [Number(w) || 0, Number(n) || 0] as [number, number]).sort((a, b) => a[0] - b[0]);
65
+ };
66
+
67
+ /**
68
+ * A media query's live list, made once per query: img() asks for every image
69
+ * of every render, and a matchMedia() call per image and breakpoint was
70
+ * tens of ms of a listing's render on a slow phone (a production storefront, 4x CPU).
71
+ * `matches` stays current as the viewport changes.
72
+ */
73
+ const lists = new Map<string, MediaQueryList>();
74
+ export const media = (query: string) => {
75
+ let l = lists.get(query);
76
+ if (!l) lists.set(query, (l = matchMedia(query)));
77
+ return l.matches;
78
+ };
79
+
80
+ /**
81
+ * How many images of a listing are its first row: the columns at the
82
+ * viewport's width (matchMedia: no layout read), the widest row on a server
83
+ * (it cannot know the width; one extra eager image costs little).
84
+ */
85
+ export const firstRowCount = (cfg: ImagesConfig = {}) => {
86
+ const bp = rows(cfg);
87
+ if (typeof matchMedia !== 'function') return Math.max(0, ...bp.map(([, n]) => n));
88
+ let n = bp[0]?.[1] ?? 0;
89
+ for (const [w, c] of bp) if (!w || media(`(min-width: ${w}px)`)) n = c;
90
+ return n;
91
+ };
92
+
93
+ /** For a preload made where the width is unknown (the server): the media query in which image `i` is in the first row, '' = always, null = never. */
94
+ export const firstRowMedia = (cfg: ImagesConfig = {}, i: number): string | null => {
95
+ const at = rows(cfg).filter(([, n]) => n > i);
96
+ if (!at.length) return null;
97
+ // Columns grow with the width: from the narrowest breakpoint that has this column on.
98
+ const w = at[0][0];
99
+ return w ? `(min-width: ${w}px)` : '';
100
+ };
101
+
102
+ /** The srcset of `src` by `images.srcset` ('' when it does not match). */
103
+ export const srcsetOf = (cfg: ImagesConfig = {}, src: string) => {
104
+ const s = cfg.srcset;
105
+ if (!s || !src) return '';
106
+ const re = regex(s.match ?? '^(.*)$');
107
+ if (!re || !re.test(src)) return '';
108
+ const out: string[] = [];
109
+ const add = (replacement: string, descriptor: string) => {
110
+ const u = safeUrl(src.replace(re, replacement.replace(/\{w\}/g, descriptor.replace(/\D/g, ''))));
111
+ // A URL with a space or comma would break the list: such a candidate is left out.
112
+ if (u && !/[\s,]/.test(u)) out.push(`${u} ${descriptor}`);
113
+ };
114
+ if (s.candidates) for (const [d, rep] of Object.entries(s.candidates)) /^\d+(?:\.\d+)?[wx]$/.test(d) && add(rep, d);
115
+ if (s.pattern && s.widths) for (const w of s.widths) Number.isFinite(w) && w > 0 && add(s.pattern, `${Math.round(w)}w`);
116
+ return out.join(', ');
117
+ };
118
+
119
+ const COLOR = /^(#[0-9a-f]{3,8}|(?:rgb|hsl)a?\([\d\s.,%/+-]+\))$/i;
120
+ /** The box's style for the catalog's placeholder: a colour (strictly a colour), or a tiny image (a web URL, quoted). */
121
+ export const placeholderOf = (cfg: ImagesConfig = {}, r: Result) => {
122
+ const p = cfg.placeholder;
123
+ if (!p) return '';
124
+ const val = (f?: string) => (f ? String(first(r.c[f] ?? r.a[f]) ?? '').trim() : '');
125
+ const color = val(p.color);
126
+ const img = safeUrl(val(p.image));
127
+ const parts: string[] = [];
128
+ if (img && !/["\\\n\r]/.test(img)) parts.push(`url("${img}") center / cover no-repeat`);
129
+ if (COLOR.test(color)) parts.push(color);
130
+ return parts.length ? `--lbx-ph:${parts.join(', ')}` : '';
131
+ };
132
+
133
+ /** Every attribute of result `r`'s image at position `i`; `lazy`: no eager first row (not a listing). */
134
+ export const imageOf = (cfg: ImagesConfig = {}, r: Result, i: number, o: ImageOptions = {}, lazy = false): ImageInfo => {
135
+ const src = String(o.src ?? r.c.image ?? '');
136
+ const eager = o.eager ?? (!lazy && i < firstRowCount(cfg));
137
+ const srcset = srcsetOf(cfg, src);
138
+ return {
139
+ src,
140
+ srcset,
141
+ sizes: srcset ? (o.sizes ?? cfg.sizes ?? '') : '',
142
+ width: cfg.width ?? 400,
143
+ height: cfg.height ?? cfg.width ?? 400,
144
+ loading: eager ? 'eager' : 'lazy',
145
+ fetchpriority: eager ? 'high' : undefined,
146
+ decoding: 'async',
147
+ eager,
148
+ ph: placeholderOf(cfg, r),
149
+ };
150
+ };
151
+
152
+ /** The first row's images of a listing (what the server and the bundle preload). */
153
+ export const firstRowImages = (cfg: ImagesConfig = {}, results: Result[], all = false) => {
154
+ const n = all ? Math.max(0, ...rows(cfg).map(([, c]) => c)) : firstRowCount(cfg);
155
+ return results.slice(0, n).map((r, i) => ({ ...imageOf(cfg, r, i, { eager: true }), i })).filter((im) => im.src);
156
+ };
157
+
158
+ const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
159
+ /** `<link rel=preload as=image>` markup for a server-rendered listing: every image of the widest first row, each for the widths it is in the first row at. */
160
+ export const preloadMarkup = (cfg: ImagesConfig = {}, results: Result[]) => {
161
+ if (cfg.preload === false) return '';
162
+ return firstRowImages(cfg, results, true)
163
+ .map((im) => {
164
+ const media = firstRowMedia(cfg, im.i);
165
+ if (media == null) return '';
166
+ const set = im.srcset ? ` imagesrcset="${esc(im.srcset)}"${im.sizes ? ` imagesizes="${esc(im.sizes)}"` : ''}` : '';
167
+ return `<link rel="preload" as="image" href="${esc(im.src)}"${set}${media ? ` media="${esc(media)}"` : ''} fetchpriority="high" data-lbx="preload">`;
168
+ })
169
+ .join('');
170
+ };
@@ -0,0 +1,120 @@
1
+ // Turning an integration's files into a config (a widget's context from it:
2
+ // ./context.ts, in the core chunk). No DOM:
3
+ // the bundle (main.ts) and the edge render service (Luigi's Box's server-side rendering)
4
+ // both use it, so a server-rendered listing sees exactly the config,
5
+ // templates, translations and helpers the bundle would.
6
+
7
+ import type { LumaApi, LumaConfigType, LumaInitType } from '../types';
8
+
9
+ export type Lazy<T> = Record<string, () => Promise<T>>;
10
+
11
+ export const folderOf = (path: string) => path.split('/').slice(-2)[0];
12
+ export const nameOf = (path: string) => path.split('/').pop()!.replace(/^(template|style)-/, '').replace(/\.\w+$/, '');
13
+ const KINDS = ['autocomplete', 'search', 'plp', 'recommender'];
14
+
15
+ /**
16
+ * Each listing's folders (template and style precedence) and translation
17
+ * scopes. The bundle and the server renderer both read it: if they differed,
18
+ * a server-rendered listing would not hydrate.
19
+ */
20
+ export const LISTINGS = {
21
+ search: { folders: ['_global', 'search'], scopes: ['search'] },
22
+ plp: { folders: ['_global', 'search', 'plp'], scopes: ['plp', 'search'] },
23
+ } as const;
24
+
25
+ /** What an integration hands to Luma: its folders' files, as Vite globs. */
26
+ export interface IntegrationFiles {
27
+ configs: Record<string, any>;
28
+ inits: Record<string, any>;
29
+ /**
30
+ * `_translations.ts`, eager (needed by no boot code: on every page) or lazy
31
+ * (`import.meta.glob(…)` without `eager`: loaded with the widget of their
32
+ * folder, in its chunk). Lazy ones are not in the config `_init.ts` sees.
33
+ */
34
+ translations: Record<string, any> | Lazy<any>;
35
+ /** style-host.css, eager: needed in the same task as a route change. */
36
+ hostStyles: Record<string, string>;
37
+ templates: Lazy<any>;
38
+ styles: Lazy<any>;
39
+ /**
40
+ * `_widget.ts`, lazy (`import.meta.glob('./*\/_widget.ts', { import: 'default' })`):
41
+ * hooks only a widget calls, run when it loads (loadWidgetInits). Optional;
42
+ * lazy always (an eager one would be in the entry, where `_init.ts` is).
43
+ */
44
+ widgetInits?: Lazy<any>;
45
+ }
46
+
47
+ /** A folder's style-host.css; a category page without its own uses the search page's. */
48
+ export const hostStyle = (files: Pick<IntegrationFiles, 'hostStyles'>, folder: string): string => {
49
+ const css = Object.entries(files.hostStyles).find(([p]) => folderOf(p) === folder)?.[1] ?? '';
50
+ return css || (folder === 'plp' ? hostStyle(files, 'search') : '');
51
+ };
52
+
53
+ /** `_config.ts` is JSON by contract (checked at boot; code belongs in `_init.ts`). */
54
+ const parseConfigJson = (value: any, file: string) => {
55
+ const check = (v: any, key: string): void => {
56
+ if (typeof v === 'function') console.error(`[lbx] ${file}: "${key}" is a function; _config.ts is parsed as JSON, set functions in _init.ts`);
57
+ else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) check(x, k);
58
+ };
59
+ check(value, '');
60
+ return JSON.parse(JSON.stringify(value ?? {}));
61
+ };
62
+
63
+ /** Folder -> slot in the config: `recommender home` -> recommenders.home. */
64
+ export const slot = (config: any, folder: string) => {
65
+ const [type, ...rest] = folder.split(' ');
66
+ if (type !== 'recommender') return (config[type] ??= {});
67
+ const name = rest.join(' ');
68
+ if (!name) throw new Error(`[lbx] "${folder}": a recommender folder needs a name, e.g. "recommender home"`);
69
+ return ((config.recommenders ??= {})[name] ??= {});
70
+ };
71
+
72
+ // Not `surface`: a category page is a collection surface, search a search surface.
73
+ const LISTING = ['anchor', 'position', 'settleMs', 'reserveSelector', 'holdTimeoutMs', 'size', 'facets', 'facetSize', 'emptyValues', 'sorts', 'fields', 'baseFilters', 'rootClass', 'restoreOnBack', 'prefetchNextPage', 'facetsApply', 'facetGroups'] as const;
74
+
75
+ /** Configs + translations + inits -> the config; null when the integration is off. */
76
+ export const loadConfig = async (files: Pick<IntegrationFiles, 'configs' | 'inits' | 'translations'>, version: string) => {
77
+ const config = { _global: {} } as LumaConfigType;
78
+ let globalInit: LumaInitType | undefined;
79
+ for (const [p, v] of Object.entries(files.configs)) Object.assign(slot(config, folderOf(p)), parseConfigJson(v, p));
80
+ for (const [p, v] of Object.entries(files.translations)) if (typeof v !== 'function') slot(config, folderOf(p)).translations = parseConfigJson(v, p);
81
+ for (const [p, v] of Object.entries(files.inits)) {
82
+ if (folderOf(p) === '_global') globalInit = v;
83
+ else slot(config, folderOf(p)).initialize = v;
84
+ }
85
+ const Luma: LumaApi = { version, config };
86
+ if (globalInit && (await globalInit(config, Luma)) === false) return null;
87
+ if (config._global.disabled || !config._global.publishableKey || !config._global.channelId) return null;
88
+ const apps = [config.autocomplete, config.search, config.plp, ...Object.values(config.recommenders ?? {})];
89
+ for (const app of apps) if (app && !app.disabled && app.initialize && (await (app.initialize as any)(app, Luma)) === false) app.disabled = true;
90
+ // Anything a category page does not set is the search page's.
91
+ const { search, plp } = config;
92
+ if (plp) for (const k of LISTING) if (plp[k] == null && search?.[k] != null) (plp as any)[k] = search[k];
93
+ for (const l of [search, plp]) if (l && l.reserveSelector == null && l.anchor) l.reserveSelector = [l.anchor].flat()[0];
94
+ return config;
95
+ };
96
+
97
+ /** A widget's lazy `_translations.ts` (see IntegrationFiles), into their folders' slots before its context is built. */
98
+ export const loadTranslations = (config: LumaConfigType, files: Pick<IntegrationFiles, 'translations'>, folders: readonly string[]) =>
99
+ Promise.all(
100
+ Object.entries(files.translations)
101
+ .filter(([p, v]) => typeof v === 'function' && folders.includes(folderOf(p)))
102
+ .map(async ([p, f]) => {
103
+ slot(config, folderOf(p)).translations = parseConfigJson(await f(), p);
104
+ }),
105
+ );
106
+
107
+ /** A widget's files from a glob, in folder order; from _global/, files named after another widget stay out. */
108
+ export const widgetFiles = <X>(kind: string, folders: string[], glob: Lazy<X>) => {
109
+ const uses = kind === 'plp' ? ['search', 'plp'] : [kind];
110
+ const mine = (p: string) => {
111
+ const prefix = nameOf(p).split('-')[0];
112
+ return folders.includes(folderOf(p)) && !p.endsWith('/style-host.css') && (folderOf(p) !== '_global' || !KINDS.includes(prefix) || uses.includes(prefix));
113
+ };
114
+ return Promise.all(
115
+ Object.entries(glob)
116
+ .filter(([p]) => mine(p))
117
+ .sort(([a], [b]) => folders.indexOf(folderOf(a)) - folders.indexOf(folderOf(b)))
118
+ .map(async ([p, f]) => [p, await f()] as const),
119
+ );
120
+ };
@@ -0,0 +1,100 @@
1
+ // The listings' boot: search results and category pages. In lbx-nav, not in
2
+ // the entry: the entry (main.ts) decides from the URL alone (the routes'
3
+ // accepts) and loads this with the listing's own chunks, on a page of a
4
+ // listing route, so a page that is not one never downloads it. Until it is
5
+ // here the entry holds their listing (the early hold claimed, or its own hold
6
+ // CSS); each route's takeover continues that hold.
7
+
8
+ import type { SearchConfigType, PlpConfigType, GlobalConfigType } from '../types';
9
+ import { hasPrefetch, productsChannel, putPrefetch } from './prefetch';
10
+ import type { Rule } from './reconciler';
11
+ import { searchReadQuery } from './routes';
12
+ import { onFlush } from './scheduler';
13
+ import { onUrlChange } from './sense';
14
+ import { hasStash } from './stash';
15
+ import { whenApplied } from './styles';
16
+ import { takeover, type Takeover } from './takeover';
17
+ import { startTurbo } from './turbo';
18
+
19
+ export interface ListingRoute {
20
+ kind: 'search' | 'plp';
21
+ cfg: SearchConfigType | PlpConfigType;
22
+ accepts: (u: URL) => boolean;
23
+ hostCss: string;
24
+ }
25
+
26
+ export interface ListingDeps {
27
+ g: GlobalConfigType;
28
+ /** The widget's chunks (main.ts load(): cached, so the entry's early call is the same load). */
29
+ load: (kind: 'search' | 'plp') => Promise<{ mod: any; ctx: any }>;
30
+ /** main.ts run(): a widget call whose errors are reported, never thrown. */
31
+ run: (where: string, p: Promise<{ mod: any; ctx: any }>, f: (m: { mod: any; ctx: any }) => unknown, onFail?: () => void) => unknown;
32
+ /** Routes the entry already holds the current visit of. */
33
+ held: Set<string>;
34
+ /**
35
+ * The reconciler's (lbx-place), handed in: the entry loads it in parallel
36
+ * with this chunk, which typing loads too (search while typing) and which
37
+ * must not pull placement code into a page without a listing.
38
+ */
39
+ addRule: (r: Rule) => unknown;
40
+ }
41
+
42
+ const toArray = <X>(v: X | X[] | undefined): X[] => (v == null ? [] : Array.isArray(v) ? v : [v]);
43
+
44
+ const listing = ({ kind, cfg, accepts, hostCss }: ListingRoute, { g, load, run, held, addRule }: ListingDeps) => {
45
+ const tag = `lbx-${kind}`;
46
+ const tk: Takeover = takeover({
47
+ id: kind,
48
+ tag,
49
+ accepts,
50
+ hostCss,
51
+ reserveSelector: cfg.reserveSelector!,
52
+ holdTimeoutMs: cfg.holdTimeoutMs ?? 4000,
53
+ // Another category, another query: a new visit (hold, or hand back anew).
54
+ visitKey: kind === 'plp' ? (u) => u.pathname : searchReadQuery(cfg as SearchConfigType),
55
+ held: held.has(kind),
56
+ });
57
+ // The element is placed by the reconciler; its class arrives with the
58
+ // widget's chunk and upgrades it in place (custom elements are upgraded when
59
+ // defined). Where their server fell back to their own listing, the page is theirs.
60
+ const active = (u: URL) => accepts(u) && tk.ssr !== 'fellback';
61
+ addRule({ id: kind, active, anchors: toArray(cfg.anchor), position: cfg.position!, settleMs: cfg.settleMs, create: () => document.createElement(tag) });
62
+ // Search: the request starts here, in parallel with the widget's chunks
63
+ // (the entry asked for them with this one). Once our element is on the page
64
+ // it fetches URL changes itself (show more continues from a cursor): a
65
+ // prefetch then would be a second, full request. The slot is taken before
66
+ // the listing starts, so it waits for this request rather than asking twice.
67
+ // A failure (the chunk, the request) is the listing's, as a failed request is.
68
+ const prefetch = (url: URL) => {
69
+ // A result stashed for this load (typed on the previous page, or left for Back): the listing renders that.
70
+ if (kind !== 'search' || !active(url) || tk.ssr || hasPrefetch(url.href) || document.querySelector(tag) || hasStash(url.href)) return;
71
+ const { products, onProducts } = productsChannel();
72
+ putPrefetch(url.href, import('../widgets/search/first').then((m) => m.searchFirst(g, cfg as SearchConfigType, url, onProducts)), { products });
73
+ };
74
+ let started = false;
75
+ const ensure = (url: URL) => {
76
+ prefetch(url);
77
+ if (started || !active(url)) return;
78
+ started = true;
79
+ // Loading adds modulepreload links to <head>: not while it is being parsed.
80
+ whenApplied(() =>
81
+ run(kind, load(kind), ({ mod, ctx }) => mod.start(ctx, tk), () => {
82
+ started = false; // the next URL change into the route tries again
83
+ tk.release(true);
84
+ }),
85
+ );
86
+ };
87
+ ensure(new URL(location.href));
88
+ onUrlChange(ensure);
89
+ // A hook that reads the page (`active()` on their data layer) may accept it
90
+ // only later, without a URL change: looked at again on every reconcile until started.
91
+ onFlush(() => !started && ensure(new URL(location.href)));
92
+ return { tk, accepts: active };
93
+ };
94
+
95
+ /** Each route's takeover, placement rule and loading; Turbo Drive's history around ours (core/turbo.ts). */
96
+ export const startListings = (routes: ListingRoute[], deps: ListingDeps, setUp: <X>(where: string, f: () => X) => X | null) => {
97
+ const listings = routes.map((r) => setUp(r.kind, () => listing(r, deps))).filter((l): l is NonNullable<typeof l> => !!l);
98
+ if (listings.length) setUp('turbo', () => startTurbo(listings));
99
+ return listings;
100
+ };
@@ -0,0 +1,14 @@
1
+ // Luma's dictionaries (src/locales/<locale>.json), each its own chunk, only
2
+ // the site's locale ever loaded. In the entry (a few bytes: the glob's map),
3
+ // so a widget's load asks for its dictionary in parallel with its chunks
4
+ // rather than after lbx-core arrived. No DOM: the server renderer uses it too.
5
+
6
+ import type { Dictionary } from '../types';
7
+
8
+ const locales = import.meta.glob<Dictionary>('../locales/*.json', { import: 'default' });
9
+
10
+ const cache = new Map<string, Promise<Dictionary>>();
11
+ export const loadDictionary = (locale: string) => {
12
+ if (!cache.has(locale)) cache.set(locale, (locales[`../locales/${locale}.json`] ?? locales['../locales/en.json'])());
13
+ return cache.get(locale)!;
14
+ };
@@ -0,0 +1,49 @@
1
+ // Everything we insert into the page is marked, so sensing can ignore our own
2
+ // mutations and the reconciler can find its nodes.
3
+
4
+ export const OWN_ATTR = 'data-lbx';
5
+
6
+ /**
7
+ * Whether a node of ours the host removes is put back before the next paint
8
+ * (core/sense.ts, scheduleNow): from when we have something to put back, a
9
+ * sheet of ours (core/styles.ts) or the reconciler's elements. Until then a
10
+ * removal is not searched for ours: on a busy page (a carousel) that was a
11
+ * querySelector per removed node, every frame, where Luma had placed nothing.
12
+ */
13
+ let removals = false;
14
+ export const watchOwnRemovals = () => void (removals = true);
15
+ export const ownRemovalsWatched = () => removals;
16
+
17
+ /**
18
+ * The modal container around a host element (a dialog, a focus-trapped
19
+ * drawer, an open popover), across shadow roots. What we show for it goes
20
+ * inside: outside a modal we would be inert, and a press on us would count as
21
+ * outside it and close it.
22
+ */
23
+ export const modalOf = (el: Element): Element | null => {
24
+ for (let n: Element | null = el; n; n = ((n.getRootNode() as ShadowRoot).host as Element | undefined) ?? null) {
25
+ const d = n.closest('dialog, [role=dialog], [aria-modal=true], [popover]:popover-open');
26
+ if (d) return d;
27
+ }
28
+ return null;
29
+ };
30
+
31
+ /** React hydrating the whole document (Next.js app router, Remix, React Router 7 / Hydrogen). */
32
+ export const wholeDocument = () => {
33
+ const w = window as any;
34
+ return !!(w.__next_f || w.__remixContext || w.__reactRouterContext);
35
+ };
36
+ /**
37
+ * A page that may yet be hydrated as a whole: known whole-document React, or a
38
+ * Next.js page still parsing (its flag comes at the end of <body>, its assets
39
+ * show before). Anywhere else, nothing of ours has to wait for a framework.
40
+ */
41
+ export const documentMayHydrate = () => wholeDocument() || (document.readyState === 'loading' && !!document.querySelector('script[src*="/_next/"]'));
42
+
43
+ /** React has hydrated this element (claimed it: a fiber, or React 16's instance). */
44
+ export const hasFiber = (n: object) => Object.keys(n).some((k) => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'));
45
+
46
+ export const isOwnNode = (n: Node | null): boolean => {
47
+ const el = n && (n.nodeType === 1 ? (n as Element) : n.parentElement);
48
+ return !!el?.closest(`[${OWN_ATTR}]`);
49
+ };
@@ -0,0 +1,14 @@
1
+ // Which page a URL shows. Tiny and DOM-free, so the entry can key prefetched
2
+ // and stashed results by page without pulling in core/url.ts (router writes),
3
+ // which only the listing chunk needs. canon lives in core/entries.ts.
4
+
5
+ import { canon } from './entries';
6
+ import { withoutTracking } from './routes';
7
+
8
+ export { canon };
9
+
10
+ /**
11
+ * The page a URL shows, for matching server-rendered state to it: canonical and
12
+ * without tracking params (an include leaves those out, the browser's URL has them).
13
+ */
14
+ export const pageKey = (href: string) => canon(withoutTracking(href).href);