@luigisbox/luma 0.0.0-stage → 0.1.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 (127) hide show
  1. package/LICENSE.md +90 -0
  2. package/README.md +488 -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 +498 -0
  16. package/chrome/run.mjs +145 -0
  17. package/docs/ACCESSIBILITY.md +502 -0
  18. package/docs/INTEGRATION-PERFORMANCE.md +214 -0
  19. package/docs/PARITY.md +946 -0
  20. package/footprint/model.mjs +184 -0
  21. package/footprint/run.mjs +331 -0
  22. package/package.json +58 -4
  23. package/parity/alignment.mjs +109 -0
  24. package/parity/archive.mjs +223 -0
  25. package/parity/args.mjs +137 -0
  26. package/parity/behaviour.mjs +379 -0
  27. package/parity/compare.mjs +985 -0
  28. package/parity/engine.mjs +848 -0
  29. package/parity/hints.mjs +132 -0
  30. package/parity/hold.mjs +54 -0
  31. package/parity/init.mjs +703 -0
  32. package/parity/map.mjs +640 -0
  33. package/parity/owner.mjs +170 -0
  34. package/parity/pairing.mjs +110 -0
  35. package/parity/pixels.mjs +33 -0
  36. package/parity/probe.mjs +1239 -0
  37. package/parity/report.mjs +236 -0
  38. package/parity/run.mjs +267 -0
  39. package/snippet/early-hold.js +64 -0
  40. package/snippet/early-hold.min.js +1 -0
  41. package/src/api/ai.ts +98 -0
  42. package/src/api/index.ts +87 -0
  43. package/src/api/token-key.ts +42 -0
  44. package/src/api/token.ts +311 -0
  45. package/src/core/a11y-keys.ts +60 -0
  46. package/src/core/a11y.ts +160 -0
  47. package/src/core/boot-state.ts +6 -0
  48. package/src/core/color.ts +42 -0
  49. package/src/core/context.ts +108 -0
  50. package/src/core/early-rules.ts +96 -0
  51. package/src/core/entries.ts +25 -0
  52. package/src/core/fold.ts +7 -0
  53. package/src/core/health.ts +26 -0
  54. package/src/core/hold.ts +74 -0
  55. package/src/core/i18n.ts +66 -0
  56. package/src/core/image-load.ts +102 -0
  57. package/src/core/image-tags.ts +26 -0
  58. package/src/core/images.ts +170 -0
  59. package/src/core/integration.ts +120 -0
  60. package/src/core/listings.ts +100 -0
  61. package/src/core/locales.ts +14 -0
  62. package/src/core/own.ts +49 -0
  63. package/src/core/page.ts +14 -0
  64. package/src/core/prefetch.ts +88 -0
  65. package/src/core/recommenders.ts +166 -0
  66. package/src/core/reconciler.ts +397 -0
  67. package/src/core/result.ts +73 -0
  68. package/src/core/routes.ts +115 -0
  69. package/src/core/scheduler.ts +83 -0
  70. package/src/core/sense.ts +107 -0
  71. package/src/core/speculation.ts +59 -0
  72. package/src/core/stash.ts +38 -0
  73. package/src/core/storage.ts +45 -0
  74. package/src/core/styles.ts +106 -0
  75. package/src/core/support.ts +12 -0
  76. package/src/core/takeover.ts +220 -0
  77. package/src/core/templates.ts +74 -0
  78. package/src/core/turbo.ts +64 -0
  79. package/src/core/url.ts +164 -0
  80. package/src/core/widget-inits.ts +40 -0
  81. package/src/env.d.ts +3 -0
  82. package/src/lit.ts +11 -0
  83. package/src/locales/cs.json +94 -0
  84. package/src/locales/de.json +88 -0
  85. package/src/locales/en.json +88 -0
  86. package/src/main.ts +443 -0
  87. package/src/types.ts +760 -0
  88. package/src/widgets/autocomplete/index.ts +1367 -0
  89. package/src/widgets/autocomplete/style-autocomplete.css +266 -0
  90. package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
  91. package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
  92. package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
  93. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
  94. package/src/widgets/listing/element.ts +1769 -0
  95. package/src/widgets/listing/flow.ts +30 -0
  96. package/src/widgets/listing/instant.ts +133 -0
  97. package/src/widgets/listing/intent.ts +193 -0
  98. package/src/widgets/listing/query.ts +298 -0
  99. package/src/widgets/listing/stash.ts +97 -0
  100. package/src/widgets/listing/state.ts +131 -0
  101. package/src/widgets/listing/view.ts +257 -0
  102. package/src/widgets/plp/index.ts +11 -0
  103. package/src/widgets/plp/route.ts +263 -0
  104. package/src/widgets/recommender/index.ts +250 -0
  105. package/src/widgets/recommender/style-recommender.css +26 -0
  106. package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
  107. package/src/widgets/recommender/template-recommender-popup.ts +12 -0
  108. package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
  109. package/src/widgets/search/assets.ts +7 -0
  110. package/src/widgets/search/first.ts +15 -0
  111. package/src/widgets/search/form-url.ts +59 -0
  112. package/src/widgets/search/in-place.ts +226 -0
  113. package/src/widgets/search/index.ts +16 -0
  114. package/src/widgets/search/range.ts +260 -0
  115. package/src/widgets/search/route.ts +32 -0
  116. package/src/widgets/search/speculate.ts +202 -0
  117. package/src/widgets/search/style-search.css +65 -0
  118. package/src/widgets/search/template-search-box.ts +17 -0
  119. package/src/widgets/search/template-search-facets-default.ts +73 -0
  120. package/src/widgets/search/template-search-header.ts +14 -0
  121. package/src/widgets/search/template-search-pagination.ts +12 -0
  122. package/src/widgets/search/template-search-range-default.ts +48 -0
  123. package/src/widgets/search/template-search-result-default.ts +13 -0
  124. package/src/widgets/search/template-search-results-default.ts +8 -0
  125. package/tasks/model.mjs +116 -0
  126. package/tasks/run.mjs +305 -0
  127. package/tsconfig.json +14 -0
