@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
@@ -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);
@@ -5,6 +5,7 @@
5
5
  // templates, translations and helpers the bundle would.
6
6
 
7
7
  import type { LumaApi, LumaConfigType, LumaInitType } from '../types';
8
+ import { AUTOCOMPLETE, HOST_STYLES, INITS, PLP, RECOMMENDER, SEARCH, TRANSLATIONS } from './features';
8
9
 
9
10
  export type Lazy<T> = Record<string, () => Promise<T>>;
10
11
 
@@ -52,26 +53,36 @@ export interface IntegrationFiles {
52
53
 
53
54
  /** A folder's style-host.css; a category page without its own uses the search page's. */
54
55
  export const hostStyle = (files: Pick<IntegrationFiles, 'hostStyles'>, folder: string): string => {
56
+ // None in the integration (core/features.ts): nothing of the host's to hide or hold.
57
+ if (!HOST_STYLES) return '';
55
58
  const css = Object.entries(files.hostStyles).find(([p]) => folderOf(p) === folder)?.[1] ?? '';
56
- return css || (folder === 'plp' ? hostStyle(files, 'search') : '');
59
+ return css || (PLP ? (folder === 'plp' ? hostStyle(files, 'search') : '') : '');
57
60
  };
58
61
 
59
- /** `_config.ts` is JSON by contract (checked at boot; code belongs in `_init.ts`). */
62
+ /**
63
+ * `_config.ts` is JSON by contract (code belongs in `_init.ts`): a function
64
+ * in it is dropped. Said in development (the dev server, unit tests), where
65
+ * configs are written; a build (the bundle, the render service's) leaves the
66
+ * check out: ~0.1 kB gzip of every page.
67
+ */
60
68
  const parseConfigJson = (value: any, file: string) => {
61
- const check = (v: any, key: string): void => {
62
- if (typeof v === 'function') console.error(`[lbx] ${file}: "${key}" is a function; _config.ts is parsed as JSON, set functions in _init.ts`);
63
- else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) check(x, k);
64
- };
65
- check(value, '');
69
+ if (import.meta.env.DEV) {
70
+ const check = (v: any, key: string): void => {
71
+ if (typeof v === 'function') console.error(`[lbx] ${file}: "${key}" is a function; _config.ts is parsed as JSON, set functions in _init.ts`);
72
+ else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) check(x, k);
73
+ };
74
+ check(value, '');
75
+ }
66
76
  return JSON.parse(JSON.stringify(value ?? {}));
67
77
  };
68
78
 
69
79
  /** Folder -> slot in the config: `recommender home` -> recommenders.home. */
70
80
  export const slot = (config: any, folder: string) => {
71
81
  const [type, ...rest] = folder.split(' ');
72
- if (type !== 'recommender') return (config[type] ??= {});
82
+ if (!RECOMMENDER || type !== 'recommender') return (config[type] ??= {});
73
83
  const name = rest.join(' ');
74
- if (!name) throw new Error(`[lbx] "${folder}": a recommender folder needs a name, e.g. "recommender home"`);
84
+ // A build fails on such a folder (build/vite.mjs featureDefines): said here in development only.
85
+ if (import.meta.env.DEV && !name) throw Error(`[lbx] "${folder}": a recommender folder needs a name, e.g. "recommender home"`);
75
86
  return ((config.recommenders ??= {})[name] ??= {});
76
87
  };
77
88
 
