@wcag-audit/cli 1.0.0-alpha.11

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 (79) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +110 -0
  3. package/package.json +73 -0
  4. package/patches/@guidepup+guidepup+0.24.1.patch +30 -0
  5. package/src/__tests__/sanity.test.js +7 -0
  6. package/src/ai-fix-json.js +321 -0
  7. package/src/audit.js +199 -0
  8. package/src/cache/route-cache.js +46 -0
  9. package/src/cache/route-cache.test.js +96 -0
  10. package/src/checkers/ai-vision.js +102 -0
  11. package/src/checkers/auth.js +111 -0
  12. package/src/checkers/axe.js +65 -0
  13. package/src/checkers/consistency.js +222 -0
  14. package/src/checkers/forms.js +149 -0
  15. package/src/checkers/interaction.js +142 -0
  16. package/src/checkers/keyboard.js +351 -0
  17. package/src/checkers/media.js +102 -0
  18. package/src/checkers/motion.js +155 -0
  19. package/src/checkers/pointer.js +128 -0
  20. package/src/checkers/screen-reader.js +522 -0
  21. package/src/checkers/util/consistency-match.js +53 -0
  22. package/src/checkers/util/consistency-match.test.js +54 -0
  23. package/src/checkers/viewport.js +214 -0
  24. package/src/cli.js +169 -0
  25. package/src/commands/ci.js +63 -0
  26. package/src/commands/ci.test.js +55 -0
  27. package/src/commands/doctor.js +105 -0
  28. package/src/commands/doctor.test.js +81 -0
  29. package/src/commands/init.js +162 -0
  30. package/src/commands/init.test.js +83 -0
  31. package/src/commands/scan.js +362 -0
  32. package/src/commands/scan.test.js +139 -0
  33. package/src/commands/watch.js +89 -0
  34. package/src/config/global.js +60 -0
  35. package/src/config/global.test.js +58 -0
  36. package/src/config/project.js +35 -0
  37. package/src/config/project.test.js +44 -0
  38. package/src/devserver/spawn.js +82 -0
  39. package/src/devserver/spawn.test.js +58 -0
  40. package/src/discovery/astro.js +86 -0
  41. package/src/discovery/astro.test.js +76 -0
  42. package/src/discovery/crawl.js +93 -0
  43. package/src/discovery/crawl.test.js +93 -0
  44. package/src/discovery/dynamic-samples.js +44 -0
  45. package/src/discovery/dynamic-samples.test.js +66 -0
  46. package/src/discovery/manual.js +38 -0
  47. package/src/discovery/manual.test.js +52 -0
  48. package/src/discovery/nextjs.js +136 -0
  49. package/src/discovery/nextjs.test.js +141 -0
  50. package/src/discovery/registry.js +80 -0
  51. package/src/discovery/registry.test.js +33 -0
  52. package/src/discovery/remix.js +82 -0
  53. package/src/discovery/remix.test.js +77 -0
  54. package/src/discovery/sitemap.js +73 -0
  55. package/src/discovery/sitemap.test.js +69 -0
  56. package/src/discovery/sveltekit.js +85 -0
  57. package/src/discovery/sveltekit.test.js +76 -0
  58. package/src/discovery/vite.js +94 -0
  59. package/src/discovery/vite.test.js +144 -0
  60. package/src/license/log-usage.js +23 -0
  61. package/src/license/log-usage.test.js +45 -0
  62. package/src/license/request-free.js +46 -0
  63. package/src/license/request-free.test.js +57 -0
  64. package/src/license/validate.js +58 -0
  65. package/src/license/validate.test.js +58 -0
  66. package/src/output/agents-md.js +58 -0
  67. package/src/output/agents-md.test.js +62 -0
  68. package/src/output/cursor-rules.js +57 -0
  69. package/src/output/cursor-rules.test.js +62 -0
  70. package/src/output/excel-project.js +263 -0
  71. package/src/output/excel-project.test.js +165 -0
  72. package/src/output/markdown.js +119 -0
  73. package/src/output/markdown.test.js +95 -0
  74. package/src/report.js +239 -0
  75. package/src/util/anthropic.js +25 -0
  76. package/src/util/llm.js +159 -0
  77. package/src/util/screenshot.js +131 -0
  78. package/src/wcag-criteria.js +256 -0
  79. package/src/wcag-manual-steps.js +114 -0
