@lownoise-studio/rendershield 0.3.1 → 1.0.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 (66) hide show
  1. package/CHANGELOG.md +58 -32
  2. package/CONTRIBUTING.md +41 -0
  3. package/README.md +209 -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 +27 -19
  8. package/dist/cli.js.map +1 -1
  9. package/dist/commands/build.d.ts +2 -0
  10. package/dist/commands/build.d.ts.map +1 -0
  11. package/dist/commands/build.js +10 -9
  12. package/dist/commands/build.js.map +1 -1
  13. package/dist/commands/init.d.ts +2 -0
  14. package/dist/commands/init.d.ts.map +1 -0
  15. package/dist/commands/verify.d.ts +19 -0
  16. package/dist/commands/verify.d.ts.map +1 -0
  17. package/dist/commands/verify.js +57 -64
  18. package/dist/commands/verify.js.map +1 -1
  19. package/dist/core/generateRobots.d.ts +3 -0
  20. package/dist/core/generateRobots.d.ts.map +1 -0
  21. package/dist/core/generateSitemap.d.ts +3 -0
  22. package/dist/core/generateSitemap.d.ts.map +1 -0
  23. package/dist/core/generateWorker.d.ts +3 -0
  24. package/dist/core/generateWorker.d.ts.map +1 -0
  25. package/dist/core/generateWorker.js +75 -75
  26. package/dist/core/loadConfig.d.ts +3 -0
  27. package/dist/core/loadConfig.d.ts.map +1 -0
  28. package/dist/core/loadConfig.js +68 -25
  29. package/dist/core/loadConfig.js.map +1 -1
  30. package/dist/core/loadMarkdown.d.ts +3 -0
  31. package/dist/core/loadMarkdown.d.ts.map +1 -0
  32. package/dist/core/loadMarkdown.js +4 -3
  33. package/dist/core/loadMarkdown.js.map +1 -1
  34. package/dist/core/renderHtml.d.ts +3 -0
  35. package/dist/core/renderHtml.d.ts.map +1 -0
  36. package/dist/core/renderHtml.js +25 -10
  37. package/dist/core/renderHtml.js.map +1 -1
  38. package/dist/core/validateOutput.d.ts +28 -0
  39. package/dist/core/validateOutput.d.ts.map +1 -0
  40. package/dist/core/validateOutput.js +7 -1
  41. package/dist/core/validateOutput.js.map +1 -1
  42. package/dist/errors.d.ts +14 -0
  43. package/dist/errors.d.ts.map +1 -0
  44. package/dist/errors.js +24 -0
  45. package/dist/errors.js.map +1 -0
  46. package/dist/index.d.ts +20 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +19 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/types.d.ts +53 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +1 -1
  53. package/dist/types.js.map +1 -1
  54. package/docs/deploy-cloudflare.md +40 -14
  55. package/package.json +24 -2
  56. package/src/cli.ts +86 -75
  57. package/src/commands/build.ts +199 -185
  58. package/src/commands/verify.ts +266 -236
  59. package/src/core/generateWorker.ts +97 -97
  60. package/src/core/loadConfig.ts +261 -173
  61. package/src/core/loadMarkdown.ts +9 -3
  62. package/src/core/renderHtml.ts +36 -12
  63. package/src/core/validateOutput.ts +335 -328
  64. package/src/errors.ts +48 -0
  65. package/src/index.ts +40 -0
  66. package/src/types.ts +4 -1
