@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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// What `yarn luma footprint` (footprint/run.mjs) turns its measurements into. Pure
|
|
2
|
+
// functions, no browser: the round trips a file needs on a new connection,
|
|
3
|
+
// Luma's main-thread time from a CPU profile and a trace, the request chain,
|
|
4
|
+
// medians, and the report table. Tested offline (Luma's own tests).
|
|
5
|
+
|
|
6
|
+
/** TCP's first flight: 10 segments (RFC 6928) of 1460 bytes of payload. */
|
|
7
|
+
export const INITCWND = 10;
|
|
8
|
+
export const MSS = 1460;
|
|
9
|
+
/**
|
|
10
|
+
* Bytes on the wire around a response body: its HTTP/2 HEADERS frame (HPACK,
|
|
11
|
+
* a CDN's usual set), DATA frame headers and TLS record overhead. An estimate,
|
|
12
|
+
* on the safe side.
|
|
13
|
+
*/
|
|
14
|
+
export const OVERHEAD = 600;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Round trips the server needs to deliver `bytes` (one response) on a
|
|
18
|
+
* connection that has already delivered `before` bytes (the loader on the same
|
|
19
|
+
* host), in TCP slow start without loss: each round trip the window grows by
|
|
20
|
+
* the segments acknowledged (it doubles). The request itself is not counted:
|
|
21
|
+
* 1 = the whole response arrives in the first flight after the request.
|
|
22
|
+
* @param {number} bytes
|
|
23
|
+
* @param {{ before?: number, overhead?: number, initcwnd?: number, mss?: number }} [o]
|
|
24
|
+
*/
|
|
25
|
+
export const roundTrips = (bytes, { before = 0, overhead = OVERHEAD, initcwnd = INITCWND, mss = MSS } = {}) => {
|
|
26
|
+
let cwnd = initcwnd + Math.ceil(before / mss);
|
|
27
|
+
let left = Math.ceil((bytes + overhead) / mss);
|
|
28
|
+
let trips = 0;
|
|
29
|
+
while (left > 0) {
|
|
30
|
+
trips++;
|
|
31
|
+
const sent = Math.min(cwnd, left);
|
|
32
|
+
left -= sent;
|
|
33
|
+
cwnd += sent;
|
|
34
|
+
}
|
|
35
|
+
return trips;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Bytes the first flight can carry for a response after `before` bytes (the largest body that still takes 1 round trip). */
|
|
39
|
+
export const firstFlight = ({ before = 0, overhead = OVERHEAD, initcwnd = INITCWND, mss = MSS } = {}) => (initcwnd + Math.ceil(before / mss)) * mss - overhead;
|
|
40
|
+
|
|
41
|
+
/** Is this URL one of Luma's scripts (the build served under /__lbx/)? */
|
|
42
|
+
export const isLumaScript = (url) => typeof url === 'string' && /\/__lbx\/[^?#]*\.js(?:[?#]|$)/.test(url);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Milliseconds of a CPU profile (CDP Profiler.Profile) spent with a frame of
|
|
46
|
+
* Luma's on the stack: our code and what it calls (DOM, layout it forces, the
|
|
47
|
+
* browser's APIs). `ours(url)` decides what is Luma's.
|
|
48
|
+
* @param {{ nodes: { id: number, parent?: number, children?: number[], callFrame: { url: string } }[], samples: number[], timeDeltas: number[] }} profile
|
|
49
|
+
*/
|
|
50
|
+
export const profileMs = (profile, ours = isLumaScript) => {
|
|
51
|
+
const byId = new Map(profile.nodes.map((n) => [n.id, n]));
|
|
52
|
+
const parent = new Map();
|
|
53
|
+
for (const n of profile.nodes) for (const c of n.children ?? []) parent.set(c, n.id);
|
|
54
|
+
const memo = new Map();
|
|
55
|
+
const mine = (id) => {
|
|
56
|
+
if (memo.has(id)) return memo.get(id);
|
|
57
|
+
const n = byId.get(id);
|
|
58
|
+
const v = !!n && (ours(n.callFrame.url) || (parent.has(id) && mine(parent.get(id))));
|
|
59
|
+
memo.set(id, v);
|
|
60
|
+
return v;
|
|
61
|
+
};
|
|
62
|
+
let us = 0;
|
|
63
|
+
// timeDeltas[i] is the time before sample i: the time sample i-1 stood for is timeDeltas[i].
|
|
64
|
+
for (let i = 0; i < profile.samples.length; i++) {
|
|
65
|
+
const d = profile.timeDeltas[i + 1] ?? 0;
|
|
66
|
+
if (d > 0 && mine(profile.samples[i])) us += d;
|
|
67
|
+
}
|
|
68
|
+
return us / 1000;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Milliseconds V8 spent compiling Luma's scripts (trace events v8.compile,
|
|
73
|
+
* v8.compileModule, …: before a module runs, outside any profiled frame).
|
|
74
|
+
* Nested events are counted once.
|
|
75
|
+
* @param {{ name: string, ph: string, dur?: number, ts: number, tid?: number, args?: any }[]} events
|
|
76
|
+
*/
|
|
77
|
+
export const compileMs = (events, ours = isLumaScript) => {
|
|
78
|
+
const urlOf = (e) => e.args?.data?.url ?? e.args?.fileName ?? e.args?.data?.fileName ?? e.args?.url;
|
|
79
|
+
const spans = events
|
|
80
|
+
.filter((e) => e.ph === 'X' && /^v8\.(compile|compileModule|parseOnBackground|produceModuleCache)/.test(e.name) && ours(urlOf(e)))
|
|
81
|
+
.map((e) => [e.tid ?? 0, e.ts, e.ts + (e.dur ?? 0)])
|
|
82
|
+
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
83
|
+
let total = 0;
|
|
84
|
+
let tid = null;
|
|
85
|
+
let end = -Infinity;
|
|
86
|
+
for (const [t, s, e] of spans) {
|
|
87
|
+
if (t !== tid) (tid = t), (end = -Infinity);
|
|
88
|
+
if (e <= end) continue;
|
|
89
|
+
total += e - Math.max(s, end);
|
|
90
|
+
end = e;
|
|
91
|
+
}
|
|
92
|
+
return total / 1000;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The longest chain of Luma requests, each started by the one before it (its
|
|
97
|
+
* initiator): the page → the entry → a chunk = 2. `requests`: url and the url
|
|
98
|
+
* of what started it (a script, or the page).
|
|
99
|
+
* @param {{ url: string, initiator?: string }[]} requests
|
|
100
|
+
*/
|
|
101
|
+
export const chainDepth = (requests, ours = isLumaScript) => {
|
|
102
|
+
const by = new Map(requests.filter((r) => ours(r.url)).map((r) => [r.url, r]));
|
|
103
|
+
const memo = new Map();
|
|
104
|
+
const depth = (url, seen = new Set()) => {
|
|
105
|
+
if (memo.has(url)) return memo.get(url);
|
|
106
|
+
const r = by.get(url);
|
|
107
|
+
if (!r || seen.has(url)) return 0;
|
|
108
|
+
seen.add(url);
|
|
109
|
+
const d = 1 + (r.initiator && r.initiator !== url ? depth(r.initiator, seen) : 0);
|
|
110
|
+
memo.set(url, d);
|
|
111
|
+
return d;
|
|
112
|
+
};
|
|
113
|
+
return Math.max(0, ...[...by.keys()].map((u) => depth(u)));
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** The median (the mean of the two middle values for an even count); null for none. */
|
|
117
|
+
export const median = (xs) => {
|
|
118
|
+
const s = xs.filter((x) => x != null && Number.isFinite(x)).sort((a, b) => a - b);
|
|
119
|
+
if (!s.length) return null;
|
|
120
|
+
const m = s.length >> 1;
|
|
121
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** The rows of the report, in order: what each scenario costs. `lower`: smaller is better (all of them). */
|
|
125
|
+
export const METRICS = [
|
|
126
|
+
{ key: 'requests', label: 'Luma scripts', unit: '', digits: 0 },
|
|
127
|
+
{ key: 'br', label: 'Luma JS (brotli)', unit: 'kB', digits: 1, scale: 1 / 1000 },
|
|
128
|
+
{ key: 'gzip', label: 'Luma JS (gzip)', unit: 'kB', digits: 1, scale: 1 / 1000 },
|
|
129
|
+
{ key: 'chain', label: 'script chain (page = 0)', unit: '', digits: 0 },
|
|
130
|
+
{ key: 'mainMs', label: 'Luma main thread', unit: 'ms', digits: 1 },
|
|
131
|
+
{ key: 'compileMs', label: 'Luma compile', unit: 'ms', digits: 1 },
|
|
132
|
+
{ key: 'apiCalls', label: 'API requests (auth + api)', unit: '', digits: 0 },
|
|
133
|
+
{ key: 'reconciles', label: 'reconcile passes', unit: '', digits: 0 },
|
|
134
|
+
{ key: 'readyMs', label: 'ready (listing shown / suggestions)', unit: 'ms', digits: 0 },
|
|
135
|
+
// --dictionary only (a row without a value in any scenario is left out).
|
|
136
|
+
{ key: 'dict', label: 'Luma JS (previous build as dictionary)', unit: 'kB', digits: 1, scale: 1 / 1000 },
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
const fmt = (m, v) => (v == null ? '–' : `${(v * (m.scale ?? 1)).toFixed(m.digits)}${m.unit ? ` ${m.unit}` : ''}`);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The terminal report: the entry's size and round trips, then per scenario the
|
|
143
|
+
* medians of its runs.
|
|
144
|
+
* @param {{ entry: { raw: number, gzip: number, br: number, trips: number, tripsGzip: number, flight: number }, scenarios: { name: string, runs: Record<string, number | null>[] }[], meta?: Record<string, string> }} r
|
|
145
|
+
*/
|
|
146
|
+
export const formatReport = ({ entry, scenarios, meta = {} }) => {
|
|
147
|
+
const lines = [];
|
|
148
|
+
for (const [k, v] of Object.entries(meta)) lines.push(`${k}: ${v}`);
|
|
149
|
+
lines.push(
|
|
150
|
+
`entry: ${(entry.br / 1000).toFixed(1)} kB brotli, ${(entry.gzip / 1000).toFixed(1)} kB gzip, ${(entry.raw / 1000).toFixed(1)} kB raw; ` +
|
|
151
|
+
`round trips ${entry.trips} (brotli) / ${entry.tripsGzip} (gzip); first flight holds ${(entry.flight / 1000).toFixed(1)} kB`,
|
|
152
|
+
);
|
|
153
|
+
const head = ['metric', ...scenarios.map((s) => s.name)];
|
|
154
|
+
const rows = METRICS.filter((m) => scenarios.some((s) => median(s.runs.map((r) => r[m.key])) != null)).map((m) => [m.label, ...scenarios.map((s) => fmt(m, median(s.runs.map((r) => r[m.key]))))]);
|
|
155
|
+
const width = head.map((_, i) => Math.max(...[head, ...rows].map((r) => String(r[i]).length)));
|
|
156
|
+
const line = (r) => r.map((c, i) => (i ? String(c).padStart(width[i]) : String(c).padEnd(width[i]))).join(' ');
|
|
157
|
+
lines.push('', line(head), ...rows.map(line));
|
|
158
|
+
return lines.join('\n');
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** Medians per scenario and metric: what is compared between two runs of the tool (--json). */
|
|
162
|
+
export const summarize = (scenarios) => Object.fromEntries(scenarios.map((s) => [s.name, Object.fromEntries(METRICS.map((m) => [m.key, median(s.runs.map((r) => r[m.key]))]))]));
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A page that names these scripts as modulepreload links, first in its <head>
|
|
166
|
+
* (where the preload scanner finds them with the entry): a loader's hint.
|
|
167
|
+
* @param {string} html @param {string[]} hrefs
|
|
168
|
+
*/
|
|
169
|
+
export const withPreloads = (html, hrefs) => {
|
|
170
|
+
if (!hrefs.length) return html;
|
|
171
|
+
const links = hrefs.map((h) => `<link rel="modulepreload" href="${h.replace(/"/g, '"')}">`).join('');
|
|
172
|
+
return /<head(\s[^>]*)?>/i.test(html) ? html.replace(/<head(\s[^>]*)?>/i, (h) => h + links) : links + html;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** A build file's name without its content hash (`luma-chunks/lbx-listing-BgR9IMfP.js` -> `luma-chunks/lbx-listing.js`): the same chunk in another build. */
|
|
176
|
+
export const chunkBase = (file) => file.replace(/-[\w-]{8}\.js$/, '.js');
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The previous build's version of `file` (Use-As-Dictionary: the browser
|
|
180
|
+
* offers what it has cached of the same name pattern): the same path, else
|
|
181
|
+
* the chunk of the same name under another hash; null for a new file.
|
|
182
|
+
* @param {string} file @param {string[]} previous
|
|
183
|
+
*/
|
|
184
|
+
export const previousOf = (file, previous) => (previous.includes(file) ? file : (previous.find((p) => chunkBase(p) === chunkBase(file)) ?? null));
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// yarn luma footprint: what Luma itself costs a storefront's pages, page type by
|
|
3
|
+
// page type, measured on the integration's build. Run from the integration
|
|
4
|
+
// repository (Luma as the @luigisbox/luma package, or an older luma/ submodule):
|
|
5
|
+
//
|
|
6
|
+
// yarn luma footprint (the integration's one `luma` script: bin/luma.mjs)
|
|
7
|
+
// yarn luma footprint [--runs 5] [--cpu 4] [--json out.json] [--no-build]
|
|
8
|
+
//
|
|
9
|
+
// - The entry (dist/luma-integration.js): its size (brotli, gzip, raw) and the
|
|
10
|
+
// round trips it takes on the CDN's connection after the platform's loader
|
|
11
|
+
// (footprint/model.mjs roundTrips: TCP slow start; --loader-bytes).
|
|
12
|
+
// - Per scenario: the pages (luma.config.mjs `perf.pages`, else home and
|
|
13
|
+
// its fixtures' category, product and search) and "typing" (home, then the
|
|
14
|
+
// fixtures' query typed into the search input): the Luma scripts loaded and
|
|
15
|
+
// their bytes (the build's files compressed here, as the CDN serves them),
|
|
16
|
+
// the longest chain of them (page = 0: the entry is 1), Luma's main-thread
|
|
17
|
+
// time (CPU profile: samples with a frame of ours on the stack) and compile
|
|
18
|
+
// time (trace), API requests, reconcile passes, and when it was ready (our
|
|
19
|
+
// listing shown, suggestions on screen; a listing still loading after --settle
|
|
20
|
+
// is waited for). Cold (a new profile) and warm (the same page again in that
|
|
21
|
+
// tab: HTTP cache, token, stored rules), medians of --runs.
|
|
22
|
+
// - --rtt: a round trip of latency for each of the page's and the build's
|
|
23
|
+
// responses (concurrent requests overlap, as on one HTTP/2 connection), so
|
|
24
|
+
// the chain of scripts costs what it would on a network. --preload: the page
|
|
25
|
+
// names the scenario's own chunks as modulepreload links (found by a run
|
|
26
|
+
// before), as a platform loader that knew them could: what that would save.
|
|
27
|
+
//
|
|
28
|
+
// The page is the integration's stub page (luma.config.mjs `stubPage`) on the
|
|
29
|
+
// storefront's host name (LBX_HOST), with Luma as a customer installs it (the
|
|
30
|
+
// early-hold snippet, then the build: parity/hold.mjs); the API is live. Not
|
|
31
|
+
// the storefront's own scripts: this is Luma's cost, not the page's (that is
|
|
32
|
+
// `yarn luma perf`, which needs the server-side rendering rig, not in the published package). Chromium at --cpu x CPU throttling.
|
|
33
|
+
import { execFileSync } from 'node:child_process';
|
|
34
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
35
|
+
import { dirname, extname, join, normalize, relative, resolve } from 'node:path';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
import { brotliCompressSync, constants, gzipSync, zstdCompressSync } from 'node:zlib';
|
|
38
|
+
import { chainDepth, compileMs, firstFlight, formatReport, isLumaScript, previousOf, profileMs, roundTrips, summarize, withPreloads } from './model.mjs';
|
|
39
|
+
|
|
40
|
+
const LUMA = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
41
|
+
const USAGE = `usage: yarn luma footprint [options]
|
|
42
|
+
|
|
43
|
+
--runs N runs per scenario and visit (default 5)
|
|
44
|
+
--cpu N CPU throttling (default 4)
|
|
45
|
+
--settle MS time after the load event before reading the page (default 4000)
|
|
46
|
+
--only a,b scenarios: the perf pages (home, category, search, product) and typing
|
|
47
|
+
--loader-bytes N bytes the platform's loader took on the same connection before the entry (default 0)
|
|
48
|
+
--rtt MS latency per response of the page and the build (default 0)
|
|
49
|
+
--preload the page preloads the chunks its scenario loads (not typing's): a loader's hint
|
|
50
|
+
--dictionary DIR a previous build (its dist/): each script also compressed with its previous
|
|
51
|
+
version as the dictionary (zstd), as a returning visitor gets it after a deploy
|
|
52
|
+
--json FILE also write the runs and medians as JSON
|
|
53
|
+
--no-build use the integration's dist/ as it is
|
|
54
|
+
--headed show the browser
|
|
55
|
+
--site-dir DIR the integration repository (default: the current directory, LBX_SITE_DIR)`;
|
|
56
|
+
|
|
57
|
+
const args = process.argv.slice(2);
|
|
58
|
+
const opt = { runs: 5, cpu: 4, settle: 4000, only: [], loader: 0, rtt: 0, preload: false, dictionary: '', json: '', build: true, headed: false, siteDir: '' };
|
|
59
|
+
const die = (msg) => {
|
|
60
|
+
console.error(msg);
|
|
61
|
+
process.exit(2);
|
|
62
|
+
};
|
|
63
|
+
for (let i = 0; i < args.length; i++) {
|
|
64
|
+
const a = args[i];
|
|
65
|
+
const next = () => {
|
|
66
|
+
const v = args[++i];
|
|
67
|
+
if (v === undefined || v.startsWith('--')) die(`${a} needs a value\n\n${USAGE}`);
|
|
68
|
+
return v;
|
|
69
|
+
};
|
|
70
|
+
if (a === '--help' || a === '-h') {
|
|
71
|
+
console.log(USAGE);
|
|
72
|
+
process.exit(0);
|
|
73
|
+
} else if (a === '--runs') opt.runs = Number(next());
|
|
74
|
+
else if (a === '--cpu') opt.cpu = Number(next());
|
|
75
|
+
else if (a === '--settle') opt.settle = Number(next());
|
|
76
|
+
else if (a === '--only') opt.only = next().split(',').map((s) => s.trim()).filter(Boolean);
|
|
77
|
+
else if (a === '--loader-bytes') opt.loader = Number(next());
|
|
78
|
+
else if (a === '--rtt') opt.rtt = Number(next());
|
|
79
|
+
else if (a === '--preload') opt.preload = true;
|
|
80
|
+
else if (a === '--dictionary') opt.dictionary = resolve(next());
|
|
81
|
+
else if (a === '--json') opt.json = next();
|
|
82
|
+
else if (a === '--no-build') opt.build = false;
|
|
83
|
+
else if (a === '--headed') opt.headed = true;
|
|
84
|
+
else if (a === '--site-dir') opt.siteDir = next();
|
|
85
|
+
else die(`unknown option ${a}\n\n${USAGE}`);
|
|
86
|
+
}
|
|
87
|
+
const SITE_DIR = resolve(opt.siteDir || process.env.LBX_SITE_DIR || process.cwd());
|
|
88
|
+
if (!existsSync(join(SITE_DIR, 'integration'))) die(`${SITE_DIR} has no integration/ folder: run it from the integration repository (or --site-dir).`);
|
|
89
|
+
process.env.LBX_SITE_DIR = SITE_DIR;
|
|
90
|
+
|
|
91
|
+
const { chromium } = await import('playwright-core');
|
|
92
|
+
const { loadConfig } = await import('../bin/config.mjs');
|
|
93
|
+
const config = await loadConfig(SITE_DIR);
|
|
94
|
+
const { host: HOST, stubPage } = config;
|
|
95
|
+
const { SNIPPET, holdInit } = await import('../parity/hold.mjs');
|
|
96
|
+
|
|
97
|
+
const F = config.fixtures;
|
|
98
|
+
const ORIGIN = `https://${HOST}`;
|
|
99
|
+
const DIST = join(SITE_DIR, 'dist');
|
|
100
|
+
const PREFIX = '/__lbx/footprint/';
|
|
101
|
+
const BUNDLE = `${ORIGIN}${PREFIX}luma-integration.js`;
|
|
102
|
+
|
|
103
|
+
if (opt.build) {
|
|
104
|
+
process.stderr.write(`building ${relative(process.cwd(), SITE_DIR) || '.'} (yarn build)… `);
|
|
105
|
+
try {
|
|
106
|
+
execFileSync('yarn', ['build'], { cwd: SITE_DIR, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
107
|
+
process.stderr.write('done\n');
|
|
108
|
+
} catch (e) {
|
|
109
|
+
process.stderr.write('failed\n');
|
|
110
|
+
die(`yarn build failed:\n${e.stdout ?? ''}${e.stderr ?? ''}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!existsSync(join(DIST, 'luma-integration.js'))) die(`${DIST}/luma-integration.js is missing: run yarn build (or drop --no-build).`);
|
|
114
|
+
|
|
115
|
+
// ------------------------------------------------------------------ the build's files, as the CDN serves them
|
|
116
|
+
|
|
117
|
+
const walk = (dir, out = []) => {
|
|
118
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) (e.isDirectory() ? walk : (p) => out.push(p))(join(dir, e.name), out);
|
|
119
|
+
return out;
|
|
120
|
+
};
|
|
121
|
+
const sizes = new Map();
|
|
122
|
+
for (const f of walk(DIST)) {
|
|
123
|
+
const b = readFileSync(f);
|
|
124
|
+
sizes.set(relative(DIST, f).split('\\').join('/'), {
|
|
125
|
+
raw: b.length,
|
|
126
|
+
gzip: gzipSync(b, { level: 9 }).length,
|
|
127
|
+
br: brotliCompressSync(b, { params: { [constants.BROTLI_PARAM_QUALITY]: 11 } }).length,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// --dictionary: each file compressed with its previous version as the dictionary (a new file: its brotli size).
|
|
131
|
+
if (opt.dictionary) {
|
|
132
|
+
if (!existsSync(join(opt.dictionary, 'luma-integration.js'))) die(`--dictionary ${opt.dictionary}: no luma-integration.js there (a build's dist/)`);
|
|
133
|
+
if (typeof zstdCompressSync !== 'function') die('--dictionary needs zstd in node:zlib (Node 22.15+)');
|
|
134
|
+
const previous = walk(opt.dictionary).map((f) => relative(opt.dictionary, f).split('\\').join('/'));
|
|
135
|
+
for (const [f, sz] of sizes) {
|
|
136
|
+
const prev = previousOf(f, previous);
|
|
137
|
+
sz.dict = prev ? zstdCompressSync(readFileSync(join(DIST, f)), { params: { [constants.ZSTD_c_compressionLevel]: 19 }, dictionary: readFileSync(join(opt.dictionary, prev)) }).length : sz.br;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const entrySize = sizes.get('luma-integration.js');
|
|
141
|
+
const entry = {
|
|
142
|
+
...entrySize,
|
|
143
|
+
trips: roundTrips(entrySize.br, { before: opt.loader }),
|
|
144
|
+
tripsGzip: roundTrips(entrySize.gzip, { before: opt.loader }),
|
|
145
|
+
flight: firstFlight({ before: opt.loader }),
|
|
146
|
+
};
|
|
147
|
+
const fileOf = (url) => {
|
|
148
|
+
const p = new URL(url).pathname;
|
|
149
|
+
return p.startsWith(PREFIX) ? decodeURIComponent(p.slice(PREFIX.length)) : null;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// ------------------------------------------------------------------ scenarios
|
|
153
|
+
|
|
154
|
+
const pages = config.perf?.pages ?? Object.fromEntries([['home', '/'], ['category', F.category], ['search', F.query ? `/search?q=${encodeURIComponent(F.query)}` : null], ['product', F.product]].filter(([, p]) => p));
|
|
155
|
+
const scenarios = [...Object.entries(pages).map(([name, path]) => ({ name, path })), ...(F.input && F.query ? [{ name: 'typing', path: '/', type: F.query }] : [])].filter((s) => !opt.only.length || opt.only.includes(s.name));
|
|
156
|
+
if (!scenarios.length) die('no scenario to run (luma.config.mjs perf.pages or fixtures; --only)');
|
|
157
|
+
|
|
158
|
+
// ------------------------------------------------------------------ browser
|
|
159
|
+
|
|
160
|
+
const launch = async () => {
|
|
161
|
+
const o = { headless: !opt.headed };
|
|
162
|
+
const tries = [...(process.env.LBX_CHROMIUM_PATH ? [{ ...o, executablePath: process.env.LBX_CHROMIUM_PATH }] : []), { ...o, channel: 'chromium' }, { ...o, channel: 'chrome' }, o];
|
|
163
|
+
let err;
|
|
164
|
+
for (const t of tries) {
|
|
165
|
+
try {
|
|
166
|
+
return await chromium.launch(t);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
err = e;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
die(`No Chromium to run (set LBX_CHROMIUM_PATH).\n${err?.message?.split('\n')[0] ?? ''}`);
|
|
172
|
+
};
|
|
173
|
+
const TYPES = { '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json' };
|
|
174
|
+
|
|
175
|
+
/** Per page (path and query): the build's files it names as modulepreload links (--preload). */
|
|
176
|
+
const preloads = new Map();
|
|
177
|
+
|
|
178
|
+
const newContext = async (browser) => {
|
|
179
|
+
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, serviceWorkers: 'block' });
|
|
180
|
+
// The site's own Luigi's Box scripts never run: ours is the build measured.
|
|
181
|
+
await ctx.route(/^https:\/\/(cdn\.luigisbox\.(com|tech)|cdn\.eu1\.luigisbox\.ai|scripts\.luigisbox\.(com|tech))\//, (r) => r.abort('blockedbyclient'));
|
|
182
|
+
await ctx.route(`${ORIGIN}/**`, async (route) => {
|
|
183
|
+
const req = route.request();
|
|
184
|
+
const url = new URL(req.url());
|
|
185
|
+
if (opt.rtt) await sleep(opt.rtt);
|
|
186
|
+
if (url.pathname.startsWith(PREFIX)) {
|
|
187
|
+
const file = normalize(join(DIST, decodeURIComponent(url.pathname.slice(PREFIX.length))));
|
|
188
|
+
if (!file.startsWith(DIST) || !existsSync(file) || !statSync(file).isFile()) return route.fulfill({ status: 404, body: '' });
|
|
189
|
+
return route.fulfill({ status: 200, body: readFileSync(file), headers: { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' } });
|
|
190
|
+
}
|
|
191
|
+
if (req.resourceType() === 'document') return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: withPreloads(await stubPage(url), (preloads.get(url.pathname + url.search) ?? []).map((f) => PREFIX + f)) });
|
|
192
|
+
return route.fulfill({ status: 404, body: '' });
|
|
193
|
+
});
|
|
194
|
+
await ctx.addInitScript(holdInit, { origin: ORIGIN, bundle: BUNDLE, snippet: SNIPPET, tag: 'footprint' });
|
|
195
|
+
return ctx;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/** In the page: when an autocomplete row is on screen (through shadow roots). */
|
|
199
|
+
function suggestionsShown() {
|
|
200
|
+
const find = (root) => {
|
|
201
|
+
if (root.querySelector?.('[data-lbx-part="ac-row"], [role="option"]')) return true;
|
|
202
|
+
for (const el of root.querySelectorAll?.('*') ?? []) if (el.shadowRoot && find(el.shadowRoot)) return true;
|
|
203
|
+
return false;
|
|
204
|
+
};
|
|
205
|
+
return find(document);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
209
|
+
|
|
210
|
+
/** One page load of a scenario in `page`, measured. */
|
|
211
|
+
const measure = async (browser, page, s) => {
|
|
212
|
+
const cdp = await page.context().newCDPSession(page);
|
|
213
|
+
await cdp.send('Emulation.setCPUThrottlingRate', { rate: opt.cpu });
|
|
214
|
+
const requests = [];
|
|
215
|
+
await cdp.send('Network.enable');
|
|
216
|
+
cdp.on('Network.requestWillBeSent', (e) => {
|
|
217
|
+
const i = e.initiator ?? {};
|
|
218
|
+
const frames = [];
|
|
219
|
+
for (let st = i.stack; st; st = st.parent) frames.push(...(st.callFrames ?? []));
|
|
220
|
+
const by = frames.find((f) => f.url)?.url ?? i.url ?? '';
|
|
221
|
+
requests.push({ url: e.request.url, initiator: by });
|
|
222
|
+
});
|
|
223
|
+
await cdp.send('Profiler.enable');
|
|
224
|
+
await cdp.send('Profiler.setSamplingInterval', { interval: 100 });
|
|
225
|
+
await browser.startTracing(page, { categories: ['v8', 'disabled-by-default-v8.compile', 'devtools.timeline'] });
|
|
226
|
+
await cdp.send('Profiler.start');
|
|
227
|
+
await page.goto(`${ORIGIN}${s.path}`, { waitUntil: 'load', timeout: 60000 });
|
|
228
|
+
let readyMs = null;
|
|
229
|
+
if (s.type) {
|
|
230
|
+
await sleep(opt.settle);
|
|
231
|
+
const input = page.locator(F.input).first();
|
|
232
|
+
const t0 = await page.evaluate(() => performance.now());
|
|
233
|
+
await input.click();
|
|
234
|
+
await input.pressSequentially(s.type, { delay: 120 });
|
|
235
|
+
const shown = await page.waitForFunction(suggestionsShown, null, { timeout: 15000, polling: 16 }).then(() => true, () => false);
|
|
236
|
+
if (shown) readyMs = (await page.evaluate(() => performance.now())) - t0;
|
|
237
|
+
await sleep(1500);
|
|
238
|
+
} else {
|
|
239
|
+
await sleep(opt.settle);
|
|
240
|
+
// A listing still loading (held, not yet ours or handed back): its end, within 20 s.
|
|
241
|
+
await page
|
|
242
|
+
.waitForFunction(() => {
|
|
243
|
+
const t = (window.__lbx?.health?.timeline ?? []).map(([, w]) => w);
|
|
244
|
+
const held = t.some((w) => / takeover (hold|native)$/.test(w));
|
|
245
|
+
return !held || t.some((w) => / takeover (ours|released)$/.test(w));
|
|
246
|
+
}, null, { timeout: 20000, polling: 100 })
|
|
247
|
+
.catch(() => {});
|
|
248
|
+
await sleep(500);
|
|
249
|
+
}
|
|
250
|
+
const { profile } = await cdp.send('Profiler.stop');
|
|
251
|
+
const trace = JSON.parse((await browser.stopTracing()).toString('utf8'));
|
|
252
|
+
const lbx = await page.evaluate(() => {
|
|
253
|
+
const l = window.__lbx;
|
|
254
|
+
return l ? { apiCalls: l.health?.apiCalls ?? 0, reconciles: l.health?.reconciles ?? 0, errors: l.health?.errors ?? [], timeline: l.health?.timeline ?? [] } : null;
|
|
255
|
+
});
|
|
256
|
+
await cdp.detach();
|
|
257
|
+
if (!s.type && lbx) {
|
|
258
|
+
const shown = lbx.timeline.find(([, what]) => / takeover ours$/.test(what));
|
|
259
|
+
readyMs = shown ? shown[0] : null;
|
|
260
|
+
}
|
|
261
|
+
const files = [...new Set(requests.map((r) => r.url).filter(isLumaScript))].map(fileOf).filter(Boolean);
|
|
262
|
+
const sum = (k) => files.reduce((a, f) => a + (sizes.get(f)?.[k] ?? 0), 0);
|
|
263
|
+
return {
|
|
264
|
+
metrics: {
|
|
265
|
+
requests: files.length,
|
|
266
|
+
br: sum('br'),
|
|
267
|
+
gzip: sum('gzip'),
|
|
268
|
+
chain: chainDepth(requests),
|
|
269
|
+
mainMs: profileMs(profile),
|
|
270
|
+
compileMs: compileMs(trace.traceEvents ?? []),
|
|
271
|
+
apiCalls: requests.filter((r) => /^https:\/\/(auth|api)\.[\w-]+\.luigisbox\.ai\//.test(r.url)).length,
|
|
272
|
+
reconciles: lbx?.reconciles ?? null,
|
|
273
|
+
readyMs,
|
|
274
|
+
dict: opt.dictionary ? sum('dict') : null,
|
|
275
|
+
},
|
|
276
|
+
files,
|
|
277
|
+
errors: lbx?.errors ?? ['window.__lbx missing: Luma did not boot'],
|
|
278
|
+
};
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const browser = await launch();
|
|
282
|
+
const results = { cold: [], warm: [] };
|
|
283
|
+
const errors = new Set();
|
|
284
|
+
const filesBy = {};
|
|
285
|
+
for (const s of scenarios) {
|
|
286
|
+
const cold = { name: s.name, runs: [] };
|
|
287
|
+
const warm = { name: s.name, runs: [] };
|
|
288
|
+
if (opt.preload && !s.type) {
|
|
289
|
+
// The scenario's chunks, from a run not counted.
|
|
290
|
+
process.stderr.write(`\r${s.name}: finding its chunks `);
|
|
291
|
+
const ctx = await newContext(browser);
|
|
292
|
+
try {
|
|
293
|
+
const { files } = await measure(browser, await ctx.newPage(), s);
|
|
294
|
+
preloads.set(s.path, files.filter((f) => f !== 'luma-integration.js'));
|
|
295
|
+
} finally {
|
|
296
|
+
await ctx.close();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
for (let i = 0; i < opt.runs; i++) {
|
|
300
|
+
process.stderr.write(`\r${s.name}: run ${i + 1}/${opt.runs} `);
|
|
301
|
+
const ctx = await newContext(browser);
|
|
302
|
+
try {
|
|
303
|
+
const page = await ctx.newPage();
|
|
304
|
+
const c = await measure(browser, page, s);
|
|
305
|
+
cold.runs.push(c.metrics);
|
|
306
|
+
filesBy[s.name] = c.files;
|
|
307
|
+
c.errors.forEach((e) => errors.add(`${s.name}: ${e}`));
|
|
308
|
+
// The same tab (sessionStorage is per tab: the token, stashes), the next page view.
|
|
309
|
+
await page.goto('about:blank');
|
|
310
|
+
const w = await measure(browser, page, s);
|
|
311
|
+
warm.runs.push(w.metrics);
|
|
312
|
+
w.errors.forEach((e) => errors.add(`${s.name} (warm): ${e}`));
|
|
313
|
+
} finally {
|
|
314
|
+
await ctx.close();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
results.cold.push(cold);
|
|
318
|
+
results.warm.push(warm);
|
|
319
|
+
}
|
|
320
|
+
process.stderr.write('\r');
|
|
321
|
+
await browser.close();
|
|
322
|
+
|
|
323
|
+
const meta = { integration: SITE_DIR, host: HOST, runs: String(opt.runs), cpu: `${opt.cpu}x`, ...(opt.rtt ? { rtt: `${opt.rtt} ms` } : {}), ...(opt.preload ? { preload: 'the scenario\'s chunks (a loader\'s hint)' } : {}), ...(opt.dictionary ? { dictionary: `${opt.dictionary} (the entry with it: ${(entrySize.dict / 1000).toFixed(1)} kB)` } : {}), page: config.ownStubPage ? "the integration's stub page" : "Luma's neutral stub page" };
|
|
324
|
+
console.log(formatReport({ entry, scenarios: results.cold, meta: { ...meta, visit: 'cold (a new profile)' } }));
|
|
325
|
+
console.log(`\n${formatReport({ entry, scenarios: results.warm, meta: { visit: 'warm (the same page again, same tab)' } }).split('\n').slice(2).join('\n')}`);
|
|
326
|
+
console.log(`\nscripts per scenario (cold): ${Object.entries(filesBy).map(([k, v]) => `\n ${k}: ${v.join(', ')}`).join('')}`);
|
|
327
|
+
if (errors.size) console.log(`\nLuma errors:\n ${[...errors].join('\n ')}`);
|
|
328
|
+
if (opt.json) {
|
|
329
|
+
writeFileSync(opt.json, `${JSON.stringify({ meta, entry, files: Object.fromEntries(sizes), scripts: filesBy, cold: summarize(results.cold), warm: summarize(results.warm), runs: results }, null, 2)}\n`);
|
|
330
|
+
console.log(`\nwritten: ${opt.json}`);
|
|
331
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luigisbox/luma",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Luma: Luigi's Box frontend integrations (autocomplete, search, category listings, recommenders) on the Luigi's Box AI API. Pre-release.",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
7
|
+
"packageManager": "yarn@4.4.1",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=24"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"luma": "bin/luma.mjs"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"!src/ssr.ts",
|
|
17
|
+
"tsconfig.json",
|
|
18
|
+
"build/vite.mjs",
|
|
19
|
+
"build/baseline.mjs",
|
|
20
|
+
"build/baseline.d.mts",
|
|
21
|
+
"build/tooling.mjs",
|
|
22
|
+
"bin",
|
|
23
|
+
"a11y",
|
|
24
|
+
"footprint",
|
|
25
|
+
"tasks",
|
|
26
|
+
"parity",
|
|
27
|
+
"chrome",
|
|
28
|
+
"snippet/early-hold.js",
|
|
29
|
+
"snippet/early-hold.min.js",
|
|
30
|
+
"docs/ACCESSIBILITY.md",
|
|
31
|
+
"docs/INTEGRATION-PERFORMANCE.md",
|
|
32
|
+
"docs/PARITY.md"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@jridgewell/trace-mapping": "0.3.31",
|
|
39
|
+
"@lit-labs/ssr-client": "^1.1.8",
|
|
40
|
+
"@typescript-eslint/parser": "8.71.0",
|
|
41
|
+
"axe-core": "4.13.0",
|
|
42
|
+
"eslint": "10.11.0",
|
|
43
|
+
"eslint-plugin-baseline-js": "0.7.2",
|
|
44
|
+
"lit-html": "^3.3.3",
|
|
45
|
+
"parse5": "8.0.1",
|
|
46
|
+
"pixelmatch": "7.2.0",
|
|
47
|
+
"playwright-core": "1.63.0",
|
|
48
|
+
"pngjs": "7.0.0",
|
|
49
|
+
"stylelint": "17.16.0",
|
|
50
|
+
"stylelint-plugin-use-baseline": "1.4.6",
|
|
51
|
+
"terser": "5.36.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"typescript": "^6.0.3",
|
|
55
|
+
"vite": "^7.3.6"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"postpublish": "node build/publish-manifest.mjs restore"
|
|
59
|
+
}
|
|
60
|
+
}
|