@lownoise-studio/rendershield 0.3.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +73 -32
  2. package/CONTRIBUTING.md +41 -0
  3. package/README.md +227 -144
  4. package/SECURITY.md +25 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +39 -26
  8. package/dist/cli.js.map +1 -1
  9. package/dist/cliArgs.d.ts +8 -0
  10. package/dist/cliArgs.d.ts.map +1 -0
  11. package/dist/cliArgs.js +56 -0
  12. package/dist/cliArgs.js.map +1 -0
  13. package/dist/commands/build.d.ts +3 -0
  14. package/dist/commands/build.d.ts.map +1 -0
  15. package/dist/commands/build.js +12 -11
  16. package/dist/commands/build.js.map +1 -1
  17. package/dist/commands/init.d.ts +3 -0
  18. package/dist/commands/init.d.ts.map +1 -0
  19. package/dist/commands/init.js +8 -7
  20. package/dist/commands/init.js.map +1 -1
  21. package/dist/commands/verify.d.ts +33 -0
  22. package/dist/commands/verify.d.ts.map +1 -0
  23. package/dist/commands/verify.js +202 -117
  24. package/dist/commands/verify.js.map +1 -1
  25. package/dist/configPath.d.ts +7 -0
  26. package/dist/configPath.d.ts.map +1 -0
  27. package/dist/configPath.js +8 -0
  28. package/dist/configPath.js.map +1 -0
  29. package/dist/core/generateRobots.d.ts +3 -0
  30. package/dist/core/generateRobots.d.ts.map +1 -0
  31. package/dist/core/generateSitemap.d.ts +3 -0
  32. package/dist/core/generateSitemap.d.ts.map +1 -0
  33. package/dist/core/generateWorker.d.ts +3 -0
  34. package/dist/core/generateWorker.d.ts.map +1 -0
  35. package/dist/core/generateWorker.js +75 -75
  36. package/dist/core/generateWorker.js.map +1 -1
  37. package/dist/core/listOutputRoutes.d.ts +4 -0
  38. package/dist/core/listOutputRoutes.d.ts.map +1 -0
  39. package/dist/core/listOutputRoutes.js +40 -0
  40. package/dist/core/listOutputRoutes.js.map +1 -0
  41. package/dist/core/loadConfig.d.ts +5 -0
  42. package/dist/core/loadConfig.d.ts.map +1 -0
  43. package/dist/core/loadConfig.js +108 -58
  44. package/dist/core/loadConfig.js.map +1 -1
  45. package/dist/core/loadMarkdown.d.ts +3 -0
  46. package/dist/core/loadMarkdown.d.ts.map +1 -0
  47. package/dist/core/loadMarkdown.js +4 -3
  48. package/dist/core/loadMarkdown.js.map +1 -1
  49. package/dist/core/renderHtml.d.ts +3 -0
  50. package/dist/core/renderHtml.d.ts.map +1 -0
  51. package/dist/core/renderHtml.js +25 -10
  52. package/dist/core/renderHtml.js.map +1 -1
  53. package/dist/core/validateOutput.d.ts +28 -0
  54. package/dist/core/validateOutput.d.ts.map +1 -0
  55. package/dist/core/validateOutput.js +7 -1
  56. package/dist/core/validateOutput.js.map +1 -1
  57. package/dist/errors.d.ts +14 -0
  58. package/dist/errors.d.ts.map +1 -0
  59. package/dist/errors.js +24 -0
  60. package/dist/errors.js.map +1 -0
  61. package/dist/index.d.ts +22 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +20 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/types.d.ts +53 -0
  66. package/dist/types.d.ts.map +1 -0
  67. package/dist/types.js +1 -1
  68. package/dist/types.js.map +1 -1
  69. package/docs/CONFIG.md +70 -0
  70. package/docs/deploy-cloudflare.md +40 -14
  71. package/package.json +25 -2
  72. package/rendershield.config.schema.json +100 -0
  73. package/src/cli.ts +96 -75
  74. package/src/cliArgs.ts +71 -0
  75. package/src/commands/build.ts +200 -185
  76. package/src/commands/init.ts +8 -8
  77. package/src/commands/verify.ts +451 -236
  78. package/src/configPath.ts +17 -0
  79. package/src/core/generateWorker.ts +97 -97
  80. package/src/core/listOutputRoutes.ts +51 -0
  81. package/src/core/loadConfig.ts +282 -173
  82. package/src/core/loadMarkdown.ts +9 -3
  83. package/src/core/renderHtml.ts +36 -12
  84. package/src/core/validateOutput.ts +335 -328
  85. package/src/errors.ts +48 -0
  86. package/src/index.ts +43 -0
  87. package/src/types.ts +5 -2
