@majordigital/create-acorn 1.5.9 → 1.6.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.
@@ -89,6 +89,52 @@ function runCommand(cmd, args, options = {}) {
89
89
  });
90
90
  }
91
91
 
92
+ async function setupPreDeploy() {
93
+ const pkgPath = join(process.cwd(), 'package.json');
94
+ let pkg;
95
+ try {
96
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
97
+ } catch {
98
+ console.log('Warning: Could not read package.json — skipping pre-deploy setup.');
99
+ return;
100
+ }
101
+
102
+ const alreadyInstalled =
103
+ pkg.scripts?.['lighthouse'] === 'node scripts/lighthouse-audit.mjs' &&
104
+ existsSync(join(process.cwd(), 'scripts', 'lighthouse-audit.mjs'));
105
+
106
+ if (alreadyInstalled) {
107
+ console.log('Pre-deploy tools already installed — skipping.');
108
+ console.log('');
109
+ return;
110
+ }
111
+
112
+ const __dirname = dirname(fileURLToPath(import.meta.url));
113
+ const scriptsSrc = join(__dirname, '..', 'template', 'scripts');
114
+ const scriptsDest = join(process.cwd(), 'scripts');
115
+ mkdirSync(scriptsDest, { recursive: true });
116
+ cpSync(scriptsSrc, scriptsDest, { recursive: true, force: true });
117
+ console.log('Pre-deploy scripts copied to scripts/.');
118
+
119
+ console.log('Installing pre-deploy tools (Lighthouse, Playwright, linkinator)...');
120
+ await runCommand('npm', ['install', '--save-dev', '--legacy-peer-deps',
121
+ 'lighthouse', 'chrome-launcher', 'playwright', 'linkinator'
122
+ ]);
123
+ console.log('');
124
+
125
+ try {
126
+ if (!pkg.scripts) pkg.scripts = {};
127
+ pkg.scripts['lighthouse'] = 'node scripts/lighthouse-audit.mjs';
128
+ pkg.scripts['check-links'] = 'node scripts/check-links.mjs';
129
+ pkg.scripts['cross-browser'] = 'node scripts/cross-browser-check.mjs';
130
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
131
+ console.log('Added pre-deploy scripts to package.json.');
132
+ } catch {
133
+ console.log('Warning: Could not add pre-deploy scripts to package.json.');
134
+ }
135
+ console.log('');
136
+ }
137
+
92
138
  async function setupNextApp() {
93
139
  console.log('Setting up Next.js 15 project...');
94
140
  console.log('');
@@ -150,6 +196,8 @@ async function setupNextApp() {
150
196
  console.log('');
151
197
  console.log('Biome, commitlint, and lefthook configured.');
152
198
  console.log('');
199
+
200
+ await setupPreDeploy();
153
201
  }
154
202
 
155
203
  async function setupPrismic(projectName) {
@@ -1050,6 +1098,15 @@ The primary contact for this project is [Davs Howard](mailto:davs@majordigital.c
1050
1098
  async function main() {
1051
1099
  printHeader();
1052
1100
 
1101
+ // Standalone pre-deploy install — run in the current directory, no full setup needed
1102
+ if (argv.includes('--add-pre-deploy')) {
1103
+ console.log('Adding pre-deploy tools to the current project...');
1104
+ console.log('');
1105
+ await setupPreDeploy();
1106
+ console.log('Done. Run /pre-deploy in Claude Code to start a pre-deployment validation.');
1107
+ return;
1108
+ }
1109
+
1053
1110
  // Prompt for project name first
1054
1111
  const nameFromFlag = parseFlag('name');
1055
1112
  let projectDir = nameFromFlag;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@majordigital/create-acorn",
3
- "version": "1.5.9",
3
+ "version": "1.6.0",
4
4
  "description": "Interactive starter CLI for Acorn with Storyblok/Prismic/DatoCMS, TypeScript, and Tailwind.",
5
5
  "bin": {
6
6
  "create-acorn": "bin/create-acorn.mjs",
@@ -0,0 +1,319 @@
1
+ ---
2
+ description: "Run a comprehensive pre-deployment validation including code checks, browser tests, and Lighthouse audits"
3
+ ---
4
+
5
+ # Pre-Deploy: Deployment Readiness Validation
6
+
7
+ **User arguments:** $ARGUMENTS
8
+
9
+ **Before starting, check for conflicts:** Run `ls ~/.claude/commands/pre-deploy.md 2>/dev/null` — if that file exists, **stop immediately** and tell the user: "A user-level `~/.claude/commands/pre-deploy.md` exists and will override this project command. Delete it with `rm ~/.claude/commands/pre-deploy.md` and re-run `/pre-deploy`." Do not proceed until the conflict is resolved.
10
+
11
+ **Required scripts:** This command relies on scripts in `scripts/`. If they are missing, stop and tell the user to add them:
12
+ - `scripts/check-links.mjs` — broken link crawler (uses `linkinator`)
13
+ - `scripts/cross-browser-check.mjs` — cross-browser screenshots (uses `playwright`)
14
+ - `scripts/lighthouse-audit.mjs` — Lighthouse audit runner (uses `lighthouse` + `chrome-launcher`)
15
+ - `scripts/cleanup-screenshots.mjs` — removes artifacts older than 1 month
16
+
17
+ These must also be wired up in `package.json`:
18
+ ```json
19
+ "scripts": {
20
+ "lighthouse": "node scripts/lighthouse-audit.mjs",
21
+ "check-links": "node scripts/check-links.mjs",
22
+ "cross-browser": "node scripts/cross-browser-check.mjs"
23
+ }
24
+ ```
25
+
26
+ ### How to run
27
+
28
+ - `/pre-deploy` — Run all 7 phases from scratch (Phase 0 through Phase 6)
29
+ - `/pre-deploy continue` — Read `progress.md` and resume from the first incomplete phase
30
+ - `/pre-deploy continue phase 3` — Resume from Phase 3 specifically
31
+ - `/pre-deploy phase 2` — Re-run only Phase 2, then continue through Phase 6
32
+ - `/pre-deploy continue lighthouse` — Re-run only Phase 3 (Lighthouse), then continue through Phase 6
33
+
34
+ **Tell the user at the start of every run:** "You can stop this process at any time. To resume, type `/pre-deploy continue` and it will pick up where it left off."
35
+
36
+ ### Determining the starting phase
37
+
38
+ 1. If `$ARGUMENTS` contains "continue" with no phase number: read `.claude/pre-deployment-result/progress.md`, find the first phase that is NOT marked "Done", and start from there.
39
+ 2. If `$ARGUMENTS` contains a phase number (e.g., "continue phase 3", "phase 5"): start from that phase.
40
+ 3. If `$ARGUMENTS` contains a phase name (e.g., "continue lighthouse", "continue browser", "continue links"): map to the phase number and start from there:
41
+ - "lighthouse" → Phase 3
42
+ - "browser" → Phase 2
43
+ - "links" → Phase 4
44
+ - "cross-browser" → Phase 5
45
+ - "report" → Phase 6
46
+ 4. If `$ARGUMENTS` is empty: run all phases starting from Phase 0.
47
+
48
+ When resuming, do NOT re-run Phase 0 cleanup — only clean the specific phase directory being re-run (e.g., `rm -rf .claude/pre-deployment-result/lighthouse` before re-running Phase 3). This preserves completed work.
49
+
50
+ ### Progress tracking
51
+
52
+ **At the start and end of every phase, print a progress summary to the user** showing the status of all 7 phases. Use this exact format:
53
+
54
+ ```
55
+ Pre-Deploy Progress
56
+ ━━━━━━━━━━━━━━━━━━━━━
57
+ Phase 0 — Cleanup [Done]
58
+ Phase 1 — Static Code Checks [In Progress]
59
+ Phase 2 — Browser Checks [Not Started]
60
+ Phase 3 — Lighthouse Audit [Not Started]
61
+ Phase 4 — Broken Link Check [Not Started]
62
+ Phase 5 — Cross-Browser [Not Started]
63
+ Phase 6 — Final Report [Not Started]
64
+ ━━━━━━━━━━━━━━━━━━━━━
65
+ ```
66
+
67
+ Also write this status to `.claude/pre-deployment-result/progress.md` after each phase completes, so the user can check progress even in a new conversation.
68
+
69
+ Execute **all 7 phases (Phase 0 through Phase 6)** below and present a final checklist report. Do not skip any phase (unless resuming from a specific phase).
70
+
71
+ ## Phase 0: Cleanup
72
+
73
+ Before generating new artifacts, wipe the phase output directories to start fresh. **Do NOT delete previous reports** — those are kept for historical reference unless the user deletes them manually.
74
+
75
+ ```bash
76
+ rm -rf .claude/pre-deployment-result/pre-deploy-screenshots
77
+ rm -rf .claude/pre-deployment-result/lighthouse
78
+ rm -rf .claude/pre-deployment-result/cross-browser
79
+ rm -f .claude/pre-deployment-result/broken-links.md
80
+ ```
81
+
82
+ This ensures no stale data from previous runs mixes with fresh results, while preserving previous reports (`pre-deploy-report-*.md`) for comparison.
83
+
84
+ ## Phase 1: Static Code Checks
85
+
86
+ Run these checks against the codebase using grep/glob and report pass/fail for each:
87
+
88
+ 1. **No console.logs in production code** — Search `src/` for `console.log` statements. Ignore files in `node_modules/`, config files, and scripts. Report any found with file paths and line numbers.
89
+ 2. **No test/debug third-party tools** — Search for debug-only packages or scripts (e.g., `why-did-you-render`, `react-devtools`, debug toolbars, placeholder analytics IDs like `GTM-XXXX` or `UA-000000`). Check `package.json` for anything that should be in `devDependencies` but is in `dependencies`.
90
+ 3. **No test users or form data** — Search for hardcoded test data, dummy emails (`test@`, `example@`), placeholder usernames, or seed data that shouldn't ship.
91
+ 4. **Favicon exists** — Check that favicon files exist (e.g., `public/favicon.ico`, `app/favicon.ico`, or referenced in the layout/head).
92
+ 5. **robots.txt exists** — Check for `public/robots.txt` or a route that serves it.
93
+ 6. **Sitemap configured** — Check for sitemap generation (e.g., `sitemap.ts`, `sitemap.xml`, or a next-sitemap config).
94
+ 7. **Analytics code present** — Check for GTM, Google Analytics, or other analytics integration. Verify the ID is not a placeholder.
95
+ 8. **Metadata configured** — Check the root layout and page components for title, description, OpenGraph, and Twitter card metadata.
96
+ 9. **Legal pages exist** — Check routes/pages for privacy policy, terms of service, cookie policy, or similar legal content.
97
+ 10. **Webfonts configuration** — Check how fonts are loaded (next/font, Google Fonts link, self-hosted) and whether they're tied to a specific domain.
98
+
99
+ ## Phase 2: Browser Checks
100
+
101
+ **Ask the user for the target URL first.** Do not start a dev server or assume localhost. Ask:
102
+
103
+ > "What URL should I use for browser checks? Options:
104
+ > - Production: `https://[your-production-url]`
105
+ > - Staging: `https://[your-staging-url]`
106
+ > - Local: `http://localhost:3000` (make sure the dev server is already running)
107
+ > - Other: provide a URL"
108
+
109
+ Wait for the user's response before proceeding. Use the provided URL for all browser checks in this phase.
110
+
111
+ Before starting, ensure agent-browser is available:
112
+ ```bash
113
+ agent-browser --version || (npm install -g agent-browser && agent-browser install --with-deps)
114
+ ```
115
+
116
+ ### Verify the correct project is running
117
+
118
+ **Before taking any screenshots, verify the URL is serving the right project.** Check the page `<title>` tag and confirm it matches the expected site:
119
+
120
+ ```bash
121
+ curl -s <target-url> | grep -o '<title>[^<]*</title>'
122
+ ```
123
+
124
+ If the title does NOT match the expected project, **stop immediately** and tell the user: "The site at `<target-url>` appears to be serving a different project (found: [title]). Please check the URL and try again." Do not proceed with screenshots.
125
+
126
+ **IMPORTANT:** After every `agent-browser open <url>`, always run `agent-browser wait --load networkidle` before taking a screenshot. This ensures CSS, fonts, and async content have fully loaded.
127
+
128
+ ### Infrastructure checks
129
+
130
+ 1. **Sitemap accessible** — Open `<target-url>/sitemap.xml`, wait for network idle, screenshot `pre-deploy-screenshots/01-sitemap.png`. Verify valid XML sitemap is returned.
131
+ 2. **robots.txt accessible** — Open `<target-url>/robots.txt`, wait for network idle, screenshot `pre-deploy-screenshots/02-robots.png`. Verify it returns valid content.
132
+ 3. **OG metadata present** — On the homepage, run:
133
+ ```
134
+ agent-browser eval "JSON.stringify({title: document.title, ogTitle: document.querySelector('meta[property=\"og:title\"]')?.content, ogDesc: document.querySelector('meta[property=\"og:description\"]')?.content, ogImage: document.querySelector('meta[property=\"og:image\"]')?.content, twitterCard: document.querySelector('meta[name=\"twitter:card\"]')?.content})"
135
+ ```
136
+ Verify all fields are populated and correct.
137
+ 4. **No console errors** — Run `agent-browser console` and `agent-browser errors` to check for JavaScript errors on key pages.
138
+
139
+ ### E2E page screenshots
140
+
141
+ **Step 1 — Build the page list.** Fetch the sitemap at `<target-url>/sitemap.xml`. Extract all URLs. If there are more than 30 pages, sample intelligently: always include the homepage and 404, then keep all unique route templates while selecting one representative per large repeated group (e.g., one article per content type, one per top-level section).
142
+
143
+ **Log the count before proceeding.** Example: "Pages: 18 pages to screenshot × 2 viewports = 36 screenshots."
144
+
145
+ **Step 2 — Capture every page at both viewports.** For each page: open the URL, wait for network idle, take a full-page screenshot (`--full`), verify the page renders correctly (not blank, no error states).
146
+
147
+ Before capturing, set the viewport:
148
+ ```bash
149
+ # Desktop
150
+ agent-browser set viewport 1440 900
151
+
152
+ # Mobile
153
+ agent-browser set viewport 390 844
154
+ ```
155
+
156
+ Save desktop screenshots to `pre-deploy-screenshots/pages/desktop/` and mobile to `pre-deploy-screenshots/pages/mobile/`.
157
+
158
+ 5. **Homepage** — `<target-url>` → `desktop/01-homepage.png` + `mobile/01-homepage.png`
159
+ 6. **404 page** — `<target-url>/this-page-does-not-exist` → `desktop/02-404-page.png` + `mobile/02-404-page.png`. Verify a styled 404 page renders (not a blank white page or default Next.js error).
160
+ 7. **All remaining core pages** — Screenshot each unique page/template discovered from the sitemap, numbered sequentially. Desktop and mobile for each.
161
+ 8. **Legal pages accessible** — Visit each legal page found in Phase 1, verify they render with styled content.
162
+ 9. **Cookie popup appears (if configured)** — Check if a cookie consent banner appears on first load.
163
+
164
+ **Step 3 — Verify completion.** Count files in `pre-deploy-screenshots/pages/desktop/` and `pre-deploy-screenshots/pages/mobile/`. Both counts must match the page count from Step 1. Capture any missing pages before proceeding.
165
+
166
+ After all checks, close the browser:
167
+ ```bash
168
+ agent-browser close
169
+ ```
170
+
171
+ ## Phase 3: Lighthouse Audit
172
+
173
+ Run Lighthouse audits using `npm run lighthouse`. The script reads the sitemap, selects core pages (all unique templates, 1 sample per large repeated group), runs mobile and desktop audits concurrently, and saves results to `.claude/pre-deployment-result/lighthouse/`.
174
+
175
+ **Use the same URL the user provided in Phase 2.** If Phase 2 was skipped (resuming), ask the user for the URL.
176
+
177
+ ```bash
178
+ npm run lighthouse -- <target-url> .claude/pre-deployment-result/lighthouse
179
+ ```
180
+
181
+ Results:
182
+ - `lighthouse/summary.md` — scores for audited pages
183
+ - `lighthouse/psi-links.md` — PageSpeed Insights links for all pages (for manual checking)
184
+
185
+ ### Monitoring Lighthouse progress
186
+
187
+ Lighthouse is the slowest phase (~15 minutes with 3 concurrent Chrome instances). The script writes a `progress.json` file to the output directory after each batch of audits.
188
+
189
+ **Important (Claude Code UI only):** If the user is on the desktop app or web app, remind them to keep the conversation open while Lighthouse runs. Navigating away or closing the conversation may prevent scheduled wake-ups from firing. This does not apply to the CLI.
190
+
191
+ When checking on Lighthouse progress:
192
+
193
+ 1. **Read `progress.json`** — it shows `status` ("starting" / "running" / "complete"), `currentStrategy`, `completedInStrategy`, `totalInStrategy`, and `lastUpdatedAt`
194
+ 2. **Always tell the user concrete progress** (e.g., "Lighthouse: 8/22 mobile pages done, last updated 30s ago") rather than vague statements like "still running"
195
+ 3. **Never wait more than 5 minutes between checks** — use 270s intervals consistently
196
+ 4. **Detect stalls early:**
197
+ - If `progress.json` doesn't exist or is empty after 2 minutes, the script likely failed to start
198
+ - If `lastUpdatedAt` hasn't changed in over 5 minutes, the script is likely hung
199
+ 5. **When Lighthouse stalls or fails, offer alternatives:**
200
+ - **Option A: Give the user the PageSpeed Insights link for the page that hung** — so they can run that specific audit manually, then continue the report with scores already collected
201
+ - **Option B: Skip and include PSI links for all remaining pages** — produce the final report with scores for completed pages and direct PSI links for the rest
202
+ - **Option C: Retry** — kill the hung process, wipe the output directory, and re-run:
203
+ ```bash
204
+ kill <pid>
205
+ rm -rf .claude/pre-deployment-result/lighthouse
206
+ npm run lighthouse -- <same-base-url> .claude/pre-deployment-result/lighthouse
207
+ ```
208
+ - **Option D: Retry against localhost** — if the stall was a network/rate-limit issue, kill the hung process and re-run against the local dev server
209
+ - Always proceed to Phase 6 (final report) regardless — a report without complete Lighthouse scores is still valuable
210
+ - **Recovering partial results:** Read `.claude/pre-deployment-result/lighthouse/progress.json` — the `allResults` field contains scores collected before the hang. Use these in the final report alongside PSI links for remaining pages.
211
+
212
+ After the script completes, read `lighthouse/summary.md` and include the scores table in the final report. Flag any category scoring below 90. Mention that `lighthouse/psi-links.md` has PSI links for all pages if the user wants to check any page manually.
213
+
214
+ ## Phase 4: Broken Link Check
215
+
216
+ Run the broken link checker to crawl the site and find any broken internal links. **Use the same URL from Phase 2.**
217
+
218
+ ```bash
219
+ npm run check-links -- <target-url> .claude/pre-deployment-result
220
+ ```
221
+
222
+ Include the results in the final report. Any broken links should be flagged as High severity.
223
+
224
+ ## Phase 5: Cross-Browser Compatibility
225
+
226
+ Run cross-browser screenshots across Chromium (Chrome/Edge), Firefox, and WebKit (Safari/Mobile Safari) at both desktop and mobile viewports. **Use the same URL from Phase 2.**
227
+
228
+ ```bash
229
+ npm run cross-browser -- <target-url> .claude/pre-deployment-result/cross-browser
230
+ ```
231
+
232
+ This tests key pages across all browser engines and captures console errors. Review the summary and flag any browser-specific rendering issues.
233
+
234
+ Browser coverage:
235
+ - **Chromium** → Chrome (Win/Mac), Edge (Win/Mac), Chrome (Android/iOS)
236
+ - **Firefox** → Firefox (Win/Mac)
237
+ - **WebKit** → Safari (Mac), Mobile Safari (iOS)
238
+
239
+ ## Phase 6: Final Report
240
+
241
+ Save the report to `.claude/pre-deployment-result/pre-deploy-report-MM-DD-YY.md` using today's date. Use this exact format:
242
+
243
+ ```markdown
244
+ # Pre-Deployment Validation Report
245
+
246
+ **Date:** YYYY-MM-DD
247
+ **Target:** <the URL(s) used for each phase>
248
+
249
+ ---
250
+
251
+ ## Issues Found
252
+
253
+ Collect ALL issues from every phase into a single table, sorted by severity (High first, then Medium, then Low). Include the file path/location where relevant. This section goes first so it's the first thing the reader sees.
254
+
255
+ | Severity | Issue | Location |
256
+ |----------|-------|----------|
257
+ | **High** | description of issue | file path or page |
258
+ | **Medium** | description of issue | file path or page |
259
+ | **Low** | description of issue | file path or page |
260
+
261
+ ---
262
+
263
+ ## Automated Checks (Pass/Fail)
264
+
265
+ ### Phase 1 — Static Code Checks
266
+ - [x] or [ ] each check with details (file paths, what was found)
267
+
268
+ ### Phase 2 — Browser Checks
269
+ - [x] or [ ] each check with details (page counts, screenshot counts, any errors found)
270
+
271
+ ### Phase 3 — Lighthouse Audit (N pages)
272
+
273
+ **Mobile averages:** Performance **X** | Accessibility **X** | Best Practices **X** | SEO **X**
274
+ **Desktop averages:** Performance **X** | Accessibility **X** | Best Practices **X** | SEO **X**
275
+
276
+ Include the scores table from lighthouse/summary.md here.
277
+
278
+ **Flags:**
279
+ - List any categories below 90
280
+
281
+ Full PSI links for all pages: see `lighthouse/psi-links.md`
282
+
283
+ ### Phase 4 — Broken Link Check
284
+ - [x] or [ ] with broken link table (status, URL, occurrences)
285
+
286
+ ### Phase 5 — Cross-Browser Compatibility
287
+ - [x] or [ ] with screenshot counts and any FAIL results
288
+
289
+ ---
290
+
291
+ ## Manual Verification Required
292
+ - [ ] Confirm who will be the site's Webmaster
293
+ - [ ] All page aliases/301 redirects are set up
294
+ - [ ] Cookie popup is appropriate for target regions
295
+ - [ ] Third-party APIs are configured for the production domain (not dev/staging keys)
296
+ - [ ] Third-party webfonts are licensed/configured for the production domain
297
+ - [ ] Required webhooks are set up and configured to trigger builds
298
+
299
+ ## Post-Deployment (within a few hours)
300
+ - [ ] Approve site on Google Webmasters
301
+ - [ ] Check site's visibility with Google
302
+ - [ ] Check analytics are capturing correctly
303
+
304
+ ## Artifacts
305
+
306
+ | Type | Location |
307
+ |------|----------|
308
+ | Infrastructure screenshots | `pre-deploy-screenshots/01-*.png` |
309
+ | Desktop page screenshots | `pre-deploy-screenshots/pages/desktop/` |
310
+ | Mobile page screenshots | `pre-deploy-screenshots/pages/mobile/` |
311
+ | Lighthouse summary | `lighthouse/summary.md` |
312
+ | Lighthouse PSI links | `lighthouse/psi-links.md` |
313
+ | Broken links report | `broken-links.md` |
314
+ | Cross-browser screenshots | `cross-browser/` |
315
+ | Cross-browser summary | `cross-browser/summary.md` |
316
+ | Progress tracker | `progress.md` |
317
+ ```
318
+
319
+ Fill in actual results from each phase. For any failed checks, include details on what was found and where (file paths, line numbers, screenshot references). Group all issues into the "Issues Found" table at the top of the report.
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Broken link checker using linkinator.
5
+ * Crawls the site and reports broken internal links.
6
+ *
7
+ * Usage:
8
+ * node scripts/check-links.mjs <base-url> [output-dir]
9
+ *
10
+ * Example:
11
+ * node scripts/check-links.mjs https://your-project.netlify.app
12
+ * node scripts/check-links.mjs http://localhost:3000 .claude/pre-deployment-result
13
+ */
14
+
15
+ import { LinkChecker } from "linkinator";
16
+ import { writeFileSync, mkdirSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ const baseUrl = process.argv[2] || "http://localhost:3000";
20
+ const outputDir = process.argv[3] || ".claude/pre-deployment-result";
21
+
22
+ mkdirSync(outputDir, { recursive: true });
23
+
24
+ const checker = new LinkChecker();
25
+
26
+ checker.on("link", (result) => {
27
+ if (result.state === "BROKEN") {
28
+ console.log(
29
+ ` BROKEN [${result.status}] ${result.url} (from ${result.parent})`,
30
+ );
31
+ }
32
+ });
33
+
34
+ console.log(`Checking links on: ${baseUrl}`);
35
+ console.log("This may take several minutes...\n");
36
+
37
+ const result = await checker.check({
38
+ path: baseUrl,
39
+ recurse: true,
40
+ concurrency: 10,
41
+ timeout: 30000,
42
+ // Skip external links to avoid false positives from rate limiting
43
+ linksToSkip: [
44
+ "^(?!(" + baseUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "))",
45
+ ],
46
+ });
47
+
48
+ const broken = result.links.filter((l) => l.state === "BROKEN");
49
+ const ok = result.links.filter((l) => l.state === "OK");
50
+ const skipped = result.links.filter((l) => l.state === "SKIPPED");
51
+
52
+ console.log(`\n${"=".repeat(60)}`);
53
+ console.log(`Link Check Results`);
54
+ console.log(`${"=".repeat(60)}`);
55
+ console.log(`Total links checked: ${result.links.length}`);
56
+ console.log(`OK: ${ok.length}`);
57
+ console.log(`Broken: ${broken.length}`);
58
+ console.log(`Skipped: ${skipped.length}`);
59
+
60
+ let md = `## Broken Link Check\n\n`;
61
+ md += `Crawled: ${baseUrl}\n\n`;
62
+ md += `| Metric | Count |\n|--------|-------|\n`;
63
+ md += `| Total links | ${result.links.length} |\n`;
64
+ md += `| OK | ${ok.length} |\n`;
65
+ md += `| Broken | ${broken.length} |\n`;
66
+ md += `| Skipped | ${skipped.length} |\n\n`;
67
+
68
+ if (broken.length > 0) {
69
+ const grouped = new Map();
70
+ for (const link of broken) {
71
+ const key = link.url;
72
+ if (!grouped.has(key)) {
73
+ grouped.set(key, { status: link.status, url: link.url, foundOn: new Set() });
74
+ }
75
+ grouped.get(key).foundOn.add(link.parent);
76
+ }
77
+
78
+ md += `### Broken Links (${grouped.size} unique)\n\n`;
79
+ md += `| Status | URL | Found on (${broken.length} total occurrences) |\n|--------|-----|----------|\n`;
80
+ for (const [, entry] of grouped) {
81
+ const pages = [...entry.foundOn];
82
+ const foundOnText =
83
+ pages.length <= 3
84
+ ? pages.join(", ")
85
+ : `${pages.slice(0, 3).join(", ")} + ${pages.length - 3} more pages`;
86
+ md += `| ${entry.status || "N/A"} | ${entry.url} | ${foundOnText} |\n`;
87
+ }
88
+ } else {
89
+ md += `No broken links found.\n`;
90
+ }
91
+
92
+ writeFileSync(join(outputDir, "broken-links.md"), md);
93
+ console.log(`\nReport saved to ${join(outputDir, "broken-links.md")}`);
94
+
95
+ if (broken.length > 0) {
96
+ process.exit(1);
97
+ }
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Deletes pre-deployment artifacts older than 1 month.
5
+ * Covers screenshots, cross-browser results, and lighthouse reports.
6
+ *
7
+ * Usage:
8
+ * node scripts/cleanup-screenshots.mjs [--dry-run] [--list]
9
+ *
10
+ * Options:
11
+ * --dry-run Preview what will be deleted without removing anything
12
+ * --list Show all files with their age (nothing is deleted)
13
+ */
14
+
15
+ import { readdirSync, statSync, rmSync } from "node:fs";
16
+ import { join } from "node:path";
17
+
18
+ const dirs = [
19
+ ".claude/pre-deployment-result/pre-deploy-screenshots",
20
+ ".claude/pre-deployment-result/cross-browser",
21
+ ".claude/pre-deployment-result/lighthouse",
22
+ ];
23
+
24
+ const dryRun = process.argv.includes("--dry-run");
25
+ const listOnly = process.argv.includes("--list");
26
+ const oneMonthMs = 30 * 24 * 60 * 60 * 1000;
27
+ const oneMonthAgo = Date.now() - oneMonthMs;
28
+
29
+ function formatAge(mtimeMs) {
30
+ const ageMs = Date.now() - mtimeMs;
31
+ const days = Math.floor(ageMs / (24 * 60 * 60 * 1000));
32
+ if (days === 0) return "today";
33
+ if (days === 1) return "1 day ago";
34
+ if (days < 30) return `${days} days ago`;
35
+ const months = Math.floor(days / 30);
36
+ return `${months} month${months > 1 ? "s" : ""} ago (${days} days)`;
37
+ }
38
+
39
+ function cleanDir(dir) {
40
+ let entries;
41
+ try {
42
+ entries = readdirSync(dir);
43
+ } catch {
44
+ return { removed: 0, listed: 0 };
45
+ }
46
+
47
+ let removed = 0;
48
+ let listed = 0;
49
+
50
+ for (const entry of entries) {
51
+ const fullPath = join(dir, entry);
52
+ const stat = statSync(fullPath);
53
+
54
+ if (stat.isDirectory()) {
55
+ const result = cleanDir(fullPath);
56
+ removed += result.removed;
57
+ listed += result.listed;
58
+ try {
59
+ const remaining = readdirSync(fullPath);
60
+ if (remaining.length === 0 && !listOnly) {
61
+ if (dryRun) {
62
+ console.log(`[dry-run] rmdir: ${fullPath}`);
63
+ } else {
64
+ rmSync(fullPath);
65
+ console.log(`Removed empty dir: ${fullPath}`);
66
+ }
67
+ }
68
+ } catch {
69
+ // ignore
70
+ }
71
+ continue;
72
+ }
73
+
74
+ const age = formatAge(stat.mtimeMs);
75
+ listed++;
76
+
77
+ if (listOnly) {
78
+ const old = stat.mtimeMs < oneMonthAgo ? " [OLD]" : "";
79
+ console.log(` ${age}${old} ${fullPath}`);
80
+ continue;
81
+ }
82
+
83
+ if (stat.mtimeMs < oneMonthAgo) {
84
+ if (dryRun) {
85
+ console.log(`[dry-run] delete (${age}): ${fullPath}`);
86
+ } else {
87
+ rmSync(fullPath);
88
+ console.log(`Deleted (${age}): ${fullPath}`);
89
+ }
90
+ removed++;
91
+ }
92
+ }
93
+
94
+ return { removed, listed };
95
+ }
96
+
97
+ let totalRemoved = 0;
98
+ let totalListed = 0;
99
+
100
+ for (const dir of dirs) {
101
+ console.log(`\nScanning: ${dir}`);
102
+ const { removed, listed } = cleanDir(dir);
103
+ totalRemoved += removed;
104
+ totalListed += listed;
105
+ }
106
+
107
+ if (listOnly) {
108
+ console.log(`\nTotal files: ${totalListed}`);
109
+ } else {
110
+ console.log(
111
+ `\n${dryRun ? "[dry-run] Would delete" : "Deleted"} ${totalRemoved} file(s) older than 1 month.`,
112
+ );
113
+ }
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Cross-browser screenshot and compatibility check.
5
+ * Takes screenshots across Chromium (Chrome/Edge), Firefox, and WebKit (Safari).
6
+ *
7
+ * Usage:
8
+ * node scripts/cross-browser-check.mjs <base-url> [output-dir]
9
+ *
10
+ * Example:
11
+ * node scripts/cross-browser-check.mjs https://your-project.netlify.app
12
+ * node scripts/cross-browser-check.mjs http://localhost:3000 .claude/pre-deployment-result/cross-browser
13
+ */
14
+
15
+ import { chromium, firefox, webkit } from "playwright";
16
+ import { writeFileSync, mkdirSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ const baseUrl = process.argv[2] || "http://localhost:3000";
20
+ const outputDir = process.argv[3] || ".claude/pre-deployment-result/cross-browser";
21
+
22
+ // Browsers to test — covers the full support matrix:
23
+ // Chromium → Chrome (Win/Mac), Edge (Win/Mac), Chrome (Android/iOS)
24
+ // Firefox → Firefox (Win/Mac)
25
+ // WebKit → Safari (Mac), Mobile Safari (iOS)
26
+ const browsers = [
27
+ { name: "chromium", engine: chromium },
28
+ { name: "firefox", engine: firefox },
29
+ { name: "webkit", engine: webkit },
30
+ ];
31
+
32
+ const viewports = [
33
+ { name: "desktop", width: 1440, height: 900, isMobile: false },
34
+ { name: "mobile", width: 390, height: 844, isMobile: true },
35
+ ];
36
+
37
+ /**
38
+ * Fetches all URLs from sitemap.xml and returns a representative sample.
39
+ * Always includes homepage and 404. For large sitemaps, samples 1 page
40
+ * per top-level section to keep the test suite manageable.
41
+ */
42
+ async function discoverPages(base) {
43
+ const sitemapUrl = `${base}/sitemap.xml`;
44
+ console.log(`Fetching sitemap: ${sitemapUrl}`);
45
+
46
+ try {
47
+ const res = await fetch(sitemapUrl);
48
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
49
+ const xml = await res.text();
50
+
51
+ const urls = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
52
+ if (urls.length === 0) throw new Error("No URLs found in sitemap");
53
+
54
+ const allPages = urls.map((raw) => {
55
+ const path = new URL(raw).pathname;
56
+ const name =
57
+ path === "/"
58
+ ? "homepage"
59
+ : path.replace(/^\/|\/$/g, "").replaceAll("/", "--");
60
+ return { name, path };
61
+ });
62
+
63
+ // For large sitemaps (>30 pages), sample: keep all shallow pages
64
+ // (depth 1-2) plus 1 page per top-level section for deeper routes.
65
+ let pages;
66
+ if (allPages.length <= 30) {
67
+ pages = allPages;
68
+ } else {
69
+ const seenSections = new Set();
70
+ pages = allPages.filter((p) => {
71
+ const segments = p.path.replace(/^\/|\/$/g, "").split("/");
72
+ if (segments.length <= 2) return true; // always include shallow pages
73
+ const section = segments[0];
74
+ if (seenSections.has(section)) return false;
75
+ seenSections.add(section);
76
+ return true;
77
+ });
78
+ console.log(
79
+ `Large sitemap: sampled ${pages.length} of ${allPages.length} pages (1 per section for deep routes)\n`,
80
+ );
81
+ }
82
+
83
+ // Always include 404
84
+ pages.push({ name: "404", path: "/this-page-does-not-exist" });
85
+
86
+ console.log(`Testing ${pages.length} pages\n`);
87
+ return pages;
88
+ } catch (err) {
89
+ console.warn(`Could not fetch sitemap (${err.message}), using fallback pages`);
90
+ return [
91
+ { name: "homepage", path: "/" },
92
+ { name: "404", path: "/this-page-does-not-exist" },
93
+ ];
94
+ }
95
+ }
96
+
97
+ const pages = await discoverPages(baseUrl);
98
+ const results = [];
99
+
100
+ for (const { name: browserName, engine } of browsers) {
101
+ console.log(`\n${"=".repeat(60)}`);
102
+ console.log(`Testing: ${browserName}`);
103
+ console.log(`${"=".repeat(60)}`);
104
+
105
+ const browser = await engine.launch({ headless: true });
106
+
107
+ for (const viewport of viewports) {
108
+ const dirPath = join(outputDir, `${browserName}-${viewport.name}`);
109
+ mkdirSync(dirPath, { recursive: true });
110
+
111
+ const contextOptions = {
112
+ viewport: { width: viewport.width, height: viewport.height },
113
+ };
114
+ // Firefox doesn't support isMobile
115
+ if (viewport.isMobile && browserName !== "firefox") {
116
+ contextOptions.isMobile = true;
117
+ }
118
+ const context = await browser.newContext(contextOptions);
119
+ const page = await context.newPage();
120
+
121
+ const consoleErrors = [];
122
+ page.on("console", (msg) => {
123
+ if (msg.type() === "error") consoleErrors.push(msg.text());
124
+ });
125
+ page.on("pageerror", (err) => {
126
+ consoleErrors.push(err.message);
127
+ });
128
+
129
+ for (const { name: pageName, path } of pages) {
130
+ const url = `${baseUrl}${path}`;
131
+ console.log(` [${browserName}/${viewport.name}] ${pageName}: ${url}`);
132
+
133
+ try {
134
+ const response = await page.goto(url, {
135
+ waitUntil: "networkidle",
136
+ timeout: 30000,
137
+ });
138
+
139
+ const status = response?.status() || 0;
140
+
141
+ const label = `${browserName} — ${viewport.name} (${viewport.width}x${viewport.height})`;
142
+ await page.evaluate((text) => {
143
+ const banner = document.createElement("div");
144
+ banner.textContent = text;
145
+ banner.style.cssText =
146
+ "position:fixed;top:0;left:0;right:0;z-index:999999;" +
147
+ "background:#1a1a2e;color:#e0e0e0;font:bold 14px/1 monospace;" +
148
+ "padding:8px 16px;text-align:center;letter-spacing:0.5px;";
149
+ document.body.prepend(banner);
150
+ }, label);
151
+
152
+ await page.screenshot({
153
+ path: join(dirPath, `${pageName}.png`),
154
+ fullPage: true,
155
+ });
156
+
157
+ const errors = [...consoleErrors];
158
+ consoleErrors.length = 0;
159
+
160
+ results.push({
161
+ browser: browserName,
162
+ viewport: viewport.name,
163
+ page: pageName,
164
+ url,
165
+ status,
166
+ errors,
167
+ ok: true,
168
+ });
169
+
170
+ if (errors.length > 0) {
171
+ console.log(` Console errors: ${errors.length}`);
172
+ }
173
+ } catch (err) {
174
+ results.push({
175
+ browser: browserName,
176
+ viewport: viewport.name,
177
+ page: pageName,
178
+ url,
179
+ status: 0,
180
+ errors: [err.message],
181
+ ok: false,
182
+ });
183
+ console.log(` FAILED: ${err.message}`);
184
+ }
185
+ }
186
+
187
+ await context.close();
188
+ }
189
+
190
+ await browser.close();
191
+ }
192
+
193
+ let md = `## Cross-Browser Compatibility Check\n\n`;
194
+ md += `Tested ${pages.length} pages across ${browsers.length} browsers (desktop + mobile).\n\n`;
195
+
196
+ md += `### Browser Coverage\n\n`;
197
+ md += `| Browser Engine | Covers |\n|----------------|--------|\n`;
198
+ md += `| Chromium | Chrome (Win/Mac), Edge (Win/Mac), Chrome (Android/iOS) |\n`;
199
+ md += `| Firefox | Firefox (Win/Mac) |\n`;
200
+ md += `| WebKit | Safari (Mac), Mobile Safari (iOS) |\n\n`;
201
+
202
+ md += `### Results\n\n`;
203
+ md += `| Browser | Viewport | Page | Status | Console Errors | Screenshot |\n`;
204
+ md += `|---------|----------|------|--------|----------------|------------|\n`;
205
+
206
+ for (const r of results) {
207
+ const statusIcon = r.ok ? (r.status === 200 || r.status === 404 ? "OK" : `${r.status}`) : "FAIL";
208
+ const errorCount = r.errors.length > 0 ? `${r.errors.length} error(s)` : "None";
209
+ const screenshot = `${r.browser}-${r.viewport}/${r.page}.png`;
210
+ md += `| ${r.browser} | ${r.viewport} | ${r.page} | ${statusIcon} | ${errorCount} | ${screenshot} |\n`;
211
+ }
212
+
213
+ const withErrors = results.filter((r) => r.errors.length > 0);
214
+ if (withErrors.length > 0) {
215
+ md += `\n### Console Errors Detail\n\n`;
216
+ for (const r of withErrors) {
217
+ md += `**${r.browser}/${r.viewport} — ${r.page}**\n`;
218
+ for (const err of r.errors) {
219
+ md += `- \`${err.slice(0, 200)}\`\n`;
220
+ }
221
+ md += `\n`;
222
+ }
223
+ }
224
+
225
+ const failedInSome = pages
226
+ .map((p) => {
227
+ const pageResults = results.filter((r) => r.page === p.name);
228
+ const failedBrowsers = pageResults.filter((r) => !r.ok);
229
+ return { page: p.name, failedBrowsers };
230
+ })
231
+ .filter((p) => p.failedBrowsers.length > 0 && p.failedBrowsers.length < results.filter((r) => r.page === p.page).length);
232
+
233
+ if (failedInSome.length > 0) {
234
+ md += `\n### Browser-Specific Issues\n\n`;
235
+ for (const p of failedInSome) {
236
+ const browserList = p.failedBrowsers.map((r) => `${r.browser}/${r.viewport}`).join(", ");
237
+ md += `- **${p.page}** fails in: ${browserList}\n`;
238
+ }
239
+ }
240
+
241
+ md += `\nScreenshots saved to \`${outputDir}/\`\n`;
242
+
243
+ writeFileSync(join(outputDir, "summary.md"), md);
244
+ console.log(`\nSummary saved to ${join(outputDir, "summary.md")}`);
@@ -0,0 +1,319 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Lighthouse audit script for pre-deployment validation.
5
+ * Runs both mobile and desktop audits on pages discovered from the sitemap.
6
+ *
7
+ * Usage:
8
+ * node scripts/lighthouse-audit.mjs <base-url> [output-dir] [--all] [--concurrency=N]
9
+ *
10
+ * Options:
11
+ * --all Audit ALL pages from sitemap instead of core sample
12
+ * --concurrency=N Number of concurrent Chrome instances (default: 3)
13
+ *
14
+ * By default (core mode), audits a representative sample:
15
+ * - All pages at depth 1-2 (e.g. /, /about, /blog)
16
+ * - 1 page per top-level section for deeper routes (e.g. one /blog/* post)
17
+ *
18
+ * Examples:
19
+ * node scripts/lighthouse-audit.mjs https://your-project.netlify.app
20
+ * node scripts/lighthouse-audit.mjs http://localhost:3000 .claude/pre-deployment-result/lighthouse
21
+ * node scripts/lighthouse-audit.mjs https://your-project.netlify.app --all
22
+ */
23
+
24
+ import { launch } from "chrome-launcher";
25
+ import lighthouse from "lighthouse";
26
+ import { writeFileSync, mkdirSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { fork } from "node:child_process";
29
+ import { fileURLToPath } from "node:url";
30
+
31
+ // Worker mode: run a single audit in a child process and report back
32
+ if (process.argv.includes("--worker")) {
33
+ process.on("message", async (msg) => {
34
+ try {
35
+ const chrome = await launch({ chromeFlags: ["--headless", "--no-sandbox"] });
36
+ const result = await lighthouse(msg.url, {
37
+ port: chrome.port,
38
+ output: "json",
39
+ onlyCategories: msg.categories,
40
+ formFactor: msg.formFactor,
41
+ screenEmulation: msg.screenEmulation,
42
+ });
43
+ await chrome.kill();
44
+
45
+ const scores = {};
46
+ for (const cat of msg.categories) {
47
+ scores[cat] = Math.round((result.lhr.categories[cat]?.score || 0) * 100);
48
+ }
49
+ process.send({ scores });
50
+ } catch (err) {
51
+ process.send({ error: err.message });
52
+ }
53
+ process.exit(0);
54
+ });
55
+ } else {
56
+
57
+ const args = process.argv.slice(2);
58
+ const flags = args.filter((a) => a.startsWith("--"));
59
+ const positional = args.filter((a) => !a.startsWith("--"));
60
+
61
+ const baseUrl = positional[0] || "http://localhost:3000";
62
+ const outputDir = positional[1] || ".claude/pre-deployment-result/lighthouse";
63
+ const auditAll = flags.includes("--all");
64
+ const coreOnly = !auditAll;
65
+ const concurrency = Number.parseInt(
66
+ flags.find((f) => f.startsWith("--concurrency="))?.split("=")[1] || "3",
67
+ 10,
68
+ );
69
+
70
+ const categories = ["performance", "accessibility", "best-practices", "seo"];
71
+
72
+ const strategies = [
73
+ {
74
+ name: "Mobile",
75
+ formFactor: "mobile",
76
+ screenEmulation: { mobile: true, width: 412, height: 823, deviceScaleFactor: 1.75 },
77
+ },
78
+ {
79
+ name: "Desktop",
80
+ formFactor: "desktop",
81
+ screenEmulation: { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1 },
82
+ },
83
+ ];
84
+
85
+ /**
86
+ * Groups a path by its top-level section for sampling.
87
+ * Depth 0-2: treated as unique pages (always audited in core mode).
88
+ * Depth 3+: grouped by top-level segment (1 audited per group in core mode).
89
+ */
90
+ function classifyRoute(path) {
91
+ const segments = path.replace(/^\/|\/$/g, "").split("/").filter(Boolean);
92
+ if (segments.length === 0) return { depth: 0, group: "homepage" };
93
+ if (segments.length <= 2) return { depth: segments.length, group: path };
94
+ return { depth: segments.length, group: segments[0] };
95
+ }
96
+
97
+ /**
98
+ * Fetches all URLs from sitemap.xml, rebases them to the target URL,
99
+ * and returns both the core sample and the full page list.
100
+ */
101
+ async function discoverPages(base) {
102
+ const sitemapUrl = `${base}/sitemap.xml`;
103
+ console.log(`Fetching sitemap: ${sitemapUrl}`);
104
+
105
+ try {
106
+ const res = await fetch(sitemapUrl);
107
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
108
+ const xml = await res.text();
109
+
110
+ const rawUrls = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
111
+ if (rawUrls.length === 0) throw new Error("No URLs found in sitemap");
112
+
113
+ console.log(`Found ${rawUrls.length} pages in sitemap`);
114
+
115
+ const allPages = rawUrls.map((raw) => {
116
+ const path = new URL(raw).pathname;
117
+ const url = `${base}${path}`;
118
+ const name =
119
+ path === "/"
120
+ ? "homepage"
121
+ : path.replace(/^\/|\/$/g, "").replaceAll("/", "--");
122
+ return { name, url, path };
123
+ });
124
+
125
+ if (auditAll) {
126
+ console.log(`Auditing all ${allPages.length} pages\n`);
127
+ return { audited: allPages, all: allPages };
128
+ }
129
+
130
+ // Core mode: all shallow pages + 1 per top-level section for deeper routes
131
+ const seenGroups = new Set();
132
+ const corePages = allPages.filter((p) => {
133
+ const { depth, group } = classifyRoute(p.path);
134
+ if (depth <= 2) return true;
135
+ if (seenGroups.has(group)) return false;
136
+ seenGroups.add(group);
137
+ return true;
138
+ });
139
+
140
+ console.log(`\nCore mode: ${corePages.length} pages to audit (of ${allPages.length} total)`);
141
+ for (const p of corePages) console.log(` ${p.path}`);
142
+ console.log(`\nFull PSI links for all ${allPages.length} pages will be in the report.\n`);
143
+
144
+ return { audited: corePages, all: allPages };
145
+ } catch (err) {
146
+ console.warn(`Could not fetch sitemap (${err.message}), falling back to homepage only`);
147
+ const fallback = [{ name: "homepage", url: base, path: "/" }];
148
+ return { audited: fallback, all: fallback };
149
+ }
150
+ }
151
+
152
+ function psiLink(url, strategy) {
153
+ return `https://pagespeed.web.dev/analysis?url=${encodeURIComponent(url)}&form_factor=${strategy.toLowerCase()}`;
154
+ }
155
+
156
+ function buildTable(results) {
157
+ const header = "| Page | Performance | Accessibility | Best Practices | SEO | Report |";
158
+ const divider = "|------|------------|---------------|----------------|-----|--------|";
159
+ const rows = results
160
+ .map((r) => `| ${r.name} | ${r.scores.performance} | ${r.scores.accessibility} | ${r.scores["best-practices"]} | ${r.scores.seo} | [View](${r.psiLink}) |`)
161
+ .join("\n");
162
+
163
+ const avg = {};
164
+ for (const cat of categories) {
165
+ avg[cat] = Math.round(results.reduce((sum, r) => sum + r.scores[cat], 0) / results.length);
166
+ }
167
+ const avgRow = `| **Average** | **${avg.performance}** | **${avg.accessibility}** | **${avg["best-practices"]}** | **${avg.seo}** | |`;
168
+
169
+ return `${header}\n${divider}\n${rows}\n${avgRow}`;
170
+ }
171
+
172
+ function buildFlaggedSection(results) {
173
+ const flagged = results.filter((r) => categories.some((cat) => r.scores[cat] < 90));
174
+ if (flagged.length === 0) return "";
175
+
176
+ const items = flagged
177
+ .map((r) => {
178
+ const low = categories
179
+ .filter((cat) => r.scores[cat] < 90)
180
+ .map((cat) => `${cat}: ${r.scores[cat]}`)
181
+ .join(", ");
182
+ return `- **${r.name}** — ${low}`;
183
+ })
184
+ .join("\n");
185
+
186
+ return `\n### Pages below 90 in any category\n\n${items}\n`;
187
+ }
188
+
189
+ mkdirSync(outputDir, { recursive: true });
190
+
191
+ const progressFile = join(outputDir, "progress.json");
192
+
193
+ function writeProgress(data, allResults = null, pages = null) {
194
+ const combined = { ...data };
195
+ if (allResults) combined.allResults = allResults;
196
+ if (pages) combined.pages = pages;
197
+ writeFileSync(progressFile, JSON.stringify(combined, null, 2));
198
+ }
199
+
200
+ const { audited: pages, all: allPages } = await discoverPages(baseUrl);
201
+ const startedAt = new Date().toISOString();
202
+
203
+ writeProgress({
204
+ status: "starting",
205
+ totalPages: pages.length,
206
+ strategies: strategies.map((s) => s.name),
207
+ startedAt,
208
+ });
209
+
210
+ const effectiveConcurrency = Math.min(concurrency, pages.length) || 1;
211
+ console.log(`Running audits with concurrency ${effectiveConcurrency} (separate processes)\n`);
212
+
213
+ const thisFile = fileURLToPath(import.meta.url);
214
+
215
+ function auditPageInWorker(page, strategy) {
216
+ return new Promise((resolve, reject) => {
217
+ const child = fork(thisFile, ["--worker"], { silent: true });
218
+
219
+ child.send({
220
+ url: page.url,
221
+ categories,
222
+ formFactor: strategy.formFactor,
223
+ screenEmulation: strategy.screenEmulation,
224
+ });
225
+
226
+ child.on("message", (msg) => {
227
+ if (msg.error) reject(new Error(msg.error));
228
+ else resolve({
229
+ name: page.name,
230
+ url: page.url,
231
+ scores: msg.scores,
232
+ psiLink: psiLink(page.url, strategy.name),
233
+ });
234
+ });
235
+
236
+ child.on("error", reject);
237
+ child.on("exit", (code) => {
238
+ if (code !== 0) reject(new Error(`Worker exited with code ${code}`));
239
+ });
240
+ });
241
+ }
242
+
243
+ async function auditBatch(pages, strategy) {
244
+ const results = [];
245
+ let completed = 0;
246
+
247
+ for (let i = 0; i < pages.length; i += effectiveConcurrency) {
248
+ const batch = pages.slice(i, i + effectiveConcurrency);
249
+ const batchLabel = batch.map((p) => p.name).join(", ");
250
+ console.log(`[${strategy.name}] Batch [${completed + 1}-${completed + batch.length}/${pages.length}]: ${batchLabel}`);
251
+
252
+ const batchResults = await Promise.all(batch.map((page) => auditPageInWorker(page, strategy)));
253
+
254
+ for (const r of batchResults) {
255
+ results.push(r);
256
+ completed++;
257
+ console.log(` ${r.name}: Performance: ${r.scores.performance} | Accessibility: ${r.scores.accessibility} | Best Practices: ${r.scores["best-practices"]} | SEO: ${r.scores.seo}`);
258
+ }
259
+
260
+ writeProgress({
261
+ status: "running",
262
+ currentStrategy: strategy.name,
263
+ completedInStrategy: completed,
264
+ totalInStrategy: pages.length,
265
+ totalPages: pages.length,
266
+ strategies: strategies.map((s) => s.name),
267
+ startedAt,
268
+ lastUpdatedAt: new Date().toISOString(),
269
+ lastPage: batchResults[batchResults.length - 1].name,
270
+ lastPageUrl: batchResults[batchResults.length - 1].url,
271
+ lastScores: batchResults[batchResults.length - 1].scores,
272
+ }, allResults, pages);
273
+ }
274
+
275
+ return results;
276
+ }
277
+
278
+ const allResults = {};
279
+
280
+ for (const strategy of strategies) {
281
+ console.log(`\n${"=".repeat(60)}`);
282
+ console.log(`Running ${strategy.name} audits`);
283
+ console.log(`${"=".repeat(60)}`);
284
+ allResults[strategy.name] = await auditBatch(pages, strategy);
285
+ }
286
+
287
+ const sections = strategies.map((strategy) => {
288
+ const results = allResults[strategy.name];
289
+ const table = buildTable(results);
290
+ const flagged = buildFlaggedSection(results);
291
+ return `## Lighthouse Audit — ${strategy.name}\n\nAudited ${results.length} pages from sitemap.\n\n${table}\n${flagged}`;
292
+ });
293
+
294
+ const auditedNames = new Set(pages.map((p) => p.name));
295
+ const psiRows = allPages.map((p) => {
296
+ const audited = auditedNames.has(p.name) ? "Yes" : "";
297
+ return `| ${p.name} | ${audited} | [Mobile](${psiLink(p.url, "Mobile")}) | [Desktop](${psiLink(p.url, "Desktop")}) |`;
298
+ });
299
+ const psiLinksContent = `# PageSpeed Insights Links — All Pages
300
+
301
+ ${allPages.length} pages total. Audited pages are marked — click any PSI link to check a specific page manually.
302
+
303
+ | Page | Audited | Mobile PSI | Desktop PSI |
304
+ |------|---------|-----------|-------------|
305
+ ${psiRows.join("\n")}
306
+ `;
307
+
308
+ writeFileSync(join(outputDir, "psi-links.md"), psiLinksContent);
309
+
310
+ const summary = sections.join("\n---\n\n") + `\n\n---\n\nFull PSI links for all ${allPages.length} pages: see \`psi-links.md\`\n`;
311
+ writeFileSync(join(outputDir, "summary.md"), summary);
312
+
313
+ writeProgress({ status: "complete", totalPages: pages.length, completedAt: new Date().toISOString() });
314
+
315
+ console.log(`\nSummary saved to ${join(outputDir, "summary.md")}`);
316
+ console.log(`PSI links saved to ${join(outputDir, "psi-links.md")}`);
317
+ console.log("\n" + summary);
318
+
319
+ } // end non-worker mode