@lownoise-studio/rendershield 0.1.4 → 0.3.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.
@@ -1,72 +1,185 @@
1
- import fs from "fs-extra";
2
- import path from "node:path";
3
- import { loadConfig } from "../core/loadConfig.js";
4
- import { loadAllMarkdownDocs } from "../core/loadMarkdown.js";
5
- import { renderPageHtml } from "../core/renderHtml.js";
6
- import { generateSitemapXml } from "../core/generateSitemap.js";
7
- import { generateRobotsTxt } from "../core/generateRobots.js";
8
- import { generateWorkerJs } from "../core/generateWorker.js";
9
- import { validatePrerenderHtml } from "../core/validateOutput.js";
10
-
11
- function routeToOutDir(outDirAbs: string, routePath: string): string {
12
- // /blog/slug -> outDir/blog/slug/index.html
13
- const clean = routePath.replace(/^\//, "");
14
- return path.join(outDirAbs, clean);
15
- }
16
-
17
- export async function cmdBuild(cwd = process.cwd()) {
18
- const cfg = await loadConfig(cwd);
19
- const outDirAbs = path.join(cwd, cfg.output.outDir);
20
-
21
- // Clean output (boring + deterministic)
22
- await fs.remove(outDirAbs);
23
- await fs.ensureDir(outDirAbs);
24
-
25
- const docs = await loadAllMarkdownDocs(cfg, cwd);
26
-
27
- if (docs.length === 0) {
28
- throw new Error("No markdown documents found. Check content paths/patterns.");
29
- }
30
-
31
- // Generate pages (validate BEFORE writing)
32
- for (const doc of docs) {
33
- const pageDir = routeToOutDir(outDirAbs, doc.routePath);
34
- await fs.ensureDir(pageDir);
35
-
36
- const outFile = path.join(pageDir, "index.html");
37
- const html = renderPageHtml(cfg, doc);
38
-
39
- validatePrerenderHtml({
40
- html,
41
- outFile,
42
- routePath: doc.routePath,
43
- });
44
-
45
- await fs.writeFile(outFile, html, "utf8");
46
- }
47
-
48
- // sitemap.xml
49
- if (cfg.sitemap.enabled) {
50
- const sitemapXml = generateSitemapXml(cfg, docs);
51
- const sitemapPath = path.join(outDirAbs, cfg.sitemap.path.replace(/^\//, ""));
52
- await fs.writeFile(sitemapPath, sitemapXml, "utf8");
53
- }
54
-
55
- // robots.txt
56
- if (cfg.robots.enabled) {
57
- const robotsTxt = generateRobotsTxt(cfg);
58
- const robotsPath = path.join(outDirAbs, cfg.robots.path.replace(/^\//, ""));
59
- await fs.writeFile(robotsPath, robotsTxt, "utf8");
60
- }
61
-
62
- // worker.js
63
- if (cfg.worker.enabled) {
64
- const workerJs = generateWorkerJs(cfg);
65
- await fs.writeFile(path.join(outDirAbs, "worker.js"), workerJs, "utf8");
66
- }
67
-
68
- console.log(`Built ${docs.length} pages into ${cfg.output.outDir}/`);
69
- console.log(
70
- `Output includes: ${cfg.sitemap.enabled ? "sitemap.xml " : ""}${cfg.robots.enabled ? "robots.txt " : ""}${cfg.worker.enabled ? "worker.js" : ""}`
71
- );
72
- }
1
+ import fs from "fs-extra";
2
+ import path from "node:path";
3
+ import { loadConfig } from "../core/loadConfig.js";
4
+ import { loadAllMarkdownDocs } from "../core/loadMarkdown.js";
5
+ import { renderPageHtml } from "../core/renderHtml.js";
6
+ import { generateSitemapXml } from "../core/generateSitemap.js";
7
+ import { generateRobotsTxt } from "../core/generateRobots.js";
8
+ import { generateWorkerJs } from "../core/generateWorker.js";
9
+ import { validatePrerenderHtml } from "../core/validateOutput.js";
10
+
11
+ function routeToOutDir(outDirAbs: string, routePath: string): string {
12
+ // /blog/slug -> outDir/blog/slug/index.html
13
+ const clean = routePath.replace(/^\//, "");
14
+ return path.join(outDirAbs, clean);
15
+ }
16
+
17
+ /**
18
+ * Validates output path before any destructive operation (fs.remove).
19
+ * Pass: outDir is a subdirectory inside project root; no symlink escape.
20
+ * Fail: "/", "C:\", "..", "../", or outDir (or any of its existing parents) resolving outside project.
21
+ * Policy: strict build hard-fails before any delete attempt.
22
+ * Segment-safe: uses path.relative(root, out) only; no prefix startsWith.
23
+ */
24
+ async function validateOutputPath(outDir: string, cwd: string): Promise<void> {
25
+ const cwdAbs = path.resolve(cwd);
26
+ let cwdReal: string;
27
+ try {
28
+ cwdReal = await fs.realpath(cwdAbs);
29
+ } catch (err: unknown) {
30
+ const msg = err instanceof Error ? err.message : String(err);
31
+ throw new Error(`Cannot resolve project root: ${cwdAbs}. ${msg}`);
32
+ }
33
+
34
+ const outDirAbs = path.resolve(cwdAbs, outDir);
35
+ // Order: realpath → normalize → then case-fold for comparisons
36
+ const normalizedCwd = path.normalize(cwdReal);
37
+ const normalizedOut = path.normalize(outDirAbs);
38
+
39
+ // Segment-safe: use path.relative only (no prefix startsWith on full path). Cross-platform: relative()
40
+ // rarely returns absolute on Windows; startsWith("..") and path.isAbsolute(rel) cover escapes.
41
+ const relative = path.relative(normalizedCwd, normalizedOut);
42
+ if (relative.startsWith("..") || relative === ".." || path.isAbsolute(relative)) {
43
+ throw new Error(
44
+ `Output directory "${outDir}" resolves outside project root. Use a relative path within the project.`
45
+ );
46
+ }
47
+
48
+ // Reject: root filesystem (/, C:\, C:/) — early reject; real safety is "inside root" above
49
+ const rootPaths = ["/", "c:\\", "c:/"];
50
+ const outLower = normalizedOut.toLowerCase();
51
+ if (rootPaths.includes(outLower)) {
52
+ throw new Error(
53
+ `Output directory "${outDir}" resolves to root filesystem. This is not allowed for safety.`
54
+ );
55
+ }
56
+
57
+ // Reject: output dir equals project root (would delete entire project). Case-fold after normalize.
58
+ const cwdLower = normalizedCwd.toLowerCase();
59
+ if (outLower === cwdLower) {
60
+ throw new Error(
61
+ `Output directory "${outDir}" cannot be the project root. Use a subdirectory (e.g. dist-prerender).`
62
+ );
63
+ }
64
+
65
+ if (await fs.pathExists(outDirAbs)) {
66
+ // Path exists: resolve symlinks and ensure real path is inside root
67
+ let outDirReal: string;
68
+ try {
69
+ outDirReal = await fs.realpath(outDirAbs);
70
+ } catch {
71
+ outDirReal = outDirAbs;
72
+ }
73
+ const outDirRealNorm = path.normalize(outDirReal);
74
+ const relativeReal = path.relative(normalizedCwd, outDirRealNorm);
75
+ if (relativeReal.startsWith("..") || relativeReal === ".." || path.isAbsolute(relativeReal)) {
76
+ throw new Error(
77
+ `Output directory "${outDir}" resolves (via symlink) outside project root. Use a path that does not escape the project.`
78
+ );
79
+ }
80
+ } else {
81
+ // Path does not exist: walk up to nearest existing parent, realpath it, ensure it stays inside root.
82
+ // Guards e.g. outDir "dist-link/prerender-new" where dist-link is a symlink to /.
83
+ // If we never find an existing parent (or the only one is root), we must reject — do not treat as "fine."
84
+ let current = normalizedOut;
85
+ const rootDir = path.normalize(path.parse(normalizedCwd).root);
86
+ let foundParentInsideRoot = false;
87
+ while (current) {
88
+ if (await fs.pathExists(current)) {
89
+ let parentReal: string;
90
+ try {
91
+ parentReal = await fs.realpath(current);
92
+ } catch {
93
+ parentReal = current;
94
+ }
95
+ const parentRealNorm = path.normalize(parentReal);
96
+ // Nearest existing parent is filesystem root → outside project, reject
97
+ const parentLower = parentRealNorm.toLowerCase();
98
+ if (rootPaths.includes(parentLower)) {
99
+ throw new Error(
100
+ `Output directory "${outDir}" has a parent that resolves to filesystem root. Use a path inside the project.`
101
+ );
102
+ }
103
+ const relParent = path.relative(normalizedCwd, parentRealNorm);
104
+ if (relParent.startsWith("..") || relParent === ".." || path.isAbsolute(relParent)) {
105
+ throw new Error(
106
+ `Output directory "${outDir}" has a parent that resolves (via symlink) outside project root. Use a path that does not escape the project.`
107
+ );
108
+ }
109
+ foundParentInsideRoot = true;
110
+ break;
111
+ }
112
+ const parent = path.dirname(current);
113
+ if (parent === current) break;
114
+ current = parent;
115
+ }
116
+ if (!foundParentInsideRoot) {
117
+ // No existing parent found before hitting dirname loop stop (shouldn't happen on a normal FS)
118
+ throw new Error(
119
+ `Output directory "${outDir}" could not be validated: no existing parent path found. Use a path inside the project.`
120
+ );
121
+ }
122
+ }
123
+ }
124
+
125
+ export async function cmdBuild(cwd = process.cwd()) {
126
+ const cfg = await loadConfig(cwd);
127
+
128
+ // Validate output path before any destructive operations
129
+ await validateOutputPath(cfg.output.outDir, cwd);
130
+
131
+ const outDirAbs = path.join(cwd, cfg.output.outDir);
132
+
133
+ // Clean output (boring + deterministic)
134
+ await fs.remove(outDirAbs);
135
+ await fs.ensureDir(outDirAbs);
136
+
137
+ const docs = await loadAllMarkdownDocs(cfg, cwd);
138
+
139
+ if (docs.length === 0) {
140
+ throw new Error("No markdown documents found. Check content paths/patterns.");
141
+ }
142
+
143
+ // Generate pages (validate BEFORE writing)
144
+ for (const doc of docs) {
145
+ const pageDir = routeToOutDir(outDirAbs, doc.routePath);
146
+ await fs.ensureDir(pageDir);
147
+
148
+ const outFile = path.join(pageDir, "index.html");
149
+ const html = renderPageHtml(cfg, doc);
150
+
151
+ validatePrerenderHtml({
152
+ html,
153
+ outFile,
154
+ routePath: doc.routePath,
155
+ sourcePath: doc.sourcePath,
156
+ });
157
+
158
+ await fs.writeFile(outFile, html, "utf8");
159
+ }
160
+
161
+ // sitemap.xml
162
+ if (cfg.sitemap.enabled) {
163
+ const sitemapXml = generateSitemapXml(cfg, docs);
164
+ const sitemapPath = path.join(outDirAbs, cfg.sitemap.path.replace(/^\//, ""));
165
+ await fs.writeFile(sitemapPath, sitemapXml, "utf8");
166
+ }
167
+
168
+ // robots.txt
169
+ if (cfg.robots.enabled) {
170
+ const robotsTxt = generateRobotsTxt(cfg);
171
+ const robotsPath = path.join(outDirAbs, cfg.robots.path.replace(/^\//, ""));
172
+ await fs.writeFile(robotsPath, robotsTxt, "utf8");
173
+ }
174
+
175
+ // worker.js
176
+ if (cfg.worker.enabled) {
177
+ const workerJs = generateWorkerJs(cfg);
178
+ await fs.writeFile(path.join(outDirAbs, "worker.js"), workerJs, "utf8");
179
+ }
180
+
181
+ console.log(`Built ${docs.length} pages into ${cfg.output.outDir}/`);
182
+ console.log(
183
+ `Output includes: ${cfg.sitemap.enabled ? "sitemap.xml " : ""}${cfg.robots.enabled ? "robots.txt " : ""}${cfg.worker.enabled ? "worker.js" : ""}`
184
+ );
185
+ }
@@ -1,114 +1,233 @@
1
- import fs from "fs-extra";
2
- import path from "node:path";
3
- import { loadConfig } from "../core/loadConfig.js";
4
-
5
- function joinUrl(base: string, routePath: string): string {
6
- const b = base.endsWith("/") ? base.slice(0, -1) : base;
7
- const p = routePath.startsWith("/") ? routePath : `/${routePath}`;
8
- return b + p;
9
- }
10
-
11
- async function findFirstIndexHtml(outDirAbs: string): Promise<string | null> {
12
- const stack: string[] = [outDirAbs];
13
-
14
- while (stack.length > 0) {
15
- const current = stack.pop() as string;
16
-
17
- let entries: fs.Dirent[];
18
- try {
19
- entries = await fs.readdir(current, { withFileTypes: true });
20
- } catch {
21
- continue;
22
- }
23
-
24
- entries.sort((a, b) => a.name.localeCompare(b.name));
25
-
26
- for (const entry of entries) {
27
- const full = path.join(current, entry.name);
28
-
29
- if (entry.isDirectory()) {
30
- stack.push(full);
31
- continue;
32
- }
33
-
34
- if (entry.isFile() && entry.name.toLowerCase() === "index.html") {
35
- // Ignore index.html at the output root; prefer a routed page like
36
- // <section>/<slug>/index.html (or deeper).
37
- const rel = path.relative(outDirAbs, full);
38
- const parts = rel.split(path.sep).filter(Boolean);
39
- if (parts.length >= 2) return full;
40
- }
41
- }
42
- }
43
-
44
- return null;
45
- }
46
-
47
- function indexHtmlPathToRoute(outDirAbs: string, indexPathAbs: string): string {
48
- const rel = path.relative(outDirAbs, indexPathAbs);
49
- // rel: <section>/<slug>/index.html
50
- const noFile = rel.replace(/index\.html$/i, "");
51
- const normalized = noFile.split(path.sep).join("/").replace(/\/+$/, "");
52
- return "/" + normalized.replace(/^\/+/, "");
53
- }
54
-
55
- export async function cmdVerify(cwd = process.cwd()) {
56
- const cfg = await loadConfig(cwd);
57
-
58
- const outDirAbs = path.join(cwd, cfg.output.outDir);
59
- const exists = await fs.pathExists(outDirAbs);
60
-
61
- if (!exists) {
62
- console.log(`
63
- RenderShield verify
64
-
65
- No prerender output directory found: ${cfg.output.outDir}/
66
-
67
- Run:
68
- node dist/cli.js build
69
- `);
70
- return;
71
- }
72
-
73
- const firstIndex = await findFirstIndexHtml(outDirAbs);
74
-
75
- if (!firstIndex) {
76
- console.log(`
77
- RenderShield verify
78
-
79
- No prerendered pages found inside: ${cfg.output.outDir}/
80
-
81
- Run:
82
- node dist/cli.js build
83
- `);
84
- return;
85
- }
86
-
87
- const routePath = indexHtmlPathToRoute(outDirAbs, firstIndex);
88
- const url = joinUrl(cfg.site.canonicalBase, routePath);
89
-
90
- console.log(`
91
- RenderShield verify
92
-
93
- Using:
94
- canonicalBase: ${cfg.site.canonicalBase}
95
- routePath: ${routePath}
96
- output file: ${path.relative(cwd, firstIndex)}
97
-
98
- Smoke tests:
99
-
100
- 1) Human (usually SPA shell):
101
- curl -s ${url} | grep -i "<title>"
102
-
103
- 2) Bot (should see prerendered, route-specific title):
104
- curl -s -H "User-Agent: Googlebot" ${url} | grep -i "<title>"
105
-
106
- 3) Debug headers (Worker must be routed + proxy ON):
107
- curl -I -H "User-Agent: GPTBot" ${url}
108
-
109
- Expected headers (if debugHeaders enabled):
110
- X-Bot-Detected: true
111
- X-Prerender: true
112
- X-Final-Path: ${routePath.replace(/\/?$/, "/index.html")}
113
- `);
114
- }
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
+ throw new Error(
197
+ `RenderShield verify --prod: expected x-rendershield: bot-hit (proving Worker routed bot to prerender). ` +
198
+ `Got: ${xRenderShield ?? "(missing)"}. Ensure the Worker is deployed and bound to this route.`
199
+ );
200
+ }
201
+
202
+ const contract = checkPrerenderContract(botHtml, {
203
+ routePath: normalizedUrl,
204
+ outFile: normalizedUrl,
205
+ });
206
+
207
+ const humanSpa = looksLikeSpaShell(humanHtml);
208
+
209
+ // Report
210
+ console.log(`
211
+ RenderShield verify --prod
212
+ URL: ${normalizedUrl}
213
+
214
+ Fetch:
215
+ Bot (Googlebot): ${botStatus} (${botHtml.length} bytes) x-rendershield: ${xRenderShield ?? "(none)"}
216
+ Human (Chrome): ${humanStatus} (${humanHtml.length} bytes)
217
+
218
+ Routing: x-rendershield: bot-hit (Worker served prerendered HTML to bot)
219
+
220
+ Bot contract (title, meta, canonical, OG, JSON-LD, article):
221
+ ${contract.ok ? "PASS — all required fields present." : "FAIL — missing or invalid:"}
222
+ ${contract.missing.length > 0 ? contract.missing.map((m) => ` - ${m}`).join("\n") : ""}
223
+
224
+ Human response:
225
+ ${humanSpa.likely ? `Likely SPA shell: ${humanSpa.reason ?? "unknown"}` : "Has substantial content (not a minimal SPA shell)."}
226
+ `);
227
+
228
+ if (!contract.ok) {
229
+ throw new Error(
230
+ `Production URL did not satisfy bot contract. Missing: ${contract.missing.join("; ")}`
231
+ );
232
+ }
233
+ }