@luigisbox/luma 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +75 -8
  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 +1 -1
  8. package/src/analytics/pageview.ts +1 -1
  9. package/src/analytics/start.ts +3 -4
  10. package/src/analytics/wire.ts +1 -1
  11. package/src/api/ai.ts +32 -20
  12. package/src/api/token.ts +7 -7
  13. package/src/core/a11y.ts +6 -35
  14. package/src/core/context.ts +2 -0
  15. package/src/core/contrast-toggle.ts +32 -0
  16. package/src/core/default-button.ts +66 -0
  17. package/src/core/early-rules.ts +3 -2
  18. package/src/core/features.ts +109 -0
  19. package/src/core/frameworks-check.ts +32 -0
  20. package/src/core/frameworks.ts +35 -0
  21. package/src/core/image-load.ts +0 -26
  22. package/src/core/image-preload.ts +29 -0
  23. package/src/core/image-tags.ts +7 -1
  24. package/src/core/images.ts +3 -2
  25. package/src/core/integration.ts +42 -26
  26. package/src/core/listings.ts +9 -5
  27. package/src/core/locales.ts +2 -1
  28. package/src/core/own.ts +7 -3
  29. package/src/core/recommenders.ts +2 -2
  30. package/src/core/reconciler.ts +32 -23
  31. package/src/core/routes.ts +7 -6
  32. package/src/core/scheduler.ts +39 -5
  33. package/src/core/speculation.ts +2 -0
  34. package/src/core/stash.ts +15 -6
  35. package/src/core/styles.ts +6 -5
  36. package/src/core/support.ts +1 -1
  37. package/src/core/takeover.ts +6 -5
  38. package/src/core/templates.ts +5 -2
  39. package/src/core/tiles.ts +33 -10
  40. package/src/core/url.ts +6 -3
  41. package/src/core/widget-inits.ts +4 -1
  42. package/src/lit.ts +2 -1
  43. package/src/locales/cs.json +0 -1
  44. package/src/locales/de.json +0 -1
  45. package/src/locales/en.json +0 -1
  46. package/src/main.ts +70 -53
  47. package/src/types.ts +29 -9
  48. package/src/widgets/autocomplete/index.ts +58 -19
  49. package/src/widgets/listing/element.ts +81 -58
  50. package/src/widgets/listing/query.ts +6 -3
  51. package/src/widgets/listing/stash.ts +33 -16
  52. package/src/widgets/listing/state.ts +12 -0
  53. package/src/widgets/listing/view.ts +2 -1
  54. package/src/widgets/plp/route.ts +3 -2
  55. package/src/widgets/recommender/index.ts +3 -2
  56. package/src/widgets/search/direct.ts +39 -0
  57. package/src/widgets/search/form-url.ts +38 -34
  58. package/src/widgets/search/in-place.ts +43 -108
  59. package/src/widgets/search/index.ts +7 -2
  60. package/src/widgets/search/speculate.ts +48 -27
  61. package/src/widgets/search/submit.ts +136 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,118 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@luigisbox/luma`. Follows [semver](https://semver.org);
4
+ before 1.0, a minor version may break the integration API.
5
+
6
+ ## Unreleased
7
+
8
+ ## 0.5.0 — 2026-10-19
9
+
10
+ Upgrading from 0.4.0: read **Changed** first.
11
+
12
+ ### Added
13
+
14
+ - `search.queryUrl` hook: the search page URL for a phrase. Use it when the
15
+ site's server redirects its search form (e.g. `/search?q=x` → `/search/x/`).
16
+ Enter, suggestions and "show all results" then go straight to that page.
17
+ - `search.pageWhileTyping`: `'prefetch'` (default), `'prerender'` or `false`.
18
+ `prerenderWhileTyping: true` equals `'prerender'`.
19
+ - `clientConfig` option `framework` (name or list): build in support for these
20
+ frameworks only. List every framework that renders part of a page Luma runs
21
+ on. Unknown names fail the build.
22
+ - `clientConfig` option `serverRendering: false`: leave out hydration of
23
+ server-rendered listings.
24
+ - `clientConfig` option `locales`: ship only these dictionaries (plus English).
25
+ Unknown locales or an empty list fail the build.
26
+
27
+ ### Changed
28
+
29
+ - The build includes only widgets that have a folder (`autocomplete/`,
30
+ `search/`, `plp/`, `recommender <name>/`). Config for a widget without a
31
+ folder is ignored. A `recommender` folder without a name fails the build.
32
+ `yarn dev` still includes every widget.
33
+ - These are built only if their name appears literally in a `.ts`, `.js` (or
34
+ variant) or `.html` file of the integration's repository, outside
35
+ `node_modules`, `dist` and hidden folders:
36
+ - attributes `data-lbx-cart`, `data-lbx-drawer`, `data-lbx-menu`,
37
+ `data-lbx-dropdown`
38
+ - hooks `onRender`, `readQuery`, `queryUrl`, `facetsFor`, `onOpen`,
39
+ `onClose`, `onSelect`, `onReady`
40
+ - options `emptyValues`, `prerenderWhileTyping` / `pageWhileTyping`,
41
+ `contentVisibility`
42
+
43
+ A name that only appears in a `.vue`, `.svelte` or `.hbs` file or in a
44
+ package does not count.
45
+ - The search page for the typed phrase is prefetched by default. Set
46
+ `search.pageWhileTyping: false` to turn it off.
47
+ - Results fetched while typing are cached per query, not per URL.
48
+ - Enter in the search form follows the browser's implicit submission rules.
49
+ - The `_config.ts` check (no functions) runs in development only.
50
+
51
+ ### Fixed
52
+
53
+ - WebKit: when frames stalled, the site's listing could show instead of
54
+ Luma's.
55
+ - Safari: listing images were all fetched on render, twice. Bind `src` last
56
+ in your own `<img>` templates too.
57
+ - WebKit: two chunks were fetched twice per page.
58
+ - Autocomplete: ARIA attributes could stay on the site's input after focus
59
+ left.
60
+
61
+ ## 0.4.0 — 2026-10-16
62
+
63
+ ### Added
64
+
65
+ - Readers can return `{ reference, attributedToList: true }` for clicks and
66
+ add-to-carts on the site's own lists.
67
+
68
+ ### Fixed
69
+
70
+ - Analytics: clicks and add-to-carts from Luma's widgets were never credited
71
+ to the search or recommendation that showed the product. They now carry
72
+ `attributed_to_list: true`, as does GA4 `select_item`.
73
+ - Early hold now covers search routes with the query in the path (`urlMatch`
74
+ with a capture group). The snippet is unchanged.
75
+
76
+ ## 0.3.0 — 2026-10-15
77
+
78
+ ### Added
79
+
80
+ - `alignTo: { field, column }` for the autocomplete panel. By default the
81
+ panel stays within the page's content column.
82
+
83
+ ### Changed
84
+
85
+ - Cancelling the phone autocomplete sheet restores the site's input value.
86
+ - Back closes an open staged filter drawer (`facetsApply`) and discards its
87
+ changes.
88
+ - Reloading a listing within a minute after "show more" restores it as left.
89
+
90
+ ### Fixed
91
+
92
+ - Phone autocomplete sheet: sized to the visible viewport, shown in
93
+ landscape, `autocorrect="off"`, keeps letters typed while it opens.
94
+ - Autocomplete: lists scroll to top on new results; top product's prices
95
+ sit under its title.
96
+ - iOS Safari: Back to a listing restores position and "show more" pages.
97
+ - Listing position survives a reload or a redirect back within a minute.
98
+ - Position is restored at the clicked link, not a hidden copy.
99
+ - Staged drawer: Apply scrolls to the listing's top; crossing a breakpoint
100
+ closes the drawer.
101
+ - A filter group opened by a selection stays open when the value is
102
+ unticked.
103
+ - Range slider: touch drag on the track follows the finger; vertical scroll
104
+ starting on the track doesn't move a thumb.
105
+
106
+ ## 0.2.0 — 2026-10-13
107
+
108
+ ### Added
109
+
110
+ - Analytics, configured in `_global/_analytics.ts`: one `collect.<event>` line
111
+ per event, with triggers (`lumaPages`, `lumaClicks`, `lumaCarts`, `dl`,
112
+ `domClick`, `urlChange`, `mutationOf`, `xhrMatching`, `manual`) and readers
113
+ (`fromLuma`, `fromGa4` or a function). `analytics: false` turns it off.
114
+ - `yarn luma chrome --allow HOST`: keep that host's Luigi's Box scripts.
115
+
116
+ ## 0.1.0 — 2026-10-12
117
+
118
+ First published version.
package/README.md CHANGED
@@ -10,10 +10,10 @@ This package is what an integration is built with. Each integration lives in
10
10
  its own repository and depends on `@luigisbox/luma`
11
11
  ([Integration repository](#integration-repository)).
12
12
 
13
- > **Pre-release.** Until 1.0, a minor version (0.5.0) may change the
13
+ > **Pre-release.** Until 1.0, a minor version (0.6.0) may change the
14
14
  > integration API (config keys, template scope, the build); a patch version
15
- > (0.4.1) only fixes. Depend on `^0.4.0` to take fixes without breaking
16
- > changes.
15
+ > (0.5.1) only fixes. Depend on `^0.5.0` to take fixes without breaking
16
+ > changes. [`CHANGELOG.md`](CHANGELOG.md) says what each version changed.
17
17
 
18
18
  ## Core principles
19
19
 
@@ -245,6 +245,51 @@ import { clientConfig } from '@luigisbox/luma/build/vite.mjs';
245
245
  export default clientConfig(import.meta.url, { plugins: [mkcert()], server: { host: 'localhost', https: {} } });
246
246
  ```
