@lownoise-studio/rendershield 0.1.6 → 0.3.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.
@@ -1,74 +1,173 @@
1
- import fs from "fs-extra";
2
- import path from "node:path";
3
- import { RenderShieldConfig } from "../types.js";
4
-
5
- const CONFIG_NAME = "rendershield.config.json";
6
-
7
- type BoolFlag = { enabled: boolean };
8
-
9
- function isObject(v: unknown): v is Record<string, unknown> {
10
- return typeof v === "object" && v !== null && !Array.isArray(v);
11
- }
12
-
13
- function coerceBoolFlag(
14
- parsed: any,
15
- key: "sitemap" | "robots" | "worker",
16
- defaultEnabled: boolean
17
- ): BoolFlag {
18
- // If missing, provide defaults (keeps older configs from exploding)
19
- if (parsed?.[key] == null) return { enabled: defaultEnabled };
20
-
21
- // If present, validate shape
22
- const v = parsed[key];
23
- if (!isObject(v)) {
24
- throw new Error(`${key} must be an object like { "enabled": true }`);
25
- }
26
- if (typeof (v as any).enabled !== "boolean") {
27
- throw new Error(`${key}.enabled must be a boolean`);
28
- }
29
- return { enabled: (v as any).enabled };
30
- }
31
-
32
- export async function loadConfig(
33
- cwd = process.cwd()
34
- ): Promise<RenderShieldConfig> {
35
- const p = path.join(cwd, CONFIG_NAME);
36
- const exists = await fs.pathExists(p);
37
- if (!exists) {
38
- throw new Error(`Missing ${CONFIG_NAME}. Run: rendershield init`);
39
- }
40
-
41
- const raw = await fs.readFile(p, "utf8");
42
- let parsed: any;
43
- try {
44
- parsed = JSON.parse(raw);
45
- } catch {
46
- throw new Error(`${CONFIG_NAME} is not valid JSON`);
47
- }
48
-
49
- // Minimal validation (v0)
50
- if (parsed?.version !== 1) throw new Error(`Config version must be 1`);
51
- if (!parsed?.site?.canonicalBase)
52
- throw new Error(`site.canonicalBase is required`);
53
- if (!parsed?.site?.siteName) throw new Error(`site.siteName is required`);
54
- if (!parsed?.site?.defaultOgImage)
55
- throw new Error(`site.defaultOgImage is required`);
56
- if (!parsed?.site?.authorName)
57
- throw new Error(`site.authorName is required`);
58
- if (!parsed?.content?.markdown?.baseDir)
59
- throw new Error(`content.markdown.baseDir is required`);
60
- if (
61
- !Array.isArray(parsed?.content?.markdown?.collections) ||
62
- parsed.content.markdown.collections.length === 0
63
- ) {
64
- throw new Error(`content.markdown.collections must be a non-empty array`);
65
- }
66
- if (!parsed?.output?.outDir) throw new Error(`output.outDir is required`);
67
-
68
- // Validate + default these optional sections to prevent TypeErrors later
69
- parsed.sitemap = coerceBoolFlag(parsed, "sitemap", true);
70
- parsed.robots = coerceBoolFlag(parsed, "robots", true);
71
- parsed.worker = coerceBoolFlag(parsed, "worker", true);
72
-
73
- return parsed as RenderShieldConfig;
74
- }
1
+ import fs from "fs-extra";
2
+ import path from "node:path";
3
+ import { RenderShieldConfig } from "../types.js";
4
+
5
+ const CONFIG_NAME = "rendershield.config.json";
6
+
7
+ const DEFAULT_SITEMAP_PATH = "/sitemap.xml";
8
+ const DEFAULT_ROBOTS_PATH = "/robots.txt";
9
+
10
+ type BoolFlag = { enabled: boolean };
11
+
12
+ /** Shape of config as read from JSON (before normalization). Used for validation only. */
13
+ interface ParsedInput {
14
+ version?: unknown;
15
+ site?: { canonicalBase?: unknown; siteName?: unknown; defaultOgImage?: unknown; authorName?: unknown };
16
+ content?: { markdown?: { baseDir?: unknown; collections?: unknown[] } };
17
+ output?: { outDir?: unknown };
18
+ sitemap?: Record<string, unknown>;
19
+ robots?: Record<string, unknown>;
20
+ worker?: Record<string, unknown>;
21
+ }
22
+
23
+ function isObject(v: unknown): v is Record<string, unknown> {
24
+ return typeof v === "object" && v !== null && !Array.isArray(v);
25
+ }
26
+
27
+ function coerceBoolFlag(
28
+ parsed: Record<string, unknown>,
29
+ key: "sitemap" | "robots" | "worker",
30
+ defaultEnabled: boolean
31
+ ): BoolFlag {
32
+ // If missing, provide defaults (keeps older configs from exploding)
33
+ if (parsed?.[key] == null) return { enabled: defaultEnabled };
34
+
35
+ // If present, validate shape
36
+ const v = parsed[key];
37
+ if (!isObject(v)) {
38
+ throw new Error(`${key} must be an object like { "enabled": true }`);
39
+ }
40
+ const enabled = (v as Record<string, unknown>).enabled;
41
+ if (typeof enabled !== "boolean") {
42
+ throw new Error(`${key}.enabled must be a boolean`);
43
+ }
44
+ return { enabled };
45
+ }
46
+
47
+ /**
48
+ * Worker config policy: strict mode.
49
+ * When worker.enabled is true, all of lovableOrigin, rewriteRouteBases, and
50
+ * botUserAgentPatterns are required and must be non-empty. No defaults are
51
+ * supplied — build fails with a crisp error.
52
+ * lovableOrigin: must be http: or https: only (no file:, javascript:, protocol-relative).
53
+ * botUserAgentPatterns: substring match only (not regex); empty strings rejected to avoid overmatching.
54
+ */
55
+ function validateWorkerWhenEnabled(parsed: Record<string, unknown>): void {
56
+ const worker = parsed.worker as Record<string, unknown> | undefined;
57
+ if (!worker?.lovableOrigin || typeof worker.lovableOrigin !== "string") {
58
+ throw new Error(
59
+ `worker.lovableOrigin is required when worker.enabled is true. Example: "https://your-site.lovable.app"`
60
+ );
61
+ }
62
+ let url: URL;
63
+ try {
64
+ url = new URL(worker.lovableOrigin);
65
+ } catch {
66
+ throw new Error(
67
+ `worker.lovableOrigin must be a valid URL. Got: "${worker.lovableOrigin}"`
68
+ );
69
+ }
70
+ const scheme = url.protocol.toLowerCase();
71
+ if (scheme !== "http:" && scheme !== "https:") {
72
+ throw new Error(
73
+ `worker.lovableOrigin must use http or https. Got: "${url.protocol}"`
74
+ );
75
+ }
76
+ if (
77
+ !Array.isArray(worker.rewriteRouteBases) ||
78
+ worker.rewriteRouteBases.length === 0
79
+ ) {
80
+ throw new Error(
81
+ `worker.rewriteRouteBases must be a non-empty array when worker.enabled is true. Example: ["/blog/"]`
82
+ );
83
+ }
84
+ if (
85
+ !Array.isArray(worker.botUserAgentPatterns) ||
86
+ worker.botUserAgentPatterns.length === 0
87
+ ) {
88
+ throw new Error(
89
+ `worker.botUserAgentPatterns must be a non-empty array when worker.enabled is true. Example: ["googlebot", "bingbot"]`
90
+ );
91
+ }
92
+ // Substring match only; empty string would match every User-Agent
93
+ for (let i = 0; i < worker.botUserAgentPatterns.length; i++) {
94
+ const p = worker.botUserAgentPatterns[i];
95
+ if (typeof p !== "string" || p.trim() === "") {
96
+ throw new Error(
97
+ `worker.botUserAgentPatterns[${i}] must be a non-empty string (substring match). Empty or invalid entry would overmatch.`
98
+ );
99
+ }
100
+ }
101
+ }
102
+
103
+ export async function loadConfig(
104
+ cwd = process.cwd()
105
+ ): Promise<RenderShieldConfig> {
106
+ const p = path.join(cwd, CONFIG_NAME);
107
+ const exists = await fs.pathExists(p);
108
+ if (!exists) {
109
+ throw new Error(`Missing ${CONFIG_NAME}. Run: rendershield init`);
110
+ }
111
+
112
+ const raw = await fs.readFile(p, "utf8");
113
+ let parsed: ParsedInput & Record<string, unknown>;
114
+ try {
115
+ parsed = JSON.parse(raw) as ParsedInput & Record<string, unknown>;
116
+ } catch {
117
+ throw new Error(`${CONFIG_NAME} is not valid JSON`);
118
+ }
119
+
120
+ // Minimal validation (v0)
121
+ if (parsed?.version !== 1) throw new Error(`Config version must be 1`);
122
+ if (!parsed?.site?.canonicalBase)
123
+ throw new Error(`site.canonicalBase is required`);
124
+ if (!parsed?.site?.siteName) throw new Error(`site.siteName is required`);
125
+ if (!parsed?.site?.defaultOgImage)
126
+ throw new Error(`site.defaultOgImage is required`);
127
+ if (!parsed?.site?.authorName)
128
+ throw new Error(`site.authorName is required`);
129
+ if (!parsed?.content?.markdown?.baseDir)
130
+ throw new Error(`content.markdown.baseDir is required`);
131
+ if (
132
+ !Array.isArray(parsed?.content?.markdown?.collections) ||
133
+ parsed.content.markdown.collections.length === 0
134
+ ) {
135
+ throw new Error(`content.markdown.collections must be a non-empty array`);
136
+ }
137
+ if (!parsed?.output?.outDir) throw new Error(`output.outDir is required`);
138
+
139
+ // Validate + default these optional sections (preserve path so it is not lost)
140
+ const sitemapFlag = coerceBoolFlag(parsed, "sitemap", true);
141
+ const sitemapObj = parsed.sitemap as Record<string, unknown> | undefined;
142
+ const sitemapPathVal = sitemapObj?.path;
143
+ const sitemapPath =
144
+ typeof sitemapPathVal === "string" && sitemapPathVal.trim().startsWith("/")
145
+ ? sitemapPathVal.trim()
146
+ : DEFAULT_SITEMAP_PATH;
147
+ parsed.sitemap = { enabled: sitemapFlag.enabled, path: sitemapPath };
148
+
149
+ const robotsFlag = coerceBoolFlag(parsed, "robots", true);
150
+ const robotsObj = parsed.robots as Record<string, unknown> | undefined;
151
+ const robotsPathVal = robotsObj?.path;
152
+ const robotsPath =
153
+ typeof robotsPathVal === "string" && robotsPathVal.trim().startsWith("/")
154
+ ? robotsPathVal.trim()
155
+ : DEFAULT_ROBOTS_PATH;
156
+ parsed.robots = { enabled: robotsFlag.enabled, path: robotsPath };
157
+
158
+ const workerFlag = coerceBoolFlag(parsed, "worker", true);
159
+ if (workerFlag.enabled) {
160
+ validateWorkerWhenEnabled(parsed);
161
+ // Keep parsed.worker as the full object from JSON (already validated)
162
+ } else {
163
+ parsed.worker = {
164
+ enabled: false,
165
+ lovableOrigin: "",
166
+ rewriteRouteBases: [],
167
+ botUserAgentPatterns: [],
168
+ debugHeaders: false,
169
+ };
170
+ }
171
+
172
+ return parsed as RenderShieldConfig;
173
+ }
@@ -9,7 +9,7 @@ const md = new MarkdownIt({ html: false, linkify: true, typographer: true });
9
9
 