@@ -83,32 +94,37 @@ export const loadConfig = async (files: Pick<IntegrationFiles, 'configs' | 'init
83
94
  const config = { _global: {} } as LumaConfigType;
84
95
  let globalInit: LumaInitType | undefined;
85
96
  for (const [p, v] of Object.entries(files.configs)) Object.assign(slot(config, folderOf(p)), parseConfigJson(v, p));
86
- for (const [p, v] of Object.entries(files.translations)) if (typeof v !== 'function') slot(config, folderOf(p)).translations = parseConfigJson(v, p);
87
- for (const [p, v] of Object.entries(files.inits)) {
88
- if (folderOf(p) === '_global') globalInit = v;
89
- else slot(config, folderOf(p)).initialize = v;
90
- }
97
+ if (TRANSLATIONS) for (const [p, v] of Object.entries(files.translations)) if (typeof v !== 'function') slot(config, folderOf(p)).translations = parseConfigJson(v, p);
98
+ // No _init.ts in the integration (core/features.ts INITS): no function sets initialize either.
99
+ if (INITS)
100
+ for (const [p, v] of Object.entries(files.inits)) {
101
+ if (folderOf(p) === '_global') globalInit = v;
102
+ else slot(config, folderOf(p)).initialize = v;
103
+ }
91
104
  const Luma: LumaApi = { version, config };
92
105
  if (globalInit && (await globalInit(config, Luma)) === false) return null;
93
106
  if (config._global.disabled || !config._global.publishableKey || !config._global.channelId) return null;
94
- const apps = [config.autocomplete, config.search, config.plp, ...Object.values(config.recommenders ?? {})];
95
- for (const app of apps) if (app && !app.disabled && app.initialize && (await (app.initialize as any)(app, Luma)) === false) app.disabled = true;
107
+ const apps = [AUTOCOMPLETE ? config.autocomplete : null, SEARCH ? config.search : null, PLP ? config.plp : null, ...(RECOMMENDER ? Object.values(config.recommenders ?? {}) : [])];
108
+ if (INITS) for (const app of apps) if (app && !app.disabled && app.initialize && (await (app.initialize as any)(app, Luma)) === false) app.disabled = true;
96
109
  // Anything a category page does not set is the search page's.
97
- const { search, plp } = config;
98
- if (plp) for (const k of LISTING) if (plp[k] == null && search?.[k] != null) (plp as any)[k] = search[k];
99
- for (const l of [search, plp]) if (l && l.reserveSelector == null && l.anchor) l.reserveSelector = [l.anchor].flat()[0];
110
+ const plp = PLP ? config.plp : null;
111
+ if (plp) for (const k of LISTING) if (plp[k] == null && config.search?.[k] != null) (plp as any)[k] = config.search[k];
112
+ if (SEARCH || PLP) for (const l of [config.search, config.plp]) if (l && l.reserveSelector == null && l.anchor) l.reserveSelector = [l.anchor].flat()[0];
100
113
  return config;
101
114
  };
102
115
 
103
116
  /** A widget's lazy `_translations.ts` (see IntegrationFiles), into their folders' slots before its context is built. */
104
117
  export const loadTranslations = (config: LumaConfigType, files: Pick<IntegrationFiles, 'translations'>, folders: readonly string[]) =>
105
- Promise.all(
106
- Object.entries(files.translations)
107
- .filter(([p, v]) => typeof v === 'function' && folders.includes(folderOf(p)))
108
- .map(async ([p, f]) => {
109
- slot(config, folderOf(p)).translations = parseConfigJson(await f(), p);
110
- }),
111
- );
118
+ // None in the integration (core/features.ts): Luma's dictionary alone.
119
+ TRANSLATIONS
120
+ ? Promise.all(
121
+ Object.entries(files.translations)
122
+ .filter(([p, v]) => typeof v === 'function' && folders.includes(folderOf(p)))
123
+ .map(async ([p, f]) => {
124
+ slot(config, folderOf(p)).translations = parseConfigJson(await f(), p);
125
+ }),
126
+ )
127
+ : Promise.resolve([]);
112
128
 
113
129
  /** A widget's files from a glob, in folder order; from _global/, files named after another widget stay out. */
