automatica11y 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -8
- package/package.json +6 -3
- package/skills/automatica11y-runner/SKILL.md +47 -13
- package/skills/automatica11y-runner/references/fixtures.md +38 -2
- package/src/commands/common.js +5 -2
- package/src/data/README.md +19 -0
- package/src/data/wcag-2.2.json +7557 -0
- package/src/data/wcag-2.2.source.json +6 -0
- 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 +2 -0
- package/src/harness/bundle.js +23 -8
- 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 +85 -5
- 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 +11 -7
- package/src/plan/resolve-npm.js +15 -9
- package/src/plan/subpath.js +133 -0
- package/src/report/comparison.js +21 -6
- package/src/report/parts.js +60 -8
- package/src/run/audit-npm.js +137 -29
- package/src/run/generate-fixture.js +74 -0
- package/src/run/run-plan.js +24 -4
- package/src/run/summary.js +10 -0
- package/src/schema.js +34 -7
- package/src/tiers/computed/checks.js +197 -0
- package/src/tiers/computed/color.js +48 -0
- package/src/tiers/computed/index.js +25 -0
- package/src/tiers/computed/measure-kit.js +225 -0
- 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 +117 -4
- package/src/tiers/interactions/focus-indicator.js +39 -0
- package/src/tiers/interactions/helpers.js +87 -16
- package/src/tiers/interactions/index.js +17 -4
- package/src/tiers/rules/axe.js +4 -3
- package/src/wcag/index.js +83 -0
- package/src/harness/npm-react.js +0 -39
- package/src/harness/npm-wc.js +0 -30
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { ARCHETYPE_PATTERNS } from "../storybook.js";
|
|
2
|
+
import { score } from "../../plan/mapping.js";
|
|
3
|
+
import { buildKit } from "./kit.js";
|
|
4
|
+
import { buildRecipes } from "./jsx-recipes.js";
|
|
5
|
+
import { ATTEMPT_LIMIT, GENERATABLE, nameSaysSo } from "./shared.js";
|
|
6
|
+
|
|
7
|
+
/** The names a part of a compound component ends with. A name that ends in one belongs to a family that shares the rest. */
|
|
8
|
+
const PART_SUFFIX = /(Root|Trigger|Portal|Overlay|Backdrop|Positioner|Content|Popup|Panel|Title|Description|Close|Header|Item|List|Input|Label|Control|Provider)$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The prefix a family of flat exports shares. `DialogClose` and `DialogRoot` belong to `Dialog`, even when no export is
|
|
12
|
+
* named `Dialog`. It counts only when at least two exports start with it.
|
|
13
|
+
*/
|
|
14
|
+
export function inferBase(name, exports) {
|
|
15
|
+
if (!name) return null;
|
|
16
|
+
const base = name.replace(PART_SUFFIX, "");
|
|
17
|
+
if (!base || base === name) return null;
|
|
18
|
+
return exports.filter((e) => e.name.startsWith(base) && e.name !== base).length >= 2 ? base : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const words = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[-_]/g, " ");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The component families in a package that look like this archetype, best first. A family is a base name and the parts that share
|
|
25
|
+
* it (`DropdownMenuRoot`, `DropdownMenuItem`), a namespaced export with parts, or a single export. A family ranks by how well its
|
|
26
|
+
* base fits the archetype's own name, then by how many parts it has. At most three are tried, so a library with a context menu,
|
|
27
|
+
* a dropdown menu, and a menubar gets the plainest ones first.
|
|
28
|
+
*/
|
|
29
|
+
export function familyBases(archetype, exports) {
|
|
30
|
+
const families = new Map();
|
|
31
|
+
for (const e of exports) {
|
|
32
|
+
if (!/^[A-Z]/.test(e.name) || !ARCHETYPE_PATTERNS[archetype].test(words(e.name))) continue;
|
|
33
|
+
const base = inferBase(e.name, exports) ?? e.name;
|
|
34
|
+
if (families.has(base)) continue;
|
|
35
|
+
const size = exports.filter((other) => other.name !== base && other.name.startsWith(base)).length + (exports.find((other) => other.name === base)?.parts?.length ?? 0);
|
|
36
|
+
families.set(base, { base, fit: score(archetype, base), size });
|
|
37
|
+
}
|
|
38
|
+
return [...families.values()].sort((a, b) => b.fit - a.fit || b.size - a.size || a.base.localeCompare(b.base)).slice(0, 3).map((f) => f.base);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Candidate fixtures for a JSX framework: the recipes, run over the parts of the component the mapping found,
|
|
43
|
+
* then over the package itself when its own name says it is this archetype.
|
|
44
|
+
* @param {import("./dialects.js").Dialect} dialect
|
|
45
|
+
* @param {{ archetype: string, pkg: string, entry: { export?: string }, exports: Array<{ name: string, type: string, parts?: string[] }>, explicit?: boolean }} input
|
|
46
|
+
* `explicit` means a person named the export in a mapping, so only that component is tried.
|
|
47
|
+
* @returns {{ candidates: Array<{ id: string, summary: string, source: string, used: string[] }>, reason: string | null }}
|
|
48
|
+
*/
|
|
49
|
+
export function generateJsx(dialect, { archetype, pkg, entry, exports, explicit = false }) {
|
|
50
|
+
if (!GENERATABLE.has(archetype)) return { candidates: [], reason: `Nothing is generated for the ${archetype} archetype.` };
|
|
51
|
+
const recipes = buildRecipes(dialect)[archetype] ?? [];
|
|
52
|
+
const bases = [];
|
|
53
|
+
if (explicit && entry.export) {
|
|
54
|
+
bases.push(inferBase(entry.export, exports) ?? entry.export);
|
|
55
|
+
} else {
|
|
56
|
+
bases.push(...familyBases(archetype, exports));
|
|
57
|
+
if (bases.length === 0 && entry.export) bases.push(inferBase(entry.export, exports) ?? entry.export);
|
|
58
|
+
}
|
|
59
|
+
if (nameSaysSo(archetype, pkg)) bases.push(null);
|
|
60
|
+
const candidates = [];
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
for (const base of [...new Set(bases)]) {
|
|
63
|
+
const makeKit = () => buildKit(exports, base);
|
|
64
|
+
for (const recipe of recipes) {
|
|
65
|
+
const candidate = recipe(makeKit, pkg, dialect);
|
|
66
|
+
if (candidate && !seen.has(candidate.source)) {
|
|
67
|
+
seen.add(candidate.source);
|
|
68
|
+
candidates.push(candidate);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (candidates.length === 0) {
|
|
73
|
+
return { candidates: [], reason: `The package's exports don't have the parts a ${archetype} recipe needs${entry.export ? ` (looked at ${entry.export} and the exports that start with it)` : ""}.` };
|
|
74
|
+
}
|
|
75
|
+
return { candidates: candidates.slice(0, ATTEMPT_LIMIT), reason: null };
|
|
76
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find the parts of a compound component from the names a package exports.
|
|
3
|
+
*
|
|
4
|
+
* Two shapes are common. A namespaced export carries its parts as properties (`Dialog.Root`, `Dialog.Trigger`).
|
|
5
|
+
* A flat set of exports shares a prefix (`DialogRoot`, `DialogTrigger`, or `Dialog` plus `DialogContent`).
|
|
6
|
+
* A package that is one component's parts, such as a `Root` and a `Trigger` with no `Dialog` in front, works too.
|
|
7
|
+
* Parts are found by name only. Nothing here knows any one library.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {Array<{ name: string, type: string, parts?: string[] }>} exports
|
|
12
|
+
* @param {string | null} base The export that names the component, or null when the package itself is the component.
|
|
13
|
+
*/
|
|
14
|
+
export function buildKit(exports, base) {
|
|
15
|
+
/** @type {Map<string, string>} lower-case part name to the expression that reaches it */
|
|
16
|
+
const parts = new Map();
|
|
17
|
+
const baseInfo = base ? exports.find((e) => e.name === base) : null;
|
|
18
|
+
if (base) {
|
|
19
|
+
for (const part of baseInfo?.parts ?? []) parts.set(part.toLowerCase(), `Lib.${base}.${part}`);
|
|
20
|
+
for (const e of exports) {
|
|
21
|
+
if (e.name === base || !e.name.startsWith(base)) continue;
|
|
22
|
+
const rest = e.name.slice(base.length);
|
|
23
|
+
if (/^[A-Z]/.test(rest) && !parts.has(rest.toLowerCase())) parts.set(rest.toLowerCase(), `Lib.${e.name}`);
|
|
24
|
+
}
|
|
25
|
+
} else {
|
|
26
|
+
for (const e of exports) if (/^[A-Z]/.test(e.name) && !parts.has(e.name.toLowerCase())) parts.set(e.name.toLowerCase(), `Lib.${e.name}`);
|
|
27
|
+
}
|
|
28
|
+
const used = new Set();
|
|
29
|
+
return {
|
|
30
|
+
base,
|
|
31
|
+
/** The component itself (`Lib.Dialog`), or null for a package with no single base. */
|
|
32
|
+
self: base && baseInfo ? `Lib.${base}` : null,
|
|
33
|
+
/** True when any of these part names exists. */
|
|
34
|
+
has: (...names) => names.some((name) => parts.has(name)),
|
|
35
|
+
/** The first part that exists, as a JSX tag. Records what was used. */
|
|
36
|
+
pick(...names) {
|
|
37
|
+
for (const name of names) {
|
|
38
|
+
const ref = parts.get(name);
|
|
39
|
+
if (ref) {
|
|
40
|
+
used.add(ref.replace(/^Lib\./, ""));
|
|
41
|
+
return ref;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
},
|
|
46
|
+
/** What the candidates used, for the report. */
|
|
47
|
+
used: () => [...used],
|
|
48
|
+
names: () => [...parts.keys()],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
5
|
const NPM_FLAGS = ["--ignore-scripts", "--no-audit", "--no-fund", "--prefer-offline", "--loglevel=error"];
|
|
@@ -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,10 +69,90 @@ 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.`] };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Find the optional peer dependencies that installed packages declare, with the range each asks for.
|
|
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).
|
|
112
|
+
* @param {string} dir The install folder.
|
|
113
|
+
* @returns {Map<string, string>} Package name to range.
|
|
114
|
+
*/
|
|
115
|
+
export function declaredOptionalPeers(dir) {
|
|
116
|
+
const root = join(dir, "node_modules");
|
|
117
|
+
const found = new Map();
|
|
118
|
+
const names = [];
|
|
119
|
+
for (const entry of existsSync(root) ? readdirSync(root) : []) {
|
|
120
|
+
if (entry.startsWith(".")) continue;
|
|
121
|
+
if (entry.startsWith("@")) for (const inner of readdirSync(join(root, entry))) names.push(`${entry}/${inner}`);
|
|
122
|
+
else names.push(entry);
|
|
123
|
+
}
|
|
124
|
+
for (const name of names) {
|
|
125
|
+
try {
|
|
126
|
+
const pkg = JSON.parse(readFileSync(join(root, name, "package.json"), "utf8"));
|
|
127
|
+
for (const [peer, meta] of Object.entries(pkg.peerDependenciesMeta ?? {})) {
|
|
128
|
+
if (meta?.optional && pkg.peerDependencies?.[peer] && !found.has(peer)) found.set(peer, pkg.peerDependencies[peer]);
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// A folder without a readable package.json isn't a package.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return found;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Install the optional peer dependencies a failed bundle was missing. Only packages that an installed library
|
|
139
|
+
* declares as optional peers are added, so a typo in a fixture can't pull in an arbitrary package.
|
|
140
|
+
* @param {{ dir: string, unresolved: string[], run?: typeof runNpm }} options
|
|
141
|
+
* @returns {Promise<{ installed: string[], warnings: string[] }>}
|
|
142
|
+
*/
|
|
143
|
+
export async function installOptionalPeers({ dir, unresolved, run = runNpm }) {
|
|
144
|
+
const declared = declaredOptionalPeers(dir);
|
|
145
|
+
const wanted = unresolved.filter((name) => declared.has(name) && !installedVersion(dir, name));
|
|
146
|
+
if (wanted.length === 0) return { installed: [], warnings: [] };
|
|
147
|
+
// A loose install prunes packages that only arrived as peers, so name the framework again to keep it.
|
|
148
|
+
const keep = pinnedRuntime(dir);
|
|
149
|
+
const specs = [...wanted.map((name) => `${name}@${declared.get(name)}`), ...keep];
|
|
150
|
+
try {
|
|
151
|
+
await run(["install", ...specs, "--legacy-peer-deps", ...NPM_FLAGS], dir);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
throw new Error(`npm couldn't install the optional peer dependencies ${wanted.join(", ")}: ${firstLine(error.message)}`);
|
|
154
|
+
}
|
|
155
|
+
return { installed: wanted, warnings: [`The library lists ${wanted.join(", ")} as optional peer dependencies and its code needs them to load, so they were installed.`] };
|
|
76
156
|
}
|
|
77
157
|
|
|
78
158
|
function firstLine(text) {
|
|
@@ -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
|
|