10
10
  const REQUIRED_FIELDS = "title, excerpt, datePublished, coverImage, slug";
11
11
 
12
- function requireString(value: any, field: string, file: string): string {
12
+ function requireString(value: unknown, field: string, file: string): string {
13
13
  if (typeof value !== "string" || value.trim().length === 0) {
14
14
  throw new Error(
15
15
  `Missing required frontmatter field "${field}" in ${file}. Required fields: ${REQUIRED_FIELDS}`
@@ -18,7 +18,7 @@ function requireString(value: any, field: string, file: string): string {
18
18
  return value.trim();
19
19
  }
20
20
 
21
- function normalizeDate(value: any, file: string): string {
21
+ function normalizeDate(value: unknown, file: string): string {
22
22
  if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
23
23
  return value;
24
24
  }
@@ -57,7 +57,11 @@ export function renderPageHtml(cfg: RenderShieldConfig, doc: MarkdownDoc): strin
57
57
  <meta name="twitter:description" content="${escapeHtml(description)}">
58
58
  <meta name="twitter:image" content="${escapeHtml(ogImageUrl)}">
59
59
 
60
- <script type="application/ld+json">${escapeHtml(JSON.stringify(jsonLd))}</script>
60
+ <script type="application/ld+json">${((): string => {
61
+ const raw = JSON.stringify(jsonLd);
62
+ // Case-insensitive: </script> and </SCRIPT> etc. must not break out of the tag
63
+ return raw.replace(/<\/script>/gi, "<\\/script>");
64
+ })()}</script>
61
65
  </head>
62
66
  <body>
63
67
  <main>
@@ -1,9 +1,18 @@
1
- type ValidateParams = {
1
+ export type ValidateParams = {
2
2
  html: string;
3
3
  outFile: string;
4
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[];
5
12
  };
6
13
 
14
+ const DEFAULT_ALLOWED_JSON_LD_TYPES = ["Article", "BlogPosting", "WebPage"];
15
+
7
16
  function hasNonEmptyTitle(html: string): boolean {
8
17
  const m = html.match(/<title>([\s\S]*?)<\/title>/i);
9
18
  if (!m) return false;
@@ -12,7 +21,6 @@ function hasNonEmptyTitle(html: string): boolean {
12
21
  }
13
22
 
14
23
  function getMetaContent(html: string, name: string): string | null {
15
- // matches: <meta name="description" content="...">
16
24
  const re = new RegExp(
17
25
  `<meta\\s+[^>]*name=["']${escapeRegExp(name)}["'][^>]*>`,
18
26
  "i"
@@ -48,12 +56,126 @@ function getOgContent(html: string, property: string): string | null {
48
56
  return contentMatch?.[1]?.trim() ?? null;
49
57
  }
50
58
 
51
- function getJsonLd(html: string): string | null {
52
- const m = html.match(
53
- /<script\s+[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/i
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(", ")}]).`
54
178
  );
55
- if (!m) return null;
56
- return (m[1] ?? "").trim();
57
179
  }
58
180
 
59
181
  function getArticleInnerHtml(html: string): string | null {
@@ -80,69 +202,127 @@ function escapeRegExp(s: string): string {
80
202
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
81
203
  }
82
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
+
83
239
  export function validatePrerenderHtml(params: ValidateParams): void {
84
- const { html, outFile, routePath } = params;
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
+ }
85
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[] {
86
269
  const missing: string[] = [];
87
270
 
88
- // 1) Title
89
271
  if (!hasNonEmptyTitle(html)) missing.push("Missing or empty <title>");
90
-
91
- // 2) Meta description
92
272
  const desc = getMetaContent(html, "description");
93
273
  if (!desc) missing.push('Missing <meta name="description" content="...">');
94
-
95
- // 3) Canonical
96
274
  const canonical = getLinkHref(html, "canonical");
97
275
  if (!canonical) missing.push('Missing <link rel="canonical" href="...">');
98
276
 
99
- // 4) Open Graph tags
100
277
  const ogTitle = getOgContent(html, "og:title");
101
278
  const ogDesc = getOgContent(html, "og:description");
102
279
  const ogImg = getOgContent(html, "og:image");
103
280
  const ogUrl = getOgContent(html, "og:url");
104
-
105
281
  if (!ogTitle) missing.push("Missing Open Graph tag: og:title");
106
- if (!ogDesc) missing.push("Missing Open Graph tag: og:description");
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
+ }
107
287
  if (!ogImg) missing.push("Missing Open Graph tag: og:image");
108
288
  if (!ogUrl) missing.push("Missing Open Graph tag: og:url");
109
289
 
110
- // 5) JSON-LD
111
- const jsonLd = getJsonLd(html);
112
- if (!jsonLd) {
290
+ const jsonLdScripts = getAllJsonLdScripts(html);
291
+ if (jsonLdScripts.length === 0) {
113
292
  missing.push('Missing JSON-LD: <script type="application/ld+json">...</script>');
114
- } else if (jsonLd.length <= 20) {
115
- missing.push("JSON-LD script present but too short/empty");
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.");
116
310
  }
117
311
 
118
- // 6) Article content
119
312
  const articleInner = getArticleInnerHtml(html);
120
313
  if (!articleInner) {
121
314
  missing.push("Missing <article>...</article>");
122
315
  } else {
123
316
  const text = stripTags(articleInner);
124
317
  const words = wordCount(text);
125
-
126
- // Require either enough characters or enough words
127
318
  const okByChars = text.length >= 80;
128
319
  const okByWords = words >= 20;
129
-
130
320
  if (!okByChars && !okByWords) {
131
321
  missing.push(
132
- `Article content too short (got ${words} words, ${text.length} chars). Require >= 20 words or >= 80 chars.`
322
+ `Article content too short (${words} words, ${text.length} chars). Require >= 20 words or >= 80 chars.`
133
323
  );
134
324
  }
135
325
  }
136
326
 
137
- if (missing.length > 0) {
138
- const msg =
139
- `RenderShield validation failed for prerendered page:\n` +
140
- `- routePath: ${routePath}\n` +
141
- `- outFile: ${outFile}\n` +
142
- `Missing/invalid requirements:\n` +
143
- missing.map((m) => `- ${m}`).join("\n") +
144
- `\n\nFix the source content or renderer so bots receive complete HTML.`;
145
-
146
- throw new Error(msg);
147
- }
327
+ return missing;
148
328
  }