@luigisbox/luma 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +90 -0
- package/README.md +615 -2
- package/a11y/audit.mjs +735 -0
- package/a11y/axe.mjs +73 -0
- package/a11y/report.mjs +347 -0
- package/a11y/run.mjs +256 -0
- package/a11y/wcag.mjs +233 -0
- package/bin/config.mjs +60 -0
- package/bin/luma.mjs +99 -0
- package/bin/paths.mjs +11 -0
- package/bin/tools.mjs +43 -0
- package/build/baseline.d.mts +8 -0
- package/build/baseline.mjs +167 -0
- package/build/tooling.mjs +102 -0
- package/build/vite.mjs +507 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +150 -0
- package/docs/ACCESSIBILITY.md +501 -0
- package/docs/INTEGRATION-PERFORMANCE.md +213 -0
- package/docs/PARITY.md +946 -0
- package/footprint/model.mjs +184 -0
- package/footprint/run.mjs +331 -0
- package/package.json +58 -4
- package/parity/alignment.mjs +109 -0
- package/parity/archive.mjs +223 -0
- package/parity/args.mjs +137 -0
- package/parity/behaviour.mjs +379 -0
- package/parity/compare.mjs +985 -0
- package/parity/engine.mjs +848 -0
- package/parity/hints.mjs +132 -0
- package/parity/hold.mjs +54 -0
- package/parity/init.mjs +703 -0
- package/parity/map.mjs +640 -0
- package/parity/owner.mjs +170 -0
- package/parity/pairing.mjs +110 -0
- package/parity/pixels.mjs +33 -0
- package/parity/probe.mjs +1239 -0
- package/parity/report.mjs +236 -0
- package/parity/run.mjs +267 -0
- package/snippet/early-hold.js +64 -0
- package/snippet/early-hold.min.js +1 -0
- package/src/analytics/chain.ts +137 -0
- package/src/analytics/collect.ts +64 -0
- package/src/analytics/datalayer.ts +67 -0
- package/src/analytics/from-ga4.ts +68 -0
- package/src/analytics/from-luma.ts +21 -0
- package/src/analytics/index.ts +24 -0
- package/src/analytics/outbox.ts +118 -0
- package/src/analytics/page.ts +46 -0
- package/src/analytics/pageview.ts +82 -0
- package/src/analytics/ref.ts +19 -0
- package/src/analytics/setup.ts +41 -0
- package/src/analytics/start.ts +53 -0
- package/src/analytics/triggers.ts +239 -0
- package/src/analytics/user.ts +12 -0
- package/src/analytics/wire.ts +67 -0
- package/src/api/ai.ts +98 -0
- package/src/api/index.ts +87 -0
- package/src/api/token-key.ts +42 -0
- package/src/api/token.ts +312 -0
- package/src/core/a11y-keys.ts +60 -0
- package/src/core/a11y.ts +160 -0
- package/src/core/boot-state.ts +6 -0
- package/src/core/color.ts +42 -0
- package/src/core/context.ts +108 -0
- package/src/core/early-rules.ts +96 -0
- package/src/core/entries.ts +25 -0
- package/src/core/fold.ts +7 -0
- package/src/core/health.ts +26 -0
- package/src/core/hold.ts +74 -0
- package/src/core/i18n.ts +66 -0
- package/src/core/image-load.ts +102 -0
- package/src/core/image-tags.ts +26 -0
- package/src/core/images.ts +170 -0
- package/src/core/integration.ts +126 -0
- package/src/core/kept.ts +25 -0
- package/src/core/listings.ts +100 -0
- package/src/core/locales.ts +14 -0
- package/src/core/own.ts +49 -0
- package/src/core/page.ts +14 -0
- package/src/core/prefetch.ts +88 -0
- package/src/core/recommenders.ts +166 -0
- package/src/core/reconciler.ts +397 -0
- package/src/core/result.ts +73 -0
- package/src/core/routes.ts +115 -0
- package/src/core/scheduler.ts +83 -0
- package/src/core/sense.ts +107 -0
- package/src/core/speculation.ts +59 -0
- package/src/core/stash.ts +38 -0
- package/src/core/storage.ts +45 -0
- package/src/core/styles.ts +106 -0
- package/src/core/support.ts +12 -0
- package/src/core/takeover.ts +220 -0
- package/src/core/templates.ts +74 -0
- package/src/core/tiles.ts +137 -0
- package/src/core/turbo.ts +64 -0
- package/src/core/url.ts +164 -0
- package/src/core/widget-inits.ts +40 -0
- package/src/env.d.ts +3 -0
- package/src/lit.ts +11 -0
- package/src/locales/cs.json +94 -0
- package/src/locales/de.json +88 -0
- package/src/locales/en.json +88 -0
- package/src/main.ts +493 -0
- package/src/types.ts +778 -0
- package/src/widgets/autocomplete/index.ts +1387 -0
- package/src/widgets/autocomplete/style-autocomplete.css +266 -0
- package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
- package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
- package/src/widgets/listing/element.ts +1772 -0
- package/src/widgets/listing/flow.ts +30 -0
- package/src/widgets/listing/instant.ts +133 -0
- package/src/widgets/listing/intent.ts +193 -0
- package/src/widgets/listing/query.ts +298 -0
- package/src/widgets/listing/stash.ts +97 -0
- package/src/widgets/listing/state.ts +131 -0
- package/src/widgets/listing/view.ts +257 -0
- package/src/widgets/plp/index.ts +11 -0
- package/src/widgets/plp/route.ts +263 -0
- package/src/widgets/recommender/index.ts +261 -0
- package/src/widgets/recommender/style-recommender.css +26 -0
- package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
- package/src/widgets/recommender/template-recommender-popup.ts +12 -0
- package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
- package/src/widgets/search/assets.ts +7 -0
- package/src/widgets/search/first.ts +15 -0
- package/src/widgets/search/form-url.ts +59 -0
- package/src/widgets/search/in-place.ts +226 -0
- package/src/widgets/search/index.ts +16 -0
- package/src/widgets/search/range.ts +260 -0
- package/src/widgets/search/route.ts +32 -0
- package/src/widgets/search/speculate.ts +202 -0
- package/src/widgets/search/style-search.css +65 -0
- package/src/widgets/search/template-search-box.ts +17 -0
- package/src/widgets/search/template-search-facets-default.ts +73 -0
- package/src/widgets/search/template-search-header.ts +14 -0
- package/src/widgets/search/template-search-pagination.ts +12 -0
- package/src/widgets/search/template-search-range-default.ts +48 -0
- package/src/widgets/search/template-search-result-default.ts +13 -0
- package/src/widgets/search/template-search-results-default.ts +8 -0
- package/tasks/model.mjs +116 -0
- package/tasks/run.mjs +305 -0
- package/tsconfig.json +14 -0
package/README.md
CHANGED
|
@@ -1,3 +1,616 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Luma
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Luma is the framework behind Luigi's Box frontend integrations: autocomplete,
|
|
4
|
+
search results, category listings (PLP) and recommenders on the Luigi's Box AI
|
|
5
|
+
API, for sites that can only include a script. Optionally, listings arrive
|
|
6
|
+
server-rendered through the site's own cache, from a rendering service Luigi's
|
|
7
|
+
Box runs.
|
|
8
|
+
|
|
9
|
+
This package is what an integration is built with. Each integration lives in
|
|
10
|
+
its own repository and depends on `@luigisbox/luma`
|
|
11
|
+
([Integration repository](#integration-repository)).
|
|
12
|
+
|
|
13
|
+
> **Pre-release.** Until 1.0, a minor version (0.3.0) may change the
|
|
14
|
+
> integration API (config keys, template scope, the build); a patch version
|
|
15
|
+
> (0.2.1) only fixes. Depend on `^0.2.0` to take fixes without breaking
|
|
16
|
+
> changes.
|
|
17
|
+
|
|
18
|
+
## Core principles
|
|
19
|
+
|
|
20
|
+
### The problem
|
|
21
|
+
|
|
22
|
+
Luma runs on storefronts it doesn't own, often included as a single script
|
|
23
|
+
tag. That sets the constraints:
|
|
24
|
+
|
|
25
|
+
1. **The page belongs to the site.** The site's framework, router, CSS and
|
|
26
|
+
scripts were there first and keep changing. Luma works around them without
|
|
27
|
+
breaking anything.
|
|
28
|
+
2. **Luma is judged by how the page feels.** If search or listings feel slower,
|
|
29
|
+
flicker or jump compared with the site's own, the integration is a
|
|
30
|
+
regression, however good the results are.
|
|
31
|
+
3. **The shopper should not notice Luma.** Results look like the site's own UI
|
|
32
|
+
and are usable by everyone.
|
|
33
|
+
4. **One framework, many integrations.** Every change to Luma ships to every
|
|
34
|
+
site that uses it, so the rules must hold in general, not for one site.
|
|
35
|
+
5. **Measured, not claimed.** No one can check every site by hand, so each
|
|
36
|
+
principle below has a test, a gate or a tool behind it.
|
|
37
|
+
|
|
38
|
+
### A. A guest on the page
|
|
39
|
+
|
|
40
|
+
- **Do no harm, always fall back.** Luma adds only its own nodes
|
|
41
|
+
(`data-lbx`), hides the host's UI with CSS and never removes the host's
|
|
42
|
+
nodes. It never throws into the page, every network call has a deadline, and
|
|
43
|
+
nothing of Luma's keeps a page out of the back/forward cache. A failed
|
|
44
|
+
request, a URL Luma cannot serve, a listing not rendered within 4 s, a
|
|
45
|
+
non-HTTPS page or a missing browser feature: the site's own UI comes back as
|
|
46
|
+
if Luma were never there.
|
|
47
|
+
- **Navigation stays the host's.** The site's router owns the URL and history.
|
|
48
|
+
Luma's history entries carry no state of its own (null, or for SvelteKit and
|
|
49
|
+
Astro only the router's own bookkeeping keys); it never rewrites the host's.
|
|
50
|
+
Anything Luma cannot do exactly as the browser would (POST, a new window, a
|
|
51
|
+
modifier key) stays the browser's. The one exception: a new query from the
|
|
52
|
+
site's search form while on Luma's search page is shown in place.
|
|
53
|
+
- **Sense and reconcile, not imperative patching.** Host signals (URL changes,
|
|
54
|
+
DOM mutations, Luma's nodes removed) only mean "reconcile soon". At most once
|
|
55
|
+
per frame the reconciler makes the page match declarative rules: it removes
|
|
56
|
+
nodes whose rule is inactive, puts the same instance back if the host
|
|
57
|
+
removed it, and waits for the anchor to settle. A missed signal is harmless:
|
|
58
|
+
the next reconcile computes everything from scratch.
|
|
59
|
+
- **Works with any frontend stack.** React/Next.js, Remix/React Router 7,
|
|
60
|
+
Vue/Nuxt, Angular, SvelteKit, Astro, Turbo and htmx, with no code change on
|
|
61
|
+
the site. Inside a server-rendered root, Luma waits for that framework's own
|
|
62
|
+
hydration signal before inserting anything. Every framework host has a
|
|
63
|
+
contract suite on Chromium, Firefox and WebKit.
|
|
64
|
+
- **Listen at the window, not on host elements.** Input, submit and keydown are
|
|
65
|
+
window-capture listeners matched against selectors; inputs in shadow roots
|
|
66
|
+
are read from the composed path. Nothing is bound to the host's input or
|
|
67
|
+
form, so a re-created input keeps working.
|
|
68
|
+
|
|
69
|
+
### B. Faster than the site it replaces
|
|
70
|
+
|
|
71
|
+
- **No flicker, no layout shift.** With the inline snippet in `<head>`, the
|
|
72
|
+
host's listing is held before first paint and swapped for Luma's in one
|
|
73
|
+
frame. Its height is reserved from the last visit. Target: zero layout shift
|
|
74
|
+
after Luma's listing is shown.
|
|
75
|
+
- **A small entry, one round trip.** The entry is at most 14 kB brotli (one TCP
|
|
76
|
+
initial congestion window). Each widget's code, templates, styles and
|
|
77
|
+
dictionary are 2–7 kB chunks loaded only when that widget can run: a site
|
|
78
|
+
without recommenders never downloads that code.
|
|
79
|
+
- **Never block the main thread.** Target: no task of Luma's over 16 ms (a frame
|
|
80
|
+
at 60 fps) at 4x CPU; the test gate is 30 ms. Typing in autocomplete reaches
|
|
81
|
+
the next paint within 50 ms (INP). Visual feedback in the handler; fetches
|
|
82
|
+
and history writes in later tasks. No layout reads after Luma's DOM writes in
|
|
83
|
+
the same task. Large listings render in steps that each fit a frame.
|
|
84
|
+
- **Instant.** Results are prefetched while the shopper types or hovers a
|
|
85
|
+
filter, Back restores the listing at the tile they followed, product pages
|
|
86
|
+
are prerendered on intent (Speculation Rules, Chromium).
|
|
87
|
+
- **Server-side rendering, same code.** The same integration and templates
|
|
88
|
+
render in the browser and on Luigi's Box servers. The site can serve Luma's
|
|
89
|
+
listing in its own HTML through SSI/ESI in its web server or CDN; the browser takes the
|
|
90
|
+
server-rendered listing over. The server renderer and everything it imports
|
|
91
|
+
stay free of DOM access.
|
|
92
|
+
|
|
93
|
+
### C. Looks native, works for everyone
|
|
94
|
+
|
|
95
|
+
- **The site's own look.** Listing templates reuse the site's markup and CSS
|
|
96
|
+
classes. Luma's built-in CSS sits in `@layer lbx`, so the site's CSS always
|
|
97
|
+
wins. `yarn luma parity` measures how close the port is.
|
|
98
|
+
- **Web components, DOM mode per widget.** Custom elements are the unit of
|
|
99
|
+
placement and lifecycle. Listings are light DOM, so the site's CSS applies.
|
|
100
|
+
Autocomplete is shadow DOM by default: a robust house design isolated from
|
|
101
|
+
host CSS, with a light-DOM mode for sites that want their own styling.
|
|
102
|
+
- **Accessible by design.** WCAG 2.2 AA (EN 301 549, the European
|
|
103
|
+
Accessibility Act). Combobox ARIA on the input (`aria-expanded`,
|
|
104
|
+
`aria-controls`, `aria-activedescendant`), modal dialogs with managed focus,
|
|
105
|
+
`aria-busy` while a listing renders. `yarn luma a11y` checks an integration.
|
|
106
|
+
- **The modern platform, no polyfills.** Baseline Widely available (custom
|
|
107
|
+
elements, shadow DOM, cascade layers, `<dialog>`, `inert`, `color-mix()`).
|
|
108
|
+
Anything newer (Popover API, Navigation API, Speculation Rules) is
|
|
109
|
+
feature-detected and the page works without it. `yarn luma baseline`
|
|
110
|
+
enforces it ([Browser support](#browser-support)).
|
|
111
|
+
|
|
112
|
+
### D. How Luma is built
|
|
113
|
+
|
|
114
|
+
- **One framework, many integrations.** Nothing site-specific in Luma itself;
|
|
115
|
+
each integration has its own repository, with Luma as the `@luigisbox/luma`
|
|
116
|
+
package. `_config.ts` is JSON; code goes in `_init.ts`.
|
|
117
|
+
- **Measured, not claimed.** Every principle has a gate: budgets and
|
|
118
|
+
main-thread limits, request counts, the bfcache test, the baseline lint,
|
|
119
|
+
framework contracts, the accessibility and parity checks. The suites run on
|
|
120
|
+
Chromium, Firefox and WebKit, desktop and phones, on every change. Every fix
|
|
121
|
+
lands with a test that fails without it.
|
|
122
|
+
|
|
123
|
+
The trade-offs that come with this: [Known limitations](#known-limitations).
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
## What's in the package
|
|
127
|
+
|
|
128
|
+
| Path | What |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `src/` | the framework: config, sensing and placement, takeover, templates, API client, widgets |
|
|
131
|
+
| `build/vite.mjs` | the Vite config every integration builds with |
|
|
132
|
+
| `snippet/` | the inline early-hold snippet (identical for every site) |
|
|
133
|
+
| `a11y/` | `yarn luma a11y`: an integration's WCAG 2.2 AA check |
|
|
134
|
+
| `parity/` | `yarn luma parity`: an integration compared with the site it replaces |
|
|
135
|
+
| `bin/` | `yarn luma <tool>`: every tool below, from an integration repository, with one script |
|
|
136
|
+
| `footprint/` | `yarn luma footprint`: what Luma itself costs each page type of an integration |
|
|
137
|
+
| `chrome/` | `yarn luma chrome`: the live storefront in your Chrome with an integration on it, before the site includes it |
|
|
138
|
+
| `tasks/` | `yarn luma tasks`: an integration's main-thread tasks over a frame's budget on the storefront, by source line |
|
|
139
|
+
|
|
140
|
+
Integrations are not part of the package: each has its own repository with
|
|
141
|
+
Luma as the `@luigisbox/luma` package
|
|
142
|
+
([Integration repository](#integration-repository)).
|
|
143
|
+
|
|
144
|
+
## What the site includes
|
|
145
|
+
|
|
146
|
+
Everything goes in the site's HTML, in `<head>`, on every page.
|
|
147
|
+
`<distribution id>` is the site's distribution (`lbdr_…`), provided by
|
|
148
|
+
Luigi's Box.
|
|
149
|
+
|
|
150
|
+
**Loader only** (a tag manager is fine): the site's own listing may flash
|
|
151
|
+
before Luma's on a hard load.
|
|
152
|
+
|
|
153
|
+
```html
|
|
154
|
+
<script async src="https://cdn.eu1.luigisbox.ai/b/<distribution id>/loader.js"></script>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**Snippet + loader** (recommended): the inline snippet hides the site's listing
|
|
158
|
+
before first paint, from rules the bundle stored on earlier page views; it makes
|
|
159
|
+
no request and gives up after a timeout. The first page view in a browser has
|
|
160
|
+
no rules yet. Put both first in `<head>`, before the site's own scripts and
|
|
161
|
+
stylesheets (not through a tag manager: too late). With a strict CSP, give the
|
|
162
|
+
snippet's tag the page's `nonce` or allow its hash. On a site whose React
|
|
163
|
+
hydrates the whole document (Remix, React Router 7, Hydrogen), put the loader
|
|
164
|
+
first and the snippet as the last element of `<head>`.
|
|
165
|
+
|
|
166
|
+
<!-- lbx-snippet: the minified form is snippet/early-hold.min.js (generated from snippet/early-hold.js); this copy must not differ from it -->
|
|
167
|
+
```html
|
|
168
|
+
<script>
|
|
169
|
+
"use strict";(function(){try{if(window.__lbx&&window.__lbx.early)return;var n=JSON.parse(localStorage.getItem("lbx:early")||"null");if(!n||n.v!==1||!(n.exp>Date.now())||performance.getEntriesByName&&performance.getEntriesByName("first-contentful-paint").length)return;for(var o=new URL(location.href),i=0;i<n.routes.length;i++){var e=n.routes[i];if(new RegExp(e.path).test(o.pathname)){for(var r=!0,l=0;e.need&&l<e.need.length;l++)(o.searchParams.get(e.need[l])||"").trim()||(r=!1);if(r&&e.allow){var d=new RegExp(e.allow);o.searchParams.forEach(function(a,p){a&&!d.test(p)&&(r=!1)})}if(r){var t=document.createElement("style"),c=document.currentScript;c&&c.nonce&&(t.nonce=c.nonce),t.setAttribute("data-lbx","early"),t.textContent=e.css,(document.head||document.documentElement).appendChild(t);var f=function(){clearTimeout(u),t.parentNode&&t.parentNode.removeChild(t)},u=setTimeout(f,n.ms||1500),m=window.__lbx=window.__lbx||{};m.early={t:performance.now(),route:e.id,snippet:1,claim:function(a){return!t.parentNode||a!==e.id?!1:(clearTimeout(u),!0)},drop:f};return}}}}catch(a){}})();
|
|
170
|
+
</script>
|
|
171
|
+
<script async src="https://cdn.eu1.luigisbox.ai/b/<distribution id>/loader.js"></script>
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**Connection hints** (recommended, first in `<head>`, before the snippet): the
|
|
175
|
+
connections Luma's requests need, opened while the HTML is still arriving instead
|
|
176
|
+
of after the loader and the entry have run (the entry adds the same hints, but
|
|
177
|
+
only once `<body>` starts). On a first visit they take the TCP and TLS setup
|
|
178
|
+
(one to three round trips per host) off the path to the listing. Use the
|
|
179
|
+
region of the site's distribution (`eu1` here) and the host of the
|
|
180
|
+
catalog's product images.
|
|
181
|
+
|
|
182
|
+
```html
|
|
183
|
+
<link rel="preconnect" href="https://cdn.eu1.luigisbox.ai">
|
|
184
|
+
<link rel="preconnect" href="https://cdn.eu1.luigisbox.ai" crossorigin>
|
|
185
|
+
<link rel="preconnect" href="https://api.eu1.luigisbox.ai" crossorigin>
|
|
186
|
+
<link rel="preconnect" href="https://auth.eu1.luigisbox.ai" crossorigin>
|
|
187
|
+
<link rel="preconnect" href="https://<product image host>">
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
- The CDN twice: the loader is a classic script (no CORS) and the chunks are
|
|
191
|
+
modules loaded with CORS. A browser does not share connections between the two
|
|
192
|
+
modes. Drop the plain one if the Network panel shows a single connection.
|
|
193
|
+
- The API and auth hosts with `crossorigin`: Luma's requests are CORS `fetch()`es.
|
|
194
|
+
A preconnect without it opens a connection they cannot use.
|
|
195
|
+
- The image host without `crossorigin` (images are no-CORS). Leave it out if
|
|
196
|
+
it is the site's own host (connected already).
|
|
197
|
+
- At most these five. More preconnects compete with the page's own
|
|
198
|
+
connections, and an unused one is closed after about 10 s.
|
|
199
|
+
- On listing and search pages, `fetchpriority="high"` on the loader's tag moves
|
|
200
|
+
it ahead of the page's images (an `async` script is otherwise fetched at low
|
|
201
|
+
priority). Luma's listing is those pages' main content. Measure it with
|
|
202
|
+
`yarn luma footprint` and DevTools before keeping it.
|
|
203
|
+
|
|
204
|
+
The whole `<head>` start of a listing page, in order:
|
|
205
|
+
|
|
206
|
+
```html
|
|
207
|
+
<head>
|
|
208
|
+
<meta charset="utf-8">
|
|
209
|
+
<link rel="preconnect" href="https://cdn.eu1.luigisbox.ai">
|
|
210
|
+
<link rel="preconnect" href="https://cdn.eu1.luigisbox.ai" crossorigin>
|
|
211
|
+
<link rel="preconnect" href="https://api.eu1.luigisbox.ai" crossorigin>
|
|
212
|
+
<link rel="preconnect" href="https://auth.eu1.luigisbox.ai" crossorigin>
|
|
213
|
+
<link rel="preconnect" href="https://<product image host>">
|
|
214
|
+
<script>/* the early-hold snippet above, verbatim */</script>
|
|
215
|
+
<script async fetchpriority="high" src="https://cdn.eu1.luigisbox.ai/b/<distribution id>/loader.js"></script>
|
|
216
|
+
<!-- the site's own stylesheets and scripts -->
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
What to measure, and the numbers to optimize for:
|
|
220
|
+
[docs/INTEGRATION-PERFORMANCE.md](docs/INTEGRATION-PERFORMANCE.md).
|
|
221
|
+
|
|
222
|
+
**Server-side includes**: the listing arrives in the site's HTML, through
|
|
223
|
+
the site's web server or CDN config, with the loader as above. The site adds an
|
|
224
|
+
include (nginx SSI or Varnish/CDN ESI) and a marker at its listing, and a short
|
|
225
|
+
snippet to its server config; Luigi's Box provides both. The
|
|
226
|
+
include sits outside any root the site's own framework hydrates, and the site's
|
|
227
|
+
own listing stays the fallback.
|
|
228
|
+
|
|
229
|
+
If Luma's listing does not render within 4 s, the request fails or the URL is
|
|
230
|
+
one Luma cannot serve, the site's own listing is shown and the page stays the
|
|
231
|
+
site's until the next visit.
|
|
232
|
+
|
|
233
|
+
## Integration repository
|
|
234
|
+
|
|
235
|
+
An integration repository has the shape of the distribution platform's
|
|
236
|
+
scaffold: Luma as the `@luigisbox/luma` package (a devDependency, in
|
|
237
|
+
`node_modules/@luigisbox/luma/`), the integration in `integration/`, a Vite
|
|
238
|
+
build that emits `dist/luma-integration.js` (what the platform's loader
|
|
239
|
+
imports) plus on-demand chunks. Its `vite.config.ts`:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
import mkcert from 'vite-plugin-mkcert';
|
|
243
|
+
import { clientConfig } from '@luigisbox/luma/build/vite.mjs';
|
|
244
|
+
|
|
245
|
+
export default clientConfig(import.meta.url, { plugins: [mkcert()], server: { host: 'localhost', https: {} } });
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Its `tsconfig.json` extends `@luigisbox/luma/tsconfig.json`, includes
|
|
249
|
+
`integration/**/*` and `node_modules/@luigisbox/luma/src/**/*`, and maps `@/*`
|
|
250
|
+
to `./node_modules/@luigisbox/luma/src/*`. Its `package.json` depends on
|
|
251
|
+
`@luigisbox/luma`, `vite` 7, `typescript` and `vite-plugin-mkcert` (Luma brings
|
|
252
|
+
`lit-html`, `@lit-labs/ssr-client` and its tools' dependencies).
|
|
253
|
+
|
|
254
|
+
The package holds what an integration uses: the framework's source, the build
|
|
255
|
+
config, `yarn luma` and its tools, and their docs.
|
|
256
|
+
|
|
257
|
+
```
|
|
258
|
+
integration/
|
|
259
|
+
__integration.ts hands the folder to the framework (startIntegration)
|
|
260
|
+
_global/ _config.ts _init.ts [_widget.ts] [_analytics.ts] _translations.ts style-*.css
|
|
261
|
+
template-*.ts shared by every widget (a tile, …)
|
|
262
|
+
autocomplete/ _config.ts [_init.ts] [_widget.ts] [style-host.css] [template-autocomplete-*.ts]
|
|
263
|
+
search/ _config.ts _translations.ts style-host.css template-search-*.ts
|
|
264
|
+
plp/ _config.ts _init.ts style-host.css (templates fall back to search/)
|
|
265
|
+
recommender <name>/ _config.ts [_init.ts] _translations.ts
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
- **`_config.ts` is JSON** (checked at boot); anything that needs code goes in
|
|
269
|
+
`_init.ts`. `_global/_init.ts` runs first and can change any config or return
|
|
270
|
+
`false` to stop; a widget's `_init.ts` gets that widget's config. Every config
|
|
271
|
+
key is typed and documented in `src/types.ts`.
|
|
272
|
+
- **`_init.ts` ships and runs on every page; `_widget.ts` loads with its
|
|
273
|
+
widget.** Keep in `_init.ts` only what decides whether and where a widget
|
|
274
|
+
runs (`disabled`, `active`, `readQuery`, `items`, `intent`, selectors set in
|
|
275
|
+
code, switching the site's own widgets off). Hooks only a widget calls
|
|
276
|
+
(`computedAttributes`, `toFilter`, `facetsFor`, `onRender`, `onOpen`, …) go
|
|
277
|
+
in the folder's optional `_widget.ts`, same signature (its return value is
|
|
278
|
+
ignored): it is loaded with the widget's chunk and run once before its
|
|
279
|
+
context is made, `_global/_widget.ts` first. `__integration.ts` hands it
|
|
280
|
+
over as a lazy glob: `widgetInits: import.meta.glob('./*/_widget.ts', { import: 'default' })`.
|
|
281
|
+
- **`style-host.css`** hides the host's own listing or suggestions, only while
|
|
282
|
+
Luma has the page. Other `style-*.css` load with their widget. Luma's built-in
|
|
283
|
+
CSS sits in `@layer lbx`, so the integration's and the site's CSS win.
|
|
284
|
+
- **Templates override by name**: a widget's built-in templates, then
|
|
285
|
+
`_global/`, then the widget's own folder. Names: `search-box`, `search-header`,
|
|
286
|
+
`search-facets-default`, `search-results-default`, `search-result-default`,
|
|
287
|
+
`search-pagination`, `autocomplete-box`, `autocomplete-result-default`,
|
|
288
|
+
`autocomplete-sheet`, `autocomplete-empty`, `recommender-box-default`,
|
|
289
|
+
`recommender-result-default`, `recommender-popup`.
|
|
290
|
+
|
|
291
|
+
### Templates
|
|
292
|
+
|
|
293
|
+
Templates are [lit-html](https://lit.dev/docs/templates/overview/): each
|
|
294
|
+
`template-<name>.ts` default-exports a function of the template's scope that
|
|
295
|
+
returns `` html`…` ``. Only changed bindings touch the DOM, so images, focus and
|
|
296
|
+
open `<details>` survive re-renders; no `eval`, CSP-safe. Import lit from `@/lit`
|
|
297
|
+
and the scope types from `@/types` (`SearchScope`, `SearchResultScope`,
|
|
298
|
+
`AutocompleteScope`, `RecommenderScope`, …).
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
// integration/search/template-search-result-default.ts
|
|
302
|
+
import { html, nothing } from '@/lit';
|
|
303
|
+
import type { SearchResultScope } from '@/types';
|
|
304
|
+
|
|
305
|
+
export default ({ r, i, img, price }: SearchResultScope) => html`
|
|
306
|
+
<a class="tile" href=${r.c.url}>
|
|
307
|
+
${img(r, i)}
|
|
308
|
+
<span>${r.c.title}</span>
|
|
309
|
+
${r.c.price ? html`<b>${price(r.c.price)}</b>` : nothing}
|
|
310
|
+
</a>`;
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
| lit | |
|
|
314
|
+
|---|---|
|
|
315
|
+
| `${expr}` | text (escaped) |
|
|
316
|
+
| `` ${cond ? html`…` : nothing} `` | conditions |
|
|
317
|
+
| `` ${repeat(list, (x) => x.id, (x, i) => html`…`)} `` | keyed lists (`list.map(…)` for unkeyed) |
|
|
318
|
+
| `attr=${v}`, `?disabled=${b}`, `.checked=${b}` | attribute, boolean attribute, property |
|
|
319
|
+
| `@click=${s.showMore}`, `@change=${() => s.toggleFacet(f.name, v.value)}` | event handlers |
|
|
320
|
+
| `${include('product-tile', { extra })}` | another template of the widget, with this scope plus props |
|
|
321
|
+
|
|
322
|
+
- **Results**: `r.a` is the hit's attributes, `r.c` its computed attributes
|
|
323
|
+
(functions in `_global/_init.ts`, `computedAttributes`). `r.c.url` is always a
|
|
324
|
+
web link; a `javascript:` or `data:` value becomes `''`. Links you bind from
|
|
325
|
+
`r.a` or your own computed attributes, and `unsafeHTML`, get no such check:
|
|
326
|
+
keep catalog data out of them, or check it. With Trusted Types enforced, the
|
|
327
|
+
site's `trusted-types` directive must allow the `lit-html` policy.
|
|
328
|
+
- **Helpers** in every scope: `t(key, params)`, `price(n)`, `plain()`,
|
|
329
|
+
`first()`, `include()`, and `img(r, i)` (a complete `<img>` in its placeholder
|
|
330
|
+
box) / `image(r, i)` (its attributes, for your own markup), configured by
|
|
331
|
+
`_global.images` (srcset, sizes, the eager first row, placeholders).
|
|
332
|
+
- **Pagination** is "show more" only: templates get `results`, `more` and
|
|
333
|
+
`showMore`; there are no numbered pages.
|
|
334
|
+
- **Translations** are JSON dictionaries per locale (`src/locales/<locale>.json`,
|
|
335
|
+
`{name}` placeholders, plural forms by `Intl.PluralRules` category); only the
|
|
336
|
+
site's locale is loaded. Override keys in `_translations.ts`, keyed by locale:
|
|
337
|
+
`_global` for all widgets, a widget's own for that widget. Give
|
|
338
|
+
`__integration.ts` their glob without `eager`
|
|
339
|
+
(`translations: import.meta.glob('./*/_translations.ts', { import: 'default' })`)
|
|
340
|
+
and they load with their widget instead of on every page; `_init.ts` then
|
|
341
|
+
does not see them.
|
|
342
|
+
|
|
343
|
+
### Behaviour switches
|
|
344
|
+
|
|
345
|
+
On by default unless noted; each falls back to what the page would do anyway.
|
|
346
|
+
|
|
347
|
+
| Config | What it does | Turn it off when |
|
|
348
|
+
|---|---|---|
|
|
349
|
+
| `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 |
|
|
350
|
+
| `search.prefetchWhileTyping` | the search result is fetched once the typed phrase settles, so Enter shows it at once | |
|
|
351
|
+
| `restoreOnBack` | Back from a product restores the listing and the followed tile's position | |
|
|
352
|
+
| `prefetchNextPage` | "show more" is fetched as the shopper nears the end | |
|
|
353
|
+
| `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 |
|
|
354
|
+
| `_global.prefetchFilters` | a filter, sort or chip is fetched on hover, touch or focus | |
|
|
355
|
+
| `_global.speculation` | product pages are prerendered on hover (`'prerender'`, `'prefetch'`, `false`) | the site's server or its product-page scripts can't take a render per unfollowed hover |
|
|
356
|
+
|
|
357
|
+
### Analytics
|
|
358
|
+
|
|
359
|
+
Luma sends analytics events to the Luigi's Box AI events API
|
|
360
|
+
([docs](https://docs.luigisbox.ai/analytics/overview/)) as the integration's
|
|
361
|
+
`_global/_analytics.ts` describes them. Every event sent is one
|
|
362
|
+
`collect.<event>` line in that file, and nothing else is sent: reading the file
|
|
363
|
+
top to bottom tells you what the site collects. Without the file there is no
|
|
364
|
+
analytics at all (no code loaded, nothing sent).
|
|
365
|
+
|
|
366
|
+
`__integration.ts` hands the file over as a lazy glob:
|
|
367
|
+
`analytics: import.meta.glob('./*/_analytics.ts', { import: 'default' })`.
|
|
368
|
+
`analytics: false` in `_global/_config.ts` turns analytics off whatever the
|
|
369
|
+
file says. Luma does not check consent: whatever includes Luma on the page
|
|
370
|
+
(the Luigi's Box loader) decides that, and once Luma runs, events are sent.
|
|
371
|
+
|
|
372
|
+
Each line names the event, when it happens and how it is read:
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
collect.addToCart // the event sent: an add-to-cart
|
|
376
|
+
.on(dl('add_to_cart')) // when: each dataLayer push with event 'add_to_cart'
|
|
377
|
+
.read(fromGa4.addToCart) // how: the push read as GA4 ecommerce
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
`.read()` takes a reader (`fromGa4.…`, `fromLuma.…`) or a function of the
|
|
381
|
+
trigger's context that returns what the event sends (`null`: nothing).
|
|
382
|
+
`.when(f)`, `.throttle(ms)` and `.once()` (once per page) go before `.read()`.
|
|
383
|
+
A typical integration:
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
// integration/_global/_analytics.ts
|
|
387
|
+
import { collect, dl, fromGa4, fromLuma, lumaCarts, lumaClicks, lumaPages, ref } from '@/analytics';
|
|
388
|
+
|
|
389
|
+
ref.product.by('sku'); // product references by the catalog's `sku` field: product/sku:<value>
|
|
390
|
+
|
|
391
|
+
collect.pageView.on(lumaPages()).read(fromLuma.pageView);
|
|
392
|
+
collect.click.on(lumaClicks()).read(fromLuma.click);
|
|
393
|
+
collect.addToCart.on(lumaCarts()).read(fromLuma.addToCart);
|
|
394
|
+
collect.addToCart.on(dl('add_to_cart')).read(fromGa4.addToCart);
|
|
395
|
+
collect.purchase.on(dl('purchase')).read(fromGa4.purchase);
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
**Events**
|
|
399
|
+
|
|
400
|
+
| Event | What `.read()` returns |
|
|
401
|
+
|---|---|
|
|
402
|
+
| `pageView` | the page's reference, or `null` for a page without one. On `lumaPages()`, one pageview per page, sent at once. On any other trigger, one per page that waits for its reference from that trigger (`.timeout(ms)`, default 1000 ms) and is sent without one after that. One `pageView` line per file |
|
|
403
|
+
| `click` | a product click: a reference, or `{ reference }` |
|
|
404
|
+
| `addToCart` | `{ reference, count?, price? }`, or a list of them |
|
|
405
|
+
| `purchase` | `{ id?, currency?, items: [{ reference, title, count, total_price, was_discounted? }] }`; sent once per `id`, so a reloaded thank-you page does not count twice |
|
|
406
|
+
| `search`, `autocomplete`, `recommender(name)` | a list from the site's own service: `{ query?, items, filters? }`. Not sent on a page where Luma runs that service itself (the API records Luma's own results) |
|
|
407
|
+
|
|
408
|
+
**Triggers**
|
|
409
|
+
|
|
410
|
+
| Trigger | When | Context |
|
|
411
|
+
|---|---|---|
|
|
412
|
+
| `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 }` |
|
|
413
|
+
| `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 }` |
|
|
414
|
+
| `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? }` |
|
|
415
|
+
| `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 }` |
|
|
416
|
+
| `domClick(selector)` | a click (or middle click) on an element of the site's that matches `selector` | `{ element, event }` |
|
|
417
|
+
| `urlChange()` | a navigation in a single-page app | `{ url, previousUrl }` |
|
|
418
|
+
| `mutationOf(selector, { root? })` | the site renders that element into the page, or renders into it; read once the browser is idle | `{ element }` |
|
|
419
|
+
| `xhrMatching(({ url, method }) => …)` | a response to one of the site's own requests (XMLHttpRequest or fetch) that the test matches | `{ url, method, response }` |
|
|
420
|
+
| `manual()` | `fire(data)` is called: `const { trigger, fire } = manual()` | `{ data }` |
|
|
421
|
+
|
|
422
|
+
**Readers**
|
|
423
|
+
|
|
424
|
+
| Reader | Reads |
|
|
425
|
+
|---|---|
|
|
426
|
+
| `fromLuma.pageView`, `.click`, `.addToCart` | what Luma's own triggers saw (`lumaPages()`, `lumaClicks()`, `lumaCarts()`). Products are referenced by their catalog `@id` |
|
|
427
|
+
| `fromGa4.addToCart`, `.purchase`, `.click`, `.pageView`, `.search`, `.autocomplete`, `.recommender` | GA4 ecommerce: `add_to_cart`, `purchase`, `select_item`, `view_item`, `view_item_list`. The data is taken from the push's `ecommerce` (or `gtag()`'s parameters), or from the push itself when its `items` are there. Items are referenced by `ref.product(item_id)`; purchases get line totals, and `was_discounted` from `discount` |
|
|
428
|
+
|
|
429
|
+
Each reader belongs to one event: `collect.purchase…read(fromGa4.addToCart)`
|
|
430
|
+
is a type error, and throws when the file runs.
|
|
431
|
+
|
|
432
|
+
**References** follow `<type>/<field>:<value>`
|
|
433
|
+
([docs](https://docs.luigisbox.ai/analytics/references/)).
|
|
434
|
+
`ref.product(value)` uses the field set by `ref.product.by(field)` (default
|
|
435
|
+
`@id`); `ref.product(field, value)` names another field. `ref.variant`,
|
|
436
|
+
`ref.category` and `ref.brand` work the same way. `user.identify(fn)` sends
|
|
437
|
+
the signed-in shopper's own id as `user_id`.
|
|
438
|
+
|
|
439
|
+
**Nothing is lost when the page changes.** A click or add-to-cart on a Luma
|
|
440
|
+
product is recorded the moment it happens, and dataLayer pushes on a page the
|
|
441
|
+
shopper leaves before analytics has loaded (a thank-you page closed at once)
|
|
442
|
+
are kept. The next page sends them, dated as they happened; a purchase is still
|
|
443
|
+
sent once.
|
|
444
|
+
|
|
445
|
+
**Size.** Analytics loads as one chunk, after the page has loaded, holding
|
|
446
|
+
only what the file uses: every event, trigger and reader is a separate export
|
|
447
|
+
(`fromGa4.purchase` brings that reader alone). Gzip sizes:
|
|
448
|
+
|
|
449
|
+
| `_analytics.ts` | Size |
|
|
450
|
+
|---|---|
|
|
451
|
+
| none | nothing loaded |
|
|
452
|
+
| pageviews only | 2.2 kB |
|
|
453
|
+
| pageviews, add-to-carts and purchases from GA4 | 3.6 kB |
|
|
454
|
+
| + Luma's own clicks and add-to-carts (`lumaClicks()`, `lumaCarts()`) | +0.2 kB |
|
|
455
|
+
| + `domClick()` | +0.2 kB |
|
|
456
|
+
| + a `fromGa4` list reader (`.search`, `.autocomplete`, `.recommender`) | +0.1 kB |
|
|
457
|
+
| + `mutationOf()`, `xhrMatching()` | +0.3, +0.4 kB |
|
|
458
|
+
| + `user.identify()`, a page reference, a list read by a function of yours, `.once()`, `.throttle()` | under 0.1 kB each |
|
|
459
|
+
|
|
460
|
+
**A site's own search and suggestions** (where Luma does not render them) are
|
|
461
|
+
read with functions of your own, for example:
|
|
462
|
+
|
|
463
|
+
```ts
|
|
464
|
+
import { collect, dl, domClick, fromGa4, mutationOf, ref, xhrMatching } from '@/analytics';
|
|
465
|
+
|
|
466
|
+
// the site's search results, rendered into #results
|
|
467
|
+
collect.search.on(mutationOf('#results')).once().read(({ element }) => ({
|
|
468
|
+
query: { string: new URLSearchParams(location.search).get('q') ?? '' },
|
|
469
|
+
items: [...element.querySelectorAll('[data-sku]')].map((tile, n) => ({ title: tile.textContent ?? '', type: 'product', reference: ref.product(tile.getAttribute('data-sku')!), position: n + 1 })),
|
|
470
|
+
}));
|
|
471
|
+
// clicks on those results
|
|
472
|
+
collect.click.on(domClick('#results [data-sku] a')).read(({ element }) => ref.product(element.closest('[data-sku]')!.getAttribute('data-sku')!));
|
|
473
|
+
// the site's suggestions, from its own API
|
|
474
|
+
collect.autocomplete.on(xhrMatching(({ url }) => url.includes('/suggest'))).read(({ response }) => ({ items: /* … */ [] }));
|
|
475
|
+
// the product page's product, from GA4's view_item
|
|
476
|
+
collect.pageView.on(dl('view_item')).read(fromGa4.pageView);
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
## Browser support
|
|
480
|
+
|
|
481
|
+
Luma targets [Baseline](https://web.dev/baseline) **Widely available**; anything
|
|
482
|
+
newer (Popover API, Navigation API, Speculation Rules) is
|
|
483
|
+
feature-detected and optional. **HTTPS only**: on a plain-HTTP page Luma does
|
|
484
|
+
not start and the site keeps its own search. Tested on Chromium, Firefox and
|
|
485
|
+
WebKit, desktop and phone, on every change.
|
|
486
|
+
|
|
487
|
+
An integration repository runs the same check on its `integration/` folder:
|
|
488
|
+
`yarn luma baseline`, and `yarn luma
|
|
489
|
+
baseline --built dist` over a build's output (below, "Tools for an
|
|
490
|
+
integration repository").
|
|
491
|
+
|
|
492
|
+
## Known limitations
|
|
493
|
+
|
|
494
|
+
- **Autocomplete needs the Popover API**: without it (Safari < 17, Firefox
|
|
495
|
+
< 125) the site keeps its own suggestions.
|
|
496
|
+
- **Loader without the snippet**: the site's own listing may flash before
|
|
497
|
+
Luma's on a hard load.
|
|
498
|
+
- **Analytics**: a search shown in place fires no page view in the site's own
|
|
499
|
+
tags (Luma's `lumaPages()` counts it). Prefetches and prerenders count in the
|
|
500
|
+
API's request numbers, though never as searches or impressions. Analytics
|
|
501
|
+
loads after the page has: a visitor who leaves before that sends no pageview
|
|
502
|
+
and no click on the site's own elements (`domClick()`); clicks and
|
|
503
|
+
add-to-carts on Luma's products and dataLayer pushes are kept for the next
|
|
504
|
+
page. A/B tests are not supported yet (`listing_ab_test` is always `false`).
|
|
505
|
+
- **Prerendering** costs the site a page render per unfollowed hover
|
|
506
|
+
(`speculation: false` turns it off).
|
|
507
|
+
- **Early hold**: the snippet carries no site's rules, so the first page
|
|
508
|
+
view is not held.
|
|
509
|
+
- **Host routers out of reach**: where Luma cannot find the host's router (Vue
|
|
510
|
+
Router 4 when its app is not discoverable, Angular in production), the
|
|
511
|
+
router can overwrite Luma's history entry or show a stale query in its search
|
|
512
|
+
box. `inPlaceQuery: false` for such a site.
|
|
513
|
+
|
|
514
|
+
## Development
|
|
515
|
+
|
|
516
|
+
Node 24 or later, Yarn 4 (corepack).
|
|
517
|
+
|
|
518
|
+
### Seeing an integration on the storefront
|
|
519
|
+
|
|
520
|
+
An integration is developed in its own repository. How you see it on the live
|
|
521
|
+
site depends on whether the site includes the Luigi's Box loader yet.
|
|
522
|
+
|
|
523
|
+
**The site includes the loader.** Serve the integration and add
|
|
524
|
+
`?_lbx_env=development` to any page of the site:
|
|
525
|
+
|
|
526
|
+
```bash
|
|
527
|
+
yarn dev # serves the integration on https://localhost:8069
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
```
|
|
531
|
+
https://www.shop.example/some-category?_lbx_env=development
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
The loader then imports the integration from your dev server instead of the
|
|
535
|
+
published build (a red banner says so); an edit shows on reload.
|
|
536
|
+
`LBX_DEV_ORIGINS` (comma-separated) limits which sites may load from it.
|
|
537
|
+
|
|
538
|
+
**The site does not include the loader yet.** Open the storefront with the
|
|
539
|
+
integration injected as the site would include it (the early-hold snippet,
|
|
540
|
+
then the build):
|
|
541
|
+
|
|
542
|
+
```bash
|
|
543
|
+
yarn luma chrome # builds, opens the home page in your Chrome
|
|
544
|
+
yarn luma chrome /some-category # another page
|
|
545
|
+
yarn luma chrome --dev # from yarn dev: an edit shows on reload, no build
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
It needs the storefront's host in `luma.config.mjs` (`export const host =
|
|
549
|
+
'www.shop.example'`). The site's own Luigi's Box scripts are blocked, nothing
|
|
550
|
+
else of the site changes, and the browser profile is kept per storefront
|
|
551
|
+
(`~/.lbx-chrome/<host>`), apart from your own. `--check` prints what Luma did
|
|
552
|
+
and exits; `--help` lists the rest ([`chrome/run.mjs`](chrome/run.mjs)).
|
|
553
|
+
|
|
554
|
+
Either way, `window.__lbx` in the console is Luma's state (`health.errors`,
|
|
555
|
+
`health.timeline`). `yarn build` emits `dist/`.
|
|
556
|
+
|
|
557
|
+
### Working on Luma itself
|
|
558
|
+
|
|
559
|
+
Luma is developed by Luigi's Box. Every change is type-checked and runs the
|
|
560
|
+
unit and browser suites and the performance gates on Chromium, Firefox and
|
|
561
|
+
WebKit before it ships.
|
|
562
|
+
|
|
563
|
+
### Tools for an integration repository
|
|
564
|
+
|
|
565
|
+
An integration repository's `package.json` has one script for
|
|
566
|
+
all of them, `"luma": "luma"` (the package's command), and its
|
|
567
|
+
`luma.config.mjs` the storefront's host (`export const host =
|
|
568
|
+
'www.shop.example'`): a tool Luma adds is there once Luma is updated, nothing to
|
|
569
|
+
change in the integration. `yarn luma` lists them; `yarn luma tooling --write`
|
|
570
|
+
sets an integration up
|
|
571
|
+
([`bin/tools.mjs`](bin/tools.mjs), [`build/tooling.mjs`](build/tooling.mjs)).
|
|
572
|
+
|
|
573
|
+
| Command | What |
|
|
574
|
+
|---|---|
|
|
575
|
+
| `yarn luma chrome [path]` | the live storefront in your Chrome with the integration on it as the site would include it (the early-hold snippet, then the build), whether or not the site includes it yet; the site's own Luigi's Box scripts blocked, nothing else changed; `--dev` from `yarn dev`, `--check` prints what Luma did and exits ([`chrome/run.mjs`](chrome/run.mjs)) |
|
|
576
|
+
| `yarn luma a11y` | WCAG 2.2 AA check of the integration's widgets, by keyboard too ([`docs/ACCESSIBILITY.md`](docs/ACCESSIBILITY.md) §8) |
|
|
577
|
+
| `yarn luma parity` | compares the site's search and category UI with Luma's, component by component ([`docs/PARITY.md`](docs/PARITY.md)) |
|
|
578
|
+
| `yarn luma footprint` | what Luma itself costs per page type: the entry's size and round trips, scripts and bytes loaded, the script chain, main-thread and compile time, API requests, time to ready; on the integration's stub page ([`footprint/run.mjs`](footprint/run.mjs)) |
|
|
579
|
+
| `yarn luma tasks` | Luma's main-thread tasks per scenario (pages, a state change, show more, Back, typing) at 4x CPU on the storefront: the worst task's own time against a 16 ms budget, its functions by source line, the site's code under Luma's calls apart ([`tasks/run.mjs`](tasks/run.mjs)) |
|
|
580
|
+
| `yarn luma baseline` | nothing in `integration/` newer than Baseline Widely available (`--built dist`: the build) |
|
|
581
|
+
|
|
582
|
+
The integration's settings for the tools are its `luma.config.mjs`
|
|
583
|
+
([`bin/config.mjs`](bin/config.mjs) loads it, with Luma's defaults). Every
|
|
584
|
+
export is optional:
|
|
585
|
+
|
|
586
|
+
```js
|
|
587
|
+
// The storefront's host name: the tools open it (LBX_HOST overrides it). Without
|
|
588
|
+
// it they use the stub page (`yarn luma chrome` needs it).
|
|
589
|
+
export const host = 'www.shop.example';
|
|
590
|
+
// The site's name (LBX_SITE overrides it); default: package.json's name.
|
|
591
|
+
export const site = 'shop';
|
|
592
|
+
// A page in the site's own markup and stylesheet, for the tools' runs on the
|
|
593
|
+
// stub page (a string, or a promise of one).
|
|
594
|
+
export const stubPage = (url) => `<!DOCTYPE html>…`;
|
|
595
|
+
// What the tools' scenarios load: a category page, a search, a product page,
|
|
596
|
+
// the header search input.
|
|
597
|
+
export const fixtures = { category: '/some-category', query: 'shoes', product: '/some-product', input: 'input#search' };
|
|
598
|
+
// The pages footprint and tasks measure, { label: path }; default: home,
|
|
599
|
+
// fixtures.category, a search for fixtures.query, fixtures.product.
|
|
600
|
+
export const perf = { pages: { home: '/', category: '/some-category', search: '/search?q=shoes' } };
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
Without a `stubPage`: a neutral page with a header search form (`input
|
|
604
|
+
name=q`), a search anchor `<div id="lbx-search-anchor">` on `/search`, and
|
|
605
|
+
elsewhere a category listing `<div id="lbx-listing">`. Older integrations kept
|
|
606
|
+
these settings in `edge/rig.mjs`: the tools refuse that name, `yarn luma
|
|
607
|
+
tooling --write` moves it.
|
|
608
|
+
|
|
609
|
+
How to make an integration fast and light, and which tool measures what:
|
|
610
|
+
[`docs/INTEGRATION-PERFORMANCE.md`](docs/INTEGRATION-PERFORMANCE.md).
|
|
611
|
+
|
|
612
|
+
## Documentation
|
|
613
|
+
|
|
614
|
+
- [`docs/INTEGRATION-PERFORMANCE.md`](docs/INTEGRATION-PERFORMANCE.md): what to measure in integration work, and the metrics to optimize.
|
|
615
|
+
- [`docs/ACCESSIBILITY.md`](docs/ACCESSIBILITY.md): what Luma guarantees, what templates must do.
|
|
616
|
+
- [`docs/PARITY.md`](docs/PARITY.md): the parity check.
|