@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.
Files changed (145) hide show
  1. package/LICENSE.md +90 -0
  2. package/README.md +615 -2
  3. package/a11y/audit.mjs +735 -0
  4. package/a11y/axe.mjs +73 -0
  5. package/a11y/report.mjs +347 -0
  6. package/a11y/run.mjs +256 -0
  7. package/a11y/wcag.mjs +233 -0
  8. package/bin/config.mjs +60 -0
  9. package/bin/luma.mjs +99 -0
  10. package/bin/paths.mjs +11 -0
  11. package/bin/tools.mjs +43 -0
  12. package/build/baseline.d.mts +8 -0
  13. package/build/baseline.mjs +167 -0
  14. package/build/tooling.mjs +102 -0
  15. package/build/vite.mjs +507 -0
  16. package/chrome/block.mjs +8 -0
  17. package/chrome/run.mjs +150 -0
  18. package/docs/ACCESSIBILITY.md +501 -0
  19. package/docs/INTEGRATION-PERFORMANCE.md +213 -0
  20. package/docs/PARITY.md +946 -0
  21. package/footprint/model.mjs +184 -0
  22. package/footprint/run.mjs +331 -0
  23. package/package.json +58 -4
  24. package/parity/alignment.mjs +109 -0
  25. package/parity/archive.mjs +223 -0
  26. package/parity/args.mjs +137 -0
  27. package/parity/behaviour.mjs +379 -0
  28. package/parity/compare.mjs +985 -0
  29. package/parity/engine.mjs +848 -0
  30. package/parity/hints.mjs +132 -0
  31. package/parity/hold.mjs +54 -0
  32. package/parity/init.mjs +703 -0
  33. package/parity/map.mjs +640 -0
  34. package/parity/owner.mjs +170 -0
  35. package/parity/pairing.mjs +110 -0
  36. package/parity/pixels.mjs +33 -0
  37. package/parity/probe.mjs +1239 -0
  38. package/parity/report.mjs +236 -0
  39. package/parity/run.mjs +267 -0
  40. package/snippet/early-hold.js +64 -0
  41. package/snippet/early-hold.min.js +1 -0
  42. package/src/analytics/chain.ts +137 -0
  43. package/src/analytics/collect.ts +64 -0
  44. package/src/analytics/datalayer.ts +67 -0
  45. package/src/analytics/from-ga4.ts +68 -0
  46. package/src/analytics/from-luma.ts +21 -0
  47. package/src/analytics/index.ts +24 -0
  48. package/src/analytics/outbox.ts +118 -0
  49. package/src/analytics/page.ts +46 -0
  50. package/src/analytics/pageview.ts +82 -0
  51. package/src/analytics/ref.ts +19 -0
  52. package/src/analytics/setup.ts +41 -0
  53. package/src/analytics/start.ts +53 -0
  54. package/src/analytics/triggers.ts +239 -0
  55. package/src/analytics/user.ts +12 -0
  56. package/src/analytics/wire.ts +67 -0
  57. package/src/api/ai.ts +98 -0
  58. package/src/api/index.ts +87 -0
  59. package/src/api/token-key.ts +42 -0
  60. package/src/api/token.ts +312 -0
  61. package/src/core/a11y-keys.ts +60 -0
  62. package/src/core/a11y.ts +160 -0
  63. package/src/core/boot-state.ts +6 -0
  64. package/src/core/color.ts +42 -0
  65. package/src/core/context.ts +108 -0
  66. package/src/core/early-rules.ts +96 -0
  67. package/src/core/entries.ts +25 -0
  68. package/src/core/fold.ts +7 -0
  69. package/src/core/health.ts +26 -0
  70. package/src/core/hold.ts +74 -0
  71. package/src/core/i18n.ts +66 -0
  72. package/src/core/image-load.ts +102 -0
  73. package/src/core/image-tags.ts +26 -0
  74. package/src/core/images.ts +170 -0
  75. package/src/core/integration.ts +126 -0
  76. package/src/core/kept.ts +25 -0
  77. package/src/core/listings.ts +100 -0
  78. package/src/core/locales.ts +14 -0
  79. package/src/core/own.ts +49 -0
  80. package/src/core/page.ts +14 -0
  81. package/src/core/prefetch.ts +88 -0
  82. package/src/core/recommenders.ts +166 -0
  83. package/src/core/reconciler.ts +397 -0
  84. package/src/core/result.ts +73 -0
  85. package/src/core/routes.ts +115 -0
  86. package/src/core/scheduler.ts +83 -0
  87. package/src/core/sense.ts +107 -0
  88. package/src/core/speculation.ts +59 -0
  89. package/src/core/stash.ts +38 -0
  90. package/src/core/storage.ts +45 -0
  91. package/src/core/styles.ts +106 -0
  92. package/src/core/support.ts +12 -0
  93. package/src/core/takeover.ts +220 -0
  94. package/src/core/templates.ts +74 -0
  95. package/src/core/tiles.ts +137 -0
  96. package/src/core/turbo.ts +64 -0
  97. package/src/core/url.ts +164 -0
  98. package/src/core/widget-inits.ts +40 -0
  99. package/src/env.d.ts +3 -0
  100. package/src/lit.ts +11 -0
  101. package/src/locales/cs.json +94 -0
  102. package/src/locales/de.json +88 -0
  103. package/src/locales/en.json +88 -0
  104. package/src/main.ts +493 -0
  105. package/src/types.ts +778 -0
  106. package/src/widgets/autocomplete/index.ts +1387 -0
  107. package/src/widgets/autocomplete/style-autocomplete.css +266 -0
  108. package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
  109. package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
  110. package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
  111. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
  112. package/src/widgets/listing/element.ts +1772 -0
  113. package/src/widgets/listing/flow.ts +30 -0
  114. package/src/widgets/listing/instant.ts +133 -0
  115. package/src/widgets/listing/intent.ts +193 -0
  116. package/src/widgets/listing/query.ts +298 -0
  117. package/src/widgets/listing/stash.ts +97 -0
  118. package/src/widgets/listing/state.ts +131 -0
  119. package/src/widgets/listing/view.ts +257 -0
  120. package/src/widgets/plp/index.ts +11 -0
  121. package/src/widgets/plp/route.ts +263 -0
  122. package/src/widgets/recommender/index.ts +261 -0
  123. package/src/widgets/recommender/style-recommender.css +26 -0
  124. package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
  125. package/src/widgets/recommender/template-recommender-popup.ts +12 -0
  126. package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
  127. package/src/widgets/search/assets.ts +7 -0
  128. package/src/widgets/search/first.ts +15 -0
  129. package/src/widgets/search/form-url.ts +59 -0
  130. package/src/widgets/search/in-place.ts +226 -0
  131. package/src/widgets/search/index.ts +16 -0
  132. package/src/widgets/search/range.ts +260 -0
  133. package/src/widgets/search/route.ts +32 -0
  134. package/src/widgets/search/speculate.ts +202 -0
  135. package/src/widgets/search/style-search.css +65 -0
  136. package/src/widgets/search/template-search-box.ts +17 -0
  137. package/src/widgets/search/template-search-facets-default.ts +73 -0
  138. package/src/widgets/search/template-search-header.ts +14 -0
  139. package/src/widgets/search/template-search-pagination.ts +12 -0
  140. package/src/widgets/search/template-search-range-default.ts +48 -0
  141. package/src/widgets/search/template-search-result-default.ts +13 -0
  142. package/src/widgets/search/template-search-results-default.ts +8 -0
  143. package/tasks/model.mjs +116 -0
  144. package/tasks/run.mjs +305 -0
  145. package/tsconfig.json +14 -0
