@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.
- package/CHANGELOG.md +118 -0
- package/README.md +75 -8
- package/build/baseline.mjs +2 -2
- package/build/vite.mjs +253 -20
- package/docs/ACCESSIBILITY.md +9 -2
- package/package.json +3 -2
- package/src/analytics/chain.ts +1 -1
- package/src/analytics/pageview.ts +1 -1
- package/src/analytics/start.ts +3 -4
- package/src/analytics/wire.ts +1 -1
- package/src/api/ai.ts +32 -20
- package/src/api/token.ts +7 -7
- package/src/core/a11y.ts +6 -35
- package/src/core/context.ts +2 -0
- package/src/core/contrast-toggle.ts +32 -0
- package/src/core/default-button.ts +66 -0
- package/src/core/early-rules.ts +3 -2
- package/src/core/features.ts +109 -0
- package/src/core/frameworks-check.ts +32 -0
- package/src/core/frameworks.ts +35 -0
- package/src/core/image-load.ts +0 -26
- package/src/core/image-preload.ts +29 -0
- package/src/core/image-tags.ts +7 -1
- package/src/core/images.ts +3 -2
- package/src/core/integration.ts +42 -26
- package/src/core/listings.ts +9 -5
- package/src/core/locales.ts +2 -1
- package/src/core/own.ts +7 -3
- package/src/core/recommenders.ts +2 -2
- package/src/core/reconciler.ts +32 -23
- package/src/core/routes.ts +7 -6
- package/src/core/scheduler.ts +39 -5
- package/src/core/speculation.ts +2 -0
- package/src/core/stash.ts +15 -6
- package/src/core/styles.ts +6 -5
- package/src/core/support.ts +1 -1
- package/src/core/takeover.ts +6 -5
- package/src/core/templates.ts +5 -2
- package/src/core/tiles.ts +33 -10
- package/src/core/url.ts +6 -3
- package/src/core/widget-inits.ts +4 -1
- package/src/lit.ts +2 -1
- package/src/locales/cs.json +0 -1
- package/src/locales/de.json +0 -1
- package/src/locales/en.json +0 -1
- package/src/main.ts +70 -53
- package/src/types.ts +29 -9
- package/src/widgets/autocomplete/index.ts +58 -19
- package/src/widgets/listing/element.ts +81 -58
- package/src/widgets/listing/query.ts +6 -3
- package/src/widgets/listing/stash.ts +33 -16
- package/src/widgets/listing/state.ts +12 -0
- package/src/widgets/listing/view.ts +2 -1
- package/src/widgets/plp/route.ts +3 -2
- package/src/widgets/recommender/index.ts +3 -2
- package/src/widgets/search/direct.ts +39 -0
- package/src/widgets/search/form-url.ts +38 -34
- package/src/widgets/search/in-place.ts +43 -108
- package/src/widgets/search/index.ts +7 -2
- package/src/widgets/search/speculate.ts +48 -27
- package/src/widgets/search/submit.ts +136 -0
package/src/core/image-load.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/core/image-tags.ts
CHANGED
|
@@ -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
|
|
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) => ({
|
package/src/core/images.ts
CHANGED
|
@@ -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,
|
|
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, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
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);
|
package/src/core/integration.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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>) => {
|
package/src/core/listings.ts
CHANGED
|
@@ -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:
|
|
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
|
|
70
|
-
if (
|
|
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
|
};
|
package/src/core/locales.ts
CHANGED
|
@@ -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
|
|
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
|
|
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$'));
|
package/src/core/recommenders.ts
CHANGED
|
@@ -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 ?
|
|
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',
|
|
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
|
package/src/core/reconciler.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
101
|
-
addEventListener('
|
|
102
|
-
addEventListener('
|
|
103
|
-
addEventListener('
|
|
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
|
|
226
|
-
|
|
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);
|
package/src/core/routes.ts
CHANGED
|
@@ -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 =
|
|
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 ?
|
|
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 ?
|
|
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 =
|
|
87
|
+
const re = RegExp(c.urlMatch);
|
|
87
88
|
const pm: ParamMap = c.params ?? {};
|
|
88
|
-
const ignore =
|
|
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]));
|