@luigisbox/luma 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/LICENSE.md +90 -0
  2. package/README.md +615 -2
  3. package/a11y/audit.mjs +735 -0
  4. package/a11y/axe.mjs +73 -0
  5. package/a11y/report.mjs +347 -0
  6. package/a11y/run.mjs +256 -0
  7. package/a11y/wcag.mjs +233 -0
  8. package/bin/config.mjs +60 -0
  9. package/bin/luma.mjs +99 -0
  10. package/bin/paths.mjs +11 -0
  11. package/bin/tools.mjs +43 -0
  12. package/build/baseline.d.mts +8 -0
  13. package/build/baseline.mjs +167 -0
  14. package/build/tooling.mjs +102 -0
  15. package/build/vite.mjs +507 -0
  16. package/chrome/block.mjs +8 -0
  17. package/chrome/run.mjs +150 -0
  18. package/docs/ACCESSIBILITY.md +501 -0
  19. package/docs/INTEGRATION-PERFORMANCE.md +213 -0
  20. package/docs/PARITY.md +946 -0
  21. package/footprint/model.mjs +184 -0
  22. package/footprint/run.mjs +331 -0
  23. package/package.json +58 -4
  24. package/parity/alignment.mjs +109 -0
  25. package/parity/archive.mjs +223 -0
  26. package/parity/args.mjs +137 -0
  27. package/parity/behaviour.mjs +379 -0
  28. package/parity/compare.mjs +985 -0
  29. package/parity/engine.mjs +848 -0
  30. package/parity/hints.mjs +132 -0
  31. package/parity/hold.mjs +54 -0
  32. package/parity/init.mjs +703 -0
  33. package/parity/map.mjs +640 -0
  34. package/parity/owner.mjs +170 -0
  35. package/parity/pairing.mjs +110 -0
  36. package/parity/pixels.mjs +33 -0
  37. package/parity/probe.mjs +1239 -0
  38. package/parity/report.mjs +236 -0
  39. package/parity/run.mjs +267 -0
  40. package/snippet/early-hold.js +64 -0
  41. package/snippet/early-hold.min.js +1 -0
  42. package/src/analytics/chain.ts +137 -0
  43. package/src/analytics/collect.ts +64 -0
  44. package/src/analytics/datalayer.ts +67 -0
  45. package/src/analytics/from-ga4.ts +68 -0
  46. package/src/analytics/from-luma.ts +21 -0
  47. package/src/analytics/index.ts +24 -0
  48. package/src/analytics/outbox.ts +118 -0
  49. package/src/analytics/page.ts +46 -0
  50. package/src/analytics/pageview.ts +82 -0
  51. package/src/analytics/ref.ts +19 -0
  52. package/src/analytics/setup.ts +41 -0
  53. package/src/analytics/start.ts +53 -0
  54. package/src/analytics/triggers.ts +239 -0
  55. package/src/analytics/user.ts +12 -0
  56. package/src/analytics/wire.ts +67 -0
  57. package/src/api/ai.ts +98 -0
  58. package/src/api/index.ts +87 -0
  59. package/src/api/token-key.ts +42 -0
  60. package/src/api/token.ts +312 -0
  61. package/src/core/a11y-keys.ts +60 -0
  62. package/src/core/a11y.ts +160 -0
  63. package/src/core/boot-state.ts +6 -0
  64. package/src/core/color.ts +42 -0
  65. package/src/core/context.ts +108 -0
  66. package/src/core/early-rules.ts +96 -0
  67. package/src/core/entries.ts +25 -0
  68. package/src/core/fold.ts +7 -0
  69. package/src/core/health.ts +26 -0
  70. package/src/core/hold.ts +74 -0
  71. package/src/core/i18n.ts +66 -0
  72. package/src/core/image-load.ts +102 -0
  73. package/src/core/image-tags.ts +26 -0
  74. package/src/core/images.ts +170 -0
  75. package/src/core/integration.ts +126 -0
  76. package/src/core/kept.ts +25 -0
  77. package/src/core/listings.ts +100 -0
  78. package/src/core/locales.ts +14 -0
  79. package/src/core/own.ts +49 -0
  80. package/src/core/page.ts +14 -0
  81. package/src/core/prefetch.ts +88 -0
  82. package/src/core/recommenders.ts +166 -0
  83. package/src/core/reconciler.ts +397 -0
  84. package/src/core/result.ts +73 -0
  85. package/src/core/routes.ts +115 -0
  86. package/src/core/scheduler.ts +83 -0
  87. package/src/core/sense.ts +107 -0
  88. package/src/core/speculation.ts +59 -0
  89. package/src/core/stash.ts +38 -0
  90. package/src/core/storage.ts +45 -0
  91. package/src/core/styles.ts +106 -0
  92. package/src/core/support.ts +12 -0
  93. package/src/core/takeover.ts +220 -0
  94. package/src/core/templates.ts +74 -0
  95. package/src/core/tiles.ts +137 -0
  96. package/src/core/turbo.ts +64 -0
  97. package/src/core/url.ts +164 -0
  98. package/src/core/widget-inits.ts +40 -0
  99. package/src/env.d.ts +3 -0
  100. package/src/lit.ts +11 -0
  101. package/src/locales/cs.json +94 -0
  102. package/src/locales/de.json +88 -0
  103. package/src/locales/en.json +88 -0
  104. package/src/main.ts +493 -0
  105. package/src/types.ts +778 -0
  106. package/src/widgets/autocomplete/index.ts +1387 -0
  107. package/src/widgets/autocomplete/style-autocomplete.css +266 -0
  108. package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
  109. package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
  110. package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
  111. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
  112. package/src/widgets/listing/element.ts +1772 -0
  113. package/src/widgets/listing/flow.ts +30 -0
  114. package/src/widgets/listing/instant.ts +133 -0
  115. package/src/widgets/listing/intent.ts +193 -0
  116. package/src/widgets/listing/query.ts +298 -0
  117. package/src/widgets/listing/stash.ts +97 -0
  118. package/src/widgets/listing/state.ts +131 -0
  119. package/src/widgets/listing/view.ts +257 -0
  120. package/src/widgets/plp/index.ts +11 -0
  121. package/src/widgets/plp/route.ts +263 -0
  122. package/src/widgets/recommender/index.ts +261 -0
  123. package/src/widgets/recommender/style-recommender.css +26 -0
  124. package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
  125. package/src/widgets/recommender/template-recommender-popup.ts +12 -0
  126. package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
  127. package/src/widgets/search/assets.ts +7 -0
  128. package/src/widgets/search/first.ts +15 -0
  129. package/src/widgets/search/form-url.ts +59 -0
  130. package/src/widgets/search/in-place.ts +226 -0
  131. package/src/widgets/search/index.ts +16 -0
  132. package/src/widgets/search/range.ts +260 -0
  133. package/src/widgets/search/route.ts +32 -0
  134. package/src/widgets/search/speculate.ts +202 -0
  135. package/src/widgets/search/style-search.css +65 -0
  136. package/src/widgets/search/template-search-box.ts +17 -0
  137. package/src/widgets/search/template-search-facets-default.ts +73 -0
  138. package/src/widgets/search/template-search-header.ts +14 -0
  139. package/src/widgets/search/template-search-pagination.ts +12 -0
  140. package/src/widgets/search/template-search-range-default.ts +48 -0
  141. package/src/widgets/search/template-search-result-default.ts +13 -0
  142. package/src/widgets/search/template-search-results-default.ts +8 -0
  143. package/tasks/model.mjs +116 -0
  144. package/tasks/run.mjs +305 -0
  145. package/tsconfig.json +14 -0
