@luigisbox/luma 0.4.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 (61) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +75 -8
  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 +1 -1
  8. package/src/analytics/pageview.ts +1 -1
  9. package/src/analytics/start.ts +3 -4
  10. package/src/analytics/wire.ts +1 -1
  11. package/src/api/ai.ts +32 -20
  12. package/src/api/token.ts +7 -7
  13. package/src/core/a11y.ts +6 -35
  14. package/src/core/context.ts +2 -0
  15. package/src/core/contrast-toggle.ts +32 -0
  16. package/src/core/default-button.ts +66 -0
  17. package/src/core/early-rules.ts +3 -2
  18. package/src/core/features.ts +109 -0
  19. package/src/core/frameworks-check.ts +32 -0
  20. package/src/core/frameworks.ts +35 -0
  21. package/src/core/image-load.ts +0 -26
  22. package/src/core/image-preload.ts +29 -0
  23. package/src/core/image-tags.ts +7 -1
  24. package/src/core/images.ts +3 -2
  25. package/src/core/integration.ts +42 -26
  26. package/src/core/listings.ts +9 -5
  27. package/src/core/locales.ts +2 -1
  28. package/src/core/own.ts +7 -3
  29. package/src/core/recommenders.ts +2 -2
  30. package/src/core/reconciler.ts +32 -23
  31. package/src/core/routes.ts +7 -6
  32. package/src/core/scheduler.ts +39 -5
  33. package/src/core/speculation.ts +2 -0
  34. package/src/core/stash.ts +15 -6
  35. package/src/core/styles.ts +6 -5
  36. package/src/core/support.ts +1 -1
  37. package/src/core/takeover.ts +6 -5
  38. package/src/core/templates.ts +5 -2
  39. package/src/core/tiles.ts +33 -10
  40. package/src/core/url.ts +6 -3
  41. package/src/core/widget-inits.ts +4 -1
  42. package/src/lit.ts +2 -1
  43. package/src/locales/cs.json +0 -1
  44. package/src/locales/de.json +0 -1
  45. package/src/locales/en.json +0 -1
  46. package/src/main.ts +70 -53
  47. package/src/types.ts +29 -9
  48. package/src/widgets/autocomplete/index.ts +58 -19
  49. package/src/widgets/listing/element.ts +81 -58
  50. package/src/widgets/listing/query.ts +6 -3
  51. package/src/widgets/listing/stash.ts +33 -16
  52. package/src/widgets/listing/state.ts +12 -0
  53. package/src/widgets/listing/view.ts +2 -1
  54. package/src/widgets/plp/route.ts +3 -2
  55. package/src/widgets/recommender/index.ts +3 -2
  56. package/src/widgets/search/direct.ts +39 -0
  57. package/src/widgets/search/form-url.ts +38 -34
  58. package/src/widgets/search/in-place.ts +43 -108
  59. package/src/widgets/search/index.ts +7 -2
  60. package/src/widgets/search/speculate.ts +48 -27
  61. package/src/widgets/search/submit.ts +136 -0
@@ -105,7 +105,8 @@ guessed alt text, and forced colours and the OS setting are honoured.
105
105
  the fallback products another, named by their heading and the note that
106
106
  they are not results; the help box's phone link is a plain link. "Show all
107
107
  products" is a real link (`href`: the
108
- search page the site's form would submit to) and the open dropdown's one
108
+ search page the site's form would submit to, or with `search.queryUrl` the
109
+ query's page, the one Enter loads) and the open dropdown's one
109
110
  tab stop: Tab from the input reaches it (the options' selection cleared:
110
111
  one thing lit), Shift+Tab goes back, Escape closes and goes back, Tab moves
111
112
  on to the page's next control after the input. It is also the arrow keys'
@@ -165,7 +166,10 @@ guessed alt text, and forced colours and the OS setting are honoured.
165
166
  opened from the keyboard, focus goes to its selected item
166
167
  (`aria-selected`/`aria-current`/`aria-checked`), else the first; arrows move
167
168
  through its items; Escape closes it and focus returns to the button; picking
168
- an item returns focus to the button; Tab out of it closes it.
169
+ an item returns focus to the button; Tab out of it closes it. The build
170
+ includes menus only where a file of the integration's repository (a script or `.html` file, not in `node_modules`, `dist` or a hidden folder) has `data-lbx-menu` (written literally, as in a template). A
171
+ `<details data-lbx-dropdown>` menu closes on a press outside it and on
172
+ picking an item; the same holds for it with `data-lbx-dropdown`.
169
173
  - **The drawer** (`[data-lbx-drawer="open|close"]`): `aria-expanded` on its
170
174
  openers. With a panel marked `[data-lbx-drawer-panel]`, while open, the panel
