automatica11y 0.4.1 → 0.7.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 +37 -130
  2. package/package.json +30 -2
  3. package/skills/automatica11y-runner/SKILL.md +16 -3
  4. package/skills/automatica11y-runner/references/fixtures.md +24 -0
  5. package/src/cli.js +1 -0
  6. package/src/commands/common.js +14 -11
  7. package/src/frameworks/angular-errors.js +27 -0
  8. package/src/frameworks/angular-selectors.js +36 -0
  9. package/src/frameworks/angular.js +162 -0
  10. package/src/frameworks/errors.js +12 -0
  11. package/src/frameworks/html.js +163 -0
  12. package/src/frameworks/index.js +13 -7
  13. package/src/frameworks/react.js +8 -4
  14. package/src/frameworks/svelte-errors.js +30 -0
  15. package/src/frameworks/svelte.js +162 -0
  16. package/src/frameworks/vue.js +2 -1
  17. package/src/globals.d.ts +123 -6
  18. package/src/harness/bundle.js +2 -1
  19. package/src/harness/generate/angular-recipes.js +360 -0
  20. package/src/harness/generate/dialects.js +35 -1
  21. package/src/harness/generate/jsx-recipes.js +4 -6
  22. package/src/harness/generate/probe.js +16 -5
  23. package/src/harness/npm-install.js +47 -11
  24. package/src/harness/shadow.js +1 -1
  25. package/src/harness/storybook.js +18 -5
  26. package/src/plan/build-plan.js +1 -0
  27. package/src/plan/classify.js +39 -11
  28. package/src/plan/mapping.js +8 -6
  29. package/src/plan/resolve-npm.js +49 -18
  30. package/src/plan/subpath.js +17 -0
  31. package/src/report/comparison.js +4 -1
  32. package/src/report/index.js +1 -1
  33. package/src/report/parts.js +13 -1
  34. package/src/report/single.js +1 -1
  35. package/src/run/audit-npm.js +95 -27
  36. package/src/run/fail-check.js +10 -2
  37. package/src/run/generate-fixture.js +7 -5
  38. package/src/run/run-plan.js +10 -8
  39. package/src/run/summary.js +1 -1
  40. package/src/schema.js +10 -2
  41. package/src/tiers/computed/checks.js +3 -1
  42. package/src/tiers/conditions/kit.js +3 -3
  43. package/src/tiers/interactions/archetypes.js +6 -2
  44. package/src/tiers/interactions/focus-indicator.js +4 -2
  45. package/src/tiers/interactions/helpers.js +2 -1
  46. package/src/tiers/rules/ibm.js +2 -2
  47. package/src/tiers/rules/index.js +1 -1
@@ -1,3 +1,4 @@
1
+ import { explainFrameworkError } from "../../frameworks/errors.js";
1
2
  import { installHelpers } from "../../tiers/interactions/helpers.js";
2
3
  import { openPage } from "../url.js";
3
4
  import { ROOT_ROLES } from "./marking.js";
