@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,41 @@
1
+ // The page's analytics state: the global config, what the integration's
2
+ // `_analytics.ts` reads for every event (user.identify), what only the entry
3
+ // knows (env), and the moment the page is shown (a prerendered page:
4
+ // once activated), when chains start to listen. Every other module reads it
5
+ // here; none runs anything at its top level, so what a setup does not import
6
+ // is left out of the build.
7
+
8
+ import { reportError } from '../core/health';
9
+ import type { GlobalConfigType } from '../types';
10
+
11
+ export const state: { g: GlobalConfigType | null } = { g: null };
12
+ /** Read on every event (./user.ts). */
13
+ export const stamps: { user: (() => string | number | null | undefined) | null } = { user: null };
14
+
15
+ /** A hook of the integration's: its value, or null when it threw (reported). */
16
+ export const call = <T>(f: () => T, where: string): T | null => {
17
+ try {
18
+ return f() ?? null;
19
+ } catch (e) {
20
+ reportError(where, e);
21
+ return null;
22
+ }
23
+ };
24
+
25
+ /** What only the entry knows: which of the site's services Luma runs here, URL changes, one listing visit. */
26
+ export const env: {
27
+ owns: (surface: string) => boolean;
28
+ onUrlChange: (l: (u: URL) => void) => void;
29
+ visit: (u: URL) => string | null;
30
+ /** This page's dataLayer pushes the entry kept already (left, then restored from the back/forward cache): not read again. */
31
+ kept: () => number;
32
+ } = { owns: () => false, onUrlChange: () => {}, visit: () => null, kept: () => 0 };
33
+
34
+ const waiting: (() => void)[] = [];
35
+ let shown = false;
36
+ /** Now if the page is shown, else once it is (a chain registered as the setup runs listens then). */
37
+ export const whenShown = (f: () => void) => (shown ? f() : void waiting.push(f));
38
+ export const pageShown = () => {
39
+ shown = true;
40
+ for (const f of waiting.splice(0)) f();
41
+ };
@@ -0,0 +1,53 @@
1
+ // Analytics on a page (lbx-analytics: the entry loads it once the page has
2
+ // loaded, or with the first widget, where the integration has an
3
+ // `_analytics.ts`; README "Analytics"). The setup is in this chunk: its chains
4
+ // (./collect.ts, ./triggers.ts) register as it runs, and what it does not
5
+ // import is not built in. Nothing is sent that a chain does not say.
6
+ //
7
+ // To the Luigi's Box AI events API (POST /analytics/v1/events, ./outbox.ts):
8
+ // `pv`, `interaction` (click, add_to_cart), `transaction`, and `list` for a
9
+ // service of the site's own (ours the API records itself). Never anything
10
+ // speculative: a prerendered page counts and sends once shown.
11
+
12
+ import { reportError } from '../core/health';
13
+ import type { LumaConfigType } from '../types';
14
+ import { flush } from './outbox';
15
+ import { env, pageShown, state } from './setup';
16
+
17
+ export interface StartOptions {
18
+ config: LumaConfigType;
19
+ /** The integration's `_analytics.ts` files (lazy; in this chunk), `_global`'s first. */
20
+ files: Record<string, () => Promise<any>>;
21
+ /** Whether Luma runs this service of the site's on this page (`search`, `autocomplete`, `recommender:<name>`). */
22
+ owns: (surface: string) => boolean;
23
+ /** One listing visit (a category, a query) is one page, whatever its filters write. */
24
+ visit: (u: URL) => string | null;
25
+ onUrlChange: (l: (u: URL) => void) => void;
26
+ /** This page's dataLayer pushes the entry kept for the next page already (core/kept.ts). */
27
+ kept: () => number;
28
+ }
29
+
30
+ let started = false;
31
+ /** The page's analytics (the entry, once per page). */
32
+ export const start = async (o: StartOptions) => {
33
+ if (started) return;
34
+ started = true;
35
+ state.g = o.config._global;
36
+ env.owns = o.owns;
37
+ env.visit = o.visit;
38
+ env.onUrlChange = o.onUrlChange;
39
+ env.kept = o.kept;
40
+ // The setup (`_global` first). Its chains register as it runs.
41
+ for (const [p, f] of Object.entries(o.files).sort(([a], [b]) => (a.includes('/_global/') ? -1 : b.includes('/_global/') ? 1 : 0)))
42
+ try {
43
+ await f();
44
+ } catch (e) {
45
+ reportError(`analytics ${p}`, e);
46
+ }
47
+ const shown = () => {
48
+ pageShown();
49
+ void flush();
50
+ };
51
+ if ((document as any).prerendering) document.addEventListener('prerenderingchange', shown, { once: true });
52
+ else shown();
53
+ };
@@ -0,0 +1,239 @@
1
+ // Triggers of the builder (./chain.ts): when a chain's `.read()` runs, with
2
+ // what. Luma's own (lumaPages, lumaClicks, lumaCarts) and the site's (dl,
3
+ // domClick, urlChange, manual, mutationOf, xhrMatching). Each is its own export: a setup brings only the ones it imports (the
4
+ // XMLHttpRequest and fetch wrapper, the mutation observer, the click
5
+ // listeners only where a chain uses them).
6
+
7
+ import { reportError } from '../core/health';
8
+ import { session } from '../core/storage';
9
+ import { TILES, type TileEvent } from '../core/tiles';
10
+ import type { Trigger } from './chain';
11
+ import { onDl } from './datalayer';
12
+ import { onPage, type Page } from './page';
13
+ import { env } from './setup';
14
+
15
+ export interface DlContext {
16
+ event: string;
17
+ /** The same as `event`. */
18
+ eventType: string;
19
+ /** The entry as pushed (gtag's: `{ event, ...params }`). */
20
+ data: Record<string, any>;
21
+ /** GA4's ecommerce object (gtag's: its parameters), or null. */
22
+ ecommerce: Record<string, any> | null;
23
+ /** A push of a page left before analytics ran there: that page, and when (the event is sent as of them). */
24
+ url?: string;
25
+ at?: number;
26
+ }
27
+ /** The site's dataLayer event `name` (object or gtag form), the ones pushed before Luma loaded too. */
28
+ export const dl = (name: string): Trigger<DlContext> => ({
29
+ key: `dl:${name}`,
30
+ sub: (h) => onDl(name, (e) => h({ ...e, eventType: e.event })),
31
+ });
32
+
33
+ export interface DomClickContext {
34
+ /** The element matching `selector` (the click's target or an ancestor). */
35
+ element: Element;
36
+ event: MouseEvent;
37
+ }
38
+ /** A click (or a middle click) on an element of the site's matching `selector`: never on ours (`lumaClicks()`). */
39
+ export const domClick = (selector: string): Trigger<DomClickContext> => ({
40
+ key: `domClick:${selector}`,
41
+ sub: (h) => {
42
+ const on = (e: Event) => {
43
+ const me = e as MouseEvent;
44
+ if (e.type === 'auxclick' && me.button !== 1) return;
45
+ const t = e.composedPath()[0];
46
+ const el = t instanceof Element ? t.closest(selector) : null;
47
+ if (el && !el.closest('[data-lbx]')) h({ element: el, event: me });
48
+ };
49
+ addEventListener('click', on, true);
50
+ addEventListener('auxclick', on, true);
51
+ },
52
+ });
53
+
54
+ export interface UrlChangeContext {
55
+ url: string;
56
+ previousUrl: string;
57
+ }
58
+ /** The page's URL changed (an SPA navigation, ours too). */
59
+ export const urlChange = (): Trigger<UrlChangeContext> => ({
60
+ key: 'url',
61
+ sub: (h) => {
62
+ let previousUrl = location.href;
63
+ env.onUrlChange((u) => {
64
+ h({ url: u.href, previousUrl });
65
+ previousUrl = u.href;
66
+ });
67
+ },
68
+ });
69
+
70
+ /**
71
+ * Luma's pages, as analytics counts them (./page.ts): the first once shown, an
72
+ * SPA's next page, a back/forward cache restore; not our own filters or sort.
73
+ * The context is the page, `{ url, referrer }`: `collect.pageView.on(lumaPages())`.
74
+ */
75
+ export const lumaPages = (): Trigger<Page> => ({ key: 'pages', sub: (h) => onPage(h) });
76
+
77
+ /**
78
+ * Luma's own tiles (core/tiles.ts): a product click on them (`lumaClicks()`,
79
+ * the autocomplete's picks too), an add-to-cart by their `[data-lbx-cart]`
80
+ * (`lumaCarts()`). The context is a TileEvent, `{ reference, result, index,
81
+ * list, count?, price?, url, at }`, read by fromLuma.click / .addToCart.
82
+ * Every one, whenever it happened: queued before analytics listened (on this
83
+ * page or the last), claimed now, sent as of its page and time.
84
+ */
85
+ const tiles = (kind: TileEvent['kind']): Trigger<TileEvent> => ({
86
+ key: `tiles:${kind}`,
87
+ sub: (h) => {
88
+ // Claimed from the queue (core/tiles.ts): each once, whenever analytics listens.
89
+ const claim = (id: string) => {
90
+ const q = session.get<TileEvent[]>(TILES) ?? [];
91
+ const i = q.findIndex((x) => x.id === id);
92
+ if (i < 0) return false;
93
+ q.splice(i, 1);
94
+ session.set(TILES, q.length ? q : undefined);
95
+ return true;
96
+ };
97
+ for (const d of session.get<TileEvent[]>(TILES) ?? []) if (d.kind === kind && claim(d.id)) h(d);
98
+ document.addEventListener('lbx:tile', (e) => {
99
+ const d = (e as CustomEvent<TileEvent>).detail;
100
+ if (d.kind === kind && claim(d.id)) h(d);
101
+ });
102
+ },
103
+ });
104
+ export const lumaClicks = () => tiles('click');
105
+ export const lumaCarts = () => tiles('add_to_cart');
106
+
107
+ export interface MutationContext {
108
+ /** The element matching the selector: added, or with nodes added inside it. */
109
+ element: Element;
110
+ }
111
+ /**
112
+ * The site's element `selector`, once in the page and each time it (or what
113
+ * is inside it) is added: a service of theirs that renders its results with
114
+ * no dataLayer event. Read when the browser is idle (the render has settled);
115
+ * never ours. `root`: only inside the first element it matches (default the document).
116
+ */
117
+ export const mutationOf = (selector: string, opts?: { root?: string }): Trigger<MutationContext> => ({
118
+ key: `mutation:${selector}:${opts?.root ?? ''}`,
119
+ sub: (h) => {
120
+ const due = new Set<Element>();
121
+ let asked = false;
122
+ const fire = () => {
123
+ asked = false;
124
+ const els = [...due];
125
+ due.clear();
126
+ for (const element of els) if (element.isConnected) h({ element });
127
+ };
128
+ const add = (el: Element | null) => {
129
+ if (!el || el.closest('[data-lbx]')) return;
130
+ due.add(el);
131
+ if (asked) return;
132
+ asked = true;
133
+ if (typeof requestIdleCallback === 'function') requestIdleCallback(fire, { timeout: 1000 });
134
+ else setTimeout(fire, 50);
135
+ };
136
+ const look = (n: Node) => {
137
+ if (!(n instanceof Element)) return;
138
+ if (n.matches(selector)) add(n);
139
+ n.querySelectorAll(selector).forEach(add);
140
+ };
141
+ const root = (opts?.root && document.querySelector(opts.root)) || document.documentElement;
142
+ new MutationObserver((ms) => {
143
+ for (const m of ms) {
144
+ if (!m.addedNodes.length) continue;
145
+ add(m.target instanceof Element ? m.target.closest(selector) : null);
146
+ m.addedNodes.forEach(look);
147
+ }
148
+ }).observe(root, { childList: true, subtree: true });
149
+ root.querySelectorAll(selector).forEach(add);
150
+ },
151
+ });
152
+
153
+ export interface XhrContext {
154
+ url: string;
155
+ method: string;
156
+ /** Its JSON body (the text, where it is not JSON). */
157
+ response: any;
158
+ }
159
+ type XhrTest = (r: { url: string; method: string }) => boolean;
160
+ const xhrs: { test: XhrTest; h: (c: XhrContext) => void }[] = [];
161
+ const matching = (url: string, method: string) =>
162
+ xhrs.filter((x) => {
163
+ try {
164
+ return x.test({ url, method });
165
+ } catch (e) {
166
+ reportError('analytics xhrMatching', e);
167
+ return false;
168
+ }
169
+ });
170
+ const answered = (hs: typeof xhrs, url: string, method: string, text: string) => {
171
+ let response: any = text;
172
+ try {
173
+ response = JSON.parse(text);
174
+ } catch {}
175
+ for (const x of hs) x.h({ url, method, response });
176
+ };
177
+ let wrapped = false;
178
+ /** XMLHttpRequest and fetch, watched once: only a response a trigger asks for is read. */
179
+ const wrapXhr = () => {
180
+ if (wrapped) return;
181
+ wrapped = true;
182
+ const open = XMLHttpRequest.prototype.open;
183
+ XMLHttpRequest.prototype.open = function (this: XMLHttpRequest, method: string, url: string | URL, ...rest: unknown[]) {
184
+ const u = new URL(url, location.href).href;
185
+ const m = method.toUpperCase();
186
+ this.addEventListener('load', () => {
187
+ const hs = this.status >= 200 && this.status < 300 ? matching(u, m) : [];
188
+ if (!hs.length) return;
189
+ const t = this.responseType === '' || this.responseType === 'text' ? this.responseText : this.responseType === 'json' ? JSON.stringify(this.response) : null;
190
+ if (t != null) answered(hs, u, m, t);
191
+ });
192
+ return (open as (...a: unknown[]) => void).call(this, method, url, ...rest);
193
+ } as typeof open;
194
+ const f = window.fetch;
195
+ window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
196
+ const p = f.call(window, input, init);
197
+ const u = new URL(input instanceof Request ? input.url : String(input), location.href).href;
198
+ const m = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
199
+ const hs = matching(u, m);
200
+ if (hs.length)
201
+ p.then((r) => (r.ok ? r.clone().text() : null))
202
+ .then((t) => t != null && answered(hs, u, m, t))
203
+ .catch(() => {});
204
+ return p;
205
+ };
206
+ };
207
+ /**
208
+ * A response to one of the site's own requests (XMLHttpRequest or fetch)
209
+ * that `test` matches (`url` absolute, `method` upper case): a service of
210
+ * theirs that asks its own API (their suggestions). Requests made before the
211
+ * page loaded are not seen.
212
+ */
213
+ export const xhrMatching = (test: XhrTest): Trigger<XhrContext> => ({
214
+ key: 'xhr',
215
+ sub: (h) => {
216
+ wrapXhr();
217
+ xhrs.push({ test, h });
218
+ },
219
+ });
220
+
221
+ export interface ManualContext<D = any> {
222
+ data: D;
223
+ }
224
+ /** A trigger the integration fires itself: `const { trigger, fire } = manual(); …on(trigger)…; fire(data)`. */
225
+ export const manual = <D = any>(): { trigger: Trigger<ManualContext<D>>; fire(data?: D): void } => {
226
+ const hs: ((c: ManualContext<D>) => void)[] = [];
227
+ return {
228
+ trigger: { key: 'manual', sub: (h) => void hs.push(h) },
229
+ fire: (data = {} as D) =>
230
+ hs.forEach((h) => {
231
+ try {
232
+ h({ data });
233
+ } catch (e) {
234
+ reportError('analytics manual', e);
235
+ }
236
+ }),
237
+ };
238
+ };
239
+
@@ -0,0 +1,12 @@
1
+ // user.identify(fn): the signed-in shopper's own id, read on every event and
2
+ // sent as `user_id` (omitted when null).
3
+ //
4
+ // user.identify(() => window.shop?.customerId ?? null);
5
+
6
+ import { stamps } from './setup';
7
+
8
+ export const user = {
9
+ identify(fn: () => string | number | null | undefined) {
10
+ stamps.user = fn;
11
+ },
12
+ };
@@ -0,0 +1,67 @@
1
+ // What analytics events are made of, without the page: object references,
2
+ // the site's dataLayer entries, the page a URL is (one pageview per page, not per filter). Pure: unit-tested
3
+ // (test/unit/analytics-wire.test.ts).
4
+ //
5
+ // The wire format is the Luigi's Box AI events API
6
+ // (docs.luigisbox.ai/analytics/event-reference): `interaction` (click,
7
+ // add_to_cart), `transaction`, and, as the legacy Luma sends them, `pv` and
8
+ // `list` (an original service's results: ours the API records itself).
9
+
10
+ import { P, TRACKING, withoutTracking } from '../core/routes';
11
+
12
+ /**
13
+ * An object reference, `<type>/<field>:<value>`
14
+ * (docs.luigisbox.ai/analytics/references). An `@id` value carries its type
15
+ * ("product/123"): a bare one is prefixed.
16
+ */
17
+ export const ref = (type: string, field: string, value: unknown) => {
18
+ const v = String(value);
19
+ return `${type}/${field}:${field === '@id' && !v.startsWith(`${type}/`) ? `${type}/${v}` : v}`;
20
+ };
21
+
22
+ /** A reference as the API accepts it: three non-empty parts, no `/` or `:` in the first two. */
23
+ export const isRef = (r: unknown): r is string => typeof r === 'string' && /^[^/:]+\/[^/:]+:.+/.test(r);
24
+
25
+ /** An event of the site's dataLayer, read (object form or gtag's). */
26
+ export interface DlEntry {
27
+ event: string;
28
+ data: Record<string, any>;
29
+ ecommerce: Record<string, any> | null;
30
+ /** A push of a page left before analytics ran there: that page, and when it was left. */
31
+ url?: string;
32
+ at?: number;
33
+ }
34
+
35
+ /** gtag's `arguments` (or one kept by a page left before analytics ran: `{ 0: 'event', 1: … }`). */
36
+ const isArgs = (x: any): x is ArrayLike<any> => !!x && typeof x === 'object' && typeof x[0] === 'string';
37
+
38
+ /**
39
+ * `{ event: 'purchase', ecommerce: {…} }` (GTM) or `gtag('event', 'purchase',
40
+ * {…})` (an `arguments` object: gtag's parameters are the ecommerce data).
41
+ */
42
+ export const readDl = (x: any): DlEntry | null => {
43
+ if (isArgs(x)) {
44
+ if (x[0] === 'event' && typeof x[1] === 'string') {
45
+ const params = x[2] && typeof x[2] === 'object' ? x[2] : {};
46
+ return { event: x[1], data: { event: x[1], ...params }, ecommerce: params };
47
+ }
48
+ return null;
49
+ }
50
+ if (x && typeof x === 'object' && typeof x.event === 'string') return { event: x.event, data: x, ecommerce: x.ecommerce && typeof x.ecommerce === 'object' ? x.ecommerce : null };
51
+ return null;
52
+ };
53
+
54
+ const IGNORE = new RegExp(`${TRACKING}|^${P.replace('.', '\\.')}`);
55
+ /**
56
+ * The page a URL shows, for pageviews: without tracking params, the hash and
57
+ * our own `lbx.*` (a filter, a sort on our listing is the same page). A
58
+ * listing's own visit (its category, its query: the caller's `visit`) is one
59
+ * page whatever its filters write into the site's params.
60
+ */
61
+ export const pageOf = (href: string, visit?: (u: URL) => string | null) => {
62
+ const u = withoutTracking(href, IGNORE);
63
+ return visit?.(u) ?? u.href;
64
+ };
65
+
66
+ /** The URL sent with events: without tracking params (gclid, utm_…) and the hash. */
67
+ export const eventUrl = (href: string) => withoutTracking(href).href;
package/src/api/ai.ts ADDED
@@ -0,0 +1,98 @@
1
+ // Luigi's Box AI discovery client: the requests (search, facets, recommend).
2
+ // Authentication (publishable key -> browser token, the visitor id) is
3
+ // ./token.ts, in the entry so that every page mints the token at boot.
4
+ //
5
+ // Every discovery call carries X-Lbx-Visitor-Id: Luma's first-party
6
+ // `_lbx_vid` cookie, so requests and analytics events join on one visitor.
7
+ //
8
+ // Also runs in Node (the edge render service): there the
9
+ // storefront origin is sent explicitly and nothing touches the DOM.
10
+
11
+ import { health } from '../core/health';
12
+ import type { GlobalConfigType } from '../types';
13
+ import type { Api, FacetData, Hit, Page } from '.';
14
+ import { fetchJson, TIMEOUT_MS, tokensFor, untilAborted, visitorId } from './token';
15
+
16
+ export { untilAborted };
17
+
18
+ export const aiApi = (g: GlobalConfigType, origin?: string): Api => {
19
+ const tk = tokensFor(g, origin);
20
+ const { API, originHeaders } = tk;
21
+ const getToken = tk.get;
22
+
23
+ // A 401 re-mints, but not on every request when a fresh token is refused too
24
+ // (an origin the key does not allow): at most once a minute. A request whose
25
+ // token was already replaced (a parallel one re-minted) just retries once.
26
+ let reminted = -Infinity;
27
+ const post = async (path: string, params: Record<string, string>, body: unknown, signal?: AbortSignal, retried = false): Promise<any> => {
28
+ health.apiCalls++;
29
+ // The caller's signal bounds the wait for a token too (a server render's budget).
30
+ const t = await untilAborted(getToken(), signal);
31
+ const qs = new URLSearchParams(params).toString();
32
+ const r = await fetchJson(
33
+ `${API}/discovery/v1/${path}?${qs}`,
34
+ {
35
+ method: 'POST',
36
+ headers: { ...originHeaders, Authorization: `Bearer ${t}`, 'X-Lbx-Visitor-Id': visitorId(), 'Content-Type': 'application/json' },
37
+ body: JSON.stringify(body),
38
+ },
39
+ TIMEOUT_MS,
40
+ signal,
41
+ );
42
+ if (r.status === 401 && !retried) {
43
+ if (tk.current() !== t) return post(path, params, body, signal, true);
44
+ if (Date.now() - reminted > 60e3) {
45
+ // Expired or revoked early: mint a new one.
46
+ reminted = Date.now();
47
+ tk.clear();
48
+ return post(path, params, body, signal, true);
49
+ }
50
+ }
51
+ if (!r.ok) throw new Error(`${path}: HTTP ${r.status} ${r.body.slice(0, 300)}`);
52
+ if (r.json == null) throw new Error(`${path}: not JSON`);
53
+ return r.json;
54
+ };
55
+
56
+ /** Drops undefined fields: unknown or empty ones are a 422. */
57
+ const compact = (o: Record<string, unknown>) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
58
+ const nonEmpty = (f?: Record<string, unknown>) => (f && Object.keys(f).length ? f : undefined);
59
+
60
+ return {
61
+ warm: () => void getToken().catch(() => {}),
62
+
63
+ search: async (r, signal): Promise<Page> => {
64
+ const body = compact({
65
+ type: r.type,
66
+ size: r.size,
67
+ query: r.endpoint === 'search' ? (r.query ?? '') : undefined,
68
+ collection_filters: r.endpoint === 'collections' ? nonEmpty(r.scope) : undefined,
69
+ filters: nonEmpty(r.filters),
70
+ sort: r.sort,
71
+ cursor: r.cursor || undefined,
72
+ return_fields: r.fields?.length ? r.fields : undefined,
73
+ });
74
+ const j = await post(r.endpoint, { channel_id: g.channelId, surface_id: r.surface }, body, signal);
75
+ return { hits: j.hits ?? [], total: j.total ?? j.total_approx ?? 0, guid: j.guid, cursor: j.next_page_cursor ?? null, status: j.pagination_status ?? 'exhausted' };
76
+ },
77
+
78
+ facets: async (guid, facets, signal): Promise<Record<string, FacetData>> =>
79
+ (await post('facets', { channel_id: g.channelId }, { guid, facets: facets.map((f) => compact(f)) }, signal)).facets ?? {},
80
+
81
+ recommend: async (r, signal): Promise<Hit[]> =>
82
+ (
83
+ await post(
84
+ 'recommender',
85
+ { channel_id: g.channelId, surface_id: r.surface },
86
+ compact({
87
+ type: r.type,
88
+ size: r.size,
89
+ anchor_ids: r.anchors.length ? r.anchors.slice(0, 50) : undefined,
90
+ exclude_ids: r.exclude?.length ? r.exclude.slice(0, 50) : undefined,
91
+ filters: nonEmpty(r.filters),
92
+ return_fields: r.fields?.length ? r.fields : undefined,
93
+ }),
94
+ signal,
95
+ )
96
+ ).hits ?? [],
97
+ };
98
+ };
@@ -0,0 +1,87 @@
1
+ // The data API behind every widget: Luigi's Box AI discovery
2
+ // (api.<region>.luigisbox.ai/discovery/v1). Widgets only see this interface;
3
+ // the implementation (./ai.ts) owns authentication (publishable key ->
4
+ // browser token), the visitor id and the wire format.
5
+
6
+ import type { GlobalConfigType } from '../types';
7
+ import { aiApi } from './ai';
8
+
9
+ /** A catalog object as discovery returns it: flat, `@id` and `@type` always present. */
10
+ export interface Hit {
11
+ '@id': string;
12
+ '@type': string;
13
+ [field: string]: any;
14
+ }
15
+
16
+ /** A discovery filter tree: condition and group nodes. */
17
+ export type FilterNode = Record<string, unknown>;
18
+
19
+ export interface Sort {
20
+ field: string;
21
+ direction: 'asc' | 'desc';
22
+ }
23
+
24
+ /** One page of /search or /collections. */
25
+ export interface Page {
26
+ hits: Hit[];
27
+ /** Exact when known, else the API's approximation. */
28
+ total: number;
29
+ /** Identifies the result set; /facets takes it. */
30
+ guid: string;
31
+ /** Cursor of the next page; null when there is none (see status). */
32
+ cursor: string | null;
33
+ /** more | exhausted | unsupported | unavailable */
34
+ status: string;
35
+ }
36
+
37
+ export interface ListingRequest {
38
+ /** search: a query; collections: a scope (collection_filters). */
39
+ endpoint: 'search' | 'collections';
40
+ surface: string;
41
+ type: string;
42
+ query?: string;
43
+ scope?: FilterNode;
44
+ filters?: FilterNode;
45
+ sort?: Sort;
46
+ size: number;
47
+ /** From the previous page; every other field must be unchanged. */
48
+ cursor?: string;
49
+ fields?: string[];
50
+ }
51
+
52
+ export interface FacetData {
53
+ field: string;
54
+ type: 'terms' | 'range' | 'hierarchy' | string;
55
+ values?: any[];
56
+ min?: number;
57
+ max?: number;
58
+ }
59
+
60
+ export interface RecoRequest {
61
+ surface: string;
62
+ type: string;
63
+ /** Typed identities ("product/<id>") the slate is about; [] = none. */
64
+ anchors: string[];
65
+ exclude?: string[];
66
+ size: number;
67
+ filters?: FilterNode;
68
+ fields?: string[];
69
+ }
70
+
71
+ export interface Api {
72
+ search(r: ListingRequest, signal?: AbortSignal): Promise<Page>;
73
+ /** Facets of a result set (its guid): values, no counts. */
74
+ facets(guid: string, facets: { field: string; size?: number }[], signal?: AbortSignal): Promise<Record<string, FacetData>>;
75
+ recommend(r: RecoRequest, signal?: AbortSignal): Promise<Hit[]>;
76
+ /** Mint the browser token now, so the first request does not wait for it. */
77
+ warm(): void;
78
+ }
79
+
80
+ const apis = new Map<string, Api>();
81
+ /** `origin`: the storefront origin, for server-side callers (browsers send their own). */
82
+ export const getApi = (g: GlobalConfigType, origin?: string): Api => {
83
+ const k = `${g.channelId}|${g.region ?? 'eu1'}|${origin ?? ''}`;
84
+ let api = apis.get(k);
85
+ if (!api) apis.set(k, (api = aiApi(g, origin)));
86
+ return api;
87
+ };
@@ -0,0 +1,42 @@
1
+ // Where the browser token is kept (sessionStorage), and whether a fresh one is
2
+ // there: the entry asks this at boot and loads the minting code (./token.ts,
3
+ // its own chunk) only when there is none. No DOM.
4
+
5
+ import { session } from '../core/storage';
6
+ import type { GlobalConfigType } from '../types';
7
+
8
+ export interface StoredToken {
9
+ token: string;
10
+ /** ms epoch; refreshed 30 s early. */
11
+ exp: number;
12
+ }
13
+
14
+ /** Per key too: a rotated publishable key must not keep using a stored token of the old one. */
15
+ export const tokenKey = (g: GlobalConfigType) => `lbx:tok:${g.publishableKey}:${g.channelId}:${g.region ?? 'eu1'}`;
16
+
17
+ /** The stored token of this channel while it is valid. */
18
+ export const storedToken = (g: GlobalConfigType): StoredToken | null => {
19
+ const t = session.get<StoredToken>(tokenKey(g));
20
+ return t && t.exp > Date.now() ? t : null;
21
+ };
22
+
23
+ const challenges = new Map<string, Promise<Response>>();
24
+ /** The browser challenge of this channel's key. */
25
+ export const challengeUrl = (g: GlobalConfigType) => `https://auth.${g.region ?? 'eu1'}.luigisbox.ai/browser-challenge?api_key=${encodeURIComponent(g.publishableKey)}`;
26
+ /**
27
+ * The challenge asked by the entry (main.ts warm()) while the minting code,
28
+ * its own chunk, loads: the mint starts a round trip earlier. Taken once by
29
+ * ./token.ts (a challenge expires in seconds).
30
+ */
31
+ export const askChallenge = (g: GlobalConfigType) => {
32
+ const url = challengeUrl(g);
33
+ if (challenges.has(url)) return;
34
+ const p = fetch(url);
35
+ p.catch(() => {}); // the mint that takes it reports a failure
36
+ challenges.set(url, p);
37
+ };
38
+ export const takeChallenge = (url: string) => {
39
+ const p = challenges.get(url);
40
+ challenges.delete(url);
41
+ return p;
42
+ };