package/a11y/run.mjs ADDED
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env node
2
+ // yarn luma a11y [path...]: does this integration pass WCAG 2.2 AA (EN 301 549
3
+ // V4.1.1, the European Accessibility Act)? Run from the integration
4
+ // repository (Luma as the @luigisbox/luma package, or an older luma/ submodule):
5
+ //
6
+ // yarn luma a11y (the integration's one `luma` script: bin/luma.mjs)
7
+ // yarn luma a11y the home page (autocomplete, then the search page
8
+ // through the header's form), the fixtures' category and
9
+ // product pages (luma.config.mjs fixtures)
10
+ // yarn luma a11y /search?q=shoe /sneakers /some-product
11
+ //
12
+ // It builds the integration (yarn build), opens Chromium on the storefront's
13
+ // host name (LBX_HOST) with Luma as a customer installs it (the early-hold
14
+ // snippet first in <head>, then the build, as the platform's loader would
15
+ // import it), and drives the real widgets (a11y/audit.mjs). The page is the
16
+ // integration's stub page (luma.config.mjs `stubPage`) by default, or the live
17
+ // storefront (--origin live, or LBX_ORIGIN=https://…); a storefront that
18
+ // answers with a bot challenge is never worked around: the run falls back to
19
+ // the stub page and says so. Exit code: 0 pass, 1 failures, 2 nothing could
20
+ // be checked (or the run broke). docs/ACCESSIBILITY.md, "Checking an integration".
21
+ import { execFileSync } from 'node:child_process';
22
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
23
+ import { dirname, extname, join, normalize, relative, resolve } from 'node:path';
24
+ import { createRequire } from 'node:module';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const LUMA = resolve(dirname(fileURLToPath(import.meta.url)), '..');
28
+ const USAGE = `usage: yarn luma a11y [options] [path...]
29
+
30
+ path... storefront paths to check (default: /, then the fixtures' category and product pages)
31
+ --origin stub|live the integration's stub page (default) or the live storefront (LBX_ORIGIN)
32
+ --host <name> the storefront host name (LBX_HOST; default www.shop.example)
33
+ --query <text> what to type into the search input (default: luma.config.mjs fixtures.query)
34
+ --only <widgets> autocomplete,search,plp,recommender (comma-separated)
35
+ --dev load the integration from the dev server (https://localhost:8069, yarn dev) instead of building it
36
+ --no-build use the integration's dist/ as it is
37
+ --html [file.html] also write an HTML report (default a11y-report.html)
38
+ --json <file> also write the findings as JSON
39
+ --headed show the browser
40
+ --site-dir <dir> the integration repository (default: the current directory, LBX_SITE_DIR)`;
41
+
42
+ // ------------------------------------------------------------------ options
43
+
44
+ const args = process.argv.slice(2);
45
+ const opt = { paths: [], html: '', json: '', build: true, dev: false, headed: false, origin: '', host: '', query: '', only: [], siteDir: '' };
46
+ for (let i = 0; i < args.length; i++) {
47
+ const a = args[i];
48
+ const next = () => {
49
+ const v = args[++i];
50
+ if (v === undefined || v.startsWith('--')) die(`${a} needs a value\n\n${USAGE}`);
51
+ return v;
52
+ };
53
+ if (a === '--help' || a === '-h') {
54
+ console.log(USAGE);
55
+ process.exit(0);
56
+ } else if (a === '--origin') opt.origin = next();
57
+ else if (a === '--host') opt.host = next();
58
+ else if (a === '--query') opt.query = next();
59
+ else if (a === '--only') opt.only = next().split(',').map((s) => s.trim()).filter(Boolean);
60
+ else if (a === '--dev') opt.dev = true;
61
+ else if (a === '--no-build') opt.build = false;
62
+ else if (a === '--headed') opt.headed = true;
63
+ else if (a === '--json') opt.json = next();
64
+ else if (a === '--site-dir') opt.siteDir = next();
65
+ else if (a === '--html') opt.html = args[i + 1] && /\.html?$/i.test(args[i + 1]) ? args[++i] : 'a11y-report.html';
66
+ else if (a.startsWith('--html=')) opt.html = a.slice(7);
67
+ else if (a.startsWith('--json=')) opt.json = a.slice(7);
68
+ else if (a.startsWith('/')) opt.paths.push(a);
69
+ else die(`unknown argument "${a}" (paths start with "/")\n\n${USAGE}`);
70
+ }
71
+
72
+ function die(msg, code = 2) {
73
+ console.error(msg);
74
+ process.exit(code);
75
+ }
76
+
77
+ // The integration repository: --site-dir, LBX_SITE_DIR, the current directory, or luma/'s parent.
78
+ const SITE_DIR = [opt.siteDir, process.env.LBX_SITE_DIR, process.cwd(), dirname(LUMA)].filter(Boolean).map((d) => resolve(d)).find((d) => existsSync(join(d, 'integration')));
79
+ if (!SITE_DIR) die('No integration found: run yarn luma a11y in the integration repository (the folder with integration/), or pass --site-dir.');
80
+ process.env.LBX_SITE_DIR = SITE_DIR;
81
+ if (opt.host) process.env.LBX_HOST = opt.host;
82
+ const originOpt = opt.origin || process.env.LBX_ORIGIN || 'stub';
83
+ if (!/^(stub|live|https?:\/\/.+)$/.test(originOpt)) die(`--origin must be stub, live or a storefront URL, not "${originOpt}"`);
84
+
85
+ // Luma's dev dependencies (playwright-core, axe-core, parse5) live in luma/node_modules.
86
+ for (const dep of ['playwright-core', 'axe-core', 'parse5']) {
87
+ try {
88
+ await import(dep);
89
+ } catch {
90
+ die(`Luma's dependencies are not installed (${dep} is missing): run \`yarn install\`.`);
91
+ }
92
+ }
93
+ const { chromium } = await import('playwright-core');
94
+ const { loadConfig } = await import('../bin/config.mjs');
95
+ const config = await loadConfig(SITE_DIR);
96
+ const { host: HOST, stubPage } = config;
97
+ const { audit } = await import('./audit.mjs');
98
+ const { SNIPPET, holdInit } = await import('../parity/hold.mjs');
99
+ const { Results, formatHtml, formatText, summarize } = await import('./report.mjs');
100
+
101
+ const F = config.fixtures;
102
+ const ORIGIN = originOpt === 'stub' || originOpt === 'live' ? `https://${HOST}` : new URL(originOpt).origin;
103
+ const HOSTNAME = new URL(ORIGIN).host;
104
+ const QUERY = opt.query || F.query || 'a';
105
+ const paths = opt.paths.length ? opt.paths : ['/', F.category, F.product].filter((p, i, a) => p && a.indexOf(p) === i);
106
+ const DIST = join(SITE_DIR, 'dist');
107
+ const DEV = 'https://localhost:8069/integration/__integration.ts';
108
+ const BUNDLE = opt.dev ? DEV : `${ORIGIN}/__lbx/a11y/luma-integration.js`;
109
+
110
+ // ------------------------------------------------------------------ build
111
+
112
+ if (!opt.dev && opt.build) {
113
+ process.stderr.write(`building ${relative(process.cwd(), SITE_DIR) || '.'} (yarn build)… `);
114
+ try {
115
+ execFileSync('yarn', ['build'], { cwd: SITE_DIR, stdio: ['ignore', 'pipe', 'pipe'] });
116
+ process.stderr.write('done\n');
117
+ } catch (e) {
118
+ process.stderr.write('failed\n');
119
+ die(`yarn build failed:\n${e.stdout ?? ''}${e.stderr ?? ''}`);
120
+ }
121
+ }
122
+ if (!opt.dev && !existsSync(join(DIST, 'luma-integration.js'))) die(`${DIST}/luma-integration.js is missing: run yarn build (or drop --no-build).`);
123
+
124
+ // ------------------------------------------------------------------ browser
125
+
126
+ const launch = async () => {
127
+ const o = { headless: !opt.headed };
128
+ // Full Chromium in its new headless mode (as the browser suites), else the installed Chrome.
129
+ const tries = [...(process.env.LBX_CHROMIUM_PATH ? [{ ...o, executablePath: process.env.LBX_CHROMIUM_PATH }] : []), { ...o, channel: 'chromium' }, { ...o, channel: 'chrome' }];
130
+ let err;
131
+ for (const t of tries) {
132
+ try {
133
+ return await chromium.launch(t);
134
+ } catch (e) {
135
+ err = e;
136
+ }
137
+ }
138
+ die(`No Chromium to run: install Chrome, or \`yarn playwright-core install chromium\`, or set LBX_CHROMIUM_PATH.\n${err?.message?.split('\n')[0] ?? ''}`);
139
+ };
140
+
141
+ const TYPES = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.map': 'application/json', '.svg': 'image/svg+xml', '.woff2': 'font/woff2' };
142
+
143
+ /** A context on the storefront's host name: the build served at /__lbx/a11y/, Luigi's Box's CDNs blocked, the stub page or the live site. */
144
+ const newContext = async (browser, stub) => {
145
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, ignoreHTTPSErrors: opt.dev, serviceWorkers: 'block' });
146
+ // The site's own Luigi's Box scripts (the platform's loader, previous-generation widgets) never run: ours is the build under test.
147
+ await ctx.route(/^https:\/\/(cdn\.luigisbox\.(com|tech)|cdn\.eu1\.luigisbox\.ai|scripts\.luigisbox\.(com|tech))\//, (r) => r.abort('blockedbyclient'));
148
+ await ctx.route(`${ORIGIN}/**`, async (route) => {
149
+ const req = route.request();
150
+ const url = new URL(req.url());
151
+ if (url.pathname.startsWith('/__lbx/a11y/')) {
152
+ const file = normalize(join(DIST, decodeURIComponent(url.pathname.slice('/__lbx/a11y/'.length))));
153
+ if (!file.startsWith(DIST) || !existsSync(file) || !statSync(file).isFile()) return route.fulfill({ status: 404, body: '' });
154
+ return route.fulfill({ status: 200, body: readFileSync(file), headers: { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream', 'cache-control': 'no-store' } });
155
+ }
156
+ if (!stub) return route.fallback();
157
+ if (req.resourceType() === 'document') return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: await stubPage(url) });
158
+ return route.fulfill({ status: 404, body: '' });
159
+ });
160
+ // As a customer installs it, once, in the top document of the storefront: the early-hold snippet
161
+ // first in <head>, then the bundle as the platform's loader imports it (parity/hold.mjs).
162
+ await ctx.addInitScript(holdInit, { origin: ORIGIN, bundle: BUNDLE, snippet: SNIPPET, tag: 'a11y' });
163
+ return ctx;
164
+ };
165
+
166
+ /** A bot challenge, a block or a rate limit instead of the storefront's page. */
167
+ const challenged = async (res, page) => {
168
+ if (!res) return 'no response';
169
+ const h = res.headers();
170
+ if (h['cf-mitigated'] === 'challenge') return `a Cloudflare challenge (HTTP ${res.status()})`;
171
+ if (res.status() === 429) return 'HTTP 429 (rate limited)';
172
+ if (res.status() === 403 || res.status() === 503) {
173
+ const text = await page.content().catch(() => '');
174
+ if (/challenge-platform|cf-chl|Just a moment|captcha|Attention Required/i.test(text)) return `a bot challenge (HTTP ${res.status()})`;
175
+ return `HTTP ${res.status()}`;
176
+ }
177
+ return '';
178
+ };
179
+
180
+ const browser = await launch();
181
+ const results = new Results();
182
+ let stub = originOpt === 'stub';
183
+ let ctx = await newContext(browser, stub);
184
+ let page = await ctx.newPage();
185
+ let fellBack = '';
186
+ if (!stub) {
187
+ const res = await page.goto(`${ORIGIN}${paths[0]}`, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => null);
188
+ const why = await challenged(res, page);
189
+ if (why) {
190
+ fellBack = `The live storefront ${ORIGIN} answered with ${why}: not worked around; checked on the integration's stub page (luma.config.mjs stubPage) instead.`;
191
+ console.error(fellBack);
192
+ results.note(fellBack);
193
+ await ctx.close();
194
+ stub = true;
195
+ ctx = await newContext(browser, true);
196
+ page = await ctx.newPage();
197
+ }
198
+ }
199
+ if (stub && !config.ownStubPage) results.note('The integration has no luma.config.mjs stubPage: checked on Luma\'s neutral stub page, without the site\'s own markup and CSS (add one: Luma\'s README, "Tools for an integration repository").');
200
+
201
+ const errors = [];
202
+ page.on('pageerror', (e) => errors.push(e.message));
203
+ try {
204
+ await audit(page, {
205
+ results,
206
+ paths,
207
+ query: QUERY,
208
+ only: opt.only,
209
+ log: (s) => process.stderr.write(`${s}\n`),
210
+ goto: async (p) => {
211
+ const res = await page.goto(`${ORIGIN}${p}`, { waitUntil: 'load', timeout: 45000 }).catch((e) => (results.note(`${p}: ${e.message.split('\n')[0]}`), null));
212
+ if (!stub && res) {
213
+ const why = await challenged(res, page);
214
+ if (why) results.note(`${p}: the live storefront answered with ${why}`);
215
+ }
216
+ },
217
+ });
218
+ } catch (e) {
219
+ results.note(`the run stopped: ${e.stack ?? e}`);
220
+ process.exitCode = 2;
221
+ }
222
+ await browser.close();
223
+ for (const e of [...new Set(errors)].slice(0, 5)) results.note(`page error: ${e.slice(0, 300)}`);
224
+
225
+ // ------------------------------------------------------------------ report
226
+
227
+ const walk = (dir, out = []) => {
228
+ if (!existsSync(dir)) return out;
229
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
230
+ const p = join(dir, e.name);
231
+ if (e.isDirectory()) walk(p, out);
232
+ else if (/^(template-.*\.ts|style-.*\.css|_init\.ts)$/.test(e.name)) out.push(p);
233
+ }
234
+ return out;
235
+ };
236
+ // The integration's templates and styles first, then Luma's built-in ones (a node from a built-in template).
237
+ const files = [...walk(join(SITE_DIR, 'integration')), ...walk(join(LUMA, 'src/widgets'))].map((p) => ({ path: relative(SITE_DIR, p), text: readFileSync(p, 'utf8') }));
238
+ const summary = summarize(results, { files });
239
+ const meta = {
240
+ integration: SITE_DIR,
241
+ page: stub ? `${ORIGIN} (the integration's stub page${config.ownStubPage ? '' : ': Luma\'s neutral one'})` : `${ORIGIN} (live)`,
242
+ bundle: opt.dev ? DEV : 'dist/ (yarn build)',
243
+ axe: `axe-core ${JSON.parse(readFileSync(createRequire(import.meta.url).resolve('axe-core/package.json'), 'utf8')).version}, WCAG 2.0–2.2 A/AA`,
244
+ date: new Date().toISOString().slice(0, 16).replace('T', ' '),
245
+ };
246
+ console.log(formatText(summary, { color: process.stdout.isTTY && !process.env.NO_COLOR, meta }));
247
+ if (opt.html) {
248
+ const out = resolve(opt.html);
249
+ writeFileSync(out, formatHtml(summary, { meta }));
250
+ console.log(`\nHTML report: ${out}`);
251
+ }
252
+ if (opt.json) {
253
+ writeFileSync(resolve(opt.json), JSON.stringify({ meta, ...summary }, null, 1));
254
+ console.log(`JSON: ${resolve(opt.json)}`);
255
+ }
256
+ process.exit(process.exitCode || summary.exitCode);
package/a11y/wcag.mjs ADDED
@@ -0,0 +1,233 @@
1
+ // WCAG 2.2 success criteria at levels A and AA (what EN 301 549 V4.1.1 asks
2
+ // of web content for the European Accessibility Act), how axe-core's tags map
3
+ // to them, and how to fix what `yarn luma a11y` finds in a Luma integration.
4
+ // docs/ACCESSIBILITY.md has the background; §5 what templates must do.
5
+ import { relative } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ /** axe-core tags for WCAG 2.0–2.2 A and AA. */
9
+ export const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];
10
+
11
+ /**
12
+ * Every WCAG 2.2 A/AA success criterion (4.1.1 Parsing is obsolete in 2.2).
13
+ * `auto`: what the command can decide by itself (axe or its own checks) on
14
+ * Luma's widgets; the others need a person (a screen reader pass, captions,
15
+ * the meaning of a text) and are listed as "check by hand".
16
+ */
17
+ export const CRITERIA = {
18
+ '1.1.1': { title: 'Non-text content', level: 'A', auto: true },
19
+ '1.2.1': { title: 'Audio-only and video-only (prerecorded)', level: 'A' },
20
+ '1.2.2': { title: 'Captions (prerecorded)', level: 'A' },
21
+ '1.2.3': { title: 'Audio description or media alternative (prerecorded)', level: 'A' },
22
+ '1.2.4': { title: 'Captions (live)', level: 'AA' },
23
+ '1.2.5': { title: 'Audio description (prerecorded)', level: 'AA' },
24
+ '1.3.1': { title: 'Info and relationships', level: 'A', auto: true },
25
+ '1.3.2': { title: 'Meaningful sequence', level: 'A' },
26
+ '1.3.3': { title: 'Sensory characteristics', level: 'A' },
27
+ '1.3.4': { title: 'Orientation', level: 'AA', auto: true },
28
+ '1.3.5': { title: 'Identify input purpose', level: 'AA', auto: true },
29
+ '1.4.1': { title: 'Use of color', level: 'A', auto: true },
30
+ '1.4.2': { title: 'Audio control', level: 'A' },
31
+ '1.4.3': { title: 'Contrast (minimum)', level: 'AA', auto: true },
32
+ '1.4.4': { title: 'Resize text', level: 'AA', auto: true },
33
+ '1.4.5': { title: 'Images of text', level: 'AA' },
34
+ '1.4.10': { title: 'Reflow', level: 'AA', auto: true },
35
+ '1.4.11': { title: 'Non-text contrast', level: 'AA', auto: true },
36
+ '1.4.12': { title: 'Text spacing', level: 'AA', auto: true },
37
+ '1.4.13': { title: 'Content on hover or focus', level: 'AA' },
38
+ '2.1.1': { title: 'Keyboard', level: 'A', auto: true },
39
+ '2.1.2': { title: 'No keyboard trap', level: 'A', auto: true },
40
+ '2.1.4': { title: 'Character key shortcuts', level: 'A' },
41
+ '2.2.1': { title: 'Timing adjustable', level: 'A', auto: true },
42
+ '2.2.2': { title: 'Pause, stop, hide', level: 'A', auto: true },
43
+ '2.3.1': { title: 'Three flashes or below threshold', level: 'A' },
44
+ '2.4.1': { title: 'Bypass blocks', level: 'A', auto: true },
45
+ '2.4.2': { title: 'Page titled', level: 'A', auto: true },
46
+ '2.4.3': { title: 'Focus order', level: 'A', auto: true },
47
+ '2.4.4': { title: 'Link purpose (in context)', level: 'A', auto: true },
48
+ '2.4.5': { title: 'Multiple ways', level: 'AA' },
49
+ '2.4.6': { title: 'Headings and labels', level: 'AA' },
50
+ '2.4.7': { title: 'Focus visible', level: 'AA', auto: true },
51
+ '2.4.11': { title: 'Focus not obscured (minimum)', level: 'AA' },
52
+ '2.5.1': { title: 'Pointer gestures', level: 'A' },
53
+ '2.5.2': { title: 'Pointer cancellation', level: 'A' },
54
+ '2.5.3': { title: 'Label in name', level: 'A', auto: true },
55
+ '2.5.4': { title: 'Motion actuation', level: 'A' },
56
+ '2.5.7': { title: 'Dragging movements', level: 'AA' },
57
+ '2.5.8': { title: 'Target size (minimum)', level: 'AA', auto: true },
58
+ '3.1.1': { title: 'Language of page', level: 'A', auto: true },
59
+ '3.1.2': { title: 'Language of parts', level: 'AA', auto: true },
60
+ '3.2.1': { title: 'On focus', level: 'A' },
61
+ '3.2.2': { title: 'On input', level: 'A' },
62
+ '3.2.3': { title: 'Consistent navigation', level: 'AA' },
63
+ '3.2.4': { title: 'Consistent identification', level: 'AA' },
64
+ '3.2.6': { title: 'Consistent help', level: 'A' },
65
+ '3.3.1': { title: 'Error identification', level: 'A' },
66
+ '3.3.2': { title: 'Labels or instructions', level: 'A', auto: true },
67
+ '3.3.3': { title: 'Error suggestion', level: 'AA' },
68
+ '3.3.4': { title: 'Error prevention (legal, financial, data)', level: 'AA' },
69
+ '3.3.7': { title: 'Redundant entry', level: 'A' },
70
+ '3.3.8': { title: 'Accessible authentication (minimum)', level: 'AA' },
71
+ '4.1.2': { title: 'Name, role, value', level: 'A', auto: true },
72
+ '4.1.3': { title: 'Status messages', level: 'AA', auto: true },
73
+ };
74
+
75
+ /** Sorts success criteria numerically (1.4.3 before 1.4.10). */
76
+ export const bySc = (a, b) => {
77
+ const x = a.split('.').map(Number);
78
+ const y = b.split('.').map(Number);
79
+ for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) - (y[i] ?? 0);
80
+ return 0;
81
+ };
82
+
83
+ /**
84
+ * The success criteria of an axe rule, from its tags (`wcag143` -> 1.4.3,
85
+ * `wcag2411` -> 2.4.11). Only criteria of the catalogue above: an AAA tag
86
+ * (`wcag146`) is not a failure at AA.
87
+ */
88
+ export const scOfTags = (tags = []) => {
89
+ const out = new Set();
90
+ for (const t of tags) {
91
+ const m = /^wcag(\d)(\d)(\d{1,2})$/.exec(t);
92
+ if (!m) continue;
93
+ const sc = `${m[1]}.${m[2]}.${Number(m[3])}`;
94
+ if (CRITERIA[sc]) out.add(sc);
95
+ }
96
+ return [...out].sort(bySc);
97
+ };
98
+
99
+ /** docs/ACCESSIBILITY.md as a path from where the command runs (luma/…, node_modules/@luigisbox/luma/…). */
100
+ export const DOC = relative(process.cwd(), fileURLToPath(new URL('../docs/ACCESSIBILITY.md', import.meta.url)));
101
+
102
+ /**
103
+ * How to fix a finding in an integration: per axe rule id or per check of
104
+ * the command's own. Templates are lit (`integration/<widget>/template-*.ts`),
105
+ * styles `integration/<widget>/style-*.css`.
106
+ */
107
+ export const FIX = {
108
+ // axe-core rules
109
+ 'color-contrast': 'Text needs 4.5:1 against its background (3:1 at 24 px, or 19 px bold). Darken the colour in the integration\'s style-*.css; if the site\'s palette is fixed, at least adopt the high-contrast mode (' + DOC + ' §4: rules under :root[data-lbx-contrast="more"]).',
110
+ 'link-in-text-block': 'A link inside text must not be told apart by colour alone: underline it (or 3:1 against the text and a non-colour cue on hover and focus).',
111
+ 'image-alt': 'Give the <img> an alt: alt="" when the product title is in the same link (the picture is decorative), else a short description.',
112
+ 'role-img-alt': 'An element with role="img" needs aria-label (or drop the role if it is decorative and add aria-hidden="true").',
113
+ 'svg-img-alt': 'An <svg role="img"> needs a <title> or aria-label; a decorative icon gets aria-hidden="true" instead.',
114
+ 'input-image-alt': 'An <input type="image"> needs alt text naming its action.',
115
+ 'button-name': 'A button needs a name: visible text, or aria-label=${t(\'…\')} when it is an icon (e.g. the close "×" or a slider arrow).',
116
+ 'link-name': 'A link needs a name: its text, or aria-label when it holds only an image or icon. A tile\'s link reads as its title (the image alt="").',
117
+ 'label': 'A form control needs a label: <label> around it or for= its id, or aria-label. Filters: a fieldset with a legend and a label per checkbox; range thumbs "Price min" / "Price max".',
118
+ 'select-name': 'A <select> (e.g. the sort) needs a label or aria-label.',
119
+ 'aria-input-field-name': 'An element with an input role (combobox, slider, spinbutton, …) needs aria-label or aria-labelledby.',
120
+ 'aria-toggle-field-name': 'A role="checkbox" / "switch" / "menuitemcheckbox" needs a name (its text or aria-label).',
121
+ 'aria-command-name': 'A role="button" / "link" / "menuitem" needs a name (its text or aria-label).',
122
+ 'aria-meter-name': 'A role="meter" needs a name.',
123
+ 'aria-progressbar-name': 'A role="progressbar" needs a name.',
124
+ 'aria-tooltip-name': 'A role="tooltip" needs a name.',
125
+ 'aria-dialog-name': 'A dialog needs a name (aria-label or aria-labelledby its heading). Luma names the drawer panel "Filters" unless the markup names it.',
126
+ 'aria-required-children': 'A listbox may hold only role="option" and role="group" (with options): wrappers in between get role="presentation", headings go in a group\'s aria-label, and "Show all results" goes outside the listbox (or aria-hidden="true" if it repeats Enter). ' + DOC + ' §5.',
127
+ 'aria-required-parent': 'role="option" must be inside role="listbox" (or a group in one); role="listitem" inside a list; role="tab" inside a tablist. Put the role on the container, or drop the child\'s role.',
128
+ 'aria-required-attr': 'The role is missing an attribute it requires (e.g. aria-checked on role="checkbox", aria-valuenow on a slider, aria-selected on an option in a listbox).',
129
+ 'aria-allowed-attr': 'The attribute is not allowed on this role (e.g. aria-checked on a plain link: make it role="checkbox" with aria-checked, or use a native checkbox).',
130
+ 'aria-prohibited-attr': 'aria-label is not allowed on this element\'s role (a <div> or <span> without a role): give it a role, or put the text in the content.',
131
+ 'aria-valid-attr-value': 'An ARIA attribute has an invalid value (e.g. aria-controls or aria-labelledby pointing at an id that is not there, aria-expanded other than true/false).',
132
+ 'aria-valid-attr': 'A misspelt or unknown aria-* attribute: fix the name.',
133
+ 'aria-roles': 'An unknown role value: use a WAI-ARIA role.',
134
+ 'aria-allowed-role': 'This role is not allowed on this element (e.g. role="button" on a link that navigates): use the matching element instead.',
135
+ 'aria-hidden-focus': 'Something focusable is inside aria-hidden="true": add tabindex="-1" to it, or do not hide it.',
136
+ 'aria-hidden-body': 'aria-hidden must never be on <body>.',
137
+ 'aria-braille-equivalent': 'aria-braillelabel needs an aria-label too.',
138
+ 'aria-conditional-attr': 'An ARIA attribute is used where the role does not support it in this state (e.g. aria-checked on a native checkbox: remove it, the input has its own state).',
139
+ 'aria-deprecated-role': 'A deprecated ARIA role: use its replacement.',
140
+ 'nested-interactive': 'An interactive element is inside another (a button in a link, a link in a role="option"): screen readers announce only the outer one. Move the inner control out, or make it non-interactive.',
141
+ 'list': 'A <ul>/<ol> may contain only <li> (and script/template): wrap other children in an <li>, or use a <div>.',
142
+ 'listitem': 'An <li> must be inside a <ul> or <ol> (or role="list").',
143
+ 'definition-list': 'A <dl> may contain only <dt>/<dd> groups.',
144
+ 'dlitem': '<dt>/<dd> must be inside a <dl>.',
145
+ 'duplicate-id-aria': 'Two elements share an id that ARIA refers to: ids in a template that repeats (tiles, facets) need a unique suffix.',
146
+ 'duplicate-id-active': 'Two focusable elements share an id: make ids unique per tile/facet.',
147
+ 'heading-order': 'Heading levels skip a level.',
148
+ 'empty-heading': 'A heading has no text: remove the heading element or give it text.',
149
+ 'empty-table-header': 'A table header cell has no text.',
150
+ 'frame-title': 'An <iframe> needs a title.',
151
+ 'td-headers-attr': 'A table cell\'s headers attribute points at missing header cells.',
152
+ 'th-has-data-cells': 'A table header has no data cells; layout tables get role="presentation".',
153
+ 'scrollable-region-focusable': 'A scrolling container must be reachable by keyboard: tabindex="0" on it (with a name), unless every item in it is focusable.',
154
+ 'target-size': 'Targets need 24 x 24 CSS px, or 24 px of space around them (WCAG 2.5.8): padding or min-height/min-width on the control in style-*.css (range thumbs, chips\' ×, pager links, small checkboxes).',
155
+ 'autocomplete-valid': 'An autocomplete attribute has an invalid value.',
156
+ 'avoid-inline-spacing': 'Inline style with !important on letter/word spacing or line height prevents users\' text spacing overrides: move it to CSS without !important.',
157
+ 'meta-viewport': 'The page\'s viewport meta disables zoom (user-scalable=no or maximum-scale<2): the host page\'s.',
158
+ 'html-has-lang': 'The page needs <html lang>: the host page\'s.',
159
+ 'html-lang-valid': 'The page\'s lang value is invalid: the host page\'s.',
160
+ 'valid-lang': 'A lang attribute has an invalid value.',
161
+ 'document-title': 'The page needs a <title>: the host page\'s.',
162
+ 'bypass': 'The page needs a skip link or landmarks: the host page\'s.',
163
+ 'blink': '<blink> must not be used.',
164
+ 'marquee': '<marquee> must not be used.',
165
+ 'server-side-image-map': 'Server-side image maps are not keyboard-accessible.',
166
+ 'area-alt': 'An image map area needs alt text.',
167
+ 'object-alt': 'An <object> needs alternative text.',
168
+ 'video-caption': 'A video needs captions.',
169
+ 'audio-caption': 'Audio needs a transcript.',
170
+ 'no-autoplay-audio': 'Audio must not play automatically for more than 3 s without a control.',
171
+ 'form-field-multiple-labels': 'A control has more than one label: keep one.',
172
+ 'input-button-name': 'An <input type="button|submit|reset"> needs a value or aria-label.',
173
+ 'p-as-heading': 'Bold or large text used as a heading should be a heading element.',
174
+ 'identical-links-same-purpose': 'Links with the same name go to different places.',
175
+ 'label-content-name-mismatch': 'The visible label must be part of the accessible name (aria-label starts with the visible text).',
176
+
177
+ // The command's own checks
178
+ 'combobox-role': 'Luma sets role="combobox", aria-expanded, aria-controls and aria-autocomplete on _global.inputSelector while it has focus. Missing ones mean the input Luma binds is not the one the shopper types into (check inputSelector), or the host\'s code removes them.',
179
+ 'combobox-controls': 'aria-controls must name the dropdown that is on the page. Light DOM: the integration\'s autocomplete-box template keeps Luma\'s host (no id of its own on another element with the same id).',
180
+ 'combobox-active-option': 'The keyboard selection must be exposed: in light DOM (dom: "light") options need id="lbx-ac-<i>" (from the template scope) and aria-selected, so aria-activedescendant points at them (from an input in a shadow root, where an id cannot reach the document, Luma sets ariaActiveDescendantElement instead). ' + DOC + ' §5.',
181
+ 'combobox-restore':
182
+ 'Once focus leaves the input, Luma puts its ARIA back as the host had it. When the summary says the input is in a shadow root and focus moved within it (a web-component header: Tab to the button beside the input), Luma did not see focus leave (the dropdown also stays open there): update Luma, which sees a focusout that stays inside a shadow root; a focusout re-dispatched by an _init.ts is then not needed. Otherwise a host script that copies or freezes attributes on focus interferes: report it with the page.',
183
+ 'combobox-focus': 'Focus must stay in the input while the arrows move the selection: options are tabindex="-1", nothing in the dropdown takes focus on pointerdown.',
184
+ 'combobox-escape': 'Escape must close the dropdown (aria-expanded="false").',
185
+ 'status-message': 'Changes must be announced without moving focus (WCAG 4.1.3). Luma announces them in its own [data-lbx-a11y] status region; a template must not remove it, nor wrap it in aria-hidden or a hidden container. Do not add aria-live of your own (two would speak).',
186
+ 'focus-lost': 'After a filter, sort or "show more", focus must not fall back to <body>. Luma moves it to the listing when the focused control is gone; a template that re-creates the control (a key that changes on every render) loses it: give repeated controls a stable key (repeat(list, (x) => x.id, …)).',
187
+ 'not-focusable': 'This element acts as a control (a role="button"/"checkbox"/"option" or a click handler on a div) but cannot get keyboard focus: use a <button>/<a href>/<input>, or add tabindex="0" (Luma then adds Enter/Space to role="button").',
188
+ 'hidden-control': 'A checkbox or radio is display:none or visibility:hidden behind a custom-drawn box: keyboard users cannot reach it. Hide it visually instead (opacity:0; position:absolute; or the .lbx-sr-only class) so it keeps focus, and draw the focus ring on the custom box (input:focus-visible + .checkmark).',
189
+ 'focus-visible': 'Keyboard focus must be visible: the site\'s CSS removes the outline (e.g. *:focus { outline: none }) on this control. Add a :focus-visible style in the integration\'s style-*.css (an outline of at least 2 px in a colour at 3:1).',
190
+ 'keyboard-filter': 'A filter could not be applied from the keyboard (Space on its checkbox or Enter on its link changed nothing): keep filters native checkboxes or links, or role="checkbox" with tabindex="0".',
191
+ 'menu-expanded': 'The sort/menu button must say whether it is open: Luma sets aria-expanded on the [data-lbx-toggle] with aria-haspopup (else the first one). Mark the menu [data-lbx-menu] and its button [data-lbx-toggle].',
192
+ 'menu-focus': 'Opened from the keyboard, focus must move into the menu (its selected item, else the first): the items must be focusable (links, buttons, or role="option" with tabindex="0").',
193
+ 'menu-escape': 'Escape must close the menu and return focus to its button.',
194
+ 'drawer-dialog': 'While open, the filter drawer must be a named modal dialog: mark its panel [data-lbx-drawer-panel] (Luma adds role="dialog", aria-modal and the name), the openers [data-lbx-drawer="open"].',
195
+ 'drawer-focus': 'Opening the drawer must move focus into it, and Tab must stay inside while it is open: the panel must be [data-lbx-drawer-panel] and contain the drawer\'s controls (its close button included).',
196
+ 'drawer-escape': 'Escape must close the drawer and return focus to the button that opened it.',
197
+ 'reflow': 'At 320 CSS px wide (400 % zoom) nothing may need horizontal scrolling: a fixed width or min-width in style-*.css, a wide table or a nowrap row. Use max-width:100%, flex-wrap and minmax(0,1fr) grids; a deliberate scrolling row (a recommender carousel) needs overflow-x:auto on its own container.',
198
+ 'region-name': 'The listing / recommender must be a named region: Luma sets role="region" and aria-label (search.label, plp.label; the recommender\'s title). A template must not override them with an empty name.',
199
+ 'forced-colors-selection': 'In Windows contrast themes (forced-colors: active) backgrounds and shadows are replaced: draw a selected or checked state with an outline or border, or with forced-color-adjust:none and system colours (Highlight / HighlightText). ' + DOC + ' §4.',
200
+ 'contrast-mode': 'Under the OS "increase contrast" setting (prefers-contrast: more) Luma\'s high-contrast mode is on; the integration\'s own colours must follow: rules under :root[data-lbx-contrast="more"] in style-*.css. ' + DOC + ' §4.',
201
+ 'reduced-motion': 'Under prefers-reduced-motion: reduce, motion of ours should stop (transitions of transform/position, animations, smooth scrolling): wrap them in @media (prefers-reduced-motion: no-preference). WCAG 2.3.3 is AAA: advisory, not an AA failure.',
202
+ };
203
+
204
+ /** The SC each of the command's own checks tests. */
205
+ export const CHECK_SC = {
206
+ 'combobox-role': ['4.1.2'],
207
+ 'combobox-controls': ['4.1.2'],
208
+ 'combobox-active-option': ['4.1.2'],
209
+ 'combobox-restore': ['4.1.2'],
210
+ 'combobox-focus': ['2.4.3', '2.1.1'],
211
+ 'combobox-escape': ['2.1.1'],
212
+ 'status-message': ['4.1.3'],
213
+ 'focus-lost': ['2.4.3'],
214
+ 'not-focusable': ['2.1.1'],
215
+ 'hidden-control': ['2.1.1'],
216
+ 'focus-visible': ['2.4.7'],
217
+ 'keyboard-filter': ['2.1.1'],
218
+ 'menu-expanded': ['4.1.2'],
219
+ 'menu-focus': ['2.4.3'],
220
+ 'menu-escape': ['2.1.1', '2.4.3'],
221
+ 'drawer-dialog': ['4.1.2', '1.3.1'],
222
+ 'drawer-focus': ['2.4.3', '2.1.2'],
223
+ 'drawer-escape': ['2.1.2', '2.4.3'],
224
+ 'reflow': ['1.4.10'],
225
+ 'region-name': ['1.3.1'],
226
+ 'forced-colors-selection': ['1.4.11', '1.4.1'],
227
+ 'contrast-mode': ['1.4.3', '1.4.11'],
228
+ // AAA: reported, never fails the run
229
+ 'reduced-motion': [],
230
+ };
231
+
232
+ /** Checks whose findings are advice, not AA failures. */
233
+ export const ADVISORY = new Set(['reduced-motion']);
package/bin/config.mjs ADDED
@@ -0,0 +1,60 @@
1
+ // An integration's settings for Luma's tools: its luma.config.mjs, with Luma's
2
+ // defaults for what it leaves out. Every tool loads it (yarn luma <tool>), the
3
+ // server-side rendering rig too (not in the published package). The integration's file may export:
4
+ //
5
+ // host the storefront's host name: the tools open it LBX_HOST overrides it
6
+ // site the site's name (the rig's fragment URLs) LBX_SITE overrides it; default: package.json's name
7
+ // stubPage (url) => HTML: a page in the site's markup when the live storefront is not used
8
+ // fixtures a category, a query, a product… the tools' scenarios use
9
+ // perf { pages }: the pages footprint and tasks measure
10
+ // anchors, routeOf, headExtra the server-side rendering rig's template change
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ import { basename, join } from 'node:path';
13
+ import { pathToFileURL } from 'node:url';
14
+
15
+ export const CONFIG_FILE = 'luma.config.mjs';
16
+ /** Its name before 2026-10-12: the edge rig's, though every tool reads it. */
17
+ export const OLD_CONFIG_FILE = 'edge/rig.mjs';
18
+ /** The host the stub page is served as when the storefront's is not known. */
19
+ export const STUB_HOST = 'www.shop.example';
20
+
21
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
22
+
23
+ /** A neutral storefront page: a header search form, and on /search the search anchor, elsewhere a category listing. */
24
+ export const neutralStubPage = (url) => {
25
+ const search = url.pathname === '/search';
26
+ const q = search ? (url.searchParams.get('q') ?? '') : '';
27
+ return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>stub ${esc(url.pathname)}</title></head>
28
+ <body>
29
+ <header><form action="/search" method="get" role="search"><input name="q" autocomplete="off" value="${esc(q)}"><button>Search</button></form></header>
30
+ <main>
31
+ ${search ? '<div id="lbx-search-anchor"></div>' : `<h1>${esc(url.pathname.slice(1))}</h1><div id="lbx-listing"><p class="their-listing" style="min-height:400px">their listing</p></div>`}
32
+ </main></body></html>`;
33
+ };
34
+
35
+ /**
36
+ * The integration's settings: its luma.config.mjs (none: Luma's defaults), the environment's LBX_HOST and LBX_SITE first.
37
+ * `hostKnown`: the storefront's host is named (else `host` is the stub's); `ownStubPage`: the integration has a stub page of its own.
38
+ * @param {string} dir the integration repository ('': none, Luma's defaults)
39
+ * @param {Record<string, string | undefined>} [env]
40
+ */
41
+ export const loadConfig = async (dir, env = process.env) => {
42
+ const file = dir && join(dir, CONFIG_FILE);
43
+ if (file && !existsSync(file) && existsSync(join(dir, OLD_CONFIG_FILE)))
44
+ throw new Error(`${OLD_CONFIG_FILE} is now ${CONFIG_FILE}: rename it (yarn luma tooling --write does)`);
45
+ const own = file && existsSync(file) ? await import(pathToFileURL(file).href) : {};
46
+ let name = '';
47
+ try {
48
+ if (dir) name = String(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).name ?? '').replace(/^@[^/]+\//, '');
49
+ } catch {}
50
+ const host = env.LBX_HOST || own.host || '';
51
+ return {
52
+ ...own,
53
+ host: host || STUB_HOST,
54
+ hostKnown: !!host,
55
+ site: env.LBX_SITE || own.site || name || (dir && basename(dir)) || 'site',
56
+ stubPage: own.stubPage ?? neutralStubPage,
57
+ ownStubPage: !!own.stubPage,
58
+ fixtures: own.fixtures ?? {},
59
+ };
60
+ };