@@ -25,9 +26,9 @@ export async function probeFixture(browser, url, archetype) {
25
26
  opened = await openPage(browser, url, {
26
27
  waitUntil: "load",
27
28
  beforeGoto: async (page) => {
28
- page.on("pageerror", (error) => errors.push(error.message.split("\n")[0]));
29
+ page.on("pageerror", (error) => errors.push(explainFrameworkError(error.message.split("\n")[0])));
29
30
  page.on("console", (message) => {
30
- if (message.type() === "error" && !/favicon|Failed to load resource/i.test(message.text())) errors.push(message.text().split("\n")[0]);
31
+ if (message.type() === "error" && !/favicon|Failed to load resource/i.test(message.text())) errors.push(explainFrameworkError(message.text().split("\n")[0]));
31
32
  });
32
33
  await page.addInitScript(installHelpers);
33
34
  },
@@ -60,8 +61,8 @@ export async function probeFixture(browser, url, archetype) {
60
61
  } catch (error) {
61
62
  return { ok: false, reason: `the trigger couldn't be activated: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}` };
62
63
  }
63
- try {
64
- await page.waitForFunction(
64
+ /** Wait for the surface. A tooltip gets a second chance below, so this one is short for it. */
65
+ const waitForSurface = (timeout) => page.waitForFunction(
65
66
  ({ live, needsRoot }) => {
66
67
  const state = window.__a11y.live();
67
68
  if (live) return state.present && state.visible && Boolean(state.text || state.named);
@@ -71,8 +72,18 @@ export async function probeFixture(browser, url, archetype) {
71
72
  return (state.present && state.visible) || trigger?.getAttribute("aria-expanded") === "true";
72
73
  },
73
74
  { live: archetype === "live-region", needsRoot: Boolean(ROOT_ROLES[archetype]) },
74
- { timeout: 4000 },
75
+ { timeout },
75
76
  );
77
+ try {
78
+ try {
79
+ await waitForSurface(archetype === "tooltip" ? 1500 : 4000);
80
+ } catch (error) {
81
+ if (archetype !== "tooltip") throw error;
82
+ // Some tooltips open only for focus that came from the keyboard, so focus the trigger again the way a person would.
83
+ await page.evaluate(() => /** @type {HTMLElement | null} */ (document.activeElement)?.blur());
84
+ await page.keyboard.press("Tab");
85
+ await waitForSurface(3000);
86
+ }
76
87
  } catch {
77
88
  return { ok: false, reason: `activating the trigger showed no ${archetype === "live-region" ? "message" : "element marked as the root"}${ROOT_ROLES[archetype] ? ` (looked for an element with role ${ROOT_ROLES[archetype].join(", ")})` : ""}, which can also mean the library doesn't set that role` };
78
89
  }
@@ -35,7 +35,7 @@ export function installedVersion(dir, name) {
35
35
  * Install a package into its own directory, never next to another target's install.
36
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" | "vue" | "wc" | "unknown", run?: typeof runNpm }} options
38
+ * @param {{ dir: string, name: string, version: string, flavor: "react" | "vue" | "angular" | "svelte" | "html" | "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 });
@@ -66,23 +66,59 @@ export async function installPackage({ dir, name, version, flavor, run = runNpm
66
66
  if (flavor === "react") {
67
67
  const react = installedVersion(dir, "react");
68
68
  if (!react) {
69
- await npm(["install", "react", "react-dom", ...NPM_FLAGS, "--legacy-peer-deps"]);
69
+ await npm(["install", "react", "react-dom", ...pinnedRuntime(dir, ["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
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
+ await npm(["install", `react@${react}`, `react-dom@${react}`, ...pinnedRuntime(dir, ["react", "react-dom"]), ...NPM_FLAGS, "--legacy-peer-deps"]);
74
74
  }
75
75
  }
76
76
  if (flavor === "vue" && !installedVersion(dir, "vue")) {
77
- await npm(["install", "vue", ...NPM_FLAGS, "--legacy-peer-deps"]);
77
+ await npm(["install", "vue", ...pinnedRuntime(dir, ["vue"]), ...NPM_FLAGS, "--legacy-peer-deps"]);
78
78
  warnings.push(`${name} didn't bring in vue, so the latest vue was added.`);
79
79
  }
80
- return { dir, warnings, react: installedVersion(dir, "react"), reactDom: installedVersion(dir, "react-dom"), vue: installedVersion(dir, "vue"), version: installedVersion(dir, name) };
80
+ if (flavor === "svelte" && !installedVersion(dir, "svelte")) {
81
+ await npm(["install", "svelte", ...pinnedRuntime(dir, ["svelte"]), ...NPM_FLAGS, "--legacy-peer-deps"]);
82
+ warnings.push(`${name} didn't bring in svelte, so the latest svelte was added.`);
83
+ }
84
+ if (flavor === "angular") {
85
+ // The Angular packages have to be the same version as core. A library's peers usually bring in core and common, not the compiler or the platform.
86
+ const core = installedVersion(dir, "@angular/core");
87
+ const missing = ANGULAR_RUNTIME.filter((pkg) => !installedVersion(dir, pkg));
88
+ if (!core) {
89
+ await npm(["install", ...ANGULAR_RUNTIME, ...pinnedRuntime(dir, ANGULAR_RUNTIME), ...NPM_FLAGS, "--legacy-peer-deps"]);
90
+ warnings.push(`${name} didn't bring in @angular/core, so the latest Angular packages were added.`);
91
+ } else if (missing.length) {
92
+ // Name everything already installed too. A loose install prunes a package that only arrived as a peer.
93
+ const adding = missing.map((pkg) => (pkg.startsWith("@angular/") ? `${pkg}@${core}` : pkg));
94
+ await npm(["install", ...adding, ...pinnedRuntime(dir, adding), ...NPM_FLAGS, "--legacy-peer-deps"]);
95
+ }
96
+ }
97
+ return { dir, warnings, react: installedVersion(dir, "react"), reactDom: installedVersion(dir, "react-dom"), vue: installedVersion(dir, "vue"), angular: installedVersion(dir, "@angular/core"), svelte: installedVersion(dir, "svelte"), version: installedVersion(dir, name) };
81
98
  }
82
99
 
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)}`] : []));
100
+ /** What an Angular fixture needs beside the library: core, the runtime compiler, the platform, and the pieces core uses. */
101
+ const ANGULAR_RUNTIME = ["@angular/core", "@angular/common", "@angular/compiler", "@angular/platform-browser", "rxjs"];
102
+
103
+ /**
104
+ * Every package installed at the top level, as exact specs. A loose install (--legacy-peer-deps) prunes a package that only arrived
105
+ * as someone's peer, which is how React, and then Angular's CDK, went missing. Naming everything that's already there keeps it all.
106
+ * A spec for a package in `except` is left out, because the caller is installing that one at a different version.
107
+ * @param {string} dir
108
+ * @param {string[]} [except] Package names, or specs such as `name@range`.
109
+ * @returns {string[]}
110
+ */
111
+ function pinnedRuntime(dir, except = []) {
112
+ const skip = new Set(except.map((spec) => spec.replace(/^(@?[^@]+)@.*$/, "$1")));
113
+ const root = join(dir, "node_modules");
114
+ /** @type {string[]} */
115
+ const names = [];
116
+ for (const entry of existsSync(root) ? readdirSync(root) : []) {
117
+ if (entry.startsWith(".")) continue;
118
+ if (entry.startsWith("@")) for (const inner of readdirSync(join(root, entry))) names.push(`${entry}/${inner}`);
119
+ else names.push(entry);
120
+ }
121
+ return names.filter((name) => !skip.has(name)).flatMap((name) => (installedVersion(dir, name) ? [`${name}@${installedVersion(dir, name)}`] : []));
86
122
  }
87
123
 
88
124
  const PACKAGE_SPEC = /^(@[a-z0-9~][\w.~-]*\/)?[a-z0-9~][\w.~-]*(@[\w.^~<>=*|-]+)?$/i;
@@ -98,7 +134,7 @@ export async function installExtraPackages({ dir, specs, run = runNpm }) {
98
134
  if (wanted.length === 0) return { installed: [], warnings: [] };
99
135
  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
136
  try {
101
- await run(["install", ...wanted, ...pinnedRuntime(dir), "--legacy-peer-deps", ...NPM_FLAGS], dir);
137
+ await run(["install", ...wanted, ...pinnedRuntime(dir, wanted), "--legacy-peer-deps", ...NPM_FLAGS], dir);
102
138
  } catch (error) {
103
139
  throw new Error(`npm couldn't install ${wanted.join(", ")}: ${firstLine(error.message)}`);
104
140
  }
@@ -145,8 +181,8 @@ export async function installOptionalPeers({ dir, unresolved, run = runNpm }) {
145
181
  const wanted = unresolved.filter((name) => declared.has(name) && !installedVersion(dir, name));
146
182
  if (wanted.length === 0) return { installed: [], warnings: [] };
147
183
  // A loose install prunes packages that only arrived as peers, so name the framework again to keep it.
148
- const keep = pinnedRuntime(dir);
149
- const specs = [...wanted.map((name) => `${name}@${declared.get(name)}`), ...keep];
184
+ const adding = wanted.map((name) => `${name}@${declared.get(name)}`);
185
+ const specs = [...adding, ...pinnedRuntime(dir, adding)];
150
186
  try {
151
187
  await run(["install", ...specs, "--legacy-peer-deps", ...NPM_FLAGS], dir);
152
188
  } catch (error) {
@@ -18,7 +18,7 @@ export function recordClosedShadowRoots() {
18
18
  * @returns {Promise<Record<string, number>>}
19
19
  */
20
20
  export async function closedShadowHosts(page) {
21
- const hosts = await page.evaluate(() => /** @type {any} */ (window).__a11yClosedShadowHosts ?? []).catch(() => []);
21
+ const hosts = await page.evaluate(() => window.__a11yClosedShadowHosts ?? []).catch(() => []);
22
22
  /** @type {Record<string, number>} */
23
23
  const counts = {};
24
24
  for (const host of hosts) counts[host] = (counts[host] ?? 0) + 1;
@@ -16,20 +16,32 @@ export const ARCHETYPE_PATTERNS = {
16
16
  chart: /\bcharts?\b|\bgraphs?\b|\bplots?\b/i,
17
17
  };
18
18
 
19
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
20
+ function isRecord(value) {
21
+ return typeof value === "object" && value !== null && !Array.isArray(value);
22
+ }
23
+
24
+ /** @param {unknown} value @returns {value is unknown[]} */
25
+ function isArray(value) {
26
+ return Array.isArray(value);
27
+ }
28
+
19
29
  /**
20
30
  * Pull the stories out of a Storybook index. Handles `index.json` (`entries`, with docs pages) and the older `stories.json`.
21
- * @param {any} index
31
+ * @param {unknown} index
22
32
  * @returns {Array<{ id: string, title: string, name: string, tags: string[] }>}
23
33
  */
24
34
  export function listStories(index) {
25
- const table = index.entries ?? index.stories ?? {};
35
+ const data = isRecord(index) ? index : {};
36
+ const table = (isRecord(data.entries) ? data.entries : null) ?? (isRecord(data.stories) ? data.stories : {});
26
37
  return Object.values(table)
27
- .filter((entry) => entry && typeof entry === "object" && (entry.type === undefined || entry.type === "story"))
38
+ .filter(isRecord)
39
+ .filter((entry) => entry.type === undefined || entry.type === "story")
28
40
  .map((entry) => ({
29
41
  id: String(entry.id),
30
42
  title: String(entry.title ?? entry.kind ?? ""),
31
43
  name: String(entry.name ?? entry.story ?? ""),
32
- tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [],
44
+ tags: isArray(entry.tags) ? entry.tags.map(String) : [],
33
45
  }))
34
46
  .sort((a, b) => a.id.localeCompare(b.id));
35
47
  }
@@ -88,7 +100,8 @@ export function storyUrl(base, id) {
88
100
  /**
89
101
  * Read a Storybook's index from disk or over HTTP.
90
102
  * @param {{ path?: string | null, url?: string | null, index: string }} resolved
91
- * @param {Function} [doFetch]
103
+ * @param {(url: URL, options?: { signal?: AbortSignal }) => Promise<{ ok: boolean, status: number, json(): Promise<unknown> }>} [doFetch]
104
+ * @returns {Promise<unknown>}
92
105
  */
93
106
  export async function readIndex(resolved, doFetch = globalThis.fetch) {
94
107
  if (resolved.path) return JSON.parse(readFileSync(join(resolved.path, resolved.index), "utf8"));
@@ -36,6 +36,7 @@ export async function buildPlan({ command, targets, options, browserVersion = nu
36
36
  kind: target.kind,
37
37
  evidenceLevel: target.evidenceLevel,
38
38
  resolved: target.resolved,
39
+ ...(target.companions?.length ? { companions: target.companions } : {}),
39
40
  mapping: null,
40
41
  };
41
42
  });
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { cleanSubpath } from "./subpath.js";
6
6
 
7
7
  /**
8
- * @typedef {"npm" | "storybook" | "url" | "html-file" | "static-dir"} TargetKind
8
+ * @typedef {"npm" | "npm-react" | "npm-vue" | "npm-angular" | "npm-svelte" | "npm-html" | "npm-wc" | "npm-unsupported" | "storybook" | "url" | "html-file" | "static-dir"} TargetKind
9
9
  * @typedef {{
10
10
  * input: string,
11
11
  * label: string | null,
@@ -15,9 +15,10 @@ import { cleanSubpath } from "./subpath.js";
15
15
  * kind: TargetKind | null,
16
16
  * evidenceLevel: "component" | "page" | null,
17
17
  * resolved: Record<string, string | null> | null,
18
+ * companions?: Array<{ name: string, requested: string | null, version: string | null, subpath: string | null }>,
18
19
  * }} ClassifiedTarget
19
20
  * @typedef {(url: string | URL, init?: { signal?: AbortSignal, redirect?: string }) => Promise<{ ok: boolean, status: number, text(): Promise<string> }>} FetchLike
20
- * @typedef {{ cwd?: string, home?: string, fetch?: FetchLike, timeoutMs?: number, npmView?: (spec: string) => Promise<any> }} ClassifyContext
21
+ * @typedef {{ cwd?: string, home?: string, fetch?: FetchLike, timeoutMs?: number, npmView?: (spec: string) => Promise<unknown> }} ClassifyContext
21
22
  */
22
23
 
23
24
  const LOCAL_PATH = /^(\.{1,2}(\/|$)|\/|~(\/|$)|file:)/;
@@ -123,30 +124,57 @@ async function classifyUrl(spec, base, ctx) {
123
124
  }
124
125
  }
125
126
 
126
- /** @param {string} spec @param {{ input: string, label: string | null, name: string }} base @returns {ClassifiedTarget} */
127
- function classifyNpm(spec, base) {
127
+ /**
128
+ * Read one npm spec: `name`, `name@version`, or either with a sub-path.
129
+ * @param {string} spec
130
+ * @returns {{ name: string, requested: string | null, subpath: string | null } | { error: string }}
131
+ */
132
+ function parseNpmSpec(spec) {
128
133
  const match = NPM_NAME.exec(spec);
129
134
  if (!match) {
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);
131
- return failed(`"${spec}" isn't a valid npm package name. Write npm:name, npm:@scope/name, or npm:name@version.`, base);
135
+ if (/[A-Z]/.test(spec) && NPM_NAME.test(spec.toLowerCase())) return { error: `"${spec}" isn't a valid package name. npm package names are lowercase.` };
136
+ return { error: `"${spec}" isn't a valid npm package name. Write npm:name, npm:@scope/name, or npm:name@version.` };
132
137
  }
133
138
  const [, scope, name, version, rawSubpath] = match;
134
- if (version === "") return failed(`"${spec}" ends with @ but has no version.`, base);
139
+ if (version === "") return { error: `"${spec}" ends with @ but has no version.` };
135
140
  let subpath = null;
136
141
  if (rawSubpath !== undefined) {
137
142
  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);
143
+ if ("reason" in cleaned) return { error: `"${spec}" isn't a valid sub-path: ${cleaned.reason}. Write npm:name/sub/path or npm:name@version/sub/path.` };
139
144
  subpath = /** @type {{ subpath: string }} */ (cleaned).subpath;
140
145
  }
141
- const full = scope ? `@${scope}/${name}` : name;
146
+ return { name: scope ? `@${scope}/${name}` : name, requested: version ?? null, subpath };
147
+ }
148
+
149
+ /**
150
+ * An npm target. A comma-separated list (`npm:a,b@1.2`) is one target made of several packages that install into one folder.
151
+ * The first is the primary: it names the target and decides the framework. The rest are companions, such as a script package
152
+ * that goes with a stylesheet package.
153
+ * @param {string} spec @param {{ input: string, label: string | null, name: string }} base @returns {ClassifiedTarget}
154
+ */
155
+ function classifyNpm(spec, base) {
156
+ const entries = spec.split(",").map((entry) => entry.trim());
157
+ if (entries.length > 1 && entries.some((entry) => entry === "")) {
158
+ return failed(`"${spec}" has an empty entry in its list. Write the packages with commas and no spaces, for example npm:a,b.`, base);
159
+ }
160
+ const parsed = entries.map(parseNpmSpec);
161
+ for (const entry of parsed) if ("error" in entry) return failed(entry.error, base);
162
+ const [primary, ...companions] = /** @type {Array<{ name: string, requested: string | null, subpath: string | null }>} */ (parsed);
163
+ const seen = new Set();
164
+ for (const entry of [primary, ...companions]) {
165
+ const key = `${entry.name}/${entry.subpath ?? ""}`;
166
+ if (seen.has(key)) return failed(`"${entry.name}${entry.subpath ? `/${entry.subpath}` : ""}" is in the list more than once.`, base);
167
+ seen.add(key);
168
+ }
142
169
  return {
143
170
  ...base,
144
- name: base.name || (subpath ? `${full}/${subpath}` : full),
171
+ name: base.name || (primary.subpath ? `${primary.name}/${primary.subpath}` : primary.name),
145
172
  status: "ok",
146
173
  reason: null,
147
174
  kind: "npm",
148
175
  evidenceLevel: "component",
149
- resolved: { name: full, requested: version ?? null, version: null, subpath },
176
+ resolved: { name: primary.name, requested: primary.requested, version: null, subpath: primary.subpath },
177
+ ...(companions.length ? { companions: companions.map((c) => ({ name: c.name, requested: c.requested, version: null, subpath: c.subpath })) } : {}),
150
178
  };
151
179
  }
152
180
 
@@ -39,14 +39,14 @@ export function score(archetype, name) {
39
39
  }
40
40
 
41
41
  /**
42
- * Guess which exports (React or Vue) or tags (web components) stand for each archetype.
42
+ * Guess which exports (React, Vue, or Angular) or tags (web components) stand for each archetype.
43
43
  * The result is a starting point. A person or the skill checks it before trusting it.
44
- * @param {{ flavor: "react" | "vue" | "wc", exports?: Array<{ name: string, type: string, parts: string[] }>, tags?: string[] }} input
45
- * @returns {Record<string, any>}
44
+ * @param {{ flavor: "react" | "vue" | "angular" | "svelte" | "html" | "wc", exports?: Array<{ name: string, type: string, parts: string[] }>, tags?: string[] }} input
45
+ * @returns {ReturnType<typeof parseMappingFile>[string]}
46
46
  */
47
47
  export function candidateMapping({ flavor, exports = [], tags = [] }) {
48
48
  const names = flavor === "wc" ? tags : exports.filter((e) => /^[A-Z]/.test(e.name)).map((e) => e.name);
49
- /** @type {Record<string, any>} */
49
+ /** @type {ReturnType<typeof parseMappingFile>[string]} */
50
50
  const mapping = {};
51
51
  for (const archetype of ARCHETYPES) {
52
52
  const ranked = names
@@ -64,7 +64,9 @@ export function candidateMapping({ flavor, exports = [], tags = [] }) {
64
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.
65
65
  const siblings = flavor !== "wc" && !TEMPLATED.has(archetype) ? names.filter((n) => n !== best && n.startsWith(best) && n.length > best.length) : [];
66
66
  const compound = parts.length > 0 || siblings.length >= 2;
67
- const templated = TEMPLATED.has(archetype) && !compound;
67
+ // A button written as `Button.Root` (a namespace whose only part is the root) is still one element, so a template can use the root.
68
+ const hasRoot = TEMPLATED.has(archetype) && parts.length === 1 && /^root$/i.test(parts[0]);
69
+ const templated = TEMPLATED.has(archetype) && (!compound || hasRoot);
68
70
  mapping[archetype] = {
69
71
  flavor,
70
72
  ...(flavor === "wc" ? { tag: best } : { export: best }),
@@ -97,7 +99,7 @@ export function findAuthoredFixture({ cwd, targetId, archetype, mapped }) {
97
99
  const file = resolve(cwd, mapped.fixture);
98
100
  return existsSync(file) ? file : null;
99
101
  }
100
- for (const ext of ["jsx", "js"]) {
102
+ for (const ext of ["jsx", "js", "ts", "svelte", "html"]) {
101
103
  const file = resolve(cwd, "fixtures", targetId, `${archetype}.${ext}`);
102
104
  if (existsSync(file)) return file;
103
105
  }
@@ -1,11 +1,10 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { isAsset } from "../frameworks/html.js";
2
3
  import { ADAPTERS } from "../frameworks/index.js";
3
4
  import { checkExports, notExportedMessage } from "./subpath.js";
4
5
 
5
- const FIELDS = ["name", "version", "peerDependencies", "dependencies", "keywords", "customElements", "deprecated", "exports"];
6
+ const FIELDS = ["name", "version", "peerDependencies", "dependencies", "keywords", "customElements", "deprecated", "exports", "style", "unpkg", "jsdelivr"];
6
7
  const OTHER_FRAMEWORKS = {
7
- "@angular/core": "Angular",
8
- svelte: "Svelte",
9
8
  "solid-js": "Solid",
10
9
  preact: "Preact",
11
10
  "@builder.io/qwik": "Qwik",
@@ -13,11 +12,16 @@ const OTHER_FRAMEWORKS = {
13
12
  };
14
13
  const WEB_COMPONENT_BASES = ["lit", "lit-element", "@lit/reactive-element", "@stencil/core", "@microsoft/fast-element", "@polymer/polymer"];
15
14
 
15
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
16
+ function isRecord(value) {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+
16
20
  /**
17
21
  * Read a package's registry metadata without installing it.
18
22
  * @param {string} spec `name`, `name@version`, or `name@range`
19
23
  * @param {{ timeoutMs?: number }} [options]
20
- * @returns {Promise<any>} The metadata. Throws an Error with a plain reason when the package can't be read.
24
+ * @returns {Promise<unknown>} The metadata. Throws an Error with a plain reason when the package can't be read.
21
25
  */
22
26
  export function npmView(spec, { timeoutMs = 60_000 } = {}) {
23
27
  return new Promise((resolve, reject) => {
@@ -45,48 +49,75 @@ export function npmView(spec, { timeoutMs = 60_000 } = {}) {
45
49
  }
46
50
 
47
51
  /**
48
- * Guess how a package renders from its metadata alone. React wins when both signals appear, then a custom elements manifest, then Vue.
52
+ * Guess how a package renders from its metadata alone. React wins when both signals appear, then a custom elements manifest, then Vue, then Angular.
49
53
  * `npm` means the metadata can't say, so the run decides after it installs and loads the package.
50
- * @param {any} meta
51
- * @returns {{ kind: "npm-react" | "npm-vue" | "npm-wc" | "npm-unsupported" | "npm", framework: string | null, reason: string }}
54
+ * @param {unknown} meta
55
+ * @returns {{ kind: "npm-react" | "npm-vue" | "npm-angular" | "npm-svelte" | "npm-html" | "npm-wc" | "npm-unsupported" | "npm", framework: string | null, reason: string }}
52
56
  */
53
57
  export function detectFlavor(meta) {
54
- const peers = meta.peerDependencies ?? {};
55
- const deps = meta.dependencies ?? {};
56
- const react = /** @type {any} */ (ADAPTERS.react.detect(meta));
58
+ const data = isRecord(meta) ? meta : {};
59
+ const peers = isRecord(data.peerDependencies) ? data.peerDependencies : {};
60
+ const deps = isRecord(data.dependencies) ? data.dependencies : {};
61
+ const react = ADAPTERS.react.detect(data);
57
62
  if (react) return react;
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));
63
+ if (data.customElements) return { kind: "npm-wc", framework: "Web components", reason: "The package has a customElements manifest." };
64
+ const vue = ADAPTERS.vue.detect(data);
60
65
  if (vue) return vue;
66
+ const angular = ADAPTERS.angular.detect(data);
67
+ if (angular) return angular;
68
+ const svelte = ADAPTERS.svelte.detect(data);
69
+ if (svelte) return svelte;
61
70
  for (const [name, label] of Object.entries(OTHER_FRAMEWORKS)) {
62
71
  if (name in peers) return { kind: "npm-unsupported", framework: label, reason: `The package needs ${label}.` };
63
72
  }
64
73
  const base = WEB_COMPONENT_BASES.find((name) => name in deps || name in peers);
65
74
  if (base) return { kind: "npm-wc", framework: "Web components", reason: `The package builds on ${base}.` };
75
+ const html = ADAPTERS.html.detect(data);
76
+ if (html) return html;
66
77
  return { kind: "npm", framework: null, reason: "The metadata doesn't say. The run decides after it loads the package." };
67
78
  }
68
79
 
69
80
  /**
70
81
  * Fill in a classified npm target: the concrete version and the framework guess.
71
82
  * @param {import("./classify.js").ClassifiedTarget} target
72
- * @param {(spec: string) => Promise<any>} view
83
+ * @param {(spec: string) => Promise<unknown>} view
73
84
  * @returns {Promise<import("./classify.js").ClassifiedTarget>}
74
85
  */
75
86
  export async function resolveNpmTarget(target, view) {
76
87
  if (target.status !== "ok" || target.kind !== "npm" || !target.resolved) return target;
77
88
  const { name, requested, subpath } = target.resolved;
78
89
  try {
79
- const meta = await view(`${name}@${requested ?? "latest"}`);
90
+ const metadata = await view(`${name}@${requested ?? "latest"}`);
91
+ if (!isRecord(metadata)) throw new Error("npm returned package metadata in an unreadable shape.");
92
+ const meta = metadata;
80
93
  // The registry lists a package's exports, so a wrong sub-path is caught here, before anything is installed.
81
94
  if (subpath) {
82
95
  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 }));
96
+ if (result.checked && !result.ok) throw new Error(notExportedMessage({ name, version: typeof meta.version === "string" ? meta.version : null, subpath, exact: result.exact, patterns: result.patterns }));
97
+ }
98
+ let flavor = detectFlavor(meta);
99
+ // A list that names a stylesheet or a script (`npm:a/components.css,b/interactions.iife.js`) is plain HTML when the metadata names no framework.
100
+ if (flavor.kind === "npm" && [target.resolved.subpath, ...(target.companions ?? []).map((c) => c.subpath)].some(isAsset)) {
101
+ flavor = { kind: "npm-html", framework: "HTML", reason: "The target names a stylesheet or a script to load." };
102
+ }
103
+ // Each companion is looked up the same way, so a wrong name, version, or sub-path fails here, before anything is installed.
104
+ /** @type {Array<{ name: string, requested: string | null, version: string | null, subpath: string | null }>} */
105
+ const companions = [];
106
+ for (const companion of target.companions ?? []) {
107
+ const found = await view(`${companion.name}@${companion.requested ?? "latest"}`);
108
+ if (!isRecord(found)) throw new Error(`npm returned metadata for ${companion.name} in an unreadable shape.`);
109
+ const version = typeof found.version === "string" ? found.version : null;
110
+ if (companion.subpath) {
111
+ const result = checkExports(found.exports, companion.subpath);
112
+ if (result.checked && !result.ok) throw new Error(notExportedMessage({ name: companion.name, version, subpath: companion.subpath, exact: result.exact, patterns: result.patterns }));
113
+ }
114
+ companions.push({ ...companion, version });
84
115
  }
85
- const flavor = detectFlavor(meta);
86
116
  return {
87
117
  ...target,
88
- kind: /** @type {any} */ (flavor.kind),
89
- resolved: { ...target.resolved, version: meta.version ?? null, framework: flavor.framework, detectedBy: flavor.reason },
118
+ kind: flavor.kind,
119
+ resolved: { ...target.resolved, version: typeof meta.version === "string" ? meta.version : null, framework: flavor.framework, detectedBy: flavor.reason },
120
+ ...(companions.length ? { companions } : {}),
90
121
  };
91
122
  } catch (error) {
92
123
  return { ...target, status: "failed", reason: error instanceof Error ? error.message : String(error), kind: null, evidenceLevel: null, resolved: null };
@@ -77,6 +77,23 @@ export function subpathProblem(workDir, name, subpath, version) {
77
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
78
  }
79
79
 
80
+ /**
81
+ * Sub-paths an installed package offers, as specs a person could pass (`@scope/pkg/button`), for a message that says where
82
+ * to look when a package's main entry has no components. Patterns and `package.json` are left out.
83
+ * @param {string} workDir
84
+ * @param {string} name
85
+ * @param {number} [limit]
86
+ * @returns {string[]}
87
+ */
88
+ export function offeredSubpaths(workDir, name, limit = 4) {
89
+ try {
90
+ const manifest = JSON.parse(readFileSync(join(workDir, "node_modules", name, "package.json"), "utf8"));
91
+ return exportKeys(manifest.exports).exact.slice(0, limit).map((key) => `${name}${key.slice(1)}`);
92
+ } catch {
93
+ return [];
94
+ }
95
+ }
96
+
80
97
  /** The extensions a file can be imported without writing, for a package with no exports map. */
81
98
  const EXTENSIONS = ["", ".js", ".mjs", ".cjs", ".json", "/index.js", "/index.mjs", "/index.cjs"];
82
99
 
@@ -10,6 +10,8 @@ import {
10
10
  configLabel,
11
11
  finish,
12
12
  hasGenerated,
13
+ hasNativeTemplates,
14
+ NATIVE_NOTE,
13
15
  notTestableLines,
14
16
  reportFooter,
15
17
  reportHeader,
@@ -168,7 +170,7 @@ function impactTables(results, engines) {
168
170
  /**
169
171
  * Render report.md for a comparison of two or more targets.
170
172
  * Every target ran with the same settings, so the columns line up. A gap or a failure shows in its cell, and never reads as a pass.
171
- * @param {{ plan: any, results: any }} input
173
+ * @param {{ plan: ReturnType<typeof import("../schema.js").parsePlan>, results: ReturnType<typeof import("../schema.js").parseResults> }} input
172
174
  */
173
175
  export function renderComparison({ plan, results }) {
174
176
  const o = plan.options;
@@ -185,6 +187,7 @@ export function renderComparison({ plan, results }) {
185
187
  }
186
188
  lines.push(...notTestableLines(results));
187
189
  if (hasGenerated(results)) lines.push(GENERATED_NOTE, "");
190
+ if (hasNativeTemplates(results)) lines.push(NATIVE_NOTE, "");
188
191
 
189
192
  lines.push("## Findings.", "", FINDINGS_NOTE, "", ...(o.tiers.includes("rules") ? impactTables(results, o.engines) : []));
190
193
  for (const key of keys) {
@@ -3,7 +3,7 @@ import { renderSingleReport } from "./single.js";
3
3
 
4
4
  /**
5
5
  * Render report.md. A comparison of two or more targets gets the side-by-side report. Anything else gets the single report.
6
- * @param {{ plan: any, results: any }} input
6
+ * @param {{ plan: ReturnType<typeof import("../schema.js").parsePlan>, results: ReturnType<typeof import("../schema.js").parseResults> }} input
7
7
  */
8
8
  export function renderReport(input) {
9
9
  return input.plan.command === "compare" && input.results.targets.length > 1 ? renderComparison(input) : renderSingleReport(input);
@@ -2,6 +2,8 @@ import { adapterFor } from "../frameworks/index.js";
2
2
  import { criterion, criterionName, wcagAttribution } from "../wcag/index.js";
3
3
  import { cap, num, plural } from "../text.js";
4
4
 
5
+ /** @typedef {NonNullable<ReturnType<typeof import("../schema.js").parseResults>["targets"][number]["archetypes"][string]["configs"][number]["tiers"][string]["flags"]>[number]} VsrFlag */
6
+
5
7
  /** Markdown helpers. */
6
8
  export const cell = (text) => String(text ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
7
9
  export const code = (text) => `\`${String(text).replace(/`/g, "'")}\``;
@@ -162,7 +164,7 @@ export function storybookSection(planTarget, target) {
162
164
  }
163
165
  const walks = Object.entries(target.archetypes).map(([key, a]) => ({ id: key.replace(/^story:/, ""), vsr: a.configs[0]?.tiers.vsr })).filter((w) => w.vsr?.status === "ran");
164
166
  if (walks.length) {
165
- /** @type {Map<string, { flag: any, stories: string[] }>} */
167
+ /** @type {Map<string, { flag: VsrFlag, stories: string[] }>} */
166
168
  const byFlag = new Map();
167
169
  for (const { id, vsr } of walks) for (const flag of vsr.flags) {
168
170
  const key = `${flag.type}|${flag.phrase}`;
@@ -265,6 +267,14 @@ export function hasGenerated(results) {
265
267
  return results.targets.some((t) => Object.values(t.archetypes ?? {}).some((a) => a.fixture?.source === "generated"));
266
268
  }
267
269
 
270
+ /** True when a plain HTML target ran from a native-markup template. */
271
+ export function hasNativeTemplates(results) {
272
+ return results.targets.some((t) => t.npm?.flavor === "html" && Object.values(t.archetypes ?? {}).some((a) => a.fixture?.source === "template"));
273
+ }
274
+
275
+ /** What a plain HTML template is, and what it can't say about a package. */
276
+ export const NATIVE_NOTE = "**Native markup templates.** For a plain HTML target the tool used bare native markup (a button, a link, a fieldset, a dialog, details, a popover) with none of the package's classes, so these results show what the package's styles and scripts do to ordinary elements. They don't test a component the package built, and a pass here never means the package's own components pass. A check that needs script behavior the markup doesn't have, such as arrow keys in a menu, measures the bare markup and not the package. Bring a fixture of your own to test the package's components.";
277
+
268
278
  /** What "generated" means, for any report that has one. */
269
279
  export const GENERATED_NOTE = "**Generated fixtures.** Where no fixture was written, the tool built one from the parts the package exports (or from what a custom element says about itself) and ran it only after it checked that the trigger and root behaved. A generated fixture is a guess about how the library is meant to be assembled, so a failure may come from how it was wired and not from the library. Treat generated results as lower evidence than an authored fixture. The source of each is in the `generated` folder beside this report. Copy one to `fixtures/<target id>/<archetype>.jsx` (`.js` for web components) and edit it to make it an authored fixture.";
270
280
 
@@ -279,6 +289,7 @@ export function targetSection(planTarget, target, { generatedNote = true } = {})
279
289
  if (target.npm) {
280
290
  const n = target.npm;
281
291
  lines.push(`Installed ${code(`${n.name}@${n.version}`)} on its own${n.subpath ? ` and tested its ${code(`${n.name}/${n.subpath}`)} entry` : ""}, as ${adapterFor(n.flavor).describe(n)}.`, "");
292
+ if (n.companions?.length) lines.push(`Installed beside it, in the same folder: ${n.companions.map((c) => code(`${c.name}@${c.version}${c.subpath ? `/${c.subpath}` : ""}`)).join(", ")}.`, "");
282
293
  }
283
294
  for (const warning of target.warnings) lines.push(`Warning: ${warning}`, "");
284
295
  if (target.status === "failed") {
@@ -286,6 +297,7 @@ export function targetSection(planTarget, target, { generatedNote = true } = {})
286
297
  }
287
298
  if (target.npm) lines.push("**Archetypes.** A gap means the archetype wasn't tested, so it counts against coverage and never as a pass.", "", ...archetypeTable(target), "");
288
299
  if (generatedNote && target.npm && hasGenerated({ targets: [target] })) lines.push(GENERATED_NOTE, "");
300
+ if (generatedNote && target.npm && hasNativeTemplates({ targets: [target] })) lines.push(NATIVE_NOTE, "");
289
301
  /** @type {Set<string>} */
290
302
  const skipped = new Set();
291
303
  for (const [name, archetype] of Object.entries(target.archetypes)) {
@@ -2,7 +2,7 @@ import { FINDINGS_NOTE, coverageMatrix, finish, failCheckSection, notTestableLin
2
2
 
3
3
  /**
4
4
  * Render report.md for an audit. The text comes from results.json and nothing else.
5
- * @param {{ plan: any, results: any }} input
5
+ * @param {{ plan: ReturnType<typeof import("../schema.js").parsePlan>, results: ReturnType<typeof import("../schema.js").parseResults> }} input
6
6
  */
7
7
  export function renderSingleReport({ plan, results }) {
8
8
  const lines = reportHeader(plan, results, "Accessibility report.");