automatica11y 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AGENTS.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +136 -2
  4. package/bin/automatica11y.js +4 -0
  5. package/package.json +50 -4
  6. package/skills/automatica11y/SKILL.md +23 -0
  7. package/skills/automatica11y-runner/SKILL.md +181 -0
  8. package/skills/automatica11y-runner/references/fixtures.md +59 -0
  9. package/src/cli.js +49 -0
  10. package/src/commands/audit.js +4 -0
  11. package/src/commands/common.js +274 -0
  12. package/src/commands/compare.js +4 -0
  13. package/src/commands/doctor.js +20 -0
  14. package/src/commands/guide.js +45 -0
  15. package/src/env/browser.js +136 -0
  16. package/src/env/versions.js +60 -0
  17. package/src/globals.d.ts +10 -0
  18. package/src/harness/browser.js +20 -0
  19. package/src/harness/bundle.js +49 -0
  20. package/src/harness/npm-install.js +65 -0
  21. package/src/harness/npm-react.js +39 -0
  22. package/src/harness/npm-wc.js +30 -0
  23. package/src/harness/shadow.js +42 -0
  24. package/src/harness/static-serve.js +68 -0
  25. package/src/harness/storybook.js +116 -0
  26. package/src/harness/url.js +41 -0
  27. package/src/plan/build-plan.js +62 -0
  28. package/src/plan/classify.js +185 -0
  29. package/src/plan/mapping.js +101 -0
  30. package/src/plan/resolve-npm.js +87 -0
  31. package/src/report/comparison.js +183 -0
  32. package/src/report/index.js +10 -0
  33. package/src/report/parts.js +334 -0
  34. package/src/report/single.js +16 -0
  35. package/src/run/audit-npm.js +279 -0
  36. package/src/run/fail-check.js +62 -0
  37. package/src/run/pool.js +21 -0
  38. package/src/run/run-plan.js +262 -0
  39. package/src/run/summary.js +49 -0
  40. package/src/schema.js +255 -0
  41. package/src/text.js +9 -0
  42. package/src/tiers/interactions/archetypes.js +417 -0
  43. package/src/tiers/interactions/helpers.js +145 -0
  44. package/src/tiers/interactions/index.js +107 -0
  45. package/src/tiers/rules/axe.js +75 -0
  46. package/src/tiers/rules/canvas.js +34 -0
  47. package/src/tiers/rules/ibm.js +121 -0
  48. package/src/tiers/rules/index.js +81 -0
  49. package/src/tiers/vsr.js +134 -0