114
130
  export const widgetFiles = <X>(kind: string, folders: string[], glob: Lazy<X>) => {
@@ -6,6 +6,8 @@
6
6
  // CSS); each route's takeover continues that hold.
7
7
 
8
8
  import type { SearchConfigType, PlpConfigType, GlobalConfigType } from '../types';
9
+ import { SEARCH, SSR } from './features';
10
+ import { TURBO } from './frameworks';
9
11
  import { hasPrefetch, productsChannel, putPrefetch } from './prefetch';
10
12
  import type { Rule } from './reconciler';
11
13
  import { searchReadQuery } from './routes';
@@ -43,6 +45,8 @@ const toArray = <X>(v: X | X[] | undefined): X[] => (v == null ? [] : Array.isAr
43
45
 
44
46
  const listing = ({ kind, cfg, accepts, hostCss }: ListingRoute, { g, load, run, held, addRule }: ListingDeps) => {
45
47
  const tag = `lbx-${kind}`;
48
+ /** The query a search URL holds (the route's readQuery / urlMatch), once. */
49
+ const read = kind === 'search' ? searchReadQuery(cfg as SearchConfigType) : null;
46
50
  const tk: Takeover = takeover({
47
51
  id: kind,
48
52
  tag,
@@ -51,13 +55,13 @@ const listing = ({ kind, cfg, accepts, hostCss }: ListingRoute, { g, load, run,
51
55
  reserveSelector: cfg.reserveSelector!,
52
56
  holdTimeoutMs: cfg.holdTimeoutMs ?? 4000,
53
57
  // Another category, another query: a new visit (hold, or hand back anew).
54
- visitKey: kind === 'plp' ? (u) => u.pathname : searchReadQuery(cfg as SearchConfigType),
58
+ visitKey: read ?? ((u) => u.pathname),
55
59
  held: held.has(kind),
56
60
  });
57
61
  // The element is placed by the reconciler; its class arrives with the
58
62
  // widget's chunk and upgrades it in place (custom elements are upgraded when
59
63
  // defined). Where their server fell back to their own listing, the page is theirs.
60
- const active = (u: URL) => accepts(u) && tk.ssr !== 'fellback';
64
+ const active = (u: URL) => accepts(u) && (SSR ? tk.ssr !== 'fellback' : true);
61
65
  addRule({ id: kind, active, anchors: toArray(cfg.anchor), position: cfg.position!, settleMs: cfg.settleMs, create: () => document.createElement(tag) });
62
66
  // Search: the request starts here, in parallel with the widget's chunks
63
67
  // (the entry asked for them with this one). Once our element is on the page
@@ -66,8 +70,8 @@ const listing = ({ kind, cfg, accepts, hostCss }: ListingRoute, { g, load, run,
66
70
  // the listing starts, so it waits for this request rather than asking twice.
67
71
  // A failure (the chunk, the request) is the listing's, as a failed request is.
68
72
  const prefetch = (url: URL) => {
69
- // A result stashed for this load (typed on the previous page, or left for Back): the listing renders that.
70
- if (kind !== 'search' || !active(url) || tk.ssr || hasPrefetch(url.href) || document.querySelector(tag) || hasStash(url.href)) return;
73
+ // A result stashed for this load (typed on the previous page: under the query; or left for Back: under the page): the listing renders that.
74
+ if (!SEARCH || !read || !active(url) || (SSR ? !!tk.ssr : false) || hasPrefetch(url.href) || document.querySelector(tag) || hasStash(url.href, { route: kind, q: read(url) })) return;
71
75
  const { products, onProducts } = productsChannel();
72
76
  putPrefetch(url.href, import('../widgets/search/first').then((m) => m.searchFirst(g, cfg as SearchConfigType, url, onProducts)), { products });
73
77
  };
@@ -95,6 +99,6 @@ const listing = ({ kind, cfg, accepts, hostCss }: ListingRoute, { g, load, run,
95
99
  /** Each route's takeover, placement rule and loading; Turbo Drive's history around ours (core/turbo.ts). */
96
100
  export const startListings = (routes: ListingRoute[], deps: ListingDeps, setUp: <X>(where: string, f: () => X) => X | null) => {
97
101
  const listings = routes.map((r) => setUp(r.kind, () => listing(r, deps))).filter((l): l is NonNullable<typeof l> => !!l);
98
- if (listings.length) setUp('turbo', () => startTurbo(listings));
102
+ if (TURBO && listings.length) setUp('turbo', () => startTurbo(listings));
99
103
  return listings;
100
104
  };
@@ -1,5 +1,6 @@
1
1
  // Luma's dictionaries (src/locales/<locale>.json), each its own chunk, only
2
- // the site's locale ever loaded. In the entry (a few bytes: the glob's map),
2
+ // the site's locale ever loaded. In the entry (a few bytes per locale: the
3
+ // glob's map, narrowed to the declared ones: build/vite.mjs onlyLocales),
3
4
  // so a widget's load asks for its dictionary in parallel with its chunks
4
5
  // rather than after lbx-core arrived. No DOM: the server renderer uses it too.
5
6
 
package/src/core/own.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  // Everything we insert into the page is marked, so sensing can ignore our own
2
2
  // mutations and the reconciler can find its nodes.
3
3
 
4
+ import { DOCUMENT_REACT } from './frameworks';
5
+
4
6
  export const OWN_ATTR = 'data-lbx';
5
7
 
6
8
  /**
@@ -28,17 +30,19 @@ export const modalOf = (el: Element): Element | null => {
28
30
  return null;
29
31
  };
30
32
 
31
- /** React hydrating the whole document (Next.js app router, Remix, React Router 7 / Hydrogen). */
32
- export const wholeDocument = () => {
33
+ /** The page's own signs of React hydrating the whole document (Next.js app router, Remix, React Router 7 / Hydrogen). */
34
+ export const reactDocumentSigns = () => {
33
35
  const w = window as any;
34
36
  return !!(w.__next_f || w.__remixContext || w.__reactRouterContext);
35
37
  };
38
+ /** React hydrating the whole document. */
39
+ export const wholeDocument = DOCUMENT_REACT ? reactDocumentSigns : () => false;
36
40
  /**
37
41
  * A page that may yet be hydrated as a whole: known whole-document React, or a
38
42
  * Next.js page still parsing (its flag comes at the end of <body>, its assets
39
43
  * show before). Anywhere else, nothing of ours has to wait for a framework.
40
44
  */
41
- export const documentMayHydrate = () => wholeDocument() || (document.readyState === 'loading' && !!document.querySelector('script[src*="/_next/"]'));
45
+ export const documentMayHydrate = () => (DOCUMENT_REACT ? wholeDocument() || (document.readyState === 'loading' && !!document.querySelector('script[src*="/_next/"]')) : false);
42
46
 
43
47
  /** React has hydrated this element (claimed it: a fiber, or React 16's instance). */
44
48
  export const hasFiber = (n: object) => Object.keys(n).some((k) => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'));
@@ -40,7 +40,7 @@ export const startRecommenders = (recos: Reco[], run: Run, setUp: <X>(where: str
40
40
  * (2026-10-11).
41
41
  */
42
42
  function inlineRecommender(id: string, rc: RecommenderConfigType, get: () => Promise<Loaded>, run: Run) {
43
- const re = rc.urlMatch ? new RegExp(rc.urlMatch) : null;
43
+ const re = rc.urlMatch ? RegExp(rc.urlMatch) : null;
44
44
  const page = rc.active ?? ((u: URL) => !re || re.test(u.pathname));
45
45
  const io = new IntersectionObserver(
46
46
  (entries) => {
@@ -113,7 +113,7 @@ function inlineRecommender(id: string, rc: RecommenderConfigType, get: () => Pro
113
113
  * their way) when the notice appears.
114
114
  */
115
115
  function popupTrigger(rc: RecommenderConfigType, get: () => Promise<Loaded>, run: Run) {
116
- if (!rc.intent) return reportError('reco popup', new Error('a popup recommender needs intent() in its _init.ts'));
116
+ if (!rc.intent) return reportError('reco popup', Error('a popup recommender needs intent() in its _init.ts'));
117
117
  let intent: { item: string; t: number } | null = null;
118
118
  // Sensing sees nodes come and go, not attributes: a notice the host shows by
119
119
  // a class (or style, hidden, open) on a node that stays is watched for while
@@ -6,6 +6,8 @@
6
6
  // are stable and need no settle wait. Read at the entry's boot (this module arrives later, in lbx-place).
7
7
  import { lateBoot } from './boot-state';
8
8
  import { documentMayHydrate, hasFiber, OWN_ATTR, watchOwnRemovals, wholeDocument } from './own';
9
+ import { SSR } from './features';
10
+ import { ANGULAR, ASTRO, NEXT, REACT, SVELTEKIT, TURBO, VUE } from './frameworks';
9
11
  import { onFlush, scheduleIn, scheduleNow } from './scheduler';
10
12
  import { bump, health, mark, reportError } from './health';
11
13
 
@@ -46,11 +48,12 @@ const pausedUntil = new Map<string, number>();
46
48
  // re-renders the root on the client (a full-page flash), Vue drops our node,
47
49
  // Angular reports NG0500. The host CSS hold covers the wait. Any other React
48
50
  // root is known by its container key once createRoot/hydrateRoot has run
49
- // (reactContainer(), below).
51
+ // (reactContainer(), below). Only the frameworks the build has (core/frameworks.ts).
50
52
  const REACT_ROOTS = '#__next, #___gatsby, [data-reactroot]';
51
53
  const VUE_ROOTS = '#__nuxt, [data-server-rendered]';
52
54
  // Astro removes `ssr` once an island has hydrated.
53
- const SSR_ROOTS = `${REACT_ROOTS}, ${VUE_ROOTS}, [ng-server-context], astro-island[ssr]`;
55
+ // (Ternaries, not &&: a false flag must leave no reference behind, or this chunk imports it and keeps the code.)
56
+ const SSR_ROOTS = [REACT ? REACT_ROOTS : '', VUE ? VUE_ROOTS : '', ANGULAR ? '[ng-server-context]' : '', ASTRO ? 'astro-island[ssr]' : ''].filter(Boolean).join(', ');
54
57
  const keyOf = (n: object, prefix: string) => Object.keys(n).find((k) => k.startsWith(prefix));
55
58
 
56
59
  const CONTAINER = '__reactContainer$';
@@ -60,6 +63,7 @@ const CONTAINER = '__reactContainer$';
60
63
  * is as common as `#__next`), or the document React hydrates as a whole.
61
64
  */
62
65
  const reactContainer = (el: Element): Element | Document | null => {
66
+ if (!REACT) return null;
63
67
  for (let a: Element | null = el; a; a = a.parentElement) if (keyOf(a, CONTAINER)) return a;
64
68
  return wholeDocument() ? document : null;
65
69
  };
@@ -70,7 +74,7 @@ const reactContainer = (el: Element): Element | Document | null => {
70
74
  * ours in that markup was React error #418, the root rendered anew).
71
75
  */
72
76
  const reactDehydrated = (c: Element | Document | null) => {
73
- const k = c && keyOf(c, CONTAINER);
77
+ const k = REACT ? c && keyOf(c, CONTAINER) : null;
74
78
  return !!k && (c as any)[k]?.stateNode?.current?.memoizedState?.isDehydrated === true;
75
79
  };
76
80
  // A wait for the parse to end (hydrated(), below) needs a reconcile when it does.
@@ -97,10 +101,12 @@ const beforeCache = () => {
97
101
  // (Turbolinks snapshots in a setTimeout of 1 ms: a few ms more covers it.)
98
102
  setTimeout(() => setTimeout(endSnapshot, 5));
99
103
  };
100
- addEventListener('turbo:before-cache', beforeCache);
101
- addEventListener('turbolinks:before-cache', beforeCache);
102
- addEventListener('turbo:render', endSnapshot);
103
- addEventListener('turbolinks:render', endSnapshot);
104
+ if (TURBO) {
105
+ addEventListener('turbo:before-cache', beforeCache);
106
+ addEventListener('turbolinks:before-cache', beforeCache);
107
+ addEventListener('turbo:render', endSnapshot);
108
+ addEventListener('turbolinks:render', endSnapshot);
109
+ }
104
110
  const sinceLoad = () => {
105
111
  const end = (performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined)?.loadEventEnd;
106
112
  return end ? performance.now() - end : -1;
@@ -173,7 +179,7 @@ const inDehydratedSuspense = (el: Element): boolean => {
173
179
  * meanwhile (their listing shows); ours is placed once React is done.
174
180
  */
175
181
  const reactHydrating = (anchor: Element, age: number) => {
176
- return age <= MAX_REACT_WAIT && reactDehydrated(reactContainer(anchor));
182
+ return REACT ? age <= MAX_REACT_WAIT && reactDehydrated(reactContainer(anchor)) : false;
177
183
  };
178
184
  /** However long React takes, not longer than this. */
179
185
  const MAX_REACT_WAIT = 15_000;
@@ -185,7 +191,7 @@ const MAX_REACT_WAIT = 15_000;
185
191
  * production Angular binds the server's next node to the wrong element.
186
192
  */
187
193
  const ngHydrating = (anchor: Element, pos: Position, age: number) => {
188
- if (age > MAX_REACT_WAIT) return false;
194
+ if (!ANGULAR || age > MAX_REACT_WAIT) return false;
189
195
  const node = pos === 'before' || pos === 'after' ? anchor.parentElement ?? anchor : anchor;
190
196
  return !!node.closest('[ng-server-context][ngh], [ng-server-context] [ngh]');
191
197
  };
@@ -203,37 +209,39 @@ const ngHydrating = (anchor: Element, pos: Position, age: number) => {
203
209
  let svelteKit: Element | null | undefined;
204
210
  let svelteKitStarted = false;
205
211
  const svelteKitRoot = (anchor: Element) => {
212
+ if (!SVELTEKIT) return null;
206
213
  if (svelteKit === undefined) {
207
214
  const boot = [...document.scripts].find((s) => !s.src && s.text.includes('__sveltekit_') && s.text.includes('.start('));
208
215
  if (boot || document.readyState !== 'loading') svelteKit = boot?.parentElement ?? null;
209
216
  }
210
217
  return svelteKit?.contains(anchor) ? svelteKit : null;
211
218
  };
212
- const svelteKitParsing = () => svelteKit === undefined && !!document.querySelector('link[href*="/immutable/entry/start."]');
219
+ const svelteKitParsing = () => (SVELTEKIT ? svelteKit === undefined && !!document.querySelector('link[href*="/immutable/entry/start."]') : false);
213
220
 
214
221
  const hydrated = (anchor: Element, pos: Position) => {
215
222
  if (svelteKitRoot(anchor)) return (svelteKitStarted ||= history.scrollRestoration === 'manual');
216
223
  // Any React root (its container key), and not before it has committed its hydration.
217
224
  const react = reactContainer(anchor);
218
225
  if (reactDehydrated(react)) return false;
219
- const root = anchor.closest(SSR_ROOTS) ?? (react !== document ? (react as Element | null) : null) ?? (wholeDocument() ? document.body : null);
226
+ const root = (SSR_ROOTS ? anchor.closest(SSR_ROOTS) : null) ?? (REACT ? (react !== document ? (react as Element | null) : null) : null) ?? (wholeDocument() ? document.body : null);
220
227
  // Still parsing a Next.js page (its whole-document flag comes at the end of
221
228
  // <body>, its assets are visible before) or a SvelteKit page (its boot
222
229
  // script is at the end of <body>): wait for the parse. Any other page is
223
230
  // placed at once (before first paint, as the hold intends).
224
231
  if (!root) return !documentMayHydrate() && !svelteKitParsing();
225
- // For a sibling insert, the parent's state is what counts (all of its children claimed).
226
- const node = pos === 'before' || pos === 'after' ? anchor.parentElement ?? anchor : anchor;
232
+ // For a sibling insert, the parent's state is what counts (all of its
233
+ // children claimed): React's and Angular's only (a read the rest leave out).
234
+ const node = REACT || ANGULAR ? (pos === 'before' || pos === 'after' ? anchor.parentElement ?? anchor : anchor) : anchor;
227
235
  // Frameworks with a signal: wait for it (the cap bounds the wait).
228
236
  // For a sibling insert, the anchor's own place counts too: its parent may be
229
237
  // claimed while the anchor sits in a boundary React has not hydrated yet.
230
- if (root === document.body || root === react || root.matches(REACT_ROOTS)) {
238
+ if (REACT && (root === document.body || root === react || root.matches(REACT_ROOTS))) {
231
239
  // Next.js pages router: its hydration measure (it stays; the marks it is
232
240
  // made of do not) says the shell is done, not the Suspense boundaries.
233
- const shell = performance.getEntriesByName('Next.js-hydration', 'measure').length ? !inAnyDehydratedBoundary(node) : reactHydrated(node);
241
+ const shell = NEXT && performance.getEntriesByName('Next.js-hydration', 'measure').length ? !inAnyDehydratedBoundary(node) : reactHydrated(node);
234
242
  return shell && (node === anchor || !inDehydratedSuspense(anchor));
235
243
  }
236
- if (root.matches(VUE_ROOTS)) {
244
+ if (VUE && root.matches(VUE_ROOTS)) {
237
245
  const vue = (root as any).__vue_app__;
238
246
  const nuxt = vue?.config?.globalProperties?.$nuxt;
239
247
  if (nuxt) return nuxt.isHydrating === false; // Nuxt 3 hydrates async parts after mount
@@ -243,11 +251,11 @@ const hydrated = (anchor: Element, pos: Position) => {
243
251
  // that task), and no component around the anchor still has its hydration
244
252
  // key (`ngh`, removed once Angular has read it): a lazy route's page
245
253
  // hydrates later than the shell, when its chunk has arrived.
246
- if ('__ngContext__' in root) {
254
+ if (ANGULAR && '__ngContext__' in root) {
247
255
  const waiting = node.closest('[ngh]');
248
256
  return !waiting || waiting === root || !root.contains(waiting);
249
257
  }
250
- if (root.localName === 'astro-island') return false; // it drops `ssr` when hydrated (the cap bounds the wait)
258
+ if (ASTRO && root.localName === 'astro-island') return false; // it drops `ssr` when hydrated (the cap bounds the wait)
251
259
  // No signal (a framework we cannot see): after load and a moment.
252
260
  return lateBoot || sinceLoad() > 1000;
253
261
  };
@@ -300,8 +308,9 @@ const allowInsert = (id: string, now: number) => {
300
308
  * markup in an island that hydrates after the swap; ours in it then fails
301
309
  * that hydration and the island renders anew).
302
310
  */
311
+ // (hydrated() reads the position for React and Angular only: no read of it in a build without them.)
303
312
  const mustWait = (r: Rule, anchor: Element, age: number) =>
304
- (!lost.has(r.id) || !!anchor.closest('astro-island[ssr]')) && !hydrated(anchor, r.position) && (age < MAX_HYDRATION_WAIT || reactHydrating(anchor, age) || ngHydrating(anchor, r.position, age));
313
+ (!lost.has(r.id) || (ASTRO ? !!anchor.closest('astro-island[ssr]') : false)) && !hydrated(anchor, REACT || ANGULAR ? r.position : 'prepend') && (age < MAX_HYDRATION_WAIT || reactHydrating(anchor, age) || (ANGULAR ? ngHydrating(anchor, r.position, age) : false));
305
314
 
306
315
  const reconcileRule = (r: Rule, url: URL, now: number) => {
307
316
  const mine = [...document.querySelectorAll(`[${OWN_ATTR}="${r.id}"]`)];
@@ -322,21 +331,21 @@ const reconcileRule = (r: Rule, url: URL, now: number) => {
322
331
  // placed. Otherwise only our own instance counts: a copy of it (a Turbo
323
332
  // snapshot restored) is not live, it goes.
324
333
  const keep =
325
- mine.find((el) => el.hasAttribute('data-lbx-ssr') && isPlaced(el, anchor!, r.position)) ??
334
+ (SSR ? mine.find((el) => el.hasAttribute('data-lbx-ssr') && isPlaced(el, anchor!, r.position)) : undefined) ??
326
335
  mine.find((el) => el === instances.get(r.id) && isPlaced(el, anchor!, r.position));
327
336
  if (!keep && placedOn.has(r.id) && placedOn.get(r.id) !== anchor) lost.add(r.id);
328
337
  mine.filter((el) => el !== keep).forEach((el) => el.remove());
329
338
  if (keep) {
330
339
  // The server's element becomes ours (it drops its marker once hydrated).
331
340
  instances.set(r.id, keep as HTMLElement);
332
- if (!keep.hasAttribute('data-turbo-temporary')) keep.setAttribute('data-turbo-temporary', '');
341
+ if (TURBO && !keep.hasAttribute('data-turbo-temporary')) keep.setAttribute('data-turbo-temporary', '');
333
342
  // Placed while parsing, before the page said React hydrates it as a whole
334
343
  // (Remix, React Router 7 and Hydrogen say so at the end of <body>): out
335
344
  // again until React has it, or React re-renders the document. Their
336
345
  // scripts run after the parse, so it is gone before hydration starts.
337
346
  // Within the same limits as the wait before an insert (React never hydrating must not keep ours out).
338
347
  if (!firstSeen.has(anchor)) firstSeen.set(anchor, now);
339
- if (!keep.hasAttribute('data-lbx-ssr') && wholeDocument() && mustWait(r, anchor, now - (firstSeen.get(anchor) ?? now))) {
348
+ if ((!SSR || !keep.hasAttribute('data-lbx-ssr')) && wholeDocument() && mustWait(r, anchor, now - (firstSeen.get(anchor) ?? now))) {
340
349
  keep.remove();
341
350
  scheduleIn(200);
342
351
  }
@@ -361,7 +370,7 @@ const reconcileRule = (r: Rule, url: URL, now: number) => {
361
370
  instances.set(r.id, el);
362
371
  el.setAttribute(OWN_ATTR, r.id);
363
372
  // Turbo Drive: not in its page snapshots (a restored copy would sit next to ours).
364
- el.setAttribute('data-turbo-temporary', '');
373
+ if (TURBO) el.setAttribute('data-turbo-temporary', '');
365
374
  place(el, anchor, r.position);
366
375
  placedOn.set(r.id, anchor);
367
376
  lost.delete(r.id);
@@ -3,6 +3,7 @@
3
3
 
4
4
  import type { ParamMap, PlpConfigType, SearchConfigType } from '../types';
5
5
  import { reportError } from './health';
6
+ import { READ_QUERY } from './features';
6
7
 
7
8
  /** An integration's URL hook (active, readQuery) that throws: the page is not ours. */
8
9
  const guarded = (where: string, f: (u: URL) => boolean) => (u: URL) => {
@@ -19,7 +20,7 @@ export const P = 'lbx.';
19
20
 
20
21
  // Tracking params, and the LBX loader's own (_lbx_env=development, …): never a reason to leave a page to the host.
21
22
  export const TRACKING = '^(utm_|_lbx_|gclid$|gbraid$|wbraid$|fbclid$|msclkid$|_gl$|mc_(cid|eid)$|srsltid$)';
22
- const TRACKING_RE = new RegExp(TRACKING);
23
+ const TRACKING_RE = RegExp(TRACKING);
23
24
 
24
25
  /** A URL without the params in `ignore` (default: tracking) and without its #hash: the page it shows. */
25
26
  export const withoutTracking = (href: string, ignore = TRACKING_RE) => {
@@ -47,7 +48,7 @@ export const firstPage = (v: string) => /^0*1$/.test(v.trim());
47
48
  export const has = (o: object, k: string) => Object.prototype.hasOwnProperty.call(o, k);
48
49
 
49
50
  export const searchReadQuery = (c: SearchConfigType) => {
50
- if (c.readQuery) {
51
+ if (READ_QUERY && c.readQuery) {
51
52
  const read = c.readQuery;
52
53
  return (u: URL) => {
53
54
  try {
@@ -58,13 +59,13 @@ export const searchReadQuery = (c: SearchConfigType) => {
58
59
  }
59
60
  };
60
61
  }
61
- const re = c.urlMatch ? new RegExp(c.urlMatch) : null;
62
+ const re = c.urlMatch ? RegExp(c.urlMatch) : null;
62
63
  return (u: URL) => (c.queryParam ? u.searchParams.get(c.queryParam) : decode((re?.exec(u.pathname)?.[1] ?? '').replace(/\+/g, ' ')))?.trim() || '';
63
64
  };
64
65
 
65
66
  export const searchAccepts = (c: SearchConfigType) => {
66
67
  if (c.active) return guarded('search active', c.active);
67
- const re = c.urlMatch ? new RegExp(c.urlMatch) : null;
68
+ const re = c.urlMatch ? RegExp(c.urlMatch) : null;
68
69
  const read = searchReadQuery(c);
69
70
  return guarded('search readQuery', (u: URL) => (!re || re.test(u.pathname)) && !!read(u));
70
71
  };
@@ -83,9 +84,9 @@ export const flagId = (f: { param: string; value: string }) => `${f.param}=${f.v
83
84
  * anything else leaves the page to the host, so no URL ever loses a filter.
84
85
  */
85
86
  export const plpAccepts = (c: Pick<PlpConfigType, 'urlMatch' | 'params' | 'active'>) => {
86
- const re = new RegExp(c.urlMatch);
87
+ const re = RegExp(c.urlMatch);
87
88
  const pm: ParamMap = c.params ?? {};
88
- const ignore = new RegExp(pm.ignore ?? TRACKING);
89
+ const ignore = RegExp(pm.ignore ?? TRACKING);
89
90
  const flags = pm.flags ?? [];
90
91
  const facetParams = pm.facetParams ?? {};
91
92
  const byParam = new Map(Object.entries(facetParams).map(([facet, fp]) => [fp.param, facet]));