@@ -1,236 +1,451 @@
1
- import fs from "fs-extra";
2
- import path from "node:path";
3
- import { loadConfig } from "../core/loadConfig.js";
4
- import { checkPrerenderContract } from "../core/validateOutput.js";
5
-
6
- function joinUrl(base: string, routePath: string): string {
7
- const b = base.endsWith("/") ? base.slice(0, -1) : base;
8
- const p = routePath.startsWith("/") ? routePath : `/${routePath}`;
9
- return b + p;
10
- }
11
-
12
- async function findFirstIndexHtml(outDirAbs: string): Promise<string | null> {
13
- const stack: string[] = [outDirAbs];
14
-
15
- while (stack.length > 0) {
16
- const current = stack.pop() as string;
17
-
18
- let entries: fs.Dirent[];
19
- try {
20
- entries = await fs.readdir(current, { withFileTypes: true });
21
- } catch {
22
- continue;
23
- }
24
-
25
- entries.sort((a, b) => a.name.localeCompare(b.name));
26
-
27
- for (const entry of entries) {
28
- const full = path.join(current, entry.name);
29
-
30
- if (entry.isDirectory()) {
31
- stack.push(full);
32
- continue;
33
- }
34
-
35
- if (entry.isFile() && entry.name.toLowerCase() === "index.html") {
36
- // Ignore index.html at the output root; prefer a routed page like
37
- // <section>/<slug>/index.html (or deeper).
38
- const rel = path.relative(outDirAbs, full);
39
- const parts = rel.split(path.sep).filter(Boolean);
40
- if (parts.length >= 2) return full;
41
- }
42
- }
43
- }
44
-
45
- return null;
46
- }
47
-
48
- function indexHtmlPathToRoute(outDirAbs: string, indexPathAbs: string): string {
49
- const rel = path.relative(outDirAbs, indexPathAbs);
50
- // rel: <section>/<slug>/index.html
51
- const noFile = rel.replace(/index\.html$/i, "");
52
- const normalized = noFile.split(path.sep).join("/").replace(/\/+$/, "");
53
- return "/" + normalized.replace(/^\/+/, "");
54
- }
55
-
56
- const BOT_UA =
57
- "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
58
- const HUMAN_UA =
59
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
60
-
61
- /** Heuristic: likely SPA shell if body has almost no visible content and no article. */
62
- function looksLikeSpaShell(html: string): { likely: boolean; reason?: string } {
63
- const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
64
- const bodyHtml = bodyMatch ? bodyMatch[1] : html;
65
- const noScript = bodyHtml
66
- .replace(/<script[\s\S]*?<\/script>/gi, " ")
67
- .replace(/<style[\s\S]*?<\/style>/gi, " ")
68
- .replace(/<noscript[\s\S]*?<\/noscript>/gi, " ");
69
- const text = noScript.replace(/<\/?[^>]+>/g, " ").replace(/\s+/g, " ").trim();
70
- if (text.length < 150) {
71
- return { likely: true, reason: `Body text very short (${text.length} chars); likely app shell.` };
72
- }
73
- if (!/<article\b/i.test(html)) {
74
- return { likely: true, reason: "No <article> present; may be SPA shell." };
75
- }
76
- const rootOnly = /<body[^>]*>\s*<div[^>]*id=["'](?:root|app|__next)["'][^>]*>\s*<\/div>\s*<\/body>/i.test(
77
- html.replace(/\s+/g, " ")
78
- );
79
- if (rootOnly) {
80
- return { likely: true, reason: "Single root div (e.g. #root, #app) with no content." };
81
- }
82
- return { likely: false };
83
- }
84
-
85
- export type VerifyProdOptions = { prodUrl: string };
86
-
87
- export async function cmdVerify(
88
- cwd = process.cwd(),
89
- options?: VerifyProdOptions
90
- ) {
91
- if (options?.prodUrl) {
92
- await runVerifyProd(options.prodUrl);
93
- return;
94
- }
95
-
96
- const cfg = await loadConfig(cwd);
97
-
98
- const outDirAbs = path.join(cwd, cfg.output.outDir);
99
- const exists = await fs.pathExists(outDirAbs);
100
-
101
- if (!exists) {
102
- console.log(`
103
- RenderShield verify
104
-
105
- No prerender output directory found: ${cfg.output.outDir}/
106
-
107
- Run:
108
- node dist/cli.js build
109
- `);
110
- return;
111
- }
112
-
113
- const firstIndex = await findFirstIndexHtml(outDirAbs);
114
-
115
- if (!firstIndex) {
116
- console.log(`
117
- RenderShield verify
118
-
119
- No prerendered pages found inside: ${cfg.output.outDir}/
120
-
121
- Run:
122
- node dist/cli.js build
123
- `);
124
- return;
125
- }
126
-
127
- const routePath = indexHtmlPathToRoute(outDirAbs, firstIndex);
128
- const url = joinUrl(cfg.site.canonicalBase, routePath);
129
-
130
- console.log(`
131
- RenderShield verify
132
-
133
- Using:
134
- canonicalBase: ${cfg.site.canonicalBase}
135
- routePath: ${routePath}
136
- output file: ${path.relative(cwd, firstIndex)}
137
-
138
- Smoke tests:
139
-
140
- 1) Human (usually SPA shell):
141
- curl -s ${url} | grep -i "<title>"
142
-
143
- 2) Bot (should see prerendered, route-specific title):
144
- curl -s -H "User-Agent: Googlebot" ${url} | grep -i "<title>"
145
-
146
- 3) Debug headers (Worker must be routed + proxy ON):
147
- curl -I -H "User-Agent: GPTBot" ${url}
148
-
149
- Expected: x-rendershield: bot-hit (proves Worker served prerender to bot).
150
- If debugHeaders enabled: X-Bot-Detected, X-Prerender, X-Final-Path.
151
- `);
152
- }
153
-
154
- async function runVerifyProd(url: string): Promise<void> {
155
- const normalizedUrl = url.startsWith("http") ? url : `https://${url}`;
156
-
157
- let botHtml: string;
158
- let humanHtml: string;
159
- let botStatus: number;
160
- let humanStatus: number;
161
- let xRenderShield: string | null;
162
-
163
- try {
164
- const [botRes, humanRes] = await Promise.all([
165
- fetch(normalizedUrl, {
166
- headers: { "User-Agent": BOT_UA },
167
- redirect: "follow",
168
- }),
169
- fetch(normalizedUrl, {
170
- headers: { "User-Agent": HUMAN_UA },
171
- redirect: "follow",
172
- }),
173
- ]);
174
-
175
- botStatus = botRes.status;
176
- humanStatus = humanRes.status;
177
- xRenderShield = botRes.headers.get("x-rendershield");
178
- botHtml = await botRes.text();
179
- humanHtml = await humanRes.text();
180
- } catch (err: unknown) {
181
- const msg = err instanceof Error ? err.message : String(err);
182
- throw new Error(
183
- `RenderShield verify --prod: failed to fetch ${normalizedUrl}. ${msg}`
184
- );
185
- }
186
-
187
- // Prove routing: Worker must set x-rendershield: bot-hit for bot requests. No inference.
188
- const routingOk = xRenderShield === "bot-hit";
189
- if (xRenderShield === "bot-fallback") {
190
- throw new Error(
191
- `RenderShield verify --prod: bot request received x-rendershield: bot-fallback. ` +
192
- `Prerender origin returned non-200; Worker fell back to SPA. Fix deployment or origin so bots get prerendered HTML.`
193
- );
194
- }
195
- if (!routingOk) {
196
- const hint = xRenderShield == null
197
- ? " If no Worker is deployed, use verify without --prod to check local/build output."
198
- : "";
199
- throw new Error(
200
- `RenderShield verify --prod: expected x-rendershield: bot-hit (proving Worker routed bot to prerender). ` +
201
- `Got: ${xRenderShield ?? "(missing)"}. Ensure the Worker is deployed and bound to this route.${hint}`
202
- );
203
- }
204
-
205
- const contract = checkPrerenderContract(botHtml, {
206
- routePath: normalizedUrl,
207
- outFile: normalizedUrl,
208
- });
209
-
210
- const humanSpa = looksLikeSpaShell(humanHtml);
211
-
212
- // Report
213
- console.log(`
214
- RenderShield verify --prod
215
- URL: ${normalizedUrl}
216
-
217
- Fetch:
218
- Bot (Googlebot): ${botStatus} (${botHtml.length} bytes) x-rendershield: ${xRenderShield ?? "(none)"}
219
- Human (Chrome): ${humanStatus} (${humanHtml.length} bytes)
220
-
221
- Routing: x-rendershield: bot-hit (Worker served prerendered HTML to bot)
222
-
223
- Bot contract (title, meta, canonical, OG, JSON-LD, article):
224
- ${contract.ok ? "PASS — all required fields present." : "FAIL — missing or invalid:"}
225
- ${contract.missing.length > 0 ? contract.missing.map((m) => ` - ${m}`).join("\n") : ""}
226
-
227
- Human response:
228
- ${humanSpa.likely ? `Likely SPA shell: ${humanSpa.reason ?? "unknown"}` : "Has substantial content (not a minimal SPA shell)."}
229
- `);
230
-
231
- if (!contract.ok) {
232
- throw new Error(
233
- `Production URL did not satisfy bot contract. Missing: ${contract.missing.join("; ")}`
234
- );
235
- }
236
- }
1
+ import fs from "fs-extra";
2
+ import path from "node:path";
3
+ import { loadConfig } from "../core/loadConfig.js";
4
+ import {
5
+ listPrerenderIndexFiles,
6
+ indexHtmlPathToRoute,
7
+ } from "../core/listOutputRoutes.js";
8
+ import {
9
+ checkPrerenderContract,
10
+ type ContractCheckResult,
11
+ } from "../core/validateOutput.js";
12
+ import { renderShieldError } from "../errors.js";
13
+ import type { CommandOptions } from "../configPath.js";
14
+
15
+ function joinUrl(base: string, routePath: string): string {
16
+ const b = base.endsWith("/") ? base.slice(0, -1) : base;
17
+ const p = routePath.startsWith("/") ? routePath : `/${routePath}`;
18
+ return b + p;
19
+ }
20
+
21
+ const BOT_UA =
22
+ "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
23
+ const HUMAN_UA =
24
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
25
+
26
+ function looksLikeSpaShell(html: string): { likely: boolean; reason?: string } {
27
+ const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
28
+ const bodyHtml = bodyMatch ? bodyMatch[1] : html;
29
+ const noScript = bodyHtml
30
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
31
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
32
+ .replace(/<noscript[\s\S]*?<\/noscript>/gi, " ");
33
+ const text = noScript.replace(/<\/?[^>]+>/g, " ").replace(/\s+/g, " ").trim();
34
+ if (text.length < 150) {
35
+ return { likely: true, reason: `Body text very short (${text.length} chars); likely app shell.` };
36
+ }
37
+ if (!/<article\b/i.test(html)) {
38
+ return { likely: true, reason: "No <article> present; may be SPA shell." };
39
+ }
40
+ const rootOnly = /<body[^>]*>\s*<div[^>]*id=["'](?:root|app|__next)["'][^>]*>\s*<\/div>\s*<\/body>/i.test(
41
+ html.replace(/\s+/g, " ")
42
+ );
43
+ if (rootOnly) {
44
+ return { likely: true, reason: "Single root div (e.g. #root, #app) with no content." };
45
+ }
46
+ return { likely: false };
47
+ }
48
+
49
+ export type VerifyPageResult = {
50
+ routePath: string;
51
+ outputFile: string;
52
+ url: string;
53
+ contract?: ContractCheckResult;
54
+ };
55
+
56
+ export type VerifyOptions = CommandOptions & {
57
+ /** Set when --prod is passed (URL may be omitted with --all). */
58
+ prod?: boolean;
59
+ prodUrl?: string;
60
+ check?: boolean;
61
+ all?: boolean;
62
+ };
63
+
64
+ export type VerifyLocalResult = {
65
+ mode: "local";
66
+ canonicalBase: string;
67
+ checked: boolean;
68
+ ok: boolean;
69
+ pages: VerifyPageResult[];
70
+ };
71
+
72
+ export type VerifyProdResult = {
73
+ mode: "prod";
74
+ ok: boolean;
75
+ pages: Array<{ url: string; contract: ContractCheckResult }>;
76
+ };
77
+
78
+ export type VerifyResult = VerifyLocalResult | VerifyProdResult;
79
+
80
+ export async function cmdVerify(
81
+ cwd = process.cwd(),
82
+ options: VerifyOptions = {}
83
+ ): Promise<VerifyResult> {
84
+ if (options.prod || options.prodUrl) {
85
+ return runVerifyProdMode(cwd, options);
86
+ }
87
+
88
+ if (options.check) {
89
+ return runLocalCheckMode(cwd, options);
90
+ }
91
+
92
+ if (options.all) {
93
+ throw renderShieldError(
94
+ "CLI_INVALID_ARGS",
95
+ "verify --all requires --check (local) or --prod (production). Example: rendershield verify --all --check"
96
+ );
97
+ }
98
+
99
+ return runLocalSmoke(cwd, options);
100
+ }
101
+
102
+ async function runVerifyProdMode(
103
+ cwd: string,
104
+ options: VerifyOptions
105
+ ): Promise<VerifyProdResult> {
106
+ const cfg = await loadConfig(cwd, options);
107
+ const outDirAbs = path.join(cwd, cfg.output.outDir);
108
+
109
+ if (options.all) {
110
+ await assertOutputExists(outDirAbs, cfg.output.outDir);
111
+ const indexFiles = await listPrerenderIndexFiles(outDirAbs);
112
+ if (indexFiles.length === 0) {
113
+ throw renderShieldError(
114
+ "VERIFY_FAILED",
115
+ `No prerendered pages found inside: ${cfg.output.outDir}/. Run: rendershield build`,
116
+ { outDir: cfg.output.outDir }
117
+ );
118
+ }
119
+ return runVerifyProdAll(cfg, cwd, outDirAbs, indexFiles);
120
+ }
121
+
122
+ if (!options.prodUrl) {
123
+ throw renderShieldError(
124
+ "CLI_INVALID_ARGS",
125
+ "verify --prod requires a URL, or use --prod --all to check every route from build output."
126
+ );
127
+ }
128
+
129
+ const single = await fetchAndVerifyProd(
130
+ options.prodUrl.startsWith("http") ? options.prodUrl : `https://${options.prodUrl}`
131
+ );
132
+ return { mode: "prod", ok: true, pages: [single] };
133
+ }
134
+
135
+ async function runLocalCheckMode(
136
+ cwd: string,
137
+ options: VerifyOptions
138
+ ): Promise<VerifyLocalResult> {
139
+ const cfg = await loadConfig(cwd, options);
140
+ const outDirAbs = path.join(cwd, cfg.output.outDir);
141
+ await assertOutputExists(outDirAbs, cfg.output.outDir);
142
+
143
+ const indexFiles = await listPrerenderIndexFiles(outDirAbs);
144
+ if (indexFiles.length === 0) {
145
+ throw renderShieldError(
146
+ "VERIFY_FAILED",
147
+ `No prerendered pages found inside: ${cfg.output.outDir}/. Run: rendershield build`,
148
+ { outDir: cfg.output.outDir }
149
+ );
150
+ }
151
+
152
+ if (options.all) {
153
+ return runLocalCheckAll(cwd, cfg, outDirAbs, indexFiles);
154
+ }
155
+
156
+ return runLocalCheckOne(cwd, cfg, outDirAbs, indexFiles[0]);
157
+ }
158
+
159
+ async function assertOutputExists(outDirAbs: string, outDir: string): Promise<void> {
160
+ if (!(await fs.pathExists(outDirAbs))) {
161
+ throw renderShieldError(
162
+ "VERIFY_FAILED",
163
+ `No prerender output directory found: ${outDir}/. Run: rendershield build`,
164
+ { outDir }
165
+ );
166
+ }
167
+ }
168
+
169
+ async function runLocalSmoke(
170
+ cwd: string,
171
+ options: VerifyOptions
172
+ ): Promise<VerifyLocalResult> {
173
+ const cfg = await loadConfig(cwd, options);
174
+ const outDirAbs = path.join(cwd, cfg.output.outDir);
175
+ await assertOutputExists(outDirAbs, cfg.output.outDir);
176
+
177
+ const indexFiles = await listPrerenderIndexFiles(outDirAbs);
178
+ if (indexFiles.length === 0) {
179
+ throw renderShieldError(
180
+ "VERIFY_FAILED",
181
+ `No prerendered pages found inside: ${cfg.output.outDir}/. Run: rendershield build`,
182
+ { outDir: cfg.output.outDir }
183
+ );
184
+ }
185
+
186
+ const firstIndex = indexFiles[0];
187
+ const routePath = indexHtmlPathToRoute(outDirAbs, firstIndex);
188
+ const url = joinUrl(cfg.site.canonicalBase, routePath);
189
+ const outputFile = path.relative(cwd, firstIndex);
190
+
191
+ console.log(`
192
+ RenderShield verify
193
+
194
+ Using:
195
+ canonicalBase: ${cfg.site.canonicalBase}
196
+ routePath: ${routePath}
197
+ output file: ${outputFile}
198
+ pages in output: ${indexFiles.length} (showing first; use --all --check to validate all)
199
+
200
+ Smoke tests:
201
+
202
+ 1) Human (usually SPA shell):
203
+ curl -s ${url} | grep -i "<title>"
204
+
205
+ 2) Bot (should see prerendered, route-specific title):
206
+ curl -s -H "User-Agent: Googlebot" ${url} | grep -i "<title>"
207
+
208
+ 3) Debug headers (Worker must be routed + proxy ON):
209
+ curl -I -H "User-Agent: GPTBot" ${url}
210
+
211
+ Expected: x-rendershield: bot-hit (proves Worker served prerender to bot).
212
+ If debugHeaders enabled: X-Bot-Detected, X-Prerender, X-Final-Path.
213
+
214
+ Tip: rendershield verify --check validates built HTML without fetching production.
215
+ `);
216
+
217
+ return {
218
+ mode: "local",
219
+ canonicalBase: cfg.site.canonicalBase,
220
+ checked: false,
221
+ ok: true,
222
+ pages: [{ routePath, outputFile, url }],
223
+ };
224
+ }
225
+
226
+ async function runLocalCheckOne(
227
+ cwd: string,
228
+ cfg: Awaited<ReturnType<typeof loadConfig>>,
229
+ outDirAbs: string,
230
+ indexPath: string
231
+ ): Promise<VerifyLocalResult> {
232
+ const page = await checkLocalPage(cwd, outDirAbs, indexPath, cfg.site.canonicalBase);
233
+ const ok = page.contract?.ok ?? false;
234
+
235
+ console.log(`
236
+ RenderShield verify --check
237
+ Route: ${page.routePath}
238
+ File: ${page.outputFile}
239
+ Contract: ${ok ? "PASS" : "FAIL"}
240
+ ${!ok && page.contract ? page.contract.missing.map((m) => ` - ${m}`).join("\n") : ""}
241
+ `);
242
+
243
+ if (!ok) {
244
+ throw renderShieldError(
245
+ "VERIFY_FAILED",
246
+ `Built HTML failed contract for ${page.routePath}. Missing: ${page.contract?.missing.join("; ")}`,
247
+ { routePath: page.routePath, missing: page.contract?.missing }
248
+ );
249
+ }
250
+
251
+ return {
252
+ mode: "local",
253
+ canonicalBase: cfg.site.canonicalBase,
254
+ checked: true,
255
+ ok: true,
256
+ pages: [page],
257
+ };
258
+ }
259
+
260
+ async function runLocalCheckAll(
261
+ cwd: string,
262
+ cfg: Awaited<ReturnType<typeof loadConfig>>,
263
+ outDirAbs: string,
264
+ indexFiles: string[]
265
+ ): Promise<VerifyLocalResult> {
266
+ const pages: VerifyPageResult[] = [];
267
+ const failures: string[] = [];
268
+
269
+ for (const indexPath of indexFiles) {
270
+ const page = await checkLocalPage(cwd, outDirAbs, indexPath, cfg.site.canonicalBase);
271
+ pages.push(page);
272
+ if (!page.contract?.ok) {
273
+ failures.push(
274
+ `${page.routePath}: ${page.contract?.missing.join("; ") ?? "contract failed"}`
275
+ );
276
+ }
277
+ }
278
+
279
+ console.log(`
280
+ RenderShield verify --all --check
281
+ Pages: ${pages.length}
282
+ ${pages
283
+ .map((p) => ` ${p.contract?.ok ? "PASS" : "FAIL"} ${p.routePath}`)
284
+ .join("\n")}
285
+ `);
286
+
287
+ if (failures.length > 0) {
288
+ throw renderShieldError(
289
+ "VERIFY_FAILED",
290
+ `Built HTML failed contract on ${failures.length} page(s). ${failures.join(" | ")}`,
291
+ { failures }
292
+ );
293
+ }
294
+
295
+ return {
296
+ mode: "local",
297
+ canonicalBase: cfg.site.canonicalBase,
298
+ checked: true,
299
+ ok: true,
300
+ pages,
301
+ };
302
+ }
303
+
304
+ async function checkLocalPage(
305
+ cwd: string,
306
+ outDirAbs: string,
307
+ indexPath: string,
308
+ canonicalBase: string
309
+ ): Promise<VerifyPageResult> {
310
+ const html = await fs.readFile(indexPath, "utf8");
311
+ const routePath = indexHtmlPathToRoute(outDirAbs, indexPath);
312
+ const outputFile = path.relative(cwd, indexPath);
313
+ const contract = checkPrerenderContract(html, {
314
+ routePath,
315
+ outFile: outputFile,
316
+ });
317
+ return {
318
+ routePath,
319
+ outputFile,
320
+ url: joinUrl(canonicalBase, routePath),
321
+ contract,
322
+ };
323
+ }
324
+
325
+ async function runVerifyProdAll(
326
+ cfg: Awaited<ReturnType<typeof loadConfig>>,
327
+ _cwd: string,
328
+ outDirAbs: string,
329
+ indexFiles: string[]
330
+ ): Promise<VerifyProdResult> {
331
+ const pages: VerifyProdResult["pages"] = [];
332
+ const failures: string[] = [];
333
+
334
+ for (const indexPath of indexFiles) {
335
+ const routePath = indexHtmlPathToRoute(outDirAbs, indexPath);
336
+ const prodUrl = joinUrl(cfg.site.canonicalBase, routePath);
337
+ try {
338
+ const result = await fetchAndVerifyProd(prodUrl);
339
+ pages.push(result);
340
+ } catch (err: unknown) {
341
+ const msg = err instanceof Error ? err.message : String(err);
342
+ failures.push(`${routePath}: ${msg}`);
343
+ }
344
+ }
345
+
346
+ console.log(`
347
+ RenderShield verify --prod --all
348
+ Checked ${pages.length} URL(s) from build output.
349
+ `);
350
+
351
+ if (failures.length > 0) {
352
+ throw renderShieldError(
353
+ "VERIFY_FAILED",
354
+ `Production verify failed for ${failures.length} route(s). ${failures.join(" | ")}`,
355
+ { failures }
356
+ );
357
+ }
358
+
359
+ return { mode: "prod", ok: true, pages };
360
+ }
361
+
362
+ async function fetchAndVerifyProd(
363
+ normalizedUrl: string
364
+ ): Promise<{ url: string; contract: ContractCheckResult }> {
365
+ let botHtml: string;
366
+ let humanHtml: string;
367
+ let botStatus: number;
368
+ let humanStatus: number;
369
+ let xRenderShield: string | null;
370
+
371
+ try {
372
+ const [botRes, humanRes] = await Promise.all([
373
+ fetch(normalizedUrl, {
374
+ headers: { "User-Agent": BOT_UA },
375
+ redirect: "follow",
376
+ }),
377
+ fetch(normalizedUrl, {
378
+ headers: { "User-Agent": HUMAN_UA },
379
+ redirect: "follow",
380
+ }),
381
+ ]);
382
+
383
+ botStatus = botRes.status;
384
+ humanStatus = humanRes.status;
385
+ xRenderShield = botRes.headers.get("x-rendershield");
386
+ botHtml = await botRes.text();
387
+ humanHtml = await humanRes.text();
388
+ } catch (err: unknown) {
389
+ const msg = err instanceof Error ? err.message : String(err);
390
+ throw renderShieldError(
391
+ "VERIFY_FAILED",
392
+ `verify --prod: failed to fetch ${normalizedUrl}. ${msg}`,
393
+ { url: normalizedUrl }
394
+ );
395
+ }
396
+
397
+ if (xRenderShield === "bot-fallback") {
398
+ throw renderShieldError(
399
+ "VERIFY_FAILED",
400
+ `verify --prod: bot request received x-rendershield: bot-fallback for ${normalizedUrl}. ` +
401
+ `Prerender origin returned non-200; Worker fell back to SPA.`,
402
+ { url: normalizedUrl, xRenderShield }
403
+ );
404
+ }
405
+ if (xRenderShield !== "bot-hit") {
406
+ const hint = xRenderShield == null
407
+ ? " If no Worker is deployed, use verify --check for local/build output."
408
+ : "";
409
+ throw renderShieldError(
410
+ "VERIFY_FAILED",
411
+ `verify --prod: expected x-rendershield: bot-hit for ${normalizedUrl}. ` +
412
+ `Got: ${xRenderShield ?? "(missing)"}.${hint}`,
413
+ { url: normalizedUrl, xRenderShield }
414
+ );
415
+ }
416
+
417
+ const contract = checkPrerenderContract(botHtml, {
418
+ routePath: normalizedUrl,
419
+ outFile: normalizedUrl,
420
+ });
421
+
422
+ const humanSpa = looksLikeSpaShell(humanHtml);
423
+
424
+ console.log(`
425
+ RenderShield verify --prod
426
+ URL: ${normalizedUrl}
427
+
428
+ Fetch:
429
+ Bot (Googlebot): ${botStatus} (${botHtml.length} bytes) x-rendershield: ${xRenderShield}
430
+ Human (Chrome): ${humanStatus} (${humanHtml.length} bytes)
431
+
432
+ Routing: x-rendershield: bot-hit
433
+
434
+ Bot contract:
435
+ ${contract.ok ? "PASS" : "FAIL"}
436
+ ${contract.missing.length > 0 ? contract.missing.map((m) => ` - ${m}`).join("\n") : ""}
437
+
438
+ Human response:
439
+ ${humanSpa.likely ? `Likely SPA shell: ${humanSpa.reason ?? "unknown"}` : "Has substantial content."}
440
+ `);
441
+
442
+ if (!contract.ok) {
443
+ throw renderShieldError(
444
+ "VERIFY_FAILED",
445
+ `Production URL did not satisfy bot contract: ${normalizedUrl}. Missing: ${contract.missing.join("; ")}`,
446
+ { url: normalizedUrl, missing: contract.missing }
447
+ );
448
+ }
449
+
450
+ return { url: normalizedUrl, contract };
451
+ }