@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,115 @@
1
+ // Which URLs are search / category pages. Pure and tiny: the entry needs it on
2
+ // every URL change (to hold the host's page before it paints).
3
+
4
+ import type { ParamMap, PlpConfigType, SearchConfigType } from '../types';
5
+ import { reportError } from './health';
6
+
7
+ /** An integration's URL hook (active, readQuery) that throws: the page is not ours. */
8
+ const guarded = (where: string, f: (u: URL) => boolean) => (u: URL) => {
9
+ try {
10
+ return f(u);
11
+ } catch (e) {
12
+ reportError(where, e);
13
+ return false;
14
+ }
15
+ };
16
+
17
+ /** Namespace of our own URL params (facets, page, sort we cannot express in the host's). */
18
+ export const P = 'lbx.';
19
+
20
+ // Tracking params, and the LBX loader's own (_lbx_env=development, …): never a reason to leave a page to the host.
21
+ export const TRACKING = '^(utm_|_lbx_|gclid$|gbraid$|wbraid$|fbclid$|msclkid$|_gl$|mc_(cid|eid)$|srsltid$)';
22
+ const TRACKING_RE = new RegExp(TRACKING);
23
+
24
+ /** A URL without the params in `ignore` (default: tracking) and without its #hash: the page it shows. */
25
+ export const withoutTracking = (href: string, ignore = TRACKING_RE) => {
26
+ const u = new URL(href);
27
+ for (const k of [...u.searchParams.keys()]) if (ignore.test(k)) u.searchParams.delete(k);
28
+ u.hash = '';
29
+ return u;
30
+ };
31
+
32
+ /** decodeURIComponent that never throws (a stray `%` in a hand-typed URL): the raw text instead. */
33
+ const decode = (s: string) => {
34
+ try {
35
+ return decodeURIComponent(s);
36
+ } catch {
37
+ return s;
38
+ }
39
+ };
40
+
41
+ /** Most pages a listing appends ("show more"), each one cursor request: a bound on the tiles kept in the page and in a stash. */
42
+ export const MAX_PAGE = 50;
43
+ /** A host page param that means the first page (absent is too): any later page of theirs is theirs to render. */
44
+ export const firstPage = (v: string) => /^0*1$/.test(v.trim());
45
+
46
+ /** Own keys only: a param named `constructor` or `__proto__` is not a mapped value. */
47
+ export const has = (o: object, k: string) => Object.prototype.hasOwnProperty.call(o, k);
48
+
49
+ export const searchReadQuery = (c: SearchConfigType) => {
50
+ if (c.readQuery) {
51
+ const read = c.readQuery;
52
+ return (u: URL) => {
53
+ try {
54
+ return read(u);
55
+ } catch (e) {
56
+ reportError('search readQuery', e);
57
+ return '';
58
+ }
59
+ };
60
+ }
61
+ const re = c.urlMatch ? new RegExp(c.urlMatch) : null;
62
+ return (u: URL) => (c.queryParam ? u.searchParams.get(c.queryParam) : decode((re?.exec(u.pathname)?.[1] ?? '').replace(/\+/g, ' ')))?.trim() || '';
63
+ };
64
+
65
+ export const searchAccepts = (c: SearchConfigType) => {
66
+ if (c.active) return guarded('search active', c.active);
67
+ const re = c.urlMatch ? new RegExp(c.urlMatch) : null;
68
+ const read = searchReadQuery(c);
69
+ return guarded('search readQuery', (u: URL) => (!re || re.test(u.pathname)) && !!read(u));
70
+ };
71
+
72
+ /** Values of a host param, splitting joined lists (filters=new,sale). */
73
+ export const paramValues = (url: URL, param: string, sep?: string) =>
74
+ url.searchParams
75
+ .getAll(param)
76
+ .flatMap((v) => (sep ? v.split(sep) : [v]))
77
+ .filter(Boolean);
78
+
79
+ export const flagId = (f: { param: string; value: string }) => `${f.param}=${f.value}`;
80
+
81
+ /**
82
+ * A category URL is ours only if every param on it is one we can express;
83
+ * anything else leaves the page to the host, so no URL ever loses a filter.
84
+ */
85
+ export const plpAccepts = (c: Pick<PlpConfigType, 'urlMatch' | 'params' | 'active'>) => {
86
+ const re = new RegExp(c.urlMatch);
87
+ const pm: ParamMap = c.params ?? {};
88
+ const ignore = new RegExp(pm.ignore ?? TRACKING);
89
+ const flags = pm.flags ?? [];
90
+ const facetParams = pm.facetParams ?? {};
91
+ const byParam = new Map(Object.entries(facetParams).map(([facet, fp]) => [fp.param, facet]));
92
+ const ranges = new Set((pm.ranges ?? []).flatMap((r) => [r.min, r.max]));
93
+ return guarded('plp active', (url: URL) => {
94
+ if (!(c.active ? c.active(url) : re.test(url.pathname))) return false;
95
+ for (const k of new Set(url.searchParams.keys())) {
96
+ const vs = paramValues(url, k, pm.separator);
97
+ // Our own lbx.* params are written only by our UI.
98
+ if (!vs.length || k.startsWith(P) || ignore.test(k) || ranges.has(k)) continue;
99
+ // Pages are appended, never opened (a cursor can only be followed): their
100
+ // page 2 is theirs to render, their page 1 is ours.
101
+ if (k === pm.page && vs.every(firstPage)) continue;
102
+ if (pm.sort && k === pm.sort.param && vs.every((v) => has(pm.sort!.values, v))) continue;
103
+ if (flags.some((f) => f.param === k) && vs.every((v) => flags.some((f) => f.param === k && f.value === v))) continue;
104
+ const facet = byParam.get(k);
105
+ if (facet) {
106
+ const fp = facetParams[facet];
107
+ const map = fp.values;
108
+ // Mapped values (not to ''), or any value where they pass through (slug: false).
109
+ if (!map || vs.every((v) => (has(map, v) ? !!map[v] : fp.slug === false))) continue;
110
+ }
111
+ return false;
112
+ }
113
+ return true;
114
+ });
115
+ };
@@ -0,0 +1,83 @@
1
+ // Every signal (URL change, DOM mutation, timer, widget request) collapses into
2
+ // "please reconcile soon". At most one reconcile per animation frame.
3
+
4
+ import { reportError } from './health';
5
+
6
+ type Task = () => void;
7
+
8
+ const tasks = new Set<Task>();
9
+ let pending = false;
10
+
11
+ const flush = () => {
12
+ pending = false;
13
+ for (const t of [...tasks]) {
14
+ try {
15
+ t();
16
+ } catch (e) {
17
+ reportError('flush', e);
18
+ }
19
+ }
20
+ };
21
+
22
+ export const onFlush = (t: Task) => tasks.add(t);
23
+
24
+ // Until the first paint the browser runs no frames (no rAF), yet this is when
25
+ // we want to be placed: our listing can then render as soon as its data
26
+ // arrives, and its webfonts get requested while the page is still loading.
27
+ let framesRunning = false;
28
+ const firstPaintDone = () =>
29
+ framesRunning || (framesRunning = performance.getEntriesByType('paint').length > 0 || document.readyState === 'complete');
30
+
31
+ export const schedule = () => {
32
+ if (pending) return;
33
+ pending = true;
34
+ // rAF does not run in background tabs or before the first paint; use a timeout there.
35
+ if (document.visibilityState === 'visible' && firstPaintDone()) requestAnimationFrame(flush);
36
+ else setTimeout(flush, document.visibilityState === 'visible' ? 16 : 50);
37
+ };
38
+
39
+ export const scheduleIn = (ms: number) => setTimeout(schedule, ms);
40
+
41
+ /**
42
+ * `f` in a task after the next frame has rendered: the page's styles and layout
43
+ * are clean then, so reading computed styles or sizes costs only what we read,
44
+ * not a style pass over the whole (host) document. Where no frames run (a
45
+ * hidden tab, before the first paint) in a task of its own; a tab hidden
46
+ * meanwhile (rAF waits until it is shown) by a timeout.
47
+ */
48
+ export const afterFrame = (f: Task) => {
49
+ if (document.visibilityState !== 'visible') {
50
+ // A message, not a timer: timers in background tabs wait up to a second.
51
+ const c = new MessageChannel();
52
+ c.port1.onmessage = () => f();
53
+ return c.port2.postMessage(0);
54
+ }
55
+ if (!firstPaintDone()) return void setTimeout(f, 16);
56
+ let done = false;
57
+ const run = () => !done && ((done = true), f());
58
+ // Ahead of the page's own queued tasks where the browser can (Chrome's scheduler).
59
+ const post = (globalThis as any).scheduler?.postTask;
60
+ requestAnimationFrame(() => (post ? post.call((globalThis as any).scheduler, run, { priority: 'user-blocking' }) : setTimeout(run, 0)));
61
+ setTimeout(run, 500);
62
+ };
63
+
64
+ let pendingNow = false;
65
+ /**
66
+ * Reconcile in the current microtask checkpoint, i.e. before the browser paints.
67
+ * Used when the host just removed one of our nodes: waiting for the next frame
68
+ * would paint at least one frame without us (a visible flash).
69
+ */
70
+ export const scheduleNow = () => {
71
+ if (pendingNow) return;
72
+ pendingNow = true;
73
+ queueMicrotask(() => {
74
+ pendingNow = false;
75
+ flush();
76
+ });
77
+ };
78
+
79
+ /**
80
+ * Promise check that survives a replaced global: Zone.js (Angular) swaps
81
+ * window.Promise after we load, so `instanceof Promise` misses native promises.
82
+ */
83
+ export const isPromise = <T>(x: unknown): x is Promise<T> => typeof (x as any)?.then === 'function';
@@ -0,0 +1,107 @@
1
+ // Sensing layer. Its only job is to turn host activity into schedule() calls
2
+ // and to notify URL listeners. Correctness does not depend on catching every
3
+ // signal: the reconciler is level-triggered and recomputes from scratch.
4
+
5
+ import { schedule, scheduleNow } from './scheduler';
6
+ import { reportError } from './health';
7
+ import { ownBack } from './entries';
8
+ import { isOwnNode, OWN_ATTR, ownRemovalsWatched } from './own';
9
+
10
+ type UrlListener = (url: URL) => void;
11
+ const urlListeners = new Set<UrlListener>();
12
+ let lastHref = location.href;
13
+
14
+ export const onUrlChange = (l: UrlListener) => {
15
+ urlListeners.add(l);
16
+ return () => urlListeners.delete(l);
17
+ };
18
+
19
+ const checkUrl = () => {
20
+ if (location.href === lastHref) return;
21
+ lastHref = location.href;
22
+ const url = new URL(location.href);
23
+ // One listener throwing (an integration hook inside it) must not keep the
24
+ // others, or the reconcile, from hearing about this URL.
25
+ for (const l of [...urlListeners]) {
26
+ try {
27
+ l(url);
28
+ } catch (e) {
29
+ reportError('url change', e);
30
+ }
31
+ }
32
+ schedule();
33
+ };
34
+
35
+ /**
36
+ * The Navigation API has shown it reports history writes (one of ours fired
37
+ * currententrychange): a DOM change then needs no look at the URL (a router
38
+ * that kept the history methods from before our wrappers is reported too).
39
+ * On a busy page (a carousel) that look was most of our time.
40
+ */
41
+ let urlReported = false;
42
+
43
+ const senseUrl = () => {
44
+ // The Navigation API sees pushState/replaceState from any router, even one
45
+ // that kept the original methods before we wrapped them, and
46
+ // navigation.navigate(). But WebKit's (Safari 26.2+) fires no
47
+ // currententrychange for pushState/replaceState: history is wrapped
48
+ // everywhere too (always delegating to the original), and checkUrl is a
49
+ // no-op for a URL it has seen.
50
+ const nav = (window as any).navigation;
51
+ let navEvents = 0;
52
+ if (nav && typeof nav.addEventListener === 'function') nav.addEventListener('currententrychange', () => (navEvents++, checkUrl()));
53
+ for (const m of ['pushState', 'replaceState'] as const) {
54
+ const orig = history[m];
55
+ if ((orig as any).__lbxWrapped) continue;
56
+ const wrapped = function (this: History, ...args: any[]) {
57
+ const before = navEvents;
58
+ const r = (orig as any).apply(this, args);
59
+ // Chromium reports it in the call: from then on the Navigation API is
60
+ // trusted with every URL change (WebKit's never reports one here).
61
+ if (navEvents > before) urlReported = true;
62
+ queueMicrotask(checkUrl);
63
+ return r;
64
+ };
65
+ (wrapped as any).__lbxWrapped = true;
66
+ history[m] = wrapped as any;
67
+ }
68
+ // Capture, and added before any other listener of ours (startSensing runs
69
+ // first): a popstate listener that stops the event (core/turbo.ts, on a
70
+ // back/forward between our states) must not keep the URL change from us.
71
+ // At the window, capture listeners run before the others; where there is
72
+ // no Navigation API (Firefox, Safari before 26.2) this is the only signal
73
+ // of a back/forward (Chromium's currententrychange came before popstate).
74
+ // Back from an entry that only stood for something of ours being open (the
75
+ // autocomplete's phone sheet, core/entries.ts): ours alone, first.
76
+ addEventListener('popstate', (e) => ownBack() && e.stopImmediatePropagation(), true);
77
+ addEventListener('popstate', checkUrl, true);
78
+ addEventListener('hashchange', checkUrl, true);
79
+ };
80
+
81
+ const senseDom = () => {
82
+ const ownRemoved = (r: MutationRecord) =>
83
+ [...r.removedNodes].some((n) => n.nodeType === 1 && ((n as Element).hasAttribute(OWN_ATTR) || !!(n as Element).querySelector(`[${OWN_ATTR}]`)));
84
+ // On the document, not its root element: a host that re-renders the whole
85
+ // document (React hydrating <html>) replaces documentElement.
86
+ new MutationObserver((records) => {
87
+ // The host removed one of our nodes (re-render, hydration): put it back
88
+ // before the next paint.
89
+ if (ownRemovalsWatched() && records.some(ownRemoved)) scheduleNow();
90
+ // Ignore mutations that happen entirely inside our own nodes.
91
+ for (const r of records) {
92
+ if (!isOwnNode(r.target)) {
93
+ schedule();
94
+ // URL changes without history events (rare) are also caught here,
95
+ // unless the Navigation API has shown it reports them all.
96
+ if (!urlReported) checkUrl();
97
+ return;
98
+ }
99
+ }
100
+ }).observe(document, { childList: true, subtree: true });
101
+ };
102
+
103
+ export const startSensing = () => {
104
+ senseUrl();
105
+ senseDom();
106
+ addEventListener('pageshow', () => schedule());
107
+ };
@@ -0,0 +1,59 @@
1
+ // Product pages prerendered (or prefetched) on intent, with the Speculation
2
+ // Rules API (Chrome, Edge 121+): one document rule for the links in our
3
+ // widgets, `moderate` eagerness (~200 ms of hover, pointerdown on touch). The
4
+ // browser keeps at most two such prerenders and drops the oldest, so a
5
+ // handful of hovers never becomes a handful of page renders at once.
6
+ // On by default: `_global.speculation` ('prerender', the default | 'prefetch' | false). In the shared
7
+ // support chunk (core/support.ts): our links exist only once a widget has
8
+ // loaded, so the entry installs the rules with the first widget. No DOM at
9
+ // import (the server renderer imports support too).
10
+ //
11
+ // Links that are not page views are left out: rails-ujs `data-method` /
12
+ // `data-remote` (a GET of a POST action), `rel=nofollow`, downloads, other
13
+ // windows. Our own listing links (filters, sort, pages) are not in the default
14
+ // selector: they are handled in place, not navigated.
15
+ //
16
+ // Our bundle in a prerendered page: it boots there as on any page (window.__lbx
17
+ // guards a second copy); see the takeover's hold timer, which waits for the
18
+ // activation (core/takeover.ts).
19
+
20
+ import { mark } from './health';
21
+ import type { GlobalConfigType } from '../types';
22
+
23
+ const LINKS = ':is(lbx-search, lbx-plp) [data-lbx-list] a[href], lbx-reco a[href], [data-lbx="ac-overlay"] a[href]';
24
+ const NOT_A_VIEW = '[data-method], [data-remote], [rel~="nofollow"], [download], [target]:not([target="_self"])';
25
+
26
+ let installed = false;
27
+ export const speculate = (g: GlobalConfigType, onUrlChange: (f: () => void) => unknown) => {
28
+ const mode = g.speculation ?? 'prerender';
29
+ if (installed || (mode !== 'prerender' && mode !== 'prefetch') || !(HTMLScriptElement as any).supports?.('speculationrules')) return;
30
+ installed = true;
31
+ const rules = {
32
+ [mode]: [
33
+ {
34
+ source: 'document',
35
+ where: {
36
+ and: [
37
+ { href_matches: ['/*', '/*\\?*'] }, // same origin (relative to the page), with or without a query
38
+ { selector_matches: g.speculationSelector || LINKS },
39
+ { not: { selector_matches: NOT_A_VIEW } },
40
+ ],
41
+ },
42
+ eagerness: g.speculationEagerness ?? 'moderate',
43
+ },
44
+ ],
45
+ };
46
+ let el: HTMLScriptElement | null = null;
47
+ const put = () => {
48
+ if (el?.isConnected) return;
49
+ el = document.createElement('script');
50
+ el.type = 'speculationrules';
51
+ el.setAttribute('data-lbx', 'speculation');
52
+ el.textContent = JSON.stringify(rules);
53
+ document.head.append(el);
54
+ };
55
+ put();
56
+ // A host that swaps <head> (an SPA visit) may take it: put back on the next URL.
57
+ onUrlChange(put);
58
+ mark(`speculation rules: ${mode}`);
59
+ };
@@ -0,0 +1,38 @@
1
+ // A listing result kept in sessionStorage for the next page load of its URL
2
+ // (widgets/listing/stash.ts reads and writes them). The entry only asks
3
+ // whether this load can use one: a search page then does not send its own
4
+ // request before the listing's chunk has read it.
5
+ import { pageKey } from './page';
6
+
7
+ export const STASH = 'lbx:r:';
8
+ export const stashKey = (href: string) => STASH + pageKey(href);
9
+
10
+ /** How this document was loaded, and its URL (a later SPA visit is not the load a stash is for). */
11
+ export const navigation = () => {
12
+ try {
13
+ const n = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
14
+ return { type: n?.type ?? 'navigate', url: n?.name || location.href };
15
+ } catch {
16
+ return { type: 'navigate', url: location.href };
17
+ }
18
+ };
19
+
20
+ /**
21
+ * Which load a stash kind is for: a typed query's page by a navigation (or a
22
+ * prerender of it, `prerender` from its start through its activation), a
23
+ * listing left for a product by back/forward.
24
+ */
25
+ export const stashFits = (kind: string) => {
26
+ const t = navigation().type;
27
+ return kind === 'back' ? t === 'back_forward' : t === 'navigate' || t === 'prerender';
28
+ };
29
+
30
+ /** A stash this load will likely use (the listing checks it fully). Reads only the start of the entry. */
31
+ export const hasStash = (href: string) => {
32
+ try {
33
+ const head = sessionStorage.getItem(stashKey(href))?.slice(0, 60);
34
+ return !!head && stashFits(head.includes('"kind":"back"') ? 'back' : 'spec');
35
+ } catch {
36
+ return false;
37
+ }
38
+ };
@@ -0,0 +1,45 @@
1
+ // Small caches. No DOM: the edge render service uses them too.
2
+
3
+ /** sessionStorage as a best-effort JSON store: storage may be unavailable (private mode, quota, Node), never required. */
4
+ export const session = {
5
+ get<T>(k: string): T | undefined {
6
+ try {
7
+ const v = sessionStorage.getItem(k);
8
+ return v == null ? undefined : (JSON.parse(v) as T);
9
+ } catch {
10
+ return undefined;
11
+ }
12
+ },
13
+ set(k: string, v: unknown) {
14
+ try {
15
+ if (v === undefined) sessionStorage.removeItem(k);
16
+ else sessionStorage.setItem(k, JSON.stringify(v));
17
+ } catch {}
18
+ },
19
+ };
20
+
21
+ /**
22
+ * A bounded memo (least recently used out) whose entries expire: in a
23
+ * long-running server every distinct URL would otherwise stay forever, and a
24
+ * "not found" would outlive the catalog change that fixes it.
25
+ */
26
+ export const lru = <V>(max: number, ttlMs = 10 * 60e3) => {
27
+ const m = new Map<string, { v: V; at: number }>();
28
+ return {
29
+ /** undefined = not cached (store null, not undefined, for a known "nothing"). */
30
+ get(k: string): V | undefined {
31
+ const e = m.get(k);
32
+ if (!e) return undefined;
33
+ m.delete(k);
34
+ if (Date.now() - e.at > ttlMs) return undefined;
35
+ m.set(k, e);
36
+ return e.v;
37
+ },
38
+ set(k: string, v: V) {
39
+ m.delete(k);
40
+ m.set(k, { v, at: Date.now() });
41
+ if (m.size > max) m.delete(m.keys().next().value!);
42
+ },
43
+ delete: (k: string) => void m.delete(k),
44
+ };
45
+ };
@@ -0,0 +1,106 @@
1
+ // Stylesheets we own, one <style data-lbx="styles"> per section, so changing one
2
+ // (a route's host CSS on every takeover step) does not re-parse the others:
3
+ // on a large host DOM a whole-sheet rewrite costs a document-wide style pass.
4
+ // Two kinds of rules:
5
+ // - component CSS in @layer lbx: any unlayered host CSS beats it, so the host
6
+ // design wins by default (typography, links, buttons, their tile classes).
7
+ // The layer is declared first in <head>, so it also ranks below the host's
8
+ // own layers (Tailwind's utilities): among layers, the first declared loses.
9
+ // Not on a page React may yet hydrate as a whole (it hydrates <head> too, and
10
+ // React 18 tolerates extra nodes only at the end): there at the end (not
11
+ // while <head> is still parsing), and first once React has it.
12
+ // - host overrides (hide their native autocomplete / results): unlayered and
13
+ // route-scoped. CSS survives host re-renders, DOM mutations would not.
14
+ // Every sheet is put back when a host re-render drops it (onFlush).
15
+
16
+ import { documentMayHydrate, hasFiber, watchOwnRemovals } from './own';
17
+ import { onFlush } from './scheduler';
18
+
19
+ // While <head> is still parsing, on every page: whole-document frameworks
20
+ // announce themselves only at the end of <body>, and the body-start observer
21
+ // puts ours in before anything of the body paints.
22
+ const deferred = () => !document.body;
23
+
24
+ const sheets = new Map<string, HTMLStyleElement>();
25
+
26
+ /** The page's CSP nonce (from any script carrying one): a strict style-src blocks our <style>s without it. */
27
+ export const cspNonce = () => (document.querySelector('script[nonce]') as HTMLScriptElement | null)?.nonce || '';
28
+ const styleEl = () => {
29
+ const s = document.createElement('style');
30
+ s.setAttribute('data-lbx', 'styles');
31
+ const n = cspNonce();
32
+ if (n) s.nonce = n;
33
+ return s;
34
+ };
35
+
36
+ let order: HTMLStyleElement | null = null;
37
+ const declareLayer = () => {
38
+ const head = document.head || document.documentElement;
39
+ // React has it once it has claimed <head>, or <html> when its root renders
40
+ // no <head> of its own (Next's app router: Next fills it): hydrated past it.
41
+ const early = (document.readyState === 'loading' || documentMayHydrate()) && !hasFiber(head) && !hasFiber(document.documentElement);
42
+ if (order?.isConnected && (early || head.firstElementChild === order)) return;
43
+ order ??= styleEl();
44
+ order.textContent = '@layer lbx;';
45
+ // <head> still parsing on a page React may hydrate as a whole: appended once it is complete (onFlush).
46
+ if (deferred()) return;
47
+ if (early) head.append(order);
48
+ else head.prepend(order);
49
+ };
50
+
51
+ /** Our sheets into the document: the missing ones (dropped by a host re-render, or deferred). */
52
+ const restore = () => {
53
+ if (deferred()) return;
54
+ if (sheets.size) {
55
+ declareLayer();
56
+ for (const s of sheets.values()) if (!s.isConnected) (document.head || document.documentElement).append(s);
57
+ }
58
+ for (const f of applied.splice(0)) f();
59
+ };
60
+ onFlush(restore);
61
+
62
+ // Deferred sheets go in the moment <body> starts (then <head> is complete), in
63
+ // a microtask: before anything of the body can paint.
64
+ let bodyWatch: MutationObserver | null = null;
65
+ const watchForBody = () => {
66
+ if (bodyWatch || document.body) return;
67
+ bodyWatch = new MutationObserver(() => {
68
+ if (!document.body) return;
69
+ bodyWatch!.disconnect();
70
+ restore();
71
+ });
72
+ bodyWatch.observe(document.documentElement, { childList: true });
73
+ };
74
+
75
+ const applied: (() => void)[] = [];
76
+ /**
77
+ * Run `f` once our sheets are in the document, i.e. once <head> is complete
78
+ * (at once, unless it is still being parsed): e.g. only then drop an early
79
+ * hold's CSS, or add links of ours to <head>.
80
+ */
81
+ export const whenApplied = (f: () => void) => (deferred() ? (applied.push(f), watchForBody()) : f());
82
+
83
+ export const setCss = (key: string, css: string | null) => {
84
+ if (css != null) declareLayer(), watchOwnRemovals(); // first, and back if a head manager removed it
85
+ const el = sheets.get(key);
86
+ if (css == null) {
87
+ el?.remove();
88
+ sheets.delete(key);
89
+ return;
90
+ }
91
+ const s = el ?? styleEl();
92
+ if (s.textContent !== css) s.textContent = css;
93
+ // Back into the document if a host re-render dropped it (onFlush does the same).
94
+ // At once (a hold must apply before first paint), except mid-<head> on a page
95
+ // React may hydrate as a whole (React 18 would re-render the document).
96
+ if (!s.isConnected) {
97
+ if (deferred()) watchForBody();
98
+ else (document.head || document.documentElement).append(s);
99
+ }
100
+ if (!el) {
101
+ s.dataset.key = key;
102
+ sheets.set(key, s);
103
+ }
104
+ };
105
+
106
+ export const layered = (css: string) => `@layer lbx {\n${css}\n}`;
@@ -0,0 +1,12 @@
1
+ // What every widget needs and the entry does not: the API client,
2
+ // translations and result helpers. One shared chunk, loaded with the first widget.
3
+ export { getApi } from '../api';
4
+ export { widgetContext } from './context';
5
+ export { loadWidgetInits } from './widget-inits';
6
+ export { loadDictionary, translator } from './i18n';
7
+ export { first, plain, resultFactory } from './result';
8
+ export { speculate } from './speculation';
9
+ // Images (./images.ts, ./image-tags.ts, ./image-load.ts): every widget's templates and pages.
10
+ export { imageHelpers } from './image-tags';
11
+ export { firstRowCount, firstRowImages, imageOf } from './images';
12
+ export { preloadImages, settleImages, warmImages } from './image-load';