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.
- package/README.md +37 -130
- package/package.json +30 -2
- package/skills/automatica11y-runner/SKILL.md +16 -3
- package/skills/automatica11y-runner/references/fixtures.md +24 -0
- package/src/cli.js +1 -0
- package/src/commands/common.js +14 -11
- package/src/frameworks/angular-errors.js +27 -0
- package/src/frameworks/angular-selectors.js +36 -0
- package/src/frameworks/angular.js +162 -0
- package/src/frameworks/errors.js +12 -0
- package/src/frameworks/html.js +163 -0
- package/src/frameworks/index.js +13 -7
- package/src/frameworks/react.js +8 -4
- package/src/frameworks/svelte-errors.js +30 -0
- package/src/frameworks/svelte.js +162 -0
- package/src/frameworks/vue.js +2 -1
- package/src/globals.d.ts +123 -6
- package/src/harness/bundle.js +2 -1
- package/src/harness/generate/angular-recipes.js +360 -0
- package/src/harness/generate/dialects.js +35 -1
- package/src/harness/generate/jsx-recipes.js +4 -6
- package/src/harness/generate/probe.js +16 -5
- package/src/harness/npm-install.js +47 -11
- package/src/harness/shadow.js +1 -1
- package/src/harness/storybook.js +18 -5
- package/src/plan/build-plan.js +1 -0
- package/src/plan/classify.js +39 -11
- package/src/plan/mapping.js +8 -6
- package/src/plan/resolve-npm.js +49 -18
- package/src/plan/subpath.js +17 -0
- package/src/report/comparison.js +4 -1
- package/src/report/index.js +1 -1
- package/src/report/parts.js +13 -1
- package/src/report/single.js +1 -1
- package/src/run/audit-npm.js +95 -27
- package/src/run/fail-check.js +10 -2
- package/src/run/generate-fixture.js +7 -5
- package/src/run/run-plan.js +10 -8
- package/src/run/summary.js +1 -1
- package/src/schema.js +10 -2
- package/src/tiers/computed/checks.js +3 -1
- package/src/tiers/conditions/kit.js +3 -3
- package/src/tiers/interactions/archetypes.js +6 -2
- package/src/tiers/interactions/focus-indicator.js +4 -2
- package/src/tiers/interactions/helpers.js +2 -1
- package/src/tiers/rules/ibm.js +2 -2
- package/src/tiers/rules/index.js +1 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Angular adapter, for Angular 22 and newer. Angular libraries ship partially compiled, and Angular compiles them in the
|
|
3
|
+
* browser (JIT) once `@angular/compiler` is loaded, so nothing here runs the Angular linker or a TypeScript transform.
|
|
4
|
+
* A fixture is plain JavaScript that defines a component by calling the `Component` decorator as a function (see the fixtures
|
|
5
|
+
* guide). Every framework adapter has this shape (see index.js).
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { generateAngular } from "../harness/generate/angular-recipes.js";
|
|
10
|
+
import { attributeText, markupFor } from "./angular-selectors.js";
|
|
11
|
+
|
|
12
|
+
/** The oldest Angular this version supports. */
|
|
13
|
+
export const ANGULAR_FLOOR = 22;
|
|
14
|
+
const FLOOR = ANGULAR_FLOOR;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Does a peer range allow Angular 22 or newer? A part counts when it names version 22 or later, or has no upper limit (`>=15`, `*`).
|
|
18
|
+
* `^20 || ^21` doesn't, and `^21 || ^22` does.
|
|
19
|
+
*/
|
|
20
|
+
function allowsSupported(range) {
|
|
21
|
+
return String(range)
|
|
22
|
+
.split("||")
|
|
23
|
+
.some((part) => {
|
|
24
|
+
const text = part.trim();
|
|
25
|
+
const major = /(\d+)/.exec(text);
|
|
26
|
+
if (!major || /^[*x]?$/.test(text)) return true;
|
|
27
|
+
if (/^>=?/.test(text)) return true;
|
|
28
|
+
return Number(major[1]) >= FLOOR;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Loads the compiler, then the whole target, and records what each Angular export is: its kind, selectors, inputs, outputs,
|
|
34
|
+
* `exportAs`, and whether it's standalone. Services get their method names, and modules the classes they declare.
|
|
35
|
+
* Exports with no Angular definition (functions, tokens, types) are left out.
|
|
36
|
+
*/
|
|
37
|
+
export const discoverEntry = (pkg) => `import "@angular/compiler";
|
|
38
|
+
import * as lib from ${JSON.stringify(pkg)};
|
|
39
|
+
// A class can be exported under more than one name (a button that is also the anchor), and each name is a record.
|
|
40
|
+
const namesOf = new Map();
|
|
41
|
+
for (const key of Object.keys(lib)) if (typeof lib[key] === "function") namesOf.set(lib[key], [...(namesOf.get(lib[key]) ?? []), key]);
|
|
42
|
+
const listed = (items) => (typeof items === "function" ? items() : items) ?? [];
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const key of Object.keys(lib)) {
|
|
45
|
+
const value = lib[key];
|
|
46
|
+
if (typeof value !== "function") continue;
|
|
47
|
+
try {
|
|
48
|
+
const cmp = value["ɵcmp"];
|
|
49
|
+
const dir = value["ɵdir"];
|
|
50
|
+
const mod = value["ɵmod"];
|
|
51
|
+
const def = cmp ?? dir;
|
|
52
|
+
if (def) {
|
|
53
|
+
out.push({ name: key, type: "function", parts: [], angular: { kind: cmp ? "component" : "directive", selectors: def.selectors ?? [], inputs: Object.keys(def.inputs ?? {}), outputs: Object.keys(def.outputs ?? {}), exportAs: def.exportAs ?? [], standalone: Boolean(def.standalone) } });
|
|
54
|
+
} else if (mod) {
|
|
55
|
+
const named = (items) => listed(items).flatMap((c) => namesOf.get(c) ?? []);
|
|
56
|
+
out.push({ name: key, type: "function", parts: [], angular: { kind: "module", declares: named(mod.declarations), exports: named(mod.exports) } });
|
|
57
|
+
} else if (value["ɵprov"]) {
|
|
58
|
+
const methods = Object.getOwnPropertyNames(value.prototype ?? {}).filter((m) => m !== "constructor" && !m.startsWith("ɵ") && typeof value.prototype[m] === "function");
|
|
59
|
+
out.push({ name: key, type: "function", parts: [], angular: { kind: "service", methods } });
|
|
60
|
+
}
|
|
61
|
+
} catch (error) {
|
|
62
|
+
out.push({ name: key, type: "function", parts: [], angular: { kind: "error", message: String(error).split("\\n")[0] } });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// A component that isn't standalone is used through the module that declares it.
|
|
66
|
+
for (const item of out) {
|
|
67
|
+
if (item.angular.kind === "module" || item.angular.standalone !== false) continue;
|
|
68
|
+
item.angular.moduleName = out.find((m) => m.angular.kind === "module" && m.angular.declares.includes(item.name))?.name ?? null;
|
|
69
|
+
}
|
|
70
|
+
window.__a11yExports = out;
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Mounts a fixture. The runtime compiler goes first. `zone.js` is loaded only when a library brought it in, because Angular 22
|
|
75
|
+
* runs without it. The fixture's default export is its component class, and an optional `providers` export adds application providers.
|
|
76
|
+
* A failure to start is logged and kept on `window.__error`, so the report can say why.
|
|
77
|
+
*/
|
|
78
|
+
export const entry = (fixturePath, _pkg, context = {}) => `import "@angular/compiler";
|
|
79
|
+
${context.zone ? 'import "zone.js";\n' : ""}import { bootstrapApplication } from "@angular/platform-browser";
|
|
80
|
+
import * as fixture from ${JSON.stringify(fixturePath)};
|
|
81
|
+
const Fixture = fixture.default;
|
|
82
|
+
const selector = Fixture?.["ɵcmp"]?.selectors?.[0]?.[0] || "app-fixture";
|
|
83
|
+
document.getElementById("root").append(document.createElement(selector));
|
|
84
|
+
try {
|
|
85
|
+
await bootstrapApplication(Fixture, { providers: fixture.providers ?? [] });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
window.__error = String(error);
|
|
88
|
+
console.error(error);
|
|
89
|
+
}
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A fixture for a button or a link, from the class's own selector. A class that isn't standalone is imported through its module.
|
|
94
|
+
* A class whose selector can't be written from its name alone (a class name, a :not() form) is left for a person to write.
|
|
95
|
+
*/
|
|
96
|
+
export function template(archetype, pkg, exportName, info) {
|
|
97
|
+
const want = { button: "button", link: "a" }[archetype];
|
|
98
|
+
const angular = info?.angular;
|
|
99
|
+
if (!want || !angular || (angular.kind !== "component" && angular.kind !== "directive")) return null;
|
|
100
|
+
// A button class may name its own element (`p-button`), which a link class may not: a link has to be an anchor.
|
|
101
|
+
const match = markupFor(angular.selectors, { prefer: want }) ?? (archetype === "button" ? markupFor(angular.selectors, {}) : null);
|
|
102
|
+
if (!match || (archetype === "link" && match.tag !== want)) return null;
|
|
103
|
+
const imports = angular.standalone ? [exportName] : angular.moduleName ? [angular.moduleName] : null;
|
|
104
|
+
if (!imports) return null;
|
|
105
|
+
const attrs = [attributeText(match.attrs), "data-a11y-trigger", "data-a11y-root", want === "a" ? 'href="#top"' : 'type="button"'].filter(Boolean).join(" ");
|
|
106
|
+
const html = `<${match.tag} ${attrs}>${want === "a" ? "Read more" : "Save"}</${match.tag}>`;
|
|
107
|
+
return `import { Component } from "@angular/core";
|
|
108
|
+
import { ${imports.join(", ")} } from ${JSON.stringify(pkg)};
|
|
109
|
+
|
|
110
|
+
class Fixture {}
|
|
111
|
+
Component({ selector: "app-fixture", imports: [${imports.join(", ")}], template: \`${html}\` })(Fixture);
|
|
112
|
+
export default Fixture;
|
|
113
|
+
`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Keeps one copy of every Angular package and of rxjs, so a library and a fixture never load two. Secondary entry points need the resolver, not an alias. */
|
|
117
|
+
function singleCopy(workDir) {
|
|
118
|
+
return {
|
|
119
|
+
name: "single-angular-copy",
|
|
120
|
+
setup(build) {
|
|
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) => {
|
|
125
|
+
if (args.pluginData === "single-angular-copy") return undefined;
|
|
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);
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export default {
|
|
137
|
+
id: "angular",
|
|
138
|
+
label: "Angular",
|
|
139
|
+
kind: "npm-angular",
|
|
140
|
+
noun: "export",
|
|
141
|
+
extension: "js",
|
|
142
|
+
runtime: ["@angular/core", "@angular/common", "@angular/compiler", "@angular/platform-browser", "rxjs"],
|
|
143
|
+
/** @returns {import("./index.js").AdapterDetection | null} */
|
|
144
|
+
detect(meta) {
|
|
145
|
+
const peers = meta.peerDependencies ?? {};
|
|
146
|
+
const deps = meta.dependencies ?? {};
|
|
147
|
+
const range = peers["@angular/core"] ?? deps["@angular/core"];
|
|
148
|
+
if (range === undefined) return null;
|
|
149
|
+
if (!allowsSupported(range)) return { kind: "npm-unsupported", framework: "Angular", reason: `The package needs @angular/core ${range}. Only Angular ${FLOOR} and newer is supported.` };
|
|
150
|
+
return { kind: "npm-angular", framework: "Angular", reason: `The package lists @angular/core as a ${"@angular/core" in peers ? "peer dependency" : "dependency"}.` };
|
|
151
|
+
},
|
|
152
|
+
/** What the install left behind that the entry needs to know about. */
|
|
153
|
+
inspect: (workDir) => ({ zone: existsSync(join(workDir, "node_modules", "zone.js")) }),
|
|
154
|
+
// Fixtures can be TypeScript. esbuild strips the types, and legacy decorators are what Angular's JIT reads. It can't emit
|
|
155
|
+
// constructor parameter metadata, so a TypeScript fixture gets its services from inject().
|
|
156
|
+
bundle: (workDir) => ({ alias: {}, plugins: [singleCopy(workDir)], esbuild: { tsconfigRaw: { compilerOptions: { experimentalDecorators: true, useDefineForClassFields: false } } } }),
|
|
157
|
+
entry,
|
|
158
|
+
discoverEntry,
|
|
159
|
+
template,
|
|
160
|
+
generate: (input) => generateAngular(input),
|
|
161
|
+
describe: (npm) => `Angular${npm.angular ? ` (core ${npm.angular})` : ""}`,
|
|
162
|
+
};
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plain HTML adapter, for packages with no framework: stylesheets, and scripts that wire up behavior on markup the page
|
|
3
|
+
* already has. A fixture is a snippet of markup. The tool loads the target's stylesheets, puts the markup on the page, then
|
|
4
|
+
* loads the scripts, so a script that looks for its elements when it starts finds them.
|
|
5
|
+
*
|
|
6
|
+
* What loads is named by the target itself. In a list such as `npm:a/components.css,b/interactions.iife.js`, an entry whose
|
|
7
|
+
* sub-path ends in `.css` is a stylesheet, and one that ends in `.js` or `.mjs` is a script. Every other entry is just a package.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const STYLE = /\.css$/i;
|
|
11
|
+
const SCRIPT = /\.(m?js)$/i;
|
|
12
|
+
|
|
13
|
+
/** @param {string | null | undefined} subpath */
|
|
14
|
+
export const isStyle = (subpath) => Boolean(subpath && STYLE.test(subpath));
|
|
15
|
+
/** @param {string | null | undefined} subpath */
|
|
16
|
+
export const isScript = (subpath) => Boolean(subpath && SCRIPT.test(subpath));
|
|
17
|
+
/** Is this sub-path a file the page loads, rather than something a framework imports? */
|
|
18
|
+
export const isAsset = (subpath) => isStyle(subpath) || isScript(subpath);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The stylesheets and scripts a list of packages names, in the order they were written.
|
|
22
|
+
* @param {Array<{ name: string, subpath: string | null }>} entries
|
|
23
|
+
* @returns {{ styles: string[], scripts: string[] }}
|
|
24
|
+
*/
|
|
25
|
+
export function assetsOf(entries) {
|
|
26
|
+
const spec = (entry) => `${entry.name}/${entry.subpath}`;
|
|
27
|
+
return {
|
|
28
|
+
styles: entries.filter((entry) => isStyle(entry.subpath)).map(spec),
|
|
29
|
+
scripts: entries.filter((entry) => isScript(entry.subpath)).map(spec),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Does the registry's metadata say a package is plain HTML and CSS? It has to ship a stylesheet or a browser script, and it can't
|
|
35
|
+
* need a framework (those are decided before this runs).
|
|
36
|
+
* @param {any} meta
|
|
37
|
+
*/
|
|
38
|
+
export function shipsBrowserAssets(meta) {
|
|
39
|
+
if (typeof meta?.style === "string" || typeof meta?.unpkg === "string" || typeof meta?.jsdelivr === "string") return true;
|
|
40
|
+
const keys = meta?.exports && typeof meta.exports === "object" ? Object.keys(meta.exports) : [];
|
|
41
|
+
return keys.some((key) => STYLE.test(key) || /\.(iife|umd)\.m?js$/.test(key));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Does the package have a JavaScript entry to import? Then loading it is what tells plain HTML from web components. */
|
|
45
|
+
function hasScriptEntry(meta) {
|
|
46
|
+
if (typeof meta?.main === "string" || typeof meta?.module === "string") return true;
|
|
47
|
+
const exported = meta?.exports;
|
|
48
|
+
if (typeof exported === "string") return true;
|
|
49
|
+
return Boolean(exported && typeof exported === "object" && exported["."]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Mounts a fixture. The stylesheets come first, as imports, so they load with the page. The markup goes on the page next. A
|
|
54
|
+
* `<script>` inside the markup is made again so the browser runs it, because markup set by the page never runs its scripts.
|
|
55
|
+
* Then the target's scripts load, one after the other.
|
|
56
|
+
* @param {string} fixturePath
|
|
57
|
+
* @param {string} _pkg
|
|
58
|
+
* @param {{ assets?: { styles: string[], scripts: string[] } }} [context]
|
|
59
|
+
*/
|
|
60
|
+
export const entry = (fixturePath, _pkg, context = {}) => {
|
|
61
|
+
const { styles = [], scripts = [] } = context.assets ?? {};
|
|
62
|
+
return `${styles.map((spec) => `import ${JSON.stringify(spec)};`).join("\n")}
|
|
63
|
+
import markup from ${JSON.stringify(fixturePath)};
|
|
64
|
+
const root = document.getElementById("root");
|
|
65
|
+
root.innerHTML = markup;
|
|
66
|
+
for (const old of [...root.querySelectorAll("script")]) {
|
|
67
|
+
const script = document.createElement("script");
|
|
68
|
+
for (const { name, value } of old.attributes) script.setAttribute(name, value);
|
|
69
|
+
script.textContent = old.textContent;
|
|
70
|
+
old.replaceWith(script);
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
${scripts.map((spec) => ` await import(${JSON.stringify(spec)});`).join("\n")}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
window.__error = String(error);
|
|
76
|
+
console.error(error);
|
|
77
|
+
}
|
|
78
|
+
`;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The bare native markup for an archetype, with no class names and nothing guessed from a stylesheet. It tests what the target's
|
|
83
|
+
* styles and scripts do to ordinary elements. A component built on classes needs a fixture a person writes. The browser does the
|
|
84
|
+
* opening and closing (`<dialog>`, `<details>`, popovers), so a template needs no script of its own except where there's no
|
|
85
|
+
* native way (a dialog's trigger, a message that appears).
|
|
86
|
+
*/
|
|
87
|
+
const TEMPLATES = {
|
|
88
|
+
button: '<button type="button" data-a11y-trigger data-a11y-root>Save</button>\n',
|
|
89
|
+
link: '<a href="#top" data-a11y-trigger data-a11y-root>Read more</a>\n',
|
|
90
|
+
"form-field": `<fieldset data-a11y-root>
|
|
91
|
+
<legend>Contact details</legend>
|
|
92
|
+
<p><label for="name">Name</label> <input id="name" name="name" type="text" autocomplete="name" data-a11y-trigger></p>
|
|
93
|
+
<p><label for="about">About you</label> <textarea id="about" name="about"></textarea></p>
|
|
94
|
+
<p><label for="size">Size</label> <select id="size" name="size"><option>Small</option><option>Medium</option><option>Large</option></select></p>
|
|
95
|
+
<p><input id="news" name="news" type="checkbox"> <label for="news">Send me news</label></p>
|
|
96
|
+
<fieldset>
|
|
97
|
+
<legend>Contact me by</legend>
|
|
98
|
+
<p><input id="by-email" name="by" type="radio" value="email"> <label for="by-email">Email</label></p>
|
|
99
|
+
<p><input id="by-phone" name="by" type="radio" value="phone"> <label for="by-phone">Phone</label></p>
|
|
100
|
+
</fieldset>
|
|
101
|
+
<p><label for="email">Email address</label> <input id="email" name="email" type="email" aria-invalid="true" aria-describedby="email-error"> <span id="email-error">Enter an email address like name@example.com.</span></p>
|
|
102
|
+
</fieldset>
|
|
103
|
+
`,
|
|
104
|
+
dialog: `<button type="button" id="open-dialog" data-a11y-trigger>Open dialog</button>
|
|
105
|
+
<dialog id="dialog" data-a11y-root aria-labelledby="dialog-title">
|
|
106
|
+
<h2 id="dialog-title">Edit profile</h2>
|
|
107
|
+
<p>Update your details.</p>
|
|
108
|
+
<form method="dialog"><button>Close</button></form>
|
|
109
|
+
</dialog>
|
|
110
|
+
<script>document.getElementById("open-dialog").addEventListener("click", () => document.getElementById("dialog").showModal());</script>
|
|
111
|
+
`,
|
|
112
|
+
accordion: `<details name="faq">
|
|
113
|
+
<summary data-a11y-trigger>Shipping</summary>
|
|
114
|
+
<div data-a11y-root><p>Orders ship within two business days.</p></div>
|
|
115
|
+
</details>
|
|
116
|
+
<details name="faq">
|
|
117
|
+
<summary>Returns</summary>
|
|
118
|
+
<div><p>Returns are free for 30 days.</p></div>
|
|
119
|
+
</details>
|
|
120
|
+
`,
|
|
121
|
+
"live-region": `<button type="button" id="show-message" data-a11y-trigger>Show message</button>
|
|
122
|
+
<div id="message-region" role="status" data-a11y-root></div>
|
|
123
|
+
<script>document.getElementById("show-message").addEventListener("click", () => {
|
|
124
|
+
document.getElementById("message-region").textContent = "Saved.";
|
|
125
|
+
});</script>
|
|
126
|
+
`,
|
|
127
|
+
menu: `<button type="button" data-a11y-trigger popovertarget="actions" aria-haspopup="menu">Actions</button>
|
|
128
|
+
<div id="actions" popover data-a11y-root role="menu" aria-label="Actions">
|
|
129
|
+
<button type="button" role="menuitem">Copy</button>
|
|
130
|
+
<button type="button" role="menuitem">Paste</button>
|
|
131
|
+
</div>
|
|
132
|
+
`,
|
|
133
|
+
tooltip: `<button type="button" data-a11y-trigger interestfor="save-tip">Save</button>
|
|
134
|
+
<div id="save-tip" popover="hint" role="tooltip" data-a11y-root>Saves your work.</div>
|
|
135
|
+
`,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export default {
|
|
139
|
+
id: "html",
|
|
140
|
+
label: "HTML",
|
|
141
|
+
kind: "npm-html",
|
|
142
|
+
noun: "element",
|
|
143
|
+
extension: "html",
|
|
144
|
+
runtime: [],
|
|
145
|
+
/** @returns {import("./index.js").AdapterDetection | null} */
|
|
146
|
+
detect(meta) {
|
|
147
|
+
// A package that also has a JavaScript entry could be web components, so the run decides after it loads the package.
|
|
148
|
+
if (!shipsBrowserAssets(meta) || hasScriptEntry(meta)) return null;
|
|
149
|
+
return { kind: "npm-html", framework: "HTML", reason: "The package ships a stylesheet or a browser script and needs no framework." };
|
|
150
|
+
},
|
|
151
|
+
/** What the page loads, from the packages the target lists. */
|
|
152
|
+
inspect: (/** @type {string} */ _workDir, /** @type {Array<{ name: string, subpath: string | null }>} */ entries = []) => ({ assets: assetsOf(entries) }),
|
|
153
|
+
bundle: () => ({ alias: {}, esbuild: { loader: { ".html": /** @type {"text"} */ ("text") } } }),
|
|
154
|
+
entry,
|
|
155
|
+
discoverEntry: () => "window.__a11yExports = [];\n",
|
|
156
|
+
template: (/** @type {string} */ archetype) => TEMPLATES[/** @type {keyof typeof TEMPLATES} */ (archetype)] ?? null,
|
|
157
|
+
generate: () => ({ candidates: [], reason: "Plain HTML has no exports or selectors to build a fixture from." }),
|
|
158
|
+
describe: (npm) => {
|
|
159
|
+
const loaded = /** @type {any} */ (npm).assets;
|
|
160
|
+
return `plain HTML${loaded ? ` (${loaded.styles.length} ${loaded.styles.length === 1 ? "stylesheet" : "stylesheets"}, ${loaded.scripts.length} ${loaded.scripts.length === 1 ? "script" : "scripts"})` : ""}`;
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
package/src/frameworks/index.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
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-svelte" | "npm-html" | "npm-wc" | "npm-unsupported"} AdapterKind
|
|
10
|
+
* @typedef {{ kind: AdapterKind, framework: string, reason: string }} AdapterDetection
|
|
9
11
|
* @typedef {{
|
|
10
12
|
* id: string,
|
|
11
13
|
* label: string,
|
|
@@ -13,21 +15,25 @@
|
|
|
13
15
|
* noun: string,
|
|
14
16
|
* extension: string,
|
|
15
17
|
* runtime: string[],
|
|
16
|
-
* detect: (meta:
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* detect: (meta: unknown) => AdapterDetection | null,
|
|
19
|
+
* inspect?: (workDir: string, entries?: Array<{ name: string, subpath: string | null }>) => Record<string, unknown>,
|
|
20
|
+
* bundle: (workDir: string) => { alias: Record<string, string>, define?: Record<string, string>, plugins?: import("esbuild").Plugin[], esbuild: import("esbuild").BuildOptions },
|
|
21
|
+
* entry: (fixturePath: string, pkg: string, context?: Record<string, unknown>) => string,
|
|
19
22
|
* discoverEntry: (pkg: string) => string,
|
|
20
|
-
* template: (archetype: string, pkg: string, name?: string) => string | null,
|
|
21
|
-
* generate: (input:
|
|
22
|
-
* describe: (npm:
|
|
23
|
+
* template: (archetype: string, pkg: string, name?: string, info?: unknown) => string | null,
|
|
24
|
+
* generate: (input: unknown) => { candidates: Array<{ id: string, summary: string, source: string, used: string[] }>, reason: string | null },
|
|
25
|
+
* describe: (npm: unknown) => string,
|
|
23
26
|
* }} Adapter
|
|
24
27
|
*/
|
|
28
|
+
import angular from "./angular.js";
|
|
29
|
+
import html from "./html.js";
|
|
25
30
|
import react from "./react.js";
|
|
31
|
+
import svelte from "./svelte.js";
|
|
26
32
|
import vue from "./vue.js";
|
|
27
33
|
import wc from "./wc.js";
|
|
28
34
|
|
|
29
35
|
/** @type {Record<string, Adapter>} */
|
|
30
|
-
export const ADAPTERS = { react, vue, wc };
|
|
36
|
+
export const ADAPTERS = { react, vue, angular, svelte, html, wc };
|
|
31
37
|
|
|
32
38
|
/** The adapter for a flavor (`react`, `vue`, or `wc`). */
|
|
33
39
|
export function adapterFor(id) {
|
package/src/frameworks/react.js
CHANGED
|
@@ -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:
|
|
37
|
-
link:
|
|
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)};
|
|
@@ -55,6 +58,7 @@ export default {
|
|
|
55
58
|
/** Packages the fixture and the library must share one copy of, and that the install makes sure are there. */
|
|
56
59
|
runtime: ["react", "react-dom"],
|
|
57
60
|
/** Is this package React, from its registry metadata alone? */
|
|
61
|
+
/** @returns {import("./index.js").AdapterDetection | null} */
|
|
58
62
|
detect(meta) {
|
|
59
63
|
const peers = meta.peerDependencies ?? {};
|
|
60
64
|
const deps = meta.dependencies ?? {};
|
|
@@ -66,7 +70,7 @@ export default {
|
|
|
66
70
|
bundle: (workDir) => ({
|
|
67
71
|
// One copy of React for the library and the fixture, or hooks break.
|
|
68
72
|
alias: { react: join(workDir, "node_modules", "react"), "react-dom": join(workDir, "node_modules", "react-dom") },
|
|
69
|
-
esbuild: { jsx: "automatic" },
|
|
73
|
+
esbuild: { jsx: /** @type {"automatic"} */ ("automatic") },
|
|
70
74
|
}),
|
|
71
75
|
entry,
|
|
72
76
|
discoverEntry,
|
|
@@ -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
|
+
};
|
package/src/frameworks/vue.js
CHANGED
|
@@ -62,6 +62,7 @@ export default {
|
|
|
62
62
|
noun: "export",
|
|
63
63
|
extension: "jsx",
|
|
64
64
|
runtime: ["vue"],
|
|
65
|
+
/** @returns {import("./index.js").AdapterDetection | null} */
|
|
65
66
|
detect(meta) {
|
|
66
67
|
const peers = meta.peerDependencies ?? {};
|
|
67
68
|
const deps = meta.dependencies ?? {};
|
|
@@ -79,7 +80,7 @@ export default {
|
|
|
79
80
|
alias: { vue: join(workDir, "node_modules", "vue") },
|
|
80
81
|
define: { __VUE_OPTIONS_API__: "true", __VUE_PROD_DEVTOOLS__: "false", __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false" },
|
|
81
82
|
// Vue has no JSX runtime of its own, so JSX becomes h() calls. The shim is injected wherever h or Fragment is used without being declared.
|
|
82
|
-
esbuild: { jsx: "transform", jsxFactory: "h", jsxFragment: "Fragment", inject: [shim] },
|
|
83
|
+
esbuild: { jsx: /** @type {"transform"} */ ("transform"), jsxFactory: "h", jsxFragment: "Fragment", inject: [shim] },
|
|
83
84
|
};
|
|
84
85
|
},
|
|
85
86
|
entry,
|