@luigisbox/luma 0.3.0 → 0.5.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 (65) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +95 -14
  3. package/build/baseline.mjs +2 -2
  4. package/build/vite.mjs +253 -20
  5. package/docs/ACCESSIBILITY.md +9 -2
  6. package/package.json +3 -2
  7. package/src/analytics/chain.ts +10 -2
  8. package/src/analytics/collect.ts +14 -7
  9. package/src/analytics/from-ga4.ts +5 -2
  10. package/src/analytics/from-luma.ts +6 -4
  11. package/src/analytics/index.ts +1 -1
  12. package/src/analytics/pageview.ts +1 -1
  13. package/src/analytics/start.ts +3 -4
  14. package/src/analytics/wire.ts +1 -1
  15. package/src/api/ai.ts +32 -20
  16. package/src/api/token.ts +7 -7
  17. package/src/core/a11y.ts +6 -35
  18. package/src/core/context.ts +2 -0
  19. package/src/core/contrast-toggle.ts +32 -0
  20. package/src/core/default-button.ts +66 -0
  21. package/src/core/early-rules.ts +18 -4
  22. package/src/core/features.ts +109 -0
  23. package/src/core/frameworks-check.ts +32 -0
  24. package/src/core/frameworks.ts +35 -0
  25. package/src/core/image-load.ts +0 -26
  26. package/src/core/image-preload.ts +29 -0
  27. package/src/core/image-tags.ts +7 -1
  28. package/src/core/images.ts +3 -2
  29. package/src/core/integration.ts +42 -26
  30. package/src/core/listings.ts +9 -5
  31. package/src/core/locales.ts +2 -1
  32. package/src/core/own.ts +7 -3
  33. package/src/core/recommenders.ts +2 -2
  34. package/src/core/reconciler.ts +32 -23
  35. package/src/core/routes.ts +7 -6
  36. package/src/core/scheduler.ts +39 -5
  37. package/src/core/speculation.ts +2 -0
  38. package/src/core/stash.ts +15 -6
  39. package/src/core/styles.ts +6 -5
  40. package/src/core/support.ts +1 -1
  41. package/src/core/takeover.ts +6 -5
  42. package/src/core/templates.ts +5 -2
  43. package/src/core/tiles.ts +33 -10
  44. package/src/core/url.ts +6 -3
  45. package/src/core/widget-inits.ts +4 -1
  46. package/src/lit.ts +2 -1
  47. package/src/locales/cs.json +0 -1
  48. package/src/locales/de.json +0 -1
  49. package/src/locales/en.json +0 -1
  50. package/src/main.ts +70 -53
  51. package/src/types.ts +29 -9
  52. package/src/widgets/autocomplete/index.ts +58 -19
  53. package/src/widgets/listing/element.ts +81 -58
  54. package/src/widgets/listing/query.ts +6 -3
  55. package/src/widgets/listing/stash.ts +33 -16
  56. package/src/widgets/listing/state.ts +12 -0
  57. package/src/widgets/listing/view.ts +2 -1
  58. package/src/widgets/plp/route.ts +3 -2
  59. package/src/widgets/recommender/index.ts +3 -2
  60. package/src/widgets/search/direct.ts +39 -0
  61. package/src/widgets/search/form-url.ts +38 -34
  62. package/src/widgets/search/in-place.ts +43 -108
  63. package/src/widgets/search/index.ts +7 -2
  64. package/src/widgets/search/speculate.ts +48 -27
  65. package/src/widgets/search/submit.ts +136 -0
package/src/api/ai.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // Also runs in Node (the edge render service): there the
9
9
  // storefront origin is sent explicitly and nothing touches the DOM.
10
10
 
11
+ import { PLP, RECOMMENDER, SEARCH } from '../core/features';
11
12
  import { health } from '../core/health';
12
13
  import type { GlobalConfigType } from '../types';
13
14
  import type { Api, FacetData, Hit, Page } from '.';
@@ -15,6 +16,11 @@ import { fetchJson, TIMEOUT_MS, tokensFor, untilAborted, visitorId } from './tok
15
16
 
16
17
  export { untilAborted };
17
18
 
19
+ /** A method of a widget the build has no folder for (never called). */
20
+ const unbuilt = (): never => {
21
+ throw Error('not in this build');
22
+ };
23
+
18
24
  export const aiApi = (g: GlobalConfigType, origin?: string): Api => {
19
25
  const tk = tokensFor(g, origin);
20
26
  const { API, originHeaders } = tk;
@@ -48,8 +54,8 @@ export const aiApi = (g: GlobalConfigType, origin?: string): Api => {
48
54
  return post(path, params, body, signal, true);
49
55
  }
50
56
  }
51
- if (!r.ok) throw new Error(`${path}: HTTP ${r.status} ${r.body.slice(0, 300)}`);
52
- if (r.json == null) throw new Error(`${path}: not JSON`);
57
+ if (!r.ok) throw Error(`${path}: HTTP ${r.status} ${r.body.slice(0, 300)}`);
58
+ if (r.json == null) throw Error(`${path}: not JSON`);
53
59
  return r.json;
54
60
  };
55
61
 
@@ -75,24 +81,30 @@ export const aiApi = (g: GlobalConfigType, origin?: string): Api => {
75
81
  return { hits: j.hits ?? [], total: j.total ?? j.total_approx ?? 0, guid: j.guid, cursor: j.next_page_cursor ?? null, status: j.pagination_status ?? 'exhausted' };
76
82
  },
77
83
 
78
- facets: async (guid, facets, signal): Promise<Record<string, FacetData>> =>
79
- (await post('facets', { channel_id: g.channelId }, { guid, facets: facets.map((f) => compact(f)) }, signal)).facets ?? {},
84
+ // A listing's and a recommender's (core/features.ts): not in a build without one.
85
+ facets:
86
+ SEARCH || PLP
87
+ ? async (guid, facets, signal): Promise<Record<string, FacetData>> =>
88
+ (await post('facets', { channel_id: g.channelId }, { guid, facets: facets.map((f) => compact(f)) }, signal)).facets ?? {}
89
+ : unbuilt,
80
90
 
