@luigisbox/luma 0.1.0 → 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/README.md +202 -75
- package/build/vite.mjs +9 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +10 -5
- package/docs/ACCESSIBILITY.md +1 -2
- package/docs/INTEGRATION-PERFORMANCE.md +5 -6
- package/package.json +1 -1
- package/parity/hold.mjs +1 -1
- 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/token.ts +3 -2
- package/src/core/integration.ts +6 -0
- package/src/core/kept.ts +25 -0
- package/src/core/tiles.ts +137 -0
- package/src/main.ts +54 -4
- package/src/types.ts +18 -0
- package/src/widgets/autocomplete/index.ts +20 -0
- package/src/widgets/listing/element.ts +3 -0
- package/src/widgets/recommender/index.ts +15 -4
package/README.md
CHANGED
|
@@ -1,53 +1,57 @@
|
|
|
1
1
|
# Luma
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
results, category listings (PLP) and recommenders on the Luigi's Box AI
|
|
5
|
-
for sites that can only include
|
|
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
6
|
server-rendered through the site's own cache, from a rendering service Luigi's
|
|
7
7
|
Box runs.
|
|
8
8
|
|
|
9
|
-
|
|
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
|
|
10
14
|
> integration API (config keys, template scope, the build); a patch version
|
|
11
|
-
> (0.
|
|
15
|
+
> (0.2.1) only fixes. Depend on `^0.2.0` to take fixes without breaking
|
|
12
16
|
> changes.
|
|
13
17
|
|
|
14
18
|
## Core principles
|
|
15
19
|
|
|
16
20
|
### The problem
|
|
17
21
|
|
|
18
|
-
Luma runs on storefronts
|
|
19
|
-
That sets the constraints:
|
|
22
|
+
Luma runs on storefronts it doesn't own, often included as a single script
|
|
23
|
+
tag. That sets the constraints:
|
|
20
24
|
|
|
21
|
-
1. **The page
|
|
22
|
-
were there first and keep changing.
|
|
23
|
-
anything.
|
|
24
|
-
2. **
|
|
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,
|
|
25
29
|
flicker or jump compared with the site's own, the integration is a
|
|
26
30
|
regression, however good the results are.
|
|
27
|
-
3. **The shopper should not notice
|
|
31
|
+
3. **The shopper should not notice Luma.** Results look like the site's own UI
|
|
28
32
|
and are usable by everyone.
|
|
29
|
-
4. **One framework, many integrations.** Every change to
|
|
30
|
-
|
|
31
|
-
5. **Measured, not claimed.**
|
|
32
|
-
|
|
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.
|
|
33
37
|
|
|
34
38
|
### A. A guest on the page
|
|
35
39
|
|
|
36
40
|
- **Do no harm, always fall back.** Luma adds only its own nodes
|
|
37
|
-
(`data-lbx`), hides the host's UI with CSS and never removes
|
|
38
|
-
never throws into the page, every network call has a deadline, and
|
|
39
|
-
of
|
|
40
|
-
|
|
41
|
-
missing browser feature: the site's own UI comes back as
|
|
42
|
-
there.
|
|
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.
|
|
43
47
|
- **Navigation stays the host's.** The site's router owns the URL and history.
|
|
44
|
-
|
|
45
|
-
Astro only the router's own bookkeeping keys);
|
|
46
|
-
Anything
|
|
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
|
|
47
51
|
modifier key) stays the browser's. The one exception: a new query from the
|
|
48
|
-
site's search form while on
|
|
52
|
+
site's search form while on Luma's search page is shown in place.
|
|
49
53
|
- **Sense and reconcile, not imperative patching.** Host signals (URL changes,
|
|
50
|
-
DOM mutations,
|
|
54
|
+
DOM mutations, Luma's nodes removed) only mean "reconcile soon". At most once
|
|
51
55
|
per frame the reconciler makes the page match declarative rules: it removes
|
|
52
56
|
nodes whose rule is inactive, puts the same instance back if the host
|
|
53
57
|
removed it, and waits for the anchor to settle. A missed signal is harmless:
|
|
@@ -65,24 +69,24 @@ That sets the constraints:
|
|
|
65
69
|
### B. Faster than the site it replaces
|
|
66
70
|
|
|
67
71
|
- **No flicker, no layout shift.** With the inline snippet in `<head>`, the
|
|
68
|
-
host's listing is held before first paint and swapped for
|
|
69
|
-
Its height is reserved from the last visit. Target: zero layout shift
|
|
70
|
-
|
|
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.
|
|
71
75
|
- **A small entry, one round trip.** The entry is at most 14 kB brotli (one TCP
|
|
72
76
|
initial congestion window). Each widget's code, templates, styles and
|
|
73
77
|
dictionary are 2–7 kB chunks loaded only when that widget can run: a site
|
|
74
78
|
without recommenders never downloads that code.
|
|
75
|
-
- **Never block the main thread.** Target: no task of
|
|
79
|
+
- **Never block the main thread.** Target: no task of Luma's over 16 ms (a frame
|
|
76
80
|
at 60 fps) at 4x CPU; the test gate is 30 ms. Typing in autocomplete reaches
|
|
77
81
|
the next paint within 50 ms (INP). Visual feedback in the handler; fetches
|
|
78
|
-
and history writes in later tasks. No layout reads after
|
|
82
|
+
and history writes in later tasks. No layout reads after Luma's DOM writes in
|
|
79
83
|
the same task. Large listings render in steps that each fit a frame.
|
|
80
84
|
- **Instant.** Results are prefetched while the shopper types or hovers a
|
|
81
85
|
filter, Back restores the listing at the tile they followed, product pages
|
|
82
86
|
are prerendered on intent (Speculation Rules, Chromium).
|
|
83
87
|
- **Server-side rendering, same code.** The same integration and templates
|
|
84
|
-
render in the browser and on
|
|
85
|
-
its own HTML through SSI/ESI in its web server or CDN; the browser takes the
|
|
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
|
|
86
90
|
server-rendered listing over. The server renderer and everything it imports
|
|
87
91
|
stay free of DOM access.
|
|
88
92
|
|
|
@@ -105,10 +109,10 @@ That sets the constraints:
|
|
|
105
109
|
feature-detected and the page works without it. `yarn luma baseline`
|
|
106
110
|
enforces it ([Browser support](#browser-support)).
|
|
107
111
|
|
|
108
|
-
### D. How
|
|
112
|
+
### D. How Luma is built
|
|
109
113
|
|
|
110
|
-
- **One framework, many integrations.** Nothing site-specific in
|
|
111
|
-
|
|
114
|
+
- **One framework, many integrations.** Nothing site-specific in Luma itself;
|
|
115
|
+
each integration has its own repository, with Luma as the `@luigisbox/luma`
|
|
112
116
|
package. `_config.ts` is JSON; code goes in `_init.ts`.
|
|
113
117
|
- **Measured, not claimed.** Every principle has a gate: budgets and
|
|
114
118
|
main-thread limits, request counts, the bfcache test, the baseline lint,
|
|
@@ -116,16 +120,16 @@ That sets the constraints:
|
|
|
116
120
|
Chromium, Firefox and WebKit, desktop and phones, on every change. Every fix
|
|
117
121
|
lands with a test that fails without it.
|
|
118
122
|
|
|
119
|
-
|
|
123
|
+
The trade-offs that come with this: [Known limitations](#known-limitations).
|
|
120
124
|
|
|
121
125
|
|
|
122
|
-
##
|
|
126
|
+
## What's in the package
|
|
123
127
|
|
|
124
128
|
| Path | What |
|
|
125
129
|
|---|---|
|
|
126
130
|
| `src/` | the framework: config, sensing and placement, takeover, templates, API client, widgets |
|
|
127
131
|
| `build/vite.mjs` | the Vite config every integration builds with |
|
|
128
|
-
| `snippet/` | the inline early-hold snippet (identical for every
|
|
132
|
+
| `snippet/` | the inline early-hold snippet (identical for every site) |
|
|
129
133
|
| `a11y/` | `yarn luma a11y`: an integration's WCAG 2.2 AA check |
|
|
130
134
|
| `parity/` | `yarn luma parity`: an integration compared with the site it replaces |
|
|
131
135
|
| `bin/` | `yarn luma <tool>`: every tool below, from an integration repository, with one script |
|
|
@@ -133,17 +137,18 @@ What we accept in exchange: [Known limitations](#known-limitations).
|
|
|
133
137
|
| `chrome/` | `yarn luma chrome`: the live storefront in your Chrome with an integration on it, before the site includes it |
|
|
134
138
|
| `tasks/` | `yarn luma tasks`: an integration's main-thread tasks over a frame's budget on the storefront, by source line |
|
|
135
139
|
|
|
136
|
-
Integrations
|
|
137
|
-
`@luigisbox/luma` package
|
|
138
|
-
[Integration repository](#integration-repository)).
|
|
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)).
|
|
139
143
|
|
|
140
|
-
## What the
|
|
144
|
+
## What the site includes
|
|
141
145
|
|
|
142
146
|
Everything goes in the site's HTML, in `<head>`, on every page.
|
|
143
|
-
`<distribution id>` is the site's distribution (`lbdr_…`)
|
|
147
|
+
`<distribution id>` is the site's distribution (`lbdr_…`), provided by
|
|
148
|
+
Luigi's Box.
|
|
144
149
|
|
|
145
150
|
**Loader only** (a tag manager is fine): the site's own listing may flash
|
|
146
|
-
before
|
|
151
|
+
before Luma's on a hard load.
|
|
147
152
|
|
|
148
153
|
```html
|
|
149
154
|
<script async src="https://cdn.eu1.luigisbox.ai/b/<distribution id>/loader.js"></script>
|
|
@@ -167,7 +172,7 @@ first and the snippet as the last element of `<head>`.
|
|
|
167
172
|
```
|
|
168
173
|
|
|
169
174
|
**Connection hints** (recommended, first in `<head>`, before the snippet): the
|
|
170
|
-
connections
|
|
175
|
+
connections Luma's requests need, opened while the HTML is still arriving instead
|
|
171
176
|
of after the loader and the entry have run (the entry adds the same hints, but
|
|
172
177
|
only once `<body>` starts). On a first visit they take the TCP and TLS setup
|
|
173
178
|
(one to three round trips per host) off the path to the listing. Use the
|
|
@@ -185,7 +190,7 @@ catalog's product images.
|
|
|
185
190
|
- The CDN twice: the loader is a classic script (no CORS) and the chunks are
|
|
186
191
|
modules loaded with CORS. A browser does not share connections between the two
|
|
187
192
|
modes. Drop the plain one if the Network panel shows a single connection.
|
|
188
|
-
- The API and auth hosts with `crossorigin`:
|
|
193
|
+
- The API and auth hosts with `crossorigin`: Luma's requests are CORS `fetch()`es.
|
|
189
194
|
A preconnect without it opens a connection they cannot use.
|
|
190
195
|
- The image host without `crossorigin` (images are no-CORS). Leave it out if
|
|
191
196
|
it is the site's own host (connected already).
|
|
@@ -193,7 +198,7 @@ catalog's product images.
|
|
|
193
198
|
connections, and an unused one is closed after about 10 s.
|
|
194
199
|
- On listing and search pages, `fetchpriority="high"` on the loader's tag moves
|
|
195
200
|
it ahead of the page's images (an `async` script is otherwise fetched at low
|
|
196
|
-
priority).
|
|
201
|
+
priority). Luma's listing is those pages' main content. Measure it with
|
|
197
202
|
`yarn luma footprint` and DevTools before keeping it.
|
|
198
203
|
|
|
199
204
|
The whole `<head>` start of a listing page, in order:
|
|
@@ -215,15 +220,15 @@ What to measure, and the numbers to optimize for:
|
|
|
215
220
|
[docs/INTEGRATION-PERFORMANCE.md](docs/INTEGRATION-PERFORMANCE.md).
|
|
216
221
|
|
|
217
222
|
**Server-side includes**: the listing arrives in the site's HTML, through
|
|
218
|
-
|
|
223
|
+
the site's web server or CDN config, with the loader as above. The site adds an
|
|
219
224
|
include (nginx SSI or Varnish/CDN ESI) and a marker at its listing, and a short
|
|
220
|
-
snippet to its server config; Luigi's Box provides both
|
|
225
|
+
snippet to its server config; Luigi's Box provides both. The
|
|
221
226
|
include sits outside any root the site's own framework hydrates, and the site's
|
|
222
227
|
own listing stays the fallback.
|
|
223
228
|
|
|
224
|
-
If
|
|
225
|
-
|
|
226
|
-
until the next visit.
|
|
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.
|
|
227
232
|
|
|
228
233
|
## Integration repository
|
|
229
234
|
|
|
@@ -244,18 +249,15 @@ Its `tsconfig.json` extends `@luigisbox/luma/tsconfig.json`, includes
|
|
|
244
249
|
`integration/**/*` and `node_modules/@luigisbox/luma/src/**/*`, and maps `@/*`
|
|
245
250
|
to `./node_modules/@luigisbox/luma/src/*`. Its `package.json` depends on
|
|
246
251
|
`@luigisbox/luma`, `vite` 7, `typescript` and `vite-plugin-mkcert` (Luma brings
|
|
247
|
-
`lit-html`, `@lit-labs/ssr-client` and its tools' dependencies).
|
|
248
|
-
repository has Luma as the `luma/` submodule instead (`./luma/…` for
|
|
249
|
-
`@luigisbox/luma/…`, and `yarn --cwd luma install` for the tools); both build
|
|
250
|
-
the same.
|
|
252
|
+
`lit-html`, `@lit-labs/ssr-client` and its tools' dependencies).
|
|
251
253
|
|
|
252
254
|
The package holds what an integration uses: the framework's source, the build
|
|
253
|
-
config, `yarn luma` and
|
|
255
|
+
config, `yarn luma` and its tools, and their docs.
|
|
254
256
|
|
|
255
257
|
```
|
|
256
258
|
integration/
|
|
257
259
|
__integration.ts hands the folder to the framework (startIntegration)
|
|
258
|
-
_global/ _config.ts _init.ts [_widget.ts] _translations.ts style-*.css
|
|
260
|
+
_global/ _config.ts _init.ts [_widget.ts] [_analytics.ts] _translations.ts style-*.css
|
|
259
261
|
template-*.ts shared by every widget (a tile, …)
|
|
260
262
|
autocomplete/ _config.ts [_init.ts] [_widget.ts] [style-host.css] [template-autocomplete-*.ts]
|
|
261
263
|
search/ _config.ts _translations.ts style-host.css template-search-*.ts
|
|
@@ -277,7 +279,7 @@ integration/
|
|
|
277
279
|
context is made, `_global/_widget.ts` first. `__integration.ts` hands it
|
|
278
280
|
over as a lazy glob: `widgetInits: import.meta.glob('./*/_widget.ts', { import: 'default' })`.
|
|
279
281
|
- **`style-host.css`** hides the host's own listing or suggestions, only while
|
|
280
|
-
the page
|
|
282
|
+
Luma has the page. Other `style-*.css` load with their widget. Luma's built-in
|
|
281
283
|
CSS sits in `@layer lbx`, so the integration's and the site's CSS win.
|
|
282
284
|
- **Templates override by name**: a widget's built-in templates, then
|
|
283
285
|
`_global/`, then the widget's own folder. Names: `search-box`, `search-header`,
|
|
@@ -352,6 +354,128 @@ On by default unless noted; each falls back to what the page would do anyway.
|
|
|
352
354
|
| `_global.prefetchFilters` | a filter, sort or chip is fetched on hover, touch or focus | |
|
|
353
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 |
|
|
354
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
|
+
|
|
355
479
|
## Browser support
|
|
356
480
|
|
|
357
481
|
Luma targets [Baseline](https://web.dev/baseline) **Widely available**; anything
|
|
@@ -369,18 +493,22 @@ integration repository").
|
|
|
369
493
|
|
|
370
494
|
- **Autocomplete needs the Popover API**: without it (Safari < 17, Firefox
|
|
371
495
|
< 125) the site keeps its own suggestions.
|
|
372
|
-
- **Loader without the snippet**: the site's own listing may flash before
|
|
373
|
-
on a hard load.
|
|
374
|
-
- **Analytics**:
|
|
375
|
-
|
|
376
|
-
|
|
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`).
|
|
377
505
|
- **Prerendering** costs the site a page render per unfollowed hover
|
|
378
506
|
(`speculation: false` turns it off).
|
|
379
|
-
- **Early hold**: the snippet carries no
|
|
507
|
+
- **Early hold**: the snippet carries no site's rules, so the first page
|
|
380
508
|
view is not held.
|
|
381
509
|
- **Host routers out of reach**: where Luma cannot find the host's router (Vue
|
|
382
510
|
Router 4 when its app is not discoverable, Angular in production), the
|
|
383
|
-
router can overwrite
|
|
511
|
+
router can overwrite Luma's history entry or show a stale query in its search
|
|
384
512
|
box. `inPlaceQuery: false` for such a site.
|
|
385
513
|
|
|
386
514
|
## Development
|
|
@@ -390,7 +518,7 @@ Node 24 or later, Yarn 4 (corepack).
|
|
|
390
518
|
### Seeing an integration on the storefront
|
|
391
519
|
|
|
392
520
|
An integration is developed in its own repository. How you see it on the live
|
|
393
|
-
site depends on whether the site includes
|
|
521
|
+
site depends on whether the site includes the Luigi's Box loader yet.
|
|
394
522
|
|
|
395
523
|
**The site includes the loader.** Serve the integration and add
|
|
396
524
|
`?_lbx_env=development` to any page of the site:
|
|
@@ -408,7 +536,7 @@ published build (a red banner says so); an edit shows on reload.
|
|
|
408
536
|
`LBX_DEV_ORIGINS` (comma-separated) limits which sites may load from it.
|
|
409
537
|
|
|
410
538
|
**The site does not include the loader yet.** Open the storefront with the
|
|
411
|
-
integration injected as
|
|
539
|
+
integration injected as the site would include it (the early-hold snippet,
|
|
412
540
|
then the build):
|
|
413
541
|
|
|
414
542
|
```bash
|
|
@@ -435,8 +563,7 @@ WebKit before it ships.
|
|
|
435
563
|
### Tools for an integration repository
|
|
436
564
|
|
|
437
565
|
An integration repository's `package.json` has one script for
|
|
438
|
-
all of them, `"luma": "luma"` (the package's command
|
|
439
|
-
`"node luma/bin/luma.mjs"` after `yarn --cwd luma install`), and its
|
|
566
|
+
all of them, `"luma": "luma"` (the package's command), and its
|
|
440
567
|
`luma.config.mjs` the storefront's host (`export const host =
|
|
441
568
|
'www.shop.example'`): a tool Luma adds is there once Luma is updated, nothing to
|
|
442
569
|
change in the integration. `yarn luma` lists them; `yarn luma tooling --write`
|
|
@@ -445,11 +572,11 @@ sets an integration up
|
|
|
445
572
|
|
|
446
573
|
| Command | What |
|
|
447
574
|
|---|---|
|
|
448
|
-
| `yarn luma chrome [path]` | the live storefront in your Chrome with the integration on it as
|
|
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)) |
|
|
449
576
|
| `yarn luma a11y` | WCAG 2.2 AA check of the integration's widgets, by keyboard too ([`docs/ACCESSIBILITY.md`](docs/ACCESSIBILITY.md) §8) |
|
|
450
|
-
| `yarn luma parity` | compares the site's search and category UI with
|
|
577
|
+
| `yarn luma parity` | compares the site's search and category UI with Luma's, component by component ([`docs/PARITY.md`](docs/PARITY.md)) |
|
|
451
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)) |
|
|
452
|
-
| `yarn luma tasks` |
|
|
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)) |
|
|
453
580
|
| `yarn luma baseline` | nothing in `integration/` newer than Baseline Widely available (`--built dist`: the build) |
|
|
454
581
|
|
|
455
582
|
The integration's settings for the tools are its `luma.config.mjs`
|
package/build/vite.mjs
CHANGED
|
@@ -30,6 +30,9 @@ const FRAMEWORK = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
|
|
|
30
30
|
// building, stash, in-place query, URL writing): typing on
|
|
31
31
|
// any page and the listing need it, a page with only a
|
|
32
32
|
// recommender does not
|
|
33
|
+
// lbx-analytics pageviews, our tiles' clicks and add-to-carts, the
|
|
34
|
+
// site's GA4 events, the integration's _analytics.ts and
|
|
35
|
+
// what it imports (tree-shaken): every page once loaded, and widgets
|
|
33
36
|
// lbx-locale-* lbx-hydrate only where needed (the site's
|
|
34
37
|
// locale, server-rendered pages)
|
|
35
38
|
// A module the entry imports statically (health, sense, the listing's query
|
|
@@ -60,6 +63,10 @@ const entryGraph = (getModuleInfo, getModuleIds) => {
|
|
|
60
63
|
return seen;
|
|
61
64
|
};
|
|
62
65
|
export const chunkOf = (id) => {
|
|
66
|
+
// An integration's _analytics.ts (its analytics setup) goes with the analytics
|
|
67
|
+
// chunk that reads it (no request of its own), with what it imports from
|
|
68
|
+
// @/analytics: the rest of src/analytics is tree-shaken out of it.
|
|
69
|
+
if (/\/integration\/[^/]+\/_analytics\.[jt]s$/.test(id)) return 'lbx-analytics';
|
|
63
70
|
// Any other module of an integration folder that only something lazy imports
|
|
64
71
|
// (its lazy _translations.ts, code an _init.ts hook imports on demand) goes
|
|
65
72
|
// with its folder's templates: no chunk, no request of its own. An eager one
|
|
@@ -86,6 +93,8 @@ export const chunkOf = (id) => {
|
|
|
86
93
|
if (/\/src\/widgets\/search\/(index|in-place)\.ts$/.test(id)) return 'lbx-search';
|
|
87
94
|
// Search while typing (main.ts loads it once the shopper types): not in lbx-nav, which a listing page loads at once.
|
|
88
95
|
if (/\/src\/widgets\/search\/speculate\.ts$/.test(id)) return 'lbx-speculate';
|
|
96
|
+
// Analytics (src/analytics): every page once loaded (main.ts), widgets import it for their tiles' clicks.
|
|
97
|
+
if (/\/src\/analytics\//.test(id)) return 'lbx-analytics';
|
|
89
98
|
// Minting the browser token: a session's first page loads it at boot (main.ts), lbx-core imports it.
|
|
90
99
|
if (/\/src\/api\/token\.ts$/.test(id)) return 'lbx-token';
|
|
91
100
|
// Only the site's locale is ever loaded.
|
package/chrome/block.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The Luigi's Box scripts `yarn luma chrome` keeps off the storefront (a
|
|
2
|
+
// platform loader, a previous integration: never with ours), but the hosts
|
|
3
|
+
// `--allow` names: a previous generation's search or autocomplete the
|
|
4
|
+
// integration's analytics observes (mutationOf, xhrMatching).
|
|
5
|
+
export const LBX_SCRIPTS = /^https:\/\/(cdn\.luigisbox\.(com|tech)|cdn\.eu1\.luigisbox\.ai|scripts\.luigisbox\.(com|tech))\//;
|
|
6
|
+
|
|
7
|
+
/** Whether `url` is blocked, `allow` the host names left alone. */
|
|
8
|
+
export const blocked = (url, allow = []) => LBX_SCRIPTS.test(url) && !allow.includes(new URL(url).hostname);
|
package/chrome/run.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// yarn luma chrome the storefront's home page (luma.config.mjs `host`, or LBX_HOST)
|
|
8
8
|
// yarn luma chrome /some-category another page
|
|
9
9
|
// yarn luma chrome --dev from the dev server (yarn dev): an edit shows on reload, no build
|
|
10
|
+
// yarn luma chrome --allow cdn.luigisbox.tech that host's Luigi's Box scripts left on (their previous search, say)
|
|
10
11
|
//
|
|
11
12
|
// Nothing else of the site changes: Luigi's Box's scripts it already loads (a
|
|
12
13
|
// platform loader, a previous integration) are blocked, so ours is the only
|
|
@@ -20,6 +21,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } fr
|
|
|
20
21
|
import { homedir, tmpdir } from 'node:os';
|
|
21
22
|
import { extname, join, normalize, resolve } from 'node:path';
|
|
22
23
|
import { inside } from '../bin/paths.mjs';
|
|
24
|
+
import { blocked } from './block.mjs';
|
|
23
25
|
|
|
24
26
|
const USAGE = `usage: yarn luma chrome [path] [options]
|
|
25
27
|
|
|
@@ -30,10 +32,12 @@ const USAGE = `usage: yarn luma chrome [path] [options]
|
|
|
30
32
|
--no-snippet the build alone, without the early-hold snippet in <head>
|
|
31
33
|
--channel NAME chrome (default: your installed Chrome) or chromium (Playwright's)
|
|
32
34
|
--profile DIR the browser profile (default ~/.lbx-chrome/<host>)
|
|
33
|
-
--site-dir DIR the integration repository (default: the current directory, LBX_SITE_DIR)
|
|
35
|
+
--site-dir DIR the integration repository (default: the current directory, LBX_SITE_DIR)
|
|
36
|
+
--allow HOST leave that host's Luigi's Box scripts on (repeatable): a previous search or
|
|
37
|
+
autocomplete the integration's analytics observes (cdn.luigisbox.tech)`;
|
|
34
38
|
|
|
35
39
|
const args = process.argv.slice(2);
|
|
36
|
-
const opt = { path: '/', check: false, dev: false, build: true, snippet: true, channel: 'chrome', profile: '', siteDir: '' };
|
|
40
|
+
const opt = { path: '/', check: false, dev: false, build: true, snippet: true, channel: 'chrome', profile: '', siteDir: '', allow: [] };
|
|
37
41
|
const die = (msg) => {
|
|
38
42
|
console.error(msg);
|
|
39
43
|
process.exit(2);
|
|
@@ -55,6 +59,7 @@ for (let i = 0; i < args.length; i++) {
|
|
|
55
59
|
else if (a === '--channel') opt.channel = next();
|
|
56
60
|
else if (a === '--profile') opt.profile = next();
|
|
57
61
|
else if (a === '--site-dir') opt.siteDir = next();
|
|
62
|
+
else if (a === '--allow') opt.allow.push(next());
|
|
58
63
|
else if (!a.startsWith('--')) opt.path = a.startsWith('/') ? a : `/${a}`;
|
|
59
64
|
else die(`unknown option ${a}\n\n${USAGE}`);
|
|
60
65
|
}
|
|
@@ -115,8 +120,8 @@ for (const channel of [opt.channel, ...(opt.channel === 'chrome' ? ['chromium']
|
|
|
115
120
|
if (channel === 'chromium') die(`No browser to open: install Chrome, or \`yarn playwright-core install chromium\`.\n${e.message.split('\n')[0]}`);
|
|
116
121
|
}
|
|
117
122
|
}
|
|
118
|
-
// Luigi's Box's scripts the site loads already: never with ours.
|
|
119
|
-
await ctx.route(
|
|
123
|
+
// Luigi's Box's scripts the site loads already: never with ours (but the hosts --allow names).
|
|
124
|
+
await ctx.route((u) => blocked(u.href, opt.allow), (r) => r.abort('blockedbyclient'));
|
|
120
125
|
// The build, from the storefront's own origin (as a platform loader serves it): fresh on every reload.
|
|
121
126
|
await ctx.route(`${ORIGIN}${PREFIX}**`, (route) => {
|
|
122
127
|
const file = normalize(join(DIST, decodeURIComponent(new URL(route.request().url()).pathname.slice(PREFIX.length))));
|
|
@@ -139,7 +144,7 @@ if (opt.check) {
|
|
|
139
144
|
await ctx.close();
|
|
140
145
|
process.exit(!s || s.error || s.errors?.length ? 1 : 0);
|
|
141
146
|
}
|
|
142
|
-
console.log(`${ORIGIN}${opt.path} with ${opt.dev ? 'the dev server (yarn dev)' : opt.build ? 'this build' : 'dist/'}${opt.snippet ? ' and the early-hold snippet' : ''}, Luigi's Box's own scripts blocked.`);
|
|
147
|
+
console.log(`${ORIGIN}${opt.path} with ${opt.dev ? 'the dev server (yarn dev)' : opt.build ? 'this build' : 'dist/'}${opt.snippet ? ' and the early-hold snippet' : ''}, Luigi's Box's own scripts blocked${opt.allow.length ? ` but ${opt.allow.join(', ')}` : ''}.`);
|
|
143
148
|
console.log(opt.dev ? 'Edit, then reload the page. ' : opt.build ? 'A change: run this again (or --dev with yarn dev). ' : 'A change: yarn build, then reload. ');
|
|
144
149
|
console.log(`window.__lbx in the console: Luma's state. Close the window to stop (profile: ${profile}).`);
|
|
145
150
|
await new Promise((r) => ctx.on('close', r));
|
package/docs/ACCESSIBILITY.md
CHANGED
|
@@ -400,8 +400,7 @@ From the integration repository: `yarn luma a11y`
|
|
|
400
400
|
(README, "Tools for an integration repository": the one `luma` script). The
|
|
401
401
|
storefront's host name is its `luma.config.mjs` `host` (`LBX_HOST` overrides it;
|
|
402
402
|
the browser token is minted for that origin). Its dependencies come with Luma
|
|
403
|
-
(Playwright, axe-core;
|
|
404
|
-
once); the browser is Playwright's Chromium or the installed Chrome,
|
|
403
|
+
(Playwright, axe-core); the browser is Playwright's Chromium or the installed Chrome,
|
|
405
404
|
`LBX_CHROMIUM_PATH` otherwise.
|
|
406
405
|
|
|
407
406
|
```bash
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Integration performance: what to measure, what to optimize
|
|
2
2
|
|
|
3
3
|
A guideline for integration work (an integration repository with Luma as the
|
|
4
|
-
`@luigisbox/luma` package
|
|
4
|
+
`@luigisbox/luma` package). Luma's own budgets are
|
|
5
5
|
held by its own test suites. An integration can still undo them with heavy tiles, a
|
|
6
6
|
layout that moves, or a slow install. This page lists the numbers to watch,
|
|
7
7
|
in priority order, and the levers an integration has.
|
|
@@ -187,10 +187,9 @@ slowest type sets the pace (usually products).
|
|
|
187
187
|
|
|
188
188
|
## How to measure, step by step
|
|
189
189
|
|
|
190
|
-
0. `yarn luma tooling
|
|
191
|
-
`
|
|
192
|
-
|
|
193
|
-
`luma.config.mjs` (`--write` adds them); `yarn luma` lists the tools.
|
|
190
|
+
0. `yarn luma tooling`: the integration has the one `luma` script and its
|
|
191
|
+
storefront's host in `luma.config.mjs` (`--write` adds them); `yarn luma`
|
|
192
|
+
lists the tools.
|
|
194
193
|
1. `yarn luma footprint --rtt 100 --runs 5` in the integration repository, before
|
|
195
194
|
and after the change. Compare the cold and warm `ready`, main thread, scripts,
|
|
196
195
|
bytes, chain and API requests per scenario. Commit the JSON (`--json`) so
|
|
@@ -208,7 +207,7 @@ slowest type sets the pace (usually products).
|
|
|
208
207
|
layout (facets and quick types above or beside) differs by width, and the
|
|
209
208
|
first screen holds more tiles on a tall one.
|
|
210
209
|
|
|
211
|
-
## What the
|
|
210
|
+
## What the site includes
|
|
212
211
|
|
|
213
212
|
The snippet with the connection hints is in README.md ("What the customer
|
|
214
213
|
includes").
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luigisbox/luma",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Luma: Luigi's Box frontend integrations (autocomplete, search, category listings, recommenders) on the Luigi's Box AI API. Pre-release.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
package/parity/hold.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Luma on a page as a customer installs it (README, "What the
|
|
1
|
+
// Luma on a page as a customer installs it (README, "What the site includes"): the
|
|
2
2
|
// generic early-hold snippet (snippet/early-hold.min.js) first in <head>,
|
|
3
3
|
// then the build. `yarn luma parity` (ours in every mode: replay, --record,
|
|
4
4
|
// --live, --dev) and `yarn luma a11y` put the build in the storefront's own page
|