@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,30 @@
1
+ // Where a listing's later parts sit, learnt from a view of the route at about
2
+ // this width (localStorage, per 100 px), and the channel the quick types land
3
+ // by. Here (no DOM but localStorage) for the listing and for a search page's
4
+ // first request, which the entry starts before the listing exists (search/first.ts).
5
+ import type { ListingResult } from './query';
6
+
7
+ /**
8
+ * The parts that may come after the products: the facets, and on a search
9
+ * page the quick types (`[data-lbx-part="quick"]`, often a row of category
10
+ * and brand tiles above the grid). Not waited for only where they do not sit
11
+ * above the products: arriving after, they would push them down (a layout
12
+ * shift, found on a phone layout, 2026-10-11). Not known yet: waited for.
13
+ */
14
+ export type Part = 'facets' | 'quick';
15
+
16
+ export const flowKey = (route: string, part: Part = 'facets') => `lbx:${part === 'facets' ? 'pf' : 'pq'}:${route}:${Math.round(innerWidth / 100)}`;
17
+
18
+ export const notAbove = (route: string, part: Part = 'facets') => {
19
+ try {
20
+ return localStorage.getItem(flowKey(route, part)) === '1';
21
+ } catch {
22
+ return false;
23
+ }
24
+ };
25
+
26
+ /** Per route: the listing element's, called when the quick types land in a result shown without them. */
27
+ const landed: Record<string, (r: ListingResult) => void> = {};
28
+ export const onQuickLanded = (route: string, f: (r: ListingResult) => void) => void (landed[route] = f);
29
+ /** listingQuery's `onQuick` for a route, where its quick types are not waited for. */
30
+ export const quickChannel = (route: string) => (notAbove(route, 'quick') ? (r: ListingResult) => landed[route]?.(r) : undefined);
@@ -0,0 +1,133 @@
1
+ // The listing's "already there" helpers, outside its render path:
2
+ // - where the shopper was when they followed a link (back lands there again);
3
+ // - the next page, prefetched as the shopper nears the end of the loaded tiles;
4
+ // - the product image host, preconnected on later page loads.
5
+ // Nothing here is required: every failure leaves the listing as it would be.
6
+
7
+ import { reportError } from '../../core/health';
8
+
9
+ const sameDoc = (a: HTMLAnchorElement) => a.origin === location.origin;
10
+
11
+ /**
12
+ * The link a click follows away from this page, as the browser will: a plain
13
+ * primary click on a same-origin link to another page, in this tab, that no
14
+ * handler (ours: filters, sort, pages) has taken. null for anything else.
15
+ */
16
+ export const leavingLink = (e: MouseEvent, root: Element): HTMLAnchorElement | null => {
17
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return null;
18
+ const a = (e.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null;
19
+ if (!a || !root.contains(a) || !sameDoc(a) || a.hasAttribute('download') || (a.target && a.target !== '_self')) return null;
20
+ // Host handlers that turn the link into a request (rails-ujs) do not leave.
21
+ if (a.matches('[data-remote], [data-method]')) return null;
22
+ const here = new URL(location.href);
23
+ if (a.pathname === here.pathname && a.search === here.search) return null; // a #fragment on this page
24
+ return a;
25
+ };
26
+
27
+ /** The first link to `href` in root (a tile has several: its image, its title). */
28
+ const linkTo = (root: Element, href: string) => {
29
+ for (const a of root.querySelectorAll<HTMLAnchorElement>('a[href]')) if (a.href === href) return a;
30
+ return null;
31
+ };
32
+
33
+ /** Where the shopper is, pinned to the link they follow (layout above it may differ on the way back). */
34
+ export const scrollSpot = (root: Element, a: HTMLAnchorElement) => ({ y: Math.round(scrollY), href: a.href, top: Math.round((linkTo(root, a.href) ?? a).getBoundingClientRect().top) });
35
+
36
+ const scroll = (top: number) => scrollTo({ top, left: scrollX, behavior: 'instant' as ScrollBehavior });
37
+
38
+ /**
39
+ * Back where the shopper was: the followed link at the same place in the
40
+ * viewport (scrollY when it is gone). Late layout above us (their images,
41
+ * banners, fonts) moves it: followed for a while, until the shopper scrolls,
42
+ * taps or types themselves.
43
+ */
44
+ export const restoreScroll = (root: Element, s: { y: number; href?: string; top?: number }) => {
45
+ let done = false;
46
+ const place = () => {
47
+ if (done) return;
48
+ const a = s.href ? linkTo(root, s.href) : null;
49
+ if (a) {
50
+ const d = a.getBoundingClientRect().top - (s.top ?? 0);
51
+ if (Math.abs(d) >= 1) scroll(scrollY + d);
52
+ } else if (Math.abs(scrollY - s.y) >= 1) scroll(s.y);
53
+ };
54
+ const STOP = ['wheel', 'touchstart', 'keydown', 'pointerdown'];
55
+ const ro = new ResizeObserver(() => place());
56
+ const stop = () => {
57
+ done = true;
58
+ ro.disconnect();
59
+ for (const t of STOP) removeEventListener(t, stop, true);
60
+ };
61
+ for (const t of STOP) addEventListener(t, stop, { capture: true, passive: true });
62
+ place();
63
+ ro.observe(document.documentElement);
64
+ setTimeout(stop, 2500);
65
+ };
66
+
67
+ /**
68
+ * Keep what the shopper is looking at in place across a re-render (a
69
+ * revalidated result): the first tile link in the viewport stays where it is.
70
+ */
71
+ export const keepViewport = (root: Element, f: () => void) => {
72
+ let pin: { href: string; top: number } | null = null;
73
+ for (const a of root.querySelectorAll<HTMLAnchorElement>('[data-lbx-list] a[href]')) {
74
+ const top = a.getBoundingClientRect().top;
75
+ if (top >= 0) {
76
+ pin = { href: a.href, top };
77
+ break;
78
+ }
79
+ }
80
+ f();
81
+ if (!pin) return;
82
+ const a = linkTo(root, pin.href);
83
+ if (a) {
84
+ const d = a.getBoundingClientRect().top - pin.top;
85
+ if (Math.abs(d) >= 1) scroll(scrollY + d);
86
+ }
87
+ };
88
+
89
+ /**
90
+ * Calls `near` once, when the tile at ~70% of the loaded ones comes within
91
+ * reach of the viewport. Returns the cancel.
92
+ */
93
+ export const nearEnd = (root: Element, near: () => void) => {
94
+ const tiles = root.querySelector('[data-lbx-list]')?.children;
95
+ if (!tiles?.length) return () => {};
96
+ const at = tiles[Math.min(tiles.length - 1, Math.floor(tiles.length * 0.7))];
97
+ const io = new IntersectionObserver(
98
+ (es) => {
99
+ if (!es.some((e) => e.isIntersecting)) return;
100
+ io.disconnect();
101
+ try {
102
+ near();
103
+ } catch (e) {
104
+ reportError('next page prefetch', e);
105
+ }
106
+ },
107
+ { rootMargin: '0px 0px 50% 0px' },
108
+ );
109
+ io.observe(at);
110
+ // And the last: a jump to the end (an anchor, a fling on a phone: the tile
111
+ // above was out of view by the next frame) never intersects the first.
112
+ io.observe(tiles[tiles.length - 1]);
113
+ return () => io.disconnect();
114
+ };
115
+
116
+ /** localStorage: the product image host, preconnected at boot (src/main.ts). */
117
+ export const IMG_HOST = 'lbx:img';
118
+ const hinted = new Set<string>();
119
+ export const preconnectImages = (src: unknown) => {
120
+ try {
121
+ if (typeof src !== 'string' || !src) return;
122
+ const o = new URL(src, location.href).origin;
123
+ if (o === location.origin || !/^https?:/.test(o) || hinted.has(o)) return;
124
+ hinted.add(o);
125
+ if (localStorage.getItem(IMG_HOST) !== o) localStorage.setItem(IMG_HOST, o);
126
+ if (document.querySelector(`link[rel="preconnect"][href="${o}"]`)) return;
127
+ const l = document.createElement('link');
128
+ l.rel = 'preconnect';
129
+ l.href = o;
130
+ l.setAttribute('data-lbx', 'preconnect');
131
+ document.head.append(l);
132
+ } catch {}
133
+ };
@@ -0,0 +1,193 @@
1
+ // Filter clicks that render at once (`_global.prefetchFilters`, default on).
2
+ // When the shopper shows intent on one of our controls that leads to another
3
+ // state of this listing (a facet value, a flag, a sort, a chip, "clear all",
4
+ // the next page), the request that control's click would make starts then:
5
+ // - pointer: it rests on the control for DWELL_MS (a cursor sweeping over a
6
+ // list of values crosses each in less);
7
+ // - touch (no hover): pointerdown, about 100 ms before the click;
8
+ // - keyboard: focus on the control, for DWELL_MS (Tab passing through a list
9
+ // does not stop on each).
10
+ // Which state a control leads to is its URL: a link's href, or
11
+ // `data-lbx-href` on any other element (a checkbox's label, a button), as the
12
+ // templates write them from the scope's links. Range sliders have no such URL
13
+ // (their value is known on release, which is the click itself): never prefetched.
14
+ //
15
+ // What is fetched: the first page of products for that state, exactly the
16
+ // request listingQuery() starts with (`firstRequest`), keyed by that request
17
+ // (the exact query state). The click takes it, done or in flight (never a
18
+ // second request), renders the products with the sidebar it had, and then asks
19
+ // the facets of the new result set (they need its guid): the request a
20
+ // shopper hovers without clicking costs one search, not a search and its facets.
21
+ // Results are never rendered by the prefetch itself: only a click whose
22
+ // request is the same takes them.
23
+ //
24
+ // Limits: two in flight (a newer intent aborts the oldest), the state
25
+ // changing aborts those still out (their controls now lead elsewhere), kept
26
+ // TTL_MS, PER_VISIT per category or query, none with Save-Data, offline or on
27
+ // 2G. The listing adds its own (element.ts): none while a click or a request
28
+ // of the current state is pending, none for a state it has cached.
29
+ //
30
+ // Analytics: Luigi's Box counts searches and impressions from the tracker's
31
+ // events about a rendered result (its guid); a prefetch renders nothing and
32
+ // runs no hook (`onRender`), so it reports nothing.
33
+
34
+ import type { Page } from '../../api';
35
+ import { reportError } from '../../core/health';
36
+
37
+ /** Hover or focus this long counts as intent (Chrome's speculation rules wait ~200 ms on hover for a whole page; this is one request). */
38
+ export const DWELL_MS = 100;
39
+ const TTL_MS = 30_000;
40
+ /** First pages kept (done or in flight). */
41
+ const KEEP = 6;
42
+ const IN_FLIGHT = 2;
43
+ /** Prefetches per visit (a category, a query): a shopper browsing the sidebar for long stops costing requests. */
44
+ export const PER_VISIT = 20;
45
+
46
+ /** No speculative requests: offline, Save-Data, a 2G connection. */
47
+ export const thrifty = () => {
48
+ const c = (navigator as any).connection;
49
+ return navigator.onLine === false || !!c?.saveData || /(^|-)2g$/.test(c?.effectiveType ?? '');
50
+ };
51
+
52
+ const CONTROL = 'a[href], [data-lbx-href]';
53
+ /** The URL a control leads to ('' or null: none). */
54
+ export const targetOf = (el: Element) => el.getAttribute('data-lbx-href') ?? el.getAttribute('href');
55
+
56
+ /**
57
+ * Delegated listeners on `root` (ours): `intent(control)` after DWELL_MS of
58
+ * hover or focus, at once on pointerdown. A pointerover where the pointer was
59
+ * already (the page scrolled or re-rendered under a still pointer) is not
60
+ * intent. `cancel()` drops a pending dwell (a click: the control's state is
61
+ * being fetched).
62
+ */
63
+ export const watchIntent = (root: HTMLElement, intent: (el: Element) => void) => {
64
+ let timer = 0;
65
+ let armed: Element | null = null;
66
+ let at = '';
67
+ const controlOf = (t: EventTarget | null) => {
68
+ const el = (t as Element | null)?.closest?.(CONTROL);
69
+ return el && root.contains(el) ? el : null;
70
+ };
71
+ const fire = (el: Element) => {
72
+ try {
73
+ intent(el);
74
+ } catch (e) {
75
+ reportError('filter prefetch', e);
76
+ }
77
+ };
78
+ const cancel = () => {
79
+ clearTimeout(timer);
80
+ armed = null;
81
+ };
82
+ /** The control whose dwell fired, while the pointer or focus stays on it (its children's events are not new intent). */
83
+ let fired: Element | null = null;
84
+ const arm = (el: Element | null) => {
85
+ if (el && (el === armed || el === fired)) return;
86
+ cancel();
87
+ fired = null;
88
+ if (!el) return;
89
+ armed = el;
90
+ timer = window.setTimeout(() => {
91
+ armed = null;
92
+ fired = el;
93
+ fire(el);
94
+ }, DWELL_MS);
95
+ };
96
+ const over = (e: PointerEvent) => {
97
+ const spot = `${e.clientX},${e.clientY}`;
98
+ const still = spot === at;
99
+ at = spot;
100
+ if (e.pointerType === 'touch') return; // pointerdown
101
+ arm(still ? null : controlOf(e.target));
102
+ };
103
+ const out = (e: PointerEvent) => {
104
+ if (e.relatedTarget && root.contains(e.relatedTarget as Node)) return;
105
+ arm(null);
106
+ // Left us: coming back to the same spot is a move.
107
+ at = '';
108
+ };
109
+ const down = (e: PointerEvent) => {
110
+ at = `${e.clientX},${e.clientY}`;
111
+ const el = e.button === 0 ? controlOf(e.target) : null;
112
+ cancel();
113
+ fired = el;
114
+ if (el) fire(el);
115
+ };
116
+ const focus = (e: FocusEvent) => arm(controlOf(e.target));
117
+ const blur = (e: FocusEvent) => {
118
+ const el = controlOf(e.target);
119
+ if (el && (el === armed || el === fired)) arm(null);
120
+ };
121
+ const on: [string, (e: any) => void][] = [
122
+ ['pointerover', over],
123
+ ['pointerout', out],
124
+ ['pointerdown', down],
125
+ ['focusin', focus],
126
+ ['focusout', blur],
127
+ ];
128
+ for (const [t, f] of on) root.addEventListener(t, f, { passive: true });
129
+ return {
130
+ cancel,
131
+ stop: () => {
132
+ cancel();
133
+ for (const [t, f] of on) root.removeEventListener(t, f);
134
+ },
135
+ };
136
+ };
137
+
138
+ type Entry = { p: Promise<Page>; ctrl: AbortController; settled: boolean; at: number };
139
+
140
+ /** First pages prefetched on intent, by their request (JSON): in flight or done, for TTL_MS, consumed once. */
141
+ export const firstPages = () => {
142
+ const m = new Map<string, Entry>();
143
+ let visit = '';
144
+ let count = 0;
145
+ const fresh = (e: Entry) => performance.now() - e.at < TTL_MS;
146
+ const live = (k: string) => {
147
+ const e = m.get(k);
148
+ if (e && !fresh(e)) m.delete(k);
149
+ return e && fresh(e) ? e : undefined;
150
+ };
151
+ return {
152
+ has: (k: string) => !!live(k),
153
+ /** The first page for this request, done or in flight (consumed). */
154
+ take(k: string) {
155
+ const e = live(k);
156
+ m.delete(k);
157
+ return e?.p;
158
+ },
159
+ /** Starts `run` for this request unless it is there already or the visit's cap is reached. True: started. */
160
+ start(k: string, visitKey: string, run: (signal: AbortSignal) => Promise<Page>) {
161
+ if (visitKey !== visit) (visit = visitKey), (count = 0);
162
+ if (live(k) || count >= PER_VISIT) return false;
163
+ count++;
164
+ // The latest intent wins: the oldest still out goes.
165
+ const out = [...m].filter(([, e]) => !e.settled);
166
+ if (out.length >= IN_FLIGHT) {
167
+ const [ok, oe] = out[0];
168
+ oe.ctrl.abort();
169
+ m.delete(ok);
170
+ }
171
+ const ctrl = new AbortController();
172
+ const e: Entry = { ctrl, settled: false, at: performance.now(), p: run(ctrl.signal) };
173
+ e.p.then(
174
+ () => void (e.settled = true),
175
+ () => {
176
+ e.settled = true;
177
+ if (m.get(k) === e) m.delete(k); // failed or aborted: the click asks itself
178
+ },
179
+ );
180
+ m.set(k, e);
181
+ while (m.size > KEEP) m.delete(m.keys().next().value!);
182
+ return true;
183
+ },
184
+ /** The state changed: requests still out were for controls that now lead elsewhere. */
185
+ abortInFlight() {
186
+ for (const [k, e] of m)
187
+ if (!e.settled) {
188
+ e.ctrl.abort();
189
+ m.delete(k);
190
+ }
191
+ },
192
+ };
193
+ };
@@ -0,0 +1,298 @@
1
+ // A listing's API requests, from (route, query) and nothing else: the entry
2
+ // (a search page's request starts before any widget chunk), the listing
3
+ // element and the edge render service share it.
4
+
5
+ import { isPromise } from '../../core/scheduler';
6
+ import type { Api, FacetData, FilterNode, Hit, ListingRequest, Page, Sort } from '../../api';
7
+ import type { GlobalConfigType, ListingConfigType, SearchConfigType } from '../../types';
8
+ import { facetFields, type Query, type Route } from './state';
9
+ import { lru } from '../../core/storage';
10
+ import { reportError } from '../../core/health';
11
+ import { MAX_PAGE } from '../../core/routes';
12
+
13
+ const num = (s: string | undefined) => (s === undefined || s === '' ? null : Number(s));
14
+ /** A range filter value `min|max` (either end may be empty). */
15
+ export const parseRange = (v: string | undefined) => {
16
+ const [a, b] = (v ?? '').split('|');
17
+ return { from: num(a), to: num(b) };
18
+ };
19
+
20
+ export type ListingConfig = ListingConfigType & { quick?: SearchConfigType['quick'] };
21
+
22
+ /** What a listing renders: the pages loaded of one result set (the first, and each "show more"), its facets, and the quick types. */
23
+ export interface ListingResult {
24
+ /** Every hit loaded, in order: the first page, then each page "show more" appended. */
25
+ hits: Hit[];
26
+ total: number;
27
+ guid: string;
28
+ /** Pages loaded: 1, plus one per "show more". */
29
+ page: number;
30
+ /** Something optional failed (the facets): shown, not cached. */
31
+ partial?: boolean;
32
+ /** The products alone, their facets still being asked for (`onProducts`): shown, busy, not cached. */
33
+ pending?: boolean;
34
+ cursor: string | null;
35
+ status: string;
36
+ facets: Record<string, FacetData>;
37
+ /** `emptyValues`: the facets of the page without the shopper's filters (values the filtered set lacks show as empty). */
38
+ facetsAll?: Record<string, FacetData>;
39
+ quick: Record<string, Hit[]>;
40
+ /** The quick types are on their way (not waited for): they land in `quick` later. */
41
+ quickPending?: boolean;
42
+ /** Ranges over fields holding numbers as text, as the values the first page was asked with: the next pages ask the same tree. */
43
+ asValues?: Record<string, string[]>;
44
+ }
45
+
46
+ /** 'lbx:price:asc' -> { field: 'lbx:price', direction: 'asc' } (the field may contain ':'). */
47
+ const toSort = (s: string): Sort | undefined => {
48
+ const i = s.lastIndexOf(':');
49
+ if (!s || i < 1) return undefined;
50
+ const direction = s.slice(i + 1);
51
+ return direction === 'asc' || direction === 'desc' ? { field: s.slice(0, i), direction } : undefined;
52
+ };
53
+
54
+ /**
55
+ * The shopper's narrowing as a filter tree: one condition per faceted field
56
+ * (`in` over the selected values; a `min|max` value is a range), plus the
57
+ * active flags and the listing's base filters. Fields in a fixed order, values in selection order: a cursor
58
+ * is bound to the tree exactly as written. `omit` leaves one field out (its
59
+ * own facet values must not be narrowed by itself).
60
+ */
61
+ const filterTree = (route: Route, q: Query, omit?: string, base?: FilterNode, asValues: Record<string, string[]> = {}): FilterNode | undefined => {
62
+ const clauses: FilterNode[] = [];
63
+ for (const field of Object.keys(q.filters).sort()) {
64
+ const vs = q.filters[field];
65
+ if (field === omit || !vs?.length) continue;
66
+ // A range over a field that holds its numbers as text: the values within it.
67
+ if (asValues[field]) clauses.push({ [field]: { in: asValues[field].length ? asValues[field] : [NONE] } });
68
+ else if (isRange(vs)) {
69
+ const { from, to } = parseRange(vs[0]);
70
+ const node: Record<string, number> = {};
71
+ if (from != null && Number.isFinite(from)) node.gte = from;
72
+ if (to != null && Number.isFinite(to)) node.lte = to;
73
+ if (Object.keys(node).length) clauses.push({ [field]: node });
74
+ } else clauses.push({ [field]: { in: [...vs] } });
75
+ }
76
+ for (const f of route.flagDefs) if (q.flags.includes(f.id)) clauses.push(f.filter);
77
+ if (base && Object.keys(base).length) clauses.push(base);
78
+ return clauses.length === 0 ? undefined : clauses.length === 1 ? clauses[0] : { $and: clauses };
79
+ };
80
+
81
+ const isRange = (vs: string[] | undefined) => vs?.length === 1 && vs[0].includes('|');
82
+ /** A value no field holds: an `in` that must match nothing. */
83
+ const NONE = '\u0000';
84
+ /** Faceted fields by kind, as the API reported them ('range' or 'terms'). */
85
+ const kinds = lru<string>(500);
86
+ /** The facets of a scope or query without the shopper's filters (`emptyValues`), per its fields. */
87
+ const unfiltered = lru<Promise<Record<string, FacetData>>>(50);
88
+ /** Numeric text values of a field in a scope or query (a range over them is those values). */
89
+ const textValues = lru<Promise<Record<string, FacetData>>>(50);
90
+ /** Within a `min|max` range, numerically: "50.0", "201 - 500" (its first number). */
91
+ export const inRange = (v: string, sel: string) => {
92
+ const n = parseFloat(v);
93
+ const { from, to } = parseRange(sel);
94
+ return Number.isFinite(n) && (from == null || n >= from) && (to == null || n <= to);
95
+ };
96
+
97
+ /** The next page after the pages loaded, without hits already shown (Luma de-duplicates results across pages). */
98
+ const append = (shown: Hit[], page: Hit[]) => {
99
+ const ids = new Set(shown.map((h) => h['@id']));
100
+ return [...shown, ...page.filter((h) => !ids.has(h['@id']))];
101
+ };
102
+
103
+ // The quick types depend on the query only, not on filters, sort or page.
104
+ const quickMemo = lru<Promise<Record<string, Hit[]>>>(20);
105
+ const remember = (k: string, p: Promise<Record<string, Hit[]>>) => (quickMemo.set(k, p), p);
106
+ /**
107
+ * Quick tiles are extras: a slow one never holds back the listing. In a
108
+ * browser they get 1.5 s; on a server (no document) less than its render budget.
109
+ */
110
+ const server = typeof document === 'undefined';
111
+ const QUICK_GRACE_MS = server ? 300 : 1500;
112
+ const LATE = Symbol('late');
113
+ const soon = <T>(p: Promise<T>) => {
114
+ let timer: ReturnType<typeof setTimeout>;
115
+ const late = new Promise<typeof LATE>((r) => (timer = setTimeout(() => r(LATE), QUICK_GRACE_MS)));
116
+ return Promise.race([p, late]).finally(() => clearTimeout(timer));
117
+ };
118
+
119
+ /** Range selections over fields not known to be numeric: their values are looked up before the search (`asValues`). */
120
+ const unknownRanges = (cfg: ListingConfig, q: Query) => Object.keys(q.filters).filter((f) => isRange(q.filters[f]) && !cfg.facets.includes(f) && kinds.get(f) !== 'range');
121
+
122
+ /** The products request of q (`omit`: without that field's selection, for its own facet values). */
123
+ const listingRequest = (g: GlobalConfigType, cfg: ListingConfig, route: Route, q: Query, omit?: string, size = cfg.size, asValues: Record<string, string[]> = {}): ListingRequest => ({
124
+ endpoint: route.endpoint,
125
+ surface: cfg.surface,
126
+ type: g.itemType ?? 'product',
127
+ query: q.q,
128
+ scope: q.scope,
129
+ filters: filterTree(route, q, omit, cfg.baseFilters, asValues),
130
+ sort: toSort(q.sort),
131
+ size,
132
+ fields: cfg.fields,
133
+ });
134
+
135
+ /**
136
+ * The first request listingQuery() makes for q (page 1 of its products),
137
+ * exactly: a prefetch of it (./intent.ts) comes back as `pre`. null when that
138
+ * request depends on a lookup first (a range over a field holding its numbers
139
+ * as text).
140
+ */
141
+ export const firstRequest = (g: GlobalConfigType, cfg: ListingConfig, route: Route, q: Query): ListingRequest | null => (unknownRanges(cfg, q).length ? null : listingRequest(g, cfg, route, q));
142
+
143
+ /**
144
+ * The listing's data for a query: its first page, the facets of that result
145
+ * set, and on a search page the quick types. A selected facet's values come
146
+ * from the result set without its own selection, so it stays multi-select.
147
+ * `pre`: the first page of products, fetched already (firstRequest(),
148
+ * prefetched on intent): not asked again. Later pages are appended to a
149
+ * result by listingMore(), one cursor request each: no request ever follows a
150
+ * chain of cursors.
151
+ *
152
+ * `onProducts`: called with the
153
+ * products as soon as they are here (`pending`), before the facets, which
154
+ * need their result set's guid and so a request after them. `onQuick`: the
155
+ * quick types never wait for (where they do not sit above the products):
156
+ * the result comes without them if they are not here yet (`quickPending`),
157
+ * and they land in it later, `onQuick` called with it then.
158
+ */
159
+ export const listingQuery = async (api: Api, g: GlobalConfigType, cfg: ListingConfig, route: Route, q: Query, signal?: AbortSignal, pre?: Page, onProducts?: (r: ListingResult) => void, onQuick?: (r: ListingResult) => void): Promise<ListingResult> => {
160
+ // A range selection over a field that may not be a numeric one (a page's
161
+ // own facet, e.g. a parameter holding its numbers as text): if the API facets
162
+ // it as terms, the range is the values within it, from the values of the
163
+ // whole scope or query (a superset is harmless in an `in`). Asked once per
164
+ // scope; the configured facets' ranges are taken as numeric.
165
+ const asValues: Record<string, string[]> = {};
166
+ const unknown = unknownRanges(cfg, q);
167
+ if (unknown.length) {
168
+ const k = JSON.stringify([cfg.surface, q.q, q.scope, unknown]);
169
+ const fx =
170
+ textValues.get(k) ??
171
+ (() => {
172
+ const p = api
173
+ .search({ endpoint: route.endpoint, surface: cfg.surface, type: g.itemType ?? 'product', query: q.q, scope: q.scope, filters: cfg.baseFilters, size: 1 }, signal)
174
+ .then((r) => api.facets(r.guid, unknown.map((field) => ({ field, size: 1000 })), signal));
175
+ textValues.set(k, p);
176
+ p.catch(() => textValues.delete(k));
177
+ return p;
178
+ })();
179
+ const data = await fx;
180
+ for (const f of unknown) {
181
+ if (data[f]?.type) kinds.set(f, data[f].type);
182
+ if (data[f]?.type === 'terms') asValues[f] = (data[f].values ?? []).map(String).filter((v) => inRange(v, q.filters[f][0]));
183
+ }
184
+ }
185
+ const req = (omit?: string, size = cfg.size): ListingRequest => listingRequest(g, cfg, route, q, omit, size, asValues);
186
+ const quickKey = JSON.stringify([cfg.surface, q.q]);
187
+ const wantsQuick = route.endpoint === 'search' && !!q.q && !!cfg.quick?.length;
188
+ const quick =
189
+ wantsQuick && cfg.quick
190
+ ? quickMemo.get(quickKey) ?? remember(quickKey, (() => {
191
+ let failed = false;
192
+ let aborted = false;
193
+ const p: Promise<Record<string, Hit[]>> = Promise.all(
194
+ cfg.quick.map((x) =>
195
+ api
196
+ // Shared across renders of the query: not aborted with one (a server
197
+ // render's budget does bound them: nothing runs past its answer).
198
+ .search({ endpoint: 'search', surface: cfg.surface, type: x.type, query: q.q, size: x.size, fields: x.fields }, server ? signal : undefined)
199
+ .then((p) => [x.type, p.hits] as const)
200
+ .catch((e) => {
201
+ if ((e as Error)?.name === 'AbortError') aborted = true; // a render's budget: not the lookup failing
202
+ else failed = true;
203
+ return [x.type, [] as Hit[]] as const; // quick tiles are optional
204
+ }),
205
+ ),
206
+ ).then((pairs) => {
207
+ const mine = () => quickMemo.get(quickKey) === p;
208
+ // Cut short: the next render asks again. Failed: again in a few seconds (not on every render in an outage).
209
+ if (aborted) mine() && quickMemo.delete(quickKey);
210
+ else if (failed) (setTimeout(() => mine() && quickMemo.delete(quickKey), 5000) as any).unref?.();
211
+ return Object.fromEntries(pairs);
212
+ });
213
+ return p;
214
+ })())
215
+ : Promise.resolve({});
216
+ // The quick types as they are when the products are shown early: not later (they sit above the grid).
217
+ let quickNow: Record<string, Hit[]> | undefined = wantsQuick ? undefined : {};
218
+ quick.then((v) => (quickNow = v), () => {});
219
+ const first = pre ?? (await api.search(req(), signal));
220
+ const facetsOf = async (): Promise<Record<string, FacetData>> => {
221
+ const size = cfg.facetSize ?? 50;
222
+ const fields = facetFields(cfg, q);
223
+ const own = fields.filter((f) => !q.filters[f]?.length);
224
+ const selected = fields.filter((f) => q.filters[f]?.length);
225
+ const parts = await Promise.all([
226
+ own.length ? api.facets(first.guid, own.map((field) => ({ field, size })), signal) : {},
227
+ ...selected.map(async (field) => {
228
+ const without = await api.search(req(field, 1), signal);
229
+ return api.facets(without.guid, [{ field, size }], signal);
230
+ }),
231
+ ]);
232
+ const out: Record<string, FacetData> = Object.assign({}, ...parts);
233
+ for (const [f, d] of Object.entries(out)) if (d?.type) kinds.set(f, d.type);
234
+ return out;
235
+ };
236
+ // `emptyValues`: the page's values without the shopper's filters, from its
237
+ // unfiltered result (remembered), else asked once per scope or query.
238
+ const allKey = JSON.stringify([cfg.surface, q.q, q.scope, facetFields(cfg, q)]);
239
+ const narrowed = Object.keys(q.filters).length > 0 || q.flags.length > 0;
240
+ const allOf = (): Promise<Record<string, FacetData>> => {
241
+ const memo = unfiltered.get(allKey);
242
+ if (memo) return memo;
243
+ const size = cfg.facetSize ?? 50;
244
+ const p = api
245
+ .search({ endpoint: route.endpoint, surface: cfg.surface, type: g.itemType ?? 'product', query: q.q, scope: q.scope, filters: cfg.baseFilters, size: 1 }, signal)
246
+ .then((r) => api.facets(r.guid, facetFields(cfg, q).map((field) => ({ field, size })), signal));
247
+ unfiltered.set(allKey, p);
248
+ p.catch(() => unfiltered.delete(allKey));
249
+ return p;
250
+ };
251
+ // Facets are extras too: without them the results still show.
252
+ let partial = false;
253
+ const facets =
254
+ first.hits.length || Object.keys(q.filters).length
255
+ ? facetsOf().catch((e) => ((partial = true), (e as Error)?.name === 'AbortError' || reportError('facets', e), {}))
256
+ : {};
257
+ // Optional too: without them no value shows as empty.
258
+ const all = cfg.emptyValues && narrowed ? allOf().catch(() => undefined) : undefined;
259
+ // The products now, the facets when they come: a request (theirs needs
260
+ // this result set's guid) the shopper would otherwise wait for.
261
+ const early: ListingResult | null =
262
+ onProducts && isPromise(facets)
263
+ ? { hits: first.hits, total: first.total, guid: first.guid, page: 1, cursor: first.cursor, status: first.status, facets: {}, quick: quickNow ?? {}, pending: true, ...(quickNow ? {} : { quickPending: true }) }
264
+ : null;
265
+ if (early) onProducts!(early);
266
+ // Not waited for (onQuick): the facets alone hold the result.
267
+ const [facetData, quickHits, facetsAll] = await Promise.all([facets, onQuick ? quickNow ?? LATE : soon(quick), all]);
268
+ if (cfg.emptyValues && !narrowed && !partial && Object.keys(facetData).length) unfiltered.set(allKey, Promise.resolve(facetData));
269
+ // Shown early without them and not where they may come late: they come
270
+ // with a later render, not this one (above the grid, they would push it down).
271
+ const late = quickHits === LATE || (!onQuick && !!early && !Object.keys(early.quick).length && quickHits !== early.quick);
272
+ const result: ListingResult = { hits: first.hits, total: first.total, guid: first.guid, page: 1, cursor: first.cursor, status: first.status, facets: facetData, facetsAll, quick: late ? early?.quick ?? {} : (quickHits as Record<string, Hit[]>), partial: partial || undefined, ...(unknown.length ? { asValues } : {}) };
273
+ // Late tiles land in this result, which the listing caches: a render from
274
+ // the cache shows them; onQuick renders them as they come.
275
+ if (late && onQuick) result.quickPending = true;
276
+ if (late)
277
+ quick.then((qh) => {
278
+ result.quick = qh;
279
+ if (!result.quickPending) return;
280
+ delete result.quickPending;
281
+ onQuick!(result);
282
+ });
283
+ return result;
284
+ };
285
+
286
+ /** Another page can be appended to `res` ("show more"): the set goes on, and it holds fewer than MAX_PAGE pages. */
287
+ export const hasMore = (res: Pick<ListingResult, 'status' | 'cursor' | 'page'>) => res.status === 'more' && !!res.cursor && res.page < MAX_PAGE;
288
+
289
+ /**
290
+ * "Show more": `res` with its next page appended, from its cursor (one
291
+ * request, the same filter tree, sort and fields as its first page: a cursor
292
+ * is bound to them). The facets and quick types are the result set's, kept.
293
+ */
294
+ export const listingMore = async (api: Api, g: GlobalConfigType, cfg: ListingConfig, route: Route, q: Query, res: ListingResult, signal?: AbortSignal): Promise<ListingResult> => {
295
+ if (!hasMore(res)) return res;
296
+ const p = await api.search({ ...listingRequest(g, cfg, route, q, undefined, cfg.size, res.asValues), cursor: res.cursor! }, signal);
297
+ return { ...res, hits: append(res.hits, p.hits), page: res.page + 1, cursor: p.cursor, status: p.status };
298
+ };