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