@@ -0,0 +1,220 @@
1
+ // Takeover of a host page (search results, category listing), per route:
2
+ // native their listing visible, ours hidden (we booted after first paint:
3
+ // swap in one frame once ours render; on failure theirs just stay)
4
+ // hold their listing hidden before it ever paints, space reserved, a
5
+ // loading indicator shown (the early-hold snippet held it, we
6
+ // booted before first paint, or an SPA route change into the route)
7
+ // ours our listing visible
8
+ // released fail-safe or a URL we cannot serve: theirs shown, ours hidden
9
+ //
10
+ // Server-rendered pages (Luigi's Box's server-side rendering) start as `ours` when the page arrived
11
+ // with our listing in it, and as `released` when their server fell back to
12
+ // their own listing (our marker is there, our listing is not).
13
+ //
14
+ // In lbx-nav (core/listings.ts creates it), loaded with the listing's chunks
15
+ // on a page of its route. Until it arrives the entry holds their listing
16
+ // (main.ts: the early hold claimed, or its own hold CSS, in the same task as an
17
+ // SPA's URL change into the route); the takeover continues that hold.
18
+
19
+ import { earlyHold } from './early-rules';
20
+ import { canon, isOwnEntry } from './entries';
21
+ import { mark } from './health';
22
+ import { failKey, failures as storedFailures, holding, LOADING, MAX_FAILURES, painted, reserveCss } from './hold';
23
+ import { onUrlChange } from './sense';
24
+ import { session } from './storage';
25
+ import { setCss, whenApplied } from './styles';
26
+
27
+ export type State = 'native' | 'hold' | 'ours' | 'released';
28
+
29
+ export interface TakeoverRoute {
30
+ id: string;
31
+ tag: string;
32
+ accepts: (url: URL) => boolean;
33
+ /** style-host.css of the route's folder: hides the host's listing. */
34
+ hostCss: string;
35
+ reserveSelector: string;
36
+ holdTimeoutMs: number;
37
+ /** Pages of the route that are separate visits (another category = hold again). */
38
+ visitKey?: (url: URL) => string;
39
+ /** The entry already holds their listing for this visit (main.ts, before this chunk arrived): continue that hold. */
40
+ held?: boolean;
41
+ }
42
+
43
+ export interface Takeover {
44
+ readonly state: State;
45
+ /** Our first render is on screen. */
46
+ reveal(): void;
47
+ /** Hand the page back to the host; `failed`: because something went wrong (not a page we decline). */
48
+ release(failed?: boolean): void;
49
+ /** A slow step completed: re-arm the fail-safe (up to 3x the timeout in total). */
50
+ progressed(): void;
51
+ /** Set by the listing: a URL it can render synchronously from cache (no hold needed). */
52
+ cached?: (url: URL) => boolean;
53
+ /** The server rendering of the current visit: ours in the HTML, their fallback, or none (a later visit). */
54
+ readonly ssr: 'rendered' | 'fellback' | null;
55
+ /** The listing adopted a server-rendered element on the booted visit (the include arrived after our boot). */
56
+ serverRendered(): void;
57
+ /** Failed too often this session: no hold (and no early-hold rule), theirs shows while ours loads. */
58
+ readonly failing: boolean;
59
+ }
60
+
61
+ /**
62
+ * Server rendering of the page we booted on. The page template puts a marker
63
+ * after our include: `<template data-lbx-ssr="plp">`. Marker without our
64
+ * element = their server declined or failed and served their own listing.
65
+ */
66
+ const ssrOf = (r: TakeoverRoute): Takeover['ssr'] =>
67
+ document.querySelector(`${r.tag}[data-lbx-ssr]`) ? 'rendered' : document.querySelector(`template[data-lbx-ssr="${r.id}"]`) ? 'fellback' : null;
68
+
69
+
70
+ export const takeover = (r: TakeoverRoute): Takeover => {
71
+ let state: State = 'ours';
72
+ /** Pages of this visit's route we showed ours on, in this document. */
73
+ const shown = new Set<string>();
74
+ let timer = 0;
75
+ /** When the current visit started waiting for us (hold or native). */
76
+ let enteredAt = 0;
77
+ let failures = storedFailures(r.id);
78
+ const countFailures = (n: number) => {
79
+ failures = n;
80
+ session.set(failKey(r.id), n || undefined);
81
+ };
82
+ const fail = () => {
83
+ countFailures(failures + 1);
84
+ set('released');
85
+ };
86
+ const arm = () => {
87
+ clearTimeout(timer);
88
+ // A prerendered page (Speculation Rules) is not shown yet: its hold's time starts at the activation.
89
+ if ((document as any).prerendering) return document.addEventListener('prerenderingchange', () => state === 'hold' && ((enteredAt = performance.now()), arm()), { once: true });
90
+ timer = window.setTimeout(() => state === 'hold' && fail(), r.holdTimeoutMs);
91
+ };
92
+ const set = (s: State) => {
93
+ clearTimeout(timer);
94
+ if (s === 'hold' || s === 'native') enteredAt = performance.now();
95
+ state = s;
96
+ mark(`${r.id} takeover ${s}`);
97
+ if (s === 'ours') shown.add(canon(location.href));
98
+ if (s === 'hold') arm();
99
+ css();
100
+ };
101
+ // Route-scoped host CSS, plus the height we had last time on this URL: the
102
+ // browser restores scroll on reload/back before our results exist.
103
+ const css = () => {
104
+ // A server-rendered fragment carries its own CSS hiding their listing (for
105
+ // pages without JavaScript). Once we write ours, per state, it goes: theirs
106
+ // must come back when we hand the page back.
107
+ for (const el of document.querySelectorAll(`style[data-lbx-ssr-host="${r.id}"]`)) el.remove();
108
+ const key = `host-${r.id}`;
109
+ if (!r.accepts(new URL(location.href))) return setCss(key, null);
110
+ const reserve = reserveCss(r.reserveSelector);
111
+ // A server-rendered listing is never hidden: it is the page's own content.
112
+ const hideOurs = `${r.tag}:not([data-lbx-ssr]) { display: none !important; }`;
113
+ if (state === 'native' || state === 'released') setCss(key, hideOurs);
114
+ // Held: theirs hidden but keeping its height (holding()), ours not shown yet.
115
+ else if (state === 'hold') setCss(key, `${holding(r.hostCss)}${reserve}\n${hideOurs}\n${LOADING(r.reserveSelector)}`);
116
+ else setCss(key, r.hostCss + reserve);
117
+ };
118
+
119
+ let ssr = ssrOf(r);
120
+ let visitStartedAtBoot = true;
121
+ const t: Takeover = {
122
+ get state() {
123
+ return state;
124
+ },
125
+ get ssr() {
126
+ return ssr;
127
+ },
128
+ serverRendered: () => {
129
+ if (ssr === 'rendered' || !visitStartedAtBoot) return;
130
+ ssr = 'rendered';
131
+ countFailures(0); // the page is ours after all (a late include is not a failure)
132
+ if (state !== 'ours') set('ours');
133
+ },
134
+ get failing() {
135
+ return failures >= MAX_FAILURES;
136
+ },
137
+ // A success is a render shown within the hold's time (a late one, after
138
+ // the hold gave up or while theirs shows, does not end the failures).
139
+ reveal: () => {
140
+ if (state !== 'native' && state !== 'hold') return;
141
+ const failed = failures;
142
+ if (performance.now() - enteredAt < r.holdTimeoutMs) countFailures(0);
143
+ set('ours');
144
+ // Shown, if ours is in the page: a render can finish while the host has
145
+ // taken ours out (a host fighting ours, the reconciler backing off), and
146
+ // theirs would stay hidden with nothing of ours there. Theirs comes back
147
+ // if ours is not placed (again) within the hold's time.
148
+ if (!document.querySelector(`${r.tag}[data-lbx]`)) {
149
+ countFailures(failed);
150
+ timer = window.setTimeout(() => state === 'ours' && !document.querySelector(`${r.tag}[data-lbx]`) && fail(), r.holdTimeoutMs);
151
+ }
152
+ },
153
+ release: (failed = false) => {
154
+ if (state === 'native' || state === 'released') return;
155
+ // A server-rendered visit whose listing is on the page (the server's, or
156
+ // ours after hydration) keeps it: with SSI there is no listing of theirs
157
+ // in the page to hand back to.
158
+ if (ssr === 'rendered' && document.querySelector(`${r.tag}[data-lbx]`)?.childElementCount) return;
159
+ if (failed) fail();
160
+ else set('released');
161
+ },
162
+ progressed: () => state === 'hold' && performance.now() - enteredAt < r.holdTimeoutMs * 2 && arm(),
163
+ };
164
+
165
+ let wasActive = false;
166
+ let visit = '';
167
+ const visitOf = (url: URL) => r.visitKey?.(url) ?? '';
168
+ onUrlChange((url) => {
169
+ const active = r.accepts(url);
170
+ const another = active && wasActive && visitOf(url) !== visit;
171
+ // Another visit whose page is ours to show while ours is on screen: an
172
+ // entry we created (a new query in place) or a page we showed in this
173
+ // document (back/forward between them). Nothing of theirs renders there,
174
+ // so no hold: ours stays (dimmed while busy) until the new results replace
175
+ // it, instead of blanking. A visit the host navigates to holds as before.
176
+ const continuing = another && state === 'ours' && (isOwnEntry(url.href) || shown.has(canon(url.href)));
177
+ const entering = active && (!wasActive || another) && !continuing;
178
+ // The server's rendering (or fallback) was about the page we booted on.
179
+ if (entering || continuing) {
180
+ ssr = null;
181
+ visitStartedAtBoot = false;
182
+ }
183
+ // Entering the route, or another page of it: hold (their page has not
184
+ // painted yet). Moving within a page (filters, sort, paging, a #hash) keeps
185
+ // what is shown: ours, or theirs once handed back (their own filter clicks
186
+ // must not blank their listing again). After failures in a row, no hold:
187
+ // theirs shows while ours loads (and replaces it if it renders).
188
+ if (entering && t.cached?.(url)) {
189
+ set('ours');
190
+ // Shown from cache, if our element gets placed: when it does not (no
191
+ // anchor, the reconciler backing off), theirs comes back.
192
+ timer = window.setTimeout(() => state === 'ours' && !document.querySelector(`${r.tag}[data-lbx]`) && fail(), r.holdTimeoutMs);
193
+ } else if (entering) set(t.failing ? 'native' : 'hold');
194
+ else {
195
+ if (!active) clearTimeout(timer); // left during a hold: not a failure of ours
196
+ css();
197
+ }
198
+ wasActive = active;
199
+ if (active) visit = visitOf(url);
200
+ });
201
+
202
+ // Boot: the early-hold snippet may have hidden their listing before first paint;
203
+ // then we continue its hold (our CSS is written before its CSS is removed, in
204
+ // the same task: no gap frame). Otherwise hold only if nothing painted yet.
205
+ const url = new URL(location.href);
206
+ wasActive = r.accepts(url);
207
+ visit = visitOf(url);
208
+ const early = earlyHold();
209
+ // Failing: an early hold is not continued (it ends below, with drop()).
210
+ const claimed = wasActive && !t.failing && !!early?.claim?.(r.id);
211
+ if (wasActive && ssr) set(ssr === 'rendered' ? 'ours' : 'released');
212
+ else if (wasActive) set(!t.failing && (claimed || r.held || !painted()) ? 'hold' : 'native');
213
+ else css();
214
+ if (claimed) {
215
+ // Theirs is dropped once ours applies (ours may wait for the end of <head>): no gap.
216
+ whenApplied(() => early!.drop());
217
+ mark(`${r.id} early hold claimed (it began at ${Math.round(early!.t)}ms)`);
218
+ }
219
+ return t;
220
+ };
@@ -0,0 +1,74 @@
1
+ // Templates are lit-html functions: `(s) => html\`…\``, one per template-*.ts
2
+ // file, registered by name (the file name without `template-`). A widget's
3
+ // built-in templates are overridden by the integration's files of the same
4
+ // name. include(name, props) renders another template with the current
5
+ // scope plus props; lit does the DOM updates (only changed bindings touch
6
+ // the DOM, so images, focus and open <details> survive re-renders).
7
+
8
+ import { nothing, render } from 'lit-html';
9
+
10
+ export type Template<S = any> = (s: S) => unknown;
11
+ export type Templates = Record<string, Template>;
12
+
13
+ /** A widget's built-in templates from an eager import.meta.glob of './template-*.ts'. */
14
+ export const byName = (modules: Record<string, unknown>): Templates =>
15
+ Object.fromEntries(Object.entries(modules).map(([p, t]) => [p.split('/').pop()!.replace(/^template-/, '').replace(/\.\w+$/, ''), t as Template]));
16
+
17
+ /**
18
+ * What an include for a result drew (`include(name, { r, i, … })`, a tile
19
+ * or a row), by its result, template and index: the scope values it read
20
+ * and its lit value. A render where every one of them is the same (===)
21
+ * gets that value back without running the template: lit then finds the
22
+ * same values and touches nothing. A listing's view-state redraw (a facet
23
+ * group opened, a letter typed in a facet's search) ran every tile's
24
+ * template again: 31-92 ms a keystroke on a production storefront at 4x CPU.
25
+ */
26
+ const drawn = new WeakMap<object, Map<string, { reads: [string, unknown][]; out: unknown }>>();
27
+
28
+ /** Template `name` applied to `data` (with include()), as a lit value. */
29
+ export const templateOf = (templates: Templates, name: string, data: Record<string, unknown>) => {
30
+ const scope = (d: Record<string, unknown>): any => {
31
+ const s: any = { ...d };
32
+ s.include = (n: string, props?: Record<string, unknown>) => {
33
+ const t = templates[n];
34
+ if (!t) throw new Error(`template "${n}" not found`);
35
+ if (!props) return t(s);
36
+ const r = props.r;
37
+ if (!r || typeof r !== 'object') return t(scope({ ...s, ...props }));
38
+ const id = `${n}\n${String(props.i)}`;
39
+ let byR = drawn.get(r);
40
+ const was = byR?.get(id);
41
+ if (was && was.reads.every(([k, v]) => (k in props ? props[k] : s[k]) === v)) return was.out;
42
+ // Run, recording what it reads (`include` is made anew per scope: not a dependency).
43
+ const reads = new Map<string, unknown>();
44
+ const child = scope({ ...s, ...props });
45
+ const out = t(
46
+ new Proxy(child, {
47
+ get(o, k, rcv) {
48
+ const v = Reflect.get(o, k, rcv);
49
+ if (typeof k === 'string' && k !== 'include') reads.set(k, v);
50
+ return v;
51
+ },
52
+ }),
53
+ );
54
+ if (!byR) drawn.set(r, (byR = new Map()));
55
+ byR.set(id, { reads: [...reads], out });
56
+ return out;
57
+ };
58
+ return s;
59
+ };
60
+ return scope(data).include(name);
61
+ };
62
+
63
+ export const view = (root: HTMLElement | ShadowRoot, templates: Templates) => ({
64
+ /** Render template `name` with `data` into the root. */
65
+ render: (name: string, data: Record<string, unknown>) => render(templateOf(templates, name, data), root),
66
+ /** Empty the root (lit's templates stay prepared: the next render builds from them). */
67
+ clear: () => render(nothing, root),
68
+ /**
69
+ * Adopt server-rendered markup of the same template and data (lit SSR,
70
+ * server-side rendering): binds to the existing DOM instead of re-creating it.
71
+ * `hydrate` is @lit-labs/ssr-client's, loaded only on server-rendered pages.
72
+ */
73
+ hydrate: (name: string, data: Record<string, unknown>, hydrate: (v: unknown, c: HTMLElement | ShadowRoot) => void) => hydrate(templateOf(templates, name, data), root),
74
+ });
@@ -0,0 +1,137 @@
1
+ // Clicks and add-to-carts on our own tiles, seen by the framework: no template
2
+ // says anything about them. Every widget that renders products calls
3
+ // `watch()` on its root once (listings, recommenders, the popup) or `picked()`
4
+ // (the autocomplete's own navigation); the rest follows from the markup:
5
+ //
6
+ // - a product click is a click (any button: a middle click, a modifier opens
7
+ // a tab, still a click) on a link to a result's URL inside a list of ours
8
+ // (`[data-lbx-list]`); the result is the one with that URL (the tile's own
9
+ // when two share it), else, where the integration's link is not `r.c.url`,
10
+ // the tile's at its position (tiles map to hits by position, as the view
11
+ // transitions and focus code assume). Other links in a tile (wishlist,
12
+ // compare) are not product clicks.
13
+ // - an add-to-cart is a click on `[data-lbx-cart]` in a tile (its value: the
14
+ // quantity, default 1): `_global.addToCart(result, { count, el, event })`
15
+ // puts it in the site's cart. Without that hook nothing happens (and it
16
+ // says so).
17
+ //
18
+ // Each is announced as an `lbx:tile` event on document (TileEvent): analytics
19
+ // listens only where the integration's _analytics.ts asks (its `lumaClicks()`,
20
+ // `lumaCarts()` triggers), so a widget brings no analytics code. Analytics may
21
+ // not be listening yet (its chunk and the setup come after the widget's), or
22
+ // the click may leave the page: so each is first queued in sessionStorage, in
23
+ // the click's own task, where analytics claims it, on this page or the next.
24
+ // Not where the integration has no analytics (`_global.analytics` false: the
25
+ // entry sets it where there is no _analytics.ts).
26
+
27
+ import { reportError } from './health';
28
+ import { session } from './storage';
29
+ import type { GlobalConfigType, Result } from '../types';
30
+
31
+ /** `detail` of an `lbx:tile` event. */
32
+ export interface TileEvent {
33
+ /** Its id in the queue. */
34
+ id: string;
35
+ kind: 'click' | 'add_to_cart';
36
+ /** `<type>/@id:<id>`: the result's reference. */
37
+ reference: string;
38
+ result: Result;
39
+ /** Its position in the list (0-based). */
40
+ index: number;
41
+ /** Our list: `lbx_search`, `lbx_plp`, `lbx_reco_<name>`, `lbx_autocomplete`. */
42
+ list: string;
43
+ count?: number;
44
+ price?: number;
45
+ /** The page it happened on, and when (ms epoch): what the event carries, sent later or not. */
46
+ url: string;
47
+ at: number;
48
+ }
49
+ export interface Watched {
50
+ list: string;
51
+ /** The results the list shows now, in tile order. */
52
+ results: () => readonly Result[];
53
+ global: GlobalConfigType;
54
+ }
55
+
56
+ /** The queue analytics claims from (src/analytics/triggers.ts). */
57
+ export const TILES = 'lbx:tiles';
58
+ const announce = (g: GlobalConfigType, e: Omit<TileEvent, 'id' | 'url' | 'at'>) => {
59
+ if (g.analytics === false) return;
60
+ const d = { ...e, id: crypto.randomUUID(), url: location.href, at: Date.now() };
61
+ session.set(TILES, [...(session.get<TileEvent[]>(TILES) ?? []), d].slice(-20));
62
+ try {
63
+ document.dispatchEvent(new CustomEvent('lbx:tile', { detail: d }));
64
+ } catch (err) {
65
+ reportError('tile event', err);
66
+ }
67
+ };
68
+ const reference = (r: Result) => `${r.type || 'product'}/@id:${r.id}`;
69
+
70
+ /** A result clicked (a link to it, or our own navigation: the autocomplete). */
71
+ export const picked = (r: Result, index: number, list: string, g: GlobalConfigType) => announce(g, { kind: 'click', reference: reference(r), result: r, index, list });
72
+
73
+ const watched = new WeakSet<Element>();
74
+ /** Product clicks and add-to-carts inside `root` (once per root). */
75
+ export const watch = (root: Element, w: Watched) => {
76
+ if (watched.has(root)) return;
77
+ watched.add(root);
78
+ const on = (e: MouseEvent) => {
79
+ if (e.type === 'auxclick' && e.button !== 1) return;
80
+ try {
81
+ const t = e.target instanceof Element ? e.target : null;
82
+ const tile = t?.closest('[data-lbx-list] > *');
83
+ if (!tile || !root.contains(tile)) return;
84
+ const results = w.results();
85
+ const tiles = [...tile.parentElement!.children];
86
+ const at = tiles.indexOf(tile);
87
+ const cart = t!.closest<HTMLElement>('[data-lbx-cart]');
88
+ if (cart && tile.contains(cart)) return void (e.type === 'click' && toCart(e, cart, tile, at, tiles.length, results, w));
89
+ const a = t!.closest<HTMLAnchorElement>('a[href]');
90
+ if (!a || !tile.contains(a)) return;
91
+ const r = resultOf(a, at, tiles.length, results);
92
+ if (r) picked(r.r, r.i, w.list, w.global);
93
+ } catch (err) {
94
+ reportError('tile click', err);
95
+ }
96
+ };
97
+ // Capture: before a handler of the tile (or the site's) stops it or navigates.
98
+ root.addEventListener('click', on as EventListener, true);
99
+ root.addEventListener('auxclick', on as EventListener, true);
100
+ };
101
+
102
+ const sameUrl = (r: Result, href: string) => {
103
+ try {
104
+ return !!r.c.url && new URL(r.c.url, location.href).href === href;
105
+ } catch {
106
+ return false;
107
+ }
108
+ };
109
+
110
+ /** The result a link in the tile at `at` leads to. */
111
+ const resultOf = (a: HTMLAnchorElement, at: number, n: number, results: readonly Result[]) => {
112
+ const same = results.flatMap((r, i) => (sameUrl(r, a.href) ? [{ r, i }] : []));
113
+ if (same.length) return same.find((x) => x.i === at) ?? same[0];
114
+ // The integration's link is not r.c.url (its own params): the tile's, where tiles are the results.
115
+ if (n === results.length && results[at] && a.origin === location.origin && !a.matches('[data-remote], [data-method], [download]')) return { r: results[at], i: at };
116
+ return null;
117
+ };
118
+
119
+ const toCart = (e: MouseEvent, el: HTMLElement, tile: Element, at: number, n: number, results: readonly Result[], w: Watched) => {
120
+ // A cart button inside the tile's link must not follow it.
121
+ e.preventDefault();
122
+ e.stopPropagation();
123
+ const link = [...tile.querySelectorAll<HTMLAnchorElement>('a[href]')].map((a) => resultOf(a, at, n, results)).find(Boolean);
124
+ const hit = link ?? (n === results.length && results[at] ? { r: results[at], i: at } : null);
125
+ if (!hit) return reportError('add to cart', 'a [data-lbx-cart] in a tile whose result is unknown (its links lead to no result, and tiles are not the results)');
126
+ const hook = w.global.addToCart;
127
+ if (typeof hook !== 'function') return reportError('add to cart', '[data-lbx-cart] needs _global.addToCart (_init.ts or _global/_widget.ts) to put the product in the site\'s cart');
128
+ const count = Math.max(1, Math.round(Number(el.dataset.lbxCart) || 1));
129
+ const price = typeof hit.r.c.price === 'number' ? hit.r.c.price : undefined;
130
+ announce(w.global, { kind: 'add_to_cart', reference: reference(hit.r), result: hit.r, index: hit.i, list: w.list, count, ...(price !== undefined ? { price } : {}) });
131
+ try {
132
+ const p = hook(hit.r, { count, el, event: e });
133
+ if (p && typeof (p as Promise<unknown>).catch === 'function') (p as Promise<unknown>).catch((err) => reportError('addToCart', err));
134
+ } catch (err) {
135
+ reportError('addToCart', err);
136
+ }
137
+ };
@@ -0,0 +1,64 @@
1
+ // Turbo Drive (Hotwire) on the host: history and prefetching. In the entry
2
+ // (a back/forward must be handled in its own popstate, before any chunk).
3
+ //
4
+ // Turbo answers every popstate. Our history entries (null state, core/url.ts)
5
+ // are foreign to it: Turbo <= 8.0.20 ignores them, 8.0.21+ stamps the one
6
+ // landed on (caching a snapshot: turbo:before-cache takes our nodes out of the
7
+ // live page) and makes a back to an entry it stamped a restore visit (their
8
+ // page fetched or a snapshot rendered, the body swapped). So, in a popstate
9
+ // listener of ours that runs before Turbo's:
10
+ // - a move between states of the page Turbo has rendered, while ours shows
11
+ // it (filters, sort, another query in place): ours alone. Nothing of
12
+ // theirs changes on such a move; host popstate handlers (a jQuery reload on
13
+ // filter state, a storefront's own namespaced popstate handler) do not run either;
14
+ // - a move onto one of our entries of another page (back across a Turbo
15
+ // visit): Turbo would leave the other page in the document under this URL.
16
+ // Turbo renders it with a replace visit (their page for this URL; the entry
17
+ // becomes Turbo's, so its later restores, snapshot keys and same-page
18
+ // checks are right: a restore visit without a history write would leave
19
+ // Turbo's location at the other page); ours renders in it.
20
+ // Turbo 8 also prefetches a link's page on hover: our in-place links (the
21
+ // same path: a filter, a sort, taken on click) are not
22
+ // prefetched; links of ours to other pages (products) stay Turbo's.
23
+ import { isOwnEntry } from './entries';
24
+ import { mark } from './health';
25
+ import type { Takeover } from './takeover';
26
+
27
+ export const startTurbo = (listings: { tk: Takeover; accepts: (u: URL) => boolean }[]) => {
28
+ const turbo = () => {
29
+ const t = (window as any).Turbo;
30
+ return typeof t?.visit === 'function' ? t : null;
31
+ };
32
+ // The page Turbo has in the document: the one we booted on, then its renders.
33
+ let rendered = location.pathname;
34
+ addEventListener('turbo:load', () => (rendered = location.pathname));
35
+ addEventListener(
36
+ 'popstate',
37
+ (e) => {
38
+ const t = turbo();
39
+ if (!t) return;
40
+ const to = new URL(location.href);
41
+ if (to.pathname === rendered && listings.some((l) => l.accepts(to) && (l.tk.state === 'ours' || l.tk.state === 'hold'))) {
42
+ e.stopImmediatePropagation();
43
+ mark('turbo: popstate ours');
44
+ } else if (e.state == null && isOwnEntry(to.href)) {
45
+ e.stopImmediatePropagation();
46
+ mark(`turbo: visit ${to.pathname}`);
47
+ t.visit(to.href, { action: 'replace' });
48
+ }
49
+ },
50
+ true,
51
+ );
52
+ // Window listeners run in the order they were added (capture or not): a
53
+ // Turbo that started before us gets its listener added again, after ours.
54
+ // (One that starts later adds it after ours anyway.)
55
+ const h = turbo()?.session?.history;
56
+ if (h?.started && typeof h.onPopState === 'function') {
57
+ removeEventListener('popstate', h.onPopState, false);
58
+ addEventListener('popstate', h.onPopState, false);
59
+ }
60
+ document.addEventListener('turbo:before-prefetch', (e) => {
61
+ const a = e.target;
62
+ if (a instanceof HTMLAnchorElement && a.pathname === location.pathname && a.closest('[data-lbx]')) e.preventDefault();
63
+ });
64
+ };
@@ -0,0 +1,164 @@
1
+ // Polite URL writes.
2
+ //
3
+ // 1. If the host's router is detectable, navigate through it, so the router's
4
+ // own idea of the current location includes our params. A raw pushState
5
+ // behind its back leaves it stale, and its next internal replace() puts its
6
+ // stale URL back (Vue Router, e.g. under Nuxt, reverts params written
7
+ // behind its back). Vue exposes the app on its mount element
8
+ // in production builds too; Next.js (pages router) exposes window.next.
9
+ //
10
+ // 2. Otherwise, new entries get a NULL state. Every SPA router must already
11
+ // handle null-state entries (hash links and third-party scripts create them)
12
+ // and treats them as foreign: Vue Router rebuilds its bookkeeping via
13
+ // replace(), Angular navigates to the URL. Copying the current state instead
14
+ // is wrong: it carries that entry's router bookkeeping (current URL,
15
+ // position, navigationId) onto an entry with a different URL, which has made
16
+ // a router later navigate to a URL of its own invention. The exceptions
17
+ // are SvelteKit and Astro (entryState, below): their routers ignore
18
+ // null-state entries altogether.
19
+
20
+ import { canon, markOwnEntry } from './entries';
21
+
22
+ interface HostRouter {
23
+ push(to: string): unknown;
24
+ /** The router's base path (it prefixes every path it is given). */
25
+ base?: string;
26
+ }
27
+
28
+ export { canon, isOwnEntry } from './entries';
29
+ export { pageKey } from './page';
30
+
31
+ const usable = (r: any) => typeof r?.push === 'function' && typeof r?.replace === 'function';
32
+
33
+ /** A path as a router and the browser may each spell it (encoded or not, a trailing slash). */
34
+ const samePath = (a: string, b: string) => {
35
+ const norm = (p: string) => {
36
+ try {
37
+ p = decodeURI(p);
38
+ } catch {}
39
+ return p.replace(/\/+$/, '').toLowerCase() || '/';
40
+ };
41
+ return norm(a) === norm(b);
42
+ };
43
+
44
+ const vueRouter = (): HostRouter | null => {
45
+ // Vue 3 (the app on its mount element: every one, a widget app may come first), Vue 2 (the root instance), Nuxt 2.
46
+ const candidates = [
47
+ ...[...document.querySelectorAll('#__nuxt, #app, [data-v-app]')].map((el: any) => el.__vue_app__?.config?.globalProperties?.$router ?? el.__vue__?.$router),
48
+ (window as any).$nuxt?.$router,
49
+ ];
50
+ for (const r of candidates) {
51
+ if (!usable(r)) continue;
52
+ // Vue Router 3 resolves its base from <base href> into history.base.
53
+ const raw: string = r.options?.history?.base ?? r.history?.base ?? r.options?.base ?? '';
54
+ const base = raw.replace(/\/$/, '');
55
+ // Hash mode (paths in the #fragment): writing through it would not change the URL.
56
+ if (raw.includes('#') || r.mode === 'hash') {
57
+ settled = true;
58
+ return null;
59
+ }
60
+ // A memory / abstract history (a widget app): not the page's router.
61
+ const own = r.options?.history?.location; // Vue 3: the history's current path
62
+ if (r.mode === 'abstract' || (typeof own === 'string' && !samePath(own.split(/[?#]/)[0], location.pathname.slice(base.length)))) continue;
63
+ settled = true;
64
+ return { push: (to) => r.push(to), base };
65
+ }
66
+ return null;
67
+ };
68
+
69
+ /** Next.js pages router. The app router (it sets window.next.router too) follows raw history writes by itself. */
70
+ const nextRouter = (): HostRouter | null => {
71
+ const w = window as any;
72
+ if (w.next?.appDir || !w.__NEXT_DATA__) return null;
73
+ const r = w.next?.router;
74
+ if (!usable(r)) return null;
75
+ const base = (r.basePath ?? '').replace(/\/$/, '');
76
+ return { push: (to) => r.push(to, undefined, { shallow: true, scroll: false }), base };
77
+ };
78
+
79
+ const hostRouter = () => vueRouter() ?? nextRouter();
80
+
81
+ /** A mounted router found (usable, or hash mode), or waited for one in vain: writes are not queued from then on. */
82
+ let settled = false;
83
+ /** A server-rendered Vue app whose client app has not mounted yet (no router to talk to). vueRouter() may settle it (a hash-mode router: null, settled), hence `settled` is read again after it. */
84
+ const vueNotMountedYet = () => !settled && !!((window as any).__NUXT__ || document.querySelector('#__nuxt, [data-server-rendered]')) && !vueRouter() && !settled;
85
+
86
+ /** The router's own path for a URL: without its base, which it adds itself. */
87
+ const routerPath = (r: HostRouter, url: URL) => {
88
+ const path = !r.base ? url.pathname : url.pathname === r.base ? '/' : url.pathname.startsWith(`${r.base}/`) ? url.pathname.slice(r.base.length) : url.pathname;
89
+ return path + url.search + url.hash;
90
+ };
91
+
92
+ const viaRouter = (r: HostRouter, url: URL) => {
93
+ try {
94
+ const p = r.push(routerPath(r, url)) as any;
95
+ // A navigation the router itself redirected or cancelled (Vue Router 3
96
+ // rejects those) is its decision; only a broken router gets a raw write.
97
+ if (typeof p?.catch === 'function') p.catch((e: any) => e?._isRouter || raw(url));
98
+ } catch {
99
+ raw(url);
100
+ }
101
+ };
102
+
103
+ // A write that arrives before the host router exists is queued and handed to
104
+ // the router once it mounts (latest write wins). A raw pushState would be
105
+ // undone by the router's initial replace() when it starts.
106
+ let queued: { url: URL; from: string } | null = null;
107
+ const flushQueued = (tries = 0) => {
108
+ if (!queued) return;
109
+ const r = vueRouter();
110
+ // Waiting while the app is still to mount (~5 s at most, background tabs too).
111
+ if (!r && vueNotMountedYet() && tries < 300) return void setTimeout(() => flushQueued(tries + 1), 16);
112
+ const { url, from } = queued;
113
+ queued = null;
114
+ // The page moved on meanwhile (the shopper navigated): the write is stale.
115
+ // Compared canonically: the router's first replace() re-encodes the URL.
116
+ if (canon(location.href) !== canon(from) || canon(url.href) === canon(location.href)) return;
117
+ if (!r) {
118
+ settled = true; // it never mounted: plain history writes, now and later
119
+ return raw(url);
120
+ }
121
+ viaRouter(r, url);
122
+ };
123
+
124
+ /**
125
+ * Two routers with no handle on the page to write through ignore a null-state
126
+ * entry altogether, so Back into one of ours after one of their navigations
127
+ * left the other page on screen under our listing's URL. Our entry gets the
128
+ * router's own bookkeeping, built from the entry it is pushed over (only the
129
+ * keys the router reads, never the rest of its state):
130
+ * - SvelteKit reads a null state as a hash link's (it only updates
131
+ * `page.url`): ours carries the index pair of the entry below, the same
132
+ * router entry for it (back/forward between the two is nothing to it),
133
+ * which it renders anew when it comes back to it from another navigation;
134
+ * - Astro's ClientRouter returns on a null state: ours carries its index and
135
+ * the scroll position, so it fetches and swaps in our URL's page on a
136
+ * traversal, as it does for its own entries.
137
+ */
138
+ const SK_HISTORY = 'sveltekit:history';
139
+ const SK_NAVIGATION = 'sveltekit:navigation';
140
+ const entryState = () => {
141
+ const s = history.state;
142
+ if (!s || typeof s !== 'object') return null;
143
+ if (s[SK_HISTORY] && SK_NAVIGATION in s) return { [SK_HISTORY]: s[SK_HISTORY], [SK_NAVIGATION]: s[SK_NAVIGATION] };
144
+ if (typeof s.index === 'number' && document.querySelector('meta[name="astro-view-transitions-enabled"]')) return { index: s.index, scrollX, scrollY };
145
+ return null;
146
+ };
147
+
148
+ const raw = (url: URL) => history.pushState(entryState(), '', url.href);
149
+
150
+ /** A new history entry for `url` (our listing's state). */
151
+ export const writeUrl = (url: URL) => {
152
+ if (canon(url.href) === canon(location.href) && url.hash === location.hash) return;
153
+ markOwnEntry(url.href);
154
+ const same = url.origin === location.origin;
155
+ if (same && vueNotMountedYet()) {
156
+ const first = !queued;
157
+ queued = { url, from: location.href };
158
+ if (first) setTimeout(() => flushQueued(), 16);
159
+ return;
160
+ }
161
+ const router = same ? hostRouter() : null;
162
+ if (!router) return raw(url);
163
+ viaRouter(router, url);
164
+ };