81
- recommend: async (r, signal): Promise<Hit[]> =>
82
- (
83
- await post(
84
- 'recommender',
85
- { channel_id: g.channelId, surface_id: r.surface },
86
- compact({
87
- type: r.type,
88
- size: r.size,
89
- anchor_ids: r.anchors.length ? r.anchors.slice(0, 50) : undefined,
90
- exclude_ids: r.exclude?.length ? r.exclude.slice(0, 50) : undefined,
91
- filters: nonEmpty(r.filters),
92
- return_fields: r.fields?.length ? r.fields : undefined,
93
- }),
94
- signal,
95
- )
96
- ).hits ?? [],
91
+ recommend: RECOMMENDER
92
+ ? async (r, signal): Promise<Hit[]> =>
93
+ (
94
+ await post(
95
+ 'recommender',
96
+ { channel_id: g.channelId, surface_id: r.surface },
97
+ compact({
98
+ type: r.type,
99
+ size: r.size,
100
+ anchor_ids: r.anchors.length ? r.anchors.slice(0, 50) : undefined,
101
+ exclude_ids: r.exclude?.length ? r.exclude.slice(0, 50) : undefined,
102
+ filters: nonEmpty(r.filters),
103
+ return_fields: r.fields?.length ? r.fields : undefined,
104
+ }),
105
+ signal,
106
+ )
107
+ ).hits ?? []
108
+ : unbuilt,
97
109
  };
98
110
  };
package/src/api/token.ts CHANGED
@@ -99,7 +99,7 @@ export async function solveHere(nonce: string, difficulty: number, until: number
99
99
  let t0 = Date.now();
100
100
  for (let n = 0; ; n++) {
101
101
  if ((n & 1023) === 0 && Date.now() - t0 > (slice || 50)) {
102
- if (Date.now() > until) throw new Error('proof of work: no answer in time');
102
+ if (Date.now() > until) throw Error('proof of work: no answer in time');
103
103
  if (slice) await new Promise((r) => setTimeout(r));
104
104
  t0 = Date.now();
105
105
  }
@@ -142,8 +142,8 @@ const solve = (nonce: string, difficulty: number): Promise<number> => {
142
142
  return here(); // CSP worker-src
143
143
  }
144
144
  // A worker that never answers is stopped.
145
- timer = window.setTimeout(() => (done(), reject(new Error('proof of work: no answer in time'))), POW_TIMEOUT_MS);
146
- w.onmessage = (e) => (done(), e.data == null ? reject(new Error('proof of work failed')) : resolve(e.data));
145
+ timer = window.setTimeout(() => (done(), reject(Error('proof of work: no answer in time'))), POW_TIMEOUT_MS);
146
+ w.onmessage = (e) => (done(), e.data == null ? reject(Error('proof of work failed')) : resolve(e.data));
147
147
  w.onerror = here;
148
148
  w.postMessage([nonce, difficulty, until]);
149
149
  });
