automatica11y 0.3.3 → 0.4.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 (47) hide show
  1. package/README.md +25 -7
  2. package/package.json +5 -3
  3. package/skills/automatica11y-runner/SKILL.md +44 -13
  4. package/skills/automatica11y-runner/references/fixtures.md +38 -2
  5. package/src/commands/common.js +5 -2
  6. package/src/frameworks/index.js +42 -0
  7. package/src/frameworks/react.js +77 -0
  8. package/src/frameworks/vue.js +90 -0
  9. package/src/frameworks/wc.js +55 -0
  10. package/src/globals.d.ts +1 -0
  11. package/src/harness/bundle.js +7 -6
  12. package/src/harness/generate/dialects.js +64 -0
  13. package/src/harness/generate/index.js +12 -0
  14. package/src/harness/generate/jsx-recipes.js +224 -0
  15. package/src/harness/generate/jsx.js +76 -0
  16. package/src/harness/generate/kit.js +50 -0
  17. package/src/harness/generate/marking.js +64 -0
  18. package/src/harness/generate/probe.js +97 -0
  19. package/src/harness/generate/shared.js +12 -0
  20. package/src/harness/generate/wc-recipes.js +132 -0
  21. package/src/harness/npm-install.js +38 -7
  22. package/src/harness/settle.js +17 -0
  23. package/src/harness/storybook.js +1 -0
  24. package/src/harness/url.js +13 -3
  25. package/src/plan/classify.js +11 -4
  26. package/src/plan/mapping.js +10 -7
  27. package/src/plan/resolve-npm.js +15 -9
  28. package/src/plan/subpath.js +133 -0
  29. package/src/report/comparison.js +14 -9
  30. package/src/report/parts.js +36 -12
  31. package/src/run/audit-npm.js +118 -28
  32. package/src/run/generate-fixture.js +74 -0
  33. package/src/run/run-plan.js +19 -4
  34. package/src/run/summary.js +5 -0
  35. package/src/schema.js +30 -6
  36. package/src/tiers/computed/checks.js +44 -5
  37. package/src/tiers/computed/color.js +8 -0
  38. package/src/tiers/computed/index.js +2 -2
  39. package/src/tiers/computed/measure-kit.js +30 -10
  40. package/src/tiers/conditions/checks.js +283 -0
  41. package/src/tiers/conditions/index.js +30 -0
  42. package/src/tiers/conditions/kit.js +133 -0
  43. package/src/tiers/interactions/archetypes.js +111 -0
  44. package/src/tiers/interactions/helpers.js +56 -0
  45. package/src/tiers/interactions/index.js +16 -3
  46. package/src/harness/npm-react.js +0 -39
  47. package/src/harness/npm-wc.js +0 -30
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Candidate web component fixtures, built from what the element says about itself once it's defined:
3
+ * the attributes it observes, the members its class has, and the slots its shadow root offers.
4
+ * A candidate is a guess until the probe has seen it behave (see probe.js). None of this names a library.
5
+ */
6
+ import { markingSource } from "./marking.js";
7
+ import { ATTEMPT_LIMIT, GENERATABLE } from "./shared.js";
8
+
9
+ /** The module around every candidate: the marking code and a mount function that starts it. */
10
+ function frame(archetype, body) {
11
+ return `${markingSource(archetype)}
12
+ export default function mount(container) {
13
+ ${body.split("\n").map((line) => (line ? ` ${line}` : line)).join("\n")}
14
+ startMarking();
15
+ }
16
+ `;
17
+ }
18
+
19
+ const has = (facts, name) => facts.attributes.includes(name) || facts.members.includes(name);
20
+ const slotFor = (facts, pattern) => facts.slots.find((name) => pattern.test(name));
21
+
22
+ // ---- dialog ----
23
+
24
+ /** The ways a dialog element is commonly opened, from what it has. */
25
+ function openers(facts) {
26
+ const found = [];
27
+ if (facts.attributes.includes("open")) found.push(["attribute", `dialog.setAttribute("open", "");`]);
28
+ if (facts.members.includes("open")) found.push(["property", "dialog.open = true;"]);
29
+ if (facts.members.includes("showModal")) found.push(["showModal()", "dialog.showModal();"]);
30
+ if (facts.members.includes("show")) found.push(["show()", "dialog.show();"]);
31
+ return found;
32
+ }
33
+
34
+ function dialogs(tagName, facts) {
35
+ const titleSlot = slotFor(facts, /title|header|heading/i);
36
+ const heading = titleSlot ? `<h2 slot="${titleSlot}">Edit profile</h2>` : "<h2>Edit profile</h2>";
37
+ return openers(facts).map(([how, code]) => ({
38
+ id: `dialog-${how.replace(/[^a-z]+/gi, "-").replace(/-$/, "")}`,
39
+ summary: `a ${tagName} opened with its ${how}`,
40
+ source: frame("dialog", `container.innerHTML = \`<button type="button" data-a11y-trigger>Open dialog</button><${tagName}>${heading}<p>Update your details.</p><button type="button">Close</button></${tagName}>\`;
41
+ const dialog = container.querySelector(${JSON.stringify(tagName)});
42
+ container.querySelector("[data-a11y-trigger]").addEventListener("click", () => {
43
+ ${code}
44
+ });`),
45
+ used: [tagName],
46
+ }));
47
+ }
48
+
49
+ // ---- tooltip ----
50
+
51
+ function tooltips(tagName, facts) {
52
+ return facts.attributes.filter((name) => /^(tip|tooltip|content|text|label|title|message|description)$/.test(name)).map((name) => ({
53
+ id: `tooltip-${name}`,
54
+ summary: `a ${tagName} that takes its text from ${name}`,
55
+ source: frame("tooltip", `container.innerHTML = \`<${tagName} ${name}="Saves your work."><button type="button" data-a11y-trigger>Save</button></${tagName}>\`;`),
56
+ used: [tagName],
57
+ }));
58
+ }
59
+
60
+ // ---- live region ----
61
+
62
+ function messages(tagName, facts) {
63
+ const list = [{
64
+ id: "message-appended",
65
+ summary: `a ${tagName} added to the page when the trigger is pressed`,
66
+ source: frame("live-region", `container.innerHTML = \`<button type="button" data-a11y-trigger>Show message</button><div id="slot"></div>\`;
67
+ container.querySelector("[data-a11y-trigger]").addEventListener("click", () => {
68
+ const message = document.createElement(${JSON.stringify(tagName)});
69
+ message.textContent = "Saved.";
70
+ container.querySelector("#slot").append(message);
71
+ });`),
72
+ used: [tagName],
73
+ }];
74
+ if (has(facts, "open")) {
75
+ list.push({
76
+ id: "message-open",
77
+ summary: `a ${tagName} shown by setting open`,
78
+ source: frame("live-region", `container.innerHTML = \`<button type="button" data-a11y-trigger>Show message</button><${tagName}>Saved.</${tagName}>\`;
79
+ const message = container.querySelector(${JSON.stringify(tagName)});
80
+ container.querySelector("[data-a11y-trigger]").addEventListener("click", () => message.setAttribute("open", ""));`),
81
+ used: [tagName],
82
+ });
83
+ }
84
+ return list;
85
+ }
86
+
87
+ // ---- form field ----
88
+
89
+ /** The element isn't marked as the trigger here. The marking code finds the input inside its shadow root, which is the thing a person types in. */
90
+ function fields(tagName, facts) {
91
+ if (!["value", "name", "label", "placeholder", "checked", "type"].some((name) => has(facts, name))) return [];
92
+ const list = [{
93
+ id: "field-wrapped-label",
94
+ summary: `a ${tagName} inside a wrapping label`,
95
+ source: frame("form-field", `container.innerHTML = \`<label>Name <${tagName}></${tagName}></label>\`;`),
96
+ used: [tagName],
97
+ }];
98
+ if (has(facts, "label")) {
99
+ list.push({
100
+ id: "field-label-attribute",
101
+ summary: `a ${tagName} with a label attribute`,
102
+ source: frame("form-field", `container.innerHTML = \`<${tagName} label="Name"></${tagName}>\`;`),
103
+ used: [tagName],
104
+ });
105
+ }
106
+ return list;
107
+ }
108
+
109
+ /**
110
+ * Candidates for one element and archetype. Archetypes with no recipe here (menu, tabs, accordion, combobox) return none,
111
+ * because those depend on child elements and slots that an element can't describe well enough to guess.
112
+ * @param {{ archetype: string, tag: string, facts: { attributes: string[], members: string[], slots: string[] } }} input
113
+ */
114
+ export function wcCandidates({ archetype, tag, facts }) {
115
+ const build = { dialog: dialogs, tooltip: tooltips, "live-region": messages, "form-field": fields }[archetype];
116
+ return build ? build(tag, facts) : [];
117
+ }
118
+
119
+ /**
120
+ * Candidates for a web component package, from what the element the mapping found says about itself.
121
+ * @param {{ archetype: string, entry: { tag?: string }, facts: Record<string, { attributes: string[], members: string[], slots: string[] }> }} input
122
+ * @returns {{ candidates: Array<{ id: string, summary: string, source: string, used: string[] }>, reason: string | null }}
123
+ */
124
+ export function generateWc({ archetype, entry, facts }) {
125
+ if (!GENERATABLE.has(archetype)) return { candidates: [], reason: `Nothing is generated for the ${archetype} archetype.` };
126
+ const tag = entry.tag;
127
+ if (!tag || !facts[tag]) return { candidates: [], reason: "No custom element looks like this archetype, so there's nothing to build a fixture around." };
128
+ const candidates = wcCandidates({ archetype, tag, facts: facts[tag] }).slice(0, ATTEMPT_LIMIT);
129
+ return candidates.length
130
+ ? { candidates, reason: null }
131
+ : { candidates: [], reason: `${tag} doesn't show a way to wire a ${archetype} (for example, an open attribute or a tip attribute), and no recipe covers a web component ${archetype} without one.` };
132
+ }
@@ -33,9 +33,9 @@ export function installedVersion(dir, name) {
33
33
 
34
34
  /**
35
35
  * Install a package into its own directory, never next to another target's install.
36
- * npm adds the peer dependencies. A React package also needs react-dom, so add it when the peers left it out.
36
+ * npm adds the peer dependencies. A React package also needs react-dom, and a Vue package needs vue, so add them when the peers left them out.
37
37
  * Install scripts stay off, because the code is untrusted until it runs in the browser sandbox.
38
- * @param {{ dir: string, name: string, version: string, flavor: "react" | "wc" | "unknown", run?: typeof runNpm }} options
38
+ * @param {{ dir: string, name: string, version: string, flavor: "react" | "vue" | "wc" | "unknown", run?: typeof runNpm }} options
39
39
  */
40
40
  export async function installPackage({ dir, name, version, flavor, run = runNpm }) {
41
41
  mkdirSync(dir, { recursive: true });
@@ -69,15 +69,46 @@ export async function installPackage({ dir, name, version, flavor, run = runNpm
69
69
  await npm(["install", "react", "react-dom", ...NPM_FLAGS, "--legacy-peer-deps"]);
70
70
  warnings.push(`${name} didn't bring in react, so the latest react and react-dom were added.`);
71
71
  } else if (!installedVersion(dir, "react-dom")) {
72
- await npm(["install", `react-dom@${react}`, ...NPM_FLAGS, "--legacy-peer-deps"]);
72
+ // Name react too. A loose install prunes a package that only arrived as a peer, and react did.
73
+ await npm(["install", `react@${react}`, `react-dom@${react}`, ...NPM_FLAGS, "--legacy-peer-deps"]);
73
74
  }
74
75
  }
75
- return { dir, warnings, react: installedVersion(dir, "react"), reactDom: installedVersion(dir, "react-dom"), version: installedVersion(dir, name) };
76
+ if (flavor === "vue" && !installedVersion(dir, "vue")) {
77
+ await npm(["install", "vue", ...NPM_FLAGS, "--legacy-peer-deps"]);
78
+ warnings.push(`${name} didn't bring in vue, so the latest vue was added.`);
79
+ }
80
+ return { dir, warnings, react: installedVersion(dir, "react"), reactDom: installedVersion(dir, "react-dom"), vue: installedVersion(dir, "vue"), version: installedVersion(dir, name) };
81
+ }
82
+
83
+ /** The installed framework runtimes (React, React DOM, Vue), named again so a loose install can't prune them. */
84
+ function pinnedRuntime(dir) {
85
+ return ["react", "react-dom", "vue"].flatMap((name) => (installedVersion(dir, name) ? [`${name}@${installedVersion(dir, name)}`] : []));
86
+ }
87
+
88
+ const PACKAGE_SPEC = /^(@[a-z0-9~][\w.~-]*\/)?[a-z0-9~][\w.~-]*(@[\w.^~<>=*|-]+)?$/i;
89
+
90
+ /**
91
+ * Install packages a mapping names beside the target: a token stylesheet, a theme, or another package the library's own
92
+ * documentation says to load. Only plain package specs are accepted (no flags, paths, or URLs). Install scripts stay off.
93
+ * @param {{ dir: string, specs: string[], run?: typeof runNpm }} options
94
+ * @returns {Promise<{ installed: string[], warnings: string[] }>}
95
+ */
96
+ export async function installExtraPackages({ dir, specs, run = runNpm }) {
97
+ const wanted = [...new Set(specs)];
98
+ if (wanted.length === 0) return { installed: [], warnings: [] };
99
+ for (const spec of wanted) if (!PACKAGE_SPEC.test(spec)) throw new Error(`The mapping's install list has "${spec}", which isn't a package name with an optional version.`);
100
+ try {
101
+ await run(["install", ...wanted, ...pinnedRuntime(dir), "--legacy-peer-deps", ...NPM_FLAGS], dir);
102
+ } catch (error) {
103
+ throw new Error(`npm couldn't install ${wanted.join(", ")}: ${firstLine(error.message)}`);
104
+ }
105
+ return { installed: wanted, warnings: [`The mapping named ${wanted.join(", ")} to install beside the library, so it was installed.`] };
76
106
  }
77
107
 
78
108
  /**
79
109
  * Find the optional peer dependencies that installed packages declare, with the range each asks for.
80
- * npm leaves these out, but a library's default setup can still need one (MUI needs an Emotion package).
110
+ * npm leaves these out, but a library's default setup can still need one
111
+ * (e.g., React libraries may need an Emotion package).
81
112
  * @param {string} dir The install folder.
82
113
  * @returns {Map<string, string>} Package name to range.
83
114
  */
@@ -113,8 +144,8 @@ export async function installOptionalPeers({ dir, unresolved, run = runNpm }) {
113
144
  const declared = declaredOptionalPeers(dir);
114
145
  const wanted = unresolved.filter((name) => declared.has(name) && !installedVersion(dir, name));
115
146
  if (wanted.length === 0) return { installed: [], warnings: [] };
116
- // A loose install prunes packages that only arrived as peers, so name React again to keep it.
117
- const keep = ["react", "react-dom"].flatMap((name) => (installedVersion(dir, name) ? [`${name}@${installedVersion(dir, name)}`] : []));
147
+ // A loose install prunes packages that only arrived as peers, so name the framework again to keep it.
148
+ const keep = pinnedRuntime(dir);
118
149
  const specs = [...wanted.map((name) => `${name}@${declared.get(name)}`), ...keep];
119
150
  try {
120
151
  await run(["install", ...specs, "--legacy-peer-deps", ...NPM_FLAGS], dir);
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Wait for fades and slides to finish before a state is scanned, so contrast is measured on the settled colors and not on a
3
+ * half-transparent element mid-transition. Animations that never end (a spinner) are left alone. Waits at most 2 seconds.
4
+ * @param {import("playwright-core").Page} page
5
+ */
6
+ export async function settleAnimations(page) {
7
+ await page.evaluate(async () => {
8
+ const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
9
+ await frame();
10
+ const finite = () => document.getAnimations().filter((a) => a.effect && a.effect.getComputedTiming().endTime !== Infinity);
11
+ const deadline = Date.now() + 2000;
12
+ while (finite().length > 0 && Date.now() < deadline) {
13
+ await Promise.race([Promise.allSettled(finite().map((a) => a.finished)), new Promise((done) => setTimeout(done, 200))]);
14
+ await frame();
15
+ }
16
+ });
17
+ }
@@ -12,6 +12,7 @@ export const ARCHETYPE_PATTERNS = {
12
12
  "form-field": /\binputs?\b|\btext ?(field|area|box)\b|\bform\b|\bcheckbox(es)?\b|\bradio\b|\bswitch\b|\bfield\b/i,
13
13
  accordion: /\baccordions?\b|\bcollaps(e|ible)\b|\bdisclosure\b/i,
14
14
  tooltip: /\btooltips?\b|\bpopovers?\b/i,
15
+ "live-region": /\balerts?\b(?! ?dialogs?)|\bstatus\b|\btoasts?\b|\bsnackbars?\b|\bnotifications?\b|\blive ?regions?\b/i,
15
16
  chart: /\bcharts?\b|\bgraphs?\b|\bplots?\b/i,
16
17
  };
17
18
 
@@ -8,12 +8,12 @@ const NETWORK_IDLE_MS = 30_000;
8
8
  * The returned `warnings` hold anything the report should mention, such as a network that never went idle.
9
9
  * @param {import("playwright-core").Browser} browser
10
10
  * @param {string} url
11
- * @param {{ viewport?: { width: number, height: number }, forcedColors?: boolean, beforeGoto?: (page: import("playwright-core").Page) => void | Promise<unknown>, waitUntil?: "load" | "networkidle" }} [options]
11
+ * @param {{ viewport?: { width: number, height: number }, forcedColors?: boolean, colorScheme?: "light" | "dark", reducedMotion?: boolean, contrast?: "more" | "less", reducedTransparency?: boolean, beforeGoto?: (page: import("playwright-core").Page) => void | Promise<unknown>, waitUntil?: "load" | "networkidle" }} [options]
12
12
  * `beforeGoto` runs before navigation, so a caller can attach console listeners that see the first messages.
13
13
  * `waitUntil` defaults to `networkidle`. Local fixture pages don't need to wait for the network.
14
14
  */
15
- export async function openPage(browser, url, { viewport = VIEWPORT, forcedColors = false, beforeGoto, waitUntil = "networkidle" } = {}) {
16
- const context = await browser.newContext({ viewport, deviceScaleFactor: 1, forcedColors: forcedColors ? "active" : "none" });
15
+ export async function openPage(browser, url, { viewport = VIEWPORT, forcedColors = false, colorScheme = "light", reducedMotion = false, contrast, reducedTransparency = false, beforeGoto, waitUntil = "networkidle" } = {}) {
16
+ const context = await browser.newContext({ viewport, deviceScaleFactor: 1, forcedColors: forcedColors ? "active" : "none", colorScheme, reducedMotion: reducedMotion ? "reduce" : "no-preference" });
17
17
  await context.addInitScript(recordClosedShadowRoots);
18
18
  await context.addInitScript(recordCustomElements);
19
19
  /** @type {string[]} */
@@ -21,6 +21,16 @@ export async function openPage(browser, url, { viewport = VIEWPORT, forcedColors
21
21
  try {
22
22
  const page = await context.newPage();
23
23
  page.setDefaultTimeout(30_000);
24
+ // Playwright can't set prefers-reduced-transparency or prefers-contrast: less, so ask the browser directly.
25
+ // A page opened this way sets nothing else, because the call replaces any other emulated media features.
26
+ const features = [
27
+ ...(contrast ? [{ name: "prefers-contrast", value: contrast }] : []),
28
+ ...(reducedTransparency ? [{ name: "prefers-reduced-transparency", value: "reduce" }] : []),
29
+ ];
30
+ if (features.length) {
31
+ const session = await context.newCDPSession(page);
32
+ await session.send("Emulation.setEmulatedMedia", { features });
33
+ }
24
34
  await beforeGoto?.(page);
25
35
  let response;
26
36
  try {
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, extname, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { cleanSubpath } from "./subpath.js";
5
6
 
6
7
  /**
7
8
  * @typedef {"npm" | "storybook" | "url" | "html-file" | "static-dir"} TargetKind
@@ -22,7 +23,7 @@ import { fileURLToPath } from "node:url";
22
23
  const LOCAL_PATH = /^(\.{1,2}(\/|$)|\/|~(\/|$)|file:)/;
23
24
  const LABEL = /^([A-Za-z0-9][\w.-]*)=(.+)$/;
24
25
  const HTTP_URL = /^https?:\/\//i;
25
- const NPM_NAME = /^(?:@([a-z0-9][a-z0-9._~-]*)\/)?([a-z0-9][a-z0-9._~-]*)(?:@(.*))?$/;
26
+ const NPM_NAME = /^(?:@([a-z0-9][a-z0-9._~-]*)\/)?([a-z0-9][a-z0-9._~-]*)(?:@([^/]*))?(?:\/(.+))?$/;
26
27
  const COMPONENT_SOURCE = new Set([".tsx", ".jsx", ".ts", ".js", ".mjs", ".cjs", ".vue", ".svelte"]);
27
28
 
28
29
  /**
@@ -129,17 +130,23 @@ function classifyNpm(spec, base) {
129
130
  if (/[A-Z]/.test(spec) && NPM_NAME.test(spec.toLowerCase())) return failed(`"${spec}" isn't a valid package name. npm package names are lowercase.`, base);
130
131
  return failed(`"${spec}" isn't a valid npm package name. Write npm:name, npm:@scope/name, or npm:name@version.`, base);
131
132
  }
132
- const [, scope, name, version] = match;
133
+ const [, scope, name, version, rawSubpath] = match;
133
134
  if (version === "") return failed(`"${spec}" ends with @ but has no version.`, base);
135
+ let subpath = null;
136
+ if (rawSubpath !== undefined) {
137
+ const cleaned = cleanSubpath(rawSubpath);
138
+ if ("reason" in cleaned) return failed(`"${spec}" isn't a valid sub-path: ${cleaned.reason}. Write npm:name/sub/path or npm:name@version/sub/path.`, base);
139
+ subpath = /** @type {{ subpath: string }} */ (cleaned).subpath;
140
+ }
134
141
  const full = scope ? `@${scope}/${name}` : name;
135
142
  return {
136
143
  ...base,
137
- name: base.name || full,
144
+ name: base.name || (subpath ? `${full}/${subpath}` : full),
138
145
  status: "ok",
139
146
  reason: null,
140
147
  kind: "npm",
141
148
  evidenceLevel: "component",
142
- resolved: { name: full, requested: version ?? null, version: null },
149
+ resolved: { name: full, requested: version ?? null, version: null, subpath },
143
150
  };
144
151
  }
145
152
 
@@ -14,6 +14,7 @@ const BASE_NAMES = {
14
14
  "form-field": ["input", "textfield", "textinput", "field", "checkbox"],
15
15
  accordion: ["accordion", "collapsible", "disclosure"],
16
16
  tooltip: ["tooltip", "popover"],
17
+ "live-region": ["alert", "status", "toast", "snackbar", "notification", "liveregion"],
17
18
  chart: ["chart", "linechart", "barchart"],
18
19
  };
19
20
 
@@ -24,21 +25,23 @@ export const TEMPLATED = new Set(["button", "link"]);
24
25
  const words = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[-_]/g, " ");
25
26
 
26
27
  /** How well a name fits an archetype. 0 means it doesn't. */
27
- function score(archetype, name) {
28
+ export function score(archetype, name) {
28
29
  if (!ARCHETYPE_PATTERNS[archetype].test(words(name))) return 0;
29
30
  const compact = name.replace(/[-_\s]/g, "").toLowerCase();
30
31
  const last = words(name).toLowerCase().split(" ").pop();
31
32
  const bases = BASE_NAMES[archetype];
32
- if (bases.includes(compact)) return 4;
33
- if (bases.includes(last)) return 3;
34
- if (bases.some((base) => compact.startsWith(base))) return 2;
33
+ // Names listed first are closer to the archetype itself, so `tooltip` beats `popover` when both are there.
34
+ if (bases.includes(compact)) return 4 - bases.indexOf(compact) * 0.1;
35
+ if (bases.includes(last)) return 3 - bases.indexOf(last) * 0.1;
36
+ const starts = bases.findIndex((base) => compact.startsWith(base));
37
+ if (starts !== -1) return 2 - starts * 0.1;
35
38
  return 1;
36
39
  }
37
40
 
38
41
  /**
39
- * Guess which exports (React) or tags (web components) stand for each archetype.
42
+ * Guess which exports (React or Vue) or tags (web components) stand for each archetype.
40
43
  * The result is a starting point. A person or the skill checks it before trusting it.
41
- * @param {{ flavor: "react" | "wc", exports?: Array<{ name: string, type: string, parts: string[] }>, tags?: string[] }} input
44
+ * @param {{ flavor: "react" | "vue" | "wc", exports?: Array<{ name: string, type: string, parts: string[] }>, tags?: string[] }} input
42
45
  * @returns {Record<string, any>}
43
46
  */
44
47
  export function candidateMapping({ flavor, exports = [], tags = [] }) {
@@ -59,7 +62,7 @@ export function candidateMapping({ flavor, exports = [], tags = [] }) {
59
62
  const parts = info?.parts ?? [];
60
63
  // Flat compound libraries (DialogRoot, DialogTrigger, DialogContent) have sibling exports that share a prefix.
61
64
  // A button or link usually sits beside ButtonBase, ButtonGroup, and the like, which aren't its parts, so only real parts (Button.Root) count there.
62
- const siblings = flavor === "react" && !TEMPLATED.has(archetype) ? names.filter((n) => n !== best && n.startsWith(best) && n.length > best.length) : [];
65
+ const siblings = flavor !== "wc" && !TEMPLATED.has(archetype) ? names.filter((n) => n !== best && n.startsWith(best) && n.length > best.length) : [];
63
66
  const compound = parts.length > 0 || siblings.length >= 2;
64
67
  const templated = TEMPLATED.has(archetype) && !compound;
65
68
  mapping[archetype] = {
@@ -1,8 +1,9 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { ADAPTERS } from "../frameworks/index.js";
3
+ import { checkExports, notExportedMessage } from "./subpath.js";
2
4
 
3
- const FIELDS = ["name", "version", "peerDependencies", "dependencies", "keywords", "customElements", "deprecated"];
5
+ const FIELDS = ["name", "version", "peerDependencies", "dependencies", "keywords", "customElements", "deprecated", "exports"];
4
6
  const OTHER_FRAMEWORKS = {
5
- vue: "Vue",
6
7
  "@angular/core": "Angular",
7
8
  svelte: "Svelte",
8
9
  "solid-js": "Solid",
@@ -44,19 +45,19 @@ export function npmView(spec, { timeoutMs = 60_000 } = {}) {
44
45
  }
45
46
 
46
47
  /**
47
- * Guess how a package renders from its metadata alone. React wins when both signals appear.
48
+ * Guess how a package renders from its metadata alone. React wins when both signals appear, then a custom elements manifest, then Vue.
48
49
  * `npm` means the metadata can't say, so the run decides after it installs and loads the package.
49
50
  * @param {any} meta
50
- * @returns {{ kind: "npm-react" | "npm-wc" | "npm-unsupported" | "npm", framework: string | null, reason: string }}
51
+ * @returns {{ kind: "npm-react" | "npm-vue" | "npm-wc" | "npm-unsupported" | "npm", framework: string | null, reason: string }}
51
52
  */
52
53
  export function detectFlavor(meta) {
53
54
  const peers = meta.peerDependencies ?? {};
54
55
  const deps = meta.dependencies ?? {};
55
- if ("react" in peers || "react-dom" in peers || "react" in deps) {
56
- const how = "react" in peers || "react-dom" in peers ? "peer dependency" : "dependency";
57
- return { kind: "npm-react", framework: "React", reason: `The package lists react as a ${how}.` };
58
- }
56
+ const react = /** @type {any} */ (ADAPTERS.react.detect(meta));
57
+ if (react) return react;
59
58
  if (meta.customElements) return { kind: "npm-wc", framework: "Web components", reason: "The package has a customElements manifest." };
59
+ const vue = /** @type {any} */ (ADAPTERS.vue.detect(meta));
60
+ if (vue) return vue;
60
61
  for (const [name, label] of Object.entries(OTHER_FRAMEWORKS)) {
61
62
  if (name in peers) return { kind: "npm-unsupported", framework: label, reason: `The package needs ${label}.` };
62
63
  }
@@ -73,9 +74,14 @@ export function detectFlavor(meta) {
73
74
  */
74
75
  export async function resolveNpmTarget(target, view) {
75
76
  if (target.status !== "ok" || target.kind !== "npm" || !target.resolved) return target;
76
- const { name, requested } = target.resolved;
77
+ const { name, requested, subpath } = target.resolved;
77
78
  try {
78
79
  const meta = await view(`${name}@${requested ?? "latest"}`);
80
+ // The registry lists a package's exports, so a wrong sub-path is caught here, before anything is installed.
81
+ if (subpath) {
82
+ const result = checkExports(meta.exports, subpath);
83
+ if (result.checked && !result.ok) throw new Error(notExportedMessage({ name, version: meta.version ?? null, subpath, exact: result.exact, patterns: result.patterns }));
84
+ }
79
85
  const flavor = detectFlavor(meta);
80
86
  return {
81
87
  ...target,
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Sub-paths of an npm package: `@scope/pkg/button/v2` imports `button/v2` from `@scope/pkg`.
3
+ * A package says what it lets people import in its `exports` field. A package without one lets people import any file in it.
4
+ */
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ /**
9
+ * Split a sub-path into clean segments, or say what's wrong with it. Rejects empty parts, `.` and `..`, and backslashes,
10
+ * so a sub-path can't point outside the package. A trailing slash is dropped.
11
+ * @param {string} text
12
+ * @returns {{ ok: true, subpath: string } | { ok: false, reason: string }}
13
+ */
14
+ export function cleanSubpath(text) {
15
+ const trimmed = text.replace(/\/+$/, "");
16
+ if (!trimmed) return { ok: false, reason: "the sub-path is empty" };
17
+ if (trimmed.includes("\\")) return { ok: false, reason: "a sub-path uses forward slashes" };
18
+ const segments = trimmed.split("/");
19
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) return { ok: false, reason: 'a sub-path can\'t contain empty parts, "." or ".."' };
20
+ return { ok: true, subpath: segments.join("/") };
21
+ }
22
+
23
+ /**
24
+ * The export keys that name a sub-path (`./button`), and the patterns (`./es/*`), from an `exports` field.
25
+ * A string, an array, or an object with no `./` keys only exports the package root.
26
+ */
27
+ function exportKeys(exportsField) {
28
+ if (!exportsField || typeof exportsField !== "object" || Array.isArray(exportsField)) return { exact: [], patterns: [], hasMap: false };
29
+ const keys = Object.keys(exportsField).filter((key) => key === "." || key.startsWith("./"));
30
+ if (keys.length === 0) return { exact: [], patterns: [], hasMap: false };
31
+ const usable = keys.filter((key) => exportsField[key] !== null);
32
+ return { exact: usable.filter((key) => !key.includes("*") && key !== "." && key !== "./package.json"), patterns: usable.filter((key) => key.includes("*")), hasMap: true };
33
+ }
34
+
35
+ /** Does a pattern key like `./es/*` or `./features/*.js` match a sub-path? */
36
+ function matchesPattern(pattern, subpath) {
37
+ const [before, after] = pattern.slice(2).split("*");
38
+ return subpath.length >= before.length + after.length && subpath.startsWith(before) && subpath.endsWith(after);
39
+ }
40
+
41
+ /**
42
+ * Does the package's `exports` field let a sub-path be imported? Returns the sub-paths to suggest when it doesn't.
43
+ * `package.json` is always allowed. A package with no exports map isn't judged here, because its files are the answer.
44
+ * @param {unknown} exportsField
45
+ * @param {string} subpath
46
+ * @returns {{ checked: boolean, ok: boolean, exact: string[], patterns: string[] }}
47
+ */
48
+ export function checkExports(exportsField, subpath) {
49
+ const { exact, patterns, hasMap } = exportKeys(exportsField);
50
+ if (!hasMap) {
51
+ // A string or array exports field means only the root is exported. No field means nothing here can say.
52
+ return { checked: exportsField !== undefined && exportsField !== null, ok: false, exact: [], patterns: [] };
53
+ }
54
+ const key = `./${subpath}`;
55
+ const ok = key === "./package.json" || exact.includes(key) || patterns.some((pattern) => matchesPattern(pattern, subpath));
56
+ return { checked: true, ok, exact, patterns };
57
+ }
58
+
59
+ /**
60
+ * What's wrong with a sub-path of an installed package, or null when it can be imported.
61
+ * @param {string} workDir The folder the package was installed into.
62
+ * @param {string} name
63
+ * @param {string} subpath
64
+ * @param {string | null} version
65
+ * @returns {string | null}
66
+ */
67
+ export function subpathProblem(workDir, name, subpath, version) {
68
+ const packageDir = join(workDir, "node_modules", name);
69
+ let manifest;
70
+ try {
71
+ manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
72
+ } catch {
73
+ return `${name} was installed, but its package.json couldn't be read to check the sub-path "${subpath}".`;
74
+ }
75
+ const result = checkExports(manifest.exports, subpath);
76
+ if (result.checked) return result.ok ? null : notExportedMessage({ name, version, subpath, exact: result.exact, patterns: result.patterns });
77
+ return fileExists(packageDir, subpath) ? null : `"${name}/${subpath}" isn't a file in ${name}${version ? `@${version}` : ""}, and the package has no exports map that lists what it offers.`;
78
+ }
79
+
80
+ /** The extensions a file can be imported without writing, for a package with no exports map. */
81
+ const EXTENSIONS = ["", ".js", ".mjs", ".cjs", ".json", "/index.js", "/index.mjs", "/index.cjs"];
82
+
83
+ /** Is there a file for this sub-path in an installed package that has no exports map? */
84
+ export function fileExists(packageDir, subpath) {
85
+ return EXTENSIONS.some((extension) => existsSync(join(packageDir, `${subpath}${extension}`)));
86
+ }
87
+
88
+ /** How many single-character edits turn one word into another. */
89
+ function distance(a, b) {
90
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
91
+ for (let i = 1; i <= a.length; i += 1) {
92
+ const row = [i];
93
+ for (let j = 1; j <= b.length; j += 1) row.push(Math.min(previous[j] + 1, row[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)));
94
+ previous = row;
95
+ }
96
+ return previous[b.length];
97
+ }
98
+
99
+ /** The exports closest to what was asked for: a shared start, a shared last part, or a typo of it. */
100
+ function closest(subpath, exact) {
101
+ const wanted = subpath.toLowerCase().split("/");
102
+ const last = wanted[wanted.length - 1];
103
+ return exact
104
+ .map((key) => {
105
+ const parts = key.slice(2).toLowerCase().split("/");
106
+ const shared = parts.findIndex((part, i) => part !== wanted[i]);
107
+ const lead = shared === -1 ? parts.length : shared;
108
+ const tail = parts[parts.length - 1];
109
+ const rank = lead * 2 + (tail.includes(last) || last.includes(tail) ? 1 : 0) + (distance(last, tail) <= 2 ? 2 : 0);
110
+ return { key, rank };
111
+ })
112
+ .filter((entry) => entry.rank > 0)
113
+ .sort((a, b) => b.rank - a.rank || a.key.length - b.key.length)
114
+ .slice(0, 3)
115
+ .map((entry) => entry.key);
116
+ }
117
+
118
+ /**
119
+ * The message for a sub-path a package doesn't offer, with the closest matches first.
120
+ * @param {{ name: string, version: string | null, subpath: string, exact: string[], patterns: string[] }} input
121
+ */
122
+ export function notExportedMessage({ name, version, subpath, exact, patterns }) {
123
+ const spec = (key) => `${name}${key === "." ? "" : key.slice(1)}`;
124
+ const close = closest(subpath, exact);
125
+ const shown = exact.slice(0, 12).map(spec);
126
+ const more = exact.length > shown.length ? `, and ${exact.length - shown.length} more` : "";
127
+ const parts = [`"${name}/${subpath}" isn't something ${name}${version ? `@${version}` : ""} exports.`];
128
+ if (close.length) parts.push(`Did you mean ${close.map(spec).join(" or ")}?`);
129
+ if (exact.length) parts.push(`It exports ${shown.join(", ")}${more}.`);
130
+ if (patterns.length) parts.push(`It also exports files by pattern: ${patterns.slice(0, 4).map(spec).join(", ")}.`);
131
+ if (!exact.length && !patterns.length) parts.push("It exports only its main entry.");
132
+ return parts.join(" ");
133
+ }
@@ -3,11 +3,13 @@ import { num, plural } from "../text.js";
3
3
  import {
4
4
  ENGINE_NAMES,
5
5
  FINDINGS_NOTE,
6
+ GENERATED_NOTE,
6
7
  TIER_NAMES,
7
8
  cell,
8
9
  code,
9
10
  configLabel,
10
11
  finish,
12
+ hasGenerated,
11
13
  notTestableLines,
12
14
  reportFooter,
13
15
  reportHeader,
@@ -15,7 +17,7 @@ import {
15
17
  targetSection,
16
18
  } from "./parts.js";
17
19
 
18
- const TIER_ORDER = ["rules", "interactions", "computed", "vsr"];
20
+ const TIER_ORDER = ["rules", "interactions", "computed", "conditions", "vsr"];
19
21
 
20
22
  /** The archetype rows: component archetypes in a fixed order, then whole pages, then Storybook stories. */
21
23
  function archetypeKeys(results) {
@@ -67,7 +69,9 @@ function coverageTable(plan, results, tier, keys) {
67
69
  // Name the configuration only when there's more than one to tell apart, or the row stands for several stories.
68
70
  const withTier = rows.filter((r) => r.configs?.some((c) => c.tiers[tier]));
69
71
  const labels = withTier.length > 1 || withTier[0]?.always ? withTier.map((r) => r.label).filter((l) => l && l !== "-") : [];
70
- return labels.length ? `${status} (${labels.join("; ")})` : status;
72
+ const generated = target.archetypes?.[key]?.fixture?.source === "generated" && String(status).startsWith("ran");
73
+ const base = labels.length ? `${status} (${labels.join("; ")})` : status;
74
+ return generated ? `${base} (generated fixture)` : base;
71
75
  });
72
76
  lines.push(`| ${key} | ${cells.join(" | ")} |`);
73
77
  }
@@ -104,9 +108,9 @@ function interactionsCell(configs) {
104
108
  return parts.filter(Boolean).join("; ");
105
109
  }
106
110
 
107
- function computedCell(configs) {
108
- const checks = configs.flatMap((c) => c.tiers.computed?.checks ?? []);
109
- if (checks.length === 0) return configs.map((c) => c.tiers.computed?.status).find(Boolean) ?? "-";
111
+ function measuredCell(configs, tier) {
112
+ const checks = configs.flatMap((c) => c.tiers[tier]?.checks ?? []);
113
+ if (checks.length === 0) return configs.map((c) => c.tiers[tier]?.status).find(Boolean) ?? "-";
110
114
  const failed = checks.filter((c) => c.result === "fail").map((c) => code(c.name));
111
115
  const unknown = checks.filter((c) => c.result === "undetermined").length;
112
116
  const errors = checks.filter((c) => c.result === "error").length;
@@ -123,17 +127,17 @@ function vsrCell(configs) {
123
127
  }
124
128
 
125
129
  function findingsTable(plan, results, key) {
126
- const head = ["Target", "Configuration", ENGINE_NAMES.axe, ENGINE_NAMES.ibm, "Interactions", "Computed checks", "Virtual screen reader (simulated)"];
130
+ const head = ["Target", "Configuration", ENGINE_NAMES.axe, ENGINE_NAMES.ibm, "Interactions", "Computed checks", "Conditions", "Virtual screen reader (simulated)"];
127
131
  const lines = [`| ${head.join(" | ")} |`, `| ${head.map(() => "---").join(" | ")} |`];
128
132
  for (const target of results.targets) {
129
133
  const planTarget = plan.targets.find((t) => t.id === target.id);
130
134
  for (const row of rowsFor(planTarget, target, key)) {
131
135
  if (row.note) {
132
- lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(row.note)} | | | | |`);
136
+ lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(row.note)} | | | | | |`);
133
137
  continue;
134
138
  }
135
139
  const c = row.configs;
136
- lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(ruleCell(c, "axe"))} | ${cell(ruleCell(c, "ibm"))} | ${cell(interactionsCell(c))} | ${cell(computedCell(c))} | ${cell(vsrCell(c))} |`);
140
+ lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(ruleCell(c, "axe"))} | ${cell(ruleCell(c, "ibm"))} | ${cell(interactionsCell(c))} | ${cell(measuredCell(c, "computed"))} | ${cell(measuredCell(c, "conditions"))} | ${cell(vsrCell(c))} |`);
137
141
  }
138
142
  }
139
143
  return lines;
@@ -180,6 +184,7 @@ export function renderComparison({ plan, results }) {
180
184
  lines.push(`### ${TIER_NAMES[tier]}${tier === "vsr" ? " (simulated)" : ""}.`, "", ...coverageTable(plan, results, tier, keys), "");
181
185
  }
182
186
  lines.push(...notTestableLines(results));
187
+ if (hasGenerated(results)) lines.push(GENERATED_NOTE, "");
183
188
 
184
189
  lines.push("## Findings.", "", FINDINGS_NOTE, "", ...(o.tiers.includes("rules") ? impactTables(results, o.engines) : []));
185
190
  for (const key of keys) {
@@ -187,7 +192,7 @@ export function renderComparison({ plan, results }) {
187
192
  }
188
193
 
189
194
  lines.push("## Details by target.", "", "Every finding, with its elements, rule help, and logs, for each target in turn.", "");
190
- for (const target of results.targets) lines.push(targetSection(plan.targets.find((t) => t.id === target.id), target));
195
+ for (const target of results.targets) lines.push(targetSection(plan.targets.find((t) => t.id === target.id), target, { generatedNote: false }));
191
196
  lines.push(...reportFooter(results));
192
197
  return finish(lines);
193
198
  }