@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/bin/luma.mjs ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ // `yarn luma <tool> [args]`: Luma's tools, run on the integration repository
3
+ // it is called from. The integration's package.json has one script for all of
4
+ // them, and a tool Luma adds is there once Luma is updated:
5
+ //
6
+ // "scripts": { "luma": "luma" } the package's command (`node luma/bin/luma.mjs` with a submodule)
7
+ //
8
+ // yarn luma the tools, and what each answers
9
+ // yarn luma tasks --headed one of them, its own options after its name
10
+ //
11
+ // The integration's settings are its luma.config.mjs (`host`: the storefront's
12
+ // host name, `site`, the fixtures…: bin/config.mjs), never the script: each
13
+ // tool is run with LBX_SITE_DIR (this repository), LBX_SITE and LBX_HOST set
14
+ // from them (the environment's own win), from the folder it expects
15
+ // (bin/tools.mjs).
16
+ import { spawnSync } from 'node:child_process';
17
+ import { existsSync, realpathSync } from 'node:fs';
18
+ import { dirname, join, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { loadConfig } from './config.mjs';
21
+ import { TOOLS, availableTools } from './tools.mjs';
22
+
23
+ const LUMA = resolve(dirname(fileURLToPath(import.meta.url)), '..');
24
+ /** The tools this Luma has (the published package has no edge rig: bin/tools.mjs). */
25
+ const HERE = availableTools((p) => existsSync(join(LUMA, p)));
26
+
27
+ /**
28
+ * The steps `tool` runs: each a command, its arguments, its folder and its environment.
29
+ * @param {string} tool
30
+ * @param {string[]} args
31
+ * @param {{ site: string, luma?: string, env?: Record<string, string | undefined>, node?: string }} o
32
+ * @returns {{ cmd: string, args: string[], cwd: string, env: Record<string, string | undefined> }[] | null}
33
+ */
34
+ export const commandFor = (tool, args, { site, luma = LUMA, env = {}, node = process.execPath }) => {
35
+ const t = TOOLS[tool];
36
+ if (!t) return null;
37
+ const given = args.length ? args : (t.args ?? []);
38
+ const fill = (v) => v.replaceAll('<host>', env.LBX_HOST ?? '');
39
+ const extra = Object.fromEntries(Object.entries(t.env ?? {}).map(([k, v]) => [k, process.env[k] ?? fill(v)]));
40
+ return t.run.map((step, i) => {
41
+ const last = i === t.run.length - 1;
42
+ const [cmd, ...rest] = Array.isArray(step) ? step : [node, join(luma, step)];
43
+ return { cmd, args: [...rest, ...(last ? given : [])], cwd: t.cwd === 'luma' ? luma : site, env: { ...env, ...extra } };
44
+ });
45
+ };
46
+
47
+ /**
48
+ * The environment the tools share: this repository, its site name and its storefront's host (its luma.config.mjs: bin/config.mjs).
49
+ * @param {string} site
50
+ * @param {Record<string, string | undefined>} [base]
51
+ * @returns {Promise<Record<string, string | undefined>>}
52
+ */
53
+ export const siteEnv = async (site, base = process.env) => {
54
+ const config = await loadConfig(site, base);
55
+ return { ...base, LBX_SITE_DIR: base.LBX_SITE_DIR ?? site, LBX_SITE: config.site, ...(config.hostKnown ? { LBX_HOST: config.host } : {}) };
56
+ };
57
+
58
+ const list = () => {
59
+ const w = Math.max(...Object.keys(HERE).map((k) => k.length));
60
+ return `usage: yarn luma <tool> [its options] (yarn luma <tool> --help: its options)\n\n${Object.entries(HERE)
61
+ .map(([k, t]) => ` ${k.padEnd(w)} ${t.what}`)
62
+ .join('\n')}`;
63
+ };
64
+
65
+ // Run, not imported: through a symlink too (luma/ linked, npm's node_modules/.bin/luma)
66
+ if (process.argv[1] && realpathSync(resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
67
+ const [tool, ...args] = process.argv.slice(2);
68
+ if (!tool || tool === '--help' || tool === '-h' || tool === 'help') {
69
+ console.log(list());
70
+ process.exit(0);
71
+ }
72
+ if (!HERE[tool]) {
73
+ console.error(`no tool "${tool}" in this Luma (bin/tools.mjs${TOOLS[tool] ? ': its files are not in this copy' : ''})\n\n${list()}`);
74
+ process.exit(2);
75
+ }
76
+ const site = resolve(process.env.LBX_SITE_DIR || process.cwd());
77
+ if (!existsSync(join(site, 'integration'))) {
78
+ console.error(`${site} is not an integration repository (no integration/): run yarn luma from one`);
79
+ process.exit(2);
80
+ }
81
+ // tooling sets the settings up (moves them from edge/rig.mjs, their older name): it runs without them
82
+ let env;
83
+ try {
84
+ env = tool === 'tooling' ? { ...process.env, LBX_SITE_DIR: process.env.LBX_SITE_DIR ?? site } : await siteEnv(site);
85
+ } catch (e) {
86
+ console.error(e.message);
87
+ process.exit(2);
88
+ }
89
+ if (!env.LBX_HOST && tool !== 'tooling' && tool !== 'baseline')
90
+ console.error(`note: no storefront host (luma.config.mjs \`export const host = '<www.shop.example>'\`, or LBX_HOST): the tools use the stub page (yarn luma tooling --write --host <name> sets it)`);
91
+ for (const s of commandFor(tool, args, { site, env })) {
92
+ const r = spawnSync(s.cmd, s.args, { cwd: s.cwd, env: s.env, stdio: 'inherit' });
93
+ if (r.error) {
94
+ console.error(`${s.cmd}: ${r.error.message}`);
95
+ process.exit(2);
96
+ }
97
+ if (r.status !== 0) process.exit(r.status ?? 1);
98
+ }
99
+ }
package/bin/paths.mjs ADDED
@@ -0,0 +1,11 @@
1
+ // Path checks the tools' local file servers share (chrome/run.mjs, tasks/run.mjs).
2
+ import { sep } from 'node:path';
3
+
4
+ /**
5
+ * Whether `file` is `dir` or inside it. Both normalized, absolute; `dir`
6
+ * without a trailing separator. A sibling whose name starts with `dir`'s
7
+ * (`dist-old` next to `dist`) is not inside it.
8
+ * @param {string} dir
9
+ * @param {string} file
10
+ */
11
+ export const inside = (dir, file) => file === dir || file.startsWith(dir + sep);
package/bin/tools.mjs ADDED
@@ -0,0 +1,43 @@
1
+ // Luma's tools for an integration repository, by name: what `yarn luma <tool>`
2
+ // runs (bin/luma.mjs). A tool added here is there for every integration once
3
+ // it updates @luigisbox/luma: nothing in the integration's package.json.
4
+ // Tools whose files the package does not ship are not offered (availableTools).
5
+ //
6
+ // run in order, each must succeed: a script relative to luma/ (run with
7
+ // this Node), or a command as an array (['docker', 'compose', ...])
8
+ // cwd 'site': from the integration repository; 'luma': from luma/ (the edge rig's)
9
+ // args the arguments when none are given (given ones go to the last step)
10
+ // env set for it; `<host>` is the storefront's host
11
+ // what one line: what it answers
12
+
13
+ /** @type {Record<string, { run: (string | string[])[], cwd: 'site' | 'luma', args?: string[], env?: Record<string, string>, what: string }>} */
14
+ export const TOOLS = {
15
+ chrome: { run: ['chrome/run.mjs'], cwd: 'site', what: "the live storefront in your Chrome with this integration on it, as a customer installs it (whether or not the site includes it yet)" },
16
+ tasks: { run: ['tasks/run.mjs'], cwd: 'site', what: 'our main-thread tasks over a frame (16 ms at 4x CPU) on the storefront, per scenario, by source line' },
17
+ footprint: { run: ['footprint/run.mjs'], cwd: 'site', what: 'what Luma weighs per page type: entry and round trips, scripts and bytes, chain, main thread, API requests, time to ready' },
18
+ perf: { run: ['edge/src/perf.mjs'], cwd: 'luma', what: "what Luma costs the site's pages, snippet off vs on (the edge rig running: rig-up, then rig or rig-stub)" },
19
+ a11y: { run: ['a11y/run.mjs'], cwd: 'site', what: 'WCAG 2.2 AA on our widgets, by keyboard too' },
20
+ parity: { run: ['parity/run.mjs'], cwd: 'site', what: "the site's own search and category UI against ours, scored" },
21
+ baseline: { run: ['build/baseline.mjs'], cwd: 'site', args: ['integration'], what: 'nothing newer than Baseline Widely available (--built dist: the build, dependencies included)' },
22
+ 'rig-certs': { run: [['sh', 'edge/rig/certs.sh']], cwd: 'luma', what: "the rig's self-signed certificate for the storefront's host, once" },
23
+ 'rig-up': { run: [['docker', 'compose', '-f', 'edge/rig/docker-compose.yml', 'up', '-d']], cwd: 'luma', what: "the edge rig's containers (Docker), once" },
24
+ 'rig-down': { run: [['docker', 'compose', '-f', 'edge/rig/docker-compose.yml', 'down']], cwd: 'luma', what: "the edge rig's containers stopped" },
25
+ rig: { run: ['edge/build.mjs', 'edge/src/rig.mjs'], cwd: 'luma', env: { LBX_ORIGIN: 'https://<host>' }, what: 'the edge rig on the live storefront' },
26
+ 'rig-stub': { run: ['edge/build.mjs', 'edge/src/rig.mjs'], cwd: 'luma', env: { LBX_ORIGIN: 'stub' }, what: "the edge rig on the integration's stub page" },
27
+ 'rig-chrome': { run: ['edge/src/chrome.mjs'], cwd: 'luma', what: 'your Chrome on the rig: off | script | snippet | ssi | esi, and a path' },
28
+ 'edge-test': { run: ['edge/test/rig.mjs'], cwd: 'luma', what: 'server-side rendering: the fault matrix, adoption, hydration (the rig on the stub page)' },
29
+ tooling: { run: ['build/tooling.mjs'], cwd: 'site', what: "this repository's setup for these tools: the luma script and the storefront's host" },
30
+ };
31
+
32
+ /**
33
+ * The files a tool runs, relative to luma/: its scripts, and a command's arguments that are paths.
34
+ * @param {{ run: (string | string[])[] }} t
35
+ * @returns {string[]}
36
+ */
37
+ export const filesOf = (t) => t.run.flatMap((step) => (Array.isArray(step) ? step.slice(1).filter((a) => a.includes('/')) : [step]));
38
+
39
+ /**
40
+ * The tools this Luma has: those whose files are all in it.
41
+ * @param {(path: string) => boolean} has whether a path relative to luma/ exists
42
+ */
43
+ export const availableTools = (has) => Object.fromEntries(Object.entries(TOOLS).filter(([, t]) => filesOf(t).every(has)));
@@ -0,0 +1,8 @@
1
+ // Types of build/baseline.mjs (the Baseline check) for the TS that imports it (Luma's tests).
2
+ export declare const ALLOW: { feature: string; files: string[]; why: string }[];
3
+ /** Lint a CSS string with the Baseline rule: the warnings' texts (`line:column text`), none when it passes. */
4
+ export declare const lintCss: (code: string, name?: string) => Promise<string[]>;
5
+ /** Lint files or folders (relative to `cwd`): prints the problems, resolves to their count. */
6
+ export declare const check: (paths: string[], opts?: { cwd?: string; built?: boolean }) => Promise<number>;
7
+ export declare const eslintConfig: (cwd: string, files: string[], built?: boolean) => Promise<unknown[]>;
8
+ export declare const stylelintConfig: () => Record<string, unknown>;
@@ -0,0 +1,167 @@
1
+ // The Baseline check (https://web.dev/baseline): Luma targets Baseline Widely
2
+ // available, so what a page of ours runs must be in every major browser
3
+ // (Chrome, Edge, Firefox, Safari, desktop and mobile) for at least 30 months.
4
+ // A feature newer than that is used only behind a feature test, with a page
5
+ // that works without it: those uses are the ALLOW list below.
6
+ //
7
+ // yarn baseline (Luma's own sources and fixtures)
8
+ // node luma/build/baseline.mjs integration (an integration repository: `yarn luma baseline`)
9
+ // node luma/build/baseline.mjs --built dist (a build's output, dependencies included)
10
+ //
11
+ // JS and TS: ESLint with eslint-plugin-baseline-js (web-features data: syntax,
12
+ // JS builtins and Web APIs, type-aware in .ts so `el.showPopover()` is seen).
13
+ // CSS: Stylelint with stylelint-plugin-use-baseline (properties, values,
14
+ // selectors, at-rules, functions, units; a use inside a matching `@supports`
15
+ // passes). CSS inside TS strings is not linted here: Luma's own is checked by
16
+ // its tests through `lintCss`.
17
+ //
18
+ // The tools are Luma's dependencies (README.md, "Browser support"). The plugins carry
19
+ // their own snapshot of the Baseline data, so their versions are pinned: a
20
+ // feature becomes Widely available here when the pin moves.
21
+ import { existsSync, readdirSync, statSync } from 'node:fs';
22
+ import { dirname, relative, resolve } from 'node:path';
23
+ import { fileURLToPath, pathToFileURL } from 'node:url';
24
+
25
+ const LUMA = resolve(dirname(fileURLToPath(import.meta.url)), '..');
26
+
27
+ /**
28
+ * Newer than Baseline Widely available, used only where feature-detected.
29
+ * `feature` is the web-features id (https://web-platform-dx.github.io/web-features-explorer/),
30
+ * `files` the Luma sources allowed to use it (relative to the Luma root), `why`
31
+ * the test and what happens without the feature. Anything else, anywhere, fails.
32
+ */
33
+ export const ALLOW = [
34
+ {
35
+ // Baseline Newly available 2025-01-27 (Safari on iOS 18.3): Widely about 2027-07.
36
+ feature: 'popover',
37
+ files: ['src/widgets/autocomplete/index.ts', 'src/widgets/recommender/index.ts', 'src/widgets/search/in-place.ts'],
38
+ why:
39
+ "src/main.ts loads the autocomplete, and sets up the popup recommender, only when HTMLElement.prototype has showPopover: without it the site keeps its own suggestions and no popup opens. In-place search closes a host's [popover] around the form only where the API exists (without it `:popover-open` is an invalid selector).",
40
+ },
41
+ {
42
+ // Baseline Newly available 2026-01-13. Read through `any` (not in this TS lib), so the rule cannot see it: listed for the record.
43
+ feature: 'navigation',
44
+ files: ['src/core/sense.ts'],
45
+ why: 'URL changes come from navigation.currententrychange when window.navigation exists, else from a wrapped pushState/replaceState and popstate.',
46
+ },
47
+ {
48
+ // Limited availability (Chromium). navigator.connection through `any`, as above (seen in a build's output).
49
+ feature: 'network-information',
50
+ files: ['src/widgets/search/speculate.ts'],
51
+ why: 'Results are not fetched ahead while typing on a Save-Data or 2G connection when navigator.connection says so; without it (Firefox, Safari) they are (offline aside).',
52
+ },
53
+ {
54
+ // Limited availability (Chromium only). document.prerendering through `any`, as above.
55
+ feature: 'speculation-rules',
56
+ files: ['src/core/speculation.ts', 'src/core/takeover.ts', 'src/main.ts'],
57
+ why: "The prerender rule is added only when HTMLScriptElement.supports('speculationrules'); document.prerendering is undefined (falsy) elsewhere, so a hold's timer starts at once.",
58
+ },
59
+ ];
60
+
61
+ const JS =/\.(?:[cm]?js|ts)$/;
62
+ const CSS = /\.css$/;
63
+ const SKIP = /(?:^|\/)(?:node_modules|dist|\.cache)(?:\/|$)|\.d\.ts$|\.min\.js$/;
64
+
65
+ const need = async (name) => {
66
+ try {
67
+ return await import(name);
68
+ } catch (e) {
69
+ if (e?.code !== 'ERR_MODULE_NOT_FOUND') throw e;
70
+ console.error(`baseline: ${name} is not installed: \`yarn install\`.`);
71
+ process.exit(2);
72
+ }
73
+ };
74
+
75
+ /** Per file: the features of ALLOW it may use (paths relative to the Luma root). */
76
+ const allowedIn = (file) => ALLOW.filter((a) => a.files.some((f) => resolve(LUMA, f) === file)).map((a) => a.feature);
77
+
78
+ /**
79
+ * ESLint flat config: the Baseline rule on JS and TS (type-aware in TS: the
80
+ * nearest tsconfig.json). `built`: the files are a build's output (Luma and
81
+ * its dependencies bundled together), where every ALLOW feature passes.
82
+ */
83
+ export const eslintConfig = async (cwd, files, built = false) => {
84
+ const { default: baselineJs } = await need('eslint-plugin-baseline-js');
85
+ const tsParser = await need('@typescript-eslint/parser');
86
+ const rule = (ignoreFeatures = []) => [
87
+ 'error',
88
+ { available: 'widely', includeWebApis: { preset: 'type-aware' }, includeJsBuiltins: { preset: 'type-aware' }, ...(ignoreFeatures.length ? { ignoreFeatures } : {}) },
89
+ ];
90
+ if (built) return [{ files: ['**/*.{js,mjs}'], plugins: { 'baseline-js': baselineJs }, languageOptions: { ecmaVersion: 'latest', sourceType: 'module' }, rules: { 'baseline-js/use-baseline': rule(ALLOW.map((a) => a.feature)) } }];
91
+ const perFile = files.map((f) => [f, allowedIn(f)]).filter(([, a]) => a.length);
92
+ return [
93
+ { linterOptions: { reportUnusedDisableDirectives: 'error' } },
94
+ { files: ['**/*.{js,mjs,cjs,ts}'], plugins: { 'baseline-js': baselineJs }, languageOptions: { ecmaVersion: 'latest', sourceType: 'module' }, rules: { 'baseline-js/use-baseline': rule() } },
95
+ { files: ['**/*.ts'], languageOptions: { parser: tsParser, parserOptions: { projectService: true, tsconfigRootDir: cwd } } },
96
+ // Luma's inline early-hold snippet is a classic script (ES5); everything else is a module.
97
+ { files: ['snippet/*.js'], languageOptions: { sourceType: 'script' } },
98
+ ...perFile.map(([f, allowed]) => ({ files: [relative(cwd, f)], rules: { 'baseline-js/use-baseline': rule(allowed) } })),
99
+ ];
100
+ };
101
+
102
+ /** Stylelint config: the Baseline rule only (no style rules). */
103
+ export const stylelintConfig = () => ({
104
+ plugins: ['stylelint-plugin-use-baseline'],
105
+ rules: { 'plugin/use-baseline': [true, { available: 'widely' }] },
106
+ reportNeedlessDisables: true,
107
+ reportInvalidScopeDisables: true,
108
+ });
109
+
110
+ /** Lint a CSS string (CSS that Luma builds in TS): the warnings' texts. */
111
+ export const lintCss = async (code, name = 'inline.css') => {
112
+ const { default: stylelint } = await need('stylelint');
113
+ const r = await stylelint.lint({ code, codeFilename: name, config: stylelintConfig(), configBasedir: LUMA });
114
+ return r.results.flatMap((x) => x.warnings.map((w) => `${w.line}:${w.column} ${w.text}`));
115
+ };
116
+
117
+ const walk = (root, p = root, out = []) => {
118
+ if (SKIP.test(relative(root, p))) return out;
119
+ if (statSync(p).isDirectory()) for (const e of readdirSync(p)) walk(root, resolve(p, e), out);
120
+ else out.push(p);
121
+ return out;
122
+ };
123
+
124
+ /**
125
+ * Lint `paths` (files or folders, relative to `cwd`): prints the problems,
126
+ * resolves to their count. `built`: `paths` are a build's output (`dist/`):
127
+ * its scripts, dependencies included, with the ALLOW features passing anywhere.
128
+ */
129
+ export const check = async (paths, { cwd = process.cwd(), built = false } = {}) => {
130
+ const all = [];
131
+ for (const p of paths) {
132
+ const abs = resolve(cwd, p);
133
+ if (!existsSync(abs)) throw new Error(`baseline: ${p} does not exist`);
134
+ walk(abs, abs, all);
135
+ }
136
+ const js = all.filter((f) => (built ? /\.m?js$/ : JS).test(f));
137
+ const css = built ? [] : all.filter((f) => CSS.test(f));
138
+ let problems = 0;
139
+
140
+ const { ESLint } = await need('eslint');
141
+ const eslint = new ESLint({ cwd, overrideConfigFile: true, overrideConfig: await eslintConfig(cwd, js, built), errorOnUnmatchedPattern: false, ignore: false });
142
+ const results = js.length ? await eslint.lintFiles(js) : [];
143
+ problems += results.reduce((n, r) => n + r.errorCount + r.warningCount, 0);
144
+ const out = (await eslint.loadFormatter('stylish')).format(results);
145
+ if (out) console.log(out);
146
+
147
+ if (css.length) {
148
+ const { default: stylelint } = await need('stylelint');
149
+ const r = await stylelint.lint({ files: css, config: stylelintConfig(), configBasedir: LUMA, formatter: 'string', cwd });
150
+ const n = r.results.reduce((k, x) => k + x.warnings.length + (x.parseErrors?.length ?? 0), 0);
151
+ problems += n;
152
+ if (n) console.log(r.report);
153
+ }
154
+ console.log(`baseline: ${js.length} scripts, ${css.length} stylesheets${built ? ' (built)' : ''}, ${problems ? `${problems} problem(s): newer than Baseline Widely available (README.md, "Browser support")` : 'all Baseline Widely available'}`);
155
+ return problems;
156
+ };
157
+
158
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
159
+ const args = process.argv.slice(2);
160
+ const built = args.includes('--built');
161
+ const paths = args.filter((a) => a !== '--built');
162
+ if (!paths.length) {
163
+ console.error('usage: node build/baseline.mjs [--built] <file or folder>...');
164
+ process.exit(2);
165
+ }
166
+ process.exit((await check(paths, { built })) ? 1 : 0);
167
+ }
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ // An integration's setup for Luma's tools (`yarn luma <tool>`, bin/luma.mjs):
3
+ // one script in its package.json, and its storefront's host in its
4
+ // luma.config.mjs (bin/config.mjs). A tool Luma adds then needs nothing in the
5
+ // integration. Run from the integration repository:
6
+ //
7
+ // yarn luma tooling what is missing (exit 1 if anything)
8
+ // yarn luma tooling --write add it (the host from a script that sets LBX_HOST)
9
+ // yarn luma tooling --write --host www.shop.example
10
+ // node luma/build/tooling.mjs --write the same before the luma script exists
11
+ //
12
+ // Scripts already there are never changed: per-tool ones (`"a11y": "LBX_HOST=… node luma/a11y/run.mjs"`)
13
+ // keep working, `yarn luma a11y` is the same without them.
14
+ import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, writeFileSync } from 'node:fs';
15
+ import { dirname, join, resolve } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { CONFIG_FILE, OLD_CONFIG_FILE } from '../bin/config.mjs';
18
+
19
+ /** The one script an integration needs for every tool: the package's `luma` command, or the luma/ submodule's script. */
20
+ export const LUMA_SCRIPT = fileURLToPath(import.meta.url).includes('/node_modules/') ? 'luma' : 'node luma/bin/luma.mjs';
21
+
22
+ /** The storefront host a script of the package sets (LBX_HOST=…, the older per-tool scripts), or ''. */
23
+ export const hostOf = (pkg) => {
24
+ for (const s of Object.values(pkg.scripts ?? {})) {
25
+ const m = /\bLBX_HOST=([^\s;&]+)/.exec(String(s));
26
+ if (m && !m[1].includes('<')) return m[1];
27
+ }
28
+ return '';
29
+ };
30
+
31
+ /** The host luma.config.mjs exports (`export const host = '…'`), read from its source, or ''. */
32
+ export const configHost = (source) => /^\s*export\s+const\s+host\s*=\s*['"]([^'"]+)['"]/m.exec(source ?? '')?.[1] ?? '';
33
+
34
+ /** What is missing: the luma script, the storefront's host in luma.config.mjs. */
35
+ export const missing = (pkg, configSource) => [...(pkg.scripts?.luma ? [] : ['luma']), ...(configHost(configSource) ? [] : ['host'])];
36
+
37
+ /** The package with the luma script (left as it is when there), and luma.config.mjs with the host (created when absent). */
38
+ export const withTooling = (pkg, configSource, { host = configHost(configSource) || hostOf(pkg) } = {}) => {
39
+ const gone = missing(pkg, configSource);
40
+ if (gone.includes('host') && !host) throw new Error("the storefront's host is not known: pass --host <name> (it goes into luma.config.mjs)");
41
+ const scripts = { ...(pkg.scripts ?? {}) };
42
+ scripts.luma ??= LUMA_SCRIPT;
43
+ const line = `// The storefront's host name: Luma's tools (yarn luma <tool>) open it, LBX_HOST overrides it.\nexport const host = '${host}';\n`;
44
+ // After the file's opening comment (what it is), before its code.
45
+ const head = /^(?:[ \t]*\/\/[^\n]*\n)*/.exec(configSource ?? '')?.[0] ?? '';
46
+ const config = !gone.includes('host') ? configSource : configSource ? `${head}${head ? '\n' : ''}${line}\n${configSource.slice(head.length).replace(/^\n+/, '')}` : `// This integration's settings for Luma's tools (Luma's README, yarn luma <tool>).\n${line}`;
47
+ return { pkg: { ...pkg, scripts }, config };
48
+ };
49
+
50
+ // Run, not imported: through a symlink too (luma/ linked, npm's node_modules/.bin/luma)
51
+ if (process.argv[1] && realpathSync(resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
52
+ const args = process.argv.slice(2);
53
+ const write = args.includes('--write');
54
+ const hi = args.indexOf('--host');
55
+ const host = hi >= 0 ? args[hi + 1] : undefined;
56
+ const dir = resolve(process.env.LBX_SITE_DIR || process.cwd());
57
+ const pkgFile = join(dir, 'package.json');
58
+ const configFile = join(dir, CONFIG_FILE);
59
+ // Its name before 2026-10-12 (bin/config.mjs): moved, as it is
60
+ const oldFile = join(dir, OLD_CONFIG_FILE);
61
+ const rename = !existsSync(configFile) && existsSync(oldFile);
62
+ if (!existsSync(pkgFile) || !existsSync(join(dir, 'integration'))) {
63
+ console.error(`${dir} is not an integration repository (a package.json and integration/): run it from one`);
64
+ process.exit(2);
65
+ }
66
+ const text = readFileSync(pkgFile, 'utf8');
67
+ const pkg = JSON.parse(text);
68
+ const configSource = existsSync(configFile) ? readFileSync(configFile, 'utf8') : rename ? readFileSync(oldFile, 'utf8') : '';
69
+ const gone = [...(rename ? ['rename'] : []), ...missing(pkg, configSource)];
70
+ const perTool = Object.entries(pkg.scripts ?? {}).filter(([, v]) => /node luma\/(a11y|parity|footprint|tasks)\/run\.mjs|yarn perf\b/.test(String(v))).map(([k]) => k);
71
+ if (!gone.length) {
72
+ console.log(`ready: yarn luma <tool> (yarn luma: the tools), the storefront ${configHost(configSource)}`);
73
+ if (perTool.length) console.log(`(scripts ${perTool.join(', ')} do what yarn luma <tool> does: they can go)`);
74
+ process.exit(0);
75
+ }
76
+ if (!write) {
77
+ if (rename) console.log(`missing: ${CONFIG_FILE} (the settings are in ${OLD_CONFIG_FILE}, its older name)`);
78
+ if (gone.includes('luma')) console.log(`missing: the "luma" script (${LUMA_SCRIPT}) in package.json`);
79
+ if (gone.includes('host')) console.log(`missing: the storefront's host in ${CONFIG_FILE} (export const host = '…')`);
80
+ console.log('yarn luma tooling --write adds them (--host <name> if no script sets LBX_HOST)');
81
+ process.exit(1);
82
+ }
83
+ let next;
84
+ try {
85
+ next = withTooling(pkg, configSource, host ? { host } : {});
86
+ } catch (e) {
87
+ console.error(e.message);
88
+ process.exit(2);
89
+ }
90
+ if (gone.includes('luma')) {
91
+ const indent = /^(\s+)"/m.exec(text)?.[1] ?? ' ';
92
+ writeFileSync(pkgFile, `${JSON.stringify(next.pkg, null, indent)}\n`);
93
+ }
94
+ if (rename) {
95
+ renameSync(oldFile, configFile);
96
+ const edge = dirname(oldFile);
97
+ if (!readdirSync(edge).length) rmdirSync(edge);
98
+ }
99
+ if (gone.includes('host')) writeFileSync(configFile, next.config);
100
+ const what = { rename: `${OLD_CONFIG_FILE} moved to ${CONFIG_FILE}`, luma: 'the luma script (package.json)', host: `the host ${configHost(next.config)} (${CONFIG_FILE})` };
101
+ console.log(`done: ${gone.map((g) => what[g]).join(', ')}`);
102
+ }