@@ -1,328 +1,335 @@
1
- export type ValidateParams = {
2
- html: string;
3
- outFile: string;
4
- routePath: string;
5
- /** Source markdown file path; included in error context when provided */
6
- sourcePath?: string;
7
- /**
8
- * Allowed JSON-LD @type values. Default allows Article, BlogPosting, WebPage.
9
- * Add types (e.g. FAQPage, Organization) if your renderer emits them.
10
- */
11
- allowedJsonLdTypes?: string[];
12
- };
13
-
14
- const DEFAULT_ALLOWED_JSON_LD_TYPES = ["Article", "BlogPosting", "WebPage"];
15
-
16
- function hasNonEmptyTitle(html: string): boolean {
17
- const m = html.match(/<title>([\s\S]*?)<\/title>/i);
18
- if (!m) return false;
19
- const text = (m[1] ?? "").trim();
20
- return text.length > 0;
21
- }
22
-
23
- function getMetaContent(html: string, name: string): string | null {
24
- const re = new RegExp(
25
- `<meta\\s+[^>]*name=["']${escapeRegExp(name)}["'][^>]*>`,
26
- "i"
27
- );
28
- const tag = html.match(re)?.[0];
29
- if (!tag) return null;
30
-
31
- const contentMatch = tag.match(/content=["']([^"']+)["']/i);
32
- return contentMatch?.[1]?.trim() ?? null;
33
- }
34
-
35
- function getLinkHref(html: string, rel: string): string | null {
36
- const re = new RegExp(
37
- `<link\\s+[^>]*rel=["']${escapeRegExp(rel)}["'][^>]*>`,
38
- "i"
39
- );
40
- const tag = html.match(re)?.[0];
41
- if (!tag) return null;
42
-
43
- const hrefMatch = tag.match(/href=["']([^"']+)["']/i);
44
- return hrefMatch?.[1]?.trim() ?? null;
45
- }
46
-
47
- function getOgContent(html: string, property: string): string | null {
48
- const re = new RegExp(
49
- `<meta\\s+[^>]*property=["']${escapeRegExp(property)}["'][^>]*>`,
50
- "i"
51
- );
52
- const tag = html.match(re)?.[0];
53
- if (!tag) return null;
54
-
55
- const contentMatch = tag.match(/content=["']([^"']+)["']/i);
56
- return contentMatch?.[1]?.trim() ?? null;
57
- }
58
-
59
- /** Returns all JSON-LD script tag contents (order preserved). Many pages emit multiple: WebPage, BreadcrumbList, Organization, etc. */
60
- function getAllJsonLdScripts(html: string): string[] {
61
- const re = /<script\s+[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
62
- const out: string[] = [];
63
- let m: RegExpExecArray | null;
64
- while ((m = re.exec(html)) !== null) {
65
- const content = (m[1] ?? "").trim();
66
- if (content.length > 0) out.push(content);
67
- }
68
- return out;
69
- }
70
-
71
- /** Normalize @type: schema.org allows string or array of strings. Return array of lowercase types. */
72
- function normalizeJsonLdTypes(typeValue: unknown): string[] {
73
- if (typeValue == null) return [];
74
- if (typeof typeValue === "string") return [typeValue.toLowerCase().trim()].filter(Boolean);
75
- if (Array.isArray(typeValue)) {
76
- return typeValue
77
- .filter((t) => typeof t === "string")
78
- .map((t) => (t as string).toLowerCase().trim())
79
- .filter(Boolean);
80
- }
81
- return [];
82
- }
83
-
84
- /** Minimal shape for a JSON-LD node we validate (schema.org Article, BlogPosting, WebPage, etc.). */
85
- type JsonLdNode = Record<string, unknown>;
86
-
87
- /** Validate a single JSON-LD node (object). Returns true if it satisfies the contract. */
88
- function validateJsonLdNode(
89
- node: JsonLdNode,
90
- location: string,
91
- allowedTypes: string[]
92
- ): void {
93
- const types = normalizeJsonLdTypes(node["@type"]);
94
- if (types.length === 0) {
95
- throw new Error(
96
- `Invalid JSON-LD at ${location}: missing or invalid @type. Required (string or array of strings).`
97
- );
98
- }
99
-
100
- const allowedSet = new Set(allowedTypes.map((t) => t.toLowerCase()));
101
- const hasAllowedType = types.some((t) => allowedSet.has(t));
102
- if (!hasAllowedType) {
103
- throw new Error(
104
- `Invalid JSON-LD at ${location}: @type "${String(node["@type"])}" is not in allowed list [${allowedTypes.join(", ")}]. Add it to allowedJsonLdTypes if your page uses this type.`
105
- );
106
- }
107
-
108
- const primaryType = types[0];
109
- const missing: string[] = [];
110
- if (!node["@context"]) missing.push("@context");
111
- if (!node["@type"]) missing.push("@type");
112
- if (!node.headline && !node.name) missing.push("headline or name");
113
- const articleLike = ["article", "blogposting"];
114
- if (articleLike.includes(primaryType) && !node.datePublished) {
115
- missing.push("datePublished");
116
- }
117
-
118
- if (missing.length > 0) {
119
- throw new Error(
120
- `Invalid JSON-LD at ${location}: missing required fields: ${missing.join(", ")}.`
121
- );
122
- }
123
-
124
- if (node.datePublished && typeof node.datePublished === "string") {
125
- const dateMatch = node.datePublished.match(/^\d{4}-\d{2}-\d{2}/);
126
- if (!dateMatch) {
127
- throw new Error(
128
- `Invalid JSON-LD at ${location}: datePublished must be YYYY-MM-DD or ISO 8601. Got: "${node.datePublished}"`
129
- );
130
- }
131
- }
132
- }
133
-
134
- /**
135
- * Validates JSON-LD: valid JSON, @type in allowed list, required fields.
136
- * Accepts single object or array of objects (at least one item must satisfy the contract).
137
- * @type may be string or array of strings (e.g. ["Article","NewsArticle"]).
138
- */
139
- function validateJsonLdSchema(
140
- jsonLd: string,
141
- context: { routePath: string; sourcePath?: string },
142
- allowedTypes: string[]
143
- ): void {
144
- const { routePath, sourcePath } = context;
145
- const location = sourcePath ? `route ${routePath} (source: ${sourcePath})` : `route ${routePath}`;
146
-
147
- let parsed: unknown;
148
- try {
149
- parsed = JSON.parse(jsonLd);
150
- } catch {
151
- const preview = jsonLd.length > 200 ? jsonLd.slice(0, 200) + "…" : jsonLd;
152
- throw new Error(
153
- `Invalid JSON-LD at ${location}: JSON parse error. Ensure the script tag contains valid JSON. Preview: ${preview}`
154
- );
155
- }
156
-
157
- const items: unknown[] = Array.isArray(parsed) ? parsed : [parsed];
158
- if (items.length === 0) {
159
- throw new Error(
160
- `Invalid JSON-LD at ${location}: empty array or missing object.`
161
- );
162
- }
163
-
164
- let lastErr: Error | null = null;
165
- for (let i = 0; i < items.length; i++) {
166
- const item = items[i];
167
- if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
168
- try {
169
- validateJsonLdNode(item as JsonLdNode, location, allowedTypes);
170
- return;
171
- } catch (e) {
172
- lastErr = e instanceof Error ? e : new Error(String(e));
173
- }
174
- }
175
- if (lastErr) throw lastErr;
176
- throw new Error(
177
- `Invalid JSON-LD at ${location}: no item in the array satisfies the required type contract (allowed: [${allowedTypes.join(", ")}]).`
178
- );
179
- }
180
-
181
- function getArticleInnerHtml(html: string): string | null {
182
- const m = html.match(/<article\b[^>]*>([\s\S]*?)<\/article>/i);
183
- if (!m) return null;
184
- return (m[1] ?? "").trim();
185
- }
186
-
187
- function stripTags(s: string): string {
188
- return s
189
- .replace(/<script[\s\S]*?<\/script>/gi, " ")
190
- .replace(/<style[\s\S]*?<\/style>/gi, " ")
191
- .replace(/<\/?[^>]+>/g, " ")
192
- .replace(/\s+/g, " ")
193
- .trim();
194
- }
195
-
196
- function wordCount(s: string): number {
197
- if (!s.trim()) return 0;
198
- return s.trim().split(/\s+/).filter(Boolean).length;
199
- }
200
-
201
- function escapeRegExp(s: string): string {
202
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
203
- }
204
-
205
- function formatErrorContext(params: ValidateParams): string {
206
- const lines: string[] = [];
207
- lines.push(`- routePath: ${params.routePath}`);
208
- lines.push(`- outFile: ${params.outFile}`);
209
- if (params.sourcePath) {
210
- lines.push(`- sourcePath: ${params.sourcePath}`);
211
- }
212
- return lines.join("\n");
213
- }
214
-
215
- export type ContractCheckResult = {
216
- ok: boolean;
217
- missing: string[];
218
- };
219
-
220
- /**
221
- * Runs the same contract checks as validatePrerenderHtml but returns a result instead of throwing.
222
- * Used by verify --prod to report whether production HTML satisfies the bot contract.
223
- */
224
- export function checkPrerenderContract(
225
- html: string,
226
- options: {
227
- routePath?: string;
228
- outFile?: string;
229
- sourcePath?: string;
230
- allowedJsonLdTypes?: string[];
231
- } = {}
232
- ): ContractCheckResult {
233
- const routePath = options.routePath ?? "(production)";
234
- const allowedJsonLdTypes = options.allowedJsonLdTypes ?? DEFAULT_ALLOWED_JSON_LD_TYPES;
235
- const missing = collectContractMissing(html, routePath, options.sourcePath, allowedJsonLdTypes);
236
- return { ok: missing.length === 0, missing };
237
- }
238
-
239
- export function validatePrerenderHtml(params: ValidateParams): void {
240
- const {
241
- html,
242
- routePath,
243
- sourcePath,
244
- allowedJsonLdTypes = DEFAULT_ALLOWED_JSON_LD_TYPES,
245
- } = params;
246
-
247
- const missing = collectContractMissing(html, routePath, sourcePath, allowedJsonLdTypes);
248
-
249
- if (missing.length > 0) {
250
- const context = formatErrorContext(params);
251
- const msg =
252
- `RenderShield validation failed for prerendered page:\n` +
253
- context +
254
- `\nMissing/invalid requirements:\n` +
255
- missing.map((m) => `- ${m}`).join("\n") +
256
- `\n\nFix the source content or renderer so bots receive complete HTML. Check frontmatter and template (title, excerpt, datePublished, coverImage, slug).`;
257
-
258
- throw new Error(msg);
259
- }
260
- }
261
-
262
- /** Shared contract checks; returns missing list. validatePrerenderHtml throws when missing.length > 0. */
263
- function collectContractMissing(
264
- html: string,
265
- routePath: string,
266
- sourcePath: string | undefined,
267
- allowedJsonLdTypes: string[]
268
- ): string[] {
269
- const missing: string[] = [];
270
-
271
- if (!hasNonEmptyTitle(html)) missing.push("Missing or empty <title>");
272
- const desc = getMetaContent(html, "description");
273
- if (!desc) missing.push('Missing <meta name="description" content="...">');
274
- const canonical = getLinkHref(html, "canonical");
275
- if (!canonical) missing.push('Missing <link rel="canonical" href="...">');
276
-
277
- const ogTitle = getOgContent(html, "og:title");
278
- const ogDesc = getOgContent(html, "og:description");
279
- const ogImg = getOgContent(html, "og:image");
280
- const ogUrl = getOgContent(html, "og:url");
281
- if (!ogTitle) missing.push("Missing Open Graph tag: og:title");
282
- if (!ogDesc) {
283
- missing.push("Missing Open Graph tag: og:description");
284
- } else if (ogDesc.length > 200) {
285
- missing.push(`Open Graph description too long (${ogDesc.length} chars). Max 200.`);
286
- }
287
- if (!ogImg) missing.push("Missing Open Graph tag: og:image");
288
- if (!ogUrl) missing.push("Missing Open Graph tag: og:url");
289
-
290
- const jsonLdScripts = getAllJsonLdScripts(html);
291
- if (jsonLdScripts.length === 0) {
292
- missing.push('Missing JSON-LD: <script type="application/ld+json">...</script>');
293
- } else {
294
- let onePassed = false;
295
- for (const scriptContent of jsonLdScripts) {
296
- if (scriptContent.length <= 20) continue;
297
- try {
298
- validateJsonLdSchema(
299
- scriptContent,
300
- { routePath, sourcePath },
301
- allowedJsonLdTypes
302
- );
303
- onePassed = true;
304
- break;
305
- } catch {
306
- // continue to next script
307
- }
308
- }
309
- if (!onePassed) missing.push("No JSON-LD script satisfied the required type contract.");
310
- }
311
-
312
- const articleInner = getArticleInnerHtml(html);
313
- if (!articleInner) {
314
- missing.push("Missing <article>...</article>");
315
- } else {
316
- const text = stripTags(articleInner);
317
- const words = wordCount(text);
318
- const okByChars = text.length >= 80;
319
- const okByWords = words >= 20;
320
- if (!okByChars && !okByWords) {
321
- missing.push(
322
- `Article content too short (${words} words, ${text.length} chars). Require >= 20 words or >= 80 chars.`
323
- );
324
- }
325
- }
326
-
327
- return missing;
328
- }
1
+ import { renderShieldError } from "../errors.js";
2
+
3
+ export type ValidateParams = {
4
+ html: string;
5
+ outFile: string;
6
+ routePath: string;
7
+ /** Source markdown file path; included in error context when provided */
8
+ sourcePath?: string;
9
+ /**
10
+ * Allowed JSON-LD @type values. Default allows Article, BlogPosting, WebPage.
11
+ * Add types (e.g. FAQPage, Organization) if your renderer emits them.
12
+ */
13
+ allowedJsonLdTypes?: string[];
14
+ };
15
+
16
+ const DEFAULT_ALLOWED_JSON_LD_TYPES = ["Article", "BlogPosting", "WebPage"];
17
+
18
+ function hasNonEmptyTitle(html: string): boolean {
19
+ const m = html.match(/<title>([\s\S]*?)<\/title>/i);
20
+ if (!m) return false;
21
+ const text = (m[1] ?? "").trim();
22
+ return text.length > 0;
23
+ }
24
+
25
+ function getMetaContent(html: string, name: string): string | null {
26
+ const re = new RegExp(
27
+ `<meta\\s+[^>]*name=["']${escapeRegExp(name)}["'][^>]*>`,
28
+ "i"
29
+ );
30
+ const tag = html.match(re)?.[0];
31
+ if (!tag) return null;
32
+
33
+ const contentMatch = tag.match(/content=["']([^"']+)["']/i);
34
+ return contentMatch?.[1]?.trim() ?? null;
35
+ }
36
+
37
+ function getLinkHref(html: string, rel: string): string | null {
38
+ const re = new RegExp(
39
+ `<link\\s+[^>]*rel=["']${escapeRegExp(rel)}["'][^>]*>`,
40
+ "i"
41
+ );
42
+ const tag = html.match(re)?.[0];
43
+ if (!tag) return null;
44
+
45
+ const hrefMatch = tag.match(/href=["']([^"']+)["']/i);
46
+ return hrefMatch?.[1]?.trim() ?? null;
47
+ }
48
+
49
+ function getOgContent(html: string, property: string): string | null {
50
+ const re = new RegExp(
51
+ `<meta\\s+[^>]*property=["']${escapeRegExp(property)}["'][^>]*>`,
52
+ "i"
53
+ );
54
+ const tag = html.match(re)?.[0];
55
+ if (!tag) return null;
56
+
57
+ const contentMatch = tag.match(/content=["']([^"']+)["']/i);
58
+ return contentMatch?.[1]?.trim() ?? null;
59
+ }
60
+
61
+ /** Returns all JSON-LD script tag contents (order preserved). Many pages emit multiple: WebPage, BreadcrumbList, Organization, etc. */
62
+ function getAllJsonLdScripts(html: string): string[] {
63
+ const re = /<script\s+[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
64
+ const out: string[] = [];
65
+ let m: RegExpExecArray | null;
66
+ while ((m = re.exec(html)) !== null) {
67
+ const content = (m[1] ?? "").trim();
68
+ if (content.length > 0) out.push(content);
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** Normalize @type: schema.org allows string or array of strings. Return array of lowercase types. */
74
+ function normalizeJsonLdTypes(typeValue: unknown): string[] {
75
+ if (typeValue == null) return [];
76
+ if (typeof typeValue === "string") return [typeValue.toLowerCase().trim()].filter(Boolean);
77
+ if (Array.isArray(typeValue)) {
78
+ return typeValue
79
+ .filter((t) => typeof t === "string")
80
+ .map((t) => (t as string).toLowerCase().trim())
81
+ .filter(Boolean);
82
+ }
83
+ return [];
84
+ }
85
+
86
+ /** Minimal shape for a JSON-LD node we validate (schema.org Article, BlogPosting, WebPage, etc.). */
87
+ type JsonLdNode = Record<string, unknown>;
88
+
89
+ /** Validate a single JSON-LD node (object). Returns true if it satisfies the contract. */
90
+ function validateJsonLdNode(
91
+ node: JsonLdNode,
92
+ location: string,
93
+ allowedTypes: string[]
94
+ ): void {
95
+ const types = normalizeJsonLdTypes(node["@type"]);
96
+ if (types.length === 0) {
97
+ throw new Error(
98
+ `Invalid JSON-LD at ${location}: missing or invalid @type. Required (string or array of strings).`
99
+ );
100
+ }
101
+
102
+ const allowedSet = new Set(allowedTypes.map((t) => t.toLowerCase()));
103
+ const hasAllowedType = types.some((t) => allowedSet.has(t));
104
+ if (!hasAllowedType) {
105
+ throw new Error(
106
+ `Invalid JSON-LD at ${location}: @type "${String(node["@type"])}" is not in allowed list [${allowedTypes.join(", ")}]. Add it to allowedJsonLdTypes if your page uses this type.`
107
+ );
108
+ }
109
+
110
+ const primaryType = types[0];
111
+ const missing: string[] = [];
112
+ if (!node["@context"]) missing.push("@context");
113
+ if (!node["@type"]) missing.push("@type");
114
+ if (!node.headline && !node.name) missing.push("headline or name");
115
+ const articleLike = ["article", "blogposting"];
116
+ if (articleLike.includes(primaryType) && !node.datePublished) {
117
+ missing.push("datePublished");
118
+ }
119
+
120
+ if (missing.length > 0) {
121
+ throw new Error(
122
+ `Invalid JSON-LD at ${location}: missing required fields: ${missing.join(", ")}.`
123
+ );
124
+ }
125
+
126
+ if (node.datePublished && typeof node.datePublished === "string") {
127
+ const dateMatch = node.datePublished.match(/^\d{4}-\d{2}-\d{2}/);
128
+ if (!dateMatch) {
129
+ throw new Error(
130
+ `Invalid JSON-LD at ${location}: datePublished must be YYYY-MM-DD or ISO 8601. Got: "${node.datePublished}"`
131
+ );
132
+ }
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Validates JSON-LD: valid JSON, @type in allowed list, required fields.
138
+ * Accepts single object or array of objects (at least one item must satisfy the contract).
139
+ * @type may be string or array of strings (e.g. ["Article","NewsArticle"]).
140
+ */
141
+ function validateJsonLdSchema(
142
+ jsonLd: string,
143
+ context: { routePath: string; sourcePath?: string },
144
+ allowedTypes: string[]
145
+ ): void {
146
+ const { routePath, sourcePath } = context;
147
+ const location = sourcePath ? `route ${routePath} (source: ${sourcePath})` : `route ${routePath}`;
148
+
149
+ let parsed: unknown;
150
+ try {
151
+ parsed = JSON.parse(jsonLd);
152
+ } catch {
153
+ const preview = jsonLd.length > 200 ? jsonLd.slice(0, 200) + "…" : jsonLd;
154
+ throw new Error(
155
+ `Invalid JSON-LD at ${location}: JSON parse error. Ensure the script tag contains valid JSON. Preview: ${preview}`
156
+ );
157
+ }
158
+
159
+ const items: unknown[] = Array.isArray(parsed) ? parsed : [parsed];
160
+ if (items.length === 0) {
161
+ throw new Error(
162
+ `Invalid JSON-LD at ${location}: empty array or missing object.`
163
+ );
164
+ }
165
+
166
+ let lastErr: Error | null = null;
167
+ for (let i = 0; i < items.length; i++) {
168
+ const item = items[i];
169
+ if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
170
+ try {
171
+ validateJsonLdNode(item as JsonLdNode, location, allowedTypes);
172
+ return;
173
+ } catch (e) {
174
+ lastErr = e instanceof Error ? e : new Error(String(e));
175
+ }
176
+ }
177
+ if (lastErr) throw lastErr;
178
+ throw new Error(
179
+ `Invalid JSON-LD at ${location}: no item in the array satisfies the required type contract (allowed: [${allowedTypes.join(", ")}]).`
180
+ );
181
+ }
182
+
183
+ function getArticleInnerHtml(html: string): string | null {
184
+ const m = html.match(/<article\b[^>]*>([\s\S]*?)<\/article>/i);
185
+ if (!m) return null;
186
+ return (m[1] ?? "").trim();
187
+ }
188
+
189
+ function stripTags(s: string): string {
190
+ return s
191
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
192
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
193
+ .replace(/<\/?[^>]+>/g, " ")
194
+ .replace(/\s+/g, " ")
195
+ .trim();
196
+ }
197
+
198
+ function wordCount(s: string): number {
199
+ if (!s.trim()) return 0;
200
+ return s.trim().split(/\s+/).filter(Boolean).length;
201
+ }
202
+
203
+ function escapeRegExp(s: string): string {
204
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
205
+ }
206
+
207
+ function formatErrorContext(params: ValidateParams): string {
208
+ const lines: string[] = [];
209
+ lines.push(`- routePath: ${params.routePath}`);
210
+ lines.push(`- outFile: ${params.outFile}`);
211
+ if (params.sourcePath) {
212
+ lines.push(`- sourcePath: ${params.sourcePath}`);
213
+ }
214
+ return lines.join("\n");
215
+ }
216
+
217
+ export type ContractCheckResult = {
218
+ ok: boolean;
219
+ missing: string[];
220
+ };
221
+
222
+ /**
223
+ * Runs the same contract checks as validatePrerenderHtml but returns a result instead of throwing.
224
+ * Used by verify --prod to report whether production HTML satisfies the bot contract.
225
+ */
226
+ export function checkPrerenderContract(
227
+ html: string,
228
+ options: {
229
+ routePath?: string;
230
+ outFile?: string;
231
+ sourcePath?: string;
232
+ allowedJsonLdTypes?: string[];
233
+ } = {}
234
+ ): ContractCheckResult {
235
+ const routePath = options.routePath ?? "(production)";
236
+ const allowedJsonLdTypes = options.allowedJsonLdTypes ?? DEFAULT_ALLOWED_JSON_LD_TYPES;
237
+ const missing = collectContractMissing(html, routePath, options.sourcePath, allowedJsonLdTypes);
238
+ return { ok: missing.length === 0, missing };
239
+ }
240
+
241
+ export function validatePrerenderHtml(params: ValidateParams): void {
242
+ const {
243
+ html,
244
+ routePath,
245
+ sourcePath,
246
+ allowedJsonLdTypes = DEFAULT_ALLOWED_JSON_LD_TYPES,
247
+ } = params;
248
+
249
+ const missing = collectContractMissing(html, routePath, sourcePath, allowedJsonLdTypes);
250
+
251
+ if (missing.length > 0) {
252
+ const context = formatErrorContext(params);
253
+ const msg =
254
+ `RenderShield validation failed for prerendered page:\n` +
255
+ context +
256
+ `\nMissing/invalid requirements:\n` +
257
+ missing.map((m) => `- ${m}`).join("\n") +
258
+ `\n\nFix the source content or renderer so bots receive complete HTML. Check frontmatter and template (title, excerpt, datePublished, coverImage, slug).`;
259
+
260
+ throw renderShieldError("VALIDATION_FAILED", msg, {
261
+ routePath: params.routePath,
262
+ outFile: params.outFile,
263
+ sourcePath: params.sourcePath,
264
+ missing,
265
+ });
266
+ }
267
+ }
268
+
269
+ /** Shared contract checks; returns missing list. validatePrerenderHtml throws when missing.length > 0. */
270
+ function collectContractMissing(
271
+ html: string,
272
+ routePath: string,
273
+ sourcePath: string | undefined,
274
+ allowedJsonLdTypes: string[]
275
+ ): string[] {
276
+ const missing: string[] = [];
277
+
278
+ if (!hasNonEmptyTitle(html)) missing.push("Missing or empty <title>");
279
+ const desc = getMetaContent(html, "description");
280
+ if (!desc) missing.push('Missing <meta name="description" content="...">');
281
+ const canonical = getLinkHref(html, "canonical");
282
+ if (!canonical) missing.push('Missing <link rel="canonical" href="...">');
283
+
284
+ const ogTitle = getOgContent(html, "og:title");
285
+ const ogDesc = getOgContent(html, "og:description");
286
+ const ogImg = getOgContent(html, "og:image");
287
+ const ogUrl = getOgContent(html, "og:url");
288
+ if (!ogTitle) missing.push("Missing Open Graph tag: og:title");
289
+ if (!ogDesc) {
290
+ missing.push("Missing Open Graph tag: og:description");
291
+ } else if (ogDesc.length > 200) {
292
+ missing.push(`Open Graph description too long (${ogDesc.length} chars). Max 200.`);
293
+ }
294
+ if (!ogImg) missing.push("Missing Open Graph tag: og:image");
295
+ if (!ogUrl) missing.push("Missing Open Graph tag: og:url");
296
+
297
+ const jsonLdScripts = getAllJsonLdScripts(html);
298
+ if (jsonLdScripts.length === 0) {
299
+ missing.push('Missing JSON-LD: <script type="application/ld+json">...</script>');
300
+ } else {
301
+ let onePassed = false;
302
+ for (const scriptContent of jsonLdScripts) {
303
+ if (scriptContent.length <= 20) continue;
304
+ try {
305
+ validateJsonLdSchema(
306
+ scriptContent,
307
+ { routePath, sourcePath },
308
+ allowedJsonLdTypes
309
+ );
310
+ onePassed = true;
311
+ break;
312
+ } catch {
313
+ // continue to next script
314
+ }
315
+ }
316
+ if (!onePassed) missing.push("No JSON-LD script satisfied the required type contract.");
317
+ }
318
+
319
+ const articleInner = getArticleInnerHtml(html);
320
+ if (!articleInner) {
321
+ missing.push("Missing <article>...</article>");
322
+ } else {
323
+ const text = stripTags(articleInner);
324
+ const words = wordCount(text);
325
+ const okByChars = text.length >= 80;
326
+ const okByWords = words >= 20;
327
+ if (!okByChars && !okByWords) {
328
+ missing.push(
329
+ `Article content too short (${words} words, ${text.length} chars). Require >= 20 words or >= 80 chars.`
330
+ );
331
+ }
332
+ }
333
+
334
+ return missing;
335
+ }