@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +95 -14
  3. package/build/baseline.mjs +2 -2
  4. package/build/vite.mjs +253 -20
  5. package/docs/ACCESSIBILITY.md +9 -2
  6. package/package.json +3 -2
  7. package/src/analytics/chain.ts +10 -2
  8. package/src/analytics/collect.ts +14 -7
  9. package/src/analytics/from-ga4.ts +5 -2
  10. package/src/analytics/from-luma.ts +6 -4
  11. package/src/analytics/index.ts +1 -1
  12. package/src/analytics/pageview.ts +1 -1
  13. package/src/analytics/start.ts +3 -4
  14. package/src/analytics/wire.ts +1 -1
  15. package/src/api/ai.ts +32 -20
  16. package/src/api/token.ts +7 -7
  17. package/src/core/a11y.ts +6 -35
  18. package/src/core/context.ts +2 -0
  19. package/src/core/contrast-toggle.ts +32 -0
  20. package/src/core/default-button.ts +66 -0
  21. package/src/core/early-rules.ts +18 -4
  22. package/src/core/features.ts +109 -0
  23. package/src/core/frameworks-check.ts +32 -0
  24. package/src/core/frameworks.ts +35 -0
  25. package/src/core/image-load.ts +0 -26
  26. package/src/core/image-preload.ts +29 -0
  27. package/src/core/image-tags.ts +7 -1
  28. package/src/core/images.ts +3 -2
  29. package/src/core/integration.ts +42 -26
  30. package/src/core/listings.ts +9 -5
  31. package/src/core/locales.ts +2 -1
  32. package/src/core/own.ts +7 -3
  33. package/src/core/recommenders.ts +2 -2
  34. package/src/core/reconciler.ts +32 -23
  35. package/src/core/routes.ts +7 -6
  36. package/src/core/scheduler.ts +39 -5
  37. package/src/core/speculation.ts +2 -0
  38. package/src/core/stash.ts +15 -6
  39. package/src/core/styles.ts +6 -5
  40. package/src/core/support.ts +1 -1
  41. package/src/core/takeover.ts +6 -5
  42. package/src/core/templates.ts +5 -2
  43. package/src/core/tiles.ts +33 -10
  44. package/src/core/url.ts +6 -3
  45. package/src/core/widget-inits.ts +4 -1
  46. package/src/lit.ts +2 -1
  47. package/src/locales/cs.json +0 -1
  48. package/src/locales/de.json +0 -1
  49. package/src/locales/en.json +0 -1
  50. package/src/main.ts +70 -53
  51. package/src/types.ts +29 -9
  52. package/src/widgets/autocomplete/index.ts +58 -19
  53. package/src/widgets/listing/element.ts +81 -58
  54. package/src/widgets/listing/query.ts +6 -3
  55. package/src/widgets/listing/stash.ts +33 -16
  56. package/src/widgets/listing/state.ts +12 -0
  57. package/src/widgets/listing/view.ts +2 -1
  58. package/src/widgets/plp/route.ts +3 -2
  59. package/src/widgets/recommender/index.ts +3 -2
  60. package/src/widgets/search/direct.ts +39 -0
  61. package/src/widgets/search/form-url.ts +38 -34
  62. package/src/widgets/search/in-place.ts +43 -108
  63. package/src/widgets/search/index.ts +7 -2
  64. package/src/widgets/search/speculate.ts +48 -27
  65. 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 the plp widget and its templates,
25
- // placement and recommender triggers, the search page's own,
26
- // search while typing, minting the token (chunkOf)
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, in-place query, URL writing): typing on
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 search page's own (its start, a new query in place): not in lbx-listing,
92
- // which a category page loads too (2.1 kB gzip it never ran).
93
- if (/\/src\/widgets\/search\/(index|in-place)\.ts$/.test(id)) return 'lbx-search';
94
- // Search while typing (main.ts loads it once the shopper types): not in lbx-nav, which a listing page loads at once.
95
- if (/\/src\/widgets\/search\/speculate\.ts$/.test(id)) return 'lbx-speculate';
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
- const manualChunks = (id, { getModuleInfo, getModuleIds }) => (entryGraph(getModuleInfo, getModuleIds).has(id) ? 'integration' : chunkOf(id));
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
- modulePreload: { polyfill: false },
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: {
@@ -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) and the open dropdown's one
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.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"
@@ -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 | { reference: 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 new Error(`analytics: collect.${e}.read(): a reader of ${r.event}`);
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> {
@@ -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
- /** A product click: the reference (or `{ reference }`). */
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 reference = typeof p === 'string' ? p : p?.reference;
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', first);
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 }));
@@ -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 new Error('analytics: one collect.pageView chain per setup');
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 {
@@ -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
- env.owns = o.owns;
37
- env.visit = o.visit;
38
- env.onUrlChange = o.onUrlChange;
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 {
@@ -51,7 +51,7 @@ export const readDl = (x: any): DlEntry | null => {
51
51
  return null;
52
52
  };
53
53
 
54
- const IGNORE = new RegExp(`${TRACKING}|^${P.replace('.', '\\.')}`);
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