@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.
- package/LICENSE +25 -0
- package/README.md +110 -0
- package/package.json +73 -0
- package/patches/@guidepup+guidepup+0.24.1.patch +30 -0
- package/src/__tests__/sanity.test.js +7 -0
- package/src/ai-fix-json.js +321 -0
- package/src/audit.js +199 -0
- package/src/cache/route-cache.js +46 -0
- package/src/cache/route-cache.test.js +96 -0
- package/src/checkers/ai-vision.js +102 -0
- package/src/checkers/auth.js +111 -0
- package/src/checkers/axe.js +65 -0
- package/src/checkers/consistency.js +222 -0
- package/src/checkers/forms.js +149 -0
- package/src/checkers/interaction.js +142 -0
- package/src/checkers/keyboard.js +351 -0
- package/src/checkers/media.js +102 -0
- package/src/checkers/motion.js +155 -0
- package/src/checkers/pointer.js +128 -0
- package/src/checkers/screen-reader.js +522 -0
- package/src/checkers/util/consistency-match.js +53 -0
- package/src/checkers/util/consistency-match.test.js +54 -0
- package/src/checkers/viewport.js +214 -0
- package/src/cli.js +169 -0
- package/src/commands/ci.js +63 -0
- package/src/commands/ci.test.js +55 -0
- package/src/commands/doctor.js +105 -0
- package/src/commands/doctor.test.js +81 -0
- package/src/commands/init.js +162 -0
- package/src/commands/init.test.js +83 -0
- package/src/commands/scan.js +362 -0
- package/src/commands/scan.test.js +139 -0
- package/src/commands/watch.js +89 -0
- package/src/config/global.js +60 -0
- package/src/config/global.test.js +58 -0
- package/src/config/project.js +35 -0
- package/src/config/project.test.js +44 -0
- package/src/devserver/spawn.js +82 -0
- package/src/devserver/spawn.test.js +58 -0
- package/src/discovery/astro.js +86 -0
- package/src/discovery/astro.test.js +76 -0
- package/src/discovery/crawl.js +93 -0
- package/src/discovery/crawl.test.js +93 -0
- package/src/discovery/dynamic-samples.js +44 -0
- package/src/discovery/dynamic-samples.test.js +66 -0
- package/src/discovery/manual.js +38 -0
- package/src/discovery/manual.test.js +52 -0
- package/src/discovery/nextjs.js +136 -0
- package/src/discovery/nextjs.test.js +141 -0
- package/src/discovery/registry.js +80 -0
- package/src/discovery/registry.test.js +33 -0
- package/src/discovery/remix.js +82 -0
- package/src/discovery/remix.test.js +77 -0
- package/src/discovery/sitemap.js +73 -0
- package/src/discovery/sitemap.test.js +69 -0
- package/src/discovery/sveltekit.js +85 -0
- package/src/discovery/sveltekit.test.js +76 -0
- package/src/discovery/vite.js +94 -0
- package/src/discovery/vite.test.js +144 -0
- package/src/license/log-usage.js +23 -0
- package/src/license/log-usage.test.js +45 -0
- package/src/license/request-free.js +46 -0
- package/src/license/request-free.test.js +57 -0
- package/src/license/validate.js +58 -0
- package/src/license/validate.test.js +58 -0
- package/src/output/agents-md.js +58 -0
- package/src/output/agents-md.test.js +62 -0
- package/src/output/cursor-rules.js +57 -0
- package/src/output/cursor-rules.test.js +62 -0
- package/src/output/excel-project.js +263 -0
- package/src/output/excel-project.test.js +165 -0
- package/src/output/markdown.js +119 -0
- package/src/output/markdown.test.js +95 -0
- package/src/report.js +239 -0
- package/src/util/anthropic.js +25 -0
- package/src/util/llm.js +159 -0
- package/src/util/screenshot.js +131 -0
- package/src/wcag-criteria.js +256 -0
- package/src/wcag-manual-steps.js +114 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Sitemap fallback. When no framework detector matches (or user
|
|
2
|
+
// passes --sitemap), try fetching /sitemap.xml from the dev server
|
|
3
|
+
// and extracting same-origin URLs.
|
|
4
|
+
|
|
5
|
+
import { XMLParser } from "fast-xml-parser";
|
|
6
|
+
|
|
7
|
+
export function parseSitemapXml(xml) {
|
|
8
|
+
if (!xml || typeof xml !== "string") return [];
|
|
9
|
+
try {
|
|
10
|
+
const parser = new XMLParser({ ignoreAttributes: true, parseTagValue: false });
|
|
11
|
+
const parsed = parser.parse(xml);
|
|
12
|
+
const urlset = parsed?.urlset?.url;
|
|
13
|
+
if (!urlset) return [];
|
|
14
|
+
const entries = Array.isArray(urlset) ? urlset : [urlset];
|
|
15
|
+
return entries
|
|
16
|
+
.map((u) => (typeof u === "string" ? u : u?.loc))
|
|
17
|
+
.filter((u) => typeof u === "string" && u.length > 0);
|
|
18
|
+
} catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function fetchSitemapRoutes(baseUrl, { excludePaths = [] } = {}) {
|
|
24
|
+
let url;
|
|
25
|
+
try {
|
|
26
|
+
url = new URL("/sitemap.xml", baseUrl);
|
|
27
|
+
} catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
let xml;
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(url.toString());
|
|
33
|
+
if (!res.ok) return [];
|
|
34
|
+
xml = await res.text();
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
const absoluteUrls = parseSitemapXml(xml);
|
|
39
|
+
const origin = new URL(baseUrl).origin;
|
|
40
|
+
const routes = [];
|
|
41
|
+
for (const abs of absoluteUrls) {
|
|
42
|
+
try {
|
|
43
|
+
const u = new URL(abs);
|
|
44
|
+
if (u.origin !== origin) continue; // cross-origin — skip
|
|
45
|
+
const path = u.pathname || "/";
|
|
46
|
+
if (isExcluded(path, excludePaths)) continue;
|
|
47
|
+
routes.push({ path, sourceFile: "(sitemap.xml)" });
|
|
48
|
+
} catch {
|
|
49
|
+
// invalid URL — skip
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return dedupByPath(routes);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function dedupByPath(routes) {
|
|
56
|
+
const seen = new Map();
|
|
57
|
+
for (const r of routes) if (!seen.has(r.path)) seen.set(r.path, r);
|
|
58
|
+
return [...seen.values()];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isExcluded(routePath, patterns) {
|
|
62
|
+
for (const pat of patterns) {
|
|
63
|
+
const rx = new RegExp(
|
|
64
|
+
"^" +
|
|
65
|
+
pat
|
|
66
|
+
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
|
|
67
|
+
.replace(/\*/g, ".*") +
|
|
68
|
+
"$",
|
|
69
|
+
);
|
|
70
|
+
if (rx.test(routePath)) return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { fetchSitemapRoutes, parseSitemapXml } from "./sitemap.js";
|
|
3
|
+
|
|
4
|
+
describe("parseSitemapXml", () => {
|
|
5
|
+
it("returns [] for empty/invalid xml", () => {
|
|
6
|
+
expect(parseSitemapXml("")).toEqual([]);
|
|
7
|
+
expect(parseSitemapXml("not xml")).toEqual([]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("extracts <loc> URLs from a urlset", () => {
|
|
11
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
12
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
13
|
+
<url><loc>https://example.com/</loc></url>
|
|
14
|
+
<url><loc>https://example.com/about</loc></url>
|
|
15
|
+
<url><loc>https://example.com/pricing</loc></url>
|
|
16
|
+
</urlset>`;
|
|
17
|
+
expect(parseSitemapXml(xml).sort()).toEqual([
|
|
18
|
+
"https://example.com/",
|
|
19
|
+
"https://example.com/about",
|
|
20
|
+
"https://example.com/pricing",
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("handles a single-url sitemap (non-array loc)", () => {
|
|
25
|
+
const xml = `<?xml version="1.0"?>
|
|
26
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
27
|
+
<url><loc>https://example.com/only</loc></url>
|
|
28
|
+
</urlset>`;
|
|
29
|
+
expect(parseSitemapXml(xml)).toEqual(["https://example.com/only"]);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("fetchSitemapRoutes", () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
global.fetch = vi.fn();
|
|
36
|
+
});
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
vi.restoreAllMocks();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns empty array when fetch fails", async () => {
|
|
42
|
+
global.fetch.mockRejectedValue(new Error("ECONNREFUSED"));
|
|
43
|
+
const routes = await fetchSitemapRoutes("http://localhost:3000");
|
|
44
|
+
expect(routes).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns empty array on non-2xx response", async () => {
|
|
48
|
+
global.fetch.mockResolvedValue({ ok: false, status: 404, text: async () => "" });
|
|
49
|
+
const routes = await fetchSitemapRoutes("http://localhost:3000");
|
|
50
|
+
expect(routes).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("maps absolute URLs to route paths (same origin)", async () => {
|
|
54
|
+
global.fetch.mockResolvedValue({
|
|
55
|
+
ok: true,
|
|
56
|
+
status: 200,
|
|
57
|
+
text: async () => `<?xml version="1.0"?>
|
|
58
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
59
|
+
<url><loc>http://localhost:3000/</loc></url>
|
|
60
|
+
<url><loc>http://localhost:3000/about</loc></url>
|
|
61
|
+
<url><loc>https://other.com/ignored</loc></url>
|
|
62
|
+
</urlset>`,
|
|
63
|
+
});
|
|
64
|
+
const routes = await fetchSitemapRoutes("http://localhost:3000");
|
|
65
|
+
const paths = routes.map((r) => r.path).sort();
|
|
66
|
+
expect(paths).toEqual(["/", "/about"]);
|
|
67
|
+
expect(routes[0].sourceFile).toBe("(sitemap.xml)");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SvelteKit detector. Routes live at src/routes/**/+page.svelte.
|
|
2
|
+
// Conventions:
|
|
3
|
+
// (group) folders are route groups — stripped from URL
|
|
4
|
+
// [param] folders are dynamic — skipped by default
|
|
5
|
+
// +layout.svelte, +server.ts, +page.ts are not pages — skipped
|
|
6
|
+
|
|
7
|
+
import { readFile, readdir } from "fs/promises";
|
|
8
|
+
import { existsSync } from "fs";
|
|
9
|
+
import { join, relative, sep } from "path";
|
|
10
|
+
|
|
11
|
+
export async function detectSvelteKit(root) {
|
|
12
|
+
try {
|
|
13
|
+
const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
14
|
+
const all = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
15
|
+
if (!all["@sveltejs/kit"]) return null;
|
|
16
|
+
return { framework: "sveltekit", strategy: "source-walk" };
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function discoverSvelteKitRoutes(root, { excludePaths = [] } = {}) {
|
|
23
|
+
const routesDir = join(root, "src", "routes");
|
|
24
|
+
if (!existsSync(routesDir)) return [];
|
|
25
|
+
|
|
26
|
+
const files = await walk(routesDir);
|
|
27
|
+
const routes = [];
|
|
28
|
+
|
|
29
|
+
for (const abs of files) {
|
|
30
|
+
const base = abs.split(sep).pop();
|
|
31
|
+
if (base !== "+page.svelte") continue;
|
|
32
|
+
|
|
33
|
+
const rel = relative(routesDir, abs).replace(/\\/g, "/");
|
|
34
|
+
const segs = rel.split("/").slice(0, -1);
|
|
35
|
+
|
|
36
|
+
if (segs.some((s) => s.startsWith("[") && s.endsWith("]"))) continue;
|
|
37
|
+
|
|
38
|
+
const visible = segs.filter((s) => !(s.startsWith("(") && s.endsWith(")")));
|
|
39
|
+
const path = visible.length === 0 ? "/" : "/" + visible.join("/");
|
|
40
|
+
|
|
41
|
+
if (!isExcluded(path, excludePaths)) {
|
|
42
|
+
routes.push({
|
|
43
|
+
path,
|
|
44
|
+
sourceFile: relative(root, abs).replace(/\\/g, "/"),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return dedupByPath(routes);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function walk(dir) {
|
|
53
|
+
const out = [];
|
|
54
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
const abs = join(dir, entry.name);
|
|
57
|
+
if (entry.isDirectory()) {
|
|
58
|
+
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
|
|
59
|
+
out.push(...(await walk(abs)));
|
|
60
|
+
} else if (entry.isFile()) {
|
|
61
|
+
out.push(abs);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function dedupByPath(routes) {
|
|
68
|
+
const seen = new Map();
|
|
69
|
+
for (const r of routes) if (!seen.has(r.path)) seen.set(r.path, r);
|
|
70
|
+
return [...seen.values()];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isExcluded(routePath, patterns) {
|
|
74
|
+
for (const pat of patterns) {
|
|
75
|
+
const rx = new RegExp(
|
|
76
|
+
"^" +
|
|
77
|
+
pat
|
|
78
|
+
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
|
|
79
|
+
.replace(/\*/g, ".*") +
|
|
80
|
+
"$",
|
|
81
|
+
);
|
|
82
|
+
if (rx.test(routePath)) return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
@@ -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 { detectSvelteKit, discoverSvelteKitRoutes } from "./sveltekit.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(), "svkit-")); });
|
|
14
|
+
afterEach(async () => { await rm(root, { recursive: true, force: true }); });
|
|
15
|
+
|
|
16
|
+
describe("detectSvelteKit", () => {
|
|
17
|
+
it("returns null when @sveltejs/kit not in deps", async () => {
|
|
18
|
+
await writeFile(join(root, "package.json"), JSON.stringify({ dependencies: {} }), "utf8");
|
|
19
|
+
expect(await detectSvelteKit(root)).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns match when @sveltejs/kit is a dep", async () => {
|
|
23
|
+
await writeFile(
|
|
24
|
+
join(root, "package.json"),
|
|
25
|
+
JSON.stringify({ devDependencies: { "@sveltejs/kit": "2.0.0" } }),
|
|
26
|
+
"utf8",
|
|
27
|
+
);
|
|
28
|
+
const result = await detectSvelteKit(root);
|
|
29
|
+
expect(result?.framework).toBe("sveltekit");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("discoverSvelteKitRoutes", () => {
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
await writeFile(
|
|
36
|
+
join(root, "package.json"),
|
|
37
|
+
JSON.stringify({ devDependencies: { "@sveltejs/kit": "2.0.0" } }),
|
|
38
|
+
"utf8",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("discovers +page.svelte routes", async () => {
|
|
43
|
+
await touch("src/routes/+page.svelte");
|
|
44
|
+
await touch("src/routes/about/+page.svelte");
|
|
45
|
+
await touch("src/routes/blog/hello/+page.svelte");
|
|
46
|
+
const routes = await discoverSvelteKitRoutes(root);
|
|
47
|
+
const paths = routes.map((r) => r.path).sort();
|
|
48
|
+
expect(paths).toEqual(["/", "/about", "/blog/hello"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("ignores +layout, +server, +page.ts files", async () => {
|
|
52
|
+
await touch("src/routes/+page.svelte");
|
|
53
|
+
await touch("src/routes/+layout.svelte");
|
|
54
|
+
await touch("src/routes/about/+page.ts");
|
|
55
|
+
await touch("src/routes/api/+server.ts");
|
|
56
|
+
const routes = await discoverSvelteKitRoutes(root);
|
|
57
|
+
const paths = routes.map((r) => r.path).sort();
|
|
58
|
+
expect(paths).toEqual(["/"]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("strips (group) folders", async () => {
|
|
62
|
+
await touch("src/routes/(marketing)/+page.svelte");
|
|
63
|
+
await touch("src/routes/(marketing)/about/+page.svelte");
|
|
64
|
+
const routes = await discoverSvelteKitRoutes(root);
|
|
65
|
+
const paths = routes.map((r) => r.path).sort();
|
|
66
|
+
expect(paths).toEqual(["/", "/about"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("skips [slug] dynamic segments", async () => {
|
|
70
|
+
await touch("src/routes/+page.svelte");
|
|
71
|
+
await touch("src/routes/blog/[slug]/+page.svelte");
|
|
72
|
+
const routes = await discoverSvelteKitRoutes(root);
|
|
73
|
+
const paths = routes.map((r) => r.path).sort();
|
|
74
|
+
expect(paths).toEqual(["/"]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Vite + React Router v6+ detector.
|
|
2
|
+
// Uses regex-based route extraction (not AST parsing) — sufficient
|
|
3
|
+
// for the common patterns: createBrowserRouter([...]) array and
|
|
4
|
+
// <Route path="..." /> JSX. Dynamic segments (:id, :slug) are
|
|
5
|
+
// skipped by default.
|
|
6
|
+
|
|
7
|
+
import { readFile, readdir } from "fs/promises";
|
|
8
|
+
import { existsSync } from "fs";
|
|
9
|
+
import { join, relative } from "path";
|
|
10
|
+
|
|
11
|
+
export async function detectVite(root) {
|
|
12
|
+
try {
|
|
13
|
+
const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
14
|
+
const all = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
15
|
+
const hasVite = !!all.vite;
|
|
16
|
+
const hasRouter = !!all["react-router-dom"] || !!all["react-router"];
|
|
17
|
+
if (!hasVite || !hasRouter) return null;
|
|
18
|
+
return { framework: "vite-react-router", strategy: "source-regex" };
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Regex for path strings inside route declarations.
|
|
25
|
+
// Matches `path: "..."` (config-style) and `<Route path="...">` (JSX).
|
|
26
|
+
const PATH_RE = /(?:\bpath\s*:\s*|<Route[^>]*\bpath\s*=\s*)(?:"([^"]*)"|'([^']*)')/g;
|
|
27
|
+
|
|
28
|
+
export async function discoverViteRoutes(root, { excludePaths = [] } = {}) {
|
|
29
|
+
const kind = await detectVite(root);
|
|
30
|
+
if (!kind) return [];
|
|
31
|
+
|
|
32
|
+
const srcDir = join(root, "src");
|
|
33
|
+
if (!existsSync(srcDir)) return [];
|
|
34
|
+
|
|
35
|
+
const files = (await walk(srcDir)).filter((f) => /\.(tsx|ts|jsx|js)$/.test(f));
|
|
36
|
+
const routes = [];
|
|
37
|
+
|
|
38
|
+
for (const abs of files) {
|
|
39
|
+
const text = await readFile(abs, "utf8").catch(() => "");
|
|
40
|
+
if (!text.includes("path")) continue;
|
|
41
|
+
let m;
|
|
42
|
+
PATH_RE.lastIndex = 0;
|
|
43
|
+
while ((m = PATH_RE.exec(text)) !== null) {
|
|
44
|
+
const raw = m[1] ?? m[2];
|
|
45
|
+
if (!raw) continue;
|
|
46
|
+
if (raw.includes(":")) continue;
|
|
47
|
+
if (raw.includes("*")) continue;
|
|
48
|
+
const normalized = raw.startsWith("/") ? raw : "/" + raw;
|
|
49
|
+
if (!isExcluded(normalized, excludePaths)) {
|
|
50
|
+
routes.push({
|
|
51
|
+
path: normalized,
|
|
52
|
+
sourceFile: relative(root, abs).replace(/\\/g, "/"),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return dedupByPath(routes);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function walk(dir) {
|
|
62
|
+
const out = [];
|
|
63
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
const abs = join(dir, entry.name);
|
|
66
|
+
if (entry.isDirectory()) {
|
|
67
|
+
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
|
|
68
|
+
out.push(...(await walk(abs)));
|
|
69
|
+
} else if (entry.isFile()) {
|
|
70
|
+
out.push(abs);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function dedupByPath(routes) {
|
|
77
|
+
const seen = new Map();
|
|
78
|
+
for (const r of routes) if (!seen.has(r.path)) seen.set(r.path, r);
|
|
79
|
+
return [...seen.values()];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isExcluded(routePath, patterns) {
|
|
83
|
+
for (const pat of patterns) {
|
|
84
|
+
const rx = new RegExp(
|
|
85
|
+
"^" +
|
|
86
|
+
pat
|
|
87
|
+
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
|
|
88
|
+
.replace(/\*/g, ".*") +
|
|
89
|
+
"$",
|
|
90
|
+
);
|
|
91
|
+
if (rx.test(routePath)) return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
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 { detectVite, discoverViteRoutes } from "./vite.js";
|
|
6
|
+
|
|
7
|
+
let root;
|
|
8
|
+
|
|
9
|
+
async function touch(rel, content = "// test") {
|
|
10
|
+
const abs = join(root, rel);
|
|
11
|
+
await mkdir(join(abs, ".."), { recursive: true });
|
|
12
|
+
await writeFile(abs, content, "utf8");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
root = await mkdtemp(join(tmpdir(), "viteproj-"));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await rm(root, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("detectVite", () => {
|
|
24
|
+
it("returns null when no package.json", async () => {
|
|
25
|
+
expect(await detectVite(root)).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns null when vite not in deps", async () => {
|
|
29
|
+
await writeFile(
|
|
30
|
+
join(root, "package.json"),
|
|
31
|
+
JSON.stringify({ dependencies: { react: "18.0.0" } }),
|
|
32
|
+
"utf8",
|
|
33
|
+
);
|
|
34
|
+
expect(await detectVite(root)).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns match when vite + react-router-dom are deps", async () => {
|
|
38
|
+
await writeFile(
|
|
39
|
+
join(root, "package.json"),
|
|
40
|
+
JSON.stringify({
|
|
41
|
+
dependencies: { vite: "5.0.0", "react-router-dom": "6.20.0" },
|
|
42
|
+
}),
|
|
43
|
+
"utf8",
|
|
44
|
+
);
|
|
45
|
+
const result = await detectVite(root);
|
|
46
|
+
expect(result).not.toBeNull();
|
|
47
|
+
expect(result.framework).toBe("vite-react-router");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("matches devDependencies too", async () => {
|
|
51
|
+
await writeFile(
|
|
52
|
+
join(root, "package.json"),
|
|
53
|
+
JSON.stringify({
|
|
54
|
+
devDependencies: { vite: "5.0.0" },
|
|
55
|
+
dependencies: { "react-router-dom": "6.0.0" },
|
|
56
|
+
}),
|
|
57
|
+
"utf8",
|
|
58
|
+
);
|
|
59
|
+
expect(await detectVite(root)).not.toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("discoverViteRoutes", () => {
|
|
64
|
+
beforeEach(async () => {
|
|
65
|
+
await writeFile(
|
|
66
|
+
join(root, "package.json"),
|
|
67
|
+
JSON.stringify({
|
|
68
|
+
dependencies: { vite: "5.0.0", "react-router-dom": "6.20.0" },
|
|
69
|
+
}),
|
|
70
|
+
"utf8",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("extracts routes from createBrowserRouter call", async () => {
|
|
75
|
+
await touch(
|
|
76
|
+
"src/main.tsx",
|
|
77
|
+
`
|
|
78
|
+
import { createBrowserRouter } from "react-router-dom";
|
|
79
|
+
|
|
80
|
+
const router = createBrowserRouter([
|
|
81
|
+
{ path: "/", element: <Home /> },
|
|
82
|
+
{ path: "/about", element: <About /> },
|
|
83
|
+
{ path: "/pricing", element: <Pricing /> },
|
|
84
|
+
]);
|
|
85
|
+
`,
|
|
86
|
+
);
|
|
87
|
+
const routes = await discoverViteRoutes(root);
|
|
88
|
+
const paths = routes.map((r) => r.path).sort();
|
|
89
|
+
expect(paths).toEqual(["/", "/about", "/pricing"]);
|
|
90
|
+
expect(routes[0].sourceFile).toBe("src/main.tsx");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("extracts routes from <Route path=...> JSX", async () => {
|
|
94
|
+
await touch(
|
|
95
|
+
"src/App.tsx",
|
|
96
|
+
`
|
|
97
|
+
import { Routes, Route } from "react-router-dom";
|
|
98
|
+
|
|
99
|
+
export function App() {
|
|
100
|
+
return (
|
|
101
|
+
<Routes>
|
|
102
|
+
<Route path="/" element={<Home />} />
|
|
103
|
+
<Route path="/contact" element={<Contact />} />
|
|
104
|
+
</Routes>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
`,
|
|
108
|
+
);
|
|
109
|
+
const routes = await discoverViteRoutes(root);
|
|
110
|
+
const paths = routes.map((r) => r.path).sort();
|
|
111
|
+
expect(paths).toEqual(["/", "/contact"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("skips dynamic segments like :id", async () => {
|
|
115
|
+
await touch(
|
|
116
|
+
"src/main.tsx",
|
|
117
|
+
`
|
|
118
|
+
const router = createBrowserRouter([
|
|
119
|
+
{ path: "/", element: <Home /> },
|
|
120
|
+
{ path: "/users/:id", element: <User /> },
|
|
121
|
+
{ path: "/about", element: <About /> },
|
|
122
|
+
]);
|
|
123
|
+
`,
|
|
124
|
+
);
|
|
125
|
+
const routes = await discoverViteRoutes(root);
|
|
126
|
+
const paths = routes.map((r) => r.path).sort();
|
|
127
|
+
expect(paths).toEqual(["/", "/about"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("applies excludePaths", async () => {
|
|
131
|
+
await touch(
|
|
132
|
+
"src/main.tsx",
|
|
133
|
+
`
|
|
134
|
+
const router = createBrowserRouter([
|
|
135
|
+
{ path: "/", element: <Home /> },
|
|
136
|
+
{ path: "/admin", element: <Admin /> },
|
|
137
|
+
]);
|
|
138
|
+
`,
|
|
139
|
+
);
|
|
140
|
+
const routes = await discoverViteRoutes(root, { excludePaths: ["/admin"] });
|
|
141
|
+
const paths = routes.map((r) => r.path).sort();
|
|
142
|
+
expect(paths).toEqual(["/"]);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const DEFAULT_API_URL = "https://wcagaudit.io/api/v1/audit/log";
|
|
2
|
+
|
|
3
|
+
// Fire-and-forget usage logging. Never throws — billing failures
|
|
4
|
+
// should not break the user's scan.
|
|
5
|
+
export async function logUsage(payload, { apiUrl } = {}) {
|
|
6
|
+
const url = apiUrl || process.env.WCAG_AUDIT_LOG_URL || DEFAULT_API_URL;
|
|
7
|
+
try {
|
|
8
|
+
const res = await fetch(url, {
|
|
9
|
+
method: "POST",
|
|
10
|
+
headers: { "content-type": "application/json" },
|
|
11
|
+
body: JSON.stringify({
|
|
12
|
+
timestamp: new Date().toISOString(),
|
|
13
|
+
...payload,
|
|
14
|
+
}),
|
|
15
|
+
});
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
return { ok: false, status: res.status };
|
|
18
|
+
}
|
|
19
|
+
return { ok: true };
|
|
20
|
+
} catch (err) {
|
|
21
|
+
return { ok: false, error: err.message };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { logUsage } from "./log-usage.js";
|
|
3
|
+
|
|
4
|
+
describe("logUsage", () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
global.fetch = vi.fn();
|
|
7
|
+
});
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("fires a POST with the full usage payload", async () => {
|
|
13
|
+
global.fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ ok: true }) });
|
|
14
|
+
const result = await logUsage({
|
|
15
|
+
licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC",
|
|
16
|
+
pagesCount: 47,
|
|
17
|
+
newPagesCount: 12,
|
|
18
|
+
cachedPagesCount: 35,
|
|
19
|
+
issuesFound: 89,
|
|
20
|
+
source: "cli",
|
|
21
|
+
cliVersion: "1.0.0-alpha.1",
|
|
22
|
+
framework: "nextjs-app",
|
|
23
|
+
});
|
|
24
|
+
expect(result.ok).toBe(true);
|
|
25
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
26
|
+
const [url, init] = global.fetch.mock.calls[0];
|
|
27
|
+
expect(url).toMatch(/\/v1\/audit\/log$/);
|
|
28
|
+
const body = JSON.parse(init.body);
|
|
29
|
+
expect(body.licenseKey).toBe("WCAG-TEST-AAAA-BBBB-CCCC");
|
|
30
|
+
expect(body.pagesCount).toBe(47);
|
|
31
|
+
expect(body.newPagesCount).toBe(12);
|
|
32
|
+
expect(body.source).toBe("cli");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("never throws on network failure — returns { ok: false }", async () => {
|
|
36
|
+
global.fetch.mockRejectedValueOnce(new Error("socket closed"));
|
|
37
|
+
const result = await logUsage({
|
|
38
|
+
licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC",
|
|
39
|
+
pagesCount: 1,
|
|
40
|
+
issuesFound: 0,
|
|
41
|
+
});
|
|
42
|
+
expect(result.ok).toBe(false);
|
|
43
|
+
expect(result.error).toMatch(/socket closed/);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Free-tier license request. POSTs to /v1/license/free which creates a
|
|
2
|
+
// User + License row (tier: "free") and emails the key to the user.
|
|
3
|
+
// The key is NEVER returned in the response body — it's mailed out,
|
|
4
|
+
// so users have to paste it from their inbox. This matches the
|
|
5
|
+
// Chrome extension free-tier flow.
|
|
6
|
+
|
|
7
|
+
const DEFAULT_API_URL = "https://wcagaudit.io/api/v1/license/free";
|
|
8
|
+
|
|
9
|
+
export async function requestFreeLicense({ email, machineId, apiUrl } = {}) {
|
|
10
|
+
const url = apiUrl || process.env.WCAG_LICENSE_FREE_URL || DEFAULT_API_URL;
|
|
11
|
+
|
|
12
|
+
const trimmedEmail = (email || "").trim();
|
|
13
|
+
if (!trimmedEmail || !trimmedEmail.includes("@")) {
|
|
14
|
+
return {
|
|
15
|
+
ok: false,
|
|
16
|
+
error: "A valid email address is required",
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch(url, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { "content-type": "application/json" },
|
|
24
|
+
body: JSON.stringify({ email: trimmedEmail, machineId }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const body = await res.json().catch(() => ({}));
|
|
28
|
+
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
error: body.error || `Free license request failed (HTTP ${res.status})`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
message: body.message || "Check your email for your free license key",
|
|
39
|
+
};
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
error: `Network error: ${err.message}`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|