@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.
- package/LICENSE.md +90 -0
- package/README.md +615 -2
- package/a11y/audit.mjs +735 -0
- package/a11y/axe.mjs +73 -0
- package/a11y/report.mjs +347 -0
- package/a11y/run.mjs +256 -0
- package/a11y/wcag.mjs +233 -0
- package/bin/config.mjs +60 -0
- package/bin/luma.mjs +99 -0
- package/bin/paths.mjs +11 -0
- package/bin/tools.mjs +43 -0
- package/build/baseline.d.mts +8 -0
- package/build/baseline.mjs +167 -0
- package/build/tooling.mjs +102 -0
- package/build/vite.mjs +507 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +150 -0
- package/docs/ACCESSIBILITY.md +501 -0
- package/docs/INTEGRATION-PERFORMANCE.md +213 -0
- package/docs/PARITY.md +946 -0
- package/footprint/model.mjs +184 -0
- package/footprint/run.mjs +331 -0
- package/package.json +58 -4
- package/parity/alignment.mjs +109 -0
- package/parity/archive.mjs +223 -0
- package/parity/args.mjs +137 -0
- package/parity/behaviour.mjs +379 -0
- package/parity/compare.mjs +985 -0
- package/parity/engine.mjs +848 -0
- package/parity/hints.mjs +132 -0
- package/parity/hold.mjs +54 -0
- package/parity/init.mjs +703 -0
- package/parity/map.mjs +640 -0
- package/parity/owner.mjs +170 -0
- package/parity/pairing.mjs +110 -0
- package/parity/pixels.mjs +33 -0
- package/parity/probe.mjs +1239 -0
- package/parity/report.mjs +236 -0
- package/parity/run.mjs +267 -0
- package/snippet/early-hold.js +64 -0
- package/snippet/early-hold.min.js +1 -0
- package/src/analytics/chain.ts +137 -0
- package/src/analytics/collect.ts +64 -0
- package/src/analytics/datalayer.ts +67 -0
- package/src/analytics/from-ga4.ts +68 -0
- package/src/analytics/from-luma.ts +21 -0
- package/src/analytics/index.ts +24 -0
- package/src/analytics/outbox.ts +118 -0
- package/src/analytics/page.ts +46 -0
- package/src/analytics/pageview.ts +82 -0
- package/src/analytics/ref.ts +19 -0
- package/src/analytics/setup.ts +41 -0
- package/src/analytics/start.ts +53 -0
- package/src/analytics/triggers.ts +239 -0
- package/src/analytics/user.ts +12 -0
- package/src/analytics/wire.ts +67 -0
- package/src/api/ai.ts +98 -0
- package/src/api/index.ts +87 -0
- package/src/api/token-key.ts +42 -0
- package/src/api/token.ts +312 -0
- package/src/core/a11y-keys.ts +60 -0
- package/src/core/a11y.ts +160 -0
- package/src/core/boot-state.ts +6 -0
- package/src/core/color.ts +42 -0
- package/src/core/context.ts +108 -0
- package/src/core/early-rules.ts +96 -0
- package/src/core/entries.ts +25 -0
- package/src/core/fold.ts +7 -0
- package/src/core/health.ts +26 -0
- package/src/core/hold.ts +74 -0
- package/src/core/i18n.ts +66 -0
- package/src/core/image-load.ts +102 -0
- package/src/core/image-tags.ts +26 -0
- package/src/core/images.ts +170 -0
- package/src/core/integration.ts +126 -0
- package/src/core/kept.ts +25 -0
- package/src/core/listings.ts +100 -0
- package/src/core/locales.ts +14 -0
- package/src/core/own.ts +49 -0
- package/src/core/page.ts +14 -0
- package/src/core/prefetch.ts +88 -0
- package/src/core/recommenders.ts +166 -0
- package/src/core/reconciler.ts +397 -0
- package/src/core/result.ts +73 -0
- package/src/core/routes.ts +115 -0
- package/src/core/scheduler.ts +83 -0
- package/src/core/sense.ts +107 -0
- package/src/core/speculation.ts +59 -0
- package/src/core/stash.ts +38 -0
- package/src/core/storage.ts +45 -0
- package/src/core/styles.ts +106 -0
- package/src/core/support.ts +12 -0
- package/src/core/takeover.ts +220 -0
- package/src/core/templates.ts +74 -0
- package/src/core/tiles.ts +137 -0
- package/src/core/turbo.ts +64 -0
- package/src/core/url.ts +164 -0
- package/src/core/widget-inits.ts +40 -0
- package/src/env.d.ts +3 -0
- package/src/lit.ts +11 -0
- package/src/locales/cs.json +94 -0
- package/src/locales/de.json +88 -0
- package/src/locales/en.json +88 -0
- package/src/main.ts +493 -0
- package/src/types.ts +778 -0
- package/src/widgets/autocomplete/index.ts +1387 -0
- package/src/widgets/autocomplete/style-autocomplete.css +266 -0
- package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
- package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
- package/src/widgets/listing/element.ts +1772 -0
- package/src/widgets/listing/flow.ts +30 -0
- package/src/widgets/listing/instant.ts +133 -0
- package/src/widgets/listing/intent.ts +193 -0
- package/src/widgets/listing/query.ts +298 -0
- package/src/widgets/listing/stash.ts +97 -0
- package/src/widgets/listing/state.ts +131 -0
- package/src/widgets/listing/view.ts +257 -0
- package/src/widgets/plp/index.ts +11 -0
- package/src/widgets/plp/route.ts +263 -0
- package/src/widgets/recommender/index.ts +261 -0
- package/src/widgets/recommender/style-recommender.css +26 -0
- package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
- package/src/widgets/recommender/template-recommender-popup.ts +12 -0
- package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
- package/src/widgets/search/assets.ts +7 -0
- package/src/widgets/search/first.ts +15 -0
- package/src/widgets/search/form-url.ts +59 -0
- package/src/widgets/search/in-place.ts +226 -0
- package/src/widgets/search/index.ts +16 -0
- package/src/widgets/search/range.ts +260 -0
- package/src/widgets/search/route.ts +32 -0
- package/src/widgets/search/speculate.ts +202 -0
- package/src/widgets/search/style-search.css +65 -0
- package/src/widgets/search/template-search-box.ts +17 -0
- package/src/widgets/search/template-search-facets-default.ts +73 -0
- package/src/widgets/search/template-search-header.ts +14 -0
- package/src/widgets/search/template-search-pagination.ts +12 -0
- package/src/widgets/search/template-search-range-default.ts +48 -0
- package/src/widgets/search/template-search-result-default.ts +13 -0
- package/src/widgets/search/template-search-results-default.ts +8 -0
- package/tasks/model.mjs +116 -0
- package/tasks/run.mjs +305 -0
- 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
|
+
};
|
package/chrome/block.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The Luigi's Box scripts `yarn luma chrome` keeps off the storefront (a
|
|
2
|
+
// platform loader, a previous integration: never with ours), but the hosts
|
|
3
|
+
// `--allow` names: a previous generation's search or autocomplete the
|
|
4
|
+
// integration's analytics observes (mutationOf, xhrMatching).
|
|
5
|
+
export const LBX_SCRIPTS = /^https:\/\/(cdn\.luigisbox\.(com|tech)|cdn\.eu1\.luigisbox\.ai|scripts\.luigisbox\.(com|tech))\//;
|
|
6
|
+
|
|
7
|
+
/** Whether `url` is blocked, `allow` the host names left alone. */
|
|
8
|
+
export const blocked = (url, allow = []) => LBX_SCRIPTS.test(url) && !allow.includes(new URL(url).hostname);
|