@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,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,123 @@ 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
+ /** Validate a single JSON-LD node (object). Returns true if it satisfies the contract. */
85
+ function validateJsonLdNode(
86
+ node: any,
87
+ location: string,
88
+ allowedTypes: string[]
89
+ ): void {
90
+ const types = normalizeJsonLdTypes(node["@type"]);
91
+ if (types.length === 0) {
92
+ throw new Error(
93
+ `Invalid JSON-LD at ${location}: missing or invalid @type. Required (string or array of strings).`
94
+ );
95
+ }
96
+
97
+ const allowedSet = new Set(allowedTypes.map((t) => t.toLowerCase()));
98
+ const hasAllowedType = types.some((t) => allowedSet.has(t));
99
+ if (!hasAllowedType) {
100
+ throw new Error(
101
+ `Invalid JSON-LD at ${location}: @type "${node["@type"]}" is not in allowed list [${allowedTypes.join(", ")}]. Add it to allowedJsonLdTypes if your page uses this type.`
102
+ );
103
+ }
104
+
105
+ const primaryType = types[0];
106
+ const missing: string[] = [];
107
+ if (!node["@context"]) missing.push("@context");
108
+ if (!node["@type"]) missing.push("@type");
109
+ if (!node.headline && !node.name) missing.push("headline or name");
110
+ const articleLike = ["article", "blogposting"];
111
+ if (articleLike.includes(primaryType) && !node.datePublished) {
112
+ missing.push("datePublished");
113
+ }
114
+
115
+ if (missing.length > 0) {
116
+ throw new Error(
117
+ `Invalid JSON-LD at ${location}: missing required fields: ${missing.join(", ")}.`
118
+ );
119
+ }
120
+
121
+ if (node.datePublished && typeof node.datePublished === "string") {
122
+ const dateMatch = node.datePublished.match(/^\d{4}-\d{2}-\d{2}/);
123
+ if (!dateMatch) {
124
+ throw new Error(
125
+ `Invalid JSON-LD at ${location}: datePublished must be YYYY-MM-DD or ISO 8601. Got: "${node.datePublished}"`
126
+ );
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Validates JSON-LD: valid JSON, @type in allowed list, required fields.
133
+ * Accepts single object or array of objects (at least one item must satisfy the contract).
134
+ * @type may be string or array of strings (e.g. ["Article","NewsArticle"]).
135
+ */
136
+ function validateJsonLdSchema(
137
+ jsonLd: string,
138
+ context: { routePath: string; sourcePath?: string },
139
+ allowedTypes: string[]
140
+ ): void {
141
+ const { routePath, sourcePath } = context;
142
+ const location = sourcePath ? `route ${routePath} (source: ${sourcePath})` : `route ${routePath}`;
143
+
144
+ let parsed: any;
145
+ try {
146
+ parsed = JSON.parse(jsonLd);
147
+ } catch (err) {
148
+ const preview = jsonLd.length > 200 ? jsonLd.slice(0, 200) + "…" : jsonLd;
149
+ throw new Error(
150
+ `Invalid JSON-LD at ${location}: JSON parse error. Ensure the script tag contains valid JSON. Preview: ${preview}`
151
+ );
152
+ }
153
+
154
+ const items: any[] = Array.isArray(parsed) ? parsed : [parsed];
155
+ if (items.length === 0) {
156
+ throw new Error(
157
+ `Invalid JSON-LD at ${location}: empty array or missing object.`
158
+ );
159
+ }
160
+
161
+ let lastErr: Error | null = null;
162
+ for (let i = 0; i < items.length; i++) {
163
+ const item = items[i];
164
+ if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
165
+ try {
166
+ validateJsonLdNode(item, location, allowedTypes);
167
+ return;
168
+ } catch (e) {
169
+ lastErr = e instanceof Error ? e : new Error(String(e));
170
+ }
171
+ }
172
+ if (lastErr) throw lastErr;
173
+ throw new Error(
174
+ `Invalid JSON-LD at ${location}: no item in the array satisfies the required type contract (allowed: [${allowedTypes.join(", ")}]).`
54
175
  );
55
- if (!m) return null;
56
- return (m[1] ?? "").trim();
57
176
  }
58
177
 
59
178
  function getArticleInnerHtml(html: string): string | null {
@@ -80,69 +199,127 @@ function escapeRegExp(s: string): string {
80
199
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
81
200
  }
82
201
 
202
+ function formatErrorContext(params: ValidateParams): string {
203
+ const lines: string[] = [];
204
+ lines.push(`- routePath: ${params.routePath}`);
205
+ lines.push(`- outFile: ${params.outFile}`);
206
+ if (params.sourcePath) {
207
+ lines.push(`- sourcePath: ${params.sourcePath}`);
208
+ }
209
+ return lines.join("\n");
210
+ }
211
+
212
+ export type ContractCheckResult = {
213
+ ok: boolean;
214
+ missing: string[];
215
+ };
216
+
217
+ /**
218
+ * Runs the same contract checks as validatePrerenderHtml but returns a result instead of throwing.
219
+ * Used by verify --prod to report whether production HTML satisfies the bot contract.
220
+ */
221
+ export function checkPrerenderContract(
222
+ html: string,
223
+ options: {
224
+ routePath?: string;
225
+ outFile?: string;
226
+ sourcePath?: string;
227
+ allowedJsonLdTypes?: string[];
228
+ } = {}
229
+ ): ContractCheckResult {
230
+ const routePath = options.routePath ?? "(production)";
231
+ const allowedJsonLdTypes = options.allowedJsonLdTypes ?? DEFAULT_ALLOWED_JSON_LD_TYPES;
232
+ const missing = collectContractMissing(html, routePath, options.sourcePath, allowedJsonLdTypes);
233
+ return { ok: missing.length === 0, missing };
234
+ }
235
+
83
236
  export function validatePrerenderHtml(params: ValidateParams): void {
84
- const { html, outFile, routePath } = params;
237
+ const {
238
+ html,
239
+ routePath,
240
+ sourcePath,
241
+ allowedJsonLdTypes = DEFAULT_ALLOWED_JSON_LD_TYPES,
242
+ } = params;
243
+
244
+ const missing = collectContractMissing(html, routePath, sourcePath, allowedJsonLdTypes);
245
+
246
+ if (missing.length > 0) {
247
+ const context = formatErrorContext(params);
248
+ const msg =
249
+ `RenderShield validation failed for prerendered page:\n` +
250
+ context +
251
+ `\nMissing/invalid requirements:\n` +
252
+ missing.map((m) => `- ${m}`).join("\n") +
253
+ `\n\nFix the source content or renderer so bots receive complete HTML. Check frontmatter and template (title, excerpt, datePublished, coverImage, slug).`;
254
+
255
+ throw new Error(msg);
256
+ }
257
+ }
85
258
 
259
+ /** Shared contract checks; returns missing list. validatePrerenderHtml throws when missing.length > 0. */
260
+ function collectContractMissing(
261
+ html: string,
262
+ routePath: string,
263
+ sourcePath: string | undefined,
264
+ allowedJsonLdTypes: string[]
265
+ ): string[] {
86
266
  const missing: string[] = [];
87
267
 
88
- // 1) Title
89
268
  if (!hasNonEmptyTitle(html)) missing.push("Missing or empty <title>");
90
-
91
- // 2) Meta description
92
269
  const desc = getMetaContent(html, "description");
93
270
  if (!desc) missing.push('Missing <meta name="description" content="...">');
94
-
95
- // 3) Canonical
96
271
  const canonical = getLinkHref(html, "canonical");
97
272
  if (!canonical) missing.push('Missing <link rel="canonical" href="...">');
98
273
 
99
- // 4) Open Graph tags
100
274
  const ogTitle = getOgContent(html, "og:title");
101
275
  const ogDesc = getOgContent(html, "og:description");
102
276
  const ogImg = getOgContent(html, "og:image");
103
277
  const ogUrl = getOgContent(html, "og:url");
104
-
105
278
  if (!ogTitle) missing.push("Missing Open Graph tag: og:title");
106
- if (!ogDesc) missing.push("Missing Open Graph tag: og:description");
279
+ if (!ogDesc) {
280
+ missing.push("Missing Open Graph tag: og:description");
281
+ } else if (ogDesc.length > 200) {
282
+ missing.push(`Open Graph description too long (${ogDesc.length} chars). Max 200.`);
283
+ }
107
284
  if (!ogImg) missing.push("Missing Open Graph tag: og:image");
108
285
  if (!ogUrl) missing.push("Missing Open Graph tag: og:url");
109
286
 
110
- // 5) JSON-LD
111
- const jsonLd = getJsonLd(html);
112
- if (!jsonLd) {
287
+ const jsonLdScripts = getAllJsonLdScripts(html);
288
+ if (jsonLdScripts.length === 0) {
113
289
  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");
290
+ } else {
291
+ let onePassed = false;
292
+ for (const scriptContent of jsonLdScripts) {
293
+ if (scriptContent.length <= 20) continue;
294
+ try {
295
+ validateJsonLdSchema(
296
+ scriptContent,
297
+ { routePath, sourcePath },
298
+ allowedJsonLdTypes
299
+ );
300
+ onePassed = true;
301
+ break;
302
+ } catch {
303
+ // continue to next script
304
+ }
305
+ }
306
+ if (!onePassed) missing.push("No JSON-LD script satisfied the required type contract.");
116
307
  }
117
308
 
118
- // 6) Article content
119
309
  const articleInner = getArticleInnerHtml(html);
120
310
  if (!articleInner) {
121
311
  missing.push("Missing <article>...</article>");
122
312
  } else {
123
313
  const text = stripTags(articleInner);
124
314
  const words = wordCount(text);
125
-
126
- // Require either enough characters or enough words
127
315
  const okByChars = text.length >= 80;
128
316
  const okByWords = words >= 20;
129
-
130
317
  if (!okByChars && !okByWords) {
131
318
  missing.push(
132
- `Article content too short (got ${words} words, ${text.length} chars). Require >= 20 words or >= 80 chars.`
319
+ `Article content too short (${words} words, ${text.length} chars). Require >= 20 words or >= 80 chars.`
133
320
  );
134
321
  }
135
322
  }
136
323
 
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
- }
324
+ return missing;
148
325
  }