@luigisbox/luma 0.4.0 → 0.6.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 (66) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +76 -9
  3. package/build/baseline.mjs +2 -2
  4. package/build/vite.mjs +255 -20
  5. package/docs/ACCESSIBILITY.md +31 -5
  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 +1 -1
  44. package/src/locales/de.json +1 -1
  45. package/src/locales/en.json +1 -1
  46. package/src/main.ts +70 -53
  47. package/src/types.ts +48 -14
  48. package/src/widgets/autocomplete/index.ts +167 -34
  49. package/src/widgets/autocomplete/style-autocomplete.css +16 -8
  50. package/src/widgets/autocomplete/template-autocomplete-empty.ts +1 -1
  51. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +13 -9
  52. package/src/widgets/listing/applied.ts +87 -0
  53. package/src/widgets/listing/element.ts +279 -78
  54. package/src/widgets/listing/intent.ts +15 -0
  55. package/src/widgets/listing/query.ts +6 -3
  56. package/src/widgets/listing/stash.ts +33 -16
  57. package/src/widgets/listing/state.ts +12 -0
  58. package/src/widgets/listing/view.ts +2 -1
  59. package/src/widgets/plp/route.ts +3 -2
  60. package/src/widgets/recommender/index.ts +3 -2
  61. package/src/widgets/search/direct.ts +39 -0
  62. package/src/widgets/search/form-url.ts +38 -34
  63. package/src/widgets/search/in-place.ts +43 -108
  64. package/src/widgets/search/index.ts +7 -2
  65. package/src/widgets/search/speculate.ts +48 -27
  66. package/src/widgets/search/submit.ts +136 -0
package/src/core/tiles.ts CHANGED
@@ -22,8 +22,10 @@
22
22
  // the click may leave the page: so each is first queued in sessionStorage, in
23
23
  // the click's own task, where analytics claims it, on this page or the next.
24
24
  // Not where the integration has no analytics (`_global.analytics` false: the
25
- // entry sets it where there is no _analytics.ts).
25
+ // entry sets it where there is no _analytics.ts), and not in its build
26
+ // (core/features.ts ANALYTICS): there a tile only puts products in the cart.
26
27
 
28
+ import { ANALYTICS, CART } from './features';
27
29
  import { reportError } from './health';
28
30
  import { session } from './storage';
29
31
  import type { GlobalConfigType, Result } from '../types';
