@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
package/src/main.ts ADDED
@@ -0,0 +1,493 @@
1
+ // Entry of an integration: `integration/__integration.ts` hands its files to
2
+ // startIntegration(). Folder = app:
3
+ //
4
+ // integration/_global/ _config.ts _init.ts _translations.ts style-*.css template-*.ts
5
+ // integration/autocomplete/ _config.ts _init.ts _translations.ts style-*.css template-*.ts
6
+ // integration/search/ … + style-host.css (hides the host's listing while ours is shown)
7
+ // integration/plp/ … (templates fall back to search/)
8
+ // integration/recommender <name>/
9
+ //
10
+ // What this entry contains is only what must run on every page: configs and
11
+ // inits, URL/DOM sensing, placement rules, and the triggers that decide when a
12
+ // widget can run. Each widget's code, templates, styles and translations are
13
+ // separate chunks, loaded on demand: autocomplete on the first interaction with
14
+ // the search input, search / PLP when the URL is one of theirs, a recommender
15
+ // when its placement is near the viewport or its trigger fires. A site without
16
+ // a recommender config never downloads recommender code.
17
+
18
+ import type { Api } from './api';
19
+ import { health, mark, reportError } from './core/health';
20
+ import type { T } from './core/i18n';
21
+ import { failures, holdCss, MAX_FAILURES, painted, reserveCss } from './core/hold';
22
+ import { hostStyle, LISTINGS, loadConfig, loadTranslations, widgetFiles, type IntegrationFiles } from './core/integration';
23
+ import type { Templates } from './core/templates';
24
+ import { plpAccepts, searchAccepts, searchReadQuery } from './core/routes';
25
+ import { clearEarlyRules, earlyHold, plpRule, searchRule, writeEarlyRules, type EarlyRoute } from './core/early-rules';
26
+ import { askChallenge, storedToken } from './api/token-key';
27
+ import { loadDictionary } from './core/locales';
28
+ import { keepDl } from './core/kept';
29
+ import './core/boot-state'; // the page's state at boot, for lbx-place's reconciler
30
+ import { onFlush, schedule } from './core/scheduler';
31
+ import { onUrlChange, startSensing } from './core/sense';
32
+ import { layered, setCss, whenApplied } from './core/styles';
33
+ import type { ListingRoute } from './core/listings';
34
+ import type { Reco } from './core/recommenders';
35
+ import type { GlobalConfigType, LumaConfigType, Result } from './types';
36
+
37
+ declare const __VERSION__: string;
38
+ // Replaced at build time; the dev server (the loader's development mode) does not replace it.
39
+ const VERSION = typeof __VERSION__ === 'string' ? __VERSION__ : 'dev';
40
+
41
+ export type { IntegrationFiles };
42
+
43
+ /** What a widget chunk gets: its config, the API, translations, templates and helpers. */
44
+ export interface WidgetContext<C = any> {
45
+ /** Instance id; also the element's data-lbx value (recommenders: reco-<name>). */
46
+ id: string;
47
+ config: C;
48
+ global: GlobalConfigType;
49
+ api: Api;
50
+ t: T;
51
+ templates: Templates;
52
+ /** Integration CSS of the widget's folders (the widget decides where it goes: document or shadow root). */
53
+ styles: string[];
54
+ result: (hit: any) => Result;
55
+ /** Helpers every template scope gets. */
56
+ helpers: Record<string, unknown>;
57
+ }
58
+
59
+ export const startIntegration = async (files: IntegrationFiles) => {
60
+ const w = window as any;
61
+ // Luma's one global. The inline snippet may have made it before us (its early
62
+ // hold, `early`): ours goes into it. A copy already started has its `version`:
63
+ // a second copy must never double-bind.
64
+ if (w.__lbx?.version) return;
65
+ w.__lbx = Object.assign(w.__lbx ?? {}, { version: VERSION, health, bootedAt: performance.now() });
66
+ try {
67
+ // The floor (Baseline Widely available, built for
68
+ // Chrome/Edge 107, Firefox 104, Safari 16): our CSS is in @layer, which an older browser drops whole, while the
69
+ // host overrides (unlayered) would still hide the site's own widgets.
70
+ // There the site keeps its own.
71
+ const layers = typeof (window as any).CSSLayerBlockRule === 'function';
72
+ if (!layers) mark('off: no CSS cascade layers (below the browser floor)');
73
+ const config = layers ? await loadConfig(files, VERSION) : null;
74
+ if (config) {
75
+ w.__lbx.config = config;
76
+ // HTTPS only (secure contexts: HTTPS, or localhost). The rule came from
77
+ // the token's proof of work on crypto.subtle (secure contexts only);
78
+ // its solver is plain JS since (api/token.ts), the rule stands. Said
79
+ // once, with the fix; nothing starts and the site keeps its own UI.
80
+ if (!isSecureContext)
81
+ return void (reportError('boot', `not a secure context (${location.origin}): Luma runs on HTTPS pages only. Serve the site over HTTPS; Luma's widgets did not start (the site keeps its own).`), off());
82
+ return boot(config, files);
83
+ }
84
+ } catch (e) {
85
+ reportError('boot', e);
86
+ }
87
+ off();
88
+ };
89
+
90
+ /**
91
+ * Off (disabled, no key, not a secure context) or broken: nothing will claim
92
+ * an early hold, and the snippet must stop holding on later page views.
93
+ */
94
+ const off = () => {
95
+ earlyHold()?.drop?.();
96
+ clearEarlyRules();
97
+ };
98
+
99
+ type Loaded = { mod: any; ctx: WidgetContext };
100
+ /**
101
+ * Run a widget once it has loaded (the load reports its own failure; `onFail`
102
+ * puts its trigger back). Whatever the widget throws or rejects is reported,
103
+ * never thrown into the page.
104
+ */
105
+ const run = (where: string, p: Promise<Loaded>, f: (m: Loaded) => unknown, onFail?: () => void) =>
106
+ p.then(f, () => onFail?.()).catch((e) => reportError(where, e));
107
+
108
+ const modules = {
109
+ autocomplete: () => import('./widgets/autocomplete'),
110
+ search: () => import('./widgets/search'),
111
+ plp: () => import('./widgets/plp'),
112
+ recommender: () => import('./widgets/recommender'),
113
+ };
114
+ type Kind = keyof typeof modules;
115
+
116
+ function boot(config: LumaConfigType, files: IntegrationFiles) {
117
+ const g = config._global;
118
+ const region = g.region ?? 'eu1';
119
+ // Nothing of ours goes into a <head> still being parsed (a whole-document
120
+ // React 18 would re-render the page): links, like our sheets, once <body> starts.
121
+ // The product image host, learnt from an earlier listing (widgets/listing/instant.ts).
122
+ let img: string | null = null;
123
+ try {
124
+ img = g.preconnectImages !== false ? localStorage.getItem('lbx:img') : null;
125
+ } catch {}
126
+ const api = `https://api.${region}.luigisbox.ai`;
127
+ const preconnect = (host: string, cors = true) => {
128
+ const pc = document.createElement('link');
129
+ pc.rel = 'preconnect';
130
+ pc.href = host;
131
+ if (cors) pc.crossOrigin = 'anonymous'; // fetch() with CORS; images are no-cors
132
+ pc.setAttribute('data-lbx', 'preconnect');
133
+ document.head.append(pc);
134
+ };
135
+ whenApplied(() => {
136
+ preconnect(`https://auth.${region}.luigisbox.ai`);
137
+ preconnect(api);
138
+ if (img) preconnect(img, false);
139
+ });
140
+ if ((document as any).prerendering) document.addEventListener('prerenderingchange', () => mark('prerendered page activated'), { once: true });
141
+ // Every widget needs the browser token: minted (a proof of work, off the
142
+ // main thread) as soon as one is wanted, a widget's load (a hover over the
143
+ // search input, a listing page, a recommender), in parallel with its chunks,
144
+ // else once the page has loaded and the browser is idle: ready before the
145
+ // first keystroke (minted on the click instead, the first suggestions came
146
+ // ~0.9 s later on a production storefront at 4x), without competing with the page's own
147
+ // loading. Cached for the session: the minting code (its own chunk) is
148
+ // loaded only when there is none yet.
149
+ let warmed = false;
150
+ const warm = () => {
151
+ if (warmed) return;
152
+ warmed = true;
153
+ if (storedToken(g)) return;
154
+ askChallenge(g); // while the minting code loads
155
+ import('./api/token').then((m) => m.warmToken(g)).catch((e) => reportError('token', e));
156
+ };
157
+ const afterLoad = (f: () => void) => {
158
+ const idle = () => (typeof requestIdleCallback === 'function' ? requestIdleCallback(f, { timeout: 2000 }) : setTimeout(f, 200));
159
+ if (document.readyState === 'complete') idle();
160
+ else addEventListener('load', idle, { once: true });
161
+ };
162
+ afterLoad(warm);
163
+ startSensing();
164
+
165
+ // Analytics starts once the page has loaded, or with the first widget (its
166
+ // setup then listens before a tile is clicked). Set up at the end of boot.
167
+ let analyticsWanted = false;
168
+ let startAnalytics = () => void (analyticsWanted = true);
169
+
170
+ // --- lazy widget loading: code + templates + styles + dictionaries in parallel
171
+ const locale = g.locale ?? 'en';
172
+ const loaded = new Map<string, Promise<Loaded>>();
173
+ const load = (kind: Kind, id: string, folders: readonly string[], app: any, scopes: readonly string[]) => {
174
+ if (loaded.has(id)) return loaded.get(id)!;
175
+ mark(`load ${id}`);
176
+ warm();
177
+ startAnalytics();
178
+ const support = import('./core/support');
179
+ const p = Promise.all([
180
+ modules[kind]() as Promise<any>,
181
+ support,
182
+ widgetFiles(kind, [...folders], files.templates),
183
+ widgetFiles(kind, [...folders], files.styles),
184
+ loadDictionary(locale),
185
+ loadTranslations(config, files, folders),
186
+ // Hooks only the widget calls (`_widget.ts`): run before its context is made.
187
+ support.then((s) => s.loadWidgetInits(config, files, folders, VERSION)),
188
+ ]).then(([mod, s, tpls, css, dict]) => {
189
+ if (mod.css) setCss(`default:${kind}`, layered(mod.css));
190
+ // Product pages on intent (default on, core/speculation.ts): our links come with the first widget.
191
+ if (g.speculation !== false) s.speculate(g, onUrlChange);
192
+ const ctx = s.widgetContext(s, config, { id, folders: [...folders], app, scopes: [...scopes] }, mod.templates, tpls, css, dict);
193
+ mark(`loaded ${id}`);
194
+ return { mod, ctx };
195
+ });
196
+ // A failed load (network, a blocked chunk) is not remembered: the next trigger tries again.
197
+ p.catch((e) => {
198
+ reportError(`load ${id}`, e);
199
+ loaded.delete(id);
200
+ });
201
+ loaded.set(id, p);
202
+ return p;
203
+ };
204
+
205
+ const hostCss = (folder: string) => hostStyle(files, folder);
206
+
207
+ // --- autocomplete: the first interaction with the search input loads it
208
+ const ac = config.autocomplete;
209
+ // The dropdown is a popover (top layer): without it (Safari < 17, Firefox < 125) the site keeps its own.
210
+ if (ac && !ac.disabled && g.inputSelector && 'showPopover' in HTMLElement.prototype) {
211
+ const css = hostCss('autocomplete');
212
+ const TYPES = ['pointerdown', 'focusin', 'input'];
213
+ const loadAc = () => load('autocomplete', 'autocomplete', ['_global', 'autocomplete'], ac, ['autocomplete']);
214
+ // The first keystroke must not wait for the chunk: fetch it (not start it)
215
+ // on a hover over the input (a visitor who never goes near it downloads nothing;
216
+ // on touch, the tap's pointerdown/focus below starts it).
217
+ // The API's connection again: the browser closes one unused for about 10 s,
218
+ // and a visitor who read the page longer would wait for a new one (a TCP
219
+ // and TLS setup, one to three round trips) before their first suggestions.
220
+ let near = false;
221
+ const reconnect = () => near || ((near = true), preconnect(api));
222
+ const prefetch = () => (reconnect(), void loadAc().catch(() => {}));
223
+ const hover = (e: Event) => {
224
+ const i = e.composedPath()[0];
225
+ if (i instanceof HTMLInputElement && i.matches(g.inputSelector!)) removeEventListener('pointerover', hover, true), prefetch();
226
+ };
227
+ addEventListener('pointerover', hover, true);
228
+ const listen = (on: boolean) => {
229
+ for (const t of TYPES) (on ? addEventListener : removeEventListener)(t, trigger, true);
230
+ };
231
+ let begun = false;
232
+ const begin = () => {
233
+ if (begun) return;
234
+ begun = true;
235
+ listen(false);
236
+ run(
237
+ 'autocomplete',
238
+ loadAc(),
239
+ ({ mod, ctx }) => {
240
+ // Their suggestions are hidden only once ours run: a failed load or start leaves theirs.
241
+ mod.start(ctx);
242
+ if (css) setCss('host-autocomplete', css);
243
+ },
244
+ () => ((begun = false), listen(true)),
245
+ );
246
+ };
247
+ const isInput = (i: unknown): i is HTMLInputElement => i instanceof HTMLInputElement && i.matches(g.inputSelector!);
248
+ // composedPath: an input inside a shadow root reaches window retargeted to its host.
249
+ const trigger = (e: Event | { target: Element }) => {
250
+ if (!isInput(('composedPath' in e ? e.composedPath()[0] : null) ?? e.target)) return;
251
+ reconnect(); // a tap: no hover before it
252
+ begin();
253
+ };
254
+ // A touch screen has no hover: the chunk was fetched on the tap, and the
255
+ // phone sheet opened a download later, in one long task with the chunk's
256
+ // start (a production storefront at 4x CPU: 237 ms after the tap). Started once the page
257
+ // has loaded and the browser is idle, where the search input is on the
258
+ // page (its listeners, the sheet prepared: widgets/autocomplete): the tap
259
+ // opens it at once, the API reconnected then.
260
+ if (typeof matchMedia === 'function' && matchMedia('(hover: none)').matches) {
261
+ const ahead = () => {
262
+ if (!document.querySelector(g.inputSelector!) || begun) return;
263
+ const tap = (e: Event) => isInput(e.composedPath()[0]) && (removeEventListener('pointerdown', tap, true), reconnect());
264
+ addEventListener('pointerdown', tap, true);
265
+ begin();
266
+ };
267
+ const later = () => (typeof requestIdleCallback === 'function' ? requestIdleCallback(ahead, { timeout: 3000 }) : setTimeout(ahead, 500));
268
+ if (document.readyState === 'complete') later();
269
+ else addEventListener('load', later, { once: true });
270
+ }
271
+ listen(true);
272
+ // Already focused when we boot (the visitor was quick): load now.
273
+ if (document.activeElement instanceof HTMLInputElement && document.activeElement.matches(g.inputSelector)) trigger({ target: document.activeElement });
274
+ }
275
+
276
+ /** One widget's setup: if it throws, the others still start. */
277
+ const setUp = <X>(where: string, f: () => X): X | null => {
278
+ try {
279
+ return f();
280
+ } catch (e) {
281
+ reportError(where, e);
282
+ return null;
283
+ }
284
+ };
285
+
286
+ // A route's or a recommender's hook may accept the page only once content
287
+ // arrives after our boot or after an SPA's URL change (their data layer, the
288
+ // product): looked at again on reconciles for a while after the load and
289
+ // after each URL change, not for the page's life (a busy page reconciles
290
+ // every frame).
291
+ // A flag a timer clears, not a clock read per reconcile (a busy page reconciles every frame).
292
+ const LATE_MS = 10_000;
293
+ let recently = false;
294
+ let lateTimer = 0;
295
+ const late = () => {
296
+ recently = true;
297
+ clearTimeout(lateTimer);
298
+ lateTimer = window.setTimeout(() => (recently = false), LATE_MS);
299
+ };
300
+ late();
301
+ if (document.readyState !== 'complete') addEventListener('load', late, { once: true });
302
+ onUrlChange(late);
303
+ const recent = () => recently;
304
+
305
+ // --- listings: search results and category pages. Their boot (takeover,
306
+ // placement rule, the search request: core/listings.ts) is in lbx-nav,
307
+ // asked for with the listing's own chunks on a page of a route, so a page
308
+ // that is not one never downloads it. Until it is here, the entry holds
309
+ // their listing for it: it claims the early hold (stopping the snippet's
310
+ // timer), or writes the hold CSS itself before first paint and in the same
311
+ // task as an SPA's URL change into the route; the takeover continues that hold.
312
+ const routes: ListingRoute[] = [];
313
+ const search = config.search && !config.search.disabled ? config.search : null;
314
+ const plp = config.plp && !config.plp.disabled ? config.plp : null;
315
+ if (search) setUp('search', () => routes.push({ kind: 'search', cfg: search, accepts: searchAccepts(search), hostCss: hostCss('search') }));
316
+ if (plp) setUp('plp', () => routes.push({ kind: 'plp', cfg: plp, accepts: plpAccepts(plp), hostCss: hostCss('plp') }));
317
+ const early = earlyHold();
318
+ const loadListing = (kind: 'search' | 'plp') => {
319
+ const { folders, scopes } = LISTINGS[kind];
320
+ return load(kind, kind, folders, routes.find((r) => r.kind === kind)!.cfg, scopes);
321
+ };
322
+ /** Routes the entry holds now (their boot not here yet), and their fail-safes. */
323
+ const held = new Map<string, number>();
324
+ let ready = false;
325
+ let booting: Promise<unknown> | null = null;
326
+ let failedOn = '';
327
+ const unhold = (kind: string) => {
328
+ clearTimeout(held.get(kind));
329
+ held.delete(kind);
330
+ setCss(`host-${kind}`, null);
331
+ early?.drop?.();
332
+ };
333
+ const boot = () =>
334
+ (booting ??= Promise.all([import('./core/listings'), import('./core/reconciler')])
335
+ .then(([m, { addRule }]) => {
336
+ for (const t of held.values()) clearTimeout(t);
337
+ m.startListings(routes, { g, load: loadListing, run, held: new Set(held.keys()), addRule }, setUp);
338
+ held.clear();
339
+ ready = true;
340
+ })
341
+ .catch((e) => {
342
+ reportError('listings', e);
343
+ for (const kind of [...held.keys()]) unhold(kind);
344
+ booting = null; // the next URL change into a route tries again
345
+ failedOn = location.href;
346
+ }));
347
+ /** The page is (now) one of a route's: hold it if it is ours to take, and ask for its code. */
348
+ let last: URL | null = null;
349
+ const enter = (u: URL, spa: boolean) => {
350
+ const prev = last;
351
+ last = u;
352
+ if (ready) return;
353
+ let any = false;
354
+ for (const r of routes) {
355
+ if (!r.accepts(u)) {
356
+ if (held.has(r.kind)) unhold(r.kind);
357
+ continue;
358
+ }
359
+ any = true;
360
+ // Theirs shows at once where we failed too often; a server-rendered page holds nothing.
361
+ // Moving within the route (their filters) keeps what is shown.
362
+ if (held.has(r.kind) || (spa && prev && r.accepts(prev)) || failures(r.kind) >= MAX_FAILURES || document.querySelector(`lbx-${r.kind}[data-lbx-ssr], template[data-lbx-ssr="${r.kind}"]`)) continue;
363
+ const claimed = !spa && !!early?.claim?.(r.kind);
364
+ if (!claimed && !spa && painted()) continue; // booted after first paint: theirs stays until ours renders
365
+ // Ours too where the early hold is claimed: with the height ours had on this
366
+ // URL (a reload restores scroll before our results exist), at boot.
367
+ setCss(`host-${r.kind}`, `${holdCss(r.hostCss, r.cfg.reserveSelector!)}${reserveCss(r.cfg.reserveSelector!)}`);
368
+ held.set(r.kind, window.setTimeout(() => !ready && unhold(r.kind), r.cfg.holdTimeoutMs ?? 4000));
369
+ }
370
+ if (!any) return;
371
+ // A listing's first request waits for the token: minted from now, not from
372
+ // its load (which waits for <body>: category pages showed ~0.26 s later).
373
+ warm();
374
+ boot();
375
+ // The widget's chunks in parallel with its boot (the same load the listing starts: cached).
376
+ // Loading adds modulepreload links to <head>: not while it is being parsed.
377
+ for (const r of routes) if (r.accepts(u)) whenApplied(() => void loadListing(r.kind).catch(() => {}));
378
+ };
379
+ enter(new URL(location.href), false);
380
+ onUrlChange((u) => enter(u, true));
381
+ // A page of a listing route's URL shape whose hook accepts it only later
382
+ // (their data layer, after our boot): its token from now, not from then
383
+ // (a production storefront's category pages: ~0.3 s sooner).
384
+ if (routes.some((r) => r.cfg.urlMatch && setUp(`${r.kind} urlMatch`, () => new RegExp(r.cfg.urlMatch!).test(location.pathname)))) warm();
385
+ // A route whose hook reads the page (an `active()` on their data layer or a
386
+ // node) may accept it only once the page has that: looked at again on
387
+ // reconciles (while `recent()`) until the listings' boot is asked for (a failed one: on the next URL).
388
+ if (routes.length) onFlush(() => recent() && !booting && failedOn !== location.href && enter(new URL(location.href), false));
389
+ // Results ready on Enter: the first input into the search field loads the prefetch-while-typing chunk.
390
+ if (search && routes.some((r) => r.kind === 'search') && search.prefetchWhileTyping !== false && g.inputSelector) {
391
+ const first = (e: Event) => {
392
+ const i = e.composedPath?.()[0] ?? e.target;
393
+ if (!(i instanceof HTMLInputElement) || !i.matches(g.inputSelector!)) return;
394
+ removeEventListener('input', first, true);
395
+ import('./widgets/search/speculate').then((m) => m.startSpeculation(g, search, i)).catch((err) => reportError('search prefetch', err));
396
+ };
397
+ addEventListener('input', first, true);
398
+ }
399
+ // A hold no route of ours continues (stale rules, a URL the snippet
400
+ // over-matched) ends now, not at its timeout; a held route's ends with its takeover.
401
+ if (!held.size) whenApplied(() => early?.drop?.());
402
+ // Rules for the inline early-hold snippet's next hard load (src/core/early-rules.ts).
403
+ // Dev bundles boot late (unbundled modules): a longer snippet timeout there.
404
+ // A route failing this session gets no rule: the snippet must not hold it on every page view.
405
+ const ok = (kind: string) => routes.some((r) => r.kind === kind) && failures(kind) < MAX_FAILURES;
406
+ writeEarlyRules(
407
+ [search && ok('search') ? searchRule(search, hostCss('search')) : null, plp && ok('plp') ? plpRule(plp, hostCss('plp')) : null].filter((r): r is EarlyRoute => !!r),
408
+ VERSION === 'dev' ? 6000 : 1500,
409
+ );
410
+
411
+ // --- recommenders: their triggers and placement (core/recommenders.ts, in
412
+ // lbx-place with the reconciler) load on a page an inline one's urlMatch /
413
+ // active() accepts (looked at again on URL changes and reconciles), and at
414
+ // boot where a popup one is configured (its trigger may fire on any page).
415
+ const recos: Reco[] = [];
416
+ const pages: ((u: URL) => boolean)[] = [];
417
+ /** Our recommenders by name, and the pages each runs on: where ours runs, the site's of that name is not tracked. */
418
+ const recoPages = new Map<string, (u: URL) => boolean>();
419
+ for (const [name, rc] of Object.entries(config.recommenders ?? {})) {
420
+ if (rc.disabled) continue;
421
+ const id = `reco-${name}`;
422
+ const folders = ['_global', `recommender ${name}`];
423
+ // style-host.css of the folder: e.g. hides the host's own box our widget replaces
424
+ // (scoped by our element's state, so it is inert where the widget is not).
425
+ const css = hostCss(`recommender ${name}`);
426
+ if (css) setCss(`host-${id}`, css);
427
+ // The popup is a popover (top layer): without the API there is no popup.
428
+ if (rc.confirm && !('showPopover' in HTMLElement.prototype)) continue;
429
+ recos.push({ id, rc, get: () => load('recommender', id, folders, rc, ['recommender']) });
430
+ const re = !rc.confirm && rc.urlMatch ? setUp(id, () => new RegExp(rc.urlMatch!)) : null;
431
+ pages.push(rc.confirm ? () => true : (u) => (rc.active ? rc.active(u) : !re || re.test(u.pathname)));
432
+ recoPages.set(name, pages[pages.length - 1]);
433
+ }
434
+ if (recos.length) {
435
+ let asked = false;
436
+ let failedOn = '';
437
+ const check = () => {
438
+ if (asked || failedOn === location.href) return;
439
+ const u = new URL(location.href);
440
+ if (!pages.some((p, i) => setUp(`${recos[i].id} active`, () => p(u)))) return;
441
+ asked = true;
442
+ import('./core/recommenders')
443
+ .then((m) => m.startRecommenders(recos, run, setUp))
444
+ .catch((e) => {
445
+ reportError('recommenders', e);
446
+ asked = false;
447
+ failedOn = location.href;
448
+ });
449
+ };
450
+ check();
451
+ onUrlChange(check);
452
+ onFlush(() => recent() && check());
453
+ }
454
+
455
+ // --- analytics (src/analytics, lbx-analytics): where the integration has an
456
+ // _analytics.ts (nothing is sent that it does not say), every page once it
457
+ // has loaded or with the first widget (its chains on our tiles listen then).
458
+ // Without one, `analytics: false`: our tiles queue nothing (core/tiles.ts).
459
+ if (!Object.keys(files.analytics ?? {}).length) g.analytics = false;
460
+ if (g.analytics !== false) {
461
+ const here = () => new URL(location.href);
462
+ const readQ = search ? searchReadQuery(search) : null;
463
+ const owns = (s: string) => {
464
+ if (s === 'autocomplete') return !!(ac && !ac.disabled && g.inputSelector);
465
+ if (s === 'search') return routes.some((r) => r.kind === 'search' && r.accepts(here()));
466
+ const reco = s.startsWith('recommender:') ? recoPages.get(s.slice(12)) : undefined;
467
+ return !!reco && !!setUp(`${s} active`, () => reco(here()));
468
+ };
469
+ const visit = (u: URL) => {
470
+ const r = routes.find((x) => x.accepts(u));
471
+ return !r ? null : r.kind === 'plp' ? `plp:${u.origin}${u.pathname}` : `search:${u.origin}${u.pathname}?${readQ?.(u) ?? ''}`;
472
+ };
473
+ let started = false;
474
+ /** Analytics listens (the setup has run). Until then, a page left keeps its dataLayer pushes for the next one's (core/kept.ts), up to `kept`. */
475
+ let live = false;
476
+ let kept = 0;
477
+ addEventListener('pagehide', () => {
478
+ if (!live && !(document as any).prerendering) kept = keepDl(g.dataLayer ?? 'dataLayer', kept);
479
+ });
480
+ startAnalytics = () => {
481
+ if (started) return;
482
+ started = true;
483
+ import('./analytics/start')
484
+ .then((m) => m.start({ config, files: files.analytics ?? {}, owns, visit, onUrlChange, kept: () => kept }))
485
+ .then(() => (live = true))
486
+ .catch((e) => reportError('analytics', e));
487
+ };
488
+ if (analyticsWanted) startAnalytics();
489
+ else afterLoad(() => startAnalytics());
490
+ }
491
+
492
+ schedule();
493
+ }