apex-auditor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # ApexAuditor
2
+
3
+ ApexAuditor is a small, framework-agnostic Lighthouse runner that gives you **fast, structured insights** across multiple pages and devices.
4
+
5
+ It is designed to:
6
+
7
+ - **Run anywhere**: attach to an existing Chrome instance (remote debugging) on Windows, macOS, or Linux.
8
+ - **Work with any web stack**: Next.js, Vite, Rails, static sites, etc. – as long as there is an HTTP server.
9
+ - **Summarize multiple pages at once**: homepage, blog, auth, search, and more.
10
+ - **Output developer-friendly reports**: one Markdown table + JSON, ready to paste into PRs or chat.
11
+
12
+ > V1 focuses on a solid, single-project core. Route auto-detection and monorepo orchestration will land in later versions.
13
+
14
+ ---
15
+
16
+ ## Quick start (single project)
17
+
18
+ ### 1. Install dependencies
19
+
20
+ From the `apex-auditor` directory:
21
+
22
+ ```bash
23
+ pnpm install
24
+ ```
25
+
26
+ ### 2. Start your web app
27
+
28
+ In your application repo (for example, a Next.js app running on port 3000):
29
+
30
+ ```bash
31
+ pnpm start
32
+ # or: pnpm dev, npm run dev, etc.
33
+ ```
34
+
35
+ Make sure the app is reachable at the `baseUrl` you will configure (default example: `http://localhost:3000`).
36
+
37
+ ### 3. Start Chrome with remote debugging
38
+
39
+ ApexAuditor connects to an existing Chrome instance instead of launching its own. Start Chrome once with a debugging port (example for Windows):
40
+
41
+ ```bash
42
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" \
43
+ --remote-debugging-port=9222 \
44
+ --user-data-dir="%LOCALAPPDATA%\\ChromeApex"
45
+ ```
46
+
47
+ On macOS or Linux the flags are the same; only the Chrome path changes.
48
+
49
+ ### 4. Configure pages (wizard-friendly)
50
+
51
+ Run the guided wizard to scaffold `apex.config.json` and optionally auto-discover routes:
52
+
53
+ ```bash
54
+ pnpm wizard
55
+ ```
56
+
57
+ The wizard asks for the base URL, optional query string, desired Chrome port, run count, and can crawl popular frameworks (Next.js app/pages) to prefill routes before you fine-tune the list. You can still edit the file manually afterwards:
58
+
59
+ ```jsonc
60
+ {
61
+ "baseUrl": "http://localhost:3000",
62
+ "query": "?lhci=1",
63
+ "chromePort": 9222,
64
+ "runs": 1,
65
+ "pages": [
66
+ { "path": "/", "label": "home", "devices": ["mobile", "desktop"] },
67
+ { "path": "/blog", "label": "blog", "devices": ["mobile", "desktop"] },
68
+ { "path": "/contact", "label": "contact", "devices": ["mobile"] }
69
+ ]
70
+ }
71
+ ```
72
+
73
+ > Tip: rerun `pnpm wizard -- --config custom/path.json` to regenerate configs for multiple projects, or pass a different `--project-root` when prompted to detect routes from another app.
74
+
75
+ - `baseUrl`: root URL of your running app.
76
+ - `query` (optional): query string appended to every URL (for example `?lhci=1` to disable analytics).
77
+ - `chromePort` (optional): remote debugging port (defaults to `9222`).
78
+ - `runs` (optional): how many times to run Lighthouse per page/device (results are averaged).
79
+ - `pages`: list of paths and devices to audit.
80
+
81
+ ### 5. Run an audit
82
+
83
+ ```bash
84
+ pnpm audit
85
+ ```
86
+
87
+ This will:
88
+
89
+ - Run Lighthouse for every `page × device` defined in `apex.config.json`.
90
+ - Write structured results to `.apex-auditor/summary.json`.
91
+ - Write a human-readable table to `.apex-auditor/summary.md`.
92
+ - Print the same table to the terminal.
93
+
94
+ Example output:
95
+
96
+ ```text
97
+ | Label | Path | Device | P | A | BP | SEO | LCP (s) | FCP (s) | TBT (ms) | CLS | Top issues |
98
+ |-------|------|---------|----|----|----|-----|---------|---------|----------|-------|-----------|
99
+ | home | / | mobile | 95 |100 |100 |100 | 2.9 | 0.9 | 160 | 0.002 | render-blocking-resources (140ms); unused-javascript (55KB) |
100
+ | home | / | desktop |100 |100 |100 |100 | 0.6 | 0.4 | 0 | 0.016 | unused-javascript (55KB) |
101
+ ```
102
+
103
+ You can paste this table directly into PRs, tickets, or chat to discuss optimizations.
104
+
105
+ ---
106
+
107
+ ## Configuration reference (V1)
108
+
109
+ ```ts
110
+ // apex.config.json (TypeScript shape)
111
+ interface ApexPageConfig {
112
+ path: string; // URL path, must start with "/"
113
+ label: string; // short label for reports
114
+ devices: ("mobile" | "desktop")[];
115
+ }
116
+
117
+ interface ApexConfig {
118
+ baseUrl: string; // e.g. "http://localhost:3000"
119
+ query?: string; // e.g. "?lhci=1"
120
+ chromePort?: number; // default: 9222
121
+ runs?: number; // default: 1
122
+ pages: ApexPageConfig[];
123
+ }
124
+ ```
125
+
126
+ Future versions will add:
127
+
128
+ - Automatic route discovery (for example, from Next.js `app/` routes or a crawler).
129
+ - Workspace-level configs for monorepos.
130
+ - CI integration recipes and HTML dashboards.
131
+
132
+ ---
133
+
134
+ ## Code structure (V1)
135
+
136
+ The codebase is intentionally small and modular:
137
+
138
+ - `src/types.ts` – shared type definitions for config and results.
139
+ - `src/config.ts` – loads and validates `apex.config.json`.
140
+ - `src/lighthouse-runner.ts` – runs Lighthouse for each page/device and normalises results.
141
+ - `src/cli.ts` – CLI entry point; orchestrates config + runner, writes JSON/Markdown.
142
+
143
+ All public modules use explicit TypeScript types and are written to be reusable in future integrations (route detectors, monorepo orchestration, CI adapters).
144
+
145
+ See `ROADMAP.md` for planned features and phases.
package/dist/bin.js ADDED
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ import { runAuditCli } from "./cli.js";
3
+ import { runWizardCli } from "./wizard-cli.js";
4
+ function parseBinArgs(argv) {
5
+ const rawCommand = argv[2];
6
+ if (rawCommand === undefined || rawCommand === "help" || rawCommand === "--help" || rawCommand === "-h") {
7
+ return { command: "help", argv };
8
+ }
9
+ if (rawCommand === "audit" || rawCommand === "wizard") {
10
+ const commandArgv = ["node", "apex-auditor", ...argv.slice(3)];
11
+ return { command: rawCommand, argv: commandArgv };
12
+ }
13
+ return { command: "help", argv };
14
+ }
15
+ function printHelp() {
16
+ console.log([
17
+ "ApexAuditor CLI",
18
+ "",
19
+ "Usage:",
20
+ " apex-auditor wizard [--config <path>]",
21
+ " apex-auditor audit [--config <path>]",
22
+ "",
23
+ "Commands:",
24
+ " wizard Run interactive config wizard",
25
+ " audit Run Lighthouse audits using apex.config.json",
26
+ " help Show this help message",
27
+ ].join("\n"));
28
+ }
29
+ export async function runBin(argv) {
30
+ const parsed = parseBinArgs(argv);
31
+ if (parsed.command === "help") {
32
+ printHelp();
33
+ return;
34
+ }
35
+ if (parsed.command === "audit") {
36
+ await runAuditCli(parsed.argv);
37
+ return;
38
+ }
39
+ if (parsed.command === "wizard") {
40
+ await runWizardCli(parsed.argv);
41
+ }
42
+ }
43
+ void runBin(process.argv).catch((error) => {
44
+ // eslint-disable-next-line no-console
45
+ console.error("ApexAuditor CLI failed:", error);
46
+ process.exitCode = 1;
47
+ });
package/dist/cli.js ADDED
@@ -0,0 +1,65 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { loadConfig } from "./config.js";
4
+ import { runAuditsForConfig } from "./lighthouse-runner.js";
5
+ function parseArgs(argv) {
6
+ let configPath;
7
+ for (let i = 2; i < argv.length; i += 1) {
8
+ const arg = argv[i];
9
+ if ((arg === "--config" || arg === "-c") && i + 1 < argv.length) {
10
+ configPath = argv[i + 1];
11
+ i += 1;
12
+ }
13
+ }
14
+ const finalConfigPath = configPath ?? "apex.config.json";
15
+ return { configPath: finalConfigPath };
16
+ }
17
+ /**
18
+ * Runs the ApexAuditor audit CLI.
19
+ *
20
+ * @param argv - The process arguments array.
21
+ */
22
+ export async function runAuditCli(argv) {
23
+ const args = parseArgs(argv);
24
+ const { configPath, config } = await loadConfig({ configPath: args.configPath });
25
+ const summary = await runAuditsForConfig({ config, configPath });
26
+ const outputDir = resolve(".apex-auditor");
27
+ await mkdir(outputDir, { recursive: true });
28
+ await writeFile(resolve(outputDir, "summary.json"), JSON.stringify(summary, null, 2), "utf8");
29
+ const markdown = buildMarkdown(summary.results);
30
+ await writeFile(resolve(outputDir, "summary.md"), markdown, "utf8");
31
+ // Also echo a compact table to stdout for quick viewing.
32
+ // eslint-disable-next-line no-console
33
+ console.log(markdown);
34
+ }
35
+ function buildMarkdown(results) {
36
+ const header = [
37
+ "| Label | Path | Device | P | A | BP | SEO | LCP (s) | FCP (s) | TBT (ms) | CLS | Top issues |",
38
+ "|-------|------|--------|---|---|----|-----|---------|---------|----------|-----|-----------|",
39
+ ].join("\n");
40
+ const lines = results.map((result) => buildRow(result));
41
+ return `${header}\n${lines.join("\n")}`;
42
+ }
43
+ function buildRow(result) {
44
+ const scores = result.scores;
45
+ const metrics = result.metrics;
46
+ const lcpSeconds = metrics.lcpMs !== undefined ? (metrics.lcpMs / 1000).toFixed(1) : "-";
47
+ const fcpSeconds = metrics.fcpMs !== undefined ? (metrics.fcpMs / 1000).toFixed(1) : "-";
48
+ const tbtMs = metrics.tbtMs !== undefined ? Math.round(metrics.tbtMs).toString() : "-";
49
+ const cls = metrics.cls !== undefined ? metrics.cls.toFixed(3) : "-";
50
+ const issues = formatTopIssues(result.opportunities);
51
+ return `| ${result.label} | ${result.path} | ${result.device} | ${scores.performance ?? "-"} | ${scores.accessibility ?? "-"} | ${scores.bestPractices ?? "-"} | ${scores.seo ?? "-"} | ${lcpSeconds} | ${fcpSeconds} | ${tbtMs} | ${cls} | ${issues} |`;
52
+ }
53
+ function formatTopIssues(opportunities) {
54
+ if (opportunities.length === 0) {
55
+ return "";
56
+ }
57
+ const items = opportunities.map((opp) => {
58
+ const savingsMs = opp.estimatedSavingsMs !== undefined ? `${Math.round(opp.estimatedSavingsMs)}ms` : "";
59
+ const savingsBytes = opp.estimatedSavingsBytes !== undefined ? `${Math.round(opp.estimatedSavingsBytes / 1024)}KB` : "";
60
+ const parts = [savingsMs, savingsBytes].filter((p) => p.length > 0);
61
+ const suffix = parts.length > 0 ? ` (${parts.join(", ")})` : "";
62
+ return `${opp.id}${suffix}`;
63
+ });
64
+ return items.join("; ");
65
+ }
package/dist/config.js ADDED
@@ -0,0 +1,63 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ /**
4
+ * Load and minimally validate the ApexAuditor configuration file.
5
+ */
6
+ export async function loadConfig({ configPath }) {
7
+ const absolutePath = resolve(configPath);
8
+ const raw = await readFile(absolutePath, "utf8");
9
+ const parsed = JSON.parse(raw);
10
+ const config = normaliseConfig(parsed, absolutePath);
11
+ return { configPath: absolutePath, config };
12
+ }
13
+ function normaliseConfig(input, absolutePath) {
14
+ if (!input || typeof input !== "object") {
15
+ throw new Error(`Invalid config at ${absolutePath}: expected object`);
16
+ }
17
+ const maybeConfig = input;
18
+ if (typeof maybeConfig.baseUrl !== "string" || maybeConfig.baseUrl.length === 0) {
19
+ throw new Error(`Invalid config at ${absolutePath}: baseUrl must be a non-empty string`);
20
+ }
21
+ const pagesInput = maybeConfig.pages;
22
+ if (!Array.isArray(pagesInput) || pagesInput.length === 0) {
23
+ throw new Error(`Invalid config at ${absolutePath}: pages must be a non-empty array`);
24
+ }
25
+ const pages = pagesInput.map((page, index) => normalisePage(page, index, absolutePath));
26
+ const baseUrl = maybeConfig.baseUrl.replace(/\/$/, "");
27
+ const query = typeof maybeConfig.query === "string" ? maybeConfig.query : undefined;
28
+ const chromePort = typeof maybeConfig.chromePort === "number" ? maybeConfig.chromePort : undefined;
29
+ const runs = typeof maybeConfig.runs === "number" && maybeConfig.runs > 0 ? maybeConfig.runs : undefined;
30
+ return {
31
+ baseUrl,
32
+ query,
33
+ chromePort,
34
+ runs,
35
+ pages,
36
+ };
37
+ }
38
+ function normalisePage(page, index, absolutePath) {
39
+ if (!page || typeof page !== "object") {
40
+ throw new Error(`Invalid page at index ${index} in ${absolutePath}: expected object`);
41
+ }
42
+ const maybePage = page;
43
+ if (typeof maybePage.path !== "string" || !maybePage.path.startsWith("/")) {
44
+ throw new Error(`Invalid page at index ${index} in ${absolutePath}: path must start with '/'`);
45
+ }
46
+ const label = typeof maybePage.label === "string" && maybePage.label.length > 0
47
+ ? maybePage.label
48
+ : maybePage.path;
49
+ const devicesInput = maybePage.devices;
50
+ const devices = Array.isArray(devicesInput) && devicesInput.length > 0
51
+ ? devicesInput.map((d, deviceIndex) => {
52
+ if (d !== "mobile" && d !== "desktop") {
53
+ throw new Error(`Invalid device at pages[${index}].devices[${deviceIndex}] in ${absolutePath}`);
54
+ }
55
+ return d;
56
+ })
57
+ : ["mobile"];
58
+ return {
59
+ path: maybePage.path,
60
+ label,
61
+ devices,
62
+ };
63
+ }
@@ -0,0 +1,13 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ export async function readTextFile(path) {
3
+ return readFile(path, "utf8");
4
+ }
5
+ export async function pathExists(path) {
6
+ try {
7
+ await access(path);
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
@@ -0,0 +1,149 @@
1
+ import lighthouse from "lighthouse";
2
+ /**
3
+ * Run audits for all pages defined in the config and return a structured summary.
4
+ */
5
+ export async function runAuditsForConfig({ config, configPath, }) {
6
+ const port = config.chromePort ?? 9222;
7
+ const runs = config.runs ?? 1;
8
+ const results = [];
9
+ for (const page of config.pages) {
10
+ for (const device of page.devices) {
11
+ const url = buildUrl({ baseUrl: config.baseUrl, path: page.path, query: config.query });
12
+ const summaries = [];
13
+ for (let i = 0; i < runs; i += 1) {
14
+ const summary = await runSingleAudit({
15
+ url,
16
+ path: page.path,
17
+ label: page.label,
18
+ device,
19
+ port,
20
+ });
21
+ summaries.push(summary);
22
+ }
23
+ results.push(aggregateSummaries(summaries));
24
+ }
25
+ }
26
+ return { configPath, results };
27
+ }
28
+ function buildUrl({ baseUrl, path, query }) {
29
+ const cleanBase = baseUrl.replace(/\/$/, "");
30
+ const cleanPath = path.startsWith("/") ? path : `/${path}`;
31
+ const queryPart = query && query.length > 0 ? query : "";
32
+ return `${cleanBase}${cleanPath}${queryPart}`;
33
+ }
34
+ async function runSingleAudit(params) {
35
+ const options = {
36
+ port: params.port,
37
+ output: "json",
38
+ logLevel: "error",
39
+ onlyCategories: ["performance", "accessibility", "best-practices", "seo"],
40
+ emulatedFormFactor: params.device,
41
+ };
42
+ const runnerResult = await lighthouse(params.url, options);
43
+ const lhrUnknown = runnerResult.lhr;
44
+ if (!lhrUnknown || typeof lhrUnknown !== "object") {
45
+ throw new Error("Lighthouse did not return a valid result");
46
+ }
47
+ const lhr = lhrUnknown;
48
+ const scores = extractScores(lhr);
49
+ const metrics = extractMetrics(lhr);
50
+ const opportunities = extractTopOpportunities(lhr, 3);
51
+ return {
52
+ url: lhr.finalDisplayedUrl ?? params.url,
53
+ path: params.path,
54
+ label: params.label,
55
+ device: params.device,
56
+ scores,
57
+ metrics,
58
+ opportunities,
59
+ };
60
+ }
61
+ function extractScores(lhr) {
62
+ const performanceScore = lhr.categories.performance?.score;
63
+ const accessibilityScore = lhr.categories.accessibility?.score;
64
+ const bestPracticesScore = lhr.categories["best-practices"]?.score;
65
+ const seoScore = lhr.categories.seo?.score;
66
+ return {
67
+ performance: normaliseScore(performanceScore),
68
+ accessibility: normaliseScore(accessibilityScore),
69
+ bestPractices: normaliseScore(bestPracticesScore),
70
+ seo: normaliseScore(seoScore),
71
+ };
72
+ }
73
+ function normaliseScore(score) {
74
+ if (typeof score !== "number") {
75
+ return undefined;
76
+ }
77
+ return Math.round(score * 100);
78
+ }
79
+ function extractMetrics(lhr) {
80
+ const audits = lhr.audits;
81
+ const lcpAudit = audits["largest-contentful-paint"];
82
+ const fcpAudit = audits["first-contentful-paint"];
83
+ const tbtAudit = audits["total-blocking-time"];
84
+ const clsAudit = audits["cumulative-layout-shift"];
85
+ const lcpMs = typeof lcpAudit?.numericValue === "number" ? lcpAudit.numericValue : undefined;
86
+ const fcpMs = typeof fcpAudit?.numericValue === "number" ? fcpAudit.numericValue : undefined;
87
+ const tbtMs = typeof tbtAudit?.numericValue === "number" ? tbtAudit.numericValue : undefined;
88
+ const cls = typeof clsAudit?.numericValue === "number" ? clsAudit.numericValue : undefined;
89
+ return {
90
+ lcpMs,
91
+ fcpMs,
92
+ tbtMs,
93
+ cls,
94
+ };
95
+ }
96
+ function extractTopOpportunities(lhr, limit) {
97
+ const audits = Object.values(lhr.audits);
98
+ const candidates = audits
99
+ .filter((audit) => audit.details?.type === "opportunity")
100
+ .map((audit) => {
101
+ const savingsMs = audit.details?.overallSavingsMs;
102
+ const savingsBytes = audit.details?.overallSavingsBytes;
103
+ return {
104
+ id: audit.id ?? "unknown",
105
+ title: audit.title ?? (audit.id ?? "Unknown"),
106
+ estimatedSavingsMs: typeof savingsMs === "number" ? savingsMs : undefined,
107
+ estimatedSavingsBytes: typeof savingsBytes === "number" ? savingsBytes : undefined,
108
+ };
109
+ });
110
+ candidates.sort((a, b) => (b.estimatedSavingsMs ?? 0) - (a.estimatedSavingsMs ?? 0));
111
+ return candidates.slice(0, limit);
112
+ }
113
+ function aggregateSummaries(summaries) {
114
+ if (summaries.length === 1) {
115
+ return summaries[0];
116
+ }
117
+ const base = summaries[0];
118
+ const count = summaries.length;
119
+ const aggregateScores = {
120
+ performance: averageOf(summaries.map((s) => s.scores.performance)),
121
+ accessibility: averageOf(summaries.map((s) => s.scores.accessibility)),
122
+ bestPractices: averageOf(summaries.map((s) => s.scores.bestPractices)),
123
+ seo: averageOf(summaries.map((s) => s.scores.seo)),
124
+ };
125
+ const aggregateMetrics = {
126
+ lcpMs: averageOf(summaries.map((s) => s.metrics.lcpMs)),
127
+ fcpMs: averageOf(summaries.map((s) => s.metrics.fcpMs)),
128
+ tbtMs: averageOf(summaries.map((s) => s.metrics.tbtMs)),
129
+ cls: averageOf(summaries.map((s) => s.metrics.cls)),
130
+ };
131
+ const opportunities = summaries[0].opportunities;
132
+ return {
133
+ url: base.url,
134
+ path: base.path,
135
+ label: base.label,
136
+ device: base.device,
137
+ scores: aggregateScores,
138
+ metrics: aggregateMetrics,
139
+ opportunities,
140
+ };
141
+ }
142
+ function averageOf(values) {
143
+ const defined = values.filter((v) => typeof v === "number");
144
+ if (defined.length === 0) {
145
+ return undefined;
146
+ }
147
+ const total = defined.reduce((sum, value) => sum + value, 0);
148
+ return total / defined.length;
149
+ }
@@ -0,0 +1,306 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join, relative, sep } from "node:path";
3
+ import { pathExists, readTextFile } from "./fs-utils.js";
4
+ const PAGE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
5
+ const DEFAULT_LIMIT = 25;
6
+ const SOURCE_NEXT_APP = "next-app";
7
+ const SOURCE_NEXT_PAGES = "next-pages";
8
+ const SOURCE_REMIX = "remix-routes";
9
+ const SOURCE_SPA = "spa-html";
10
+ const ROUTE_DETECTORS = [
11
+ createNextAppDetector(),
12
+ createNextPagesDetector(),
13
+ createRemixRoutesDetector(),
14
+ createSpaHtmlDetector(),
15
+ ];
16
+ export async function detectRoutes(options) {
17
+ const limit = options.limit ?? DEFAULT_LIMIT;
18
+ const orderedDetectors = orderDetectors(options.preferredDetectorId);
19
+ for (const detector of orderedDetectors) {
20
+ const detectorOptions = { ...options, limit };
21
+ if (!(await detector.canDetect(detectorOptions))) {
22
+ logDetection(options, detector.id, "skipped");
23
+ continue;
24
+ }
25
+ const routes = await detector.detect(detectorOptions);
26
+ if (routes.length === 0) {
27
+ logDetection(options, detector.id, "no-routes", { limit });
28
+ continue;
29
+ }
30
+ const selected = takeTopRoutes(routes, limit);
31
+ logDetection(options, detector.id, "routes-found", {
32
+ limit,
33
+ candidateCount: routes.length,
34
+ selectedCount: selected.length,
35
+ root: detectorOptions.projectRoot,
36
+ });
37
+ return selected;
38
+ }
39
+ logDetection(options, "none", "no-detectors", { limit });
40
+ return [];
41
+ }
42
+ function createNextAppDetector() {
43
+ return {
44
+ id: SOURCE_NEXT_APP,
45
+ canDetect: async (options) => pathExists(join(options.projectRoot, "app")),
46
+ detect: async (options) => detectAppRoutes(join(options.projectRoot, "app"), options.limit),
47
+ };
48
+ }
49
+ function createNextPagesDetector() {
50
+ return {
51
+ id: SOURCE_NEXT_PAGES,
52
+ canDetect: async (options) => pathExists(join(options.projectRoot, "pages")),
53
+ detect: async (options) => detectPagesRoutes(join(options.projectRoot, "pages"), options.limit),
54
+ };
55
+ }
56
+ function createRemixRoutesDetector() {
57
+ return {
58
+ id: SOURCE_REMIX,
59
+ canDetect: async (options) => pathExists(join(options.projectRoot, "app", "routes")),
60
+ detect: async (options) => detectRemixRoutes(join(options.projectRoot, "app", "routes"), options.limit),
61
+ };
62
+ }
63
+ function createSpaHtmlDetector() {
64
+ return {
65
+ id: SOURCE_SPA,
66
+ canDetect: async (options) => Boolean(await findSpaHtml(options.projectRoot)),
67
+ detect: async (options) => detectSpaRoutes(options.projectRoot, options.limit),
68
+ };
69
+ }
70
+ async function detectAppRoutes(appRoot, limit) {
71
+ const files = await collectRouteFiles(appRoot, limit, isAppPageFile);
72
+ return files.map((file) => buildRoute(file, appRoot, formatAppRoutePath, SOURCE_NEXT_APP));
73
+ }
74
+ async function detectPagesRoutes(pagesRoot, limit) {
75
+ const files = await collectRouteFiles(pagesRoot, limit, isPagesFileAllowed);
76
+ return files.map((file) => buildRoute(file, pagesRoot, formatPagesRoutePath, SOURCE_NEXT_PAGES));
77
+ }
78
+ async function detectRemixRoutes(routesRoot, limit) {
79
+ const files = await collectRouteFiles(routesRoot, limit, isRemixRouteFile);
80
+ return files.map((file) => buildRoute(file, routesRoot, formatRemixRoutePath, SOURCE_REMIX));
81
+ }
82
+ async function detectSpaRoutes(projectRoot, limit) {
83
+ const htmlPath = await findSpaHtml(projectRoot);
84
+ if (!htmlPath) {
85
+ return [];
86
+ }
87
+ const html = await readTextFile(htmlPath);
88
+ const routes = extractRoutesFromHtml(html).slice(0, limit);
89
+ return routes.map((routePath) => ({ path: routePath, label: buildLabel(routePath), source: SOURCE_SPA }));
90
+ }
91
+ async function collectRouteFiles(root, limit, matcher) {
92
+ const stack = [root];
93
+ const files = [];
94
+ while (stack.length > 0 && files.length < limit) {
95
+ const current = stack.pop();
96
+ const entries = await readdir(current, { withFileTypes: true });
97
+ for (const entry of entries) {
98
+ const entryPath = join(current, entry.name);
99
+ const relativePath = relative(root, entryPath);
100
+ if (entry.isDirectory()) {
101
+ if (shouldRecurseDirectory(relativePath)) {
102
+ stack.push(entryPath);
103
+ }
104
+ }
105
+ else if (matcher(entry, relativePath)) {
106
+ files.push(entryPath);
107
+ }
108
+ if (files.length >= limit) {
109
+ break;
110
+ }
111
+ }
112
+ }
113
+ return files;
114
+ }
115
+ function shouldRecurseDirectory(relativePath) {
116
+ const posixPath = normalisePath(relativePath);
117
+ if (posixPath.startsWith("api/")) {
118
+ return false;
119
+ }
120
+ return true;
121
+ }
122
+ function isAppPageFile(entry, relativePath) {
123
+ if (!entry.isFile()) {
124
+ return false;
125
+ }
126
+ const posixPath = normalisePath(relativePath);
127
+ if (!hasAllowedExtension(posixPath)) {
128
+ return false;
129
+ }
130
+ return posixPath.includes("/page.") || posixPath.startsWith("page.");
131
+ }
132
+ function isPagesFileAllowed(entry, relativePath) {
133
+ if (!entry.isFile()) {
134
+ return false;
135
+ }
136
+ const posixPath = normalisePath(relativePath);
137
+ if (!hasAllowedExtension(posixPath)) {
138
+ return false;
139
+ }
140
+ if (posixPath.startsWith("api/")) {
141
+ return false;
142
+ }
143
+ if (posixPath.startsWith("_")) {
144
+ return false;
145
+ }
146
+ return true;
147
+ }
148
+ function isRemixRouteFile(entry, relativePath) {
149
+ if (!entry.isFile()) {
150
+ return false;
151
+ }
152
+ if (!hasAllowedExtension(relativePath)) {
153
+ return false;
154
+ }
155
+ const posixPath = normalisePath(relativePath);
156
+ if (posixPath.includes(".server")) {
157
+ return false;
158
+ }
159
+ return !posixPath.split("/").some((segment) => segment.startsWith("__"));
160
+ }
161
+ function hasAllowedExtension(path) {
162
+ return PAGE_EXTENSIONS.some((extension) => path.endsWith(extension));
163
+ }
164
+ function buildRoute(filePath, root, formatter, source) {
165
+ const relativePath = normalisePath(relative(root, filePath));
166
+ const routePath = formatter(relativePath);
167
+ return {
168
+ path: routePath,
169
+ label: buildLabel(routePath),
170
+ source,
171
+ };
172
+ }
173
+ function formatAppRoutePath(relativePath) {
174
+ const posixPath = relativePath.replace(/\\/g, "/");
175
+ const withoutFile = posixPath.replace(/\/?page\.[^/]+$/, "");
176
+ const cleaned = withoutFile.replace(/\([^/]+\)/g, "").replace(/^\/+/, "");
177
+ return cleaned.length === 0 ? "/" : normaliseRoute(cleaned);
178
+ }
179
+ function formatPagesRoutePath(relativePath) {
180
+ const cleanPath = relativePath.replace(/\\/g, "/").replace(/\.[^/.]+$/, "");
181
+ if (cleanPath === "index") {
182
+ return "/";
183
+ }
184
+ if (cleanPath.endsWith("/index")) {
185
+ return normaliseRoute(cleanPath.slice(0, -6));
186
+ }
187
+ return normaliseRoute(cleanPath);
188
+ }
189
+ function formatRemixRoutePath(relativePath) {
190
+ const cleanPath = relativePath.replace(/\\/g, "/").replace(/\.[^/.]+$/, "");
191
+ const tokens = cleanPath
192
+ .split("/")
193
+ .flatMap((segment) => segment.split("."))
194
+ .map((segment) => segment.trim())
195
+ .filter((segment) => segment.length > 0);
196
+ const parts = tokens
197
+ .map((segment) => (segment.startsWith("_") ? segment.slice(1) : segment))
198
+ .map((segment) => {
199
+ if (segment === "index") {
200
+ return "";
201
+ }
202
+ if (segment === "$") {
203
+ return ":param";
204
+ }
205
+ if (segment.startsWith("$")) {
206
+ return `:${segment.slice(1)}`;
207
+ }
208
+ if (segment.includes("$")) {
209
+ return segment
210
+ .split("$")
211
+ .filter((piece) => piece.length > 0)
212
+ .map((piece, index) => (index === 0 ? piece : `:${piece}`))
213
+ .join("/");
214
+ }
215
+ return segment.replace(/\$([a-zA-Z0-9]+)/g, ":$1").replace(/\$/g, "");
216
+ })
217
+ .filter((segment) => segment.length > 0);
218
+ return parts.length === 0 ? "/" : normaliseRoute(parts.join("/"));
219
+ }
220
+ function normaliseRoute(path) {
221
+ const trimmed = path.replace(/^\/+/, "");
222
+ if (trimmed.length === 0) {
223
+ return "/";
224
+ }
225
+ return `/${trimmed}`.replace(/\/+/g, "/");
226
+ }
227
+ function buildLabel(routePath) {
228
+ if (routePath === "/") {
229
+ return "home";
230
+ }
231
+ const segments = routePath.split("/").filter(Boolean);
232
+ const lastSegment = segments[segments.length - 1] ?? "page";
233
+ return lastSegment.replace(/\[\[(.+?)\]\]/g, "$1").replace(/^:/, "");
234
+ }
235
+ function normalisePath(path) {
236
+ return path.split(sep).join("/");
237
+ }
238
+ function takeTopRoutes(routes, limit) {
239
+ return routes.slice(0, limit);
240
+ }
241
+ function orderDetectors(preferredId) {
242
+ if (!preferredId) {
243
+ return ROUTE_DETECTORS;
244
+ }
245
+ const preferred = ROUTE_DETECTORS.find((detector) => detector.id === preferredId);
246
+ if (!preferred) {
247
+ return ROUTE_DETECTORS;
248
+ }
249
+ const others = ROUTE_DETECTORS.filter((detector) => detector.id !== preferredId);
250
+ return [preferred, ...others];
251
+ }
252
+ function logDetection(options, detectorId, message, context) {
253
+ if (!options.logger) {
254
+ return;
255
+ }
256
+ options.logger.log({
257
+ detectorId,
258
+ message,
259
+ context: {
260
+ root: context?.root ?? options.projectRoot,
261
+ limit: context?.limit,
262
+ candidateCount: context?.candidateCount,
263
+ selectedCount: context?.selectedCount,
264
+ },
265
+ });
266
+ }
267
+ async function findSpaHtml(projectRoot) {
268
+ const candidates = [
269
+ "dist/index.html",
270
+ "build/index.html",
271
+ "public/index.html",
272
+ "index.html",
273
+ ];
274
+ for (const candidate of candidates) {
275
+ const absolute = join(projectRoot, candidate);
276
+ if (await pathExists(absolute)) {
277
+ return absolute;
278
+ }
279
+ }
280
+ return undefined;
281
+ }
282
+ function extractRoutesFromHtml(html) {
283
+ const routes = [];
284
+ const seen = new Set();
285
+ const hrefPattern = /href\s*=\s*"(\/[^"]*)"/gi;
286
+ const dataRoutePattern = /data-route\s*=\s*"(\/[^"]*)"/gi;
287
+ const addRoute = (raw) => {
288
+ const base = raw.split(/[?#]/)[0];
289
+ if (!base || base.length === 0) {
290
+ return;
291
+ }
292
+ const normalized = normaliseRoute(base);
293
+ if (!seen.has(normalized)) {
294
+ seen.add(normalized);
295
+ routes.push(normalized);
296
+ }
297
+ };
298
+ let match;
299
+ while ((match = hrefPattern.exec(html)) !== null) {
300
+ addRoute(match[1]);
301
+ }
302
+ while ((match = dataRoutePattern.exec(html)) !== null) {
303
+ addRoute(match[1]);
304
+ }
305
+ return routes;
306
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,276 @@
1
+ import { access, writeFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import prompts from "prompts";
4
+ import { detectRoutes } from "./route-detectors.js";
5
+ import { pathExists } from "./fs-utils.js";
6
+ const PROFILE_TO_DETECTOR = {
7
+ "next-app": "next-app",
8
+ "next-pages": "next-pages",
9
+ spa: "spa-html",
10
+ remix: "remix-routes",
11
+ custom: undefined,
12
+ };
13
+ const DEFAULT_BASE_URL = "http://localhost:3000";
14
+ const DEFAULT_CHROME_PORT = 9222;
15
+ const DEFAULT_RUNS = 1;
16
+ const DEFAULT_PROJECT_ROOT = "..";
17
+ const DEFAULT_PRESELECT_COUNT = 5;
18
+ const DEFAULT_DEVICES = ["mobile", "desktop"];
19
+ const PROMPT_OPTIONS = { onCancel: handleCancel };
20
+ const profileQuestion = {
21
+ type: "select",
22
+ name: "profile",
23
+ message: "Which project type are you configuring?",
24
+ choices: [
25
+ { title: "Next.js (App Router)", value: "next-app" },
26
+ { title: "Next.js (Pages Router)", value: "next-pages" },
27
+ { title: "Remix", value: "remix" },
28
+ { title: "Single Page App (Vite/CRA/etc.)", value: "spa" },
29
+ { title: "Custom/manual", value: "custom" },
30
+ ],
31
+ };
32
+ const overwriteQuestion = {
33
+ type: "confirm",
34
+ name: "value",
35
+ message: "Found existing config. Overwrite?",
36
+ initial: false,
37
+ };
38
+ const baseQuestions = [
39
+ {
40
+ type: "text",
41
+ name: "baseUrl",
42
+ message: "Base URL of the running app",
43
+ initial: DEFAULT_BASE_URL,
44
+ validate: (value) => (value.startsWith("http") ? true : "Enter a full http(s) URL."),
45
+ },
46
+ {
47
+ type: "text",
48
+ name: "query",
49
+ message: "Query string appended to every route (optional)",
50
+ initial: "",
51
+ },
52
+ {
53
+ type: "number",
54
+ name: "chromePort",
55
+ message: "Chrome remote debugging port",
56
+ initial: DEFAULT_CHROME_PORT,
57
+ min: 1,
58
+ },
59
+ {
60
+ type: "number",
61
+ name: "runs",
62
+ message: "Number of Lighthouse runs per page/device",
63
+ initial: DEFAULT_RUNS,
64
+ min: 1,
65
+ },
66
+ ];
67
+ const pageQuestions = [
68
+ {
69
+ type: "text",
70
+ name: "path",
71
+ message: "Page path (must start with /)",
72
+ validate: (value) => (value.startsWith("/") ? true : "Path must start with '/'."),
73
+ },
74
+ {
75
+ type: "text",
76
+ name: "label",
77
+ message: "Short label for reports",
78
+ },
79
+ {
80
+ type: "multiselect",
81
+ name: "devices",
82
+ message: "Devices to audit",
83
+ instructions: false,
84
+ min: 1,
85
+ choices: [
86
+ { title: "Mobile", value: "mobile", selected: true },
87
+ { title: "Desktop", value: "desktop", selected: true },
88
+ ],
89
+ },
90
+ ];
91
+ const addFirstPageQuestion = {
92
+ type: "confirm",
93
+ name: "value",
94
+ message: "Add your first page to audit?",
95
+ initial: true,
96
+ };
97
+ const addMorePagesQuestion = {
98
+ type: "confirm",
99
+ name: "value",
100
+ message: "Add another page to audit?",
101
+ initial: false,
102
+ };
103
+ const detectRoutesQuestion = {
104
+ type: "confirm",
105
+ name: "value",
106
+ message: "Attempt to auto-detect routes from your project?",
107
+ initial: true,
108
+ };
109
+ const projectRootQuestion = {
110
+ type: "text",
111
+ name: "projectRoot",
112
+ message: "Path to your web project root (relative or absolute)",
113
+ initial: DEFAULT_PROJECT_ROOT,
114
+ };
115
+ const detectorChoiceQuestion = {
116
+ type: "select",
117
+ name: "detector",
118
+ message: "Choose a detector to guide auto-discovery",
119
+ choices: [
120
+ { title: "Next.js (App Router)", value: "next-app" },
121
+ { title: "Next.js (Pages Router)", value: "next-pages" },
122
+ { title: "Remix", value: "remix-routes" },
123
+ { title: "SPA Crawl", value: "spa-html" },
124
+ ],
125
+ };
126
+ function handleCancel() {
127
+ console.log("Wizard cancelled. No config written.");
128
+ process.exit(1);
129
+ return true;
130
+ }
131
+ async function ask(question) {
132
+ const answers = await prompts(question, PROMPT_OPTIONS);
133
+ return answers;
134
+ }
135
+ function parseArgs(argv) {
136
+ let configPath;
137
+ for (let index = 2; index < argv.length; index += 1) {
138
+ const arg = argv[index];
139
+ if ((arg === "--config" || arg === "-c") && index + 1 < argv.length) {
140
+ configPath = argv[index + 1];
141
+ index += 1;
142
+ }
143
+ }
144
+ return { configPath: configPath ?? "apex.config.json" };
145
+ }
146
+ async function fileExists(path) {
147
+ try {
148
+ await access(path);
149
+ return true;
150
+ }
151
+ catch {
152
+ return false;
153
+ }
154
+ }
155
+ async function ensureWritable(path) {
156
+ if (!(await fileExists(path))) {
157
+ return;
158
+ }
159
+ const response = await ask(overwriteQuestion);
160
+ if (response.value) {
161
+ return;
162
+ }
163
+ console.log("Aborted. Existing config preserved.");
164
+ process.exit(0);
165
+ }
166
+ async function collectBaseAnswers() {
167
+ const answers = await ask(baseQuestions);
168
+ return {
169
+ baseUrl: answers.baseUrl.trim(),
170
+ query: answers.query && answers.query.length > 0 ? answers.query : undefined,
171
+ chromePort: answers.chromePort,
172
+ runs: answers.runs,
173
+ };
174
+ }
175
+ async function confirmAddPage(hasPages) {
176
+ const question = hasPages ? addMorePagesQuestion : addFirstPageQuestion;
177
+ const response = await ask(question);
178
+ return response.value;
179
+ }
180
+ async function collectSinglePage() {
181
+ const answers = await ask(pageQuestions);
182
+ const label = answers.label && answers.label.length > 0 ? answers.label : answers.path;
183
+ return { path: answers.path, label, devices: answers.devices };
184
+ }
185
+ async function collectPages(initialPages) {
186
+ const pages = [...initialPages];
187
+ while (true) {
188
+ const shouldAdd = await confirmAddPage(pages.length > 0);
189
+ if (!shouldAdd) {
190
+ if (pages.length === 0) {
191
+ console.log("At least one page is required.");
192
+ continue;
193
+ }
194
+ return pages;
195
+ }
196
+ pages.push(await collectSinglePage());
197
+ }
198
+ }
199
+ async function maybeDetectPages(profile) {
200
+ const shouldDetect = await ask(detectRoutesQuestion);
201
+ if (!shouldDetect.value) {
202
+ return [];
203
+ }
204
+ const preferredDetector = await selectDetector(profile);
205
+ const projectRootAnswer = await ask(projectRootQuestion);
206
+ const absoluteRoot = resolve(projectRootAnswer.projectRoot);
207
+ if (!(await pathExists(absoluteRoot))) {
208
+ console.log(`No project found at ${absoluteRoot}. Skipping auto-detection.`);
209
+ return [];
210
+ }
211
+ const routes = await detectRoutes({ projectRoot: absoluteRoot, preferredDetectorId: preferredDetector });
212
+ if (routes.length === 0) {
213
+ console.log("No routes detected. Add pages manually.");
214
+ return [];
215
+ }
216
+ return selectDetectedRoutes(routes);
217
+ }
218
+ async function selectDetector(profile) {
219
+ const preset = PROFILE_TO_DETECTOR[profile];
220
+ if (preset) {
221
+ return preset;
222
+ }
223
+ const choice = await ask(detectorChoiceQuestion);
224
+ return choice.detector;
225
+ }
226
+ async function selectDetectedRoutes(routes) {
227
+ const response = await ask({
228
+ type: "multiselect",
229
+ name: "indexes",
230
+ message: "Select routes to include",
231
+ instructions: false,
232
+ min: 1,
233
+ choices: buildRouteChoices(routes),
234
+ });
235
+ const indexes = response.indexes ?? [];
236
+ if (indexes.length === 0) {
237
+ console.log("No routes selected. Add pages manually.");
238
+ return [];
239
+ }
240
+ return indexes.map((index) => convertRouteToPage(routes[index]));
241
+ }
242
+ function buildRouteChoices(routes) {
243
+ return routes.map((route, index) => ({
244
+ title: `${route.path} (${route.source})`,
245
+ value: index,
246
+ selected: index < DEFAULT_PRESELECT_COUNT,
247
+ }));
248
+ }
249
+ function convertRouteToPage(route) {
250
+ return {
251
+ path: route.path,
252
+ label: route.label,
253
+ devices: DEFAULT_DEVICES,
254
+ };
255
+ }
256
+ async function buildConfig() {
257
+ const profileAnswer = await ask(profileQuestion);
258
+ const baseAnswers = await collectBaseAnswers();
259
+ const detectedPages = await maybeDetectPages(profileAnswer.profile);
260
+ const pages = await collectPages(detectedPages);
261
+ return {
262
+ baseUrl: baseAnswers.baseUrl,
263
+ query: baseAnswers.query,
264
+ chromePort: baseAnswers.chromePort,
265
+ runs: baseAnswers.runs,
266
+ pages,
267
+ };
268
+ }
269
+ export async function runWizardCli(argv) {
270
+ const args = parseArgs(argv);
271
+ const absolutePath = resolve(args.configPath);
272
+ await ensureWritable(absolutePath);
273
+ const config = await buildConfig();
274
+ await writeFile(absolutePath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
275
+ console.log(`Saved ApexAuditor config to ${absolutePath}`);
276
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "apex-auditor",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "CLI to run structured Lighthouse audits (Performance, Accessibility, Best Practices, SEO) across routes.",
6
+ "type": "module",
7
+ "files": [
8
+ "dist",
9
+ "README.md"
10
+ ],
11
+ "bin": {
12
+ "apex-auditor": "./dist/bin.js"
13
+ },
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.build.json",
16
+ "audit": "tsx src/bin.ts audit",
17
+ "wizard": "tsx src/bin.ts wizard",
18
+ "test": "vitest run"
19
+ },
20
+ "dependencies": {
21
+ "lighthouse": "^12.6.1",
22
+ "prompts": "^2.4.2"
23
+ },
24
+ "devDependencies": {
25
+ "tsx": "^4.20.6",
26
+ "typescript": "^5.9.3",
27
+ "@types/node": "^22.0.0",
28
+ "@types/prompts": "^2.4.9",
29
+ "vitest": "^1.3.1"
30
+ }
31
+ }