@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,38 @@
1
+ // Manual routes file loader. Format: one path per line, # starts a
2
+ // line comment, blank lines are ignored, leading slash is optional.
3
+
4
+ import { readFile } from "fs/promises";
5
+
6
+ export async function loadManualRoutes(path, { excludePaths = [] } = {}) {
7
+ let raw;
8
+ try {
9
+ raw = await readFile(path, "utf8");
10
+ } catch {
11
+ return [];
12
+ }
13
+
14
+ const routes = [];
15
+ for (const rawLine of raw.split(/\r?\n/)) {
16
+ const line = rawLine.trim();
17
+ if (!line) continue;
18
+ if (line.startsWith("#")) continue;
19
+ const normalized = line.startsWith("/") ? line : "/" + line;
20
+ if (isExcluded(normalized, excludePaths)) continue;
21
+ routes.push({ path: normalized, sourceFile: `(${path})` });
22
+ }
23
+ return routes;
24
+ }
25
+
26
+ function isExcluded(routePath, patterns) {
27
+ for (const pat of patterns) {
28
+ const rx = new RegExp(
29
+ "^" +
30
+ pat
31
+ .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
32
+ .replace(/\*/g, ".*") +
33
+ "$",
34
+ );
35
+ if (rx.test(routePath)) return true;
36
+ }
37
+ return false;
38
+ }
@@ -0,0 +1,52 @@
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 { loadManualRoutes } from "./manual.js";
6
+
7
+ let dir;
8
+ beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "manroutes-")); });
9
+ afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
10
+
11
+ describe("loadManualRoutes", () => {
12
+ it("returns [] when file is missing", async () => {
13
+ const routes = await loadManualRoutes(join(dir, "missing.txt"));
14
+ expect(routes).toEqual([]);
15
+ });
16
+
17
+ it("parses one path per line", async () => {
18
+ const path = join(dir, "routes.txt");
19
+ await writeFile(path, "/\n/about\n/pricing\n", "utf8");
20
+ const routes = await loadManualRoutes(path);
21
+ expect(routes.map((r) => r.path)).toEqual(["/", "/about", "/pricing"]);
22
+ });
23
+
24
+ it("ignores blank lines and # comments", async () => {
25
+ const path = join(dir, "routes.txt");
26
+ await writeFile(
27
+ path,
28
+ "# top comment\n/\n\n/about # inline comment won't be stripped\n /pricing \n",
29
+ "utf8",
30
+ );
31
+ const routes = await loadManualRoutes(path);
32
+ expect(routes.map((r) => r.path)).toEqual([
33
+ "/",
34
+ "/about # inline comment won't be stripped",
35
+ "/pricing",
36
+ ]);
37
+ });
38
+
39
+ it("normalizes leading slash", async () => {
40
+ const path = join(dir, "routes.txt");
41
+ await writeFile(path, "about\npricing\n/contact\n", "utf8");
42
+ const routes = await loadManualRoutes(path);
43
+ expect(routes.map((r) => r.path)).toEqual(["/about", "/pricing", "/contact"]);
44
+ });
45
+
46
+ it("applies excludePaths", async () => {
47
+ const path = join(dir, "routes.txt");
48
+ await writeFile(path, "/\n/admin\n/about\n", "utf8");
49
+ const routes = await loadManualRoutes(path, { excludePaths: ["/admin"] });
50
+ expect(routes.map((r) => r.path)).toEqual(["/", "/about"]);
51
+ });
52
+ });
@@ -0,0 +1,136 @@
1
+ import { readFile } from "fs/promises";
2
+ import { existsSync } from "fs";
3
+ import { join, relative, sep } from "path";
4
+ import { readdir } from "fs/promises";
5
+
6
+ // Returns "app" | "pages" | null
7
+ export async function detectNextjsProject(root) {
8
+ try {
9
+ const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
10
+ const hasNext =
11
+ (pkg.dependencies && pkg.dependencies.next) ||
12
+ (pkg.devDependencies && pkg.devDependencies.next);
13
+ if (!hasNext) return null;
14
+ } catch {
15
+ return null;
16
+ }
17
+
18
+ // App Router takes precedence
19
+ if (existsSync(join(root, "app"))) return "app";
20
+ if (existsSync(join(root, "src", "app"))) return "app";
21
+ if (existsSync(join(root, "pages"))) return "pages";
22
+ if (existsSync(join(root, "src", "pages"))) return "pages";
23
+ return null;
24
+ }
25
+
26
+ export async function discoverNextjsRoutes(root, { excludePaths = [] } = {}) {
27
+ const kind = await detectNextjsProject(root);
28
+ if (!kind) return [];
29
+
30
+ const routes =
31
+ kind === "app"
32
+ ? await discoverAppRouter(root)
33
+ : await discoverPagesRouter(root);
34
+
35
+ return routes.filter((r) => !isExcluded(r.path, excludePaths));
36
+ }
37
+
38
+ // ── App Router ───────────────────────────────────────────────────
39
+ async function discoverAppRouter(root) {
40
+ const appDirCandidates = [join(root, "app"), join(root, "src", "app")];
41
+ const appDir = appDirCandidates.find((d) => existsSync(d));
42
+ if (!appDir) return [];
43
+
44
+ const files = await walk(appDir);
45
+ const routes = [];
46
+
47
+ for (const abs of files) {
48
+ const base = abs.split(sep).pop();
49
+ const isPageFile = /^page\.(tsx|jsx|ts|js)$/.test(base);
50
+ if (!isPageFile) continue;
51
+
52
+ const rel = relative(appDir, abs).replace(/\\/g, "/");
53
+ const segs = rel.split("/").slice(0, -1);
54
+
55
+ if (segs.some((s) => s.startsWith("[") && s.endsWith("]"))) continue;
56
+
57
+ const visible = segs.filter((s) => !(s.startsWith("(") && s.endsWith(")")));
58
+ const path = "/" + visible.join("/");
59
+ const normalized = path === "/" || path === "" ? "/" : path;
60
+
61
+ routes.push({
62
+ path: normalized,
63
+ sourceFile: relative(root, abs).replace(/\\/g, "/"),
64
+ });
65
+ }
66
+
67
+ return dedupByPath(routes);
68
+ }
69
+
70
+ // ── Pages Router ─────────────────────────────────────────────────
71
+ async function discoverPagesRouter(root) {
72
+ const candidates = [join(root, "pages"), join(root, "src", "pages")];
73
+ const pagesDir = candidates.find((d) => existsSync(d));
74
+ if (!pagesDir) return [];
75
+
76
+ const files = await walk(pagesDir);
77
+ const routes = [];
78
+
79
+ for (const abs of files) {
80
+ const rel = relative(pagesDir, abs).replace(/\\/g, "/");
81
+ if (!/\.(tsx|jsx|ts|js)$/.test(rel)) continue;
82
+
83
+ if (rel.startsWith("api/")) continue;
84
+ const noExt = rel.replace(/\.(tsx|jsx|ts|js)$/, "");
85
+ const base = noExt.split("/").pop();
86
+ if (base === "_app" || base === "_document" || base === "_error") continue;
87
+
88
+ if (noExt.split("/").some((s) => s.startsWith("[") && s.endsWith("]"))) continue;
89
+
90
+ const path =
91
+ noExt === "index"
92
+ ? "/"
93
+ : "/" + noExt.replace(/\/index$/, "");
94
+
95
+ routes.push({
96
+ path,
97
+ sourceFile: relative(root, abs).replace(/\\/g, "/"),
98
+ });
99
+ }
100
+
101
+ return dedupByPath(routes);
102
+ }
103
+
104
+ // ── Helpers ──────────────────────────────────────────────────────
105
+ async function walk(dir) {
106
+ const out = [];
107
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
108
+ for (const entry of entries) {
109
+ const abs = join(dir, entry.name);
110
+ if (entry.isDirectory()) {
111
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
112
+ out.push(...(await walk(abs)));
113
+ } else if (entry.isFile()) {
114
+ out.push(abs);
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ function dedupByPath(routes) {
121
+ const seen = new Map();
122
+ for (const r of routes) if (!seen.has(r.path)) seen.set(r.path, r);
123
+ return [...seen.values()];
124
+ }
125
+
126
+ function isExcluded(routePath, patterns) {
127
+ for (const pat of patterns) {
128
+ // Trailing "/*" matches the bare prefix too: "/admin/*" matches "/admin" and "/admin/x"
129
+ const hasTrailingStar = /\/\*$/.test(pat);
130
+ const base = hasTrailingStar ? pat.replace(/\/\*$/, "") : pat;
131
+ const escaped = base.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
132
+ const regexStr = hasTrailingStar ? `^${escaped}(/.*)?$` : `^${escaped}$`;
133
+ if (new RegExp(regexStr).test(routePath)) return true;
134
+ }
135
+ return false;
136
+ }
@@ -0,0 +1,141 @@
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 { discoverNextjsRoutes, detectNextjsProject } from "./nextjs.js";
6
+
7
+ let root;
8
+
9
+ async function touch(relPath) {
10
+ const abs = join(root, relPath);
11
+ await mkdir(join(abs, ".."), { recursive: true });
12
+ await writeFile(abs, "// test", "utf8");
13
+ }
14
+
15
+ beforeEach(async () => {
16
+ root = await mkdtemp(join(tmpdir(), "nextproj-"));
17
+ });
18
+
19
+ afterEach(async () => {
20
+ await rm(root, { recursive: true, force: true });
21
+ });
22
+
23
+ describe("detectNextjsProject", () => {
24
+ it("returns null when no package.json", async () => {
25
+ expect(await detectNextjsProject(root)).toBeNull();
26
+ });
27
+
28
+ it("returns 'app' when app/page.tsx exists", async () => {
29
+ await writeFile(
30
+ join(root, "package.json"),
31
+ JSON.stringify({ dependencies: { next: "15.0.0" } }),
32
+ "utf8"
33
+ );
34
+ await touch("app/page.tsx");
35
+ expect(await detectNextjsProject(root)).toBe("app");
36
+ });
37
+
38
+ it("returns 'app' when src/app/page.tsx exists", async () => {
39
+ await writeFile(
40
+ join(root, "package.json"),
41
+ JSON.stringify({ dependencies: { next: "15.0.0" } }),
42
+ "utf8"
43
+ );
44
+ await touch("src/app/page.tsx");
45
+ expect(await detectNextjsProject(root)).toBe("app");
46
+ });
47
+
48
+ it("returns 'pages' when pages/index.tsx exists and no app dir", async () => {
49
+ await writeFile(
50
+ join(root, "package.json"),
51
+ JSON.stringify({ dependencies: { next: "14.0.0" } }),
52
+ "utf8"
53
+ );
54
+ await touch("pages/index.tsx");
55
+ expect(await detectNextjsProject(root)).toBe("pages");
56
+ });
57
+
58
+ it("returns 'app' when both exist (App Router wins)", async () => {
59
+ await writeFile(
60
+ join(root, "package.json"),
61
+ JSON.stringify({ dependencies: { next: "15.0.0" } }),
62
+ "utf8"
63
+ );
64
+ await touch("app/page.tsx");
65
+ await touch("pages/index.tsx");
66
+ expect(await detectNextjsProject(root)).toBe("app");
67
+ });
68
+ });
69
+
70
+ describe("discoverNextjsRoutes — App Router", () => {
71
+ beforeEach(async () => {
72
+ await writeFile(
73
+ join(root, "package.json"),
74
+ JSON.stringify({ dependencies: { next: "15.0.0" } }),
75
+ "utf8"
76
+ );
77
+ });
78
+
79
+ it("discovers a single root page", async () => {
80
+ await touch("app/page.tsx");
81
+ const routes = await discoverNextjsRoutes(root);
82
+ expect(routes).toEqual([{ path: "/", sourceFile: "app/page.tsx" }]);
83
+ });
84
+
85
+ it("discovers nested routes", async () => {
86
+ await touch("app/page.tsx");
87
+ await touch("app/about/page.tsx");
88
+ await touch("app/pricing/page.tsx");
89
+ await touch("app/blog/hello/page.tsx");
90
+ const routes = await discoverNextjsRoutes(root);
91
+ const paths = routes.map((r) => r.path).sort();
92
+ expect(paths).toEqual(["/", "/about", "/blog/hello", "/pricing"]);
93
+ });
94
+
95
+ it("strips route groups like (marketing)", async () => {
96
+ await touch("app/(marketing)/page.tsx");
97
+ await touch("app/(marketing)/about/page.tsx");
98
+ const routes = await discoverNextjsRoutes(root);
99
+ const paths = routes.map((r) => r.path).sort();
100
+ expect(paths).toEqual(["/", "/about"]);
101
+ });
102
+
103
+ it("skips dynamic routes by default", async () => {
104
+ await touch("app/page.tsx");
105
+ await touch("app/blog/[slug]/page.tsx");
106
+ await touch("app/[...catchall]/page.tsx");
107
+ const routes = await discoverNextjsRoutes(root);
108
+ const paths = routes.map((r) => r.path).sort();
109
+ expect(paths).toEqual(["/"]);
110
+ });
111
+
112
+ it("excludes paths matching excludePaths glob", async () => {
113
+ await touch("app/page.tsx");
114
+ await touch("app/api/route.ts");
115
+ await touch("app/admin/page.tsx");
116
+ const routes = await discoverNextjsRoutes(root, { excludePaths: ["/admin/*", "/api/*"] });
117
+ const paths = routes.map((r) => r.path).sort();
118
+ expect(paths).toEqual(["/"]);
119
+ });
120
+ });
121
+
122
+ describe("discoverNextjsRoutes — Pages Router", () => {
123
+ beforeEach(async () => {
124
+ await writeFile(
125
+ join(root, "package.json"),
126
+ JSON.stringify({ dependencies: { next: "14.0.0" } }),
127
+ "utf8"
128
+ );
129
+ });
130
+
131
+ it("discovers pages routes and skips _app/_document/api", async () => {
132
+ await touch("pages/index.tsx");
133
+ await touch("pages/about.tsx");
134
+ await touch("pages/_app.tsx");
135
+ await touch("pages/_document.tsx");
136
+ await touch("pages/api/health.ts");
137
+ const routes = await discoverNextjsRoutes(root);
138
+ const paths = routes.map((r) => r.path).sort();
139
+ expect(paths).toEqual(["/", "/about"]);
140
+ });
141
+ });
@@ -0,0 +1,80 @@
1
+ // Framework detection registry. Each entry exports:
2
+ // name: human-readable framework name (e.g. "nextjs-app")
3
+ // detect(cwd): async () => { framework, strategy } | null
4
+ // discoverRoutes(cwd, options): async () => [{ path, sourceFile }]
5
+ //
6
+ // Detectors are tried in registration order; the first one whose
7
+ // detect() returns a non-null value wins. This keeps Next.js
8
+ // detection first (most common) and lets later frameworks match
9
+ // when Next.js is absent.
10
+
11
+ import { detectNextjsProject, discoverNextjsRoutes } from "./nextjs.js";
12
+ import { detectVite, discoverViteRoutes } from "./vite.js";
13
+ import { detectSvelteKit, discoverSvelteKitRoutes } from "./sveltekit.js";
14
+ import { detectRemix, discoverRemixRoutes } from "./remix.js";
15
+ import { detectAstro, discoverAstroRoutes } from "./astro.js";
16
+
17
+ function getBuiltinDetectors() {
18
+ return [
19
+ {
20
+ name: "nextjs",
21
+ detect: async (cwd) => {
22
+ const kind = await detectNextjsProject(cwd);
23
+ if (!kind) return null;
24
+ return { framework: `nextjs-${kind}`, strategy: "source-walk" };
25
+ },
26
+ discoverRoutes: async (cwd, options) => discoverNextjsRoutes(cwd, options),
27
+ },
28
+ {
29
+ name: "vite",
30
+ detect: async (cwd) => detectVite(cwd),
31
+ discoverRoutes: async (cwd, options) => discoverViteRoutes(cwd, options),
32
+ },
33
+ {
34
+ name: "sveltekit",
35
+ detect: async (cwd) => detectSvelteKit(cwd),
36
+ discoverRoutes: async (cwd, options) => discoverSvelteKitRoutes(cwd, options),
37
+ },
38
+ {
39
+ name: "remix",
40
+ detect: async (cwd) => detectRemix(cwd),
41
+ discoverRoutes: async (cwd, options) => discoverRemixRoutes(cwd, options),
42
+ },
43
+ {
44
+ name: "astro",
45
+ detect: async (cwd) => detectAstro(cwd),
46
+ discoverRoutes: async (cwd, options) => discoverAstroRoutes(cwd, options),
47
+ },
48
+ ];
49
+ }
50
+
51
+ let registered = getBuiltinDetectors();
52
+
53
+ // Test seam — lets tests replace the registry without touching real
54
+ // framework detectors.
55
+ export function __setDetectorsForTest(detectors) {
56
+ registered = detectors;
57
+ }
58
+
59
+ export function __resetDetectorsForTest() {
60
+ registered = getBuiltinDetectors();
61
+ }
62
+
63
+ export function registerDetector(detector) {
64
+ registered.push(detector);
65
+ }
66
+
67
+ export async function detectFramework(cwd) {
68
+ for (const det of registered) {
69
+ const hit = await det.detect(cwd);
70
+ if (hit) {
71
+ return {
72
+ name: det.name,
73
+ framework: hit.framework,
74
+ strategy: hit.strategy,
75
+ discoverRoutes: det.discoverRoutes,
76
+ };
77
+ }
78
+ }
79
+ return null;
80
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { detectFramework, __setDetectorsForTest } from "./registry.js";
3
+
4
+ describe("detectFramework", () => {
5
+ it("returns null when no detector matches", async () => {
6
+ __setDetectorsForTest([
7
+ { name: "fake", detect: async () => null, discoverRoutes: async () => [] },
8
+ ]);
9
+ const result = await detectFramework("/tmp/nowhere");
10
+ expect(result).toBeNull();
11
+ });
12
+
13
+ it("returns the first detector whose detect returns non-null", async () => {
14
+ __setDetectorsForTest([
15
+ { name: "fake-a", detect: async () => null, discoverRoutes: async () => [] },
16
+ { name: "fake-b", detect: async () => ({ framework: "fake-b", strategy: "test" }), discoverRoutes: async () => [{ path: "/", sourceFile: "x" }] },
17
+ { name: "fake-c", detect: async () => ({ framework: "fake-c", strategy: "test" }), discoverRoutes: async () => [] },
18
+ ]);
19
+ const result = await detectFramework("/tmp/x");
20
+ expect(result).not.toBeNull();
21
+ expect(result.name).toBe("fake-b");
22
+ expect(result.framework).toBe("fake-b");
23
+ });
24
+
25
+ it("attaches the detector so discoverRoutes works via result", async () => {
26
+ __setDetectorsForTest([
27
+ { name: "m", detect: async () => ({ framework: "m", strategy: "walk" }), discoverRoutes: async () => [{ path: "/", sourceFile: "page.tsx" }] },
28
+ ]);
29
+ const result = await detectFramework("/tmp/x");
30
+ const routes = await result.discoverRoutes("/tmp/x", {});
31
+ expect(routes).toEqual([{ path: "/", sourceFile: "page.tsx" }]);
32
+ });
33
+ });
@@ -0,0 +1,82 @@
1
+ // Remix v2 detector with flat-file convention.
2
+ // Conventions:
3
+ // app/routes/_index.tsx → /
4
+ // app/routes/about.tsx → /about
5
+ // app/routes/blog.hello.tsx → /blog/hello (dot = segment)
6
+ // app/routes/$slug.tsx → /:slug (dynamic, skipped)
7
+ // app/routes/api.*.tsx → API route (skipped)
8
+
9
+ import { readFile, readdir } from "fs/promises";
10
+ import { existsSync } from "fs";
11
+ import { join, relative } from "path";
12
+
13
+ export async function detectRemix(root) {
14
+ try {
15
+ const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
16
+ const all = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
17
+ const hasRemix = Object.keys(all).some((k) => k.startsWith("@remix-run/"));
18
+ if (!hasRemix) return null;
19
+ return { framework: "remix", strategy: "source-walk" };
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ export async function discoverRemixRoutes(root, { excludePaths = [] } = {}) {
26
+ const routesDir = join(root, "app", "routes");
27
+ if (!existsSync(routesDir)) return [];
28
+
29
+ const entries = await readdir(routesDir, { withFileTypes: true }).catch(() => []);
30
+ const routes = [];
31
+
32
+ for (const entry of entries) {
33
+ if (!entry.isFile()) continue;
34
+ if (!/\.(tsx|jsx|ts|js)$/.test(entry.name)) continue;
35
+
36
+ const noExt = entry.name.replace(/\.(tsx|jsx|ts|js)$/, "");
37
+
38
+ // Skip dynamic + catch-all + api routes
39
+ if (noExt.startsWith("$")) continue;
40
+ if (noExt.split(".").some((seg) => seg.startsWith("$"))) continue;
41
+ if (noExt.startsWith("api.") || noExt === "api") continue;
42
+
43
+ let path;
44
+ if (noExt === "_index") {
45
+ path = "/";
46
+ } else if (noExt.endsWith("._index")) {
47
+ // e.g. settings._index.tsx → /settings
48
+ path = "/" + noExt.replace(/\._index$/, "").split(".").join("/");
49
+ } else {
50
+ path = "/" + noExt.split(".").join("/");
51
+ }
52
+
53
+ if (!isExcluded(path, excludePaths)) {
54
+ routes.push({
55
+ path,
56
+ sourceFile: relative(root, join(routesDir, entry.name)).replace(/\\/g, "/"),
57
+ });
58
+ }
59
+ }
60
+
61
+ return dedupByPath(routes);
62
+ }
63
+
64
+ function dedupByPath(routes) {
65
+ const seen = new Map();
66
+ for (const r of routes) if (!seen.has(r.path)) seen.set(r.path, r);
67
+ return [...seen.values()];
68
+ }
69
+
70
+ function isExcluded(routePath, patterns) {
71
+ for (const pat of patterns) {
72
+ const rx = new RegExp(
73
+ "^" +
74
+ pat
75
+ .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
76
+ .replace(/\*/g, ".*") +
77
+ "$",
78
+ );
79
+ if (rx.test(routePath)) return true;
80
+ }
81
+ return false;
82
+ }
@@ -0,0 +1,77 @@
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 { detectRemix, discoverRemixRoutes } from "./remix.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(), "remix-")); });
14
+ afterEach(async () => { await rm(root, { recursive: true, force: true }); });
15
+
16
+ describe("detectRemix", () => {
17
+ it("returns null when @remix-run/* not in deps", async () => {
18
+ await writeFile(join(root, "package.json"), JSON.stringify({ dependencies: {} }), "utf8");
19
+ expect(await detectRemix(root)).toBeNull();
20
+ });
21
+
22
+ it("returns match when @remix-run/react is a dep", async () => {
23
+ await writeFile(
24
+ join(root, "package.json"),
25
+ JSON.stringify({ dependencies: { "@remix-run/react": "2.0.0" } }),
26
+ "utf8",
27
+ );
28
+ const result = await detectRemix(root);
29
+ expect(result?.framework).toBe("remix");
30
+ });
31
+ });
32
+
33
+ describe("discoverRemixRoutes", () => {
34
+ beforeEach(async () => {
35
+ await writeFile(
36
+ join(root, "package.json"),
37
+ JSON.stringify({ dependencies: { "@remix-run/react": "2.0.0" } }),
38
+ "utf8",
39
+ );
40
+ });
41
+
42
+ it("treats _index.tsx as root", async () => {
43
+ await touch("app/routes/_index.tsx");
44
+ await touch("app/routes/about.tsx");
45
+ const routes = await discoverRemixRoutes(root);
46
+ const paths = routes.map((r) => r.path).sort();
47
+ expect(paths).toEqual(["/", "/about"]);
48
+ });
49
+
50
+ it("treats dot-delimited filenames as nested paths", async () => {
51
+ await touch("app/routes/_index.tsx");
52
+ await touch("app/routes/blog.tsx");
53
+ await touch("app/routes/blog.hello.tsx");
54
+ await touch("app/routes/settings.profile.tsx");
55
+ const routes = await discoverRemixRoutes(root);
56
+ const paths = routes.map((r) => r.path).sort();
57
+ expect(paths).toEqual(["/", "/blog", "/blog/hello", "/settings/profile"]);
58
+ });
59
+
60
+ it("skips $dynamic segments", async () => {
61
+ await touch("app/routes/_index.tsx");
62
+ await touch("app/routes/blog.$slug.tsx");
63
+ await touch("app/routes/users.$id.tsx");
64
+ const routes = await discoverRemixRoutes(root);
65
+ const paths = routes.map((r) => r.path).sort();
66
+ expect(paths).toEqual(["/"]);
67
+ });
68
+
69
+ it("skips api.* routes and files starting with $", async () => {
70
+ await touch("app/routes/_index.tsx");
71
+ await touch("app/routes/api.health.tsx");
72
+ await touch("app/routes/$catchall.tsx");
73
+ const routes = await discoverRemixRoutes(root);
74
+ const paths = routes.map((r) => r.path).sort();
75
+ expect(paths).toEqual(["/"]);
76
+ });
77
+ });