@luigisbox/luma 0.3.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 +95 -14
- 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 +10 -2
- package/src/analytics/collect.ts +14 -7
- package/src/analytics/from-ga4.ts +5 -2
- package/src/analytics/from-luma.ts +6 -4
- package/src/analytics/index.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 +18 -4
- 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/build/vite.mjs
CHANGED
|
@@ -17,17 +17,21 @@ const FRAMEWORK = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
|
|
|
17
17
|
// One lazy chunk per widget plus what they share, so a page loads few files:
|
|
18
18
|
// lbx-core lit, the template registry, a11y, core/support (i18n,
|
|
19
19
|
// images, speculation rules) and the integration's shared
|
|
20
|
-
// _global templates (the product tile): every widget's
|
|
20
|
+
// _global templates (the product tile): every widget's;
|
|
21
|
+
// a core module one chunk alone reaches goes with it
|
|
22
|
+
// (soleUser: the listing's contrast toggle, fold, …)
|
|
21
23
|
// lbx-listing search results and category pages (one listing: plp is
|
|
22
24
|
// a route of it) with the integration's search/ templates
|
|
23
25
|
// lbx-autocomplete lbx-recommender each widget with its templates
|
|
24
|
-
// lbx-plp lbx-place lbx-search lbx-speculate lbx-token
|
|
25
|
-
//
|
|
26
|
-
// search
|
|
26
|
+
// lbx-plp lbx-place lbx-search lbx-search-in-place lbx-speculate lbx-token
|
|
27
|
+
// the plp widget and its templates, placement and
|
|
28
|
+
// recommender triggers, the search page's own, a new query
|
|
29
|
+
// in place, search while typing (and Enter straight to the
|
|
30
|
+
// query's page), minting the token (chunkOf)
|
|
27
31
|
// lbx-nav what search while typing (lbx-speculate) needs, a
|
|
28
32
|
// search page's first request (widgets/search/first.ts)
|
|
29
33
|
// and what they share with the listing (the route, query
|
|
30
|
-
// building, stash,
|
|
34
|
+
// building, stash, a form's URL, URL writing): typing on
|
|
31
35
|
// any page and the listing need it, a page with only a
|
|
32
36
|
// recommender does not
|
|
33
37
|
// lbx-analytics pageviews, our tiles' clicks and add-to-carts, the
|
|
@@ -82,17 +86,19 @@ export const chunkOf = (id) => {
|
|
|
82
86
|
// Hydration of server-rendered listings: only those pages load it.
|
|
83
87
|
// lit-html's private SSR support is the hydrator's alone (not every widget page's lbx-core).
|
|
84
88
|
if (/\/node_modules\/(@lit-labs\/ssr-client\/|lit-html\/private-ssr-support\.js)/.test(id)) return 'lbx-hydrate';
|
|
85
|
-
// Helpers one widget alone uses go with it, not into lbx-core (every widget's).
|
|
86
|
-
if (/\/src\/core\/color\.ts$/.test(id)) return 'lbx-autocomplete';
|
|
87
|
-
if (/\/src\/core\/a11y-keys\.ts$/.test(id)) return 'lbx-listing';
|
|
88
89
|
// Placing our elements (core/reconciler.ts) and the recommenders' triggers:
|
|
89
90
|
// pages with a listing or a recommender, in parallel with their other chunks.
|
|
90
91
|
if (/\/src\/core\/(reconciler|recommenders)\.ts$/.test(id)) return 'lbx-place';
|
|
91
|
-
// The
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
//
|
|
95
|
-
if (/\/src\/widgets\/search\/
|
|
92
|
+
// The listing's high-contrast toggle: only a site that turns it on loads it (widgets/listing/element.ts).
|
|
93
|
+
if (/\/src\/core\/contrast-toggle\.ts$/.test(id)) return 'lbx-contrast';
|
|
94
|
+
// The search page's own: not in lbx-listing, which a category page loads too
|
|
95
|
+
// (2.1 kB gzip it never ran).
|
|
96
|
+
if (/\/src\/widgets\/search\/index\.ts$/.test(id)) return 'lbx-search';
|
|
97
|
+
// A new query in place, and taking a submit of the site's search form (submit.ts, direct.ts's too: a
|
|
98
|
+
// category page never takes one): asked for as lbx-search runs, not waited for (widgets/search/index.ts).
|
|
99
|
+
if (/\/src\/widgets\/search\/(in-place|submit)\.ts$/.test(id)) return 'lbx-search-in-place';
|
|
100
|
+
// Search while typing, and Enter straight to the query's page (main.ts loads both once the shopper types): not in lbx-nav, which a listing page loads at once.
|
|
101
|
+
if (/\/src\/widgets\/search\/(speculate|direct)\.ts$/.test(id)) return 'lbx-speculate';
|
|
96
102
|
// Analytics (src/analytics): every page once loaded (main.ts), widgets import it for their tiles' clicks.
|
|
97
103
|
if (/\/src\/analytics\//.test(id)) return 'lbx-analytics';
|
|
98
104
|
// Minting the browser token: a session's first page loads it at boot (main.ts), lbx-core imports it.
|
|
@@ -108,7 +114,31 @@ export const chunkOf = (id) => {
|
|
|
108
114
|
// The entry's own modules are named into the entry chunk (rollupOptions.input's
|
|
109
115
|
// key) rather than left out: Rollup would pull a module a manual chunk depends
|
|
110
116
|
// on into that chunk, entry or not.
|
|
111
|
-
|
|
117
|
+
//
|
|
118
|
+
// A module bound for lbx-core (every widget's) that one other chunk alone
|
|
119
|
+
// reaches (directly, or through other lbx-core modules) goes with that chunk:
|
|
120
|
+
// a listing's helper is not on an autocomplete page (core/fold.ts, a11y-keys,
|
|
121
|
+
// color, the contrast toggle).
|
|
122
|
+
const soleUser = (id, getModuleInfo, entry, seen = new Set([id])) => {
|
|
123
|
+
const users = new Set();
|
|
124
|
+
for (const by of [...(getModuleInfo(id)?.importers ?? []), ...(getModuleInfo(id)?.dynamicImporters ?? [])]) {
|
|
125
|
+
if (seen.has(by)) continue;
|
|
126
|
+
seen.add(by);
|
|
127
|
+
const c = entry.has(by) ? 'integration' : chunkOf(by);
|
|
128
|
+
if (c === 'lbx-core') for (const u of soleUser(by, getModuleInfo, entry, seen)) users.add(u);
|
|
129
|
+
else users.add(c);
|
|
130
|
+
}
|
|
131
|
+
return users;
|
|
132
|
+
};
|
|
133
|
+
const manualChunks = (id, { getModuleInfo, getModuleIds }) => {
|
|
134
|
+
const entry = entryGraph(getModuleInfo, getModuleIds);
|
|
135
|
+
if (entry.has(id)) return 'integration';
|
|
136
|
+
const c = chunkOf(id);
|
|
137
|
+
if (c !== 'lbx-core') return c;
|
|
138
|
+
const users = soleUser(id, getModuleInfo, entry);
|
|
139
|
+
const [only] = users;
|
|
140
|
+
return users.size === 1 && only && only !== 'integration' ? only : c;
|
|
141
|
+
};
|
|
112
142
|
|
|
113
143
|
/**
|
|
114
144
|
* Template scope members Luma no longer has, and what replaces each. A
|
|
@@ -356,7 +386,7 @@ export const dedentTemplates = (ts, code, file) => {
|
|
|
356
386
|
for (const [a, b, s] of edits.sort((x, y) => y[0] - x[0])) out = out.slice(0, a) + s + out.slice(b);
|
|
357
387
|
return out;
|
|
358
388
|
};
|
|
359
|
-
const templateText = (root) => {
|
|
389
|
+
export const templateText = (root) => {
|
|
360
390
|
let ts;
|
|
361
391
|
// Module ids are real paths: an integration/ reached through a symlink too.
|
|
362
392
|
const dir = resolve(root, 'integration');
|
|
@@ -377,6 +407,50 @@ const templateText = (root) => {
|
|
|
377
407
|
};
|
|
378
408
|
};
|
|
379
409
|
|
|
410
|
+
/**
|
|
411
|
+
* lit-html's directives (`@/lit`) as free of side effects: each module only
|
|
412
|
+
* defines its directive, and one no template uses is left out. Without this
|
|
413
|
+
* one assigning its class's static fields (unsafeHTML: `directiveName`,
|
|
414
|
+
* `resultType`) stays in lbx-core though no template uses it (0.6 kB, found
|
|
415
|
+
* 2026-10-17); lit-html's package.json declares no `sideEffects`.
|
|
416
|
+
*/
|
|
417
|
+
const pureDirectives = () => ({
|
|
418
|
+
name: 'lbx-pure-directives',
|
|
419
|
+
apply: 'build',
|
|
420
|
+
transform(code, id) {
|
|
421
|
+
if (!/\/node_modules\/lit-html\/directives\/[\w-]+\.js$/.test(id.split('?')[0])) return null;
|
|
422
|
+
return { code, map: null, moduleSideEffects: false };
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Chunks nothing loads, left out: a widget the integration has no folder for
|
|
428
|
+
* (src/core/features.ts) leaves its modules tree-shaken to nothing, and the
|
|
429
|
+
* chunk they are named into (manualChunks) would still be emitted, empty
|
|
430
|
+
* but for imports. A chunk is loaded when another one imports it, statically
|
|
431
|
+
* or dynamically, or names its file (a preload list); the entry always.
|
|
432
|
+
*/
|
|
433
|
+
const unreachable = () => ({
|
|
434
|
+
name: 'lbx-unreachable-chunks',
|
|
435
|
+
apply: 'build',
|
|
436
|
+
enforce: 'post',
|
|
437
|
+
generateBundle(_, bundle) {
|
|
438
|
+
for (let pruned = true; pruned; ) {
|
|
439
|
+
pruned = false;
|
|
440
|
+
const chunks = Object.values(bundle).filter((c) => c.type === 'chunk');
|
|
441
|
+
for (const c of chunks) {
|
|
442
|
+
if (c.isEntry) continue;
|
|
443
|
+
const base = c.fileName.split('/').pop();
|
|
444
|
+
const used = chunks.some((o) => o !== c && (o.imports.includes(c.fileName) || o.dynamicImports.includes(c.fileName) || o.code.includes(base)));
|
|
445
|
+
if (!used) {
|
|
446
|
+
delete bundle[c.fileName];
|
|
447
|
+
pruned = true;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
|
|
380
454
|
/**
|
|
381
455
|
* A second minifier after Vite's esbuild pass: terser (two compress passes,
|
|
382
456
|
* mangled top-level names in each module) finds what esbuild leaves (~3 %
|
|
@@ -419,6 +493,14 @@ const terse = () => ({
|
|
|
419
493
|
* handles CSS dependencies, a <meta> nonce, a `preload` fallback and a
|
|
420
494
|
* vite:preloadError event, none of which a Luma build uses: ~1 kB gzip of the
|
|
421
495
|
* entry. A failed preload is the import's failure (it fetches the same file).
|
|
496
|
+
*
|
|
497
|
+
* `deps` starts with the imported chunk itself, even when it is the only one
|
|
498
|
+
* (resolveDependencies below; Vite's is [] then), so that it is marked: no
|
|
499
|
+
* later import's link preloads a chunk already imported or in flight. WebKit
|
|
500
|
+
* fetches such a link's chunk again and never uses the response (lbx-token
|
|
501
|
+
* and lbx-place, imported by the entry, then a listing's dependencies: twice
|
|
502
|
+
* per document, 2026-10-15). Its own link stays: Firefox holds the page's
|
|
503
|
+
* load event for a chunk an import() fetches, not for one a link does.
|
|
422
504
|
*/
|
|
423
505
|
export const PRELOAD_HELPER = `const seen = {};
|
|
424
506
|
export const __vitePreload = (load, deps, base) => {
|
|
@@ -444,10 +526,159 @@ const preloadHelper = () => ({
|
|
|
444
526
|
},
|
|
445
527
|
});
|
|
446
528
|
|
|
529
|
+
/**
|
|
530
|
+
* The widgets the integration has (src/core/features.ts): a widget is its
|
|
531
|
+
* folder of integration/, analytics an _analytics.ts in any. Defined for a
|
|
532
|
+
* build from the folders (the entry leaves out a missing widget's triggers);
|
|
533
|
+
* the dev server sees folders come and go: everything there.
|
|
534
|
+
*/
|
|
535
|
+
/** Options on by default whose reads fold where no file of the integration names them (src/core/features.ts NAMED_*). */
|
|
536
|
+
export const ON_BY_DEFAULT = ['preconnectImages', 'prefetchFilters', 'preload', 'fade', 'restoreOnBack', 'prefetchNextPage', 'inPlaceQuery', 'prefetchWhileTyping', 'earlyHold', 'speculation'];
|
|
537
|
+
|
|
538
|
+
export const featureDefines = (integrationDir) => {
|
|
539
|
+
const folders = existsSync(integrationDir) ? readdirSync(integrationDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) : null;
|
|
540
|
+
// A recommender folder is `recommender <name>` (src/core/integration.ts slot: checked there in development only).
|
|
541
|
+
for (const f of folders ?? []) if (f.trim() === 'recommender') throw new Error(`integration/${f}: a recommender folder needs a name, e.g. "recommender home"`);
|
|
542
|
+
const has = (f) => String(!folders || f(folders));
|
|
543
|
+
// The integration repository's code and markup (integration/ and anything
|
|
544
|
+
// it imports from beside it; not node_modules, builds or hidden folders),
|
|
545
|
+
// read once: a feature only its own files can ask for (a template's
|
|
546
|
+
// attribute, a hook _init.ts sets).
|
|
547
|
+
let text;
|
|
548
|
+
const read = (dir) =>
|
|
549
|
+
readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
|
|
550
|
+
e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'dist' ? [] : e.isDirectory() ? read(resolve(dir, e.name)) : /\.(ts|tsx|js|jsx|mts|mjs|cts|cjs|html)$/.test(e.name) ? [readFileSync(resolve(dir, e.name), 'utf8')] : [],
|
|
551
|
+
);
|
|
552
|
+
const inFiles = (...needles) => ((text ??= read(dirname(integrationDir)).join('\n')), needles.some((n) => text.includes(n)));
|
|
553
|
+
return {
|
|
554
|
+
__LBX_AUTOCOMPLETE__: has((d) => d.includes('autocomplete')),
|
|
555
|
+
__LBX_SEARCH__: has((d) => d.includes('search')),
|
|
556
|
+
__LBX_PLP__: has((d) => d.includes('plp')),
|
|
557
|
+
__LBX_RECOMMENDER__: has((d) => d.some((f) => f.split(/\s+/)[0] === 'recommender')),
|
|
558
|
+
__LBX_ANALYTICS__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, '_analytics.ts')))),
|
|
559
|
+
__LBX_WIDGET_INITS__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, '_widget.ts')))),
|
|
560
|
+
__LBX_HOST_STYLES__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, 'style-host.css')))),
|
|
561
|
+
__LBX_TRANSLATIONS__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, '_translations.ts')))),
|
|
562
|
+
__LBX_INITS__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, '_init.ts')))),
|
|
563
|
+
// Only an integration's templates make a cart button, a filter drawer or a menu (Luma's own have none).
|
|
564
|
+
__LBX_CART__: has(() => inFiles('data-lbx-cart')),
|
|
565
|
+
__LBX_DRAWER__: has(() => inFiles('data-lbx-drawer')),
|
|
566
|
+
__LBX_MENU__: has(() => inFiles('data-lbx-menu')),
|
|
567
|
+
__LBX_DROPDOWN__: has(() => inFiles('data-lbx-dropdown')),
|
|
568
|
+
// Hooks only the integration's code sets (_init.ts, _widget.ts: functions, never in _config.ts).
|
|
569
|
+
__LBX_AC_HOOKS__: has(() => inFiles('onOpen', 'onClose', 'onSelect', 'onReady')),
|
|
570
|
+
__LBX_ON_RENDER__: has(() => inFiles('onRender')),
|
|
571
|
+
__LBX_READ_QUERY__: has(() => inFiles('readQuery')),
|
|
572
|
+
__LBX_FACETS_FOR__: has(() => inFiles('facetsFor')),
|
|
573
|
+
__LBX_QUERY_URL__: has(() => inFiles('queryUrl')),
|
|
574
|
+
// Options off by default: a key no file names keeps its default.
|
|
575
|
+
__LBX_EMPTY_VALUES__: has(() => inFiles('emptyValues')),
|
|
576
|
+
__LBX_PRERENDER_TYPING__: has(() => inFiles('prerenderWhileTyping', 'pageWhileTyping')),
|
|
577
|
+
__LBX_CONTENT_VISIBILITY__: has(() => inFiles('contentVisibility')),
|
|
578
|
+
// Options on by default: one no file names is on.
|
|
579
|
+
...Object.fromEntries(ON_BY_DEFAULT.map((k) => [`__LBX_NAMED_${k.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}__`, has(() => inFiles(k))])),
|
|
580
|
+
};
|
|
581
|
+
};
|
|
582
|
+
/** Luma's dictionaries' namespaces (src/locales/*.json) a build without the widget leaves out: plp reads search's too. */
|
|
583
|
+
export const LOCALE_NAMESPACES = { autocomplete: ['AUTOCOMPLETE'], search: ['SEARCH', 'PLP'], plp: ['PLP'], recommender: ['RECOMMENDER'] };
|
|
584
|
+
export const pruneDictionary = (json, defines) => {
|
|
585
|
+
const dict = JSON.parse(json);
|
|
586
|
+
for (const [ns, flags] of Object.entries(LOCALE_NAMESPACES)) if (flags.every((f) => defines[`__LBX_${f}__`] === 'false')) delete dict[ns];
|
|
587
|
+
return JSON.stringify(dict);
|
|
588
|
+
};
|
|
589
|
+
const features = (root) => {
|
|
590
|
+
let defines = null;
|
|
591
|
+
return {
|
|
592
|
+
name: 'lbx-features',
|
|
593
|
+
enforce: 'pre',
|
|
594
|
+
config(_, { command }) {
|
|
595
|
+
if (command !== 'build') return undefined;
|
|
596
|
+
defines = featureDefines(resolve(root, 'integration'));
|
|
597
|
+
return { define: defines };
|
|
598
|
+
},
|
|
599
|
+
// Before Vite's JSON plugin: the dictionary as JSON.
|
|
600
|
+
transform(code, id) {
|
|
601
|
+
if (!defines || !id.startsWith(FRAMEWORK) || !/\/locales\/[\w-]+\.json$/.test(id.split('?')[0])) return null;
|
|
602
|
+
return { code: pruneDictionary(code, defines), map: null };
|
|
603
|
+
},
|
|
604
|
+
};
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* The locales the site uses (`locales` of clientConfig: a list), so the
|
|
609
|
+
* entry's map of Luma's dictionaries (src/core/locales.ts, a glob) has those
|
|
610
|
+
* and English only: ~20 B gzip on every page per locale left out. A locale not
|
|
611
|
+
* listed reads English (loadDictionary's fallback). Not declared: every one.
|
|
612
|
+
*/
|
|
613
|
+
export const LOCALES = readdirSync(resolve(FRAMEWORK, 'locales')).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5));
|
|
614
|
+
export const localeGlob = (locales) => {
|
|
615
|
+
if (locales === undefined) return null;
|
|
616
|
+
if (!Array.isArray(locales) || !locales.length) throw new Error(`clientConfig: locales is a list of locales (${LOCALES.join(', ')}), not ${JSON.stringify(locales)}`);
|
|
617
|
+
for (const l of locales) if (!LOCALES.includes(l)) throw new Error(`clientConfig: Luma has no dictionary for locale ${JSON.stringify(l)}: one of ${LOCALES.join(', ')}`);
|
|
618
|
+
return [...new Set(['en', ...locales])].sort().map((l) => `../locales/${l}.json`);
|
|
619
|
+
};
|
|
620
|
+
const onlyLocales = (locales) => {
|
|
621
|
+
const glob = localeGlob(locales);
|
|
622
|
+
return {
|
|
623
|
+
name: 'lbx-only-locales',
|
|
624
|
+
enforce: 'pre',
|
|
625
|
+
transform(code, id) {
|
|
626
|
+
if (!glob || id.split('?')[0] !== resolve(FRAMEWORK, 'core/locales.ts')) return null;
|
|
627
|
+
const from = "import.meta.glob<Dictionary>('../locales/*.json',";
|
|
628
|
+
if (!code.includes(from)) return this.error('src/core/locales.ts: its dictionaries glob changed; update onlyLocales');
|
|
629
|
+
return { code: code.replace(from, `import.meta.glob<Dictionary>(${JSON.stringify(glob)},`), map: null };
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Luma's support for the site's framework, in parts (src/core/frameworks.ts):
|
|
636
|
+
* react React roots and islands (hydration, Suspense)
|
|
637
|
+
* documentReact React hydrating the whole document (the entry's checks)
|
|
638
|
+
* next Next.js pages router (its router, its hydration measure)
|
|
639
|
+
* vue Vue 2 / 3 and Nuxt (mount, router)
|
|
640
|
+
* angular Angular hydration
|
|
641
|
+
* sveltekit SvelteKit (its root, its history entries)
|
|
642
|
+
* astro Astro (islands, ClientRouter history entries)
|
|
643
|
+
* turbo Turbo Drive / Turbolinks (snapshots, history, prefetch)
|
|
644
|
+
* A build has all of them unless the integration declares its framework(s)
|
|
645
|
+
* (`framework` of clientConfig: a name or a list): then only theirs, the rest
|
|
646
|
+
* left out of every chunk. Measured on the test fixture 2026-10-17: see
|
|
647
|
+
* test/browser/specs/budgets.spec.ts.
|
|
648
|
+
*/
|
|
649
|
+
export const SUPPORTS = ['react', 'documentReact', 'next', 'vue', 'angular', 'sveltekit', 'astro', 'turbo'];
|
|
650
|
+
export const FRAMEWORKS = {
|
|
651
|
+
next: ['react', 'documentReact', 'next'],
|
|
652
|
+
remix: ['react', 'documentReact'],
|
|
653
|
+
'react-router': ['react', 'documentReact'],
|
|
654
|
+
react: ['react'],
|
|
655
|
+
vue: ['vue'],
|
|
656
|
+
nuxt: ['vue'],
|
|
657
|
+
angular: ['angular'],
|
|
658
|
+
svelte: [],
|
|
659
|
+
sveltekit: ['sveltekit'],
|
|
660
|
+
astro: ['astro'],
|
|
661
|
+
turbo: ['turbo'],
|
|
662
|
+
none: [],
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
/** The defines of a declaration (undefined: everything). */
|
|
666
|
+
export const frameworkDefines = (framework) => {
|
|
667
|
+
const names = framework === undefined ? null : [framework].flat();
|
|
668
|
+
for (const f of names ?? [])
|
|
669
|
+
if (typeof f !== 'string' || !Object.hasOwn(FRAMEWORKS, f))
|
|
670
|
+
throw new Error(`clientConfig: unknown framework ${JSON.stringify(f)}: one of ${Object.keys(FRAMEWORKS).join(', ')}, a list of them, or leave it out`);
|
|
671
|
+
if (names && !names.length) throw new Error('clientConfig: framework is an empty list: name the site\'s framework(s), none, or leave it out');
|
|
672
|
+
const has = new Set(names ? names.flatMap((f) => FRAMEWORKS[f]) : SUPPORTS);
|
|
673
|
+
return Object.fromEntries(SUPPORTS.map((k) => [`__LBX_${k.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}__`, String(has.has(k))]));
|
|
674
|
+
};
|
|
675
|
+
|
|
447
676
|
/**
|
|
448
677
|
* `overrides` are merged into the config; `plugins` are appended and `server`
|
|
449
678
|
* is merged, so a client repo can add e.g. HTTPS for the loader's development
|
|
450
|
-
* mode without losing ours.
|
|
679
|
+
* mode without losing ours. `framework` (FRAMEWORKS), `serverRendering`
|
|
680
|
+
* (false: the site has no server-rendered listings, src/core/features.ts SSR)
|
|
681
|
+
* and `locales` (onlyLocales) are Luma's, not Vite's.
|
|
451
682
|
*/
|
|
452
683
|
export const clientConfig = (configUrl, overrides = {}) => {
|
|
453
684
|
const root = dirname(fileURLToPath(configUrl));
|
|
@@ -456,11 +687,12 @@ export const clientConfig = (configUrl, overrides = {}) => {
|
|
|
456
687
|
// may read it. Unset: any origin (convenient, but any page open in the
|
|
457
688
|
// developer's browser could read the source while the server runs).
|
|
458
689
|
const devOrigins = process.env.LBX_DEV_ORIGINS?.split(',').map((o) => o.trim()).filter(Boolean);
|
|
459
|
-
const { plugins = [], server = {}, ...rest } = overrides;
|
|
690
|
+
const { plugins = [], server = {}, framework, serverRendering, locales, ...rest } = overrides;
|
|
691
|
+
if (serverRendering !== undefined && typeof serverRendering !== 'boolean') throw new Error(`clientConfig: serverRendering is true or false, not ${JSON.stringify(serverRendering)}`);
|
|
460
692
|
return {
|
|
461
693
|
root,
|
|
462
694
|
resolve: { alias: { '@': FRAMEWORK } },
|
|
463
|
-
define: { __VERSION__: JSON.stringify(new Date().toISOString()) },
|
|
695
|
+
define: { __VERSION__: JSON.stringify(new Date().toISOString()), ...frameworkDefines(framework), __LBX_SSR__: String(serverRendering !== false) },
|
|
464
696
|
// css.postcss: {} stops Vite from picking up a PostCSS config from parent dirs.
|
|
465
697
|
css: { postcss: {} },
|
|
466
698
|
// Class fields are fields (tsconfig.json's ES2022), wherever Luma is: Vite
|
|
@@ -471,7 +703,7 @@ export const clientConfig = (configUrl, overrides = {}) => {
|
|
|
471
703
|
// pre-bundled as a dependency: installed under node_modules/, Vite would.
|
|
472
704
|
optimizeDeps: { exclude: ['@'] },
|
|
473
705
|
base: './',
|
|
474
|
-
plugins: [preloadHelper(), scopeCheck(), unusedBuiltIns(root), rawCss(), templateText(root), terse(), ...plugins],
|
|
706
|
+
plugins: [preloadHelper(), scopeCheck(), features(root), onlyLocales(locales), unusedBuiltIns(root), rawCss(), templateText(root), pureDirectives(), terse(), unreachable(), ...plugins],
|
|
475
707
|
server: {
|
|
476
708
|
port: 8069,
|
|
477
709
|
strictPort: true,
|
|
@@ -488,7 +720,8 @@ export const clientConfig = (configUrl, overrides = {}) => {
|
|
|
488
720
|
// Syntax only: APIs are not polyfilled, which `yarn luma baseline` checks.
|
|
489
721
|
target: 'baseline-widely-available',
|
|
490
722
|
// Preload a lazy chunk's own dependencies in parallel with it.
|
|
491
|
-
|
|
723
|
+
// Each import's own chunk first, even alone (Vite's []): the helper marks it.
|
|
724
|
+
modulePreload: { polyfill: false, resolveDependencies: (file, deps) => [file, ...deps.filter((d) => d !== file)] },
|
|
492
725
|
rollupOptions: {
|
|
493
726
|
input: { integration: resolve(root, 'integration/__integration.ts') },
|
|
494
727
|
output: {
|
package/docs/ACCESSIBILITY.md
CHANGED
|
@@ -105,7 +105,8 @@ guessed alt text, and forced colours and the OS setting are honoured.
|
|
|
105
105
|
the fallback products another, named by their heading and the note that
|
|
106
106
|
they are not results; the help box's phone link is a plain link. "Show all
|
|
107
107
|
products" is a real link (`href`: the
|
|
108
|
-
search page the site's form would submit to
|
|
108
|
+
search page the site's form would submit to, or with `search.queryUrl` the
|
|
109
|
+
query's page, the one Enter loads) and the open dropdown's one
|
|
109
110
|
tab stop: Tab from the input reaches it (the options' selection cleared:
|
|
110
111
|
one thing lit), Shift+Tab goes back, Escape closes and goes back, Tab moves
|
|
111
112
|
on to the page's next control after the input. It is also the arrow keys'
|
|
@@ -165,7 +166,10 @@ guessed alt text, and forced colours and the OS setting are honoured.
|
|
|
165
166
|
opened from the keyboard, focus goes to its selected item
|
|
166
167
|
(`aria-selected`/`aria-current`/`aria-checked`), else the first; arrows move
|
|
167
168
|
through its items; Escape closes it and focus returns to the button; picking
|
|
168
|
-
an item returns focus to the button; Tab out of it closes it.
|
|
169
|
+
an item returns focus to the button; Tab out of it closes it. The build
|
|
170
|
+
includes menus only where a file of the integration's repository (a script or `.html` file, not in `node_modules`, `dist` or a hidden folder) has `data-lbx-menu` (written literally, as in a template). A
|
|
171
|
+
`<details data-lbx-dropdown>` menu closes on a press outside it and on
|
|
172
|
+
picking an item; the same holds for it with `data-lbx-dropdown`.
|
|
169
173
|
- **The drawer** (`[data-lbx-drawer="open|close"]`): `aria-expanded` on its
|
|
170
174
|
openers. With a panel marked `[data-lbx-drawer-panel]`, while open, the panel
|
|
171
175
|
is a modal dialog (`role="dialog"`, `aria-modal="true"`, named "Filters",
|
|
@@ -176,6 +180,9 @@ guessed alt text, and forced colours and the OS setting are honoured.
|
|
|
176
180
|
focus staying on the control. With `facetsApply: 'staged'` its
|
|
177
181
|
changes wait for `[data-lbx-drawer="apply"]`; closing it otherwise discards
|
|
178
182
|
them (the scope's `pending` says whether there is anything to apply).
|
|
183
|
+
The build includes the drawer only where a file of the integration's
|
|
184
|
+
repository (a script or `.html` file, not in `node_modules`, `dist` or a hidden folder) has `data-lbx-drawer` (written
|
|
185
|
+
literally, as in a template).
|
|
179
186
|
- **Facet groups** (built-in template): each group's title is a `<button
|
|
180
187
|
aria-expanded aria-controls>` controlling its values (WAI-ARIA APG
|
|
181
188
|
accordion), focus staying on it. A group's search box
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luigisbox/luma",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Luma: Luigi's Box frontend integrations (autocomplete, search, category listings, recommenders) on the Luigi's Box AI API. Pre-release.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"snippet/early-hold.min.js",
|
|
30
30
|
"docs/ACCESSIBILITY.md",
|
|
31
31
|
"docs/INTEGRATION-PERFORMANCE.md",
|
|
32
|
-
"docs/PARITY.md"
|
|
32
|
+
"docs/PARITY.md",
|
|
33
|
+
"CHANGELOG.md"
|
|
33
34
|
],
|
|
34
35
|
"publishConfig": {
|
|
35
36
|
"access": "public"
|
package/src/analytics/chain.ts
CHANGED
|
@@ -26,6 +26,14 @@ export interface AddToCart {
|
|
|
26
26
|
reference: string;
|
|
27
27
|
count?: number;
|
|
28
28
|
price?: number;
|
|
29
|
+
/** Added in a list (a tile's cart button): the platform credits it to that list. */
|
|
30
|
+
attributedToList?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/** One click: what `collect.click` sends (a bare reference: not in a list). */
|
|
33
|
+
export interface Click {
|
|
34
|
+
reference: string;
|
|
35
|
+
/** Picked in a list (a search, suggestions, a recommender, a listing): the platform credits it to that list. */
|
|
36
|
+
attributedToList?: boolean;
|
|
29
37
|
}
|
|
30
38
|
/** A purchase: what `collect.purchase` sends, once per `id`. */
|
|
31
39
|
export interface Purchase {
|
|
@@ -42,7 +50,7 @@ export interface List {
|
|
|
42
50
|
/** What `.read()` returns, per event (null or undefined: nothing sent). */
|
|
43
51
|
export interface Reads {
|
|
44
52
|
pageView: string | null | undefined;
|
|
45
|
-
click: string |
|
|
53
|
+
click: string | Click;
|
|
46
54
|
addToCart: AddToCart | AddToCart[];
|
|
47
55
|
purchase: Purchase;
|
|
48
56
|
search: List;
|
|
@@ -63,7 +71,7 @@ export const reader = <E extends EventName, C>(event: E, f: (ctx: C) => Reads[E]
|
|
|
63
71
|
/** `.read(r)` on event `name`: `r`, or an error when it is a reader of another event. */
|
|
64
72
|
export const checked = (name: string, r: { event?: string }) => {
|
|
65
73
|
const e = name.startsWith('recommender:') ? 'recommender' : name;
|
|
66
|
-
if (r.event && r.event !== e) throw
|
|
74
|
+
if (r.event && r.event !== e) throw Error(`analytics: collect.${e}.read(): a reader of ${r.event}`);
|
|
67
75
|
};
|
|
68
76
|
|
|
69
77
|
export interface Chain<E extends EventName, C> {
|
package/src/analytics/collect.ts
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
// - pageView: `pv` per page (./pageview.ts);
|
|
4
4
|
// - search, autocomplete, recommender(name): a `list` event of the site's own
|
|
5
5
|
// service (`{ query?, items, filters? }`; ours the API records itself);
|
|
6
|
-
// - click: an `interaction` click (a reference, or `{ reference }`);
|
|
7
|
-
// - addToCart: `interaction` add_to_cart per item (`{ reference, count?, price? }` or a list);
|
|
6
|
+
// - click: an `interaction` click (a reference, or `{ reference, attributedToList? }`);
|
|
7
|
+
// - addToCart: `interaction` add_to_cart per item (`{ reference, count?, price?, attributedToList? }` or a list);
|
|
8
8
|
// - purchase: a `transaction` (`{ id?, currency?, items }`), once per id.
|
|
9
|
+
// `attributedToList`: the product was picked in a list (a search, suggestions,
|
|
10
|
+
// a recommender, a listing): sent as `attributed_to_list: true`, the only
|
|
11
|
+
// interactions the platform credits to the last list in the session that
|
|
12
|
+
// held the product. Without it the interaction counts for the product alone.
|
|
9
13
|
|
|
10
14
|
import { emit } from './outbox';
|
|
11
15
|
import { state } from './setup';
|
|
@@ -26,24 +30,27 @@ export const autocomplete = /* @__PURE__ */ surface<'autocomplete'>('autocomplet
|
|
|
26
30
|
/** The site's own recommender `name` (silent where ours of that name runs): a `list` event. */
|
|
27
31
|
export const recommender = (name: string) => surface<'recommender'>(`recommender:${name}`, list('Recommendation'));
|
|
28
32
|
|
|
29
|
-
/**
|
|
33
|
+
/** `attributed_to_list: true` where the product was picked in a list (only then: absent is false). */
|
|
34
|
+
const inList = (x: { attributedToList?: boolean } | undefined) => (x?.attributedToList === true ? { attributed_to_list: true } : {});
|
|
35
|
+
|
|
36
|
+
/** A product click: the reference (or `{ reference, attributedToList? }`). */
|
|
30
37
|
export const click = /* @__PURE__ */ surface<'click'>('click', (p) => {
|
|
31
|
-
const
|
|
32
|
-
if (isRef(reference)) emit({ type: 'interaction', interaction_type: 'click', listing_ab_test: false, reference });
|
|
38
|
+
const o = typeof p === 'string' ? { reference: p } : p;
|
|
39
|
+
if (isRef(o?.reference)) emit({ type: 'interaction', interaction_type: 'click', listing_ab_test: false, reference: o.reference, ...inList(o) });
|
|
33
40
|
});
|
|
34
41
|
|
|
35
42
|
const currency = () => state.g?.currency;
|
|
36
43
|
/** When the last add-to-cart on our tiles was: the site's own push its cart then makes is the same one. */
|
|
37
44
|
let ourCart = 0;
|
|
38
45
|
const OUR_CART_MS = 10_000;
|
|
39
|
-
/** Add-to-cart: `{ reference, count?, price? }` or a list of them. */
|
|
46
|
+
/** Add-to-cart: `{ reference, count?, price?, attributedToList? }` or a list of them. */
|
|
40
47
|
export const addToCart = /* @__PURE__ */ surface<'addToCart'>('addToCart', (p) => {
|
|
41
48
|
const all: (AddToCart & { [OURS]?: true })[] = Array.isArray(p) ? p : [p];
|
|
42
49
|
if (all.some((x) => x?.[OURS])) ourCart = Date.now();
|
|
43
50
|
else if (Date.now() - ourCart < OUR_CART_MS) return void (ourCart = 0);
|
|
44
51
|
for (const x of all) {
|
|
45
52
|
if (!isRef(x?.reference)) continue;
|
|
46
|
-
emit({ type: 'interaction', interaction_type: 'add_to_cart', listing_ab_test: false, reference: x.reference, ...(x.count ? { count: x.count } : {}), ...(x.price != null && x.price >= 0 ? { price: x.price } : {}), ...(currency() ? { currency: currency() } : {}) });
|
|
53
|
+
emit({ type: 'interaction', interaction_type: 'add_to_cart', listing_ab_test: false, reference: x.reference, ...(x.count ? { count: x.count } : {}), ...(x.price != null && x.price >= 0 ? { price: x.price } : {}), ...(currency() ? { currency: currency() } : {}), ...inList(x) });
|
|
47
54
|
}
|
|
48
55
|
});
|
|
49
56
|
|
|
@@ -56,8 +56,11 @@ export const purchase = /* @__PURE__ */ reader('purchase', (ctx: Ctx) => {
|
|
|
56
56
|
});
|
|
57
57
|
return items.length ? { id: e.transaction_id, currency: e.currency, items } : null;
|
|
58
58
|
});
|
|
59
|
-
/** select_item: the product clicked. */
|
|
60
|
-
export const click = /* @__PURE__ */ reader('click',
|
|
59
|
+
/** select_item (GA4: an item picked from a list): the product clicked, in its list. */
|
|
60
|
+
export const click = /* @__PURE__ */ reader('click', (ctx: Ctx) => {
|
|
61
|
+
const reference = first(ctx);
|
|
62
|
+
return reference ? { reference, attributedToList: true } : null;
|
|
63
|
+
});
|
|
61
64
|
/** view_item: the page's product. */
|
|
62
65
|
export const pageView = /* @__PURE__ */ reader('pageView', first);
|
|
63
66
|
/** view_item_list of their search: its items, the query from search_term. */
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// collect.addToCart.on(lumaCarts()).read(fromLuma.addToCart);
|
|
7
7
|
//
|
|
8
8
|
// Our tiles' references are their results' `@id`s (every result has one),
|
|
9
|
-
// whatever ref.product.by() says for the site's own data.
|
|
9
|
+
// whatever ref.product.by() says for the site's own data. Every tile of ours
|
|
10
|
+
// is in a list (a search, suggestions, a recommender, a listing): its clicks
|
|
11
|
+
// and add-to-carts are attributed to it.
|
|
10
12
|
|
|
11
13
|
import type { TileEvent } from '../core/tiles';
|
|
12
14
|
import { reader } from './chain';
|
|
@@ -15,7 +17,7 @@ import type { Page } from './page';
|
|
|
15
17
|
|
|
16
18
|
/** A page: no reference of its own. */
|
|
17
19
|
export const pageView = /* @__PURE__ */ reader('pageView', (_: Page) => null);
|
|
18
|
-
/** A click on one of our tiles: the product clicked. */
|
|
19
|
-
export const click = /* @__PURE__ */ reader('click', (t: TileEvent) => t.reference);
|
|
20
|
+
/** A click on one of our tiles: the product clicked, in its list. */
|
|
21
|
+
export const click = /* @__PURE__ */ reader('click', (t: TileEvent) => ({ reference: t.reference, attributedToList: true }));
|
|
20
22
|
/** `[data-lbx-cart]` on one of our tiles: the product, its quantity and price (the site's own push its cart then makes is the same one). */
|
|
21
|
-
export const addToCart = /* @__PURE__ */ reader('addToCart', (t: TileEvent) => ({ reference: t.reference, count: t.count ?? 1, ...(t.price != null ? { price: t.price } : {}), [OURS]: true as const }));
|
|
23
|
+
export const addToCart = /* @__PURE__ */ reader('addToCart', (t: TileEvent) => ({ reference: t.reference, count: t.count ?? 1, ...(t.price != null ? { price: t.price } : {}), attributedToList: true, [OURS]: true as const }));
|
package/src/analytics/index.ts
CHANGED
|
@@ -19,6 +19,6 @@ export { user } from './user';
|
|
|
19
19
|
export { ref } from './ref';
|
|
20
20
|
export { dl, domClick, lumaCarts, lumaClicks, lumaPages, manual, mutationOf, urlChange, xhrMatching } from './triggers';
|
|
21
21
|
export type { DlContext, DomClickContext, ManualContext, MutationContext, UrlChangeContext, XhrContext } from './triggers';
|
|
22
|
-
export type { AddToCart, List, Purchase, Read, Trigger } from './chain';
|
|
22
|
+
export type { AddToCart, Click, List, Purchase, Read, Trigger } from './chain';
|
|
23
23
|
export type { Page } from './page';
|
|
24
24
|
export type { TileEvent } from '../core/tiles';
|
|
@@ -42,7 +42,7 @@ export const pageView = {
|
|
|
42
42
|
},
|
|
43
43
|
read(r) {
|
|
44
44
|
checked('pageView', r);
|
|
45
|
-
if (chained) throw
|
|
45
|
+
if (chained) throw Error('analytics: one collect.pageView chain per setup');
|
|
46
46
|
chained = true;
|
|
47
47
|
const ref = (ctx: C) => {
|
|
48
48
|
try {
|
package/src/analytics/start.ts
CHANGED
|
@@ -33,10 +33,9 @@ export const start = async (o: StartOptions) => {
|
|
|
33
33
|
if (started) return;
|
|
34
34
|
started = true;
|
|
35
35
|
state.g = o.config._global;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
env
|
|
39
|
-
env.kept = o.kept;
|
|
36
|
+
// One call, not a read per field: a setup that uses none of them leaves
|
|
37
|
+
// Rollup no property reads to keep (it keeps a read: it may be a getter).
|
|
38
|
+
Object.assign(env, o);
|
|
40
39
|
// The setup (`_global` first). Its chains register as it runs.
|
|
41
40
|
for (const [p, f] of Object.entries(o.files).sort(([a], [b]) => (a.includes('/_global/') ? -1 : b.includes('/_global/') ? 1 : 0)))
|
|
42
41
|
try {
|
package/src/analytics/wire.ts
CHANGED
|
@@ -51,7 +51,7 @@ export const readDl = (x: any): DlEntry | null => {
|
|
|
51
51
|
return null;
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
-
const IGNORE =
|
|
54
|
+
const IGNORE = RegExp(`${TRACKING}|^${P.replace('.', '\\.')}`);
|
|
55
55
|
/**
|
|
56
56
|
* The page a URL shows, for pageviews: without tracking params, the hash and
|
|
57
57
|
* our own `lbx.*` (a filter, a sort on our listing is the same page). A
|