@@ -56,7 +58,7 @@ export interface Watched {
56
58
  /** The queue analytics claims from (src/analytics/triggers.ts). */
57
59
  export const TILES = 'lbx:tiles';
58
60
  const announce = (g: GlobalConfigType, e: Omit<TileEvent, 'id' | 'url' | 'at'>) => {
59
- if (g.analytics === false) return;
61
+ if (!ANALYTICS || g.analytics === false) return;
60
62
  const d = { ...e, id: crypto.randomUUID(), url: location.href, at: Date.now() };
61
63
  session.set(TILES, [...(session.get<TileEvent[]>(TILES) ?? []), d].slice(-20));
62
64
  try {
@@ -68,11 +70,33 @@ const announce = (g: GlobalConfigType, e: Omit<TileEvent, 'id' | 'url' | 'at'>)
68
70
  const reference = (r: Result) => `${r.type || 'product'}/@id:${r.id}`;
69
71
 
70
72
  /** A result clicked (a link to it, or our own navigation: the autocomplete). */
71
- export const picked = (r: Result, index: number, list: string, g: GlobalConfigType) => announce(g, { kind: 'click', reference: reference(r), result: r, index, list });
73
+ export const picked = (r: Result, index: number, list: string, g: GlobalConfigType) => (ANALYTICS ? announce(g, { kind: 'click', reference: reference(r), result: r, index, list }) : undefined);
74
+
75
+ /**
76
+ * A product click, announced: only ever announced, so not in a build without
77
+ * analytics (core/features.ts). Out here, not in watch()'s try: Rollup folds
78
+ * no flag inside a try (its tryCatchDeoptimization).
79
+ */
80
+ const clicked = ANALYTICS
81
+ ? (t: Element, tile: Element, at: number, n: number, results: readonly Result[], w: Watched) => {
82
+ const a = t.closest<HTMLAnchorElement>('a[href]');
83
+ if (!a || !tile.contains(a)) return;
84
+ const r = resultOf(a, at, n, results);
85
+ if (r) picked(r.r, r.i, w.list, w.global);
86
+ }
87
+ : null;
88
+
89
+ /**
90
+ * The cart button a click is on: only where the integration's templates have
91
+ * one (core/features.ts CART). Out of watch()'s try, as `clicked`.
92
+ */
93
+ const cartOf = CART ? (t: Element) => t.closest<HTMLElement>('[data-lbx-cart]') : null;
72
94
 
73
95
  const watched = new WeakSet<Element>();
74
96
  /** Product clicks and add-to-carts inside `root` (once per root). */
75
97
  export const watch = (root: Element, w: Watched) => {
98
+ // Nothing to see: no cart button in the integration's templates, no analytics (core/features.ts).
99
+ if (!CART && !ANALYTICS) return;
76
100
  if (watched.has(root)) return;
77
101
  watched.add(root);
78
102
  const on = (e: MouseEvent) => {
@@ -84,12 +108,9 @@ export const watch = (root: Element, w: Watched) => {
84
108
  const results = w.results();
85
109
  const tiles = [...tile.parentElement!.children];
86
110
  const at = tiles.indexOf(tile);
87
- const cart = t!.closest<HTMLElement>('[data-lbx-cart]');
111
+ const cart = cartOf?.(t!);
88
112
  if (cart && tile.contains(cart)) return void (e.type === 'click' && toCart(e, cart, tile, at, tiles.length, results, w));
89
- const a = t!.closest<HTMLAnchorElement>('a[href]');
90
- if (!a || !tile.contains(a)) return;
91
- const r = resultOf(a, at, tiles.length, results);
92
- if (r) picked(r.r, r.i, w.list, w.global);
113
+ if (clicked) clicked(t!, tile, at, tiles.length, results, w);
93
114
  } catch (err) {
94
115
  reportError('tile click', err);
95
116
  }
@@ -126,8 +147,10 @@ const toCart = (e: MouseEvent, el: HTMLElement, tile: Element, at: number, n: nu
126
147
  const hook = w.global.addToCart;
127
148
  if (typeof hook !== 'function') return reportError('add to cart', '[data-lbx-cart] needs _global.addToCart (_init.ts or _global/_widget.ts) to put the product in the site\'s cart');
128
149
  const count = Math.max(1, Math.round(Number(el.dataset.lbxCart) || 1));
129
- const price = typeof hit.r.c.price === 'number' ? hit.r.c.price : undefined;
130
- announce(w.global, { kind: 'add_to_cart', reference: reference(hit.r), result: hit.r, index: hit.i, list: w.list, count, ...(price !== undefined ? { price } : {}) });
150
+ if (ANALYTICS) {
151
+ const price = typeof hit.r.c.price === 'number' ? hit.r.c.price : undefined;
152
+ announce(w.global, { kind: 'add_to_cart', reference: reference(hit.r), result: hit.r, index: hit.i, list: w.list, count, ...(price !== undefined ? { price } : {}) });
153
+ }
131
154
  try {
132
155
  const p = hook(hit.r, { count, el, event: e });
133
156
  if (p && typeof (p as Promise<unknown>).catch === 'function') (p as Promise<unknown>).catch((err) => reportError('addToCart', err));
package/src/core/url.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  // null-state entries altogether.
19
19
 
20
20
  import { canon, markOwnEntry } from './entries';
21
+ import { ASTRO, NEXT, SVELTEKIT, VUE } from './frameworks';
21
22
 
22
23
  interface HostRouter {
23
24
  push(to: string): unknown;
@@ -42,6 +43,7 @@ const samePath = (a: string, b: string) => {
42
43
  };
43
44
 
44
45
  const vueRouter = (): HostRouter | null => {
46
+ if (!VUE) return null;
45
47
  // Vue 3 (the app on its mount element: every one, a widget app may come first), Vue 2 (the root instance), Nuxt 2.
46
48
  const candidates = [
47
49
  ...[...document.querySelectorAll('#__nuxt, #app, [data-v-app]')].map((el: any) => el.__vue_app__?.config?.globalProperties?.$router ?? el.__vue__?.$router),
@@ -68,6 +70,7 @@ const vueRouter = (): HostRouter | null => {
68
70
 
69
71
  /** Next.js pages router. The app router (it sets window.next.router too) follows raw history writes by itself. */
70
72
  const nextRouter = (): HostRouter | null => {
73
+ if (!NEXT) return null;
71
74
  const w = window as any;
72
75
  if (w.next?.appDir || !w.__NEXT_DATA__) return null;
73
76
  const r = w.next?.router;
@@ -81,7 +84,7 @@ const hostRouter = () => vueRouter() ?? nextRouter();
81
84
  /** A mounted router found (usable, or hash mode), or waited for one in vain: writes are not queued from then on. */
82
85
  let settled = false;
83
86
  /** A server-rendered Vue app whose client app has not mounted yet (no router to talk to). vueRouter() may settle it (a hash-mode router: null, settled), hence `settled` is read again after it. */
84
- const vueNotMountedYet = () => !settled && !!((window as any).__NUXT__ || document.querySelector('#__nuxt, [data-server-rendered]')) && !vueRouter() && !settled;
87
+ const vueNotMountedYet = () => (VUE ? !settled && !!((window as any).__NUXT__ || document.querySelector('#__nuxt, [data-server-rendered]')) && !vueRouter() && !settled : false);
85
88
 
86
89
  /** The router's own path for a URL: without its base, which it adds itself. */
87
90
  const routerPath = (r: HostRouter, url: URL) => {
@@ -140,8 +143,8 @@ const SK_NAVIGATION = 'sveltekit:navigation';
140
143
  const entryState = () => {
141
144
  const s = history.state;
142
145
  if (!s || typeof s !== 'object') return null;
143
- if (s[SK_HISTORY] && SK_NAVIGATION in s) return { [SK_HISTORY]: s[SK_HISTORY], [SK_NAVIGATION]: s[SK_NAVIGATION] };
144
- if (typeof s.index === 'number' && document.querySelector('meta[name="astro-view-transitions-enabled"]')) return { index: s.index, scrollX, scrollY };
146
+ if (SVELTEKIT && s[SK_HISTORY] && SK_NAVIGATION in s) return { [SK_HISTORY]: s[SK_HISTORY], [SK_NAVIGATION]: s[SK_NAVIGATION] };
147
+ if (ASTRO && typeof s.index === 'number' && document.querySelector('meta[name="astro-view-transitions-enabled"]')) return { index: s.index, scrollX, scrollY };
145
148
  return null;
146
149
  };
147
150
 
@@ -3,6 +3,7 @@
3
3
  // entry: the hooks' modules are in the widget's own chunks anyway. No DOM:
4
4
  // the server renderer runs them for its listings too.
5
5
 
6
+ import { WIDGET_INITS } from './features';
6
7
  import { folderOf, slot, type IntegrationFiles } from './integration';
7
8
  import type { LumaApi, LumaConfigType, LumaWidgetInitType } from '../types';
8
9
 
@@ -10,9 +11,11 @@ const widgetInitsRun = new WeakMap<object, Map<string, Promise<void>>>();
10
11
  /**
11
12
  * The `_widget.ts` of a widget's folders, in folder order, each run once per
12
13
  * config (a folder shared by widgets, `_global`, runs for the first): before
13
- * the widget's context is made from the config they complete.
14
+ * the widget's context is made from the config they complete. An integration
15
+ * without a _widget.ts (core/features.ts) builds none of it: 121 B gzip.
14
16
  */
15
17
  export const loadWidgetInits = (config: LumaConfigType, files: Pick<IntegrationFiles, 'widgetInits'>, folders: readonly string[], version: string) => {
18
+ if (!WIDGET_INITS) return Promise.resolve();
16
19
  let ran = widgetInitsRun.get(config);
17
20
  if (!ran) widgetInitsRun.set(config, (ran = new Map()));
18
21
  const Luma: LumaApi = { version, config };
package/src/lit.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // lit-html for templates: `import { html, repeat } from '@/lit'` in an
2
2
  // integration, so every template uses the framework's one copy of lit.
3
- // Only what a template uses ends up in the bundle. Note for form controls:
3
+ // Only what a template uses ends up in the bundle (a directive no template
4
+ // uses too: build/vite.mjs pureDirectives). Note for form controls:
4
5
  // bind `.checked` / `.selected` plainly (not with live()): lit then leaves a
5
6
  // control the user just changed alone until the state it drives agrees.
6
7
  export { html, svg, nothing } from 'lit-html';
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "_name": "Čeština",
3
3
  "autocomplete": {
4
- "showAllResults": "Zobrazit všechny výsledky",
5
4
  "empty": "Pro „{query}“ jsme nic nenašli",
6
5
  "types": {
7
6
  "product": "Produkty",
@@ -54,6 +53,7 @@
54
53
  "label": "Výsledky vyhledávání",
55
54
  "shown": "Zobrazeno {shown} z {total}",
56
55
  "drawer": "Filtry",
56
+ "applied": "Filtry použity · {results}",
57
57
  "removeFilter": "Odebrat filtr: {label}",
58
58
  "chosen": "Vybrané filtry",
59
59
  "clearAll": "Zrušit vše",
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "_name": "Deutsch",
3
3
  "autocomplete": {
4
- "showAllResults": "Alle Ergebnisse anzeigen",
5
4
  "empty": "Keine Treffer für „{query}“",
6
5
  "types": {
7
6
  "product": "Produkte",
@@ -52,6 +51,7 @@
52
51
  "label": "Suchergebnisse",
53
52
  "shown": "{shown} von {total} angezeigt",
54
53
  "drawer": "Filter",
54
+ "applied": "Filter angewendet · {results}",
55
55
  "removeFilter": "Filter entfernen: {label}",
56
56
  "chosen": "Ausgewählte Filter",
57
57
  "clearAll": "Alle entfernen",
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "_name": "English",
3
3
  "autocomplete": {
4
- "showAllResults": "See all results",
5
4
  "empty": "No results for “{query}”",
6
5
  "types": {
7
6
  "product": "Products",
@@ -52,6 +51,7 @@
52
51
  "label": "Search results",
53
52
  "shown": "Showing {shown} of {total}",
54
53
  "drawer": "Filters",
54
+ "applied": "Filters applied · {results}",
55
55
  "removeFilter": "Remove filter: {label}",
56
56
  "chosen": "Selected filters",
57
57
  "clearAll": "Clear all",
package/src/main.ts CHANGED
@@ -25,6 +25,7 @@ import { plpAccepts, searchAccepts, searchReadQuery } from './core/routes';
25
25
  import { clearEarlyRules, earlyHold, plpRule, searchRule, writeEarlyRules, type EarlyRoute } from './core/early-rules';
26
26
  import { askChallenge, storedToken } from './api/token-key';
27
27
  import { loadDictionary } from './core/locales';
28
+ import { ANALYTICS, AUTOCOMPLETE, NAMED_PRECONNECT_IMAGES, NAMED_PREFETCH_WHILE_TYPING, NAMED_SPECULATION, PLP, QUERY_URL, RECOMMENDER, SEARCH, SSR } from './core/features';
28
29
  import { keepDl } from './core/kept';
29
30
  import './core/boot-state'; // the page's state at boot, for lbx-place's reconciler
30
31
  import { onFlush, schedule } from './core/scheduler';
@@ -32,7 +33,7 @@ import { onUrlChange, startSensing } from './core/sense';
32
33
  import { layered, setCss, whenApplied } from './core/styles';
33
34
  import type { ListingRoute } from './core/listings';
34
35
  import type { Reco } from './core/recommenders';
35
- import type { GlobalConfigType, LumaConfigType, Result } from './types';
36
+ import type { GlobalConfigType, LumaConfigType, Result, SearchConfigType } from './types';
36
37
 
37
38
  declare const __VERSION__: string;
38
39
  // Replaced at build time; the dev server (the loader's development mode) does not replace it.
@@ -54,6 +55,8 @@ export interface WidgetContext<C = any> {
54
55
  result: (hit: any) => Result;
55
56
  /** Helpers every template scope gets. */
56
57
  helpers: Record<string, unknown>;
58
+ /** The search page of a phrase where the site's server redirects its form there (`search.queryUrl`): the autocomplete's "show all" link. */
59
+ queryUrl?: SearchConfigType['queryUrl'];
57
60
  }
58
61
 
59
62
  export const startIntegration = async (files: IntegrationFiles) => {
@@ -78,7 +81,7 @@ export const startIntegration = async (files: IntegrationFiles) => {
78
81
  // its solver is plain JS since (api/token.ts), the rule stands. Said
79
82
  // once, with the fix; nothing starts and the site keeps its own UI.
80
83
  if (!isSecureContext)
81
- return void (reportError('boot', `not a secure context (${location.origin}): Luma runs on HTTPS pages only. Serve the site over HTTPS; Luma's widgets did not start (the site keeps its own).`), off());
84
+ return void (reportError('boot', `not a secure context (${location.origin}): Luma runs on HTTPS pages only; its widgets did not start (the site keeps its own).`), off());
82
85
  return boot(config, files);
83
86
  }
84
87
  } catch (e) {
@@ -105,11 +108,12 @@ type Loaded = { mod: any; ctx: WidgetContext };
105
108
  const run = (where: string, p: Promise<Loaded>, f: (m: Loaded) => unknown, onFail?: () => void) =>
106
109
  p.then(f, () => onFail?.()).catch((e) => reportError(where, e));
107
110
 
111
+ // A widget the integration has no folder for (core/features.ts): no chunk.
108
112
  const modules = {
109
- autocomplete: () => import('./widgets/autocomplete'),
110
- search: () => import('./widgets/search'),
111
- plp: () => import('./widgets/plp'),
112
- recommender: () => import('./widgets/recommender'),
113
+ autocomplete: AUTOCOMPLETE ? () => import('./widgets/autocomplete') : null,
114
+ search: SEARCH ? () => import('./widgets/search') : null,
115
+ plp: PLP ? () => import('./widgets/plp') : null,
116
+ recommender: RECOMMENDER ? () => import('./widgets/recommender') : null,
113
117
  };
114
118
  type Kind = keyof typeof modules;
115
119
 
@@ -121,7 +125,7 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
121
125
  // The product image host, learnt from an earlier listing (widgets/listing/instant.ts).
122
126
  let img: string | null = null;
123
127
  try {
124
- img = g.preconnectImages !== false ? localStorage.getItem('lbx:img') : null;
128
+ img = (NAMED_PRECONNECT_IMAGES ? g.preconnectImages !== false : true) ? localStorage.getItem('lbx:img') : null;
125
129
  } catch {}
126
130
  const api = `https://api.${region}.luigisbox.ai`;
127
131
  const preconnect = (host: string, cors = true) => {
@@ -160,6 +164,9 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
160
164
  else addEventListener('load', idle, { once: true });
161
165
  };
162
166
  afterLoad(warm);
167
+ // A framework on the page whose support the build left out (clientConfig
168
+ // `framework`): said in development (core/frameworks-check.ts, not in a build).
169
+ if (import.meta.env.DEV) afterLoad(() => void import('./core/frameworks-check').then((m) => m.checkFrameworks()).catch((e) => reportError('frameworks check', e)));
163
170
  startSensing();
164
171
 
165
172
  // Analytics starts once the page has loaded, or with the first widget (its
@@ -177,7 +184,7 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
177
184
  startAnalytics();
178
185
  const support = import('./core/support');
179
186
  const p = Promise.all([
180
- modules[kind]() as Promise<any>,
187
+ modules[kind]!() as Promise<any>,
181
188
  support,
182
189
  widgetFiles(kind, [...folders], files.templates),
183
190
  widgetFiles(kind, [...folders], files.styles),
@@ -188,7 +195,7 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
188
195
  ]).then(([mod, s, tpls, css, dict]) => {
189
196
  if (mod.css) setCss(`default:${kind}`, layered(mod.css));
190
197
  // Product pages on intent (default on, core/speculation.ts): our links come with the first widget.
191
- if (g.speculation !== false) s.speculate(g, onUrlChange);
198
+ if ((NAMED_SPECULATION ? g.speculation !== false : true)) s.speculate(g, onUrlChange);
192
199
  const ctx = s.widgetContext(s, config, { id, folders: [...folders], app, scopes: [...scopes] }, mod.templates, tpls, css, dict);
193
200
  mark(`loaded ${id}`);
194
201
  return { mod, ctx };
@@ -207,7 +214,7 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
207
214
  // --- autocomplete: the first interaction with the search input loads it
208
215
  const ac = config.autocomplete;
209
216
  // The dropdown is a popover (top layer): without it (Safari < 17, Firefox < 125) the site keeps its own.
210
- if (ac && !ac.disabled && g.inputSelector && 'showPopover' in HTMLElement.prototype) {
217
+ if (AUTOCOMPLETE && ac && !ac.disabled && g.inputSelector && 'showPopover' in HTMLElement.prototype) {
211
218
  const css = hostCss('autocomplete');
212
219
  const TYPES = ['pointerdown', 'focusin', 'input'];
213
220
  const loadAc = () => load('autocomplete', 'autocomplete', ['_global', 'autocomplete'], ac, ['autocomplete']);
@@ -297,9 +304,11 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
297
304
  clearTimeout(lateTimer);
298
305
  lateTimer = window.setTimeout(() => (recently = false), LATE_MS);
299
306
  };
300
- late();
301
- if (document.readyState !== 'complete') addEventListener('load', late, { once: true });
302
- onUrlChange(late);
307
+ if (SEARCH || PLP || RECOMMENDER) {
308
+ late();
309
+ if (document.readyState !== 'complete') addEventListener('load', late, { once: true });
310
+ onUrlChange(late);
311
+ }
303
312
  const recent = () => recently;
304
313
 
305
314
  // --- listings: search results and category pages. Their boot (takeover,
@@ -310,8 +319,8 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
310
319
  // timer), or writes the hold CSS itself before first paint and in the same
311
320
  // task as an SPA's URL change into the route; the takeover continues that hold.
312
321
  const routes: ListingRoute[] = [];
313
- const search = config.search && !config.search.disabled ? config.search : null;
314
- const plp = config.plp && !config.plp.disabled ? config.plp : null;
322
+ const search = SEARCH ? (config.search && !config.search.disabled ? config.search : null) : null;
323
+ const plp = PLP ? (config.plp && !config.plp.disabled ? config.plp : null) : null;
315
324
  if (search) setUp('search', () => routes.push({ kind: 'search', cfg: search, accepts: searchAccepts(search), hostCss: hostCss('search') }));
316
325
  if (plp) setUp('plp', () => routes.push({ kind: 'plp', cfg: plp, accepts: plpAccepts(plp), hostCss: hostCss('plp') }));
317
326
  const early = earlyHold();
@@ -359,7 +368,7 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
359
368
  any = true;
360
369
  // Theirs shows at once where we failed too often; a server-rendered page holds nothing.
361
370
  // Moving within the route (their filters) keeps what is shown.
362
- if (held.has(r.kind) || (spa && prev && r.accepts(prev)) || failures(r.kind) >= MAX_FAILURES || document.querySelector(`lbx-${r.kind}[data-lbx-ssr], template[data-lbx-ssr="${r.kind}"]`)) continue;
371
+ if (held.has(r.kind) || (spa && prev && r.accepts(prev)) || failures(r.kind) >= MAX_FAILURES || (SSR ? !!document.querySelector(`lbx-${r.kind}[data-lbx-ssr], template[data-lbx-ssr="${r.kind}"]`) : false)) continue;
363
372
  const claimed = !spa && !!early?.claim?.(r.kind);
364
373
  if (!claimed && !spa && painted()) continue; // booted after first paint: theirs stays until ours renders
365
374
  // Ours too where the early hold is claimed: with the height ours had on this
@@ -376,37 +385,44 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
376
385
  // Loading adds modulepreload links to <head>: not while it is being parsed.
377
386
  for (const r of routes) if (r.accepts(u)) whenApplied(() => void loadListing(r.kind).catch(() => {}));
378
387
  };
379
- enter(new URL(location.href), false);
380
- onUrlChange((u) => enter(u, true));
381
- // A page of a listing route's URL shape whose hook accepts it only later
382
- // (their data layer, after our boot): its token from now, not from then
383
- // (a production storefront's category pages: ~0.3 s sooner).
384
- if (routes.some((r) => r.cfg.urlMatch && setUp(`${r.kind} urlMatch`, () => new RegExp(r.cfg.urlMatch!).test(location.pathname)))) warm();
385
- // A route whose hook reads the page (an `active()` on their data layer or a
386
- // node) may accept it only once the page has that: looked at again on
387
- // reconciles (while `recent()`) until the listings' boot is asked for (a failed one: on the next URL).
388
- if (routes.length) onFlush(() => recent() && !booting && failedOn !== location.href && enter(new URL(location.href), false));
389
- // Results ready on Enter: the first input into the search field loads the prefetch-while-typing chunk.
390
- if (search && routes.some((r) => r.kind === 'search') && search.prefetchWhileTyping !== false && g.inputSelector) {
388
+ if (SEARCH || PLP) {
389
+ enter(new URL(location.href), false);
390
+ onUrlChange((u) => enter(u, true));
391
+ // A page of a listing route's URL shape whose hook accepts it only later
392
+ // (their data layer, after our boot): its token from now, not from then
393
+ // (a production storefront's category pages: ~0.3 s sooner).
394
+ if (routes.some((r) => r.cfg.urlMatch && setUp(`${r.kind} urlMatch`, () => RegExp(r.cfg.urlMatch!).test(location.pathname)))) warm();
395
+ // A route whose hook reads the page (an `active()` on their data layer or a
396
+ // node) may accept it only once the page has that: looked at again on
397
+ // reconciles (while `recent()`) until the listings' boot is asked for (a failed one: on the next URL).
398
+ if (routes.length) onFlush(() => recent() && !booting && failedOn !== location.href && enter(new URL(location.href), false));
399
+ }
400
+ // Results ready on Enter, and Enter straight to the query's page (search.queryUrl):
401
+ // the first input into the search field loads the typing chunk (both are in it;
402
+ // the second only where the integration names the hook: QUERY_URL).
403
+ if (search && routes.some((r) => r.kind === 'search') && ((NAMED_PREFETCH_WHILE_TYPING ? search.prefetchWhileTyping !== false : true) || (QUERY_URL ? !!search.queryUrl : false)) && g.inputSelector) {
391
404
  const first = (e: Event) => {
392
405
  const i = e.composedPath?.()[0] ?? e.target;
393
406
  if (!(i instanceof HTMLInputElement) || !i.matches(g.inputSelector!)) return;
394
407
  removeEventListener('input', first, true);
395
- import('./widgets/search/speculate').then((m) => m.startSpeculation(g, search, i)).catch((err) => reportError('search prefetch', err));
408
+ if (NAMED_PREFETCH_WHILE_TYPING ? search.prefetchWhileTyping !== false : true) import('./widgets/search/speculate').then((m) => m.startSpeculation(g, search, i)).catch((err) => reportError('search prefetch', err));
409
+ if (QUERY_URL && search.queryUrl) import('./widgets/search/direct').then((m) => m.startDirect(search, g.inputSelector!)).catch((err) => reportError('search direct', err));
396
410
  };
397
411
  addEventListener('input', first, true);
398
412
  }
399
413
  // A hold no route of ours continues (stale rules, a URL the snippet
400
414
  // over-matched) ends now, not at its timeout; a held route's ends with its takeover.
401
- if (!held.size) whenApplied(() => early?.drop?.());
415
+ if (!(SEARCH || PLP) || !held.size) whenApplied(() => early?.drop?.());
402
416
  // Rules for the inline early-hold snippet's next hard load (src/core/early-rules.ts).
403
417
  // Dev bundles boot late (unbundled modules): a longer snippet timeout there.
404
418
  // A route failing this session gets no rule: the snippet must not hold it on every page view.
405
419
  const ok = (kind: string) => routes.some((r) => r.kind === kind) && failures(kind) < MAX_FAILURES;
406
- writeEarlyRules(
407
- [search && ok('search') ? searchRule(search, hostCss('search')) : null, plp && ok('plp') ? plpRule(plp, hostCss('plp')) : null].filter((r): r is EarlyRoute => !!r),
408
- VERSION === 'dev' ? 6000 : 1500,
409
- );
420
+ if (SEARCH || PLP)
421
+ writeEarlyRules(
422
+ [search && ok('search') ? searchRule(search, hostCss('search')) : null, plp && ok('plp') ? plpRule(plp, hostCss('plp')) : null].filter((r): r is EarlyRoute => !!r),
423
+ VERSION === 'dev' ? 6000 : 1500,
424
+ );
425
+ else clearEarlyRules(); // none to hold (a listing folder removed: its stale rules go)
410
426
 
411
427
  // --- recommenders: their triggers and placement (core/recommenders.ts, in
412
428
  // lbx-place with the reconciler) load on a page an inline one's urlMatch /
@@ -416,22 +432,23 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
416
432
  const pages: ((u: URL) => boolean)[] = [];
417
433
  /** Our recommenders by name, and the pages each runs on: where ours runs, the site's of that name is not tracked. */
418
434
  const recoPages = new Map<string, (u: URL) => boolean>();
419
- for (const [name, rc] of Object.entries(config.recommenders ?? {})) {
420
- if (rc.disabled) continue;
421
- const id = `reco-${name}`;
422
- const folders = ['_global', `recommender ${name}`];
423
- // style-host.css of the folder: e.g. hides the host's own box our widget replaces
424
- // (scoped by our element's state, so it is inert where the widget is not).
425
- const css = hostCss(`recommender ${name}`);
426
- if (css) setCss(`host-${id}`, css);
427
- // The popup is a popover (top layer): without the API there is no popup.
428
- if (rc.confirm && !('showPopover' in HTMLElement.prototype)) continue;
429
- recos.push({ id, rc, get: () => load('recommender', id, folders, rc, ['recommender']) });
430
- const re = !rc.confirm && rc.urlMatch ? setUp(id, () => new RegExp(rc.urlMatch!)) : null;
431
- pages.push(rc.confirm ? () => true : (u) => (rc.active ? rc.active(u) : !re || re.test(u.pathname)));
432
- recoPages.set(name, pages[pages.length - 1]);
433
- }
434
- if (recos.length) {
435
+ if (RECOMMENDER)
436
+ for (const [name, rc] of Object.entries(config.recommenders ?? {})) {
437
+ if (rc.disabled) continue;
438
+ const id = `reco-${name}`;
439
+ const folders = ['_global', `recommender ${name}`];
440
+ // style-host.css of the folder: e.g. hides the host's own box our widget replaces
441
+ // (scoped by our element's state, so it is inert where the widget is not).
442
+ const css = hostCss(`recommender ${name}`);
443
+ if (css) setCss(`host-${id}`, css);
444
+ // The popup is a popover (top layer): without the API there is no popup.
445
+ if (rc.confirm && !('showPopover' in HTMLElement.prototype)) continue;
446
+ recos.push({ id, rc, get: () => load('recommender', id, folders, rc, ['recommender']) });
447
+ const re = !rc.confirm && rc.urlMatch ? setUp(id, () => RegExp(rc.urlMatch!)) : null;
448
+ pages.push(rc.confirm ? () => true : (u) => (rc.active ? rc.active(u) : !re || re.test(u.pathname)));
449
+ recoPages.set(name, pages[pages.length - 1]);
450
+ }
451
+ if (RECOMMENDER && recos.length) {
435
452
  let asked = false;
436
453
  let failedOn = '';
437
454
  const check = () => {
@@ -456,12 +473,12 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
456
473
  // _analytics.ts (nothing is sent that it does not say), every page once it
457
474
  // has loaded or with the first widget (its chains on our tiles listen then).
458
475
  // Without one, `analytics: false`: our tiles queue nothing (core/tiles.ts).
459
- if (!Object.keys(files.analytics ?? {}).length) g.analytics = false;
460
- if (g.analytics !== false) {
476
+ if (!ANALYTICS || !Object.keys(files.analytics ?? {}).length) g.analytics = false;
477
+ if (ANALYTICS && g.analytics !== false) {
461
478
  const here = () => new URL(location.href);
462
479
  const readQ = search ? searchReadQuery(search) : null;
463
480
  const owns = (s: string) => {
464
- if (s === 'autocomplete') return !!(ac && !ac.disabled && g.inputSelector);
481
+ if (s === 'autocomplete') return AUTOCOMPLETE ? !!(ac && !ac.disabled && g.inputSelector) : false;
465
482
  if (s === 'search') return routes.some((r) => r.kind === 'search' && r.accepts(here()));
466
483
  const reco = s.startsWith('recommender:') ? recoPages.get(s.slice(12)) : undefined;
467
484
  return !!reco && !!setUp(`${s} active`, () => reco(here()));
package/src/types.ts CHANGED
@@ -80,7 +80,9 @@ export interface GlobalConfigType {
80
80
  * Two in flight, the latest intent wins; 20 per category or query; none
81
81
  * offline, with Save-Data or on 2G; range sliders never. A hover that is
82
82
  * not followed costs one search request (no render, no hook, no analytics
83
- * event). Default true.
83
+ * event). In a drawer that stages (`facetsApply`), the selection it holds
84
+ * is fetched the same way once the changes pause, for its apply button.
85
+ * Default true.
84
86
  */
85
87
  prefetchFilters?: boolean;
86
88
  /**
@@ -109,7 +111,8 @@ export interface GlobalConfigType {
109
111
  * Puts a product in the site's cart (`_init.ts`, or `_global/_widget.ts`:
110
112
  * only widgets call it): what a click on `[data-lbx-cart]` in one of our
111
113
  * tiles runs (announced to analytics first: `lumaCarts()`). `count`: the
112
- * attribute's value (default 1).
114
+ * attribute's value (default 1). The build has it only where a file of the
115
+ * integration contains `data-lbx-cart` (a template: core/features.ts CART).
113
116
  */
114
117
  addToCart?: (r: Result, o: { count: number; el: HTMLElement; event: MouseEvent }) => unknown;
115
118
  }
@@ -332,10 +335,22 @@ export interface ListingConfigType {
332
335
  * How a change inside the open filter drawer (`[data-lbx-drawer-panel]`,
333
336
  * opened by `[data-lbx-drawer="open"]`) applies. 'instant' (default): at
334
337
  * once, as in a sidebar. 'staged': held in the drawer (it shows the pending
335
- * selection, the scope's `pending` is true, nothing is fetched) until its
336
- * apply button (`[data-lbx-drawer="apply"]`) commits them in one request
337
- * and one history entry; closing it otherwise (`[data-lbx-drawer="close"]`,
338
- * Escape) discards them. Per viewport min-width, e.g. `{ "0": "staged",
338
+ * selection, the scope's `pending` is true, the results behind it do not
339
+ * change) until its apply button (`[data-lbx-drawer="apply"]`) commits them
340
+ * in one request and one history entry; closing it otherwise
341
+ * (`[data-lbx-drawer="close"]`, Escape) discards them. Once the changes
342
+ * pause, the held selection's results are fetched in the background
343
+ * (unless `_global.prefetchFilters` is false, with Save-Data or on 2G),
344
+ * so Apply shows them without waiting; nothing is shown and `onRender`
345
+ * does not run before Apply. Apply's swap is marked: the old list fades
346
+ * out, the new tiles come in, and a status at the listing's top says what
347
+ * was applied ("Filters applied · 9 products", translation
348
+ * `search.applied`) for 2.5 s, in `[data-lbx-applied-cue]`'s shadow root
349
+ * (out of reach of the site's resets; themed with `--lbx-applied-bg`,
350
+ * `--lbx-applied-fg`, `--lbx-applied-top` for a sticky header's height,
351
+ * `--lbx-applied-offset` to sit below a heading at the listing's top, or
352
+ * restyled with `[data-lbx-applied-cue]::part(pill)`), with
353
+ * `data-lbx-applied` on the listing meanwhile for the site's own CSS. Per viewport min-width, e.g. `{ "0": "staged",
339
354
  * "768": "instant" }` (staged on phones). Outside an open drawer every
340
355
  * change is instant.
341
356
  */
@@ -375,6 +390,18 @@ export interface SearchConfigType extends AppConfig<SearchConfigType>, ListingCo
375
390
  /** _init.ts overrides for URLs a regex cannot describe. */
376
391
  active?: (url: URL) => boolean;
377
392
  readQuery?: (url: URL) => string;
393
+ /**
394
+ * _init.ts: the search page of a phrase, where the site's server sends the
395
+ * search form (a form that submits to `/search?q=…` and is redirected to
396
+ * `/search/<phrase>/`). `form` is the URL the form would submit to (its other
397
+ * fields). The URL returned must be one `urlMatch` / `readQuery` accept;
398
+ * `''` means no page of its own for this phrase (the form submits as it is). With
399
+ * it, Enter in the site's search input (and the autocomplete's "show all" and
400
+ * its hand-off of a query) loads that page directly, without the redirect;
401
+ * the result fetched while typing, the page prerendered and a query in place
402
+ * are that page's. An empty phrase submits the form as the site has it.
403
+ */
404
+ queryUrl?: (q: string, form: URL) => string | URL;
378
405
  /** Extra result types shown above the results (category and brand tiles), one request each. */
379
406
  quick?: { type: string; size: number; fields?: string[] }[];
380
407
  /**
@@ -400,17 +427,24 @@ export interface SearchConfigType extends AppConfig<SearchConfigType>, ListingCo
400
427
  /** How long a result fetched while typing is used (ms; older: asked anew). Default 120000. */
401
428
  prefetchMaxAgeMs?: number;
402
429
  /**
403
- * Also prerender the search page (Speculation Rules: Chrome, Edge) of the
404
- * phrase whose result was just fetched while typing, if it is still the one
405
- * typed, so Enter from another page activates a page already rendered. One
406
- * at a time (a newer phrase replaces it). It costs a host page render on
407
- * the site's server per settled phrase, and runs the site's scripts in it:
430
+ * The search page itself, ahead of Enter (Speculation Rules): once the
431
+ * phrase's result is fetched while typing and the phrase is still the one
432
+ * typed, one list rule for that page. `'prefetch'` (default): its document
433
+ * is fetched (Chrome, Edge; Firefox and Safari as they ship it), so Enter
434
+ * from another page skips the site's server render and still renders from
435
+ * the stash: a document fetch on the site's server per settled phrase, no
436
+ * scripts run. `'prerender'`: the page is rendered ahead as well (Chrome,
437
+ * Edge; a prefetch rule too, for engines without prerendering): Enter
438
+ * activates a page already rendered, but the site's scripts run in it, and
408
439
  * analytics that do not wait for `prerenderingchange` count a page view the
409
- * shopper may never see. Not on the search page when `inPlaceQuery` is on.
410
- * Default false.
440
+ * shopper may never see. `false`: neither. One page at a time (a newer
441
+ * phrase replaces it); not on the search page when `inPlaceQuery` is on
442
+ * (no page load to save); nothing offline, with Save-Data or on 2G.
411
443
  */
444
+ pageWhileTyping?: 'prefetch' | 'prerender' | false;
445
+ /** `true` is `pageWhileTyping: 'prerender'`. */
412
446
  prerenderWhileTyping?: boolean;
413
- /** Speculation Rules eagerness of that list rule: 'immediate' (default) or 'eager'. */
447
+ /** Speculation Rules eagerness of the prerender rule: 'immediate' (default) or 'eager'. */
414
448
  prerenderEagerness?: 'immediate' | 'eager';
415
449
  }
416
450