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