247
247
 
248
+ `framework` names the site's frontend framework, so the build carries Luma's
249
+ support for that framework only. Without it, every integration carries the
250
+ support for all of them (waiting for each one's hydration, writing URLs through
251
+ its router, keeping out of its page snapshots), and Luma tells them apart on
252
+ each page. Declared, a listing page loads about 1.4 to 2.3 kB gzip less, and
253
+ every page about 0.2 kB less unless React hydrates the whole document. It is
254
+ one of the following, or a list of them:
255
+
256
+ | `framework` | the site |
257
+ |---|---|
258
+ | `next` | Next.js (app or pages router) |
259
+ | `remix`, `react-router` | Remix, React Router 7, Hydrogen |
260
+ | `react` | a React app in a root element (Vite, Create React App) |
261
+ | `vue`, `nuxt` | Vue 2 or 3, Nuxt |
262
+ | `angular` | Angular |
263
+ | `svelte`, `sveltekit` | a Svelte app, SvelteKit |
264
+ | `astro` | Astro (add the framework of its islands that hold Luma's anchors) |
265
+ | `turbo` | Turbo Drive or Turbolinks |
266
+ | `none` | server-rendered pages: jQuery, htmx, Alpine |
267
+
268
+ ```ts
269
+ export default clientConfig(import.meta.url, { framework: 'vue', plugins: [mkcert()], server: { host: 'localhost', https: {} } });
270
+ ```
271
+
272
+ `serverRendering: false` declares that the site has no server-rendered
273
+ listings (the server-side includes above): the build then leaves out adopting
274
+ and hydrating the server's markup, about 0.9 kB gzip on a listing page. Leave
275
+ it out when the site uses them or may; in development Luma reports
276
+ server-rendered markup a build without them meets.
277
+
278
+ `locales` lists the locales the site uses (`['cs', 'sk']`): every page then
279
+ carries the map of those dictionaries and English only (about 20 B gzip per
280
+ locale left out). A widget in a locale not listed reads English. Without it,
281
+ every locale Luma has a dictionary for. A locale Luma has no dictionary for
282
+ fails the build.
283
+
284
+ List every framework that renders a part of the page Luma places into or
285
+ navigates with, on every page Luma runs on (`['astro', 'react']` for an Astro
286
+ site with React islands). Without its framework's support, Luma does not
287
+ wait for that framework's hydration: React or Svelte renders the page anew,
288
+ Vue drops Luma's element, Angular binds the wrong element, a router puts its
289
+ own URL back. In development (`yarn dev`) Luma reports a framework the page
290
+ uses and the build leaves out in `window.__lbx.health.errors`. An unknown name
291
+ fails the build.
292
+
248
293
  Its `tsconfig.json` extends `@luigisbox/luma/tsconfig.json`, includes
249
294
  `integration/**/*` and `node_modules/@luigisbox/luma/src/**/*`, and maps `@/*`
250
295
  to `./node_modules/@luigisbox/luma/src/*`. Its `package.json` depends on
@@ -265,19 +310,31 @@ integration/
265
310
  recommender <name>/ _config.ts [_init.ts] _translations.ts
266
311
  ```
267
312
 
268
- - **`_config.ts` is JSON** (checked at boot); anything that needs code goes in
313
+ - **`_config.ts` is JSON** (checked at boot in development: `yarn dev`);
314
+ anything that needs code goes in
269
315
  `_init.ts`. `_global/_init.ts` runs first and can change any config or return
270
316
  `false` to stop; a widget's `_init.ts` gets that widget's config. Every config
271
317
  key is typed and documented in `src/types.ts`.
318
+ - **A widget is its folder.** The build carries only the widgets the
319
+ integration has folders for (`autocomplete/`, `search/`, `plp/`,
320
+ `recommender <name>/`) and analytics only where a folder has an
321
+ `_analytics.ts`: an integration with an autocomplete only loads about half
322
+ as much on every page as one with every widget. A config `_global/_init.ts`
323
+ creates for a widget without its folder is ignored. The development server
324
+ (`yarn dev`) carries every widget.
272
325
  - **`_init.ts` ships and runs on every page; `_widget.ts` loads with its
273
326
  widget.** Keep in `_init.ts` only what decides whether and where a widget
274
- runs (`disabled`, `active`, `readQuery`, `items`, `intent`, selectors set in
327
+ runs (`disabled`, `active`, `readQuery`, `queryUrl`, `items`, `intent`, selectors set in
275
328
  code, switching the site's own widgets off). Hooks only a widget calls
276
329
  (`computedAttributes`, `toFilter`, `facetsFor`, `onRender`, `onOpen`, …) go
277
330
  in the folder's optional `_widget.ts`, same signature (its return value is
278
331
  ignored): it is loaded with the widget's chunk and run once before its
279
332
  context is made, `_global/_widget.ts` first. `__integration.ts` hands it
280
333
  over as a lazy glob: `widgetInits: import.meta.glob('./*/_widget.ts', { import: 'default' })`.
334
+ The build calls `onRender`, `readQuery`, `queryUrl`, `facetsFor` and the autocomplete's
335
+ `onOpen`, `onClose`, `onSelect`, `onReady` only where a file of the
336
+ integration's repository (a script or `.html` file, not in `node_modules`, `dist` or a hidden folder) names them, as setting
337
+ them does (`config.search.onRender = …`).
281
338
  - **`style-host.css`** hides the host's own listing or suggestions, only while
282
339
  Luma has the page. Other `style-*.css` load with their widget. Luma's built-in
283
340
  CSS sits in `@layer lbx`, so the integration's and the site's CSS win.
@@ -328,7 +385,10 @@ export default ({ r, i, img, price }: SearchResultScope) => html`
328
385
  - **Helpers** in every scope: `t(key, params)`, `price(n)`, `plain()`,
329
386
  `first()`, `include()`, and `img(r, i)` (a complete `<img>` in its placeholder
330
387
  box) / `image(r, i)` (its attributes, for your own markup), configured by
331
- `_global.images` (srcset, sizes, the eager first row, placeholders).
388
+ `_global.images` (srcset, sizes, the eager first row, placeholders). In
389
+ your own `<img>`, bind `src` last: Safari starts the fetch when `src` is
390
+ set, so a `loading` or `srcset` bound after it loads every image at once,
391
+ and twice.
332
392
  - **Pagination** is "show more" only: templates get `results`, `more` and
333
393
  `showMore`; there are no numbered pages.
334
394
  - **No results under filters**: the scope still holds the selected groups,
@@ -348,11 +408,17 @@ export default ({ r, i, img, price }: SearchResultScope) => html`
348
408
  ### Behaviour switches
349
409
 
350
410
  On by default unless noted; each falls back to what the page would do anyway.
411
+ `emptyValues`, `prerenderWhileTyping` and `contentVisibility`, off by
412
+ default, are in the build only where a file of the integration's repository
413
+ (a script or `.html` file, not in `node_modules`, `dist` or a hidden folder) names them, as `_config.ts` or `_init.ts` does when
414
+ it sets them; a switch on by default that no file names is built as on.
351
415
 
352
416
  | Config | What it does | Turn it off when |
353
417
  |---|---|---|
354
418
  | `search.inPlaceQuery` | a new query on the search page renders in place, without a page load | the host's page-view analytics or server-rendered parts of the search page must follow every query |
355
419
  | `search.prefetchWhileTyping` | the search result is fetched once the typed phrase settles, so Enter shows it at once | |
420
+ | `search.pageWhileTyping` (`'prefetch'`, `'prerender'`, `false`) | the search page of the typed phrase is fetched ahead (Chrome, Edge; Firefox and Safari as they ship it), so Enter from another page skips the site's server render; `'prerender'` renders it ahead as well (Chrome, Edge) | the site cannot take a document request per typed phrase; `'prerender'` only where its tags wait for `prerenderingchange` |
421
+ | `search.queryUrl` (an `_init.ts` hook, off unless set) | where the site's server redirects its search form to the query's page (`/search?q=x` to `/search/x/`), names that page: Enter, the autocomplete's "show all" and a picked query load it directly, without the redirect, and the result fetched while typing is shown there | the form's other fields decide the page, or the site must handle every search itself |
356
422
  | `restoreOnBack` | Back from a product restores the listing (every page shown) and the followed tile's position, as does the page loaded again within a minute of following a link (a reload, a link whose server redirects back to it); Luma turns the browser's own scroll restoration (`history.scrollRestoration`) off for the listing's history entry until it has placed the page, unless the site set it | |
357
423
  | `prefetchNextPage` | "show more" is fetched as the shopper nears the end | |
358
424
  | `contentVisibility` (opt-in) | tiles out of view skip style, layout and paint, each at its own measured size: a long list's re-render about halves | keep it off where a tile draws outside its box (a hover panel, a badge hanging out) or holds a `position: fixed` element |
@@ -416,7 +482,7 @@ collect.purchase.on(dl('purchase')).read(fromGa4.purchase);
416
482
  |---|---|---|
417
483
  | `lumaPages()` | each page: the first, the next page of a single-page app, a page restored from the back/forward cache, a prerendered page once it is shown. Filtering or sorting a Luma listing is not a new page | `{ url, referrer }` |
418
484
  | `lumaClicks()` | a click on a product in a Luma widget: any link to it, middle and modifier clicks included, and a pick in the autocomplete | `{ reference, result, index, list }` |
419
- | `lumaCarts()` | a click on a `[data-lbx-cart]` button in a Luma product tile (its value: the quantity). `_global.addToCart(result, { count, el, event })` adds the product to the site's cart, with or without analytics | `{ reference, result, index, list, count, price? }` |
485
+ | `lumaCarts()` | a click on a `[data-lbx-cart]` button in a Luma product tile (its value: the quantity). `_global.addToCart(result, { count, el, event })` adds the product to the site's cart, with or without analytics. The attribute is written in a template of the integration as `data-lbx-cart`: the build includes add-to-cart only where a file of the integration's repository (a script or `.html` file, not in `node_modules`, `dist` or a hidden folder) has it | `{ reference, result, index, list, count, price? }` |
420
486
  | `dl(event)` | a dataLayer push with that event name, object or `gtag()` form, including pushes made before Luma loaded. `_global.dataLayer` in `_config.ts` names another layer | `{ event, eventType, data, ecommerce }` |
421
487
  | `domClick(selector)` | a click (or middle click) on an element of the site's that matches `selector` | `{ element, event }` |
422
488
  | `urlChange()` | a navigation in a single-page app | `{ url, previousUrl }` |
@@ -522,7 +588,8 @@ integration repository").
522
588
  add-to-carts on Luma's products and dataLayer pushes are kept for the next
523
589
  page. A/B tests are not supported yet (`listing_ab_test` is always `false`).
524
590
  - **Prerendering** costs the site a page render per unfollowed hover
525
- (`speculation: false` turns it off).
591
+ (`speculation: false` turns it off), and the search page fetched ahead
592
+ while typing a document per settled phrase (`search.pageWhileTyping: false`).
526
593
  - **Early hold**: the snippet carries no site's rules, so the first page
527
594
  view is not held.
528
595
  - **Host routers out of reach**: where Luma cannot find the host's router (Vue
@@ -53,8 +53,8 @@ export const ALLOW = [
53
53
  {
54
54
  // Limited availability (Chromium only). document.prerendering through `any`, as above.
55
55
  feature: 'speculation-rules',
56
- files: ['src/core/speculation.ts', 'src/core/takeover.ts', 'src/main.ts'],
57
- why: "The prerender rule is added only when HTMLScriptElement.supports('speculationrules'); document.prerendering is undefined (falsy) elsewhere, so a hold's timer starts at once.",
56
+ files: ['src/core/speculation.ts', 'src/core/takeover.ts', 'src/main.ts', 'src/widgets/search/speculate.ts'],
57
+ why: "The product-page prerender rule, and the search page of the phrase typed fetched ahead (prerendered with pageWhileTyping 'prerender'), are added only when HTMLScriptElement.supports('speculationrules'); without it Enter loads the page as usual and its result comes from the stash. document.prerendering is undefined (falsy) elsewhere, so a hold's timer starts at once.",
58
58
  },
59
59
  ];
60
60
 
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: {