@@ -0,0 +1,65 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ const NPM_FLAGS = ["--ignore-scripts", "--no-audit", "--no-fund", "--prefer-offline", "--loglevel=error"];
6
+
7
+ /** Run npm in `cwd`. Resolves with stdout and rejects with npm's own words. */
8
+ export function runNpm(args, cwd, { timeoutMs = 300_000 } = {}) {
9
+ return new Promise((resolve, reject) => {
10
+ execFile(
11
+ process.platform === "win32" ? "npm.cmd" : "npm",
12
+ args,
13
+ { cwd, timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024, shell: process.platform === "win32" },
14
+ (error, stdout, stderr) => {
15
+ if (error) reject(Object.assign(new Error(`${stderr}\n${stdout}`.trim() || error.message), { code: "NPM_FAILED" }));
16
+ else resolve(stdout);
17
+ },
18
+ );
19
+ });
20
+ }
21
+
22
+ /** The version of an installed package, or null. */
23
+ export function installedVersion(dir, name) {
24
+ try {
25
+ return JSON.parse(readFileSync(join(dir, "node_modules", name, "package.json"), "utf8")).version ?? null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Install a package into its own directory, never next to another target's install.
33
+ * npm adds the peer dependencies. A React package also needs react-dom, so add it when the peers left it out.
34
+ * Install scripts stay off, because the code is untrusted until it runs in the browser sandbox.
35
+ * @param {{ dir: string, name: string, version: string, flavor: "react" | "wc" | "unknown", run?: typeof runNpm }} options
36
+ */
37
+ export async function installPackage({ dir, name, version, flavor, run = runNpm }) {
38
+ mkdirSync(dir, { recursive: true });
39
+ if (!existsSync(join(dir, "package.json"))) writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "automatica11y-target", private: true }));
40
+ /** @type {string[]} */
41
+ const warnings = [];
42
+ try {
43
+ await run(["install", `${name}@${version}`, ...NPM_FLAGS], dir);
44
+ } catch (error) {
45
+ if (!/ERESOLVE|peer dep/i.test(String(error.message))) throw new Error(`npm couldn't install ${name}@${version}: ${firstLine(error.message)}`);
46
+ await run(["install", `${name}@${version}`, "--legacy-peer-deps", ...NPM_FLAGS], dir).catch((retry) => {
47
+ throw new Error(`npm couldn't install ${name}@${version}: ${firstLine(retry.message)}`);
48
+ });
49
+ warnings.push(`npm couldn't satisfy ${name}'s peer dependencies, so it installed them loosely (--legacy-peer-deps). Results may not match a supported setup.`);
50
+ }
51
+ if (flavor === "react") {
52
+ const react = installedVersion(dir, "react");
53
+ if (!react) {
54
+ await run(["install", "react", "react-dom", ...NPM_FLAGS, "--legacy-peer-deps"], dir);
55
+ warnings.push(`${name} didn't bring in react, so the latest react and react-dom were added.`);
56
+ } else if (!installedVersion(dir, "react-dom")) {
57
+ await run(["install", `react-dom@${react}`, ...NPM_FLAGS, "--legacy-peer-deps"], dir);
58
+ }
59
+ }
60
+ return { dir, warnings, react: installedVersion(dir, "react"), reactDom: installedVersion(dir, "react-dom"), version: installedVersion(dir, name) };
61
+ }
62
+
63
+ function firstLine(text) {
64
+ return String(text).split("\n").find((line) => line.trim())?.replace(/^npm (error|ERR!)\s*/i, "").trim() ?? "unknown error";
65
+ }
@@ -0,0 +1,39 @@
1
+ /** React flavor: the entry files and button and link templates that go into a bundle. */
2
+
3
+ /** Mounts a fixture's default export. */
4
+ export const entry = (fixturePath, _pkg) => `import { createElement } from "react";
5
+ import { createRoot } from "react-dom/client";
6
+ import Fixture from ${JSON.stringify(fixturePath)};
7
+ const libA11y = new URLSearchParams(location.search).get("libA11y") === "on";
8
+ createRoot(document.getElementById("root")).render(createElement(Fixture, { libA11y }));
9
+ `;
10
+
11
+ /** Loads the whole package, lists its exports, and records compound parts such as Dialog.Trigger. */
12
+ export const discoverEntry = (pkg) => `import * as lib from ${JSON.stringify(pkg)};
13
+ const out = [];
14
+ for (const name of Object.keys(lib)) {
15
+ const value = lib[name];
16
+ const type = typeof value;
17
+ if (value === null || (type !== "function" && type !== "object")) continue;
18
+ const parts = Object.keys(value).filter((key) => /^[A-Z]/.test(key)).slice(0, 30);
19
+ out.push({ name, type, parts });
20
+ }
21
+ window.__a11yExports = out;
22
+ `;
23
+
24
+ /**
25
+ * A fixture for the simple archetypes, from the export name alone.
26
+ * Compound components (dialog, tabs, menu) can't be guessed, so they need a fixture someone writes.
27
+ */
28
+ export function template(archetype, pkg, exportName) {
29
+ const body = {
30
+ button: `<Component data-a11y-trigger data-a11y-root type="button">Save</Component>`,
31
+ link: `<Component data-a11y-trigger data-a11y-root href="#top">Read more</Component>`,
32
+ }[archetype];
33
+ if (!body) return null;
34
+ return `import { ${exportName} as Component } from ${JSON.stringify(pkg)};
35
+ export default function Fixture() {
36
+ return ${body};
37
+ }
38
+ `;
39
+ }
@@ -0,0 +1,30 @@
1
+ /** Web component flavor: framework-free entries and templates. */
2
+
3
+ /** Loads the package so its custom elements get defined, then calls the fixture's default export, `mount(container)`. */
4
+ export const entry = (fixturePath, pkg) => `import ${JSON.stringify(pkg)};
5
+ import mount from ${JSON.stringify(fixturePath)};
6
+ const libA11y = new URLSearchParams(location.search).get("libA11y") === "on";
7
+ await mount(document.getElementById("root"), { libA11y });
8
+ `;
9
+
10
+ /** Loads the whole package. The page's init script records every custom element the package defines. */
11
+ export const discoverEntry = (pkg) => `import * as lib from ${JSON.stringify(pkg)};
12
+ window.__a11yExports = Object.keys(lib).map((name) => ({ name, type: typeof lib[name], parts: [] }));
13
+ `;
14
+
15
+ /** A fixture for the simple archetypes, from the tag name alone. */
16
+ export function template(archetype, tag) {
17
+ const attrs = {
18
+ button: `el.textContent = "Save";`,
19
+ link: `el.setAttribute("href", "#top");\n el.textContent = "Read more";`,
20
+ }[archetype];
21
+ if (!attrs) return null;
22
+ return `export default function mount(container) {
23
+ const el = document.createElement(${JSON.stringify(tag)});
24
+ el.setAttribute("data-a11y-trigger", "");
25
+ el.setAttribute("data-a11y-root", "");
26
+ ${attrs}
27
+ container.append(el);
28
+ }
29
+ `;
30
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Runs before any page script. It notes every closed shadow root, because axe and IBM can't see inside one
3
+ * and would report nothing, which reads as clean.
4
+ */
5
+ export function recordClosedShadowRoots() {
6
+ const original = Element.prototype.attachShadow;
7
+ const hosts = [];
8
+ Object.defineProperty(window, "__a11yClosedShadowHosts", { value: hosts, configurable: true });
9
+ Element.prototype.attachShadow = function attachShadow(init) {
10
+ if (init && init.mode === "closed") hosts.push(this.localName);
11
+ return original.call(this, init);
12
+ };
13
+ }
14
+
15
+ /**
16
+ * The closed shadow roots the page created, as host tag names with counts, for example `{ "x-thing": 2 }`.
17
+ * @param {import("playwright-core").Page} page
18
+ * @returns {Promise<Record<string, number>>}
19
+ */
20
+ export async function closedShadowHosts(page) {
21
+ const hosts = await page.evaluate(() => /** @type {any} */ (window).__a11yClosedShadowHosts ?? []).catch(() => []);
22
+ /** @type {Record<string, number>} */
23
+ const counts = {};
24
+ for (const host of hosts) counts[host] = (counts[host] ?? 0) + 1;
25
+ return counts;
26
+ }
27
+
28
+ /** A readable not-testable entry for each host tag. */
29
+ export function notTestableEntries(counts) {
30
+ return Object.entries(counts).map(([tag, n]) => `closed shadow root in <${tag}> (${n}): its content isn't visible to the rule engines`);
31
+ }
32
+
33
+ /** Runs before any page script. Notes every custom element the page or a package defines. */
34
+ export function recordCustomElements() {
35
+ const original = customElements.define.bind(customElements);
36
+ const tags = [];
37
+ Object.defineProperty(window, "__a11yDefined", { value: tags, configurable: true });
38
+ customElements.define = (name, constructor, options) => {
39
+ tags.push(name);
40
+ return original(name, constructor, options);
41
+ };
42
+ }
@@ -0,0 +1,68 @@
1
+ import { createReadStream, existsSync, statSync } from "node:fs";
2
+ import { createServer } from "node:http";
3
+ import { extname, join, normalize, resolve, sep } from "node:path";
4
+
5
+ const TYPES = {
6
+ ".html": "text/html; charset=utf-8",
7
+ ".htm": "text/html; charset=utf-8",
8
+ ".js": "text/javascript; charset=utf-8",
9
+ ".mjs": "text/javascript; charset=utf-8",
10
+ ".css": "text/css; charset=utf-8",
11
+ ".json": "application/json; charset=utf-8",
12
+ ".svg": "image/svg+xml",
13
+ ".png": "image/png",
14
+ ".jpg": "image/jpeg",
15
+ ".jpeg": "image/jpeg",
16
+ ".gif": "image/gif",
17
+ ".webp": "image/webp",
18
+ ".ico": "image/x-icon",
19
+ ".woff": "font/woff",
20
+ ".woff2": "font/woff2",
21
+ ".ttf": "font/ttf",
22
+ ".txt": "text/plain; charset=utf-8",
23
+ ".map": "application/json",
24
+ ".wasm": "application/wasm",
25
+ };
26
+
27
+ /**
28
+ * Serve a directory over http://127.0.0.1 so module scripts and relative assets work. Never use file://.
29
+ * Binds a free port, serves only files inside `root`, and lists no directories.
30
+ * @param {string} root
31
+ * @returns {Promise<{ origin: string, close: () => Promise<void> }>}
32
+ */
33
+ export async function serveStatic(root) {
34
+ const base = resolve(root);
35
+ const server = createServer((request, response) => {
36
+ try {
37
+ const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
38
+ let file = resolve(join(base, normalize(pathname)));
39
+ if (file !== base && !file.startsWith(base + sep)) {
40
+ response.writeHead(403).end("Forbidden");
41
+ return;
42
+ }
43
+ if (existsSync(file) && statSync(file).isDirectory()) file = join(file, "index.html");
44
+ if (!existsSync(file) || !statSync(file).isFile()) {
45
+ response.writeHead(404, { "content-type": "text/plain" }).end("Not found");
46
+ return;
47
+ }
48
+ response.writeHead(200, { "content-type": TYPES[extname(file).toLowerCase()] ?? "application/octet-stream", "cache-control": "no-store" });
49
+ if (request.method === "HEAD") response.end();
50
+ else createReadStream(file).pipe(response);
51
+ } catch {
52
+ response.writeHead(400).end("Bad request");
53
+ }
54
+ });
55
+ await new Promise((done, fail) => {
56
+ server.once("error", fail);
57
+ server.listen(0, "127.0.0.1", () => done(undefined));
58
+ });
59
+ const address = /** @type {import("node:net").AddressInfo} */ (server.address());
60
+ return {
61
+ origin: `http://127.0.0.1:${address.port}`,
62
+ close: () =>
63
+ new Promise((done) => {
64
+ server.closeAllConnections?.();
65
+ server.close(() => done());
66
+ }),
67
+ };
68
+ }
@@ -0,0 +1,116 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /** Name patterns that suggest an archetype. They read the story title, name, and tags. */
5
+ export const ARCHETYPE_PATTERNS = {
6
+ button: /\bbuttons?\b/i,
7
+ link: /\blinks?\b|\banchors?\b/i,
8
+ dialog: /\bdialogs?\b|\bmodals?\b|\bdrawers?\b|\balert ?dialogs?\b/i,
9
+ menu: /\bmenus?\b|\bdropdown ?menus?\b/i,
10
+ tabs: /\btabs?\b|\btab ?list\b/i,
11
+ combobox: /\bcombo ?box(es)?\b|\bautocomplete\b|\btypeahead\b|\bselect\b/i,
12
+ "form-field": /\binputs?\b|\btext ?(field|area|box)\b|\bform\b|\bcheckbox(es)?\b|\bradio\b|\bswitch\b|\bfield\b/i,
13
+ accordion: /\baccordions?\b|\bcollaps(e|ible)\b|\bdisclosure\b/i,
14
+ tooltip: /\btooltips?\b|\bpopovers?\b/i,
15
+ chart: /\bcharts?\b|\bgraphs?\b|\bplots?\b/i,
16
+ };
17
+
18
+ /**
19
+ * Pull the stories out of a Storybook index. Handles `index.json` (`entries`, with docs pages) and the older `stories.json`.
20
+ * @param {any} index
21
+ * @returns {Array<{ id: string, title: string, name: string, tags: string[] }>}
22
+ */
23
+ export function listStories(index) {
24
+ const table = index.entries ?? index.stories ?? {};
25
+ return Object.values(table)
26
+ .filter((entry) => entry && typeof entry === "object" && (entry.type === undefined || entry.type === "story"))
27
+ .map((entry) => ({
28
+ id: String(entry.id),
29
+ title: String(entry.title ?? entry.kind ?? ""),
30
+ name: String(entry.name ?? entry.story ?? ""),
31
+ tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [],
32
+ }))
33
+ .sort((a, b) => a.id.localeCompare(b.id));
34
+ }
35
+
36
+ /** The archetypes a story's title, name, and tags suggest. */
37
+ export function matchArchetypes(story) {
38
+ const text = `${story.title} ${story.name} ${story.tags.join(" ")}`;
39
+ return Object.entries(ARCHETYPE_PATTERNS)
40
+ .filter(([, pattern]) => pattern.test(text))
41
+ .map(([archetype]) => archetype);
42
+ }
43
+
44
+ /**
45
+ * Choose which stories to audit. With `archetypes`, keep only stories that match one. Then cap the count.
46
+ * The cap spreads over components, taking one story per title in turn, so one big component can't use up the whole budget.
47
+ * The order is stable, so two runs pick the same stories.
48
+ * @param {ReturnType<typeof listStories>} stories
49
+ * @param {{ archetypes?: string[] | null, max: number }} options
50
+ */
51
+ export function selectStories(stories, { archetypes = null, max }) {
52
+ /** Every archetype each story suggests. The comparison matrix uses this even when no filter is set. */
53
+ /** @type {Record<string, string[]>} */
54
+ const all = {};
55
+ for (const story of stories) for (const a of matchArchetypes(story)) (all[a] ??= []).push(story.id);
56
+ const filtered = Boolean(archetypes?.length);
57
+ let pool = stories;
58
+ /** @type {Record<string, string[]>} */
59
+ let matchedByArchetype = all;
60
+ if (filtered) {
61
+ matchedByArchetype = {};
62
+ pool = stories.filter((story) => {
63
+ const hits = matchArchetypes(story).filter((a) => archetypes.includes(a));
64
+ for (const a of hits) (matchedByArchetype[a] ??= []).push(story.id);
65
+ return hits.length > 0;
66
+ });
67
+ }
68
+ const byTitle = new Map();
69
+ for (const story of pool) byTitle.set(story.title, [...(byTitle.get(story.title) ?? []), story]);
70
+ const titles = [...byTitle.keys()].sort();
71
+ const selected = [];
72
+ for (let round = 0; selected.length < Math.min(max, pool.length); round += 1) {
73
+ for (const title of titles) {
74
+ const story = byTitle.get(title)[round];
75
+ if (story && selected.length < max) selected.push(story);
76
+ }
77
+ }
78
+ selected.sort((a, b) => a.id.localeCompare(b.id));
79
+ return { selected, total: stories.length, matched: pool.length, truncated: pool.length > selected.length, matchedByArchetype, filtered };
80
+ }
81
+
82
+ /** The URL that renders one story on its own. `base` must end with a slash. */
83
+ export function storyUrl(base, id) {
84
+ return `${base}iframe.html?id=${encodeURIComponent(id)}&viewMode=story`;
85
+ }
86
+
87
+ /**
88
+ * Read a Storybook's index from disk or over HTTP.
89
+ * @param {{ path?: string | null, url?: string | null, index: string }} resolved
90
+ * @param {Function} [doFetch]
91
+ */
92
+ export async function readIndex(resolved, doFetch = globalThis.fetch) {
93
+ if (resolved.path) return JSON.parse(readFileSync(join(resolved.path, resolved.index), "utf8"));
94
+ const response = await doFetch(new URL(resolved.index, /** @type {string} */ (resolved.url)), { signal: AbortSignal.timeout(15000) });
95
+ if (!response.ok) throw new Error(`The Storybook index responded with HTTP ${response.status}.`);
96
+ return response.json();
97
+ }
98
+
99
+ /**
100
+ * Wait until Storybook says a story rendered, or failed. Storybook marks `body` with a class for each outcome.
101
+ * @param {import("playwright-core").Page} page
102
+ */
103
+ export async function waitForStory(page, timeout = 20_000) {
104
+ await page.waitForFunction(
105
+ () => ["sb-show-main", "sb-show-errordisplay", "sb-show-nopreview"].some((name) => document.body.classList.contains(name)),
106
+ undefined,
107
+ { timeout },
108
+ );
109
+ const outcome = await page.evaluate(() => ({
110
+ error: document.body.classList.contains("sb-show-errordisplay"),
111
+ missing: document.body.classList.contains("sb-show-nopreview"),
112
+ detail: document.querySelector(".sb-errordisplay_main, #error-message")?.textContent?.trim().slice(0, 200) ?? "",
113
+ }));
114
+ if (outcome.error) throw new Error(`The story threw an error${outcome.detail ? `: ${outcome.detail}` : "."}`);
115
+ if (outcome.missing) throw new Error("Storybook couldn't find this story.");
116
+ }
@@ -0,0 +1,41 @@
1
+ import { VIEWPORT } from "./browser.js";
2
+ import { recordClosedShadowRoots, recordCustomElements } from "./shadow.js";
3
+
4
+ const NETWORK_IDLE_MS = 30_000;
5
+
6
+ /**
7
+ * Open a page in its own browser context and wait for the network to go quiet.
8
+ * The returned `warnings` hold anything the report should mention, such as a network that never went idle.
9
+ * @param {import("playwright-core").Browser} browser
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]
12
+ * `beforeGoto` runs before navigation, so a caller can attach console listeners that see the first messages.
13
+ * `waitUntil` defaults to `networkidle`. Local fixture pages don't need to wait for the network.
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" });
17
+ await context.addInitScript(recordClosedShadowRoots);
18
+ await context.addInitScript(recordCustomElements);
19
+ /** @type {string[]} */
20
+ const warnings = [];
21
+ try {
22
+ const page = await context.newPage();
23
+ page.setDefaultTimeout(30_000);
24
+ await beforeGoto?.(page);
25
+ let response;
26
+ try {
27
+ response = await page.goto(url, { waitUntil, timeout: NETWORK_IDLE_MS });
28
+ } catch (error) {
29
+ if (error instanceof Error && error.name === "TimeoutError" && page.url() !== "about:blank") {
30
+ warnings.push(`The network never went idle within ${NETWORK_IDLE_MS / 1000} seconds. The page was checked as it stood.`);
31
+ } else {
32
+ throw error;
33
+ }
34
+ }
35
+ if (response && response.status() >= 400) throw new Error(`The page responded with HTTP ${response.status()}.`);
36
+ return { context, page, warnings, close: () => context.close().catch(() => {}) };
37
+ } catch (error) {
38
+ await context.close().catch(() => {});
39
+ throw error;
40
+ }
41
+ }
@@ -0,0 +1,62 @@
1
+ import { classifyTarget, parseTargetInput } from "./classify.js";
2
+ import { npmView, resolveNpmTarget } from "./resolve-npm.js";
3
+ import { readToolVersions } from "../env/versions.js";
4
+
5
+ /** @param {string} text */
6
+ function slug(text) {
7
+ const out = text.toLowerCase().replace(/^@/, "").replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
8
+ return out || "target";
9
+ }
10
+
11
+ /**
12
+ * Classify every target and assemble plan.json. Nothing here installs a package or launches a browser.
13
+ * @param {{
14
+ * command: "audit" | "compare",
15
+ * targets: string[],
16
+ * options: import("valibot").InferOutput<typeof import("../schema.js").PlanSchema>["options"],
17
+ * browserVersion?: string | null,
18
+ * ctx?: import("./classify.js").ClassifyContext,
19
+ * now?: Date,
20
+ * }} input
21
+ */
22
+ export async function buildPlan({ command, targets, options, browserVersion = null, ctx = {}, now = new Date() }) {
23
+ const view = ctx.npmView ?? npmView;
24
+ const classified = await Promise.all(targets.map(async (raw) => resolveNpmTarget(await classifyTarget(raw, ctx), view)));
25
+ const seen = new Map();
26
+ const planTargets = classified.map((target) => {
27
+ const base = slug(target.label ?? target.name);
28
+ const count = (seen.get(base) ?? 0) + 1;
29
+ seen.set(base, count);
30
+ return {
31
+ id: count === 1 ? base : `${base}-${count}`,
32
+ input: target.input,
33
+ label: target.label ?? target.name,
34
+ status: target.status,
35
+ reason: target.reason,
36
+ kind: target.kind,
37
+ evidenceLevel: target.evidenceLevel,
38
+ resolved: target.resolved,
39
+ mapping: null,
40
+ };
41
+ });
42
+ return {
43
+ schema: /** @type {const} */ (1),
44
+ createdAt: now.toISOString(),
45
+ command,
46
+ options,
47
+ tools: readToolVersions({ chromium: browserVersion }),
48
+ targets: planTargets,
49
+ };
50
+ }
51
+
52
+ /** Labels the user typed must be unique. Returns the first duplicate, or null. */
53
+ export function findDuplicateLabel(rawTargets) {
54
+ const seen = new Set();
55
+ for (const raw of rawTargets) {
56
+ const { label } = parseTargetInput(raw.trim());
57
+ if (!label) continue;
58
+ if (seen.has(label)) return label;
59
+ seen.add(label);
60
+ }
61
+ return null;
62
+ }
@@ -0,0 +1,185 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, extname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /**
7
+ * @typedef {"npm" | "storybook" | "url" | "html-file" | "static-dir"} TargetKind
8
+ * @typedef {{
9
+ * input: string,
10
+ * label: string | null,
11
+ * name: string,
12
+ * status: "ok" | "failed",
13
+ * reason: string | null,
14
+ * kind: TargetKind | null,
15
+ * evidenceLevel: "component" | "page" | null,
16
+ * resolved: Record<string, string | null> | null,
17
+ * }} ClassifiedTarget
18
+ * @typedef {(url: string | URL, init?: { signal?: AbortSignal, redirect?: string }) => Promise<{ ok: boolean, status: number, text(): Promise<string> }>} FetchLike
19
+ * @typedef {{ cwd?: string, home?: string, fetch?: FetchLike, timeoutMs?: number, npmView?: (spec: string) => Promise<any> }} ClassifyContext
20
+ */
21
+
22
+ const LOCAL_PATH = /^(\.{1,2}(\/|$)|\/|~(\/|$)|file:)/;
23
+ const LABEL = /^([A-Za-z0-9][\w.-]*)=(.+)$/;
24
+ const HTTP_URL = /^https?:\/\//i;
25
+ const NPM_NAME = /^(?:@([a-z0-9][a-z0-9._~-]*)\/)?([a-z0-9][a-z0-9._~-]*)(?:@(.*))?$/;
26
+ const COMPONENT_SOURCE = new Set([".tsx", ".jsx", ".ts", ".js", ".mjs", ".cjs", ".vue", ".svelte"]);
27
+
28
+ /**
29
+ * Split `[label=]<spec>`. A label is a short word followed by `=`, so URLs with query strings stay whole.
30
+ * @param {string} raw
31
+ */
32
+ export function parseTargetInput(raw) {
33
+ const match = LABEL.exec(raw);
34
+ if (match) return { label: match[1], spec: match[2] };
35
+ return { label: null, spec: raw };
36
+ }
37
+
38
+ /** @param {string} reason @param {{ input: string, label: string | null, name: string }} base @returns {ClassifiedTarget} */
39
+ function failed(reason, base) {
40
+ return { ...base, status: "failed", reason, kind: null, evidenceLevel: null, resolved: null };
41
+ }
42
+
43
+ /** Expand `~` and `file:` into an absolute path. */
44
+ function toAbsolutePath(spec, cwd, home) {
45
+ if (spec.startsWith("file://")) return fileURLToPath(spec);
46
+ if (spec.startsWith("file:")) return resolve(cwd, spec.slice("file:".length));
47
+ if (spec === "~" || spec.startsWith("~/")) return resolve(home, spec.slice(2));
48
+ return resolve(cwd, spec);
49
+ }
50
+
51
+ /** A Storybook build has `index.json` (`entries`) or the older `stories.json` (`stories`) at its root. */
52
+ function readStorybookIndex(text) {
53
+ try {
54
+ const json = JSON.parse(text);
55
+ return Boolean(json && typeof json === "object" && (typeof json.entries === "object" || typeof json.stories === "object"));
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ /** @param {string} abs @param {{ input: string, label: string | null, name: string }} base @param {string} [hint] @returns {ClassifiedTarget} */
62
+ function classifyLocal(abs, base, hint = "") {
63
+ if (!existsSync(abs)) return failed(`Path not found: ${abs}.${hint}`, base);
64
+ const stats = statSync(abs);
65
+ if (stats.isDirectory()) {
66
+ for (const file of ["index.json", "stories.json"]) {
67
+ const candidate = resolve(abs, file);
68
+ if (existsSync(candidate) && readStorybookIndex(readFileSync(candidate, "utf8"))) {
69
+ return { ...base, status: "ok", reason: null, kind: "storybook", evidenceLevel: "component", resolved: { path: abs, index: file } };
70
+ }
71
+ }
72
+ return { ...base, status: "ok", reason: null, kind: "static-dir", evidenceLevel: "page", resolved: { path: abs } };
73
+ }
74
+ const ext = extname(abs).toLowerCase();
75
+ if (ext === ".html" || ext === ".htm") {
76
+ return { ...base, status: "ok", reason: null, kind: "html-file", evidenceLevel: "page", resolved: { path: abs } };
77
+ }
78
+ if (COMPONENT_SOURCE.has(ext)) {
79
+ return failed(`Component source files (${ext}) aren't supported as targets. Use an npm package, a Storybook build, an .html file, or a URL.`, base);
80
+ }
81
+ return failed(`Unsupported file type "${ext || "(none)"}". Use an .html file, a directory, or a URL.`, base);
82
+ }
83
+
84
+ /** Directory that would hold a Storybook's `index.json` for this URL. */
85
+ function storybookBase(url) {
86
+ const base = new URL(url);
87
+ base.search = "";
88
+ base.hash = "";
89
+ const last = base.pathname.split("/").pop() ?? "";
90
+ if (base.pathname.endsWith("/")) return base;
91
+ base.pathname = last.includes(".") ? base.pathname.slice(0, base.pathname.length - last.length) : `${base.pathname}/`;
92
+ return base;
93
+ }
94
+
95
+ /** @param {string} spec @param {{ input: string, label: string | null, name: string }} base @param {ClassifyContext} ctx @returns {Promise<ClassifiedTarget>} */
96
+ async function classifyUrl(spec, base, ctx) {
97
+ const doFetch = ctx.fetch ?? globalThis.fetch;
98
+ const signal = () => AbortSignal.timeout(ctx.timeoutMs ?? 8000);
99
+ let url;
100
+ try {
101
+ url = new URL(spec);
102
+ } catch {
103
+ return failed(`Not a valid URL: ${spec}`, base);
104
+ }
105
+ const root = storybookBase(spec);
106
+ for (const file of ["index.json", "stories.json"]) {
107
+ try {
108
+ const response = await doFetch(new URL(file, root), { signal: signal(), redirect: "follow" });
109
+ if (response.ok && readStorybookIndex(await response.text())) {
110
+ return { ...base, status: "ok", reason: null, kind: "storybook", evidenceLevel: "component", resolved: { url: root.href, index: file } };
111
+ }
112
+ } catch {
113
+ // A failed probe means "not Storybook here." The page check below reports real network trouble.
114
+ }
115
+ }
116
+ try {
117
+ const response = await doFetch(url, { signal: signal(), redirect: "follow" });
118
+ if (!response.ok) return failed(`The URL responded with HTTP ${response.status}.`, base);
119
+ return { ...base, status: "ok", reason: null, kind: "url", evidenceLevel: "page", resolved: { url: url.href } };
120
+ } catch (error) {
121
+ return failed(`The URL didn't respond: ${error instanceof Error ? error.message : String(error)}`, base);
122
+ }
123
+ }
124
+
125
+ /** @param {string} spec @param {{ input: string, label: string | null, name: string }} base @returns {ClassifiedTarget} */
126
+ function classifyNpm(spec, base) {
127
+ const match = NPM_NAME.exec(spec);
128
+ if (!match) {
129
+ 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
+ return failed(`"${spec}" isn't a valid npm package name. Write npm:name, npm:@scope/name, or npm:name@version.`, base);
131
+ }
132
+ const [, scope, name, version] = match;
133
+ if (version === "") return failed(`"${spec}" ends with @ but has no version.`, base);
134
+ const full = scope ? `@${scope}/${name}` : name;
135
+ return {
136
+ ...base,
137
+ name: base.name || full,
138
+ status: "ok",
139
+ reason: null,
140
+ kind: "npm",
141
+ evidenceLevel: "component",
142
+ resolved: { name: full, requested: version ?? null, version: null },
143
+ };
144
+ }
145
+
146
+ /** What to say when a bare word isn't a path on disk: it might have been a package or a web address. */
147
+ function notFoundHint(spec) {
148
+ const hints = [];
149
+ if (NPM_NAME.test(spec)) hints.push(`If you meant the npm package, write npm:${spec}.`);
150
+ if (/^[a-z0-9-]+(\.[a-z0-9-]+)+(\/.*)?$/i.test(spec)) hints.push(`If you meant a web page, write https://${spec}.`);
151
+ return hints.length ? ` ${hints.join(" ")}` : "";
152
+ }
153
+
154
+ /**
155
+ * Classify one target. A URL starts with http:// or https://. An npm package starts with npm:. Everything else is a path,
156
+ * relative to the working folder unless it's absolute, so `button` means the folder `./button`. First match wins: URL, npm package, path.
157
+ * A path that isn't there fails with a hint about `npm:` and `https://`, so a mistake never turns into a different target.
158
+ * A failure comes back as a `failed` target with a reason, never as a throw, so one bad target can't stop a comparison.
159
+ * @param {string} raw `[label=]<spec>`
160
+ * @param {ClassifyContext} [ctx]
161
+ * @returns {Promise<ClassifiedTarget>}
162
+ */
163
+ export async function classifyTarget(raw, ctx = {}) {
164
+ const cwd = ctx.cwd ?? process.cwd();
165
+ const home = ctx.home ?? homedir();
166
+ const { label, spec } = parseTargetInput(raw.trim());
167
+ const base = { input: raw, label, name: label ?? "" };
168
+
169
+ if (HTTP_URL.test(spec)) {
170
+ let host = spec;
171
+ try {
172
+ host = new URL(spec).hostname;
173
+ } catch {
174
+ // classifyUrl reports the invalid URL.
175
+ }
176
+ return classifyUrl(spec, { ...base, name: label ?? host }, ctx);
177
+ }
178
+ if (spec.startsWith("npm:")) return classifyNpm(spec.slice("npm:".length), base);
179
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(spec) && !spec.startsWith("file:")) {
180
+ return failed(`Only http and https URLs are supported. Got "${spec.split("://")[0]}://".`, base);
181
+ }
182
+ const explicit = LOCAL_PATH.test(spec) || spec === "." || spec === "..";
183
+ const abs = toAbsolutePath(spec, cwd, home);
184
+ return classifyLocal(abs, { ...base, name: label ?? basename(abs) }, explicit ? "" : notFoundHint(spec));
185
+ }