automatica11y 0.6.0 → 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.
package/README.md CHANGED
@@ -40,7 +40,7 @@ Pick two component libraries, two versions of one component, or two sites, and `
40
40
 
41
41
  ### It tests components, not just pages
42
42
 
43
- Point it at an npm package. It installs the package on its own, finds the components, builds the test fixtures it needs (React, Vue 3, Angular 22 and newer, and web components), then opens the dialogs and menus and presses the keys.
43
+ Point it at an npm package. It installs the package on its own, finds the components, builds the test fixtures it needs (React, Vue 3, Angular 22 and newer, Svelte 5 and newer, plain HTML, and web components), then opens the dialogs and menus and presses the keys.
44
44
 
45
45
  ### It's' composable
46
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "automatica11y",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Test and compare the accessibility of web pages, Storybook builds, and npm component libraries.",
5
5
  "homepage": "https://automatica11y.dev",
6
6
  "repository": {
@@ -56,11 +56,13 @@
56
56
  "@shikijs/langs": "^4.5.0",
57
57
  "@shikijs/themes": "^4.5.0",
58
58
  "@types/node": "^26.6.4",
59
+ "bits-ui": "^2.19.5",
59
60
  "marked": "^18.1.0",
60
61
  "react": "^19.3.0",
61
62
  "react-dom": "^19.3.0",
62
63
  "rxjs": "^7.8.2",
63
64
  "shiki": "^4.5.0",
65
+ "svelte": "^5.57.2",
64
66
  "typescript": "^7.0.2",
65
67
  "vue": "^3.5.43",
66
68
  "zone.js": "^0.16.3"
@@ -16,13 +16,13 @@ The tool is **not** an attestation or certification tool. Automated checks cover
16
16
 
17
17
  Do sections 1 and 2 before you run an audit. A question to the user about a missing target or an unsupported setting (section 3) can come before or after them.
18
18
 
19
- This skill works with automatica11y **0.6.x**. Run:
19
+ This skill works with automatica11y **0.7.x**. Run:
20
20
 
21
21
  ```bash
22
22
  npx --yes automatica11y@latest --version
23
23
  ```
24
24
 
25
- If the command exits with an error or prints nothing, tell the user it failed, include the error text, and stop. If the output doesn't start with `0.6.`, stop. Tell the user the version you got and the series this copy expects (`0.6.x`). If this copy came from a file, offer to read the matching steps with `npx --yes automatica11y@latest guide skill`. Don't run an audit until the user confirms how to proceed.
25
+ If the command exits with an error or prints nothing, tell the user it failed, include the error text, and stop. If the output doesn't start with `0.7.`, stop. Tell the user the version you got and the series this copy expects (`0.7.x`). If this copy came from a file, offer to read the matching steps with `npx --yes automatica11y@latest guide skill`. Don't run an audit until the user confirms how to proceed.
26
26
 
27
27
  If the user installed the tool globally (`npm i -g automatica11y`), `automatica11y` is on the `PATH` (so is the short name `a11y`), and it runs the same commands as `npx --yes automatica11y@latest`. Every command in this skill is written with `npx`, so replace that prefix with `automatica11y` only when `automatica11y --version` passes the check above. If the installed version is the wrong series, or nothing is installed, use `npx`.
28
28
 
@@ -219,7 +219,7 @@ Use the structure of `report.md`. Quote selectors and rule IDs exactly as `resul
219
219
 
220
220
  Say these things plainly. Don't soften them, and don't fill in a result.
221
221
 
222
- - **Unsupported framework.** The package needs a framework other than React, Vue 3, Angular 22 and newer, or web components. Name it, and say which version is unsupported (Vue 2 or Angular 21, for example). This version covers React, Vue 3, Angular 22 and newer, and web components. A Storybook for the library still works, whatever the framework.
222
+ - **Unsupported framework.** The package needs a framework other than React, Vue 3, Angular 22 and newer, Svelte 5 and newer, plain HTML, or web components. Name it, and say which version is unsupported (Vue 2, Svelte 4, or Angular 21, for example). This version covers React, Vue 3, Angular 22 and newer, Svelte 5 and newer, plain HTML, and web components. A Storybook for the library still works, whatever the framework.
223
223
  - **Not applicable.** The package has no rendering surface, such as a utility library. There's nothing to test.
224
224
  - **Not testable.** The content is a canvas with no alternative, or sits in a closed shadow root. The rule engines can't see it, so the result is untested, not clean. The virtual screen reader also can't read open shadow roots.
225
225
  - **Gap.** The archetype has no usable fixture or no matching export. Say what the archetype needs.
