@luigisbox/luma 0.0.0-stage → 0.2.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/LICENSE.md +90 -0
- package/README.md +615 -2
- package/a11y/audit.mjs +735 -0
- package/a11y/axe.mjs +73 -0
- package/a11y/report.mjs +347 -0
- package/a11y/run.mjs +256 -0
- package/a11y/wcag.mjs +233 -0
- package/bin/config.mjs +60 -0
- package/bin/luma.mjs +99 -0
- package/bin/paths.mjs +11 -0
- package/bin/tools.mjs +43 -0
- package/build/baseline.d.mts +8 -0
- package/build/baseline.mjs +167 -0
- package/build/tooling.mjs +102 -0
- package/build/vite.mjs +507 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +150 -0
- package/docs/ACCESSIBILITY.md +501 -0
- package/docs/INTEGRATION-PERFORMANCE.md +213 -0
- package/docs/PARITY.md +946 -0
- package/footprint/model.mjs +184 -0
- package/footprint/run.mjs +331 -0
- package/package.json +58 -4
- package/parity/alignment.mjs +109 -0
- package/parity/archive.mjs +223 -0
- package/parity/args.mjs +137 -0
- package/parity/behaviour.mjs +379 -0
- package/parity/compare.mjs +985 -0
- package/parity/engine.mjs +848 -0
- package/parity/hints.mjs +132 -0
- package/parity/hold.mjs +54 -0
- package/parity/init.mjs +703 -0
- package/parity/map.mjs +640 -0
- package/parity/owner.mjs +170 -0
- package/parity/pairing.mjs +110 -0
- package/parity/pixels.mjs +33 -0
- package/parity/probe.mjs +1239 -0
- package/parity/report.mjs +236 -0
- package/parity/run.mjs +267 -0
- package/snippet/early-hold.js +64 -0
- package/snippet/early-hold.min.js +1 -0
- package/src/analytics/chain.ts +137 -0
- package/src/analytics/collect.ts +64 -0
- package/src/analytics/datalayer.ts +67 -0
- package/src/analytics/from-ga4.ts +68 -0
- package/src/analytics/from-luma.ts +21 -0
- package/src/analytics/index.ts +24 -0
- package/src/analytics/outbox.ts +118 -0
- package/src/analytics/page.ts +46 -0
- package/src/analytics/pageview.ts +82 -0
- package/src/analytics/ref.ts +19 -0
- package/src/analytics/setup.ts +41 -0
- package/src/analytics/start.ts +53 -0
- package/src/analytics/triggers.ts +239 -0
- package/src/analytics/user.ts +12 -0
- package/src/analytics/wire.ts +67 -0
- package/src/api/ai.ts +98 -0
- package/src/api/index.ts +87 -0
- package/src/api/token-key.ts +42 -0
- package/src/api/token.ts +312 -0
- package/src/core/a11y-keys.ts +60 -0
- package/src/core/a11y.ts +160 -0
- package/src/core/boot-state.ts +6 -0
- package/src/core/color.ts +42 -0
- package/src/core/context.ts +108 -0
- package/src/core/early-rules.ts +96 -0
- package/src/core/entries.ts +25 -0
- package/src/core/fold.ts +7 -0
- package/src/core/health.ts +26 -0
- package/src/core/hold.ts +74 -0
- package/src/core/i18n.ts +66 -0
- package/src/core/image-load.ts +102 -0
- package/src/core/image-tags.ts +26 -0
- package/src/core/images.ts +170 -0
- package/src/core/integration.ts +126 -0
- package/src/core/kept.ts +25 -0
- package/src/core/listings.ts +100 -0
- package/src/core/locales.ts +14 -0
- package/src/core/own.ts +49 -0
- package/src/core/page.ts +14 -0
- package/src/core/prefetch.ts +88 -0
- package/src/core/recommenders.ts +166 -0
- package/src/core/reconciler.ts +397 -0
- package/src/core/result.ts +73 -0
- package/src/core/routes.ts +115 -0
- package/src/core/scheduler.ts +83 -0
- package/src/core/sense.ts +107 -0
- package/src/core/speculation.ts +59 -0
- package/src/core/stash.ts +38 -0
- package/src/core/storage.ts +45 -0
- package/src/core/styles.ts +106 -0
- package/src/core/support.ts +12 -0
- package/src/core/takeover.ts +220 -0
- package/src/core/templates.ts +74 -0
- package/src/core/tiles.ts +137 -0
- package/src/core/turbo.ts +64 -0
- package/src/core/url.ts +164 -0
- package/src/core/widget-inits.ts +40 -0
- package/src/env.d.ts +3 -0
- package/src/lit.ts +11 -0
- package/src/locales/cs.json +94 -0
- package/src/locales/de.json +88 -0
- package/src/locales/en.json +88 -0
- package/src/main.ts +493 -0
- package/src/types.ts +778 -0
- package/src/widgets/autocomplete/index.ts +1387 -0
- package/src/widgets/autocomplete/style-autocomplete.css +266 -0
- package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
- package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
- package/src/widgets/listing/element.ts +1772 -0
- package/src/widgets/listing/flow.ts +30 -0
- package/src/widgets/listing/instant.ts +133 -0
- package/src/widgets/listing/intent.ts +193 -0
- package/src/widgets/listing/query.ts +298 -0
- package/src/widgets/listing/stash.ts +97 -0
- package/src/widgets/listing/state.ts +131 -0
- package/src/widgets/listing/view.ts +257 -0
- package/src/widgets/plp/index.ts +11 -0
- package/src/widgets/plp/route.ts +263 -0
- package/src/widgets/recommender/index.ts +261 -0
- package/src/widgets/recommender/style-recommender.css +26 -0
- package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
- package/src/widgets/recommender/template-recommender-popup.ts +12 -0
- package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
- package/src/widgets/search/assets.ts +7 -0
- package/src/widgets/search/first.ts +15 -0
- package/src/widgets/search/form-url.ts +59 -0
- package/src/widgets/search/in-place.ts +226 -0
- package/src/widgets/search/index.ts +16 -0
- package/src/widgets/search/range.ts +260 -0
- package/src/widgets/search/route.ts +32 -0
- package/src/widgets/search/speculate.ts +202 -0
- package/src/widgets/search/style-search.css +65 -0
- package/src/widgets/search/template-search-box.ts +17 -0
- package/src/widgets/search/template-search-facets-default.ts +73 -0
- package/src/widgets/search/template-search-header.ts +14 -0
- package/src/widgets/search/template-search-pagination.ts +12 -0
- package/src/widgets/search/template-search-range-default.ts +48 -0
- package/src/widgets/search/template-search-result-default.ts +13 -0
- package/src/widgets/search/template-search-results-default.ts +8 -0
- package/tasks/model.mjs +116 -0
- package/tasks/run.mjs +305 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Product images in templates, right by default. DOM-free: the server renderer builds the same
|
|
2
|
+
// attributes, so a server-rendered tile hydrates as it was rendered.
|
|
3
|
+
//
|
|
4
|
+
// From `_global.images` (JSON, `_config.ts`), per image:
|
|
5
|
+
// - `srcset` from a URL pattern of the site's image CDN (named sizes, or a
|
|
6
|
+
// resizer's width parameter), `sizes` from the config;
|
|
7
|
+
// - the first row (its columns at the viewport's width, from `firstRow`) eager
|
|
8
|
+
// with fetchpriority=high, the rest lazy; decoding=async always;
|
|
9
|
+
// - width/height always (the box's aspect ratio before the image arrives);
|
|
10
|
+
// - a placeholder style for the image's box: a dominant colour or a tiny image
|
|
11
|
+
// when the catalog has one, else the neutral skeleton (style-search.css).
|
|
12
|
+
// The page side (load marks, preloads, warming) is ./image-load.ts, the
|
|
13
|
+
// template helpers (`img()`, lit) ./image-tags.ts: this file has no lit, so a
|
|
14
|
+
// chunk that only warms images (search/speculate.ts) stays small.
|
|
15
|
+
|
|
16
|
+
import type { ImagesConfig, Result } from '../types';
|
|
17
|
+
import { first, safeUrl } from './result';
|
|
18
|
+
|
|
19
|
+
/** What a template binds on an `<img>` (or its `<source>`), and on the image's box (`ph`). */
|
|
20
|
+
export interface ImageInfo {
|
|
21
|
+
src: string;
|
|
22
|
+
/** '' when the URL does not match `images.srcset`. */
|
|
23
|
+
srcset: string;
|
|
24
|
+
sizes: string;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
loading: 'eager' | 'lazy';
|
|
28
|
+
/** 'high' for the first row, else undefined (no attribute). */
|
|
29
|
+
fetchpriority: 'high' | undefined;
|
|
30
|
+
decoding: 'async';
|
|
31
|
+
/** In the first row: eager, high priority, preloaded, never faded in. */
|
|
32
|
+
eager: boolean;
|
|
33
|
+
/** Style for the box carrying `data-lbx-ph`: the catalog's placeholder (`--lbx-ph`), '' for the neutral one. */
|
|
34
|
+
ph: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ImageOptions {
|
|
38
|
+
/** The image URL (default `r.c.image`). */
|
|
39
|
+
src?: string;
|
|
40
|
+
/** Overrides `images.sizes` (a smaller tile: a recommender). */
|
|
41
|
+
sizes?: string;
|
|
42
|
+
/** Force eager (true) or lazy (false); default: the first row is eager. */
|
|
43
|
+
eager?: boolean;
|
|
44
|
+
alt?: string;
|
|
45
|
+
class?: string;
|
|
46
|
+
title?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DEFAULT_ROW: Record<string, number> = { 0: 2, 768: 3, 1200: 4 };
|
|
50
|
+
const res = new Map<string, RegExp | null>();
|
|
51
|
+
const regex = (s: string) => {
|
|
52
|
+
if (!res.has(s))
|
|
53
|
+
try {
|
|
54
|
+
res.set(s, new RegExp(s));
|
|
55
|
+
} catch {
|
|
56
|
+
res.set(s, null);
|
|
57
|
+
}
|
|
58
|
+
return res.get(s)!;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The first-row breakpoints, ascending: [min-width px, columns]. */
|
|
62
|
+
const rows = (cfg: ImagesConfig = {}): [number, number][] => {
|
|
63
|
+
const r = cfg.firstRow ?? DEFAULT_ROW;
|
|
64
|
+
return typeof r === 'number' ? [[0, r]] : Object.entries(r).map(([w, n]) => [Number(w) || 0, Number(n) || 0] as [number, number]).sort((a, b) => a[0] - b[0]);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A media query's live list, made once per query: img() asks for every image
|
|
69
|
+
* of every render, and a matchMedia() call per image and breakpoint was
|
|
70
|
+
* tens of ms of a listing's render on a slow phone (a production storefront, 4x CPU).
|
|
71
|
+
* `matches` stays current as the viewport changes.
|
|
72
|
+
*/
|
|
73
|
+
const lists = new Map<string, MediaQueryList>();
|
|
74
|
+
export const media = (query: string) => {
|
|
75
|
+
let l = lists.get(query);
|
|
76
|
+
if (!l) lists.set(query, (l = matchMedia(query)));
|
|
77
|
+
return l.matches;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* How many images of a listing are its first row: the columns at the
|
|
82
|
+
* viewport's width (matchMedia: no layout read), the widest row on a server
|
|
83
|
+
* (it cannot know the width; one extra eager image costs little).
|
|
84
|
+
*/
|
|
85
|
+
export const firstRowCount = (cfg: ImagesConfig = {}) => {
|
|
86
|
+
const bp = rows(cfg);
|
|
87
|
+
if (typeof matchMedia !== 'function') return Math.max(0, ...bp.map(([, n]) => n));
|
|
88
|
+
let n = bp[0]?.[1] ?? 0;
|
|
89
|
+
for (const [w, c] of bp) if (!w || media(`(min-width: ${w}px)`)) n = c;
|
|
90
|
+
return n;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** For a preload made where the width is unknown (the server): the media query in which image `i` is in the first row, '' = always, null = never. */
|
|
94
|
+
export const firstRowMedia = (cfg: ImagesConfig = {}, i: number): string | null => {
|
|
95
|
+
const at = rows(cfg).filter(([, n]) => n > i);
|
|
96
|
+
if (!at.length) return null;
|
|
97
|
+
// Columns grow with the width: from the narrowest breakpoint that has this column on.
|
|
98
|
+
const w = at[0][0];
|
|
99
|
+
return w ? `(min-width: ${w}px)` : '';
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** The srcset of `src` by `images.srcset` ('' when it does not match). */
|
|
103
|
+
export const srcsetOf = (cfg: ImagesConfig = {}, src: string) => {
|
|
104
|
+
const s = cfg.srcset;
|
|
105
|
+
if (!s || !src) return '';
|
|
106
|
+
const re = regex(s.match ?? '^(.*)$');
|
|
107
|
+
if (!re || !re.test(src)) return '';
|
|
108
|
+
const out: string[] = [];
|
|
109
|
+
const add = (replacement: string, descriptor: string) => {
|
|
110
|
+
const u = safeUrl(src.replace(re, replacement.replace(/\{w\}/g, descriptor.replace(/\D/g, ''))));
|
|
111
|
+
// A URL with a space or comma would break the list: such a candidate is left out.
|
|
112
|
+
if (u && !/[\s,]/.test(u)) out.push(`${u} ${descriptor}`);
|
|
113
|
+
};
|
|
114
|
+
if (s.candidates) for (const [d, rep] of Object.entries(s.candidates)) /^\d+(?:\.\d+)?[wx]$/.test(d) && add(rep, d);
|
|
115
|
+
if (s.pattern && s.widths) for (const w of s.widths) Number.isFinite(w) && w > 0 && add(s.pattern, `${Math.round(w)}w`);
|
|
116
|
+
return out.join(', ');
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const COLOR = /^(#[0-9a-f]{3,8}|(?:rgb|hsl)a?\([\d\s.,%/+-]+\))$/i;
|
|
120
|
+
/** The box's style for the catalog's placeholder: a colour (strictly a colour), or a tiny image (a web URL, quoted). */
|
|
121
|
+
export const placeholderOf = (cfg: ImagesConfig = {}, r: Result) => {
|
|
122
|
+
const p = cfg.placeholder;
|
|
123
|
+
if (!p) return '';
|
|
124
|
+
const val = (f?: string) => (f ? String(first(r.c[f] ?? r.a[f]) ?? '').trim() : '');
|
|
125
|
+
const color = val(p.color);
|
|
126
|
+
const img = safeUrl(val(p.image));
|
|
127
|
+
const parts: string[] = [];
|
|
128
|
+
if (img && !/["\\\n\r]/.test(img)) parts.push(`url("${img}") center / cover no-repeat`);
|
|
129
|
+
if (COLOR.test(color)) parts.push(color);
|
|
130
|
+
return parts.length ? `--lbx-ph:${parts.join(', ')}` : '';
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Every attribute of result `r`'s image at position `i`; `lazy`: no eager first row (not a listing). */
|
|
134
|
+
export const imageOf = (cfg: ImagesConfig = {}, r: Result, i: number, o: ImageOptions = {}, lazy = false): ImageInfo => {
|
|
135
|
+
const src = String(o.src ?? r.c.image ?? '');
|
|
136
|
+
const eager = o.eager ?? (!lazy && i < firstRowCount(cfg));
|
|
137
|
+
const srcset = srcsetOf(cfg, src);
|
|
138
|
+
return {
|
|
139
|
+
src,
|
|
140
|
+
srcset,
|
|
141
|
+
sizes: srcset ? (o.sizes ?? cfg.sizes ?? '') : '',
|
|
142
|
+
width: cfg.width ?? 400,
|
|
143
|
+
height: cfg.height ?? cfg.width ?? 400,
|
|
144
|
+
loading: eager ? 'eager' : 'lazy',
|
|
145
|
+
fetchpriority: eager ? 'high' : undefined,
|
|
146
|
+
decoding: 'async',
|
|
147
|
+
eager,
|
|
148
|
+
ph: placeholderOf(cfg, r),
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/** The first row's images of a listing (what the server and the bundle preload). */
|
|
153
|
+
export const firstRowImages = (cfg: ImagesConfig = {}, results: Result[], all = false) => {
|
|
154
|
+
const n = all ? Math.max(0, ...rows(cfg).map(([, c]) => c)) : firstRowCount(cfg);
|
|
155
|
+
return results.slice(0, n).map((r, i) => ({ ...imageOf(cfg, r, i, { eager: true }), i })).filter((im) => im.src);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const esc = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
159
|
+
/** `<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
|
+
export const preloadMarkup = (cfg: ImagesConfig = {}, results: Result[]) => {
|
|
161
|
+
if (cfg.preload === false) return '';
|
|
162
|
+
return firstRowImages(cfg, results, true)
|
|
163
|
+
.map((im) => {
|
|
164
|
+
const media = firstRowMedia(cfg, im.i);
|
|
165
|
+
if (media == null) return '';
|
|
166
|
+
const set = im.srcset ? ` imagesrcset="${esc(im.srcset)}"${im.sizes ? ` imagesizes="${esc(im.sizes)}"` : ''}` : '';
|
|
167
|
+
return `<link rel="preload" as="image" href="${esc(im.src)}"${set}${media ? ` media="${esc(media)}"` : ''} fetchpriority="high" data-lbx="preload">`;
|
|
168
|
+
})
|
|
169
|
+
.join('');
|
|
170
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Turning an integration's files into a config (a widget's context from it:
|
|
2
|
+
// ./context.ts, in the core chunk). No DOM:
|
|
3
|
+
// the bundle (main.ts) and the edge render service (Luigi's Box's server-side rendering)
|
|
4
|
+
// both use it, so a server-rendered listing sees exactly the config,
|
|
5
|
+
// templates, translations and helpers the bundle would.
|
|
6
|
+
|
|
7
|
+
import type { LumaApi, LumaConfigType, LumaInitType } from '../types';
|
|
8
|
+
|
|
9
|
+
export type Lazy<T> = Record<string, () => Promise<T>>;
|
|
10
|
+
|
|
11
|
+
export const folderOf = (path: string) => path.split('/').slice(-2)[0];
|
|
12
|
+
export const nameOf = (path: string) => path.split('/').pop()!.replace(/^(template|style)-/, '').replace(/\.\w+$/, '');
|
|
13
|
+
const KINDS = ['autocomplete', 'search', 'plp', 'recommender'];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Each listing's folders (template and style precedence) and translation
|
|
17
|
+
* scopes. The bundle and the server renderer both read it: if they differed,
|
|
18
|
+
* a server-rendered listing would not hydrate.
|
|
19
|
+
*/
|
|
20
|
+
export const LISTINGS = {
|
|
21
|
+
search: { folders: ['_global', 'search'], scopes: ['search'] },
|
|
22
|
+
plp: { folders: ['_global', 'search', 'plp'], scopes: ['plp', 'search'] },
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
/** What an integration hands to Luma: its folders' files, as Vite globs. */
|
|
26
|
+
export interface IntegrationFiles {
|
|
27
|
+
configs: Record<string, any>;
|
|
28
|
+
inits: Record<string, any>;
|
|
29
|
+
/**
|
|
30
|
+
* `_translations.ts`, eager (needed by no boot code: on every page) or lazy
|
|
31
|
+
* (`import.meta.glob(…)` without `eager`: loaded with the widget of their
|
|
32
|
+
* folder, in its chunk). Lazy ones are not in the config `_init.ts` sees.
|
|
33
|
+
*/
|
|
34
|
+
translations: Record<string, any> | Lazy<any>;
|
|
35
|
+
/** style-host.css, eager: needed in the same task as a route change. */
|
|
36
|
+
hostStyles: Record<string, string>;
|
|
37
|
+
templates: Lazy<any>;
|
|
38
|
+
styles: Lazy<any>;
|
|
39
|
+
/**
|
|
40
|
+
* `_widget.ts`, lazy (`import.meta.glob('./*\/_widget.ts', { import: 'default' })`):
|
|
41
|
+
* hooks only a widget calls, run when it loads (loadWidgetInits). Optional;
|
|
42
|
+
* lazy always (an eager one would be in the entry, where `_init.ts` is).
|
|
43
|
+
*/
|
|
44
|
+
widgetInits?: Lazy<any>;
|
|
45
|
+
/**
|
|
46
|
+
* `_analytics.ts`, lazy (`import.meta.glob('./*\/_analytics.ts', { import: 'default' })`):
|
|
47
|
+
* the analytics setup (chains of `@/analytics`), run by the analytics chunk
|
|
48
|
+
* (it is in it) once the page has loaded.
|
|
49
|
+
*/
|
|
50
|
+
analytics?: Lazy<any>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A folder's style-host.css; a category page without its own uses the search page's. */
|
|
54
|
+
export const hostStyle = (files: Pick<IntegrationFiles, 'hostStyles'>, folder: string): string => {
|
|
55
|
+
const css = Object.entries(files.hostStyles).find(([p]) => folderOf(p) === folder)?.[1] ?? '';
|
|
56
|
+
return css || (folder === 'plp' ? hostStyle(files, 'search') : '');
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** `_config.ts` is JSON by contract (checked at boot; code belongs in `_init.ts`). */
|
|
60
|
+
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, '');
|
|
66
|
+
return JSON.parse(JSON.stringify(value ?? {}));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Folder -> slot in the config: `recommender home` -> recommenders.home. */
|
|
70
|
+
export const slot = (config: any, folder: string) => {
|
|
71
|
+
const [type, ...rest] = folder.split(' ');
|
|
72
|
+
if (type !== 'recommender') return (config[type] ??= {});
|
|
73
|
+
const name = rest.join(' ');
|
|
74
|
+
if (!name) throw new Error(`[lbx] "${folder}": a recommender folder needs a name, e.g. "recommender home"`);
|
|
75
|
+
return ((config.recommenders ??= {})[name] ??= {});
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Not `surface`: a category page is a collection surface, search a search surface.
|
|
79
|
+
const LISTING = ['anchor', 'position', 'settleMs', 'reserveSelector', 'holdTimeoutMs', 'size', 'facets', 'facetSize', 'emptyValues', 'sorts', 'fields', 'baseFilters', 'rootClass', 'restoreOnBack', 'prefetchNextPage', 'facetsApply', 'facetGroups'] as const;
|
|
80
|
+
|
|
81
|
+
/** Configs + translations + inits -> the config; null when the integration is off. */
|
|
82
|
+
export const loadConfig = async (files: Pick<IntegrationFiles, 'configs' | 'inits' | 'translations'>, version: string) => {
|
|
83
|
+
const config = { _global: {} } as LumaConfigType;
|
|
84
|
+
let globalInit: LumaInitType | undefined;
|
|
85
|
+
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
|
+
}
|
|
91
|
+
const Luma: LumaApi = { version, config };
|
|
92
|
+
if (globalInit && (await globalInit(config, Luma)) === false) return null;
|
|
93
|
+
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;
|
|
96
|
+
// 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];
|
|
100
|
+
return config;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** A widget's lazy `_translations.ts` (see IntegrationFiles), into their folders' slots before its context is built. */
|
|
104
|
+
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
|
+
);
|
|
112
|
+
|
|
113
|
+
/** A widget's files from a glob, in folder order; from _global/, files named after another widget stay out. */
|
|
114
|
+
export const widgetFiles = <X>(kind: string, folders: string[], glob: Lazy<X>) => {
|
|
115
|
+
const uses = kind === 'plp' ? ['search', 'plp'] : [kind];
|
|
116
|
+
const mine = (p: string) => {
|
|
117
|
+
const prefix = nameOf(p).split('-')[0];
|
|
118
|
+
return folders.includes(folderOf(p)) && !p.endsWith('/style-host.css') && (folderOf(p) !== '_global' || !KINDS.includes(prefix) || uses.includes(prefix));
|
|
119
|
+
};
|
|
120
|
+
return Promise.all(
|
|
121
|
+
Object.entries(glob)
|
|
122
|
+
.filter(([p]) => mine(p))
|
|
123
|
+
.sort(([a], [b]) => folders.indexOf(folderOf(a)) - folders.indexOf(folderOf(b)))
|
|
124
|
+
.map(async ([p, f]) => [p, await f()] as const),
|
|
125
|
+
);
|
|
126
|
+
};
|
package/src/core/kept.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The site's dataLayer pushes of a page left before analytics ran on it (a
|
|
2
|
+
// purchase on a thank-you page closed at once): kept for the next page's
|
|
3
|
+
// analytics, which replays them as of this page and time (src/analytics/datalayer.ts).
|
|
4
|
+
// In the entry: it runs on `pagehide`, when no chunk can load any more.
|
|
5
|
+
|
|
6
|
+
import { session } from './storage';
|
|
7
|
+
|
|
8
|
+
export const KEPT = 'lbx:dl';
|
|
9
|
+
|
|
10
|
+
/** Keeps the pushes of the dataLayer `name` from index `from` on (the last 30, of the last 5 pages); the index kept up to. */
|
|
11
|
+
export const keepDl = (name: string, from: number) => {
|
|
12
|
+
const dl = (window as any)[name];
|
|
13
|
+
if (!(dl?.length > from)) return from;
|
|
14
|
+
// Each on its own: one that does not serialize (a GTM click's element, cyclic) is left out, not the rest.
|
|
15
|
+
// gtag's `arguments` become `{ 0: 'event', 1: … }` (readDl reads them); functions (GTM's callbacks) are dropped.
|
|
16
|
+
const e = dl.slice(from - dl.length).slice(-30).flatMap((x: unknown) => {
|
|
17
|
+
try {
|
|
18
|
+
return [JSON.parse(JSON.stringify(x))];
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
session.set(KEPT, [...(session.get<unknown[]>(KEPT) ?? []), { u: location.href, t: Date.now(), e }].slice(-5));
|
|
24
|
+
return dl.length;
|
|
25
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The listings' boot: search results and category pages. In lbx-nav, not in
|
|
2
|
+
// the entry: the entry (main.ts) decides from the URL alone (the routes'
|
|
3
|
+
// accepts) and loads this with the listing's own chunks, on a page of a
|
|
4
|
+
// listing route, so a page that is not one never downloads it. Until it is
|
|
5
|
+
// here the entry holds their listing (the early hold claimed, or its own hold
|
|
6
|
+
// CSS); each route's takeover continues that hold.
|
|
7
|
+
|
|
8
|
+
import type { SearchConfigType, PlpConfigType, GlobalConfigType } from '../types';
|
|
9
|
+
import { hasPrefetch, productsChannel, putPrefetch } from './prefetch';
|
|
10
|
+
import type { Rule } from './reconciler';
|
|
11
|
+
import { searchReadQuery } from './routes';
|
|
12
|
+
import { onFlush } from './scheduler';
|
|
13
|
+
import { onUrlChange } from './sense';
|
|
14
|
+
import { hasStash } from './stash';
|
|
15
|
+
import { whenApplied } from './styles';
|
|
16
|
+
import { takeover, type Takeover } from './takeover';
|
|
17
|
+
import { startTurbo } from './turbo';
|
|
18
|
+
|
|
19
|
+
export interface ListingRoute {
|
|
20
|
+
kind: 'search' | 'plp';
|
|
21
|
+
cfg: SearchConfigType | PlpConfigType;
|
|
22
|
+
accepts: (u: URL) => boolean;
|
|
23
|
+
hostCss: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ListingDeps {
|
|
27
|
+
g: GlobalConfigType;
|
|
28
|
+
/** The widget's chunks (main.ts load(): cached, so the entry's early call is the same load). */
|
|
29
|
+
load: (kind: 'search' | 'plp') => Promise<{ mod: any; ctx: any }>;
|
|
30
|
+
/** main.ts run(): a widget call whose errors are reported, never thrown. */
|
|
31
|
+
run: (where: string, p: Promise<{ mod: any; ctx: any }>, f: (m: { mod: any; ctx: any }) => unknown, onFail?: () => void) => unknown;
|
|
32
|
+
/** Routes the entry already holds the current visit of. */
|
|
33
|
+
held: Set<string>;
|
|
34
|
+
/**
|
|
35
|
+
* The reconciler's (lbx-place), handed in: the entry loads it in parallel
|
|
36
|
+
* with this chunk, which typing loads too (search while typing) and which
|
|
37
|
+
* must not pull placement code into a page without a listing.
|
|
38
|
+
*/
|
|
39
|
+
addRule: (r: Rule) => unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const toArray = <X>(v: X | X[] | undefined): X[] => (v == null ? [] : Array.isArray(v) ? v : [v]);
|
|
43
|
+
|
|
44
|
+
const listing = ({ kind, cfg, accepts, hostCss }: ListingRoute, { g, load, run, held, addRule }: ListingDeps) => {
|
|
45
|
+
const tag = `lbx-${kind}`;
|
|
46
|
+
const tk: Takeover = takeover({
|
|
47
|
+
id: kind,
|
|
48
|
+
tag,
|
|
49
|
+
accepts,
|
|
50
|
+
hostCss,
|
|
51
|
+
reserveSelector: cfg.reserveSelector!,
|
|
52
|
+
holdTimeoutMs: cfg.holdTimeoutMs ?? 4000,
|
|
53
|
+
// Another category, another query: a new visit (hold, or hand back anew).
|
|
54
|
+
visitKey: kind === 'plp' ? (u) => u.pathname : searchReadQuery(cfg as SearchConfigType),
|
|
55
|
+
held: held.has(kind),
|
|
56
|
+
});
|
|
57
|
+
// The element is placed by the reconciler; its class arrives with the
|
|
58
|
+
// widget's chunk and upgrades it in place (custom elements are upgraded when
|
|
59
|
+
// defined). Where their server fell back to their own listing, the page is theirs.
|
|
60
|
+
const active = (u: URL) => accepts(u) && tk.ssr !== 'fellback';
|
|
61
|
+
addRule({ id: kind, active, anchors: toArray(cfg.anchor), position: cfg.position!, settleMs: cfg.settleMs, create: () => document.createElement(tag) });
|
|
62
|
+
// Search: the request starts here, in parallel with the widget's chunks
|
|
63
|
+
// (the entry asked for them with this one). Once our element is on the page
|
|
64
|
+
// it fetches URL changes itself (show more continues from a cursor): a
|
|
65
|
+
// prefetch then would be a second, full request. The slot is taken before
|
|
66
|
+
// the listing starts, so it waits for this request rather than asking twice.
|
|
67
|
+
// A failure (the chunk, the request) is the listing's, as a failed request is.
|
|
68
|
+
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;
|
|
71
|
+
const { products, onProducts } = productsChannel();
|
|
72
|
+
putPrefetch(url.href, import('../widgets/search/first').then((m) => m.searchFirst(g, cfg as SearchConfigType, url, onProducts)), { products });
|
|
73
|
+
};
|
|
74
|
+
let started = false;
|
|
75
|
+
const ensure = (url: URL) => {
|
|
76
|
+
prefetch(url);
|
|
77
|
+
if (started || !active(url)) return;
|
|
78
|
+
started = true;
|
|
79
|
+
// Loading adds modulepreload links to <head>: not while it is being parsed.
|
|
80
|
+
whenApplied(() =>
|
|
81
|
+
run(kind, load(kind), ({ mod, ctx }) => mod.start(ctx, tk), () => {
|
|
82
|
+
started = false; // the next URL change into the route tries again
|
|
83
|
+
tk.release(true);
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
ensure(new URL(location.href));
|
|
88
|
+
onUrlChange(ensure);
|
|
89
|
+
// A hook that reads the page (`active()` on their data layer) may accept it
|
|
90
|
+
// only later, without a URL change: looked at again on every reconcile until started.
|
|
91
|
+
onFlush(() => !started && ensure(new URL(location.href)));
|
|
92
|
+
return { tk, accepts: active };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Each route's takeover, placement rule and loading; Turbo Drive's history around ours (core/turbo.ts). */
|
|
96
|
+
export const startListings = (routes: ListingRoute[], deps: ListingDeps, setUp: <X>(where: string, f: () => X) => X | null) => {
|
|
97
|
+
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));
|
|
99
|
+
return listings;
|
|
100
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
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),
|
|
3
|
+
// so a widget's load asks for its dictionary in parallel with its chunks
|
|
4
|
+
// rather than after lbx-core arrived. No DOM: the server renderer uses it too.
|
|
5
|
+
|
|
6
|
+
import type { Dictionary } from '../types';
|
|
7
|
+
|
|
8
|
+
const locales = import.meta.glob<Dictionary>('../locales/*.json', { import: 'default' });
|
|
9
|
+
|
|
10
|
+
const cache = new Map<string, Promise<Dictionary>>();
|
|
11
|
+
export const loadDictionary = (locale: string) => {
|
|
12
|
+
if (!cache.has(locale)) cache.set(locale, (locales[`../locales/${locale}.json`] ?? locales['../locales/en.json'])());
|
|
13
|
+
return cache.get(locale)!;
|
|
14
|
+
};
|
package/src/core/own.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Everything we insert into the page is marked, so sensing can ignore our own
|
|
2
|
+
// mutations and the reconciler can find its nodes.
|
|
3
|
+
|
|
4
|
+
export const OWN_ATTR = 'data-lbx';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether a node of ours the host removes is put back before the next paint
|
|
8
|
+
* (core/sense.ts, scheduleNow): from when we have something to put back, a
|
|
9
|
+
* sheet of ours (core/styles.ts) or the reconciler's elements. Until then a
|
|
10
|
+
* removal is not searched for ours: on a busy page (a carousel) that was a
|
|
11
|
+
* querySelector per removed node, every frame, where Luma had placed nothing.
|
|
12
|
+
*/
|
|
13
|
+
let removals = false;
|
|
14
|
+
export const watchOwnRemovals = () => void (removals = true);
|
|
15
|
+
export const ownRemovalsWatched = () => removals;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The modal container around a host element (a dialog, a focus-trapped
|
|
19
|
+
* drawer, an open popover), across shadow roots. What we show for it goes
|
|
20
|
+
* inside: outside a modal we would be inert, and a press on us would count as
|
|
21
|
+
* outside it and close it.
|
|
22
|
+
*/
|
|
23
|
+
export const modalOf = (el: Element): Element | null => {
|
|
24
|
+
for (let n: Element | null = el; n; n = ((n.getRootNode() as ShadowRoot).host as Element | undefined) ?? null) {
|
|
25
|
+
const d = n.closest('dialog, [role=dialog], [aria-modal=true], [popover]:popover-open');
|
|
26
|
+
if (d) return d;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** React hydrating the whole document (Next.js app router, Remix, React Router 7 / Hydrogen). */
|
|
32
|
+
export const wholeDocument = () => {
|
|
33
|
+
const w = window as any;
|
|
34
|
+
return !!(w.__next_f || w.__remixContext || w.__reactRouterContext);
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* A page that may yet be hydrated as a whole: known whole-document React, or a
|
|
38
|
+
* Next.js page still parsing (its flag comes at the end of <body>, its assets
|
|
39
|
+
* show before). Anywhere else, nothing of ours has to wait for a framework.
|
|
40
|
+
*/
|
|
41
|
+
export const documentMayHydrate = () => wholeDocument() || (document.readyState === 'loading' && !!document.querySelector('script[src*="/_next/"]'));
|
|
42
|
+
|
|
43
|
+
/** React has hydrated this element (claimed it: a fiber, or React 16's instance). */
|
|
44
|
+
export const hasFiber = (n: object) => Object.keys(n).some((k) => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'));
|
|
45
|
+
|
|
46
|
+
export const isOwnNode = (n: Node | null): boolean => {
|
|
47
|
+
const el = n && (n.nodeType === 1 ? (n as Element) : n.parentElement);
|
|
48
|
+
return !!el?.closest(`[${OWN_ATTR}]`);
|
|
49
|
+
};
|
package/src/core/page.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Which page a URL shows. Tiny and DOM-free, so the entry can key prefetched
|
|
2
|
+
// and stashed results by page without pulling in core/url.ts (router writes),
|
|
3
|
+
// which only the listing chunk needs. canon lives in core/entries.ts.
|
|
4
|
+
|
|
5
|
+
import { canon } from './entries';
|
|
6
|
+
import { withoutTracking } from './routes';
|
|
7
|
+
|
|
8
|
+
export { canon };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The page a URL shows, for matching server-rendered state to it: canonical and
|
|
12
|
+
* without tracking params (an include leaves those out, the browser's URL has them).
|
|
13
|
+
*/
|
|
14
|
+
export const pageKey = (href: string) => canon(withoutTracking(href).href);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Listing results requested before the listing asks for them, keyed by the
|
|
2
|
+
// page they are for (pageKey: canonical, without tracking params), consumed once:
|
|
3
|
+
// - the entry's request on a search page (the listing's chunk is still loading);
|
|
4
|
+
// - a query the shopper is typing (widgets/search/speculate.ts): Enter on the
|
|
5
|
+
// search page renders it without a request;
|
|
6
|
+
// - the next page, as the shopper nears the end of the loaded tiles ("show more").
|
|
7
|
+
// Speculative ones expire (`ttlMs`): results a shopper did not ask for go stale.
|
|
8
|
+
//
|
|
9
|
+
// How long a listing's requests take on this page (`observe`, `typicalMs`):
|
|
10
|
+
// the listing does not wait for a speculation forever (widgets/listing/element.ts).
|
|
11
|
+
import type { ListingResult } from '../widgets/listing/query';
|
|
12
|
+
import { pageKey } from './page';
|
|
13
|
+
|
|
14
|
+
export interface Prefetch {
|
|
15
|
+
p: Promise<ListingResult | null>;
|
|
16
|
+
/** performance.now() when it was asked. */
|
|
17
|
+
at: number;
|
|
18
|
+
/** Answered (a result or null). */
|
|
19
|
+
done: boolean;
|
|
20
|
+
/** Speculative (a phrase typed, not a page asked for): the listing may ask anew when it is overdue. */
|
|
21
|
+
spec: boolean;
|
|
22
|
+
/** Stops it (a speculation whose result is no longer needed). */
|
|
23
|
+
abort?: () => void;
|
|
24
|
+
/** The products alone, before their facets (listingQuery's `onProducts`): the listing may show them first. */
|
|
25
|
+
products?: Promise<ListingResult>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const pending = new Map<string, Prefetch & { until: number }>();
|
|
29
|
+
|
|
30
|
+
/** Durations (ms) of the listing requests that answered on this page, the latest few. */
|
|
31
|
+
const took: number[] = [];
|
|
32
|
+
export const observe = (ms: number) => {
|
|
33
|
+
took.push(ms);
|
|
34
|
+
if (took.length > 9) took.shift();
|
|
35
|
+
};
|
|
36
|
+
/** A listing request's usual duration here (the median of the latest), or `fallback` before any answered. */
|
|
37
|
+
export const typicalMs = (fallback = 1000) => {
|
|
38
|
+
if (!took.length) return fallback;
|
|
39
|
+
const s = [...took].sort((a, b) => a - b);
|
|
40
|
+
return s[s.length >> 1];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** null: the URL has no query (the listing hands the page back), or a speculation that failed (the listing asks itself). */
|
|
44
|
+
export const putPrefetch = (href: string, p: Promise<ListingResult | null>, { ttl = Infinity, spec = false, timed = true, abort, products }: { ttl?: number; spec?: boolean; timed?: boolean; abort?: () => void; products?: Promise<ListingResult> } = {}) => {
|
|
45
|
+
const at = performance.now();
|
|
46
|
+
const e = { p, at, done: false, spec, abort, products, until: at + ttl };
|
|
47
|
+
p.then(
|
|
48
|
+
// A whole listing's requests (not a next page's one) tell how long a fresh one takes.
|
|
49
|
+
(r) => ((e.done = true), r && timed && observe(performance.now() - at)),
|
|
50
|
+
() => void (e.done = true), // the listing reports errors when it consumes it
|
|
51
|
+
);
|
|
52
|
+
pending.set(pageKey(href), e);
|
|
53
|
+
// Speculative entries nobody consumed: a handful at most.
|
|
54
|
+
if (pending.size > 8) pending.delete(pending.keys().next().value!);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const live = (k: string) => {
|
|
58
|
+
const e = pending.get(k);
|
|
59
|
+
if (e && e.until < performance.now()) pending.delete(k);
|
|
60
|
+
return e && e.until >= performance.now() ? e : undefined;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const takePrefetch = (href: string): Prefetch | undefined => {
|
|
64
|
+
const k = pageKey(href);
|
|
65
|
+
const e = live(k);
|
|
66
|
+
pending.delete(k);
|
|
67
|
+
return e;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const hasPrefetch = (href: string) => !!live(pageKey(href));
|
|
71
|
+
|
|
72
|
+
/** The pending result without consuming it (the listing preloads its first images when it arrives). */
|
|
73
|
+
export const peekPrefetch = (href: string) => live(pageKey(href))?.p;
|
|
74
|
+
/** Its products alone, when it was asked with `products` (before its facets). */
|
|
75
|
+
export const peekProducts = (href: string) => live(pageKey(href))?.products;
|
|
76
|
+
|
|
77
|
+
/** Drop one (an aborted speculation, or one superseded by newer phrases): the listing must not wait for it. */
|
|
78
|
+
export const dropPrefetch = (href: string, p: Promise<unknown>) => {
|
|
79
|
+
const k = pageKey(href);
|
|
80
|
+
if (pending.get(k)?.p === p) pending.delete(k);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** A promise for listingQuery's `onProducts` and the callback that settles it (a prefetch's `products`). */
|
|
84
|
+
export const productsChannel = () => {
|
|
85
|
+
let got!: (r: ListingResult) => void;
|
|
86
|
+
const products = new Promise<ListingResult>((ok) => (got = ok));
|
|
87
|
+
return { products, onProducts: (r: ListingResult) => got(r) };
|
|
88
|
+
};
|