@@ -0,0 +1,35 @@
1
+ import { readFile, writeFile } from "fs/promises";
2
+ import { join } from "path";
3
+
4
+ export const DEFAULT_PROJECT_CONFIG = {
5
+ routes: "auto",
6
+ excludePaths: [],
7
+ failOn: "critical",
8
+ // Valid values: "excel" (deferred), "markdown", "cursor-rules",
9
+ // "agents-md", "json". Unknown values are ignored.
10
+ outputs: ["excel", "markdown"],
11
+ dynamicRouteSamples: {},
12
+ devServer: {
13
+ command: null,
14
+ port: null,
15
+ healthCheck: "/",
16
+ startupTimeout: 60000,
17
+ },
18
+ };
19
+
20
+ export async function readProjectConfig(cwd = process.cwd()) {
21
+ const path = join(cwd, ".wcagauditrc");
22
+ try {
23
+ const raw = await readFile(path, "utf8");
24
+ const parsed = JSON.parse(raw);
25
+ return { ...DEFAULT_PROJECT_CONFIG, ...parsed };
26
+ } catch {
27
+ return structuredClone(DEFAULT_PROJECT_CONFIG);
28
+ }
29
+ }
30
+
31
+ export async function writeProjectConfig(cwd, config) {
32
+ const path = join(cwd, ".wcagauditrc");
33
+ const merged = { ...DEFAULT_PROJECT_CONFIG, ...config };
34
+ await writeFile(path, JSON.stringify(merged, null, 2), "utf8");
35
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm, writeFile } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import {
6
+ readProjectConfig,
7
+ writeProjectConfig,
8
+ DEFAULT_PROJECT_CONFIG,
9
+ } from "./project.js";
10
+
11
+ let tmpDir;
12
+
13
+ beforeEach(async () => {
14
+ tmpDir = await mkdtemp(join(tmpdir(), "wcagproj-"));
15
+ });
16
+
17
+ afterEach(async () => {
18
+ await rm(tmpDir, { recursive: true, force: true });
19
+ });
20
+
21
+ describe("project config", () => {
22
+ it("returns defaults when file is missing", async () => {
23
+ const cfg = await readProjectConfig(tmpDir);
24
+ expect(cfg).toEqual(DEFAULT_PROJECT_CONFIG);
25
+ });
26
+
27
+ it("reads existing .wcagauditrc", async () => {
28
+ await writeFile(
29
+ join(tmpDir, ".wcagauditrc"),
30
+ JSON.stringify({ failOn: "serious", excludePaths: ["/api/*"] }),
31
+ "utf8"
32
+ );
33
+ const cfg = await readProjectConfig(tmpDir);
34
+ expect(cfg.failOn).toBe("serious");
35
+ expect(cfg.excludePaths).toEqual(["/api/*"]);
36
+ expect(cfg.routes).toBe("auto");
37
+ });
38
+
39
+ it("writeProjectConfig persists to .wcagauditrc with 644", async () => {
40
+ await writeProjectConfig(tmpDir, { failOn: "moderate" });
41
+ const cfg = await readProjectConfig(tmpDir);
42
+ expect(cfg.failOn).toBe("moderate");
43
+ });
44
+ });
@@ -0,0 +1,82 @@
1
+ import { execa } from "execa";
2
+
3
+ export function detectDevCommand(pkgJson) {
4
+ const scripts = pkgJson?.scripts || {};
5
+ if (scripts.dev) return { cmd: "npm", args: ["run", "dev"] };
6
+ if (scripts.start) return { cmd: "npm", args: ["run", "start"] };
7
+ return null;
8
+ }
9
+
10
+ export async function waitForServer(url, { timeoutMs = 60000, intervalMs = 500 } = {}) {
11
+ const deadline = Date.now() + timeoutMs;
12
+ while (Date.now() < deadline) {
13
+ try {
14
+ const res = await fetch(url, { redirect: "manual" });
15
+ if (res.status < 500) return true;
16
+ } catch {
17
+ // server not up yet — keep polling
18
+ }
19
+ await sleep(intervalMs);
20
+ }
21
+ return false;
22
+ }
23
+
24
+ function sleep(ms) {
25
+ return new Promise((r) => setTimeout(r, ms));
26
+ }
27
+
28
+ // Spawn a dev server, wait for it to be ready, return a handle with a dispose().
29
+ export async function startDevServer({
30
+ cwd,
31
+ cmd,
32
+ args,
33
+ port,
34
+ healthPath = "/",
35
+ startupTimeout = 60000,
36
+ useExisting = false,
37
+ onOutput = () => {},
38
+ }) {
39
+ const url = `http://localhost:${port}${healthPath}`;
40
+
41
+ if (useExisting) {
42
+ const ok = await waitForServer(url, { timeoutMs: 3000, intervalMs: 250 });
43
+ if (!ok) {
44
+ throw new Error(`No server responding at ${url}. Start your dev server or remove --use-existing.`);
45
+ }
46
+ return { url, port, dispose: async () => {} };
47
+ }
48
+
49
+ const child = execa(cmd, args, {
50
+ cwd,
51
+ env: { ...process.env, PORT: String(port), FORCE_COLOR: "0" },
52
+ stdio: ["ignore", "pipe", "pipe"],
53
+ });
54
+
55
+ child.stdout?.on("data", (chunk) => onOutput(chunk.toString()));
56
+ child.stderr?.on("data", (chunk) => onOutput(chunk.toString()));
57
+
58
+ const ready = await waitForServer(url, {
59
+ timeoutMs: startupTimeout,
60
+ intervalMs: 500,
61
+ });
62
+
63
+ if (!ready) {
64
+ child.kill("SIGTERM");
65
+ throw new Error(
66
+ `Dev server did not respond at ${url} within ${startupTimeout}ms. Check that \`${cmd} ${args.join(" ")}\` starts a server on PORT=${port}.`
67
+ );
68
+ }
69
+
70
+ const dispose = async () => {
71
+ if (child.killed) return;
72
+ child.kill("SIGTERM");
73
+ try {
74
+ await Promise.race([child, sleep(3000)]);
75
+ } catch {
76
+ /* ignore */
77
+ }
78
+ if (!child.killed) child.kill("SIGKILL");
79
+ };
80
+
81
+ return { url, port, dispose, child };
82
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { waitForServer, detectDevCommand } from "./spawn.js";
3
+
4
+ describe("waitForServer", () => {
5
+ beforeEach(() => {
6
+ global.fetch = vi.fn();
7
+ });
8
+ afterEach(() => {
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ it("resolves true immediately if server responds OK", async () => {
13
+ global.fetch.mockResolvedValueOnce({ ok: true, status: 200 });
14
+ const result = await waitForServer("http://localhost:3000/", { timeoutMs: 1000, intervalMs: 50 });
15
+ expect(result).toBe(true);
16
+ });
17
+
18
+ it("retries until server is ready", async () => {
19
+ global.fetch
20
+ .mockRejectedValueOnce(new Error("ECONNREFUSED"))
21
+ .mockRejectedValueOnce(new Error("ECONNREFUSED"))
22
+ .mockResolvedValueOnce({ ok: true, status: 200 });
23
+ const result = await waitForServer("http://localhost:3000/", { timeoutMs: 2000, intervalMs: 100 });
24
+ expect(result).toBe(true);
25
+ expect(global.fetch.mock.calls.length).toBe(3);
26
+ });
27
+
28
+ it("resolves false after timeout", async () => {
29
+ global.fetch.mockRejectedValue(new Error("ECONNREFUSED"));
30
+ const result = await waitForServer("http://localhost:3000/", { timeoutMs: 300, intervalMs: 100 });
31
+ expect(result).toBe(false);
32
+ });
33
+
34
+ it("accepts any 2xx/3xx status as ready", async () => {
35
+ global.fetch.mockResolvedValueOnce({ ok: false, status: 301 });
36
+ const result = await waitForServer("http://localhost:3000/", { timeoutMs: 1000, intervalMs: 50 });
37
+ expect(result).toBe(true);
38
+ });
39
+ });
40
+
41
+ describe("detectDevCommand", () => {
42
+ it("prefers scripts.dev when present", () => {
43
+ const cmd = detectDevCommand({
44
+ scripts: { dev: "next dev", build: "next build", start: "next start" },
45
+ });
46
+ expect(cmd).toEqual({ cmd: "npm", args: ["run", "dev"] });
47
+ });
48
+
49
+ it("falls back to start when no dev", () => {
50
+ const cmd = detectDevCommand({ scripts: { start: "node index.js" } });
51
+ expect(cmd).toEqual({ cmd: "npm", args: ["run", "start"] });
52
+ });
53
+
54
+ it("returns null when no usable script", () => {
55
+ expect(detectDevCommand({ scripts: {} })).toBeNull();
56
+ expect(detectDevCommand({})).toBeNull();
57
+ });
58
+ });
@@ -0,0 +1,86 @@
1
+ // Astro detector. Pages live at src/pages/**/*.{astro,md,mdx}.
2
+ // Conventions:
3
+ // index.astro → /
4
+ // blog/hello.astro → /blog/hello
5
+ // pages/api/ → API routes, skipped
6
+ // [slug].astro → dynamic, skipped
7
+
8
+ import { readFile, readdir } from "fs/promises";
9
+ import { existsSync } from "fs";
10
+ import { join, relative } from "path";
11
+
12
+ export async function detectAstro(root) {
13
+ try {
14
+ const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
15
+ const all = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
16
+ if (!all.astro) return null;
17
+ return { framework: "astro", strategy: "source-walk" };
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ export async function discoverAstroRoutes(root, { excludePaths = [] } = {}) {
24
+ const pagesDir = join(root, "src", "pages");
25
+ if (!existsSync(pagesDir)) return [];
26
+
27
+ const files = await walk(pagesDir);
28
+ const routes = [];
29
+
30
+ for (const abs of files) {
31
+ const rel = relative(pagesDir, abs).replace(/\\/g, "/");
32
+ if (!/\.(astro|md|mdx)$/.test(rel)) continue;
33
+
34
+ // Skip api/ directory
35
+ if (rel.startsWith("api/") || rel === "api") continue;
36
+
37
+ const noExt = rel.replace(/\.(astro|md|mdx)$/, "");
38
+ if (noExt.split("/").some((s) => s.startsWith("[") && s.endsWith("]"))) continue;
39
+
40
+ const path = noExt === "index" ? "/" : "/" + noExt.replace(/\/index$/, "");
41
+
42
+ if (!isExcluded(path, excludePaths)) {
43
+ routes.push({
44
+ path,
45
+ sourceFile: relative(root, abs).replace(/\\/g, "/"),
46
+ });
47
+ }
48
+ }
49
+
50
+ return dedupByPath(routes);
51
+ }
52
+
53
+ async function walk(dir) {
54
+ const out = [];
55
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
56
+ for (const entry of entries) {
57
+ const abs = join(dir, entry.name);
58
+ if (entry.isDirectory()) {
59
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
60
+ out.push(...(await walk(abs)));
61
+ } else if (entry.isFile()) {
62
+ out.push(abs);
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ function dedupByPath(routes) {
69
+ const seen = new Map();
70
+ for (const r of routes) if (!seen.has(r.path)) seen.set(r.path, r);
71
+ return [...seen.values()];
72
+ }
73
+
74
+ function isExcluded(routePath, patterns) {
75
+ for (const pat of patterns) {
76
+ const rx = new RegExp(
77
+ "^" +
78
+ pat
79
+ .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
80
+ .replace(/\*/g, ".*") +
81
+ "$",
82
+ );
83
+ if (rx.test(routePath)) return true;
84
+ }
85
+ return false;
86
+ }
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm, mkdir, writeFile } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { detectAstro, discoverAstroRoutes } from "./astro.js";
6
+
7
+ let root;
8
+ async function touch(rel) {
9
+ const abs = join(root, rel);
10
+ await mkdir(join(abs, ".."), { recursive: true });
11
+ await writeFile(abs, "<!-- test -->", "utf8");
12
+ }
13
+ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "astro-")); });
14
+ afterEach(async () => { await rm(root, { recursive: true, force: true }); });
15
+
16
+ describe("detectAstro", () => {
17
+ it("returns null when astro not in deps", async () => {
18
+ await writeFile(join(root, "package.json"), JSON.stringify({ dependencies: {} }), "utf8");
19
+ expect(await detectAstro(root)).toBeNull();
20
+ });
21
+
22
+ it("returns match when astro is in deps or devDeps", async () => {
23
+ await writeFile(
24
+ join(root, "package.json"),
25
+ JSON.stringify({ devDependencies: { astro: "4.0.0" } }),
26
+ "utf8",
27
+ );
28
+ const result = await detectAstro(root);
29
+ expect(result?.framework).toBe("astro");
30
+ });
31
+ });
32
+
33
+ describe("discoverAstroRoutes", () => {
34
+ beforeEach(async () => {
35
+ await writeFile(
36
+ join(root, "package.json"),
37
+ JSON.stringify({ devDependencies: { astro: "4.0.0" } }),
38
+ "utf8",
39
+ );
40
+ });
41
+
42
+ it("discovers .astro pages and treats index.astro as root", async () => {
43
+ await touch("src/pages/index.astro");
44
+ await touch("src/pages/about.astro");
45
+ await touch("src/pages/blog/hello.astro");
46
+ const routes = await discoverAstroRoutes(root);
47
+ const paths = routes.map((r) => r.path).sort();
48
+ expect(paths).toEqual(["/", "/about", "/blog/hello"]);
49
+ });
50
+
51
+ it("discovers .md and .mdx pages", async () => {
52
+ await touch("src/pages/index.astro");
53
+ await touch("src/pages/post.md");
54
+ await touch("src/pages/notes/first.mdx");
55
+ const routes = await discoverAstroRoutes(root);
56
+ const paths = routes.map((r) => r.path).sort();
57
+ expect(paths).toEqual(["/", "/notes/first", "/post"]);
58
+ });
59
+
60
+ it("skips pages/api/ routes", async () => {
61
+ await touch("src/pages/index.astro");
62
+ await touch("src/pages/api/ping.ts");
63
+ await touch("src/pages/api/hello.json.ts");
64
+ const routes = await discoverAstroRoutes(root);
65
+ const paths = routes.map((r) => r.path).sort();
66
+ expect(paths).toEqual(["/"]);
67
+ });
68
+
69
+ it("skips [param] dynamic routes", async () => {
70
+ await touch("src/pages/index.astro");
71
+ await touch("src/pages/blog/[slug].astro");
72
+ const routes = await discoverAstroRoutes(root);
73
+ const paths = routes.map((r) => r.path).sort();
74
+ expect(paths).toEqual(["/"]);
75
+ });
76
+ });
@@ -0,0 +1,93 @@
1
+ // BFS crawl for deployed sites. Used with --url + --crawl-depth
2
+ // when we don't have source access. Same-origin only, strips
3
+ // fragments/queries, skips mailto/tel/javascript links.
4
+
5
+ const HREF_RE = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
6
+
7
+ export function extractLinksFromHtml(html, baseUrl) {
8
+ if (!html) return [];
9
+ const origin = new URL(baseUrl).origin;
10
+ const out = new Set();
11
+ let m;
12
+ HREF_RE.lastIndex = 0;
13
+ while ((m = HREF_RE.exec(html)) !== null) {
14
+ const raw = (m[1] ?? m[2] ?? "").trim();
15
+ if (!raw) continue;
16
+ const lower = raw.toLowerCase();
17
+ if (lower.startsWith("mailto:") || lower.startsWith("tel:") || lower.startsWith("javascript:")) continue;
18
+ try {
19
+ const resolved = new URL(raw, baseUrl);
20
+ if (resolved.origin !== origin) continue;
21
+ resolved.hash = "";
22
+ resolved.search = "";
23
+ out.add(resolved.toString());
24
+ } catch {
25
+ // invalid URL — skip
26
+ }
27
+ }
28
+ return [...out];
29
+ }
30
+
31
+ export async function crawlRoutes(
32
+ baseUrl,
33
+ { maxDepth = 2, maxPages = 100, excludePaths = [] } = {},
34
+ ) {
35
+ const origin = new URL(baseUrl).origin;
36
+ const startPath = new URL(baseUrl).pathname || "/";
37
+
38
+ const visited = new Set();
39
+ const queue = [{ url: new URL(startPath, baseUrl).toString(), depth: 0 }];
40
+ const routes = [];
41
+
42
+ while (queue.length > 0 && routes.length < maxPages) {
43
+ const { url, depth } = queue.shift();
44
+ if (visited.has(url)) continue;
45
+ visited.add(url);
46
+
47
+ const pathOnly = new URL(url).pathname || "/";
48
+ if (isExcluded(pathOnly, excludePaths)) continue;
49
+
50
+ // Add this path to results
51
+ routes.push({ path: pathOnly, sourceFile: "(crawl)" });
52
+
53
+ if (depth >= maxDepth) continue;
54
+
55
+ let html = "";
56
+ try {
57
+ const res = await fetch(url);
58
+ if (!res.ok) continue;
59
+ html = await res.text();
60
+ } catch {
61
+ continue;
62
+ }
63
+
64
+ const links = extractLinksFromHtml(html, url);
65
+ for (const l of links) {
66
+ if (!visited.has(l)) {
67
+ queue.push({ url: l, depth: depth + 1 });
68
+ }
69
+ }
70
+ }
71
+
72
+ return dedupByPath(routes);
73
+ }
74
+
75
+ function dedupByPath(routes) {
76
+ const seen = new Map();
77
+ for (const r of routes) if (!seen.has(r.path)) seen.set(r.path, r);
78
+ return [...seen.values()];
79
+ }
80
+
81
+ function isExcluded(routePath, patterns) {
82
+ for (const pat of patterns) {
83
+ const rx = new RegExp(
84
+ "^" +
85
+ pat
86
+ .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
87
+ .replace(/\*/g, ".*") +
88
+ "$",
89
+ );
90
+ if (rx.test(routePath)) return true;
91
+ }
92
+ return false;
93
+ }
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { extractLinksFromHtml, crawlRoutes } from "./crawl.js";
3
+
4
+ describe("extractLinksFromHtml", () => {
5
+ it("extracts absolute and relative hrefs", () => {
6
+ const html = `
7
+ <a href="/about">About</a>
8
+ <a href='pricing'>Pricing</a>
9
+ <a href="https://example.com/contact">Contact</a>
10
+ <a href="https://other.com/off">Off-site</a>
11
+ `;
12
+ const links = extractLinksFromHtml(html, "https://example.com/").sort();
13
+ expect(links).toEqual([
14
+ "https://example.com/about",
15
+ "https://example.com/contact",
16
+ "https://example.com/pricing",
17
+ ]);
18
+ });
19
+
20
+ it("strips fragments and query strings", () => {
21
+ const html = `
22
+ <a href="/about#section">About</a>
23
+ <a href="/pricing?promo=1">Pricing</a>
24
+ `;
25
+ const links = extractLinksFromHtml(html, "https://example.com/").sort();
26
+ expect(links).toEqual([
27
+ "https://example.com/about",
28
+ "https://example.com/pricing",
29
+ ]);
30
+ });
31
+
32
+ it("ignores mailto/tel/javascript hrefs", () => {
33
+ const html = `
34
+ <a href="mailto:hi@x.com">Mail</a>
35
+ <a href="tel:555">Call</a>
36
+ <a href="javascript:void(0)">Nothing</a>
37
+ <a href="/about">About</a>
38
+ `;
39
+ const links = extractLinksFromHtml(html, "https://example.com/");
40
+ expect(links).toEqual(["https://example.com/about"]);
41
+ });
42
+ });
43
+
44
+ describe("crawlRoutes", () => {
45
+ beforeEach(() => {
46
+ global.fetch = vi.fn();
47
+ });
48
+ afterEach(() => {
49
+ vi.restoreAllMocks();
50
+ });
51
+
52
+ it("starts from baseUrl and follows same-origin links up to maxDepth", async () => {
53
+ global.fetch.mockImplementation(async (url) => {
54
+ const body = {
55
+ "https://example.com/": `<a href="/about">A</a><a href="/pricing">P</a>`,
56
+ "https://example.com/about": `<a href="/team">T</a>`,
57
+ "https://example.com/pricing": ``,
58
+ "https://example.com/team": ``,
59
+ }[url];
60
+ if (body == null) return { ok: false, status: 404, text: async () => "" };
61
+ return { ok: true, status: 200, text: async () => body };
62
+ });
63
+ const routes = await crawlRoutes("https://example.com/", { maxDepth: 1 });
64
+ const paths = routes.map((r) => r.path).sort();
65
+ // Depth 1: start + direct children (about, pricing). NOT grandchildren (team).
66
+ expect(paths).toEqual(["/", "/about", "/pricing"]);
67
+ });
68
+
69
+ it("respects maxPages cap", async () => {
70
+ global.fetch.mockImplementation(async (url) => ({
71
+ ok: true,
72
+ status: 200,
73
+ text: async () =>
74
+ `<a href="/a">A</a><a href="/b">B</a><a href="/c">C</a><a href="/d">D</a>`,
75
+ }));
76
+ const routes = await crawlRoutes("https://example.com/", { maxDepth: 2, maxPages: 3 });
77
+ expect(routes.length).toBeLessThanOrEqual(3);
78
+ });
79
+
80
+ it("applies excludePaths", async () => {
81
+ global.fetch.mockImplementation(async (url) => ({
82
+ ok: true,
83
+ status: 200,
84
+ text: async () => `<a href="/admin">Admin</a><a href="/about">About</a>`,
85
+ }));
86
+ const routes = await crawlRoutes("https://example.com/", {
87
+ maxDepth: 1,
88
+ excludePaths: ["/admin"],
89
+ });
90
+ const paths = routes.map((r) => r.path).sort();
91
+ expect(paths).toEqual(["/", "/about"]);
92
+ });
93
+ });
@@ -0,0 +1,44 @@
1
+ // Expand dynamic route patterns ([slug], :id) into concrete routes
2
+ // using config-provided sample values. Routes without matching samples
3
+ // are dropped (explicit opt-in per WCAG dynamic-routes design).
4
+
5
+ export function expandDynamicRoutes(routes, samples = {}) {
6
+ const out = [];
7
+ for (const r of routes) {
8
+ if (!isDynamicRoute(r.path)) {
9
+ out.push(r);
10
+ continue;
11
+ }
12
+ const entries = samples[r.path];
13
+ if (!Array.isArray(entries) || entries.length === 0) {
14
+ continue; // no samples → drop
15
+ }
16
+ for (const sample of entries) {
17
+ const concrete = fillPattern(r.path, sample);
18
+ out.push({ path: concrete, sourceFile: r.sourceFile });
19
+ }
20
+ }
21
+ return out;
22
+ }
23
+
24
+ function isDynamicRoute(path) {
25
+ // Matches Next.js / SvelteKit / Astro [slug] and React Router / Remix :id
26
+ return /\[[^/]+\]|:[^/]+/.test(path);
27
+ }
28
+
29
+ function fillPattern(pattern, sample) {
30
+ // Replace the first [name] or :name with the sample value.
31
+ // If a pattern has multiple dynamic segments, users should supply
32
+ // a longer sample separated by `/`.
33
+ const parts = pattern.split("/");
34
+ const sampleParts = String(sample).split("/");
35
+ let si = 0;
36
+ const filled = parts.map((seg) => {
37
+ if (/^\[[^/]+\]$/.test(seg) || /^:[^/]+$/.test(seg)) {
38
+ const v = sampleParts[si++] ?? sample;
39
+ return v;
40
+ }
41
+ return seg;
42
+ });
43
+ return filled.join("/");
44
+ }
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { expandDynamicRoutes } from "./dynamic-samples.js";
3
+
4
+ describe("expandDynamicRoutes", () => {
5
+ it("passes static routes through unchanged", () => {
6
+ const input = [
7
+ { path: "/", sourceFile: "app/page.tsx" },
8
+ { path: "/about", sourceFile: "app/about/page.tsx" },
9
+ ];
10
+ const out = expandDynamicRoutes(input, {});
11
+ expect(out).toEqual(input);
12
+ });
13
+
14
+ it("expands /[slug] when samples are provided", () => {
15
+ const input = [
16
+ { path: "/blog/[slug]", sourceFile: "app/blog/[slug]/page.tsx" },
17
+ ];
18
+ const samples = { "/blog/[slug]": ["hello-world", "another-post"] };
19
+ const out = expandDynamicRoutes(input, samples);
20
+ expect(out.map((r) => r.path).sort()).toEqual([
21
+ "/blog/another-post",
22
+ "/blog/hello-world",
23
+ ]);
24
+ expect(out[0].sourceFile).toBe("app/blog/[slug]/page.tsx");
25
+ });
26
+
27
+ it("expands multiple dynamic patterns independently", () => {
28
+ const input = [
29
+ { path: "/blog/[slug]", sourceFile: "a.tsx" },
30
+ { path: "/users/[id]", sourceFile: "b.tsx" },
31
+ ];
32
+ const samples = {
33
+ "/blog/[slug]": ["hi"],
34
+ "/users/[id]": ["1", "2"],
35
+ };
36
+ const out = expandDynamicRoutes(input, samples);
37
+ const paths = out.map((r) => r.path).sort();
38
+ expect(paths).toEqual(["/blog/hi", "/users/1", "/users/2"]);
39
+ });
40
+
41
+ it("handles :param style (React Router / Remix) as well", () => {
42
+ const input = [
43
+ { path: "/users/:id", sourceFile: "x.tsx" },
44
+ ];
45
+ const samples = { "/users/:id": ["42"] };
46
+ const out = expandDynamicRoutes(input, samples);
47
+ expect(out.map((r) => r.path)).toEqual(["/users/42"]);
48
+ });
49
+
50
+ it("drops dynamic routes with no samples (explicit opt-in)", () => {
51
+ const input = [
52
+ { path: "/", sourceFile: "a.tsx" },
53
+ { path: "/blog/[slug]", sourceFile: "b.tsx" },
54
+ ];
55
+ const out = expandDynamicRoutes(input, {});
56
+ expect(out.map((r) => r.path)).toEqual(["/"]);
57
+ });
58
+
59
+ it("treats $slug as static (Remix convention)", () => {
60
+ const input = [
61
+ { path: "/$slug", sourceFile: "x.tsx" },
62
+ ];
63
+ const out = expandDynamicRoutes(input, {});
64
+ expect(out).toEqual(input);
65
+ });
66
+ });