@@ -66,6 +66,8 @@ An Angular fixture can also be TypeScript (`fixtures/<target id>/<archetype>.ts`
66
66
 
67
67
  Angular libraries split their parts across sub-paths, so audit them with a sub-path target, for example `npm:@angular/material/menu`.
68
68
 
69
+ For Svelte 5 and newer, a fixture is a `.svelte` file (`fixtures/<target id>/<archetype>.svelte`). Import the library from its package name and write the markup with the library's components, for example `<Dialog.Root>` with `<Dialog.Trigger data-a11y-trigger>`. State uses runes (`let open = $state(false)`), and `lang="ts"` works. A component that takes content takes it as children between its tags. Some Svelte libraries give you builders and not components: you create an object in the script (`const dialog = new Dialog()`) and spread its attributes onto your own elements (`<button {...dialog.trigger} data-a11y-trigger>`). Nothing is generated for those, so write the fixture from the documentation. A fixture can also declare `let { libA11y } = $props()` to receive the library accessibility option. Svelte libraries ship `.svelte` source, and the tool compiles it, so a library that imports SvelteKit-only modules (`$app/...`) can't run.
70
+
69
71
  For plain HTML, a fixture is a `.html` file of markup (`fixtures/<target id>/<archetype>.html`). It's a snippet, not a whole page: no `<html>` or `<body>`. Put `data-a11y-trigger` and `data-a11y-root` on the right elements yourself. The target says what loads, as sub-paths in a list: `npm:pkg/base.css,pkg/components.css,other/behavior.iife.js`. Stylesheets load with the page, the markup goes on next, and the scripts load last, so a script that wires up its elements when it starts finds them. A `<script>` inside the snippet runs. Use the classes and attributes the library's documentation names, and don't invent them.
70
72
 
71
73
  For web components, make `mount(container)` the file's default export. It adds the archetype to the container. The tool imports the package first, so its elements are defined before `mount` runs. The `data-a11y-trigger` and `data-a11y-root` attributes can sit on a host element, a slotted child, or an element inside an **open** shadow root. A **closed** shadow root hides its content from every tool, so the report lists it as not testable.
@@ -118,10 +118,16 @@ function singleCopy(workDir) {
118
118
  return {
119
119
  name: "single-angular-copy",
120
120
  setup(build) {
121
- build.onResolve({ filter: /^(@angular\/|rxjs(\/|$)|zone\.js(\/|$))/ }, async (args) => {
121
+ // A library asks for the same few Angular paths over and over, so each is looked up once per build.
122
+ /** @type {Map<string, Promise<import("esbuild").OnResolveResult | undefined>>} */
123
+ const resolved = new Map();
124
+ build.onResolve({ filter: /^(@angular\/|rxjs(\/|$)|zone\.js(\/|$))/ }, (args) => {
122
125
  if (args.pluginData === "single-angular-copy") return undefined;
123
- const result = await build.resolve(args.path, { resolveDir: workDir, kind: args.kind, pluginData: "single-angular-copy" });
124
- return result.errors.length ? undefined : result;
126
+ const key = `${args.kind}\0${args.path}`;
127
+ if (!resolved.has(key)) {
128
+ resolved.set(key, build.resolve(args.path, { resolveDir: workDir, kind: args.kind, pluginData: "single-angular-copy" }).then((result) => (result.errors.length ? undefined : result)));
129
+ }
130
+ return resolved.get(key);
125
131
  });
126
132
  },
127
133
  };
@@ -0,0 +1,12 @@
1
+ import { explainAngularError } from "./angular-errors.js";
2
+ import { explainSvelteError } from "./svelte-errors.js";
3
+
4
+ /**
5
+ * One place that turns a framework's own error into a plain sentence, for any framework. The codes and wording don't overlap,
6
+ * so each explainer leaves what isn't its own alone.
7
+ * @param {string} text The first line of an error.
8
+ * @returns {string}
9
+ */
10
+ export function explainFrameworkError(text) {
11
+ return explainAngularError(explainSvelteError(text));
12
+ }
@@ -6,7 +6,7 @@
6
6
  * To add a framework, write a module with the same shape as react.js, add it here, and add its id to FLAVORS and its kind to
7
7
  * TARGET_KINDS in schema.js. A test checks that the two lists agree.
8
8
  *
9
- * @typedef {"npm-react" | "npm-vue" | "npm-angular" | "npm-html" | "npm-wc" | "npm-unsupported"} AdapterKind
9
+ * @typedef {"npm-react" | "npm-vue" | "npm-angular" | "npm-svelte" | "npm-html" | "npm-wc" | "npm-unsupported"} AdapterKind
10
10
  * @typedef {{ kind: AdapterKind, framework: string, reason: string }} AdapterDetection
11
11
  * @typedef {{
12
12
  * id: string,
@@ -28,11 +28,12 @@
28
28
  import angular from "./angular.js";
29
29
  import html from "./html.js";
30
30
  import react from "./react.js";
31
+ import svelte from "./svelte.js";
31
32
  import vue from "./vue.js";
32
33
  import wc from "./wc.js";
33
34
 
34
35
  /** @type {Record<string, Adapter>} */
35
- export const ADAPTERS = { react, vue, angular, html, wc };
36
+ export const ADAPTERS = { react, vue, angular, svelte, html, wc };
36
37
 
37
38
  /** The adapter for a flavor (`react`, `vue`, or `wc`). */
38
39
  export function adapterFor(id) {
@@ -31,10 +31,13 @@ window.__a11yExports = out;
31
31
  * A fixture for the simple archetypes, from the export name alone.
32
32
  * Compound components (dialog, tabs, menu) can't be guessed, so they come from generation or from a fixture someone writes.
33
33
  */
34
- export function template(archetype, pkg, exportName) {
34
+ export function template(archetype, pkg, exportName, info) {
35
+ // A namespace with a root part (`Button.Root`) is used through its root.
36
+ const root = /** @type {any} */ (info)?.parts?.find((part) => /^root$/i.test(part));
37
+ const tag = root ? `Component.${root}` : "Component";
35
38
  const body = {
36
- button: `<Component data-a11y-trigger data-a11y-root type="button">Save</Component>`,
37
- link: `<Component data-a11y-trigger data-a11y-root href="#top">Read more</Component>`,
39
+ button: `<${tag} data-a11y-trigger data-a11y-root type="button">Save</${tag}>`,
40
+ link: `<${tag} data-a11y-trigger data-a11y-root href="#top">Read more</${tag}>`,
38
41
  }[archetype];
39
42
  if (!body) return null;
40
43
  return `import { ${exportName} as Component } from ${JSON.stringify(pkg)};
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Svelte's own errors point at its source and its docs. A report is read without either, so the ones a fixture is likely to hit
3
+ * get a plain sentence. Anything else passes through.
4
+ * @param {string} text The first line of an error.
5
+ * @returns {string}
6
+ */
7
+ export function explainSvelteError(text) {
8
+ // A library written for SvelteKit imports modules that only exist inside a SvelteKit app.
9
+ const kit = /Could not resolve "(\$(?:app|env|lib|service-worker)(?:\/[^"]*)?)"/.exec(text);
10
+ if (kit) return `the library imports ${kit[1]}, which only exists inside a SvelteKit app, so it can't run on its own (${text.split(" (")[0].replace(/^Bundling failed: /, "")})`;
11
+ const code = /svelte\.dev\/e\/(\w+)/.exec(text)?.[1];
12
+ const name = /["'`]([\w$.-]+)["'`]/.exec(text)?.[1];
13
+ if (/missing_context|Context "?[^"]*"? not found|Could not find .*context|getContext\(\) .*undefined|Cannot read properties of undefined \(reading 'getContext'\)/i.test(text) || code === "missing_context") {
14
+ return `a part of the library needs a context that its parent part provides${name ? ` (${name})` : ""}. Put it inside its root part${code ? ` (${code})` : ""}`;
15
+ }
16
+ if (code === "lifecycle_outside_component" || /can only be used during component initiali[sz]ation/i.test(text)) {
17
+ return "the library called a lifecycle or context function outside a component, so it can't be mounted this way (lifecycle_outside_component)";
18
+ }
19
+ if (code === "props_invalid_value" || code === "invalid_default_snippet" || code === "snippet_without_render_tag") {
20
+ return `a component got a value of the wrong kind for a prop${name ? ` (${name})` : ""}, such as content where it wanted a snippet (${code})`;
21
+ }
22
+ if (code === "bind_not_bindable" || code === "props_not_bindable") {
23
+ return `the fixture binds a prop that the component doesn't allow to be bound${name ? ` (${name})` : ""} (${code})`;
24
+ }
25
+ // A namespace object (`Dialog`) used as a component is compiled to a call, and the call fails like this.
26
+ if (/^TypeError: (\w*_)?exports\w* is not a function/.test(text)) {
27
+ return `${text} (a namespace of parts, such as Dialog, was used as if it were one component)`;
28
+ }
29
+ return text;
30
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The Svelte adapter, for Svelte 5 and newer. Svelte libraries ship source: `.svelte` files, and `.svelte.js` modules that use
3
+ * runes. A consumer's bundler compiles them, so this adapter gives esbuild a small plugin that does. The compiler comes from the
4
+ * target's own `svelte` install, so the compiler and the runtime that runs the output are always the same version.
5
+ */
6
+ import { createRequire } from "node:module";
7
+ import { readFileSync, statSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+ import { svelteDialect } from "../harness/generate/dialects.js";
11
+ import { generateJsx } from "../harness/generate/jsx.js";
12
+ import { discoverEntry } from "./react.js";
13
+
14
+ /** The oldest Svelte this version supports. */
15
+ export const SVELTE_FLOOR = 5;
16
+
17
+ /**
18
+ * Does a peer range allow Svelte 5 or newer? A part counts when it names version 5 or later, or has no upper limit (`>=3`, `*`).
19
+ * `^3 || ^4` doesn't, and `^4 || ^5` does.
20
+ * @param {string} range
21
+ */
22
+ export function allowsSvelte5(range) {
23
+ return String(range)
24
+ .split("||")
25
+ .some((part) => {
26
+ const text = part.trim();
27
+ const major = /(\d+)/.exec(text);
28
+ if (!major || /^[*x]?$/.test(text) || /^>=?/.test(text)) return true;
29
+ return Number(major[1]) >= SVELTE_FLOOR;
30
+ });
31
+ }
32
+
33
+ /** Mounts a fixture's default export. */
34
+ export const entry = (fixturePath, _pkg) => `import { mount } from "svelte";
35
+ import Fixture from ${JSON.stringify(fixturePath)};
36
+ const libA11y = new URLSearchParams(location.search).get("libA11y") === "on";
37
+ try {
38
+ mount(Fixture, { target: document.getElementById("root"), props: { libA11y } });
39
+ } catch (error) {
40
+ window.__error = String(error);
41
+ console.error(error);
42
+ }
43
+ `;
44
+
45
+ /**
46
+ * A fixture for the simple archetypes, from the export name alone.
47
+ * @param {string} archetype @param {string} pkg @param {string} exportName @param {unknown} [info]
48
+ */
49
+ export function template(archetype, pkg, exportName, info) {
50
+ // A namespace with a root part (`Button.Root`) is used through its root.
51
+ const root = /** @type {any} */ (info)?.parts?.find((part) => /^root$/i.test(part));
52
+ const tag = root ? `Component.${root}` : "Component";
53
+ const body = {
54
+ button: `<${tag} data-a11y-trigger data-a11y-root type="button">Save</${tag}>`,
55
+ link: `<${tag} data-a11y-trigger data-a11y-root href="#top">Read more</${tag}>`,
56
+ }[archetype];
57
+ if (!body) return null;
58
+ return `<script>
59
+ import { ${exportName} as Component } from ${JSON.stringify(pkg)};
60
+ </script>
61
+ ${body}
62
+ `;
63
+ }
64
+
65
+ /**
66
+ * What the compiler produced for a file, kept for the life of the run. A run bundles the same library many times (every generated
67
+ * candidate is a bundle), and compiling a large library's components again each time dominates the run.
68
+ * The key is the file and the compiler's own version, so a changed file or a different Svelte compiles again.
69
+ * @type {Map<string, string>}
70
+ */
71
+ const compiled = new Map();
72
+
73
+ /** Compile a file once per version of the file. */
74
+ function cached(path, kind, build) {
75
+ const stat = statSync(path);
76
+ const key = `${kind}\0${path}\0${stat.size}\0${stat.mtimeMs}`;
77
+ let code = compiled.get(key);
78
+ if (code === undefined) {
79
+ code = build();
80
+ compiled.set(key, code);
81
+ }
82
+ return code;
83
+ }
84
+
85
+ /**
86
+ * Compiles `.svelte` files and `.svelte.js` modules with the Svelte that's installed beside the library. The compiler loads the
87
+ * first time a file needs it. A compile error becomes a build error with the file and line, so it can't pass as a result.
88
+ * @param {string} workDir
89
+ * @returns {import("esbuild").Plugin}
90
+ */
91
+ function compilerPlugin(workDir) {
92
+ /** @type {Promise<any> | null} */
93
+ let compiler = null;
94
+ const load = () => {
95
+ // The compiler may load as a module or as CommonJS, which puts the functions under `default`.
96
+ compiler ??= import(pathToFileURL(createRequire(join(workDir, "package.json")).resolve("svelte/compiler")).href).then((loaded) => (loaded.compile ? loaded : loaded.default));
97
+ return compiler;
98
+ };
99
+ return {
100
+ name: "svelte-compiler",
101
+ setup(build) {
102
+ // One copy of Svelte for the library and the fixture, or the runtime's state is split in two.
103
+ // A library of a thousand modules asks for the same few Svelte paths over and over, so each is looked up once per build.
104
+ /** @type {Map<string, Promise<import("esbuild").OnResolveResult | undefined>>} */
105
+ const resolved = new Map();
106
+ build.onResolve({ filter: /^svelte(\/|$)/ }, (args) => {
107
+ if (args.pluginData === "svelte-compiler") return undefined;
108
+ const key = `${args.kind}\0${args.path}`;
109
+ if (!resolved.has(key)) {
110
+ resolved.set(key, build.resolve(args.path, { resolveDir: workDir, kind: args.kind, pluginData: "svelte-compiler" }).then((result) => (result.errors.length ? undefined : result)));
111
+ }
112
+ return resolved.get(key);
113
+ });
114
+ const failure = (error, file) => ({ errors: [{ text: String(error?.message ?? error).split("\n")[0], location: { file, line: error?.start?.line ?? 0, column: error?.start?.column ?? 0 } }] });
115
+ build.onLoad({ filter: /\.svelte$/ }, async (args) => {
116
+ try {
117
+ const { compile } = await load();
118
+ return { contents: cached(args.path, "component", () => compile(readFileSync(args.path, "utf8"), { filename: args.path, generate: "client", css: "injected", dev: false }).js.code), loader: "js" };
119
+ } catch (error) {
120
+ return failure(error, args.path);
121
+ }
122
+ });
123
+ build.onLoad({ filter: /\.svelte\.js$/ }, async (args) => {
124
+ try {
125
+ const { compileModule } = await load();
126
+ return { contents: cached(args.path, "module", () => compileModule(readFileSync(args.path, "utf8"), { filename: args.path, generate: "client" }).js.code), loader: "js" };
127
+ } catch (error) {
128
+ return failure(error, args.path);
129
+ }
130
+ });
131
+ },
132
+ };
133
+ }
134
+
135
+ export default {
136
+ id: "svelte",
137
+ label: "Svelte",
138
+ kind: "npm-svelte",
139
+ noun: "export",
140
+ extension: "svelte",
141
+ runtime: ["svelte"],
142
+ /** @returns {import("./index.js").AdapterDetection | null} */
143
+ detect(meta) {
144
+ const peers = meta.peerDependencies ?? {};
145
+ const deps = meta.dependencies ?? {};
146
+ const range = peers.svelte ?? deps.svelte;
147
+ if (range === undefined) return null;
148
+ if (!allowsSvelte5(range)) return { kind: "npm-unsupported", framework: "Svelte", reason: `The package needs svelte ${range}. Only Svelte ${SVELTE_FLOOR} and newer is supported.` };
149
+ return { kind: "npm-svelte", framework: "Svelte", reason: `The package lists svelte as a ${"svelte" in peers ? "peer dependency" : "dependency"}.` };
150
+ },
151
+ bundle: (workDir) => ({
152
+ alias: {},
153
+ plugins: [compilerPlugin(workDir)],
154
+ // Svelte libraries name their source entry with the `svelte` condition, and some with the `svelte` field.
155
+ esbuild: { conditions: ["svelte"], mainFields: ["svelte", "browser", "module", "main"] },
156
+ }),
157
+ entry,
158
+ discoverEntry,
159
+ template,
160
+ generate: (input) => generateJsx(svelteDialect, input),
161
+ describe: (npm) => `Svelte${/** @type {any} */ (npm).svelte ? ` (svelte ${/** @type {any} */ (npm).svelte})` : ""}`,
162
+ };
@@ -7,7 +7,7 @@ import { markingSource } from "./marking.js";
7
7
  const cap = (text) => text.charAt(0).toUpperCase() + text.slice(1);
8
8
  const indentBy = (text, spaces) => text.split("\n").map((line) => (line ? " ".repeat(spaces) + line : line)).join("\n");
9
9
 
10
- /** @typedef {{ id: string, extension: string, openProps: string[][], labelFor: string, declare: (name: string, init: string) => string, read: (name: string) => string, write: (name: string, value: string) => string, attr: (name: string, expr: string) => string, frame: (archetype: string, pkg: string, body: string, hooks?: string) => string }} Dialect */
10
+ /** @typedef {{ id: string, extension: string, openProps: string[][], labelFor: string, click: (action: string) => string, fragment: (inner: string) => string, when: (condition: string, markup: string) => string, declare: (name: string, init: string) => string, read: (name: string) => string, write: (name: string, value: string) => string, attr: (name: string, expr: string) => string, frame: (archetype: string, pkg: string, body: string, hooks?: string) => string }} Dialect */
11
11
 
12
12
  /** @type {Dialect} */
13
13
  export const reactDialect = {
@@ -15,6 +15,9 @@ export const reactDialect = {
15
15
  extension: "jsx",
16
16
  openProps: [["open", "onClose"], ["open", "onOpenChange"], ["isOpen", "onOpenChange"], ["isOpen", "onClose"], ["opened", "onClose"]],
17
17
  labelFor: "htmlFor",
18
+ click: (action) => `onClick={() => ${action}}`,
19
+ fragment: (inner) => `<>\n${indentBy(inner, 2)}\n</>`,
20
+ when: (condition, markup) => `{${condition} && ${markup}}`,
18
21
  declare: (name, init) => `const [${name}, set${cap(name)}] = useState(${init});`,
19
22
  read: (name) => name,
20
23
  write: (name, value) => `set${cap(name)}(${value})`,
@@ -40,6 +43,9 @@ export const vueDialect = {
40
43
  // Vue components usually take a model value and say they changed it with an update event, or take `open` and emit `update:open`.
41
44
  openProps: [["open", "onUpdate:open"], ["modelValue", "onUpdate:modelValue"], ["visible", "onUpdate:visible"], ["show", "onUpdate:show"], ["open", "onClose"], ["isOpen", "onClose"]],
42
45
  labelFor: "for",
46
+ click: (action) => `onClick={() => ${action}}`,
47
+ fragment: (inner) => `<>\n${indentBy(inner, 2)}\n</>`,
48
+ when: (condition, markup) => `{${condition} && ${markup}}`,
43
49
  declare: (name, init) => `const ${name} = ref(${init});`,
44
50
  read: (name) => `${name}.value`,
45
51
  write: (name, value) => `(${name}.value = ${value})`,
@@ -62,3 +68,31 @@ ${indentBy(body, 6)}
62
68
  `;
63
69
  },
64
70
  };
71
+
72
+ /** @type {Dialect} */
73
+ export const svelteDialect = {
74
+ id: "svelte",
75
+ extension: "svelte",
76
+ // A Svelte component takes `open` and calls back when it changes, or takes `open` and calls `onclose`.
77
+ openProps: [["open", "onOpenChange"], ["open", "onclose"], ["visible", "onclose"], ["isOpen", "onclose"], ["opened", "onclose"]],
78
+ labelFor: "for",
79
+ click: (action) => `onclick={() => ${action}}`,
80
+ // Svelte 5 markup can have several top-level nodes, so a fragment is just its children.
81
+ fragment: (inner) => inner,
82
+ when: (condition, markup) => `{#if ${condition}}${markup}{/if}`,
83
+ declare: (name, init) => `let ${name} = $state(${init});`,
84
+ read: (name) => name,
85
+ write: (name, value) => `${name} = ${value}`,
86
+ attr: (name, expr) => `${name}={${expr}}`,
87
+ frame(archetype, pkg, body, hooks = "") {
88
+ return `<script>
89
+ import * as Lib from ${JSON.stringify(pkg)};
90
+ import { onMount } from "svelte";
91
+ ${indentBy(markingSource(archetype), 2)}
92
+ onMount(() => startMarking());
93
+ ${hooks ? `${hooks}\n` : ""}</script>
94
+
95
+ ${body}
96
+ `;
97
+ },
98
+ };
@@ -60,10 +60,8 @@ function dialogControlled([openProp, closeProp]) {
60
60
  const inner = content ? tag(content, "", dialogContent(kit)) : dialogContent(kit);
61
61
  const overlay = kit.pick("overlay", "backdrop");
62
62
  const controls = ` ${d.attr(openProp, d.read("open"))} ${d.attr(closeProp, `(next) => ${d.write("open", "next === true")}`)}`;
63
- const body = `<>
64
- <button type="button" data-a11y-trigger onClick={() => ${d.write("open", "true")}}>Open dialog</button>
65
- ${indent(tag(root, controls, `${overlay ? `<${overlay} />\n` : ""}${inner}`), 2)}
66
- </>`;
63
+ const body = d.fragment(`<button type="button" data-a11y-trigger ${d.click(d.write("open", "true"))}>Open dialog</button>
64
+ ${tag(root, controls, `${overlay ? `<${overlay} />\n` : ""}${inner}`)}`);
67
65
  return { id: `dialog-controlled-${openProp}-${closeProp}`.replace(/[^\w-]+/g, "-"), summary: `a root controlled with ${openProp} and ${closeProp}`, source: d.frame("dialog", pkg, body, ` ${d.declare("open", "false")}`), used: kit.used() };
68
66
  };
69
67
  }
@@ -195,14 +193,14 @@ const fieldSingle = (id, summary, markup) => (makeKit, pkg, d) => {
195
193
  const messageConditional = (makeKit, pkg, d) => {
196
194
  const kit = makeKit();
197
195
  if (!kit.self) return null;
198
- const body = `<div>\n <button type="button" data-a11y-trigger onClick={() => ${d.write("on", "true")}}>Show message</button>\n <div>{${d.read("on")} && <${kit.self}>Saved.</${kit.self}>}</div>\n</div>`;
196
+ const body = `<div>\n <button type="button" data-a11y-trigger ${d.click(d.write("on", "true"))}>Show message</button>\n <div>${d.when(d.read("on"), `<${kit.self}>Saved.</${kit.self}>`)}</div>\n</div>`;
199
197
  return { id: "message-mounted", summary: "a message that is mounted when the trigger is pressed", source: d.frame("live-region", pkg, body, ` ${d.declare("on", "false")}`), used: [kit.base] };
200
198
  };
201
199
 
202
200
  const messageControlled = (prop) => (makeKit, pkg, d) => {
203
201
  const kit = makeKit();
204
202
  if (!kit.self) return null;
205
- const body = `<div>\n <button type="button" data-a11y-trigger onClick={() => ${d.write("on", "true")}}>Show message</button>\n <${kit.self} ${d.attr(prop, d.read("on"))}>Saved.</${kit.self}>\n</div>`;
203
+ const body = `<div>\n <button type="button" data-a11y-trigger ${d.click(d.write("on", "true"))}>Show message</button>\n <${kit.self} ${d.attr(prop, d.read("on"))}>Saved.</${kit.self}>\n</div>`;
206
204
  return { id: `message-${prop}`, summary: `a message shown with its ${prop} prop`, source: d.frame("live-region", pkg, body, ` ${d.declare("on", "false")}`), used: [kit.base] };
207
205
  };
208
206
 
@@ -1,4 +1,4 @@
1
- import { explainAngularError } from "../../frameworks/angular-errors.js";
1
+ import { explainFrameworkError } from "../../frameworks/errors.js";
2
2
  import { installHelpers } from "../../tiers/interactions/helpers.js";
3
3
  import { openPage } from "../url.js";
4
4
  import { ROOT_ROLES } from "./marking.js";
@@ -26,9 +26,9 @@ export async function probeFixture(browser, url, archetype) {
26
26
  opened = await openPage(browser, url, {
27
27
  waitUntil: "load",
28
28
  beforeGoto: async (page) => {
29
- page.on("pageerror", (error) => errors.push(explainAngularError(error.message.split("\n")[0])));
29
+ page.on("pageerror", (error) => errors.push(explainFrameworkError(error.message.split("\n")[0])));
30
30
  page.on("console", (message) => {
31
- if (message.type() === "error" && !/favicon|Failed to load resource/i.test(message.text())) errors.push(explainAngularError(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]));
32
32
  });
33
33
  await page.addInitScript(installHelpers);
34
34
  },
@@ -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" | "angular" | "html" | "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 });
@@ -77,6 +77,10 @@ export async function installPackage({ dir, name, version, flavor, run = runNpm
77
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
+ 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
+ }
80
84
  if (flavor === "angular") {
81
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.
82
86
  const core = installedVersion(dir, "@angular/core");
@@ -90,7 +94,7 @@ export async function installPackage({ dir, name, version, flavor, run = runNpm
90
94
  await npm(["install", ...adding, ...pinnedRuntime(dir, adding), ...NPM_FLAGS, "--legacy-peer-deps"]);
91
95
  }
92
96
  }
93
- return { dir, warnings, react: installedVersion(dir, "react"), reactDom: installedVersion(dir, "react-dom"), vue: installedVersion(dir, "vue"), angular: installedVersion(dir, "@angular/core"), version: installedVersion(dir, name) };
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) };
94
98
  }
95
99
 
96
100
  /** What an Angular fixture needs beside the library: core, the runtime compiler, the platform, and the pieces core uses. */
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { cleanSubpath } from "./subpath.js";
6
6
 
7
7
  /**
8
- * @typedef {"npm" | "npm-react" | "npm-vue" | "npm-angular" | "npm-html" | "npm-wc" | "npm-unsupported" | "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,
@@ -41,7 +41,7 @@ export function score(archetype, name) {
41
41
  /**
42
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" | "angular" | "html" | "wc", exports?: Array<{ name: string, type: string, parts: string[] }>, tags?: string[] }} input
44
+ * @param {{ flavor: "react" | "vue" | "angular" | "svelte" | "html" | "wc", exports?: Array<{ name: string, type: string, parts: string[] }>, tags?: string[] }} input
45
45
  * @returns {ReturnType<typeof parseMappingFile>[string]}
46
46
  */
47
47
  export function candidateMapping({ flavor, exports = [], tags = [] }) {
@@ -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", "ts", "html"]) {
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
  }
@@ -5,7 +5,6 @@ import { checkExports, notExportedMessage } from "./subpath.js";
5
5
 
6
6
  const FIELDS = ["name", "version", "peerDependencies", "dependencies", "keywords", "customElements", "deprecated", "exports", "style", "unpkg", "jsdelivr"];
7
7
  const OTHER_FRAMEWORKS = {
8
- svelte: "Svelte",
9
8
  "solid-js": "Solid",
10
9
  preact: "Preact",
11
10
  "@builder.io/qwik": "Qwik",
@@ -53,7 +52,7 @@ export function npmView(spec, { timeoutMs = 60_000 } = {}) {
53
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.
54
53
  * `npm` means the metadata can't say, so the run decides after it installs and loads the package.
55
54
  * @param {unknown} meta
56
- * @returns {{ kind: "npm-react" | "npm-vue" | "npm-angular" | "npm-html" | "npm-wc" | "npm-unsupported" | "npm", framework: string | null, reason: string }}
55
+ * @returns {{ kind: "npm-react" | "npm-vue" | "npm-angular" | "npm-svelte" | "npm-html" | "npm-wc" | "npm-unsupported" | "npm", framework: string | null, reason: string }}
57
56
  */
58
57
  export function detectFlavor(meta) {
59
58
  const data = isRecord(meta) ? meta : {};
@@ -66,6 +65,8 @@ export function detectFlavor(meta) {
66
65
  if (vue) return vue;
67
66
  const angular = ADAPTERS.angular.detect(data);
68
67
  if (angular) return angular;
68
+ const svelte = ADAPTERS.svelte.detect(data);
69
+ if (svelte) return svelte;
69
70
  for (const [name, label] of Object.entries(OTHER_FRAMEWORKS)) {
70
71
  if (name in peers) return { kind: "npm-unsupported", framework: label, reason: `The package needs ${label}.` };
71
72
  }
@@ -8,7 +8,7 @@ import { settleAnimations } from "../harness/settle.js";
8
8
  import { installedVersion, installExtraPackages, installOptionalPeers, installPackage } from "../harness/npm-install.js";
9
9
  import { shipsBrowserAssets } from "../frameworks/html.js";
10
10
  import { ANGULAR_FLOOR } from "../frameworks/angular.js";
11
- import { explainAngularError } from "../frameworks/angular-errors.js";
11
+ import { explainFrameworkError } from "../frameworks/errors.js";
12
12
  import { adapterFor, adapterForKind } from "../frameworks/index.js";
13
13
  import { offeredSubpaths, subpathProblem } from "../plan/subpath.js";
14
14
  import { closedShadowHosts, notTestableEntries } from "../harness/shadow.js";
@@ -40,17 +40,17 @@ const STATES = {
40
40
  "live-region": ["before message", "message shown"],
41
41
  };
42
42
 
43
- const firstLine = (error) => (error instanceof Error ? error.message : String(error)).split("\n").find((l) => l.trim()) ?? "unknown error";
43
+ const firstLine = (error) => explainFrameworkError((error instanceof Error ? error.message : String(error)).split("\n").find((l) => l.trim()) ?? "unknown error");
44
44
 
45
45
  /** Page and console errors that mean a fixture didn't mount cleanly. A missing favicon doesn't count. */
46
46
  function watchErrors(errors) {
47
47
  return (page) => {
48
- page.on("pageerror", (error) => errors.push(explainAngularError(error.message.split("\n")[0])));
48
+ page.on("pageerror", (error) => errors.push(explainFrameworkError(error.message.split("\n")[0])));
49
49
  page.on("console", (message) => {
50
50
  if (message.type() !== "error") return;
51
51
  const text = message.text();
52
52
  if (/favicon|Failed to load resource/i.test(text) && !/\.(js|css)\b/.test(text)) return;
53
- errors.push(explainAngularError(text.split("\n")[0]));
53
+ errors.push(explainFrameworkError(text.split("\n")[0]));
54
54
  });
55
55
  };
56
56
  }
@@ -223,7 +223,7 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
223
223
  const base = { id: planTarget.id, reason: null, archetypes: {}, summary: { engines: {}, gaps: [], notTestable: [] }, warnings: [] };
224
224
  const resolved = planTarget.resolved ?? {};
225
225
  if (planTarget.kind === "npm-unsupported") {
226
- return { result: { ...base, status: "unsupported", reason: `${resolved.framework ?? "This framework"} packages aren't supported.${resolved.framework === "Angular" && resolved.detectedBy ? ` ${resolved.detectedBy}` : ""} This version covers React, Vue 3, Angular ${ANGULAR_FLOOR} and newer, and web components.` }, mapping: null };
226
+ return { result: { ...base, status: "unsupported", reason: `${resolved.framework ?? "This framework"} packages aren't supported.${(resolved.framework === "Angular" || resolved.framework === "Svelte") && resolved.detectedBy ? ` ${resolved.detectedBy}` : ""} This version covers React, Vue 3, Angular ${ANGULAR_FLOOR} and newer, Svelte 5 and newer, and web components.` }, mapping: null };
227
227
  }
228
228
  const tmp = mkdtempSync(join(tmpdir(), "automatica11y-npm-"));
229
229
  const workDir = join(tmp, "install");
@@ -234,8 +234,8 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
234
234
  try {
235
235
  /** The framework's id (react, vue, or wc), or "unknown" when the metadata couldn't say. */
236
236
  const adapterId = adapterForKind(planTarget.kind)?.id;
237
- /** @type {"react" | "vue" | "angular" | "html" | "wc" | "unknown"} */
238
- let flavor = adapterId === "react" || adapterId === "vue" || adapterId === "angular" || adapterId === "html" || adapterId === "wc" ? adapterId : "unknown";
237
+ /** @type {"react" | "vue" | "angular" | "svelte" | "html" | "wc" | "unknown"} */
238
+ let flavor = adapterId === "react" || adapterId === "vue" || adapterId === "angular" || adapterId === "svelte" || adapterId === "html" || adapterId === "wc" ? adapterId : "unknown";
239
239
  const installed = await install({ dir: workDir, name: resolved.name, version: resolved.version, flavor });
240
240
  const warnings = [...installed.warnings];
241
241
  // What fixtures and templates import: the package, or the sub-path of it that was asked for.
@@ -435,6 +435,7 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
435
435
  reactDom: installed.reactDom,
436
436
  vue: installed.vue ?? null,
437
437
  angular: installed.angular ?? null,
438
+ svelte: installed.svelte ?? null,
438
439
  ...(flavor === "html" ? { assets: /** @type {any} */ (context).assets } : {}),
439
440
  tags: flavor === "wc" ? found.tags : [],
440
441
  },
@@ -1,8 +1,9 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { explainFrameworkError } from "../frameworks/errors.js";
3
4
  import { probeFixture } from "../harness/generate/probe.js";
4
5
 
5
- const firstLine = (error) => (error instanceof Error ? error.message : String(error)).split("\n").find((l) => l.trim()) ?? "unknown error";
6
+ const firstLine = (error) => explainFrameworkError((error instanceof Error ? error.message : String(error)).split("\n").find((l) => l.trim()) ?? "unknown error");
6
7
 
7
8
  /**
8
9
  * Build a fixture for an archetype from what discovery found, and keep it only if it works.
@@ -17,6 +17,7 @@ import { parseResults } from "../schema.js";
17
17
  import { runRules, selfTest } from "../tiers/rules/index.js";
18
18
  import { evaluateFailCheck } from "./fail-check.js";
19
19
  import { mapPool } from "./pool.js";
20
+ import { explainFrameworkError } from "../frameworks/errors.js";
20
21
  import { auditNpm } from "./audit-npm.js";
21
22
  import { adapterFor } from "../frameworks/index.js";
22
23
  import { failedTarget, summarize } from "./summary.js";
@@ -26,7 +27,7 @@ const STORY_CONCURRENCY = 4;
26
27
 
27
28
  const EXIT = { OK: 0, FAIL_THRESHOLD: 1, ENVIRONMENT: 3, ALL_TARGETS_FAILED: 4 };
28
29
 
29
- const NPM_KINDS = new Set(["npm", "npm-react", "npm-vue", "npm-angular", "npm-html", "npm-wc", "npm-unsupported"]);
30
+ const NPM_KINDS = new Set(["npm", "npm-react", "npm-vue", "npm-angular", "npm-svelte", "npm-html", "npm-wc", "npm-unsupported"]);
30
31
  const UNSUPPORTED_KIND = (kind) => `${kind} targets aren't supported.`;
31
32
 
32
33
  /** Audit one page and return its target result. */
@@ -182,7 +183,7 @@ async function runTarget(browser, planTarget, plan, io) {
182
183
  }
183
184
  return { result: failedTarget(planTarget.id, UNSUPPORTED_KIND(planTarget.kind)), mapping: null };
184
185
  } catch (error) {
185
- return { result: failedTarget(planTarget.id, error instanceof Error ? error.message.split("\n")[0] : String(error)), mapping: null };
186
+ return { result: failedTarget(planTarget.id, explainFrameworkError(error instanceof Error ? error.message.split("\n")[0] : String(error))), mapping: null };
186
187
  } finally {
187
188
  for (const server of servers) await server.close();
188
189
  }
package/src/schema.js CHANGED
@@ -9,7 +9,7 @@ export const IMPACTS = ["minor", "moderate", "serious", "critical"];
9
9
  export const TOOLKIT_LEVELS = [1, 2, 3];
10
10
  export const FAIL_MODES = ["any", "all"];
11
11
  export const ARCHETYPES = ["button", "link", "dialog", "menu", "tabs", "combobox", "form-field", "accordion", "tooltip", "live-region", "chart"];
12
- export const FLAVORS = ["react", "vue", "angular", "html", "wc"];
12
+ export const FLAVORS = ["react", "vue", "angular", "svelte", "html", "wc"];
13
13
  export const MAPPING_STATUSES = ["template", "authored", "generated", "needs-fixture", "no-match"];
14
14
 
15
15
  /** What a candidate mapping says about one archetype of one npm target. */
@@ -49,7 +49,7 @@ export function parseMappingFile(input) {
49
49
  return result.output;
50
50
  }
51
51
 
52
- export const TARGET_KINDS = ["npm", "npm-react", "npm-vue", "npm-angular", "npm-html", "npm-wc", "npm-unsupported", "npm-non-ui", "storybook", "url", "html-file", "static-dir"];
52
+ export const TARGET_KINDS = ["npm", "npm-react", "npm-vue", "npm-angular", "npm-svelte", "npm-html", "npm-wc", "npm-unsupported", "npm-non-ui", "storybook", "url", "html-file", "static-dir"];
53
53
 
54
54
  const nullableString = v.nullable(v.string());
55
55
 
@@ -240,6 +240,7 @@ export const TargetResultSchema = v.object({
240
240
  reactDom: nullableString,
241
241
  vue: v.optional(nullableString),
242
242
  angular: v.optional(nullableString),
243
+ svelte: v.optional(nullableString),
243
244
  /** For plain HTML, the stylesheets and scripts the page loaded, as `package/sub-path`. */
244
245
  assets: v.optional(v.object({ styles: v.array(v.string()), scripts: v.array(v.string()) })),
245
246
  tags: v.array(v.string()),