package/build/vite.mjs ADDED
@@ -0,0 +1,498 @@
1
+ // Vite config for a client integration repository. The repo's own
2
+ // vite.config.ts is a one-liner:
3
+ // import { clientConfig } from '@luigisbox/luma/build/vite.mjs';
4
+ // export default clientConfig(import.meta.url);
5
+ //
6
+ // Output (dist/), what the distribution platform's loader expects:
7
+ // luma-integration.js the entry: configs, inits, sensing, triggers
8
+ // luma-chunks/*.js per widget: code, templates, styles, dictionary (loaded on demand)
9
+
10
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { dirname, resolve } from 'node:path';
13
+ import { transformWithEsbuild } from 'vite';
14
+
15
+ const FRAMEWORK = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
16
+
17
+ // One lazy chunk per widget plus what they share, so a page loads few files:
18
+ // lbx-core lit, the template registry, a11y, core/support (i18n,
19
+ // images, speculation rules) and the integration's shared
20
+ // _global templates (the product tile): every widget's
21
+ // lbx-listing search results and category pages (one listing: plp is
22
+ // a route of it) with the integration's search/ templates
23
+ // lbx-autocomplete lbx-recommender each widget with its templates
24
+ // lbx-plp lbx-place lbx-search lbx-speculate lbx-token the plp widget and its templates,
25
+ // placement and recommender triggers, the search page's own,
26
+ // search while typing, minting the token (chunkOf)
27
+ // lbx-nav what search while typing (lbx-speculate) needs, a
28
+ // search page's first request (widgets/search/first.ts)
29
+ // and what they share with the listing (the route, query
30
+ // building, stash, in-place query, URL writing): typing on
31
+ // any page and the listing need it, a page with only a
32
+ // recommender does not
33
+ // lbx-locale-* lbx-hydrate only where needed (the site's
34
+ // locale, server-rendered pages)
35
+ // A module the entry imports statically (health, sense, the listing's query
36
+ // and state for the prefetch, …) is never grouped: pulled into a lazy chunk,
37
+ // it would make the entry import that chunk at boot. The entry's static graph
38
+ // is computed from Rollup's module info, so a module moving into or out of the
39
+ // entry needs no change here. _global/ files named after a widget
40
+ // (template-recommender-*.ts) go with that widget only. Integration folders
41
+ // are named after their widget (`recommender similar`): the first word says which.
42
+ const KIND_CHUNK = { autocomplete: 'lbx-autocomplete', search: 'lbx-listing', plp: 'lbx-plp', recommender: 'lbx-recommender' };
43
+ const NAV = /\/src\/(widgets\/listing\/(stash|query|state|flow)|widgets\/search\/(form-url|route|first)|core\/(url|listings|takeover|turbo|prefetch|stash|page))\.ts$/;
44
+ const WIDGET = /\/src\/widgets\/([a-z]+)\//;
45
+ const CORE = /\/src\/(lit\.ts|core\/|api\/)|\/node_modules\/lit-html\//;
46
+ const entryGraphs = new WeakMap();
47
+ /** Every module the entry imports statically (transitively). */
48
+ const entryGraph = (getModuleInfo, getModuleIds) => {
49
+ let seen = entryGraphs.get(getModuleInfo);
50
+ if (seen) return seen;
51
+ seen = new Set();
52
+ const stack = [...getModuleIds()].filter((id) => getModuleInfo(id)?.isEntry);
53
+ while (stack.length) {
54
+ const id = stack.pop();
55
+ if (seen.has(id)) continue;
56
+ seen.add(id);
57
+ stack.push(...(getModuleInfo(id)?.importedIds ?? []));
58
+ }
59
+ entryGraphs.set(getModuleInfo, seen);
60
+ return seen;
61
+ };
62
+ export const chunkOf = (id) => {
63
+ // Any other module of an integration folder that only something lazy imports
64
+ // (its lazy _translations.ts, code an _init.ts hook imports on demand) goes
65
+ // with its folder's templates: no chunk, no request of its own. An eager one
66
+ // never gets here (the entry's, see manualChunks).
67
+ const own = id.match(/\/integration\/([^/]+)\/(?!template-|style-)[^/?]+\.[jt]s$/);
68
+ if (own) return own[1] === '_global' ? 'lbx-core' : KIND_CHUNK[own[1].split(/\s+/)[0]];
69
+ const m = id.match(/\/integration\/([^/]+)\/(?:template|style)-([^/?]+?)\.(?:ts|css)/);
70
+ if (m && m[2] !== 'host') {
71
+ const [, folder, name] = m;
72
+ const kind = (folder === '_global' ? name.split('-')[0] : folder.split(/\s+/)[0]);
73
+ return KIND_CHUNK[kind] ?? (folder === '_global' ? 'lbx-core' : `itg-${folder.replace(/\s+/g, '-')}`);
74
+ }
75
+ // Hydration of server-rendered listings: only those pages load it.
76
+ // lit-html's private SSR support is the hydrator's alone (not every widget page's lbx-core).
77
+ if (/\/node_modules\/(@lit-labs\/ssr-client\/|lit-html\/private-ssr-support\.js)/.test(id)) return 'lbx-hydrate';
78
+ // Helpers one widget alone uses go with it, not into lbx-core (every widget's).
79
+ if (/\/src\/core\/color\.ts$/.test(id)) return 'lbx-autocomplete';
80
+ if (/\/src\/core\/a11y-keys\.ts$/.test(id)) return 'lbx-listing';
81
+ // Placing our elements (core/reconciler.ts) and the recommenders' triggers:
82
+ // pages with a listing or a recommender, in parallel with their other chunks.
83
+ if (/\/src\/core\/(reconciler|recommenders)\.ts$/.test(id)) return 'lbx-place';
84
+ // The search page's own (its start, a new query in place): not in lbx-listing,
85
+ // which a category page loads too (2.1 kB gzip it never ran).
86
+ if (/\/src\/widgets\/search\/(index|in-place)\.ts$/.test(id)) return 'lbx-search';
87
+ // Search while typing (main.ts loads it once the shopper types): not in lbx-nav, which a listing page loads at once.
88
+ if (/\/src\/widgets\/search\/speculate\.ts$/.test(id)) return 'lbx-speculate';
89
+ // Minting the browser token: a session's first page loads it at boot (main.ts), lbx-core imports it.
90
+ if (/\/src\/api\/token\.ts$/.test(id)) return 'lbx-token';
91
+ // Only the site's locale is ever loaded.
92
+ const loc = id.match(/\/src\/locales\/([\w-]+)\.json$/);
93
+ if (loc) return `lbx-locale-${loc[1]}`;
94
+ if (NAV.test(id)) return 'lbx-nav';
95
+ const w = id.match(WIDGET);
96
+ if (w) return KIND_CHUNK[w[1]] ?? (w[1] === 'listing' ? 'lbx-listing' : undefined);
97
+ if (CORE.test(id)) return 'lbx-core';
98
+ };
99
+ // The entry's own modules are named into the entry chunk (rollupOptions.input's
100
+ // key) rather than left out: Rollup would pull a module a manual chunk depends
101
+ // on into that chunk, entry or not.
102
+ const manualChunks = (id, { getModuleInfo, getModuleIds }) => (entryGraph(getModuleInfo, getModuleIds).has(id) ? 'integration' : chunkOf(id));
103
+
104
+ /**
105
+ * Template scope members Luma no longer has, and what replaces each. A
106
+ * template using one would render nothing for it, or a link to
107
+ * "…/undefined", with no error: the build fails instead (`yarn typecheck`
108
+ * catches every use; this catches the unmistakable ones in a build that
109
+ * does not typecheck).
110
+ */
111
+ export const REMOVED_SCOPE = [
112
+ // 2026-10-02, "next page" only: no numbered pages.
113
+ [/\bpageLinks\b/, '`pageLinks` is gone (no numbered pages): render "show more" with `more` and `showMore`'],
114
+ [/\bsetPage\b/, '`setPage` is gone (no numbered pages): `@click=${s.showMore}` appends the next page'],
115
+ [/\bmore\s*\.\s*(?:href|patch)\b|\bgo\(\s*(?:\w+\s*\.\s*)?more\s*\)/, '`more` is a boolean now (no page URL): `<button data-lbx-more @click=${s.showMore}>`'],
116
+ ];
117
+ /** The removed scope members a template's source uses: [{ line, message }]. */
118
+ export const removedScopeUses = (code) => {
119
+ const out = [];
120
+ code.split('\n').forEach((text, i) => {
121
+ for (const [re, message] of REMOVED_SCOPE) if (re.test(text)) out.push({ line: i + 1, message });
122
+ });
123
+ return out;
124
+ };
125
+ const scopeCheck = () => ({
126
+ name: 'lbx-removed-scope',
127
+ enforce: 'pre',
128
+ transform(code, id) {
129
+ if (!/\/integration\/.+\/template-[^/]+\.ts$/.test(id.split('?')[0])) return null;
130
+ const uses = removedScopeUses(code);
131
+ if (uses.length) this.error(uses.map((u) => `${id.split('/integration/')[1]}:${u.line}: ${u.message}`).join('\n'));
132
+ return null;
133
+ },
134
+ });
135
+
136
+ /**
137
+ * Stylesheets imported as strings (`style-*.css?raw`: Luma's own widget styles,
138
+ * the integration's widget and host styles) minified, as Vite minifies the
139
+ * code around them: their comments and layout are for whoever edits them, not
140
+ * on every shopper's download (an integration's host styles are in the entry).
141
+ * esbuild's CSS minifier (Vite's own): it parses the CSS, so strings, comments
142
+ * and custom properties are safe, and it lowers nothing (no target).
143
+ */
144
+ export const minifyCss = async (css) => (await transformWithEsbuild(css, 'style.css', { loader: 'css', minify: true, logLevel: 'silent' })).code.trim();
145
+ const rawCss = () => ({
146
+ name: 'lbx-raw-css',
147
+ enforce: 'pre',
148
+ async transform(code, id) {
149
+ const [file, query] = id.split('?');
150
+ // A dependency's CSS is left as it is; Luma's own is minified, installed under node_modules/ too
151
+ if (query !== 'raw' || !file.endsWith('.css') || (file.includes('/node_modules/') && !file.startsWith(FRAMEWORK))) return null;
152
+ const m = /^export default (".*");?\s*$/s.exec(code);
153
+ if (!m) return null;
154
+ try {
155
+ return { code: `export default ${JSON.stringify(await minifyCss(JSON.parse(m[1])))};`, map: null };
156
+ } catch (e) {
157
+ // Not CSS esbuild can parse: shipped as written (the browser decides), and said.
158
+ this.warn(`${file}: not minified (${e.message.split('\n')[0]})`);
159
+ return null;
160
+ }
161
+ },
162
+ });
163
+
164
+ /**
165
+ * Built-in templates no instance can render are left out of the build. A
166
+ * widget's built-ins are an eager glob of its `template-*.ts`
167
+ * (src/widgets/<widget>/); an integration's file of the same name wins
168
+ * (src/core/context.ts). Widget code renders an entry template (ENTRY_TEMPLATES)
169
+ * and every other one is reached by `include('name')` from a template in use.
170
+ * So per instance using a widget's built-ins (search and category pages share
171
+ * the listing's; each recommender folder is an instance; a _global/ file only
172
+ * for the widget it is named after, as `widgetFiles` reads them) the templates
173
+ * in use are walked from the entries, each name resolved as the instance would
174
+ * (its folders', else the built-in); a built-in reached in no instance is
175
+ * never rendered: overridden everywhere, or only included by templates that
176
+ * are themselves overridden (an integration's own facets template drawing its
177
+ * own range slider leaves the built-in slider and its handlers, range.ts,
178
+ * unused). A template including a computed name (not a quoted literal) keeps
179
+ * every built-in of its widget. A built-in another built-in imports stays as
180
+ * far as it is imported (Rollup drops the unused rest). The build only: the
181
+ * dev server sees files come and go.
182
+ *
183
+ * `integration`: { folder: { name: source } }; `builtIns`: { widget: { name: source } }.
184
+ * Returns { widget: [built-in names to leave out] }.
185
+ */
186
+ export const ENTRY_TEMPLATES = { autocomplete: ['autocomplete-box'], search: ['search-box'], recommender: ['recommender-box-default', 'recommender-popup'] };
187
+ const includes = (src) => {
188
+ const calls = [...src.matchAll(/\binclude\(\s*(?:(['"])([\w-]+)\1)?/g)];
189
+ return calls.some((m) => !m[2]) ? null : calls.map((m) => m[2]);
190
+ };
191
+ export const unrenderedBuiltIns = (integration, builtIns) => {
192
+ const folders = Object.keys(integration);
193
+ const applies = (folder, name, kinds) => folder !== '_global' || !Object.keys(KIND_CHUNK).includes(name.split('-')[0]) || kinds.includes(name.split('-')[0]);
194
+ // Each widget's built-ins: the instances using them (their folders, and the kinds their _global/ files are read for).
195
+ const users = {
196
+ autocomplete: folders.includes('autocomplete') ? [{ folders: ['_global', 'autocomplete'], kinds: ['autocomplete'] }] : [],
197
+ search: [
198
+ ...(folders.includes('search') ? [{ folders: ['_global', 'search'], kinds: ['search'] }] : []),
199
+ ...(folders.includes('plp') ? [{ folders: ['_global', 'search', 'plp'], kinds: ['search', 'plp'] }] : []),
200
+ ],
201
+ recommender: folders.filter((f) => f.split(/\s+/)[0] === 'recommender').map((f) => ({ folders: ['_global', f], kinds: ['recommender'] })),
202
+ };
203
+ const out = {};
204
+ for (const [widget, instances] of Object.entries(users)) {
205
+ if (!instances.length) continue;
206
+ const mine = builtIns[widget] ?? {};
207
+ const used = new Set();
208
+ let dynamic = false;
209
+ for (const i of instances) {
210
+ // The instance's template of a name: its last folder having it, else the built-in.
211
+ const resolveName = (name) => {
212
+ for (const f of [...i.folders].reverse()) if (integration[f]?.[name] != null && applies(f, name, i.kinds)) return { src: integration[f][name], builtIn: false };
213
+ return mine[name] != null ? { src: mine[name], builtIn: true } : null;
214
+ };
215
+ const seen = new Set();
216
+ const todo = [...ENTRY_TEMPLATES[widget]];
217
+ while (todo.length) {
218
+ const name = todo.pop();
219
+ if (seen.has(name)) continue;
220
+ seen.add(name);
221
+ const t = resolveName(name);
222
+ if (!t) continue;
223
+ if (t.builtIn) used.add(name);
224
+ const next = includes(t.src);
225
+ if (next == null) dynamic = true;
226
+ else todo.push(...next);
227
+ }
228
+ }
229
+ out[widget] = dynamic ? [] : Object.keys(mine).filter((n) => !used.has(n)).sort();
230
+ }
231
+ return out;
232
+ };
233
+ /** { folder: { template name: source } } of an integration/ directory, or of a widget directory's built-ins. */
234
+ const templateSources = (dir) => {
235
+ const out = {};
236
+ if (!existsSync(dir)) return out;
237
+ for (const e of readdirSync(dir, { withFileTypes: true }))
238
+ if (e.isDirectory()) {
239
+ out[e.name] = {};
240
+ for (const f of readdirSync(resolve(dir, e.name))) {
241
+ const n = /^template-(.+)\.ts$/.exec(f)?.[1];
242
+ if (n) out[e.name][n] = readFileSync(resolve(dir, e.name, f), 'utf8');
243
+ }
244
+ }
245
+ return out;
246
+ };
247
+ const BUILT_INS = /\/src\/widgets\/(autocomplete|search|recommender)\/(?:index|assets)\.ts$/;
248
+
249
+ /**
250
+ * A built-in template's own rules in its widget's stylesheet, marked
251
+ * `/* @lbx-part <template> ... *\/ … /* @lbx-part-end *\/`, go with the
252
+ * template, unless a template of the integration (the widget's folders) uses
253
+ * one of their distinctive classes: those the replaced built-in renders and no
254
+ * kept built-in does (an override may reuse the built-in's markup; then its
255
+ * rules stay). A rule of the part needs one of them (`.sheet .pill`), so with
256
+ * none rendered it matches nothing.
257
+ */
258
+ const classesOf = (css) => new Set([...css.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)].map((m) => m[1]));
259
+ const wordsOf = (sources) => new Set(sources.flatMap((s) => s.match(/[-\w]+/g) ?? []));
260
+ const PART = /\/\* @lbx-part ([\w-]+)[^*]*\*\/([\s\S]*?)\/\* @lbx-part-end \*\//g;
261
+ /** The stylesheet without the parts of `names`. */
262
+ export const withoutParts = (css, names) => css.replace(PART, (all, n) => (names.includes(n) ? '' : all));
263
+ export const cssPartUnused = (partCss, droppedSrc, keptSrcs, integrationSrcs) => {
264
+ const dropped = wordsOf([droppedSrc]);
265
+ const kept = wordsOf(keptSrcs);
266
+ const theirs = wordsOf(integrationSrcs);
267
+ const own = [...classesOf(partCss)].filter((c) => dropped.has(c) && !kept.has(c));
268
+ return own.length > 0 && !own.some((c) => theirs.has(c));
269
+ };
270
+ const unusedBuiltIns = (root) => {
271
+ let drop = null;
272
+ let cssDrop = null;
273
+ return {
274
+ name: 'lbx-unused-built-ins',
275
+ apply: 'build',
276
+ enforce: 'pre',
277
+ buildStart() {
278
+ const itg = templateSources(resolve(root, 'integration'));
279
+ const builtIns = templateSources(resolve(FRAMEWORK, 'widgets'));
280
+ drop = unrenderedBuiltIns(itg, builtIns);
281
+ // The left-out templates' stylesheet parts no template of the widget's folders still uses.
282
+ cssDrop = {};
283
+ for (const [widget, names] of Object.entries(drop)) {
284
+ const theirs = [itg._global, itg[widget]].flatMap((f) => Object.values(f ?? {}));
285
+ const kept = Object.entries(builtIns[widget] ?? {}).filter(([n]) => !names.includes(n)).map(([, src]) => src);
286
+ const dir = resolve(FRAMEWORK, 'widgets', widget);
287
+ const sheets = existsSync(dir) ? readdirSync(dir).filter((f) => /^style-[\w-]+\.css$/.test(f)).map((f) => readFileSync(resolve(dir, f), 'utf8')) : [];
288
+ cssDrop[widget] = names.filter((n) => sheets.some((css) => [...css.matchAll(PART)].some(([, p, body]) => p === n && cssPartUnused(body, builtIns[widget]?.[n] ?? '', kept, theirs))));
289
+ }
290
+ },
291
+ transform(code, id) {
292
+ // A widget's own stylesheet (?raw, before it is minified): without the parts of templates left out.
293
+ const css = /\/src\/widgets\/(\w+)\/style-[\w-]+\.css\?raw$/.exec(id);
294
+ if (css && id.startsWith(FRAMEWORK)) {
295
+ const parts = cssDrop?.[css[1]];
296
+ const raw = /^export default (".*");?\s*$/s.exec(code);
297
+ if (!parts?.length || !raw) return null;
298
+ return { code: `export default ${JSON.stringify(withoutParts(JSON.parse(raw[1]), parts))};`, map: null };
299
+ }
300
+ const m = BUILT_INS.exec(id.split('?')[0]);
301
+ const names = m && id.startsWith(FRAMEWORK) ? drop?.[m[1]] : null;
302
+ if (!names?.length) return null;
303
+ const glob = "import.meta.glob('./template-*.ts',";
304
+ if (!code.includes(glob)) return null;
305
+ return { code: code.replace(glob, `import.meta.glob(${JSON.stringify(['./template-*.ts', ...names.map((n) => `!./template-${n}.ts`)])},`), map: null };
306
+ },
307
+ };
308
+ };
309
+
310
+ /**
311
+ * lit templates without their source indentation: in an `html`/`svg` tagged
312
+ * template's static text, the spaces and tabs after a line break are layout
313
+ * for whoever edits the template. HTML collapses them with the line break (and
314
+ * `white-space: pre-line` keeps the line break, which stays), so dropping them
315
+ * renders the same; only preformatted whitespace would show them, so a file
316
+ * mentioning `<pre`, `<textarea` or a `pre`/`pre-wrap`/`break-spaces`
317
+ * white-space is left as written. Templates are found by TypeScript's parser
318
+ * (strings, comments, regexes and nested templates are parsed, not guessed).
319
+ */
320
+ export const dedentTemplates = (ts, code, file) => {
321
+ if (!/\b(?:html|svg)`/.test(code) || /<pre\b|<textarea\b|white-space\s*:\s*(?:pre|pre-wrap|break-spaces)\b/i.test(code)) return null;
322
+ const sf = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true, file.endsWith('.js') ? ts.ScriptKind.JS : ts.ScriptKind.TS);
323
+ const edits = [];
324
+ const literal = (node, open, close) => {
325
+ // The literal's raw text between its delimiters (` or } before, ` or ${ after).
326
+ const a = node.getStart(sf) + open;
327
+ const b = node.getEnd() - close;
328
+ const raw = code.slice(a, b);
329
+ // Not after a line continuation (a backslash before the line break).
330
+ const out = raw.replace(/(?<!\\)\n[ \t]+/g, '\n');
331
+ if (out !== raw) edits.push([a, b, out]);
332
+ };
333
+ const visit = (n) => {
334
+ if (ts.isTaggedTemplateExpression(n) && ts.isIdentifier(n.tag) && (n.tag.text === 'html' || n.tag.text === 'svg')) {
335
+ const t = n.template;
336
+ if (ts.isNoSubstitutionTemplateLiteral(t)) literal(t, 1, 1);
337
+ else {
338
+ literal(t.head, 1, 2);
339
+ for (const s of t.templateSpans) literal(s.literal, 1, ts.isTemplateTail(s.literal) ? 1 : 2);
340
+ }
341
+ }
342
+ ts.forEachChild(n, visit);
343
+ };
344
+ visit(sf);
345
+ if (!edits.length) return null;
346
+ let out = code;
347
+ for (const [a, b, s] of edits.sort((x, y) => y[0] - x[0])) out = out.slice(0, a) + s + out.slice(b);
348
+ return out;
349
+ };
350
+ const templateText = (root) => {
351
+ let ts;
352
+ // Module ids are real paths: an integration/ reached through a symlink too.
353
+ const dir = resolve(root, 'integration');
354
+ const itg = existsSync(dir) ? realpathSync(dir) : dir;
355
+ return {
356
+ name: 'lbx-template-text',
357
+ apply: 'build',
358
+ enforce: 'pre',
359
+ async transform(code, id) {
360
+ const file = id.split('?')[0];
361
+ // Luma's own templates and the integration's (Luma installed under node_modules/ too)
362
+ if (!/\.[jt]s$/.test(file) || !(file.startsWith(FRAMEWORK) || file.startsWith(itg))) return null;
363
+ if (!/\b(?:html|svg)`/.test(code)) return null;
364
+ ts ??= (await import('typescript')).default;
365
+ const out = dedentTemplates(ts, code, file);
366
+ return out == null ? null : { code: out, map: null };
367
+ },
368
+ };
369
+ };
370
+
371
+ /**
372
+ * A second minifier after Vite's esbuild pass: terser (two compress passes,
373
+ * mangled top-level names in each module) finds what esbuild leaves (~3 %
374
+ * gzip: measured on a production integration 2026-10-10, 0.6 kB of the entry, 0.9 kB of the
375
+ * listing). On the finished chunks (generateBundle: after every renderChunk,
376
+ * Vite's own minify and target included). Luma's dependency, resolved from
377
+ * here (an integration installs Luma's dependencies: yarn install); without it, esbuild's output as before,
378
+ * and said. Source maps of a build that asks for them stay esbuild's: not run.
379
+ */
380
+ // wrap_func_args off: terser's parentheses around every function argument are a
381
+ // V8 eager-compile hint, useless for our callbacks and 0.3 kB gzip of bytes.
382
+ export const terserOptions = { module: true, ecma: 2020, compress: { passes: 2 }, mangle: true, format: { comments: false, wrap_func_args: false } };
383
+ const terse = () => ({
384
+ name: 'lbx-terser',
385
+ apply: 'build',
386
+ enforce: 'post',
387
+ async generateBundle(opts, bundle) {
388
+ if (opts.sourcemap) return;
389
+ let minify;
390
+ try {
391
+ ({ minify } = await import('terser'));
392
+ } catch {
393
+ return this.warn('terser is not installed (yarn install): esbuild\'s minification only');
394
+ }
395
+ await Promise.all(
396
+ Object.values(bundle)
397
+ .filter((c) => c.type === 'chunk')
398
+ .map(async (c) => {
399
+ const r = await minify(c.code, terserOptions);
400
+ if (r.code) c.code = r.code;
401
+ }),
402
+ );
403
+ },
404
+ });
405
+
406
+ /**
407
+ * Vite's preload helper (what a dynamic import() becomes: preload the chunk's
408
+ * own imports, then import it) replaced with what our chunks need: a
409
+ * modulepreload link per dependency, once, with the page's CSP nonce. Vite's
410
+ * handles CSS dependencies, a <meta> nonce, a `preload` fallback and a
411
+ * vite:preloadError event, none of which a Luma build uses: ~1 kB gzip of the
412
+ * entry. A failed preload is the import's failure (it fetches the same file).
413
+ */
414
+ export const PRELOAD_HELPER = `const seen = {};
415
+ export const __vitePreload = (load, deps, base) => {
416
+ for (const d of deps || []) {
417
+ const href = new URL(d, base).href;
418
+ if (seen[href]) continue;
419
+ seen[href] = 1;
420
+ const l = document.createElement('link');
421
+ l.rel = 'modulepreload';
422
+ l.href = href;
423
+ const nonce = document.querySelector('script[nonce]')?.nonce;
424
+ if (nonce) l.nonce = nonce;
425
+ document.head.append(l);
426
+ }
427
+ return load();
428
+ };`;
429
+ const preloadHelper = () => ({
430
+ name: 'lbx-preload-helper',
431
+ apply: 'build',
432
+ enforce: 'pre',
433
+ load(id) {
434
+ if (id === '\0vite/preload-helper.js') return { code: PRELOAD_HELPER, moduleSideEffects: false };
435
+ },
436
+ });
437
+
438
+ /**
439
+ * `overrides` are merged into the config; `plugins` are appended and `server`
440
+ * is merged, so a client repo can add e.g. HTTPS for the loader's development
441
+ * mode without losing ours.
442
+ */
443
+ export const clientConfig = (configUrl, overrides = {}) => {
444
+ const root = dirname(fileURLToPath(configUrl));
445
+ // The dev server serves the integration's source to the site's pages: only
446
+ // the origins in LBX_DEV_ORIGINS (comma-separated, e.g. https://www.shop.example)
447
+ // may read it. Unset: any origin (convenient, but any page open in the
448
+ // developer's browser could read the source while the server runs).
449
+ const devOrigins = process.env.LBX_DEV_ORIGINS?.split(',').map((o) => o.trim()).filter(Boolean);
450
+ const { plugins = [], server = {}, ...rest } = overrides;
451
+ return {
452
+ root,
453
+ resolve: { alias: { '@': FRAMEWORK } },
454
+ define: { __VERSION__: JSON.stringify(new Date().toISOString()) },
455
+ // css.postcss: {} stops Vite from picking up a PostCSS config from parent dirs.
456
+ css: { postcss: {} },
457
+ // Class fields are fields (tsconfig.json's ES2022), wherever Luma is: Vite
458
+ // reads no tsconfig for a file under node_modules/, and esbuild without one
459
+ // turns them into constructor assignments (the package, unlike luma/).
460
+ esbuild: { tsconfigRaw: { compilerOptions: { useDefineForClassFields: true } } },
461
+ // Luma's source (`@/…`) is served as modules like the integration's, not
462
+ // pre-bundled as a dependency: installed under node_modules/, Vite would.
463
+ optimizeDeps: { exclude: ['@'] },
464
+ base: './',
465
+ plugins: [preloadHelper(), scopeCheck(), unusedBuiltIns(root), rawCss(), templateText(root), terse(), ...plugins],
466
+ server: {
467
+ port: 8069,
468
+ strictPort: true,
469
+ cors: devOrigins ? { origin: devOrigins } : true,
470
+ // Chrome's Local Network Access: a public page loading from localhost.
471
+ headers: { 'Access-Control-Allow-Private-Network': 'true' },
472
+ ...server,
473
+ },
474
+ build: {
475
+ emptyOutDir: true,
476
+ // Baseline Widely available: the browser
477
+ // versions Vite 7 pins for it (Chrome/Edge 107, Firefox 104, Safari 16:
478
+ // Widely available on 2025-05-01), so modern syntax ships as written.
479
+ // Syntax only: APIs are not polyfilled, which `yarn luma baseline` checks.
480
+ target: 'baseline-widely-available',
481
+ // Preload a lazy chunk's own dependencies in parallel with it.
482
+ modulePreload: { polyfill: false },
483
+ rollupOptions: {
484
+ input: { integration: resolve(root, 'integration/__integration.ts') },
485
+ output: {
486
+ entryFileNames: 'luma-[name].js',
487
+ chunkFileNames: 'luma-chunks/[name]-[hash].js',
488
+ assetFileNames: 'luma-assets/[name]-[hash].[ext]',
489
+ manualChunks,
490
+ // Merges tiny chunks into ones loaded with them where that cannot change
491
+ // execution order: fewer requests per page, the entry stays untouched.
492
+ experimentalMinChunkSize: 4096,
493
+ },
494
+ },
495
+ },
496
+ ...rest,
497
+ };
498
+ };
package/chrome/run.mjs ADDED
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ // yarn luma chrome [path]: the live storefront in your Chrome with this
3
+ // integration on it as a customer installs it (the early-hold snippet first
4
+ // in <head>, then the build: parity/hold.mjs), whether or not the site
5
+ // includes it yet. Run from the integration repository:
6
+ //
7
+ // yarn luma chrome the storefront's home page (luma.config.mjs `host`, or LBX_HOST)
8
+ // yarn luma chrome /some-category another page
9
+ // yarn luma chrome --dev from the dev server (yarn dev): an edit shows on reload, no build
10
+ //
11
+ // Nothing else of the site changes: Luigi's Box's scripts it already loads (a
12
+ // platform loader, a previous integration) are blocked, so ours is the only
13
+ // one; its own scripts and consent dialog stay as they are. The
14
+ // profile is kept per storefront (its cookies, a consent given once), apart
15
+ // from your own Chrome profile. No rig, no Docker, no DNS: the storefront is
16
+ // the real one.
17
+ // The window stays until you close it.
18
+ import { execFileSync } from 'node:child_process';
19
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
20
+ import { homedir, tmpdir } from 'node:os';
21
+ import { extname, join, normalize, resolve } from 'node:path';
22
+ import { inside } from '../bin/paths.mjs';
23
+
24
+ const USAGE = `usage: yarn luma chrome [path] [options]
25
+
26
+ path the storefront page to open (default /)
27
+ --dev load the integration from the dev server (https://localhost:8069, yarn dev): no build
28
+ --check open, wait for Luma, print its state (started, what it inserted, errors), close: exit 1 on an error
29
+ --no-build serve dist/ as it is (yarn build yourself, then reload)
30
+ --no-snippet the build alone, without the early-hold snippet in <head>
31
+ --channel NAME chrome (default: your installed Chrome) or chromium (Playwright's)
32
+ --profile DIR the browser profile (default ~/.lbx-chrome/<host>)
33
+ --site-dir DIR the integration repository (default: the current directory, LBX_SITE_DIR)`;
34
+
35
+ const args = process.argv.slice(2);
36
+ const opt = { path: '/', check: false, dev: false, build: true, snippet: true, channel: 'chrome', profile: '', siteDir: '' };
37
+ const die = (msg) => {
38
+ console.error(msg);
39
+ process.exit(2);
40
+ };
41
+ for (let i = 0; i < args.length; i++) {
42
+ const a = args[i];
43
+ const next = () => {
44
+ const v = args[++i];
45
+ if (v === undefined || v.startsWith('--')) die(`${a} needs a value\n\n${USAGE}`);
46
+ return v;
47
+ };
48
+ if (a === '--help' || a === '-h') {
49
+ console.log(USAGE);
50
+ process.exit(0);
51
+ } else if (a === '--dev') opt.dev = true;
52
+ else if (a === '--check') opt.check = true;
53
+ else if (a === '--no-build') opt.build = false;
54
+ else if (a === '--no-snippet') opt.snippet = false;
55
+ else if (a === '--channel') opt.channel = next();
56
+ else if (a === '--profile') opt.profile = next();
57
+ else if (a === '--site-dir') opt.siteDir = next();
58
+ else if (!a.startsWith('--')) opt.path = a.startsWith('/') ? a : `/${a}`;
59
+ else die(`unknown option ${a}\n\n${USAGE}`);
60
+ }
61
+ const SITE_DIR = resolve(opt.siteDir || process.env.LBX_SITE_DIR || process.cwd());
62
+ if (!existsSync(join(SITE_DIR, 'integration'))) die(`${SITE_DIR} has no integration/ folder: run it from the integration repository (or --site-dir).`);
63
+ process.env.LBX_SITE_DIR = SITE_DIR;
64
+
65
+ let chromium;
66
+ try {
67
+ ({ chromium } = await import('playwright-core'));
68
+ } catch {
69
+ die("Luma's dependencies are not installed (playwright-core is missing): run `yarn install`.");
70
+ }
71
+ const { loadConfig } = await import('../bin/config.mjs');
72
+ const config = await loadConfig(SITE_DIR);
73
+ const { SNIPPET, holdInit } = await import('../parity/hold.mjs');
74
+
75
+ const HOST = config.hostKnown ? config.host : '';
76
+ if (!HOST) die("No storefront host: `export const host = 'www.shop.example'` in luma.config.mjs (yarn luma tooling --write --host <name>), or LBX_HOST.");
77
+ const ORIGIN = `https://${HOST}`;
78
+ const PREFIX = '/__lbx/chrome/';
79
+ const DEV = 'https://localhost:8069/integration/__integration.ts';
80
+
81
+ // ------------------------------------------------------------------ the build (a folder of its own: dist/ is left as it is)
82
+
83
+ let DIST = join(SITE_DIR, 'dist');
84
+ let temp = '';
85
+ if (!opt.dev && opt.build) {
86
+ temp = DIST = mkdtempSync(join(tmpdir(), 'lbx-chrome-'));
87
+ process.stderr.write('building… ');
88
+ try {
89
+ execFileSync('yarn', ['vite', 'build', '--outDir', DIST, '--emptyOutDir'], { cwd: SITE_DIR, stdio: ['ignore', 'pipe', 'pipe'] });
90
+ process.stderr.write('done\n');
91
+ } catch (e) {
92
+ process.stderr.write('failed\n');
93
+ die(`the build failed:\n${e.stdout ?? ''}${e.stderr ?? ''}`);
94
+ }
95
+ }
96
+ if (!opt.dev && !existsSync(join(DIST, 'luma-integration.js'))) die(`${DIST}/luma-integration.js is missing: run yarn build (or drop --no-build).`);
97
+ const cleanup = () => temp && rmSync(temp, { recursive: true, force: true });
98
+ process.on('exit', cleanup);
99
+ for (const s of ['SIGINT', 'SIGTERM']) process.on(s, () => process.exit(130));
100
+
101
+ // ------------------------------------------------------------------ the browser
102
+
103
+ const profile = resolve(opt.profile || join(homedir(), '.lbx-chrome', HOST));
104
+ mkdirSync(profile, { recursive: true });
105
+ const TYPES = { '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.map': 'application/json' };
106
+ let ctx;
107
+ for (const channel of [opt.channel, ...(opt.channel === 'chrome' ? ['chromium'] : [])]) {
108
+ try {
109
+ ctx = await chromium.launchPersistentContext(profile, { channel, headless: false, viewport: null, ignoreHTTPSErrors: opt.dev, serviceWorkers: 'block' });
110
+ if (channel !== opt.channel) console.error(`(no installed Chrome: Playwright's Chromium instead)`);
111
+ break;
112
+ } catch (e) {
113
+ // The profile is one browser's at a time: a window of this command still open has it.
114
+ if (/already in use|existing browser session|ProcessSingleton/i.test(e.message)) die(`The profile ${profile} is in use (a yarn luma chrome window is open): close it, or --profile <another folder>.`);
115
+ if (channel === 'chromium') die(`No browser to open: install Chrome, or \`yarn playwright-core install chromium\`.\n${e.message.split('\n')[0]}`);
116
+ }
117
+ }
118
+ // Luigi's Box's scripts the site loads already: never with ours.
119
+ await ctx.route(/^https:\/\/(cdn\.luigisbox\.(com|tech)|cdn\.eu1\.luigisbox\.ai|scripts\.luigisbox\.(com|tech))\//, (r) => r.abort('blockedbyclient'));
120
+ // The build, from the storefront's own origin (as a platform loader serves it): fresh on every reload.
121
+ await ctx.route(`${ORIGIN}${PREFIX}**`, (route) => {
122
+ const file = normalize(join(DIST, decodeURIComponent(new URL(route.request().url()).pathname.slice(PREFIX.length))));
123
+ if (!inside(DIST, file) || !existsSync(file) || !statSync(file).isFile()) return route.fulfill({ status: 404, body: '' });
124
+ return route.fulfill({ status: 200, body: readFileSync(file), headers: { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream', 'cache-control': 'no-store' } });
125
+ });
126
+ await ctx.addInitScript(holdInit, { origin: ORIGIN, bundle: opt.dev ? DEV : `${ORIGIN}${PREFIX}luma-integration.js`, snippet: opt.snippet ? SNIPPET : '', tag: 'chrome' });
127
+
128
+ const page = ctx.pages()[0] ?? (await ctx.newPage());
129
+ await page.goto(`${ORIGIN}${opt.path}`, { waitUntil: 'domcontentloaded', timeout: 60000 }).catch((e) => console.error(`${ORIGIN}${opt.path}: ${e.message.split('\n')[0]}`));
130
+ if (opt.check) {
131
+ // Luma's own record (window.__lbx.health): did it start, what did it take over, what failed.
132
+ await page.waitForFunction(() => !!(window).__lbx?.health, null, { timeout: 20000 }).catch(() => {});
133
+ await new Promise((r) => setTimeout(r, 6000));
134
+ const s = await page.evaluate(() => {
135
+ const l = (window).__lbx;
136
+ return l ? { version: l.version, snippet: !!l.hold?.snippet, inserts: l.health?.inserts ?? null, marks: (l.health?.timeline ?? []).slice(0, 12).map(([t, w]) => `${t} ${w}`), errors: l.health?.errors ?? [] } : null;
137
+ }).catch((e) => ({ error: e.message }));
138
+ console.log(JSON.stringify({ url: page.url(), luma: s }, null, 1));
139
+ await ctx.close();
140
+ process.exit(!s || s.error || s.errors?.length ? 1 : 0);
141
+ }
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.`);
143
+ 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
+ console.log(`window.__lbx in the console: Luma's state. Close the window to stop (profile: ${profile}).`);
145
+ await new Promise((r) => ctx.on('close', r));