@@ -236,9 +236,9 @@ const tokens = (g: GlobalConfigType, origin?: string): Tokens => {
236
236
  const url = challengeUrl(g);
237
237
  // The one the entry asked while this chunk loaded (the first try only: a retry needs a new one).
238
238
  const ch = await fetchJson(url, { headers: originHeaders }, TIMEOUT_MS, undefined, browser && retries ? takeChallenge(url) : undefined);
239
- if (!ch.ok) throw new Error(`browser-challenge: HTTP ${ch.status}`);
239
+ if (!ch.ok) throw Error(`browser-challenge: HTTP ${ch.status}`);
240
240
  const { nonce, difficulty } = ch.json ?? {};
241
- if (typeof nonce !== 'string' || !(difficulty >= 0 && difficulty <= MAX_DIFFICULTY)) throw new Error('browser-challenge: unexpected challenge');
241
+ if (typeof nonce !== 'string' || !(difficulty >= 0 && difficulty <= MAX_DIFFICULTY)) throw Error('browser-challenge: unexpected challenge');
242
242
  const solution = await solve(nonce, difficulty);
243
243
  const r = await fetchJson(
244
244
  `${AUTH}/browser-token`,
@@ -260,10 +260,10 @@ const tokens = (g: GlobalConfigType, origin?: string): Tokens => {
260
260
  if (!r.ok) {
261
261
  const detail = r.json?.detail;
262
262
  if (retries > 0 && detail === 'Nonce expired') return mint(retries - 1);
263
- throw new Error(`browser-token: HTTP ${r.status} ${typeof detail === 'string' ? detail : ''}`);
263
+ throw Error(`browser-token: HTTP ${r.status} ${typeof detail === 'string' ? detail : ''}`);
264
264
  }
265
265
  const { access_token, expires_in } = r.json ?? {};
266
- if (typeof access_token !== 'string' || !(expires_in > 30)) throw new Error('browser-token: unexpected response');
266
+ if (typeof access_token !== 'string' || !(expires_in > 30)) throw Error('browser-token: unexpected response');
267
267
  return { token: access_token, exp: Date.now() + (expires_in - 30) * 1000 };
268
268
  };
269
269
 
package/src/core/a11y.ts CHANGED
@@ -90,17 +90,17 @@ export const CONTRAST_CSS =
90
90
  `@media (prefers-contrast:more){:root:not([data-lbx-contrast="off"]) ${OURS}{outline:3px solid!important;outline-offset:2px!important}}` +
91
91
  `:root[data-lbx-contrast="more"] ${OURS}{outline:3px solid!important;outline-offset:2px!important}\n`;
92
92
 
93
- const KEY = 'lbx:contrast';
93
+ export const CONTRAST_KEY = 'lbx:contrast';
94
94
  const stored = () => {
95
95
  try {
96
- const v = localStorage.getItem(KEY);
96
+ const v = localStorage.getItem(CONTRAST_KEY);
97
97
  return v === 'more' || v === 'off' ? v : null;
98
98
  } catch {
99
99
  return null;
100
100
  }
101
101
  };
102
102
  const more = () => (typeof matchMedia === 'function' ? matchMedia('(prefers-contrast: more)') : null);
103
- const prefersMore = () => !!more()?.matches;
103
+ export const prefersMore = () => !!more()?.matches;
104
104
 
105
105
  /** The mode's state: the visitor's toggle, else their OS setting ('more'), else none. */
106
106
  const state = () => stored() ?? (prefersMore() ? 'more' : null);
@@ -112,7 +112,7 @@ export const contrastOn = () => state() === 'more';
112
112
  * is on (the toggle, or the OS setting: one selector for both), `"off"` when
113
113
  * the visitor switched it off against their OS setting.
114
114
  */
115
- const apply = () => {
115
+ export const applyContrast = () => {
116
116
  const v = state();
117
117
  const html = document.documentElement;
118
118
  if (!v) html.removeAttribute('data-lbx-contrast');
@@ -125,36 +125,7 @@ export const useContrast = () => {
125
125
  setCss('lbx:contrast', layered(CONTRAST_CSS));
126
126
  if (!watching) {
127
127
  watching = true;
128
- more()?.addEventListener?.('change', apply);
128
+ more()?.addEventListener?.('change', applyContrast);
129
129
  }
130
- apply();
131
- };
132
-
133
- /** Switch it (our toggle): remembered for this browser; back to "as the OS says" when that matches. */
134
- export const setContrast = (on: boolean) => {
135
- const v = on === prefersMore() ? null : on ? 'more' : 'off';
136
- try {
137
- if (v) localStorage.setItem(KEY, v);
138
- else localStorage.removeItem(KEY);
139
- } catch {}
140
- apply();
141
- };
142
-
143
- /**
144
- * Modelled on the previous-generation Luma's switch (`_global.contrastToggle`): a button that turns the
145
- * mode on and off. Visually hidden until focused unless the visitor's OS asks
146
- * for a contrast preference or the mode is on (as that one was), so a
147
- * keyboard user who reaches it always sees it.
148
- */
149
- export const contrastToggle = (label: string) => {
150
- const b = document.createElement('button');
151
- b.type = 'button';
152
- b.className = 'lbx-contrast-toggle';
153
- b.textContent = label;
154
- b.setAttribute('aria-pressed', String(contrastOn()));
155
- b.addEventListener('click', (e) => {
156
- e.stopPropagation();
157
- setContrast(!contrastOn());
158
- });
159
- return b;
130
+ applyContrast();
160
131
  };
@@ -6,6 +6,7 @@
6
6
  import type { Templates } from './templates';
7
7
  import type { LumaConfigType } from '../types';
8
8
  import type { WidgetContext } from '../main';
9
+ import { QUERY_URL } from './features';
9
10
  import { nameOf, slot } from './integration';
10
11
 
11
12
  type Support = typeof import('./support');
@@ -104,5 +105,6 @@ export const widgetContext = (
104
105
  result: s.resultFactory({ ...g.computedAttributes, ...w.app.computedAttributes }),
105
106
  // Images: a listing's first row is eager; elsewhere (recommenders, the autocomplete) every image is lazy.
106
107
  helpers: { t, price, plain: s.plain, first: s.first, ...s.imageHelpers(g.images, w.id !== 'search' && w.id !== 'plp') },
108
+ queryUrl: QUERY_URL ? config.search?.queryUrl : undefined,
107
109
  };
108
110
  };
@@ -0,0 +1,32 @@
1
+ // The listing's high-contrast toggle (`contrastToggle` of a listing config):
2
+ // apart from ./a11y.ts (every widget's), so only the listing's chunk has it.
3
+ import { applyContrast, CONTRAST_KEY, contrastOn, prefersMore } from './a11y';
4
+
5
+ /** Switch it (our toggle): remembered for this browser; back to "as the OS says" when that matches. */
6
+ export const setContrast = (on: boolean) => {
7
+ const v = on === prefersMore() ? null : on ? 'more' : 'off';
8
+ try {
9
+ if (v) localStorage.setItem(CONTRAST_KEY, v);
10
+ else localStorage.removeItem(CONTRAST_KEY);
11
+ } catch {}
12
+ applyContrast();
13
+ };
14
+
15
+ /**
16
+ * Modelled on the previous-generation Luma's switch (`_global.contrastToggle`): a button that turns the
17
+ * mode on and off. Visually hidden until focused unless the visitor's OS asks
18
+ * for a contrast preference or the mode is on (as that one was), so a
19
+ * keyboard user who reaches it always sees it.
20
+ */
21
+ export const contrastToggle = (label: string) => {
22
+ const b = document.createElement('button');
23
+ b.type = 'button';
24
+ b.className = 'lbx-contrast-toggle';
25
+ b.textContent = label;
26
+ b.setAttribute('aria-pressed', String(contrastOn()));
27
+ b.addEventListener('click', (e) => {
28
+ e.stopPropagation();
29
+ setContrast(!contrastOn());
30
+ });
31
+ return b;
32
+ };
@@ -0,0 +1,66 @@
1
+ // A form's default button, what an implicit submission (Enter in a field)
2
+ // submits with. In lbx-core: the autocomplete's "show all" link (its href is
3
+ // the URL Enter would load) and the search form's URL (widgets/search/form-url.ts,
4
+ // lbx-nav) read the same button, so the link, the result fetched while typing
5
+ // and the page fetched ahead agree.
6
+
7
+ export type Submitter = HTMLButtonElement | HTMLInputElement;
8
+
9
+ /** FormData(form, submitter) is newer (Chrome 112, Safari 16.4, Firefox 111): before, the second argument is ignored (it does not throw). */
10
+ let withSubmitter: boolean | undefined;
11
+ export const submitterSupported = () => {
12
+ if (withSubmitter === undefined) {
13
+ try {
14
+ const f = document.createElement('form');
15
+ const b = f.appendChild(document.createElement('button'));
16
+ b.name = 'x';
17
+ b.value = '1';
18
+ withSubmitter = new FormData(f, b).get('x') === '1';
19
+ } catch {
20
+ withSubmitter = false;
21
+ }
22
+ }
23
+ return withSubmitter;
24
+ };
25
+
26
+ /**
27
+ * The form's data as a submit by `btn` sends it (null: by no button). Where
28
+ * the engine ignores the submitter argument, its own entry is added by hand:
29
+ * name=value, or an image button's x and y (`name.x`, `name.y`; `x`, `y`
30
+ * without a name), 0 as a submit without a pointer.
31
+ */
32
+ export const formDataOf = (form: HTMLFormElement, btn: Submitter | null): FormData => {
33
+ const native = submitterSupported();
34
+ let own = !native; // the submitter's entry is ours to add
35
+ let data: FormData;
36
+ try {
37
+ data = new FormData(form, native && btn ? btn : undefined);
38
+ } catch {
39
+ data = new FormData(form); // not a submit button of this form
40
+ own = true;
41
+ }
42
+ if (own && btn) {
43
+ if (btn.type === 'image') {
44
+ data.append(btn.name ? `${btn.name}.x` : 'x', '0');
45
+ data.append(btn.name ? `${btn.name}.y` : 'y', '0');
46
+ } else if (btn.name) data.append(btn.name, btn.value);
47
+ }
48
+ return data;
49
+ };
50
+
51
+ /**
52
+ * The form's default button: the first submit button it owns in tree order
53
+ * (inside it, or anywhere in its tree through `form=`), what an implicit
54
+ * submission (Enter in a field) submits with, so its `formaction`,
55
+ * `formmethod`, `formtarget`, `formnovalidate` and name=value count as on a
56
+ * click. null without one.
57
+ */
58
+ export const defaultSubmitter = (form: HTMLFormElement): Submitter | null => {
59
+ // A button outside the form owns it through form="<id>": the whole tree only when the form has an id.
60
+ for (const el of (form.id ? (form.getRootNode() as ParentNode) : form).querySelectorAll<Submitter>('button, input')) {
61
+ if (el.form !== form) continue;
62
+ const type = (el.getAttribute('type') ?? '').trim().toLowerCase();
63
+ if (el instanceof HTMLButtonElement ? type === '' || type === 'submit' : type === 'submit' || type === 'image') return el;
64
+ }
65
+ return null;
66
+ };
@@ -16,6 +16,7 @@
16
16
  import { holdCss } from './hold';
17
17
  import { P, TRACKING } from './routes';
18
18
  import type { PlpConfigType, SearchConfigType } from '../types';
19
+ import { NAMED_EARLY_HOLD } from './features';
19
20
 
20
21
  const EARLY_KEY = 'lbx:early';
21
22
  // Short: when the bundle stops running (switched off, blocked), holds end soon after.
@@ -35,9 +36,22 @@ export interface EarlyRoute {
35
36
  const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
37
  // reserveSelector: set by loadConfig (default: the first anchor).
37
38
 
38
- /** Only what the snippet can express: a path regex and a query param (no custom readQuery/active). */
39
- export const searchRule = (c: SearchConfigType, hostCss: string): EarlyRoute | null =>
40
- c.urlMatch && c.queryParam && !c.readQuery && !c.active ? { id: 'search', path: c.urlMatch, need: [c.queryParam], css: holdCss(hostCss, c.reserveSelector!) } : null;
39
+ /** Capture groups in a regex source (its alternation with the empty string always matches). */
40
+ const groups = (re: string) => RegExp(`${re}|`).exec('')!.length - 1;
41
+
42
+ /**
43
+ * Only what the snippet can express (no custom readQuery/active): a path
44
+ * regex and a query param, or a path regex whose capture group 1 is the query
45
+ * (/search/<q>/). The snippet cannot check that capture (the bundle decodes
46
+ * and trims it), so it also holds a blank one (/search/+/); the bundle
47
+ * rejects that URL and drops the unclaimed hold at boot, as for plpRule's `active`.
48
+ */
49
+ export const searchRule = (c: SearchConfigType, hostCss: string): EarlyRoute | null => {
50
+ if (!c.urlMatch || c.readQuery || c.active) return null;
51
+ const css = holdCss(hostCss, c.reserveSelector!);
52
+ if (c.queryParam) return { id: 'search', path: c.urlMatch, need: [c.queryParam], css };
53
+ return groups(c.urlMatch) ? { id: 'search', path: c.urlMatch, css } : null;
54
+ };
41
55
 
42
56
  /**
43
57
  * Param names the category route can express (values are checked by the bundle).
@@ -48,7 +62,7 @@ export const searchRule = (c: SearchConfigType, hostCss: string): EarlyRoute | n
48
62
  * the unclaimed hold at boot; `earlyHold: false` opts out where it is not.
49
63
  */
50
64
  export const plpRule = (c: PlpConfigType, hostCss: string): EarlyRoute | null => {
51
- if (c.earlyHold === false) return null;
65
+ if ((NAMED_EARLY_HOLD ? c.earlyHold === false : false)) return null;
52
66
  const pm = c.params ?? {};
53
67
  // Their page param too: their page 1 is ours. A later page of theirs is
54
68
  // theirs (plpAccepts), a value the snippet does not check: held until the
@@ -0,0 +1,109 @@
1
+ // The widgets an integration has (build/vite.mjs `features`): a widget is its
2
+ // folder (integration/autocomplete/, search/, plp/, recommender <name>/; an
3
+ // _analytics.ts or _widget.ts in any folder); a feature only the
4
+ // integration's own files can ask for (a template's attribute, a hook its
5
+ // _init.ts sets) by that text in them. The build defines these from the folders, so
6
+ // the entry leaves out what a missing widget's triggers need. The dev server
7
+ // (folders come and go), the render service and unit tests: all true.
8
+ // As in core/frameworks.ts: a flag in `if (FLAG)` or `FLAG ? x : y` only.
9
+
10
+ declare const __LBX_AUTOCOMPLETE__: boolean | undefined;
11
+ declare const __LBX_SEARCH__: boolean | undefined;
12
+ declare const __LBX_PLP__: boolean | undefined;
13
+ declare const __LBX_RECOMMENDER__: boolean | undefined;
14
+ declare const __LBX_ANALYTICS__: boolean | undefined;
15
+ declare const __LBX_SSR__: boolean | undefined;
16
+ declare const __LBX_WIDGET_INITS__: boolean | undefined;
17
+ declare const __LBX_CART__: boolean | undefined;
18
+ declare const __LBX_DRAWER__: boolean | undefined;
19
+ declare const __LBX_MENU__: boolean | undefined;
20
+ declare const __LBX_DROPDOWN__: boolean | undefined;
21
+ declare const __LBX_AC_HOOKS__: boolean | undefined;
22
+ declare const __LBX_ON_RENDER__: boolean | undefined;
23
+ declare const __LBX_READ_QUERY__: boolean | undefined;
24
+ declare const __LBX_FACETS_FOR__: boolean | undefined;
25
+ declare const __LBX_QUERY_URL__: boolean | undefined;
26
+ declare const __LBX_HOST_STYLES__: boolean | undefined;
27
+ declare const __LBX_TRANSLATIONS__: boolean | undefined;
28
+ declare const __LBX_INITS__: boolean | undefined;
29
+ declare const __LBX_EMPTY_VALUES__: boolean | undefined;
30
+ declare const __LBX_PRERENDER_TYPING__: boolean | undefined;
31
+ declare const __LBX_CONTENT_VISIBILITY__: boolean | undefined;
32
+ declare const __LBX_NAMED_PRECONNECT_IMAGES__: boolean | undefined;
33
+ declare const __LBX_NAMED_PREFETCH_FILTERS__: boolean | undefined;
34
+ declare const __LBX_NAMED_PRELOAD__: boolean | undefined;
35
+ declare const __LBX_NAMED_FADE__: boolean | undefined;
36
+ declare const __LBX_NAMED_RESTORE_ON_BACK__: boolean | undefined;
37
+ declare const __LBX_NAMED_PREFETCH_NEXT_PAGE__: boolean | undefined;
38
+ declare const __LBX_NAMED_IN_PLACE_QUERY__: boolean | undefined;
39
+ declare const __LBX_NAMED_PREFETCH_WHILE_TYPING__: boolean | undefined;
40
+ declare const __LBX_NAMED_EARLY_HOLD__: boolean | undefined;
41
+ declare const __LBX_NAMED_SPECULATION__: boolean | undefined;
42
+
43
+ /** integration/autocomplete/. */
44
+ export const AUTOCOMPLETE = typeof __LBX_AUTOCOMPLETE__ === 'boolean' ? __LBX_AUTOCOMPLETE__ : true;
45
+ /** integration/search/. */
46
+ export const SEARCH = typeof __LBX_SEARCH__ === 'boolean' ? __LBX_SEARCH__ : true;
47
+ /** integration/plp/. */
48
+ export const PLP = typeof __LBX_PLP__ === 'boolean' ? __LBX_PLP__ : true;
49
+ /** An integration/recommender <name>/. */
50
+ export const RECOMMENDER = typeof __LBX_RECOMMENDER__ === 'boolean' ? __LBX_RECOMMENDER__ : true;
51
+ /** An _analytics.ts in any folder (without one, analytics is off: main.ts). */
52
+ export const ANALYTICS = typeof __LBX_ANALYTICS__ === 'boolean' ? __LBX_ANALYTICS__ : true;
53
+ /** A _widget.ts in any folder (without one, core/widget-inits.ts has nothing to run). */
54
+ export const WIDGET_INITS = typeof __LBX_WIDGET_INITS__ === 'boolean' ? __LBX_WIDGET_INITS__ : true;
55
+ /** `data-lbx-cart` in any file of the integration (its templates): a tile's add-to-cart (core/tiles.ts). */
56
+ export const CART = typeof __LBX_CART__ === 'boolean' ? __LBX_CART__ : true;
57
+ /** `data-lbx-drawer` in any file of the integration: the listing's filter drawer (widgets/listing/element.ts). */
58
+ export const DRAWER = typeof __LBX_DRAWER__ === 'boolean' ? __LBX_DRAWER__ : true;
59
+ /** `data-lbx-menu` in any file of the integration: menus in the listing's markup (widgets/listing/element.ts). */
60
+ export const MENU = typeof __LBX_MENU__ === 'boolean' ? __LBX_MENU__ : true;
61
+ /** `data-lbx-dropdown` in any file of the integration: `<details>` menus in the listing's markup, closed from outside. */
62
+ export const DROPDOWN = typeof __LBX_DROPDOWN__ === 'boolean' ? __LBX_DROPDOWN__ : true;
63
+ /** The autocomplete's `onOpen`, `onClose`, `onSelect`, `onReady` named in the integration's code. */
64
+ export const AC_HOOKS = typeof __LBX_AC_HOOKS__ === 'boolean' ? __LBX_AC_HOOKS__ : true;
65
+ /** A listing's `onRender` named in the integration's code. */
66
+ export const ON_RENDER = typeof __LBX_ON_RENDER__ === 'boolean' ? __LBX_ON_RENDER__ : true;
67
+ /** The search page's `readQuery` named in the integration's code. */
68
+ export const READ_QUERY = typeof __LBX_READ_QUERY__ === 'boolean' ? __LBX_READ_QUERY__ : true;
69
+ /** A category page's `facetsFor` named in the integration's code. */
70
+ export const FACETS_FOR = typeof __LBX_FACETS_FOR__ === 'boolean' ? __LBX_FACETS_FOR__ : true;
71
+ /** The search page's `queryUrl` named in the integration's code: Enter straight to the query's page (widgets/search/direct.ts), the hook's page as every search's. */
72
+ export const QUERY_URL = typeof __LBX_QUERY_URL__ === 'boolean' ? __LBX_QUERY_URL__ : true;
73
+ /** A style-host.css in any folder (core/integration.ts hostStyle: the host's own to hide or hold). */
74
+ export const HOST_STYLES = typeof __LBX_HOST_STYLES__ === 'boolean' ? __LBX_HOST_STYLES__ : true;
75
+ /** A _translations.ts in any folder (core/integration.ts: over Luma's dictionary). */
76
+ export const TRANSLATIONS = typeof __LBX_TRANSLATIONS__ === 'boolean' ? __LBX_TRANSLATIONS__ : true;
77
+ /** An _init.ts in any folder (core/integration.ts loadConfig). */
78
+ export const INITS = typeof __LBX_INITS__ === 'boolean' ? __LBX_INITS__ : true;
79
+ /**
80
+ * Options off by default, named in the integration's code (its _config.ts,
81
+ * or code setting them): a key no file names keeps its default, as only
82
+ * code naming it can change it. `emptyValues`, `prerenderWhileTyping` (or
83
+ * `pageWhileTyping`: prerendering the search page typed for), `contentVisibility`.
84
+ */
85
+ export const EMPTY_VALUES = typeof __LBX_EMPTY_VALUES__ === 'boolean' ? __LBX_EMPTY_VALUES__ : true;
86
+ export const PRERENDER_TYPING = typeof __LBX_PRERENDER_TYPING__ === 'boolean' ? __LBX_PRERENDER_TYPING__ : true;
87
+ export const CONTENT_VISIBILITY = typeof __LBX_CONTENT_VISIBILITY__ === 'boolean' ? __LBX_CONTENT_VISIBILITY__ : true;
88
+ /**
89
+ * Server-rendered listings (Luigi's Box's server-side includes): adopting
90
+ * and hydrating the server's markup. Declared, not found in the folders:
91
+ * `clientConfig(url, { serverRendering: false })` for a site without them.
92
+ */
93
+ export const SSR = typeof __LBX_SSR__ === 'boolean' ? __LBX_SSR__ : true;
94
+
95
+ /**
96
+ * Options on by default, named in the integration's code: where none names
97
+ * one it is on (only code naming a key sets it), and a read of it is
98
+ * `NAMED_X ? cfg.x !== false : true` (build/vite.mjs ON_BY_DEFAULT).
99
+ */
100
+ export const NAMED_PRECONNECT_IMAGES = typeof __LBX_NAMED_PRECONNECT_IMAGES__ === 'boolean' ? __LBX_NAMED_PRECONNECT_IMAGES__ : true;
101
+ export const NAMED_PREFETCH_FILTERS = typeof __LBX_NAMED_PREFETCH_FILTERS__ === 'boolean' ? __LBX_NAMED_PREFETCH_FILTERS__ : true;
102
+ export const NAMED_PRELOAD = typeof __LBX_NAMED_PRELOAD__ === 'boolean' ? __LBX_NAMED_PRELOAD__ : true;
103
+ export const NAMED_FADE = typeof __LBX_NAMED_FADE__ === 'boolean' ? __LBX_NAMED_FADE__ : true;
104
+ export const NAMED_RESTORE_ON_BACK = typeof __LBX_NAMED_RESTORE_ON_BACK__ === 'boolean' ? __LBX_NAMED_RESTORE_ON_BACK__ : true;
105
+ export const NAMED_PREFETCH_NEXT_PAGE = typeof __LBX_NAMED_PREFETCH_NEXT_PAGE__ === 'boolean' ? __LBX_NAMED_PREFETCH_NEXT_PAGE__ : true;
106
+ export const NAMED_IN_PLACE_QUERY = typeof __LBX_NAMED_IN_PLACE_QUERY__ === 'boolean' ? __LBX_NAMED_IN_PLACE_QUERY__ : true;
107
+ export const NAMED_PREFETCH_WHILE_TYPING = typeof __LBX_NAMED_PREFETCH_WHILE_TYPING__ === 'boolean' ? __LBX_NAMED_PREFETCH_WHILE_TYPING__ : true;
108
+ export const NAMED_EARLY_HOLD = typeof __LBX_NAMED_EARLY_HOLD__ === 'boolean' ? __LBX_NAMED_EARLY_HOLD__ : true;
109
+ export const NAMED_SPECULATION = typeof __LBX_NAMED_SPECULATION__ === 'boolean' ? __LBX_NAMED_SPECULATION__ : true;
@@ -0,0 +1,32 @@
1
+ // Development only (src/main.ts, import.meta.env.DEV): a framework on the
2
+ // page whose support the build left out (clientConfig `framework`,
3
+ // core/frameworks.ts) is said in window.__lbx.health.errors. Without its
4
+ // support Luma does not wait for its hydration or talk to its router: a
5
+ // re-rendered page, ours dropped, a router putting its URL back.
6
+ import { SSR } from './features';
7
+ import { ANGULAR, ASTRO, DOCUMENT_REACT, NEXT, REACT, SVELTEKIT, TURBO, VUE } from './frameworks';
8
+ import { reportError } from './health';
9
+ import { reactDocumentSigns } from './own';
10
+
11
+ const keyed = (prefixes: string[]) => [...document.querySelectorAll('*')].some((el) => Object.keys(el).some((k) => prefixes.some((p) => k.startsWith(p))));
12
+
13
+ /** Per part of the support (build/vite.mjs SUPPORTS): left out of this build, and the page shows its framework. */
14
+ const CHECKS: [boolean, string, () => boolean][] = [
15
+ [DOCUMENT_REACT, 'React hydrating the whole document (next, remix or react-router)', reactDocumentSigns],
16
+ [REACT, 'React (react)', () => !!document.querySelector('#__next, #___gatsby, [data-reactroot]') || keyed(['__reactContainer$', '__reactFiber$'])],
17
+ [NEXT, 'the Next.js pages router (next)', () => !!(window as any).__NEXT_DATA__ && !(window as any).next?.appDir],
18
+ [VUE, 'Vue (vue or nuxt)', () => !!((window as any).__NUXT__ || (window as any).$nuxt || document.querySelector('[data-v-app], #__nuxt, [data-server-rendered]')) || keyed(['__vue_app__', '__vue__'])],
19
+ [ANGULAR, 'Angular (angular)', () => !!document.querySelector('[ng-version], [ng-server-context]')],
20
+ [SVELTEKIT, 'SvelteKit (sveltekit)', () => [...document.scripts].some((s) => s.text.includes('__sveltekit_')) || !!document.querySelector('link[href*="/immutable/entry/start."]')],
21
+ [ASTRO, 'Astro (astro)', () => !!document.querySelector('astro-island, meta[name="astro-view-transitions-enabled"]')],
22
+ [TURBO, 'Turbo (turbo)', () => !!((window as any).Turbo || (window as any).Turbolinks)],
23
+ [SSR, 'server-rendered listings (serverRendering)', () => !!document.querySelector('[data-lbx-ssr]')],
24
+ ];
25
+
26
+ /** The frameworks on the page this build has no support for (reported once). */
27
+ export const checkFrameworks = () => {
28
+ const missing = CHECKS.filter(([has, , seen]) => !has && seen()).map(([, name]) => name);
29
+ if (missing.length)
30
+ reportError('boot', `this page uses ${missing.join(', ')}, which the build leaves out (clientConfig \`framework\`, \`serverRendering\`). Declare every framework the site uses, or leave them out.`);
31
+ return missing;
32
+ };
@@ -0,0 +1,35 @@
1
+ // What of each framework's support a build has (build/vite.mjs SUPPORTS):
2
+ // all of it unless the integration declares its framework(s). Constants the
3
+ // build replaces, so code behind a false one is left out of every chunk.
4
+ // Not built by clientConfig (the render service, unit tests): all true.
5
+ //
6
+ // Use them in `if (FLAG)` or `FLAG ? x : y`, not as a value (`FLAG && x`):
7
+ // Rollup keeps such a reference, and a chunk other than the entry then imports
8
+ // the flag from it and keeps the code (test/browser/specs/budgets.spec.ts,
9
+ // .claude/skills/tree-shaking/SKILL.md).
10
+
11
+ declare const __LBX_REACT__: boolean | undefined;
12
+ declare const __LBX_DOCUMENT_REACT__: boolean | undefined;
13
+ declare const __LBX_NEXT__: boolean | undefined;
14
+ declare const __LBX_VUE__: boolean | undefined;
15
+ declare const __LBX_ANGULAR__: boolean | undefined;
16
+ declare const __LBX_SVELTEKIT__: boolean | undefined;
17
+ declare const __LBX_ASTRO__: boolean | undefined;
18
+ declare const __LBX_TURBO__: boolean | undefined;
19
+
20
+ /** React roots and islands: createRoot / hydrateRoot, Suspense boundaries, Gatsby. */
21
+ export const REACT = typeof __LBX_REACT__ === 'boolean' ? __LBX_REACT__ : true;
22
+ /** React hydrating the whole document (Next.js, Remix, React Router 7 / Hydrogen): the entry's checks (core/own.ts, core/styles.ts). */
23
+ export const DOCUMENT_REACT = typeof __LBX_DOCUMENT_REACT__ === 'boolean' ? __LBX_DOCUMENT_REACT__ : true;
24
+ /** Next.js pages router: its router (core/url.ts), its hydration measure (core/reconciler.ts). */
25
+ export const NEXT = typeof __LBX_NEXT__ === 'boolean' ? __LBX_NEXT__ : true;
26
+ /** Vue 2 / 3 and Nuxt: their mount (core/reconciler.ts), their router (core/url.ts). */
27
+ export const VUE = typeof __LBX_VUE__ === 'boolean' ? __LBX_VUE__ : true;
28
+ /** Angular's hydration (core/reconciler.ts). */
29
+ export const ANGULAR = typeof __LBX_ANGULAR__ === 'boolean' ? __LBX_ANGULAR__ : true;
30
+ /** SvelteKit: its root (core/reconciler.ts), its history entries (core/url.ts). */
31
+ export const SVELTEKIT = typeof __LBX_SVELTEKIT__ === 'boolean' ? __LBX_SVELTEKIT__ : true;
32
+ /** Astro: islands (core/reconciler.ts), ClientRouter history entries (core/url.ts). */
33
+ export const ASTRO = typeof __LBX_ASTRO__ === 'boolean' ? __LBX_ASTRO__ : true;
34
+ /** Turbo Drive / Turbolinks: snapshots (core/reconciler.ts), history and prefetch (core/turbo.ts). */
35
+ export const TURBO = typeof __LBX_TURBO__ === 'boolean' ? __LBX_TURBO__ : true;
@@ -50,32 +50,6 @@ export const settleImages = (root: Element, fade = true) => {
50
50
  if (fade && !root.hasAttribute('data-lbx-fade')) root.setAttribute('data-lbx-fade', '');
51
51
  };
52
52
 
53
- const preloaded = new Set<string>();
54
- /** <link rel=preload as=image> for these images (their srcset and sizes, so the candidate the <img> picks is the one fetched), at high priority. */
55
- export const preloadImages = (list: ImageInfo[]) => {
56
- try {
57
- for (const im of list) {
58
- const key = im.srcset || im.src;
59
- if (!im.src || preloaded.has(key)) continue;
60
- preloaded.add(key);
61
- const l = document.createElement('link');
62
- l.rel = 'preload';
63
- l.setAttribute('as', 'image');
64
- l.href = im.src;
65
- if (im.srcset) {
66
- l.setAttribute('imagesrcset', im.srcset);
67
- if (im.sizes) l.setAttribute('imagesizes', im.sizes);
68
- }
69
- l.setAttribute('fetchpriority', 'high');
70
- l.setAttribute('data-lbx', 'preload');
71
- document.head.append(l);
72
- }
73
- // Bounded: the oldest of ours go (a long session of filters and queries).
74
- const ours = document.head.querySelectorAll('link[data-lbx="preload"]');
75
- for (let k = 0; k < ours.length - 24; k++) ours[k].remove();
76
- } catch {}
77
- };
78
-
79
53
  const warmed = new Set<string>();
80
54
  /**
81
55
  * The first `cap` images of results fetched ahead, at low priority (the
@@ -0,0 +1,29 @@
1
+ // The listing's preload of its first row's images: apart from ./image-load.ts
2
+ // (every widget's), so only the listing's chunk has it.
3
+ import type { ImageInfo } from './images';
4
+
5
+ const preloaded = new Set<string>();
6
+ /** <link rel=preload as=image> for these images (their srcset and sizes, so the candidate the <img> picks is the one fetched), at high priority. */
7
+ export const preloadImages = (list: ImageInfo[]) => {
8
+ try {
9
+ for (const im of list) {
10
+ const key = im.srcset || im.src;
11
+ if (!im.src || preloaded.has(key)) continue;
12
+ preloaded.add(key);
13
+ const l = document.createElement('link');
14
+ l.rel = 'preload';
15
+ l.setAttribute('as', 'image');
16
+ l.href = im.src;
17
+ if (im.srcset) {
18
+ l.setAttribute('imagesrcset', im.srcset);
19
+ if (im.sizes) l.setAttribute('imagesizes', im.sizes);
20
+ }
21
+ l.setAttribute('fetchpriority', 'high');
22
+ l.setAttribute('data-lbx', 'preload');
23
+ document.head.append(l);
24
+ }
25
+ // Bounded: the oldest of ours go (a long session of filters and queries).
26
+ const ours = document.head.querySelectorAll('link[data-lbx="preload"]');
27
+ for (let k = 0; k < ours.length - 24; k++) ours[k].remove();
28
+ } catch {}
29
+ };
@@ -10,9 +10,15 @@ import type { ImagesConfig, Result } from '../types';
10
10
  * A complete `<img>` (data-lbx-img: load marks, the fade-in). The placeholder
11
11
  * goes on its box: put `data-lbx-ph` and `style=${image(r, i).ph}` on the
12
12
  * element around it (the `img` helper does).
13
+ *
14
+ * `src` last: lit sets the attributes in this order, and WebKit starts an
15
+ * image's fetch the moment `src` is set, with the attributes it has then.
16
+ * With `loading` after it, every lazy image of a listing was fetched at once
17
+ * in Safari (and the listing's rehearsal fetched its `about:blank`, a console
18
+ * error); with `srcset` after it, the `src` was fetched besides the candidate.
13
19
  */
14
20
  export const imgOf = (im: ImageInfo, o: ImageOptions = {}) =>
15
- html`<img data-lbx-img src=${im.src} srcset=${im.srcset || nothing} sizes=${im.sizes || nothing} width=${im.width} height=${im.height} alt=${o.alt ?? ''} class=${o.class ?? nothing} title=${o.title ?? nothing} loading=${im.loading} fetchpriority=${im.fetchpriority ?? nothing} decoding="async" />`;
21
+ html`<img data-lbx-img loading=${im.loading} fetchpriority=${im.fetchpriority ?? nothing} decoding="async" width=${im.width} height=${im.height} alt=${o.alt ?? ''} class=${o.class ?? nothing} title=${o.title ?? nothing} sizes=${im.sizes || nothing} srcset=${im.srcset || nothing} src=${im.src} />`;
16
22
 
17
23
  /** The template helpers: `image(r, i, opts)` (the attributes) and `img(r, i, opts)` (an `<img>` in its placeholder box). */
18
24
  export const imageHelpers = (cfg: ImagesConfig = {}, lazy = false) => ({
@@ -15,6 +15,7 @@
15
15
 
16
16
  import type { ImagesConfig, Result } from '../types';
17
17
  import { first, safeUrl } from './result';
18
+ import { NAMED_PRELOAD } from './features';
18
19
 
19
20
  /** What a template binds on an `<img>` (or its `<source>`), and on the image's box (`ph`). */
20
21
  export interface ImageInfo {
@@ -51,7 +52,7 @@ const res = new Map<string, RegExp | null>();
51
52
  const regex = (s: string) => {
52
53
  if (!res.has(s))
53
54
  try {
54
- res.set(s, new RegExp(s));
55
+ res.set(s, RegExp(s));
55
56
  } catch {
56
57
  res.set(s, null);
57
58
  }
@@ -158,7 +159,7 @@ export const firstRowImages = (cfg: ImagesConfig = {}, results: Result[], all =
158
159
  const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
159
160
  /** `<link rel=preload as=image>` markup for a server-rendered listing: every image of the widest first row, each for the widths it is in the first row at. */
160
161
  export const preloadMarkup = (cfg: ImagesConfig = {}, results: Result[]) => {
161
- if (cfg.preload === false) return '';
162
+ if ((NAMED_PRELOAD ? cfg.preload === false : false)) return '';
162
163
  return firstRowImages(cfg, results, true)
163
164
  .map((im) => {
164
165
  const media = firstRowMedia(cfg, im.i);