@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.
- package/LICENSE.md +90 -0
- package/README.md +488 -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 +498 -0
- package/chrome/run.mjs +145 -0
- package/docs/ACCESSIBILITY.md +502 -0
- package/docs/INTEGRATION-PERFORMANCE.md +214 -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/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 +311 -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 +120 -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/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 +443 -0
- package/src/types.ts +760 -0
- package/src/widgets/autocomplete/index.ts +1367 -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 +1769 -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 +250 -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,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));
|