@majordigital/create-acorn 1.5.9 → 1.6.1
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 +34 -0
- package/bin/create-acorn.mjs +57 -0
- package/package.json +1 -1
- package/template/.claude/commands/pre-deploy.md +319 -0
- package/template/scripts/check-links.mjs +97 -0
- package/template/scripts/cleanup-screenshots.mjs +113 -0
- package/template/scripts/cross-browser-check.mjs +244 -0
- package/template/scripts/lighthouse-audit.mjs +319 -0
package/README.md
CHANGED
|
@@ -36,6 +36,35 @@ Every Acorn project ships with a carefully curated stack:
|
|
|
36
36
|
- **SVG-as-components** — Inline SVG imports via `@svgr/webpack` for both Webpack and Turbopack
|
|
37
37
|
- **Bundle Analysis** — `@next/bundle-analyzer` ready to go with `ANALYZE=true`
|
|
38
38
|
|
|
39
|
+
## Pre-Deploy Validation
|
|
40
|
+
|
|
41
|
+
Every Acorn project ships with a built-in pre-deployment validation suite. Run it with the `/pre-deploy` slash command in Claude Code to get a full readiness report before going live.
|
|
42
|
+
|
|
43
|
+
The suite covers 7 phases:
|
|
44
|
+
|
|
45
|
+
1. **Static code checks** — console logs, placeholder analytics IDs, missing favicon, robots.txt, sitemap, metadata, and legal pages
|
|
46
|
+
2. **Browser checks** — screenshots every core page at desktop and mobile viewports using agent-browser
|
|
47
|
+
3. **Lighthouse audit** — mobile + desktop scores across Performance, Accessibility, Best Practices, and SEO
|
|
48
|
+
4. **Broken link check** — crawls the full site and reports every broken internal link
|
|
49
|
+
5. **Cross-browser compatibility** — screenshots across Chromium, Firefox, and WebKit
|
|
50
|
+
6. **Final report** — saves a dated markdown report to `.claude/pre-deployment-result/`
|
|
51
|
+
|
|
52
|
+
Results and screenshots are saved to `.claude/pre-deployment-result/` for review and historical comparison.
|
|
53
|
+
|
|
54
|
+
### Adding pre-deploy to an existing project
|
|
55
|
+
|
|
56
|
+
The suite is included automatically in every new project. If you have an existing acorn project that predates v1.6.0, you can add it without re-running the full setup:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# Full new project — pre-deploy included automatically
|
|
60
|
+
npx @majordigital/create-acorn@latest
|
|
61
|
+
|
|
62
|
+
# Add pre-deploy to an existing project only
|
|
63
|
+
npx @majordigital/create-acorn@latest --add-pre-deploy
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The `--add-pre-deploy` flag copies the scripts, installs the dependencies (`lighthouse`, `chrome-launcher`, `playwright`, `linkinator`), and adds the npm scripts to your `package.json`. It skips silently if the tooling is already present.
|
|
67
|
+
|
|
39
68
|
## Acorn Component Architecture
|
|
40
69
|
|
|
41
70
|
The Acorn library follows a layered component hierarchy designed for consistency and reuse across projects:
|
|
@@ -87,6 +116,11 @@ Beyond the component library, every project includes:
|
|
|
87
116
|
| `.env.example` | CMS-specific environment variable template |
|
|
88
117
|
| `.npmrc` | Legacy peer deps for React 19 compatibility |
|
|
89
118
|
| `README.md` | Project-specific documentation based on CMS choice |
|
|
119
|
+
| `scripts/lighthouse-audit.mjs` | Lighthouse audit runner (mobile + desktop, concurrent Chrome workers) |
|
|
120
|
+
| `scripts/cross-browser-check.mjs` | Cross-browser screenshots via Playwright (Chromium, Firefox, WebKit) |
|
|
121
|
+
| `scripts/check-links.mjs` | Broken internal link crawler using linkinator |
|
|
122
|
+
| `scripts/cleanup-screenshots.mjs` | Removes pre-deploy artifacts older than 1 month |
|
|
123
|
+
| `.claude/commands/pre-deploy.md` | `/pre-deploy` slash command for Claude Code |
|
|
90
124
|
|
|
91
125
|
## npm
|
|
92
126
|
|
package/bin/create-acorn.mjs
CHANGED
|
@@ -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
|
@@ -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
|