automatica11y 0.3.3 → 0.4.1
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/AGENTS.md +2 -0
- package/README.md +39 -11
- package/package.json +5 -3
- package/skills/automatica11y/SKILL.md +3 -1
- package/skills/automatica11y-runner/SKILL.md +47 -14
- package/skills/automatica11y-runner/references/fixtures.md +38 -2
- package/src/commands/common.js +5 -2
- package/src/frameworks/index.js +42 -0
- package/src/frameworks/react.js +77 -0
- package/src/frameworks/vue.js +90 -0
- package/src/frameworks/wc.js +55 -0
- package/src/globals.d.ts +1 -0
- package/src/harness/bundle.js +7 -6
- package/src/harness/generate/dialects.js +64 -0
- package/src/harness/generate/index.js +12 -0
- package/src/harness/generate/jsx-recipes.js +224 -0
- package/src/harness/generate/jsx.js +76 -0
- package/src/harness/generate/kit.js +50 -0
- package/src/harness/generate/marking.js +64 -0
- package/src/harness/generate/probe.js +97 -0
- package/src/harness/generate/shared.js +12 -0
- package/src/harness/generate/wc-recipes.js +132 -0
- package/src/harness/npm-install.js +38 -7
- package/src/harness/settle.js +17 -0
- package/src/harness/storybook.js +1 -0
- package/src/harness/url.js +13 -3
- package/src/plan/classify.js +11 -4
- package/src/plan/mapping.js +10 -7
- package/src/plan/resolve-npm.js +15 -9
- package/src/plan/subpath.js +133 -0
- package/src/report/comparison.js +14 -9
- package/src/report/parts.js +36 -12
- package/src/run/audit-npm.js +118 -28
- package/src/run/generate-fixture.js +74 -0
- package/src/run/run-plan.js +19 -4
- package/src/run/summary.js +5 -0
- package/src/schema.js +30 -6
- package/src/tiers/computed/checks.js +44 -5
- package/src/tiers/computed/color.js +8 -0
- package/src/tiers/computed/index.js +2 -2
- package/src/tiers/computed/measure-kit.js +30 -10
- package/src/tiers/conditions/checks.js +283 -0
- package/src/tiers/conditions/index.js +30 -0
- package/src/tiers/conditions/kit.js +133 -0
- package/src/tiers/interactions/archetypes.js +111 -0
- package/src/tiers/interactions/helpers.js +56 -0
- package/src/tiers/interactions/index.js +16 -3
- package/src/harness/npm-react.js +0 -39
- package/src/harness/npm-wc.js +0 -30
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The code a generated fixture carries to mark its trigger and root.
|
|
3
|
+
*
|
|
4
|
+
* The harness needs exactly one element with `data-a11y-trigger` and, once the component is showing, one with
|
|
5
|
+
* `data-a11y-root` on the element that carries the role. A generated fixture can't know how a library forwards props,
|
|
6
|
+
* so it doesn't rely on that. It passes the trigger attribute where it can, and this code then marks whatever is missing,
|
|
7
|
+
* by role, every 50 milliseconds, reaching into open shadow roots. The probe that follows checks the result.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** What to look for, per archetype. `root` is a selector, or "trigger", "parent", or "controls". */
|
|
11
|
+
export const MARKING = {
|
|
12
|
+
dialog: { root: "dialog, [role=dialog], [role=alertdialog]", trigger: "[aria-haspopup], [aria-expanded], button, [role=button]" },
|
|
13
|
+
menu: { root: "[role=menu]", trigger: "[aria-haspopup], [aria-expanded], button, [role=button]" },
|
|
14
|
+
tooltip: { root: "[role=tooltip]", trigger: "button, [role=button], a[href], input" },
|
|
15
|
+
tabs: { root: "trigger", trigger: "[role=tab]" },
|
|
16
|
+
accordion: { root: "controls", trigger: "[aria-expanded], summary, button" },
|
|
17
|
+
combobox: { root: "[role=listbox]", trigger: "input[role=combobox], [role=combobox], input" },
|
|
18
|
+
"form-field": { root: "parent", trigger: "input:not([type=hidden]), textarea, select, [role=textbox], [role=combobox], [role=checkbox], [role=switch]" },
|
|
19
|
+
"live-region": { root: "[role=alert], [role=status], [role=log], [aria-live=polite], [aria-live=assertive]", trigger: "button, [role=button]" },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** The roles each archetype's root may carry, so the probe can tell a right mark from a wrong one. */
|
|
23
|
+
export const ROOT_ROLES = {
|
|
24
|
+
dialog: ["dialog", "alertdialog"],
|
|
25
|
+
menu: ["menu", "menubar"],
|
|
26
|
+
tooltip: ["tooltip"],
|
|
27
|
+
tabs: ["tab"],
|
|
28
|
+
combobox: ["listbox", "combobox", "grid", "tree"],
|
|
29
|
+
"live-region": ["alert", "status", "log"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The source that goes at the top of a generated fixture. It defines `startMarking()`, which returns a function that stops it. */
|
|
33
|
+
export function markingSource(archetype) {
|
|
34
|
+
const config = MARKING[archetype];
|
|
35
|
+
return `function a11yDeepAll(selector, root) {
|
|
36
|
+
const scope = root || document;
|
|
37
|
+
const found = [...scope.querySelectorAll(selector)];
|
|
38
|
+
for (const el of scope.querySelectorAll("*")) if (el.shadowRoot) found.push(...a11yDeepAll(selector, el.shadowRoot));
|
|
39
|
+
return found;
|
|
40
|
+
}
|
|
41
|
+
function a11yMark() {
|
|
42
|
+
const config = ${JSON.stringify(config)};
|
|
43
|
+
if (a11yDeepAll("[data-a11y-trigger]").length === 0) {
|
|
44
|
+
const fallback = a11yDeepAll(config.trigger).find((el) => el.getClientRects().length > 0);
|
|
45
|
+
if (fallback) fallback.setAttribute("data-a11y-trigger", "");
|
|
46
|
+
}
|
|
47
|
+
const trigger = a11yDeepAll("[data-a11y-trigger]")[0];
|
|
48
|
+
if (a11yDeepAll("[data-a11y-root]").length > 0) return;
|
|
49
|
+
let root = null;
|
|
50
|
+
if (config.root === "trigger") root = trigger;
|
|
51
|
+
else if (config.root === "parent") root = trigger && trigger.parentElement;
|
|
52
|
+
else if (config.root === "controls") {
|
|
53
|
+
const id = trigger && trigger.getAttribute("aria-controls");
|
|
54
|
+
root = (id && document.getElementById(id)) || a11yDeepAll("[role=region]")[0];
|
|
55
|
+
} else root = a11yDeepAll(config.root)[0];
|
|
56
|
+
if (root) root.setAttribute("data-a11y-root", "");
|
|
57
|
+
}
|
|
58
|
+
function startMarking() {
|
|
59
|
+
a11yMark();
|
|
60
|
+
const timer = setInterval(a11yMark, 50);
|
|
61
|
+
return () => clearInterval(timer);
|
|
62
|
+
}
|
|
63
|
+
`;
|
|
64
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { installHelpers } from "../../tiers/interactions/helpers.js";
|
|
2
|
+
import { openPage } from "../url.js";
|
|
3
|
+
import { ROOT_ROLES } from "./marking.js";
|
|
4
|
+
|
|
5
|
+
/** Archetypes whose root appears after the trigger is activated, as the audit expects. */
|
|
6
|
+
const OPENS = new Set(["dialog", "menu", "tooltip", "accordion", "combobox", "live-region"]);
|
|
7
|
+
|
|
8
|
+
const FOCUSABLE_FIELD = "input:not([type=hidden]), textarea, select, [contenteditable=true], [role=textbox], [role=combobox], [role=checkbox], [role=switch], [role=radio], [role=slider], [role=spinbutton]";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check that a generated fixture does what the audit needs, before the audit trusts it.
|
|
12
|
+
* It loads the page, then checks that exactly one element is marked as the trigger, that nothing logged an error,
|
|
13
|
+
* that activating the trigger shows a root, and that the root carries a role that fits the archetype.
|
|
14
|
+
* A fixture that fails any of these isn't used, and the reason says which.
|
|
15
|
+
* @param {import("playwright-core").Browser} browser
|
|
16
|
+
* @param {string} url
|
|
17
|
+
* @param {string} archetype
|
|
18
|
+
* @returns {Promise<{ ok: boolean, reason: string | null }>}
|
|
19
|
+
*/
|
|
20
|
+
export async function probeFixture(browser, url, archetype) {
|
|
21
|
+
const errors = [];
|
|
22
|
+
/** @type {Awaited<ReturnType<typeof openPage>> | null} */
|
|
23
|
+
let opened = null;
|
|
24
|
+
try {
|
|
25
|
+
opened = await openPage(browser, url, {
|
|
26
|
+
waitUntil: "load",
|
|
27
|
+
beforeGoto: async (page) => {
|
|
28
|
+
page.on("pageerror", (error) => errors.push(error.message.split("\n")[0]));
|
|
29
|
+
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
|
+
});
|
|
32
|
+
await page.addInitScript(installHelpers);
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
const page = opened.page;
|
|
36
|
+
try {
|
|
37
|
+
await page.waitForFunction(() => window.__a11y?.queryAllDeep("[data-a11y-trigger]").length > 0, undefined, { timeout: 4000 });
|
|
38
|
+
} catch {
|
|
39
|
+
return { ok: false, reason: errors.length ? `it logged an error and rendered nothing to mark as the trigger: ${errors[0]}` : "no element could be marked as the trigger" };
|
|
40
|
+
}
|
|
41
|
+
await page.waitForTimeout(150);
|
|
42
|
+
const count = await page.evaluate(() => window.__a11y.queryAllDeep("[data-a11y-trigger]").length);
|
|
43
|
+
if (count !== 1) return { ok: false, reason: `${count} elements were marked as the trigger` };
|
|
44
|
+
if (errors.length) return { ok: false, reason: `it logged an error: ${errors[0]}` };
|
|
45
|
+
|
|
46
|
+
if (archetype === "form-field") {
|
|
47
|
+
const field = await page.evaluate((selector) => window.__a11y.queryDeep("[data-a11y-trigger]")?.matches(selector) ?? false, FOCUSABLE_FIELD);
|
|
48
|
+
return field ? { ok: true, reason: null } : { ok: false, reason: "the element marked as the trigger isn't a field a person can type in" };
|
|
49
|
+
}
|
|
50
|
+
if (archetype === "tabs") {
|
|
51
|
+
const role = await page.evaluate(() => window.__a11y.queryDeep("[data-a11y-trigger]")?.getAttribute("role") ?? null);
|
|
52
|
+
return role === "tab" ? { ok: true, reason: null } : { ok: false, reason: `the element marked as the trigger has ${role ? `role ${role}` : "no role"}, not tab` };
|
|
53
|
+
}
|
|
54
|
+
if (!OPENS.has(archetype)) return { ok: true, reason: null };
|
|
55
|
+
|
|
56
|
+
const trigger = page.locator("[data-a11y-trigger]").first();
|
|
57
|
+
try {
|
|
58
|
+
if (archetype === "tooltip") await trigger.focus();
|
|
59
|
+
else await trigger.click({ timeout: 3000 });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return { ok: false, reason: `the trigger couldn't be activated: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}` };
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
await page.waitForFunction(
|
|
65
|
+
({ live, needsRoot }) => {
|
|
66
|
+
const state = window.__a11y.live();
|
|
67
|
+
if (live) return state.present && state.visible && Boolean(state.text || state.named);
|
|
68
|
+
// The trigger can say it's expanded an instant before the root is marked, so a role-carrying root has to be there itself.
|
|
69
|
+
if (needsRoot) return state.present && state.visible;
|
|
70
|
+
const trigger = window.__a11y.queryDeep("[data-a11y-trigger]");
|
|
71
|
+
return (state.present && state.visible) || trigger?.getAttribute("aria-expanded") === "true";
|
|
72
|
+
},
|
|
73
|
+
{ live: archetype === "live-region", needsRoot: Boolean(ROOT_ROLES[archetype]) },
|
|
74
|
+
{ timeout: 4000 },
|
|
75
|
+
);
|
|
76
|
+
} catch {
|
|
77
|
+
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
|
+
}
|
|
79
|
+
const roles = ROOT_ROLES[archetype];
|
|
80
|
+
if (roles) {
|
|
81
|
+
const fits = await page.evaluate((allowed) => {
|
|
82
|
+
const root = window.__a11y.queryDeep("[data-a11y-root]");
|
|
83
|
+
if (!root) return null;
|
|
84
|
+
const role = root.getAttribute("role");
|
|
85
|
+
return { role, fits: Boolean(role && allowed.includes(role)) || root.localName === "dialog" || root.hasAttribute("aria-live") };
|
|
86
|
+
}, roles);
|
|
87
|
+
if (!fits) return { ok: false, reason: "the root wasn't marked" };
|
|
88
|
+
if (!fits.fits) return { ok: false, reason: `the element marked as the root has ${fits.role ? `role ${fits.role}` : "no role"}, not ${roles.join(" or ")}` };
|
|
89
|
+
}
|
|
90
|
+
if (errors.length) return { ok: false, reason: `it logged an error when the trigger was activated: ${errors[0]}` };
|
|
91
|
+
return { ok: true, reason: null };
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return { ok: false, reason: error instanceof Error ? error.message.split("\n")[0] : String(error) };
|
|
94
|
+
} finally {
|
|
95
|
+
await opened?.close();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ARCHETYPE_PATTERNS } from "../storybook.js";
|
|
2
|
+
|
|
3
|
+
/** Archetypes a fixture can be generated for. Buttons and links use templates. A chart has nothing to wire. */
|
|
4
|
+
export const GENERATABLE = new Set(["dialog", "menu", "tooltip", "tabs", "accordion", "combobox", "form-field", "live-region"]);
|
|
5
|
+
|
|
6
|
+
/** The most candidates probed for one archetype, so a package that matches nothing doesn't cost minutes. */
|
|
7
|
+
export const ATTEMPT_LIMIT = 8;
|
|
8
|
+
|
|
9
|
+
/** Does the package's own name say it is this archetype? `@scope/react-dialog` is a dialog. */
|
|
10
|
+
export function nameSaysSo(archetype, pkg) {
|
|
11
|
+
return ARCHETYPE_PATTERNS[archetype].test(pkg.replace(/[^a-z0-9]+/gi, " "));
|
|
12
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Candidate web component fixtures, built from what the element says about itself once it's defined:
|
|
3
|
+
* the attributes it observes, the members its class has, and the slots its shadow root offers.
|
|
4
|
+
* A candidate is a guess until the probe has seen it behave (see probe.js). None of this names a library.
|
|
5
|
+
*/
|
|
6
|
+
import { markingSource } from "./marking.js";
|
|
7
|
+
import { ATTEMPT_LIMIT, GENERATABLE } from "./shared.js";
|
|
8
|
+
|
|
9
|
+
/** The module around every candidate: the marking code and a mount function that starts it. */
|
|
10
|
+
function frame(archetype, body) {
|
|
11
|
+
return `${markingSource(archetype)}
|
|
12
|
+
export default function mount(container) {
|
|
13
|
+
${body.split("\n").map((line) => (line ? ` ${line}` : line)).join("\n")}
|
|
14
|
+
startMarking();
|
|
15
|
+
}
|
|
16
|
+
`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const has = (facts, name) => facts.attributes.includes(name) || facts.members.includes(name);
|
|
20
|
+
const slotFor = (facts, pattern) => facts.slots.find((name) => pattern.test(name));
|
|
21
|
+
|
|
22
|
+
// ---- dialog ----
|
|
23
|
+
|
|
24
|
+
/** The ways a dialog element is commonly opened, from what it has. */
|
|
25
|
+
function openers(facts) {
|
|
26
|
+
const found = [];
|
|
27
|
+
if (facts.attributes.includes("open")) found.push(["attribute", `dialog.setAttribute("open", "");`]);
|
|
28
|
+
if (facts.members.includes("open")) found.push(["property", "dialog.open = true;"]);
|
|
29
|
+
if (facts.members.includes("showModal")) found.push(["showModal()", "dialog.showModal();"]);
|
|
30
|
+
if (facts.members.includes("show")) found.push(["show()", "dialog.show();"]);
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function dialogs(tagName, facts) {
|
|
35
|
+
const titleSlot = slotFor(facts, /title|header|heading/i);
|
|
36
|
+
const heading = titleSlot ? `<h2 slot="${titleSlot}">Edit profile</h2>` : "<h2>Edit profile</h2>";
|
|
37
|
+
return openers(facts).map(([how, code]) => ({
|
|
38
|
+
id: `dialog-${how.replace(/[^a-z]+/gi, "-").replace(/-$/, "")}`,
|
|
39
|
+
summary: `a ${tagName} opened with its ${how}`,
|
|
40
|
+
source: frame("dialog", `container.innerHTML = \`<button type="button" data-a11y-trigger>Open dialog</button><${tagName}>${heading}<p>Update your details.</p><button type="button">Close</button></${tagName}>\`;
|
|
41
|
+
const dialog = container.querySelector(${JSON.stringify(tagName)});
|
|
42
|
+
container.querySelector("[data-a11y-trigger]").addEventListener("click", () => {
|
|
43
|
+
${code}
|
|
44
|
+
});`),
|
|
45
|
+
used: [tagName],
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- tooltip ----
|
|
50
|
+
|
|
51
|
+
function tooltips(tagName, facts) {
|
|
52
|
+
return facts.attributes.filter((name) => /^(tip|tooltip|content|text|label|title|message|description)$/.test(name)).map((name) => ({
|
|
53
|
+
id: `tooltip-${name}`,
|
|
54
|
+
summary: `a ${tagName} that takes its text from ${name}`,
|
|
55
|
+
source: frame("tooltip", `container.innerHTML = \`<${tagName} ${name}="Saves your work."><button type="button" data-a11y-trigger>Save</button></${tagName}>\`;`),
|
|
56
|
+
used: [tagName],
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---- live region ----
|
|
61
|
+
|
|
62
|
+
function messages(tagName, facts) {
|
|
63
|
+
const list = [{
|
|
64
|
+
id: "message-appended",
|
|
65
|
+
summary: `a ${tagName} added to the page when the trigger is pressed`,
|
|
66
|
+
source: frame("live-region", `container.innerHTML = \`<button type="button" data-a11y-trigger>Show message</button><div id="slot"></div>\`;
|
|
67
|
+
container.querySelector("[data-a11y-trigger]").addEventListener("click", () => {
|
|
68
|
+
const message = document.createElement(${JSON.stringify(tagName)});
|
|
69
|
+
message.textContent = "Saved.";
|
|
70
|
+
container.querySelector("#slot").append(message);
|
|
71
|
+
});`),
|
|
72
|
+
used: [tagName],
|
|
73
|
+
}];
|
|
74
|
+
if (has(facts, "open")) {
|
|
75
|
+
list.push({
|
|
76
|
+
id: "message-open",
|
|
77
|
+
summary: `a ${tagName} shown by setting open`,
|
|
78
|
+
source: frame("live-region", `container.innerHTML = \`<button type="button" data-a11y-trigger>Show message</button><${tagName}>Saved.</${tagName}>\`;
|
|
79
|
+
const message = container.querySelector(${JSON.stringify(tagName)});
|
|
80
|
+
container.querySelector("[data-a11y-trigger]").addEventListener("click", () => message.setAttribute("open", ""));`),
|
|
81
|
+
used: [tagName],
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return list;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- form field ----
|
|
88
|
+
|
|
89
|
+
/** The element isn't marked as the trigger here. The marking code finds the input inside its shadow root, which is the thing a person types in. */
|
|
90
|
+
function fields(tagName, facts) {
|
|
91
|
+
if (!["value", "name", "label", "placeholder", "checked", "type"].some((name) => has(facts, name))) return [];
|
|
92
|
+
const list = [{
|
|
93
|
+
id: "field-wrapped-label",
|
|
94
|
+
summary: `a ${tagName} inside a wrapping label`,
|
|
95
|
+
source: frame("form-field", `container.innerHTML = \`<label>Name <${tagName}></${tagName}></label>\`;`),
|
|
96
|
+
used: [tagName],
|
|
97
|
+
}];
|
|
98
|
+
if (has(facts, "label")) {
|
|
99
|
+
list.push({
|
|
100
|
+
id: "field-label-attribute",
|
|
101
|
+
summary: `a ${tagName} with a label attribute`,
|
|
102
|
+
source: frame("form-field", `container.innerHTML = \`<${tagName} label="Name"></${tagName}>\`;`),
|
|
103
|
+
used: [tagName],
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return list;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Candidates for one element and archetype. Archetypes with no recipe here (menu, tabs, accordion, combobox) return none,
|
|
111
|
+
* because those depend on child elements and slots that an element can't describe well enough to guess.
|
|
112
|
+
* @param {{ archetype: string, tag: string, facts: { attributes: string[], members: string[], slots: string[] } }} input
|
|
113
|
+
*/
|
|
114
|
+
export function wcCandidates({ archetype, tag, facts }) {
|
|
115
|
+
const build = { dialog: dialogs, tooltip: tooltips, "live-region": messages, "form-field": fields }[archetype];
|
|
116
|
+
return build ? build(tag, facts) : [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Candidates for a web component package, from what the element the mapping found says about itself.
|
|
121
|
+
* @param {{ archetype: string, entry: { tag?: string }, facts: Record<string, { attributes: string[], members: string[], slots: string[] }> }} input
|
|
122
|
+
* @returns {{ candidates: Array<{ id: string, summary: string, source: string, used: string[] }>, reason: string | null }}
|
|
123
|
+
*/
|
|
124
|
+
export function generateWc({ archetype, entry, facts }) {
|
|
125
|
+
if (!GENERATABLE.has(archetype)) return { candidates: [], reason: `Nothing is generated for the ${archetype} archetype.` };
|
|
126
|
+
const tag = entry.tag;
|
|
127
|
+
if (!tag || !facts[tag]) return { candidates: [], reason: "No custom element looks like this archetype, so there's nothing to build a fixture around." };
|
|
128
|
+
const candidates = wcCandidates({ archetype, tag, facts: facts[tag] }).slice(0, ATTEMPT_LIMIT);
|
|
129
|
+
return candidates.length
|
|
130
|
+
? { candidates, reason: null }
|
|
131
|
+
: { candidates: [], reason: `${tag} doesn't show a way to wire a ${archetype} (for example, an open attribute or a tip attribute), and no recipe covers a web component ${archetype} without one.` };
|
|
132
|
+
}
|
|
@@ -33,9 +33,9 @@ export function installedVersion(dir, name) {
|
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Install a package into its own directory, never next to another target's install.
|
|
36
|
-
* npm adds the peer dependencies. A React package also needs react-dom, so add
|
|
36
|
+
* npm adds the peer dependencies. A React package also needs react-dom, and a Vue package needs vue, so add them when the peers left them out.
|
|
37
37
|
* Install scripts stay off, because the code is untrusted until it runs in the browser sandbox.
|
|
38
|
-
* @param {{ dir: string, name: string, version: string, flavor: "react" | "wc" | "unknown", run?: typeof runNpm }} options
|
|
38
|
+
* @param {{ dir: string, name: string, version: string, flavor: "react" | "vue" | "wc" | "unknown", run?: typeof runNpm }} options
|
|
39
39
|
*/
|
|
40
40
|
export async function installPackage({ dir, name, version, flavor, run = runNpm }) {
|
|
41
41
|
mkdirSync(dir, { recursive: true });
|
|
@@ -69,15 +69,46 @@ export async function installPackage({ dir, name, version, flavor, run = runNpm
|
|
|
69
69
|
await npm(["install", "react", "react-dom", ...NPM_FLAGS, "--legacy-peer-deps"]);
|
|
70
70
|
warnings.push(`${name} didn't bring in react, so the latest react and react-dom were added.`);
|
|
71
71
|
} else if (!installedVersion(dir, "react-dom")) {
|
|
72
|
-
|
|
72
|
+
// Name react too. A loose install prunes a package that only arrived as a peer, and react did.
|
|
73
|
+
await npm(["install", `react@${react}`, `react-dom@${react}`, ...NPM_FLAGS, "--legacy-peer-deps"]);
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
|
-
|
|
76
|
+
if (flavor === "vue" && !installedVersion(dir, "vue")) {
|
|
77
|
+
await npm(["install", "vue", ...NPM_FLAGS, "--legacy-peer-deps"]);
|
|
78
|
+
warnings.push(`${name} didn't bring in vue, so the latest vue was added.`);
|
|
79
|
+
}
|
|
80
|
+
return { dir, warnings, react: installedVersion(dir, "react"), reactDom: installedVersion(dir, "react-dom"), vue: installedVersion(dir, "vue"), version: installedVersion(dir, name) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The installed framework runtimes (React, React DOM, Vue), named again so a loose install can't prune them. */
|
|
84
|
+
function pinnedRuntime(dir) {
|
|
85
|
+
return ["react", "react-dom", "vue"].flatMap((name) => (installedVersion(dir, name) ? [`${name}@${installedVersion(dir, name)}`] : []));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const PACKAGE_SPEC = /^(@[a-z0-9~][\w.~-]*\/)?[a-z0-9~][\w.~-]*(@[\w.^~<>=*|-]+)?$/i;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Install packages a mapping names beside the target: a token stylesheet, a theme, or another package the library's own
|
|
92
|
+
* documentation says to load. Only plain package specs are accepted (no flags, paths, or URLs). Install scripts stay off.
|
|
93
|
+
* @param {{ dir: string, specs: string[], run?: typeof runNpm }} options
|
|
94
|
+
* @returns {Promise<{ installed: string[], warnings: string[] }>}
|
|
95
|
+
*/
|
|
96
|
+
export async function installExtraPackages({ dir, specs, run = runNpm }) {
|
|
97
|
+
const wanted = [...new Set(specs)];
|
|
98
|
+
if (wanted.length === 0) return { installed: [], warnings: [] };
|
|
99
|
+
for (const spec of wanted) if (!PACKAGE_SPEC.test(spec)) throw new Error(`The mapping's install list has "${spec}", which isn't a package name with an optional version.`);
|
|
100
|
+
try {
|
|
101
|
+
await run(["install", ...wanted, ...pinnedRuntime(dir), "--legacy-peer-deps", ...NPM_FLAGS], dir);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw new Error(`npm couldn't install ${wanted.join(", ")}: ${firstLine(error.message)}`);
|
|
104
|
+
}
|
|
105
|
+
return { installed: wanted, warnings: [`The mapping named ${wanted.join(", ")} to install beside the library, so it was installed.`] };
|
|
76
106
|
}
|
|
77
107
|
|
|
78
108
|
/**
|
|
79
109
|
* Find the optional peer dependencies that installed packages declare, with the range each asks for.
|
|
80
|
-
* npm leaves these out, but a library's default setup can still need one
|
|
110
|
+
* npm leaves these out, but a library's default setup can still need one
|
|
111
|
+
* (e.g., React libraries may need an Emotion package).
|
|
81
112
|
* @param {string} dir The install folder.
|
|
82
113
|
* @returns {Map<string, string>} Package name to range.
|
|
83
114
|
*/
|
|
@@ -113,8 +144,8 @@ export async function installOptionalPeers({ dir, unresolved, run = runNpm }) {
|
|
|
113
144
|
const declared = declaredOptionalPeers(dir);
|
|
114
145
|
const wanted = unresolved.filter((name) => declared.has(name) && !installedVersion(dir, name));
|
|
115
146
|
if (wanted.length === 0) return { installed: [], warnings: [] };
|
|
116
|
-
// A loose install prunes packages that only arrived as peers, so name
|
|
117
|
-
const keep =
|
|
147
|
+
// A loose install prunes packages that only arrived as peers, so name the framework again to keep it.
|
|
148
|
+
const keep = pinnedRuntime(dir);
|
|
118
149
|
const specs = [...wanted.map((name) => `${name}@${declared.get(name)}`), ...keep];
|
|
119
150
|
try {
|
|
120
151
|
await run(["install", ...specs, "--legacy-peer-deps", ...NPM_FLAGS], dir);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wait for fades and slides to finish before a state is scanned, so contrast is measured on the settled colors and not on a
|
|
3
|
+
* half-transparent element mid-transition. Animations that never end (a spinner) are left alone. Waits at most 2 seconds.
|
|
4
|
+
* @param {import("playwright-core").Page} page
|
|
5
|
+
*/
|
|
6
|
+
export async function settleAnimations(page) {
|
|
7
|
+
await page.evaluate(async () => {
|
|
8
|
+
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
|
9
|
+
await frame();
|
|
10
|
+
const finite = () => document.getAnimations().filter((a) => a.effect && a.effect.getComputedTiming().endTime !== Infinity);
|
|
11
|
+
const deadline = Date.now() + 2000;
|
|
12
|
+
while (finite().length > 0 && Date.now() < deadline) {
|
|
13
|
+
await Promise.race([Promise.allSettled(finite().map((a) => a.finished)), new Promise((done) => setTimeout(done, 200))]);
|
|
14
|
+
await frame();
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
package/src/harness/storybook.js
CHANGED
|
@@ -12,6 +12,7 @@ export const ARCHETYPE_PATTERNS = {
|
|
|
12
12
|
"form-field": /\binputs?\b|\btext ?(field|area|box)\b|\bform\b|\bcheckbox(es)?\b|\bradio\b|\bswitch\b|\bfield\b/i,
|
|
13
13
|
accordion: /\baccordions?\b|\bcollaps(e|ible)\b|\bdisclosure\b/i,
|
|
14
14
|
tooltip: /\btooltips?\b|\bpopovers?\b/i,
|
|
15
|
+
"live-region": /\balerts?\b(?! ?dialogs?)|\bstatus\b|\btoasts?\b|\bsnackbars?\b|\bnotifications?\b|\blive ?regions?\b/i,
|
|
15
16
|
chart: /\bcharts?\b|\bgraphs?\b|\bplots?\b/i,
|
|
16
17
|
};
|
|
17
18
|
|
package/src/harness/url.js
CHANGED
|
@@ -8,12 +8,12 @@ const NETWORK_IDLE_MS = 30_000;
|
|
|
8
8
|
* The returned `warnings` hold anything the report should mention, such as a network that never went idle.
|
|
9
9
|
* @param {import("playwright-core").Browser} browser
|
|
10
10
|
* @param {string} url
|
|
11
|
-
* @param {{ viewport?: { width: number, height: number }, forcedColors?: boolean, beforeGoto?: (page: import("playwright-core").Page) => void | Promise<unknown>, waitUntil?: "load" | "networkidle" }} [options]
|
|
11
|
+
* @param {{ viewport?: { width: number, height: number }, forcedColors?: boolean, colorScheme?: "light" | "dark", reducedMotion?: boolean, contrast?: "more" | "less", reducedTransparency?: boolean, beforeGoto?: (page: import("playwright-core").Page) => void | Promise<unknown>, waitUntil?: "load" | "networkidle" }} [options]
|
|
12
12
|
* `beforeGoto` runs before navigation, so a caller can attach console listeners that see the first messages.
|
|
13
13
|
* `waitUntil` defaults to `networkidle`. Local fixture pages don't need to wait for the network.
|
|
14
14
|
*/
|
|
15
|
-
export async function openPage(browser, url, { viewport = VIEWPORT, forcedColors = false, beforeGoto, waitUntil = "networkidle" } = {}) {
|
|
16
|
-
const context = await browser.newContext({ viewport, deviceScaleFactor: 1, forcedColors: forcedColors ? "active" : "none" });
|
|
15
|
+
export async function openPage(browser, url, { viewport = VIEWPORT, forcedColors = false, colorScheme = "light", reducedMotion = false, contrast, reducedTransparency = false, beforeGoto, waitUntil = "networkidle" } = {}) {
|
|
16
|
+
const context = await browser.newContext({ viewport, deviceScaleFactor: 1, forcedColors: forcedColors ? "active" : "none", colorScheme, reducedMotion: reducedMotion ? "reduce" : "no-preference" });
|
|
17
17
|
await context.addInitScript(recordClosedShadowRoots);
|
|
18
18
|
await context.addInitScript(recordCustomElements);
|
|
19
19
|
/** @type {string[]} */
|
|
@@ -21,6 +21,16 @@ export async function openPage(browser, url, { viewport = VIEWPORT, forcedColors
|
|
|
21
21
|
try {
|
|
22
22
|
const page = await context.newPage();
|
|
23
23
|
page.setDefaultTimeout(30_000);
|
|
24
|
+
// Playwright can't set prefers-reduced-transparency or prefers-contrast: less, so ask the browser directly.
|
|
25
|
+
// A page opened this way sets nothing else, because the call replaces any other emulated media features.
|
|
26
|
+
const features = [
|
|
27
|
+
...(contrast ? [{ name: "prefers-contrast", value: contrast }] : []),
|
|
28
|
+
...(reducedTransparency ? [{ name: "prefers-reduced-transparency", value: "reduce" }] : []),
|
|
29
|
+
];
|
|
30
|
+
if (features.length) {
|
|
31
|
+
const session = await context.newCDPSession(page);
|
|
32
|
+
await session.send("Emulation.setEmulatedMedia", { features });
|
|
33
|
+
}
|
|
24
34
|
await beforeGoto?.(page);
|
|
25
35
|
let response;
|
|
26
36
|
try {
|
package/src/plan/classify.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, extname, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { cleanSubpath } from "./subpath.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* @typedef {"npm" | "storybook" | "url" | "html-file" | "static-dir"} TargetKind
|
|
@@ -22,7 +23,7 @@ import { fileURLToPath } from "node:url";
|
|
|
22
23
|
const LOCAL_PATH = /^(\.{1,2}(\/|$)|\/|~(\/|$)|file:)/;
|
|
23
24
|
const LABEL = /^([A-Za-z0-9][\w.-]*)=(.+)$/;
|
|
24
25
|
const HTTP_URL = /^https?:\/\//i;
|
|
25
|
-
const NPM_NAME = /^(?:@([a-z0-9][a-z0-9._~-]*)\/)?([a-z0-9][a-z0-9._~-]*)(?:@(
|
|
26
|
+
const NPM_NAME = /^(?:@([a-z0-9][a-z0-9._~-]*)\/)?([a-z0-9][a-z0-9._~-]*)(?:@([^/]*))?(?:\/(.+))?$/;
|
|
26
27
|
const COMPONENT_SOURCE = new Set([".tsx", ".jsx", ".ts", ".js", ".mjs", ".cjs", ".vue", ".svelte"]);
|
|
27
28
|
|
|
28
29
|
/**
|
|
@@ -129,17 +130,23 @@ function classifyNpm(spec, base) {
|
|
|
129
130
|
if (/[A-Z]/.test(spec) && NPM_NAME.test(spec.toLowerCase())) return failed(`"${spec}" isn't a valid package name. npm package names are lowercase.`, base);
|
|
130
131
|
return failed(`"${spec}" isn't a valid npm package name. Write npm:name, npm:@scope/name, or npm:name@version.`, base);
|
|
131
132
|
}
|
|
132
|
-
const [, scope, name, version] = match;
|
|
133
|
+
const [, scope, name, version, rawSubpath] = match;
|
|
133
134
|
if (version === "") return failed(`"${spec}" ends with @ but has no version.`, base);
|
|
135
|
+
let subpath = null;
|
|
136
|
+
if (rawSubpath !== undefined) {
|
|
137
|
+
const cleaned = cleanSubpath(rawSubpath);
|
|
138
|
+
if ("reason" in cleaned) return failed(`"${spec}" isn't a valid sub-path: ${cleaned.reason}. Write npm:name/sub/path or npm:name@version/sub/path.`, base);
|
|
139
|
+
subpath = /** @type {{ subpath: string }} */ (cleaned).subpath;
|
|
140
|
+
}
|
|
134
141
|
const full = scope ? `@${scope}/${name}` : name;
|
|
135
142
|
return {
|
|
136
143
|
...base,
|
|
137
|
-
name: base.name || full,
|
|
144
|
+
name: base.name || (subpath ? `${full}/${subpath}` : full),
|
|
138
145
|
status: "ok",
|
|
139
146
|
reason: null,
|
|
140
147
|
kind: "npm",
|
|
141
148
|
evidenceLevel: "component",
|
|
142
|
-
resolved: { name: full, requested: version ?? null, version: null },
|
|
149
|
+
resolved: { name: full, requested: version ?? null, version: null, subpath },
|
|
143
150
|
};
|
|
144
151
|
}
|
|
145
152
|
|
package/src/plan/mapping.js
CHANGED
|
@@ -14,6 +14,7 @@ const BASE_NAMES = {
|
|
|
14
14
|
"form-field": ["input", "textfield", "textinput", "field", "checkbox"],
|
|
15
15
|
accordion: ["accordion", "collapsible", "disclosure"],
|
|
16
16
|
tooltip: ["tooltip", "popover"],
|
|
17
|
+
"live-region": ["alert", "status", "toast", "snackbar", "notification", "liveregion"],
|
|
17
18
|
chart: ["chart", "linechart", "barchart"],
|
|
18
19
|
};
|
|
19
20
|
|
|
@@ -24,21 +25,23 @@ export const TEMPLATED = new Set(["button", "link"]);
|
|
|
24
25
|
const words = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[-_]/g, " ");
|
|
25
26
|
|
|
26
27
|
/** How well a name fits an archetype. 0 means it doesn't. */
|
|
27
|
-
function score(archetype, name) {
|
|
28
|
+
export function score(archetype, name) {
|
|
28
29
|
if (!ARCHETYPE_PATTERNS[archetype].test(words(name))) return 0;
|
|
29
30
|
const compact = name.replace(/[-_\s]/g, "").toLowerCase();
|
|
30
31
|
const last = words(name).toLowerCase().split(" ").pop();
|
|
31
32
|
const bases = BASE_NAMES[archetype];
|
|
32
|
-
|
|
33
|
-
if (bases.includes(
|
|
34
|
-
if (bases.
|
|
33
|
+
// Names listed first are closer to the archetype itself, so `tooltip` beats `popover` when both are there.
|
|
34
|
+
if (bases.includes(compact)) return 4 - bases.indexOf(compact) * 0.1;
|
|
35
|
+
if (bases.includes(last)) return 3 - bases.indexOf(last) * 0.1;
|
|
36
|
+
const starts = bases.findIndex((base) => compact.startsWith(base));
|
|
37
|
+
if (starts !== -1) return 2 - starts * 0.1;
|
|
35
38
|
return 1;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/**
|
|
39
|
-
* Guess which exports (React) or tags (web components) stand for each archetype.
|
|
42
|
+
* Guess which exports (React or Vue) or tags (web components) stand for each archetype.
|
|
40
43
|
* The result is a starting point. A person or the skill checks it before trusting it.
|
|
41
|
-
* @param {{ flavor: "react" | "wc", exports?: Array<{ name: string, type: string, parts: string[] }>, tags?: string[] }} input
|
|
44
|
+
* @param {{ flavor: "react" | "vue" | "wc", exports?: Array<{ name: string, type: string, parts: string[] }>, tags?: string[] }} input
|
|
42
45
|
* @returns {Record<string, any>}
|
|
43
46
|
*/
|
|
44
47
|
export function candidateMapping({ flavor, exports = [], tags = [] }) {
|
|
@@ -59,7 +62,7 @@ export function candidateMapping({ flavor, exports = [], tags = [] }) {
|
|
|
59
62
|
const parts = info?.parts ?? [];
|
|
60
63
|
// Flat compound libraries (DialogRoot, DialogTrigger, DialogContent) have sibling exports that share a prefix.
|
|
61
64
|
// A button or link usually sits beside ButtonBase, ButtonGroup, and the like, which aren't its parts, so only real parts (Button.Root) count there.
|
|
62
|
-
const siblings = flavor
|
|
65
|
+
const siblings = flavor !== "wc" && !TEMPLATED.has(archetype) ? names.filter((n) => n !== best && n.startsWith(best) && n.length > best.length) : [];
|
|
63
66
|
const compound = parts.length > 0 || siblings.length >= 2;
|
|
64
67
|
const templated = TEMPLATED.has(archetype) && !compound;
|
|
65
68
|
mapping[archetype] = {
|
package/src/plan/resolve-npm.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { ADAPTERS } from "../frameworks/index.js";
|
|
3
|
+
import { checkExports, notExportedMessage } from "./subpath.js";
|
|
2
4
|
|
|
3
|
-
const FIELDS = ["name", "version", "peerDependencies", "dependencies", "keywords", "customElements", "deprecated"];
|
|
5
|
+
const FIELDS = ["name", "version", "peerDependencies", "dependencies", "keywords", "customElements", "deprecated", "exports"];
|
|
4
6
|
const OTHER_FRAMEWORKS = {
|
|
5
|
-
vue: "Vue",
|
|
6
7
|
"@angular/core": "Angular",
|
|
7
8
|
svelte: "Svelte",
|
|
8
9
|
"solid-js": "Solid",
|
|
@@ -44,19 +45,19 @@ export function npmView(spec, { timeoutMs = 60_000 } = {}) {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
|
-
* Guess how a package renders from its metadata alone. React wins when both signals appear.
|
|
48
|
+
* Guess how a package renders from its metadata alone. React wins when both signals appear, then a custom elements manifest, then Vue.
|
|
48
49
|
* `npm` means the metadata can't say, so the run decides after it installs and loads the package.
|
|
49
50
|
* @param {any} meta
|
|
50
|
-
* @returns {{ kind: "npm-react" | "npm-wc" | "npm-unsupported" | "npm", framework: string | null, reason: string }}
|
|
51
|
+
* @returns {{ kind: "npm-react" | "npm-vue" | "npm-wc" | "npm-unsupported" | "npm", framework: string | null, reason: string }}
|
|
51
52
|
*/
|
|
52
53
|
export function detectFlavor(meta) {
|
|
53
54
|
const peers = meta.peerDependencies ?? {};
|
|
54
55
|
const deps = meta.dependencies ?? {};
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return { kind: "npm-react", framework: "React", reason: `The package lists react as a ${how}.` };
|
|
58
|
-
}
|
|
56
|
+
const react = /** @type {any} */ (ADAPTERS.react.detect(meta));
|
|
57
|
+
if (react) return react;
|
|
59
58
|
if (meta.customElements) return { kind: "npm-wc", framework: "Web components", reason: "The package has a customElements manifest." };
|
|
59
|
+
const vue = /** @type {any} */ (ADAPTERS.vue.detect(meta));
|
|
60
|
+
if (vue) return vue;
|
|
60
61
|
for (const [name, label] of Object.entries(OTHER_FRAMEWORKS)) {
|
|
61
62
|
if (name in peers) return { kind: "npm-unsupported", framework: label, reason: `The package needs ${label}.` };
|
|
62
63
|
}
|
|
@@ -73,9 +74,14 @@ export function detectFlavor(meta) {
|
|
|
73
74
|
*/
|
|
74
75
|
export async function resolveNpmTarget(target, view) {
|
|
75
76
|
if (target.status !== "ok" || target.kind !== "npm" || !target.resolved) return target;
|
|
76
|
-
const { name, requested } = target.resolved;
|
|
77
|
+
const { name, requested, subpath } = target.resolved;
|
|
77
78
|
try {
|
|
78
79
|
const meta = await view(`${name}@${requested ?? "latest"}`);
|
|
80
|
+
// The registry lists a package's exports, so a wrong sub-path is caught here, before anything is installed.
|
|
81
|
+
if (subpath) {
|
|
82
|
+
const result = checkExports(meta.exports, subpath);
|
|
83
|
+
if (result.checked && !result.ok) throw new Error(notExportedMessage({ name, version: meta.version ?? null, subpath, exact: result.exact, patterns: result.patterns }));
|
|
84
|
+
}
|
|
79
85
|
const flavor = detectFlavor(meta);
|
|
80
86
|
return {
|
|
81
87
|
...target,
|