171
175
  is a modal dialog (`role="dialog"`, `aria-modal="true"`, named "Filters",
@@ -176,6 +180,9 @@ guessed alt text, and forced colours and the OS setting are honoured.
176
180
  focus staying on the control. With `facetsApply: 'staged'` its
177
181
  changes wait for `[data-lbx-drawer="apply"]`; closing it otherwise discards
178
182
  them (the scope's `pending` says whether there is anything to apply).
183
+ The build includes the drawer only where a file of the integration's
184
+ repository (a script or `.html` file, not in `node_modules`, `dist` or a hidden folder) has `data-lbx-drawer` (written
185
+ literally, as in a template).
179
186
  - **Facet groups** (built-in template): each group's title is a `<button
180
187
  aria-expanded aria-controls>` controlling its values (WAI-ARIA APG
181
188
  accordion), focus staying on it. A group's search box
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luigisbox/luma",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Luma: Luigi's Box frontend integrations (autocomplete, search, category listings, recommenders) on the Luigi's Box AI API. Pre-release.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -29,7 +29,8 @@
29
29
  "snippet/early-hold.min.js",
30
30
  "docs/ACCESSIBILITY.md",
31
31
  "docs/INTEGRATION-PERFORMANCE.md",
32
- "docs/PARITY.md"
32
+ "docs/PARITY.md",
33
+ "CHANGELOG.md"
33
34
  ],
34
35
  "publishConfig": {
35
36
  "access": "public"
@@ -71,7 +71,7 @@ export const reader = <E extends EventName, C>(event: E, f: (ctx: C) => Reads[E]
71
71
  /** `.read(r)` on event `name`: `r`, or an error when it is a reader of another event. */
72
72
  export const checked = (name: string, r: { event?: string }) => {
73
73
  const e = name.startsWith('recommender:') ? 'recommender' : name;
74
- if (r.event && r.event !== e) throw new Error(`analytics: collect.${e}.read(): a reader of ${r.event}`);
74
+ if (r.event && r.event !== e) throw Error(`analytics: collect.${e}.read(): a reader of ${r.event}`);
75
75
  };
76
76
 
77
77
  export interface Chain<E extends EventName, C> {
@@ -42,7 +42,7 @@ export const pageView = {
42
42
  },
43
43
  read(r) {
44
44
  checked('pageView', r);
45
- if (chained) throw new Error('analytics: one collect.pageView chain per setup');
45
+ if (chained) throw Error('analytics: one collect.pageView chain per setup');
46
46
  chained = true;
47
47
  const ref = (ctx: C) => {
48
48
  try {
@@ -33,10 +33,9 @@ export const start = async (o: StartOptions) => {
33
33
  if (started) return;
34
34
  started = true;
35
35
  state.g = o.config._global;
36
- env.owns = o.owns;
37
- env.visit = o.visit;
38
- env.onUrlChange = o.onUrlChange;
39
- env.kept = o.kept;
36
+ // One call, not a read per field: a setup that uses none of them leaves
37
+ // Rollup no property reads to keep (it keeps a read: it may be a getter).
38
+ Object.assign(env, o);
40
39
  // The setup (`_global` first). Its chains register as it runs.
41
40
  for (const [p, f] of Object.entries(o.files).sort(([a], [b]) => (a.includes('/_global/') ? -1 : b.includes('/_global/') ? 1 : 0)))
42
41
  try {
@@ -51,7 +51,7 @@ export const readDl = (x: any): DlEntry | null => {
51
51
  return null;
52
52
  };
53
53
 
54
- const IGNORE = new RegExp(`${TRACKING}|^${P.replace('.', '\\.')}`);
54
+ const IGNORE = RegExp(`${TRACKING}|^${P.replace('.', '\\.')}`);
55
55
  /**
56
56
  * The page a URL shows, for pageviews: without tracking params, the hash and
57
57
  * our own `lbx.*` (a filter, a sort on our listing is the same page). A
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.
@@ -36,7 +37,7 @@ const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
37
  // reserveSelector: set by loadConfig (default: the first anchor).
37
38
 
38
39
  /** Capture groups in a regex source (its alternation with the empty string always matches). */
39
- const groups = (re: string) => new RegExp(`${re}|`).exec('')!.length - 1;
40
+ const groups = (re: string) => RegExp(`${re}|`).exec('')!.length - 1;
40
41
 
41
42
  /**
42
43
  * Only what the snippet can express (no custom readQuery/active): a path
@@ -61,7 +62,7 @@ export const searchRule = (c: SearchConfigType, hostCss: string): EarlyRoute | n
61
62
  * the unclaimed hold at boot; `earlyHold: false` opts out where it is not.
62
63
  */
63
64
  export const plpRule = (c: PlpConfigType, hostCss: string): EarlyRoute | null => {
64
- if (c.earlyHold === false) return null;
65
+ if ((NAMED_EARLY_HOLD ? c.earlyHold === false : false)) return null;
65
66
  const pm = c.params ?? {};
66
67
  // Their page param too: their page 1 is ours. A later page of theirs is
67
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;