pagegraph 0.5.0 → 0.5.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,452 +0,0 @@
1
- //#region src/core/projections.ts
2
- const escapeXml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3
- /**
4
- * A node belongs in the sitemap when it declares a positive sitemap policy, is not
5
- * a redirect, is not robots-noindexed, and is not a param template (a route whose
6
- * path still contains a `$` segment — those exist only so their instances inherit).
7
- */
8
- function isSitemapEligible(node) {
9
- if (node.policy.redirectTo !== void 0) return false;
10
- if (node.policy.robots?.includes("noindex")) return false;
11
- if (!node.policy.sitemap) return false;
12
- if (node.path.includes("$")) return false;
13
- return true;
14
- }
15
- /** Canonical absolute URL for a node under the given origin. */
16
- function urlForNode(origin, node) {
17
- return node.path === "/" ? origin : `${origin}${node.path}`;
18
- }
19
- /**
20
- * Instance lastmod: the most recent date the page's frontmatter carries. A
21
- * collection whose instances carry no dates (docs, a manifest-driven gallery)
22
- * emits no `<lastmod>` at all.
23
- */
24
- function instanceLastmod(node) {
25
- const instance = node.instance;
26
- if (!instance) return void 0;
27
- const date = instance.modifiedAt ?? instance.publishedAt;
28
- return date ? new Date(date).toISOString() : void 0;
29
- }
30
- function renderUrlEntry(url, lastmod, node) {
31
- const { changeFrequency, priority } = node.policy.sitemap;
32
- const lines = [` <url>`, ` <loc>${escapeXml(url)}</loc>`];
33
- if (lastmod) lines.push(` <lastmod>${lastmod}</lastmod>`);
34
- lines.push(` <changefreq>${changeFrequency}</changefreq>`, ` <priority>${priority.toFixed(1)}</priority>`, ` </url>`);
35
- return lines.join("\n");
36
- }
37
- /**
38
- * Render sitemap.xml from the graph. Structural route entries emit no `<lastmod>`
39
- * (a route has no publish date); content instances emit it from their frontmatter.
40
- * Route entries are sorted by path, then instances follow in collection order.
41
- * `indexable` is intentionally unused — the sitemap body is host-independent;
42
- * robots.txt is what gates crawling.
43
- */
44
- function renderSitemap(graph, cfg) {
45
- const nodes = [...graph.nodes.values()];
46
- const staticNodes = nodes.filter((node) => node.source === "route" && isSitemapEligible(node)).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
47
- const instanceNodes = nodes.filter((node) => node.source !== "route" && isSitemapEligible(node));
48
- const seen = /* @__PURE__ */ new Set();
49
- const entries = [];
50
- for (const node of [...staticNodes, ...instanceNodes]) {
51
- const url = urlForNode(cfg.origin, node);
52
- const key = url.toLowerCase().replace(/\/$/, "");
53
- if (seen.has(key)) continue;
54
- seen.add(key);
55
- entries.push(renderUrlEntry(url, instanceLastmod(node), node));
56
- }
57
- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.join("\n")}\n</urlset>\n`;
58
- }
59
- /** Format a Content-Signal robots.txt directive from the preference list. */
60
- function contentSignal(value) {
61
- return `Content-Signal: ${value}`;
62
- }
63
- const groupLines = (cfg) => {
64
- const lines = [];
65
- if (cfg.contentSignal !== void 0 && cfg.contentSignal !== "") lines.push(contentSignal(cfg.contentSignal));
66
- for (const directive of cfg.directives ?? []) if (directive !== "") lines.push(directive);
67
- return lines;
68
- };
69
- /**
70
- * Render robots.txt. A non-indexable host (previews) gets a disallow-all
71
- * with no Sitemap line; an indexable host disallows exactly the prefixes the caller
72
- * passes. Pages that declare `robots: noindex` are intentionally NOT added as
73
- * Disallow entries — a Disallow would stop crawlers reaching the page to read its
74
- * `noindex, follow` meta, so the graph's per-node robots policy never feeds this
75
- * list. `graph` is unused — kept for signature parity with the other projections,
76
- * which callers load the graph once for and pass to each.
77
- *
78
- * Origin-wide group directives (`contentSignal`, `directives`) are indexable-host
79
- * only. {@link RobotsConfig.transform} always runs last so a consumer can override
80
- * the whole file.
81
- */
82
- function renderRobots(_graph, cfg) {
83
- const rendered = cfg.indexable ? [
84
- "User-agent: *",
85
- ...groupLines(cfg),
86
- "Allow: /",
87
- ...cfg.disallow.map((path) => `Disallow: ${path}`),
88
- "",
89
- `Sitemap: ${cfg.origin}/sitemap.xml`,
90
- `Host: ${cfg.origin}`,
91
- ""
92
- ].join("\n") : [
93
- "User-agent: *",
94
- "Disallow: /",
95
- ""
96
- ].join("\n");
97
- return cfg.transform === void 0 ? rendered : cfg.transform(rendered);
98
- }
99
- /** Inspect a single node: its declaration, sitemap eligibility, and edges. */
100
- function inspectNode(graph, path) {
101
- const node = graph.nodes.get(path);
102
- if (!node) return void 0;
103
- return {
104
- node,
105
- inSitemap: isSitemapEligible(node),
106
- incoming: graph.edges.filter((edge) => edge.to === path),
107
- outgoing: graph.edges.filter((edge) => edge.from === path)
108
- };
109
- }
110
- //#endregion
111
- //#region src/core/checks.ts
112
- /** A positive sitemap policy — the author asked for this page to be indexed. */
113
- const hasPositiveSitemap = (sitemap) => sitemap !== void 0 && sitemap !== false;
114
- /** Content and manifest instances carry page-level title/description. */
115
- const isInstance = (node) => node.source !== "route";
116
- /** Group instance nodes by a present, non-empty string field for duplicate detection. */
117
- const groupInstancesBy = (graph, field) => {
118
- const groups = /* @__PURE__ */ new Map();
119
- for (const node of graph.nodes.values()) {
120
- if (!isInstance(node)) continue;
121
- const value = field(node)?.trim();
122
- if (!value) continue;
123
- const bucket = groups.get(value);
124
- if (bucket) bucket.push(node);
125
- else groups.set(value, [node]);
126
- }
127
- return groups;
128
- };
129
- const DESCRIPTION_MAX = 160;
130
- const DESCRIPTION_MIN = 50;
131
- /**
132
- * Every check, in one place. `checkGraph` runs them in order and stamps each
133
- * finding with its rule name and severity, so the output is grouped by rule and
134
- * deterministic (nodes iterate in graph insertion order, edges in array order).
135
- */
136
- const CHECK_RULES = [
137
- {
138
- name: "path-owner-collision",
139
- severity: "structural",
140
- evaluate: (graph) => (graph.collisions ?? []).map((collision) => ({
141
- path: collision.path,
142
- message: `Canonical path "${collision.path}" is owned by multiple sources: ${collision.sources.join(", ")}.`,
143
- fix: "Give every concrete page one canonical path and one graph owner."
144
- }))
145
- },
146
- {
147
- name: "canonical-path-collision",
148
- severity: "structural",
149
- evaluate: (graph) => {
150
- const groups = /* @__PURE__ */ new Map();
151
- for (const path of graph.nodes.keys()) {
152
- const canonical = path.toLowerCase().replace(/\/$/, "") || "/";
153
- const paths = groups.get(canonical);
154
- if (paths) paths.push(path);
155
- else groups.set(canonical, [path]);
156
- }
157
- return [...groups.values()].filter((paths) => paths.length > 1).flatMap((paths) => paths.map((path) => ({
158
- path,
159
- message: `Canonical path collides with ${paths.filter((candidate) => candidate !== path).join(", ")}.`,
160
- fix: "Use one lowercase, trailing-slash-normalized canonical path."
161
- })));
162
- }
163
- },
164
- {
165
- name: "self-edge",
166
- severity: "structural",
167
- evaluate: (graph) => graph.edges.filter((edge) => edge.from === edge.to).map((edge) => ({
168
- path: edge.from,
169
- message: `${edge.type} edge points back to its own source node.`,
170
- fix: "Remove the self-reference from the canonical manifest or route declaration."
171
- }))
172
- },
173
- {
174
- name: "duplicate-edge",
175
- severity: "structural",
176
- evaluate: (graph) => {
177
- const seen = /* @__PURE__ */ new Set();
178
- const duplicates = [];
179
- for (const edge of graph.edges) {
180
- const key = `${edge.from}\u0000${edge.to}\u0000${edge.type}`;
181
- if (seen.has(key)) duplicates.push({
182
- path: edge.from,
183
- message: `Duplicate ${edge.type} edge from "${edge.from}" to "${edge.to}".`,
184
- fix: "Declare each graph relationship exactly once."
185
- });
186
- else seen.add(key);
187
- }
188
- return duplicates;
189
- }
190
- },
191
- {
192
- name: "dead-edge",
193
- severity: "structural",
194
- evaluate: (graph) => graph.edges.filter((edge) => edge.type !== "crumb-parent" && !graph.nodes.has(edge.to)).map((edge) => ({
195
- path: edge.from,
196
- message: `${edge.type} edge from "${edge.from}" points at "${edge.to}", which is not a node in the graph.`,
197
- fix: `Update the ${edge.type === "redirect" ? "redirectTo" : "related"} target on the "${edge.from}" route, or restore "${edge.to}".`
198
- }))
199
- },
200
- {
201
- name: "instance-missing-title",
202
- severity: "structural",
203
- evaluate: (graph) => [...graph.nodes.values()].filter((node) => isInstance(node) && !node.instance?.title.trim()).map((node) => ({
204
- path: node.path,
205
- message: `Content page "${node.path}" has no title.`,
206
- fix: "Add a `title` to the page frontmatter."
207
- }))
208
- },
209
- {
210
- name: "sitemap-noindex-contradiction",
211
- severity: "structural",
212
- evaluate: (graph) => [...graph.nodes.values()].filter((node) => hasPositiveSitemap(node.policy.sitemap) && node.policy.robots?.toLowerCase().includes("noindex")).map((node) => ({
213
- path: node.path,
214
- message: `"${node.path}" declares a sitemap policy but its robots value is "${node.policy.robots}".`,
215
- fix: "Drop the sitemap policy (or set `sitemap: false`) on a noindex page, or remove the noindex robots value."
216
- }))
217
- },
218
- {
219
- name: "robots-not-lowercase",
220
- severity: "structural",
221
- evaluate: (graph) => [...graph.nodes.values()].filter((node) => node.policy.robots !== void 0 && node.policy.robots !== node.policy.robots.toLowerCase()).map((node) => ({
222
- path: node.path,
223
- message: `"${node.path}" declares robots "${node.policy.robots}" — robots values must be lowercase.`,
224
- fix: "Lowercase the robots declaration (e.g. \"noindex, follow\")."
225
- }))
226
- },
227
- {
228
- name: "related-target-missing-link",
229
- severity: "structural",
230
- evaluate: (graph) => {
231
- const linklessTargets = /* @__PURE__ */ new Set();
232
- for (const edge of graph.edges) {
233
- if (edge.type !== "related") continue;
234
- const target = graph.nodes.get(edge.to);
235
- if (target && target.source === "route" && target.policy.link === void 0) linklessTargets.add(edge.to);
236
- }
237
- return [...linklessTargets].map((path) => ({
238
- path,
239
- message: `Route "${path}" is a related-link target but declares no link metadata; its card would render empty.`,
240
- fix: `Add \`link: { title, description }\` to the "${path}" route's staticData.seo.`
241
- }));
242
- }
243
- },
244
- {
245
- name: "redirect-in-sitemap",
246
- severity: "structural",
247
- evaluate: (graph) => [...graph.nodes.values()].filter((node) => node.policy.redirectTo !== void 0 && hasPositiveSitemap(node.policy.sitemap)).map((node) => ({
248
- path: node.path,
249
- message: `Redirect "${node.path}" (→ "${node.policy.redirectTo}") also declares a sitemap policy.`,
250
- fix: "Remove the sitemap policy from the redirect route; only its target belongs in the sitemap."
251
- }))
252
- },
253
- {
254
- name: "duplicate-title",
255
- severity: "editorial",
256
- evaluate: (graph) => [...groupInstancesBy(graph, (node) => node.instance?.title).entries()].filter(([, nodes]) => nodes.length > 1).flatMap(([title, nodes]) => nodes.map((node) => ({
257
- path: node.path,
258
- message: `Title "${title}" is shared by ${nodes.length} pages.`,
259
- fix: "Give each page a distinct title."
260
- })))
261
- },
262
- {
263
- name: "duplicate-description",
264
- severity: "editorial",
265
- evaluate: (graph) => [...groupInstancesBy(graph, (node) => node.instance?.description).entries()].filter(([, nodes]) => nodes.length > 1).flatMap(([, nodes]) => nodes.map((node) => ({
266
- path: node.path,
267
- message: `Description is shared by ${nodes.length} pages.`,
268
- fix: "Write a distinct meta description for each page."
269
- })))
270
- },
271
- {
272
- name: "description-length",
273
- severity: "editorial",
274
- evaluate: (graph) => [...graph.nodes.values()].flatMap((node) => {
275
- if (!isInstance(node)) return [];
276
- const description = node.instance?.description?.trim();
277
- if (!description) return [];
278
- if (description.length > DESCRIPTION_MAX) return [{
279
- path: node.path,
280
- message: `Description is ${description.length} chars (max ${DESCRIPTION_MAX}); it will be truncated in results.`,
281
- fix: `Trim the description to ${DESCRIPTION_MAX} characters or fewer.`
282
- }];
283
- if (description.length < DESCRIPTION_MIN) return [{
284
- path: node.path,
285
- message: `Description is ${description.length} chars (min ${DESCRIPTION_MIN}); it reads as thin.`,
286
- fix: `Expand the description to at least ${DESCRIPTION_MIN} characters.`
287
- }];
288
- return [];
289
- })
290
- }
291
- ];
292
- /** Run every rule against the graph and return the flat list of violations. */
293
- function checkGraph(graph) {
294
- return CHECK_RULES.flatMap((rule) => rule.evaluate(graph).map((raw) => ({
295
- severity: rule.severity,
296
- rule: rule.name,
297
- path: raw.path,
298
- message: raw.message,
299
- fix: raw.fix
300
- })));
301
- }
302
- /** Structural violations fail `pagegraph check`; editorial-only stays green. */
303
- const hasStructuralViolations = (violations) => violations.some((violation) => violation.severity === "structural");
304
- //#endregion
305
- //#region src/core/inspect-html.ts
306
- const ENTITIES = {
307
- "&amp;": "&",
308
- "&lt;": "<",
309
- "&gt;": ">",
310
- "&quot;": "\"",
311
- "&#39;": "'",
312
- "&apos;": "'"
313
- };
314
- const decodeEntities = (value) => value.replace(/&(?:amp|lt|gt|quot|#39|apos);/g, (match) => ENTITIES[match] ?? match);
315
- /** Pull double/single-quoted attributes off a single tag string. */
316
- const parseAttrs = (tag) => {
317
- const attrs = {};
318
- const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
319
- let match;
320
- while ((match = re.exec(tag)) !== null) attrs[match[1].toLowerCase()] = decodeEntities(match[2] ?? match[3] ?? "");
321
- return attrs;
322
- };
323
- /** Normalize a JSON-LD `@type` (string or array) to a single readable label. */
324
- const typeName = (value) => {
325
- if (typeof value === "string") return value;
326
- if (Array.isArray(value)) return value.filter((v) => typeof v === "string").join(", ") || "unknown";
327
- return "unknown";
328
- };
329
- const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
330
- /** Flatten a parsed JSON-LD payload (single object, array, or `@graph`) to items. */
331
- const collectItems = (parsed) => {
332
- if (Array.isArray(parsed)) return parsed.filter(isObject);
333
- if (isObject(parsed)) {
334
- if (Array.isArray(parsed["@graph"])) return parsed["@graph"].filter(isObject);
335
- return [parsed];
336
- }
337
- return [];
338
- };
339
- const countQuestions = (mainEntity) => {
340
- if (!Array.isArray(mainEntity)) return 0;
341
- return mainEntity.filter((entry) => isObject(entry) && typeName(entry["@type"]) === "Question").length;
342
- };
343
- const validateItemListElements = (value) => {
344
- if (!Array.isArray(value) || value.length < 1) return ["ItemList needs at least one `itemListElement` entry."];
345
- const errors = [];
346
- value.forEach((entry, index) => {
347
- if (!isObject(entry) || typeName(entry["@type"]) !== "ListItem") {
348
- errors.push(`ItemList entry ${index + 1} is not a ListItem.`);
349
- return;
350
- }
351
- if (entry["position"] !== index + 1) errors.push(`ItemList entry ${index + 1} has an invalid position.`);
352
- if (!entry["name"] || !entry["url"]) errors.push(`ItemList entry ${index + 1} needs a name and URL.`);
353
- });
354
- return errors;
355
- };
356
- /** Minimal per-type validation — enough to catch an empty or malformed block. */
357
- const validateItem = (item) => {
358
- const type = typeName(item["@type"]);
359
- const errors = [];
360
- if (type === "Article" || type === "NewsArticle" || type === "BlogPosting") {
361
- if (!item["headline"]) errors.push("Article is missing `headline`.");
362
- if (!item["datePublished"]) errors.push("Article is missing `datePublished`.");
363
- } else if (type === "FAQPage") {
364
- if (countQuestions(item["mainEntity"]) < 1) errors.push("FAQPage needs at least one Question in `mainEntity`.");
365
- } else if (type === "BreadcrumbList") {
366
- const items = item["itemListElement"];
367
- if (!Array.isArray(items) || items.length < 2) errors.push("BreadcrumbList needs at least two `itemListElement` entries.");
368
- } else if (type === "ItemList") {
369
- errors.push(...validateItemListElements(item["itemListElement"]));
370
- const items = item["itemListElement"];
371
- if (Array.isArray(items) && item["numberOfItems"] !== items.length) errors.push("ItemList `numberOfItems` does not match its entries.");
372
- }
373
- return {
374
- type,
375
- valid: errors.length === 0,
376
- errors
377
- };
378
- };
379
- const validateLdJson = (raw) => {
380
- let parsed;
381
- try {
382
- parsed = JSON.parse(raw);
383
- } catch (cause) {
384
- return [{
385
- type: "unparseable",
386
- valid: false,
387
- errors: [`JSON parse error: ${cause instanceof Error ? cause.message : String(cause)}`]
388
- }];
389
- }
390
- const items = collectItems(parsed);
391
- if (items.length === 0) return [{
392
- type: "unknown",
393
- valid: false,
394
- errors: ["No JSON-LD object found in block."]
395
- }];
396
- return items.map(validateItem);
397
- };
398
- /** Parse a rendered HTML document's `<head>` into a report. Pure. */
399
- const inspectHtml = (url, status, html) => {
400
- const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
401
- const head = headMatch ? headMatch[1] : html;
402
- const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
403
- const title = titleMatch ? decodeEntities(titleMatch[1].trim()) : void 0;
404
- const og = {};
405
- const twitter = {};
406
- let description;
407
- let robots;
408
- for (const tag of head.match(/<meta\b[^>]*>/gi) ?? []) {
409
- const attrs = parseAttrs(tag);
410
- const content = attrs["content"];
411
- if (content === void 0) continue;
412
- const property = attrs["property"];
413
- const name = attrs["name"];
414
- if (property?.startsWith("og:")) og[property] = content;
415
- else if (name?.startsWith("twitter:")) twitter[name] = content;
416
- else if (name === "description") description = content;
417
- else if (name === "robots") robots = content;
418
- }
419
- let canonical;
420
- for (const tag of head.match(/<link\b[^>]*>/gi) ?? []) {
421
- const attrs = parseAttrs(tag);
422
- if (attrs["rel"] === "canonical") canonical = attrs["href"];
423
- }
424
- const jsonLd = [];
425
- const scriptRe = /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
426
- let scriptMatch;
427
- while ((scriptMatch = scriptRe.exec(head)) !== null) jsonLd.push(...validateLdJson(scriptMatch[1].trim()));
428
- const issues = [];
429
- if (status >= 400) issues.push(`Fetch returned HTTP ${status}.`);
430
- if (!title) issues.push("Missing <title>.");
431
- if (!description) issues.push("Missing meta description.");
432
- if (!canonical) issues.push("Missing canonical link.");
433
- for (const block of jsonLd) if (!block.valid) issues.push(...block.errors.map((error) => `JSON-LD (${block.type}): ${error}`));
434
- return {
435
- url,
436
- status,
437
- title,
438
- description,
439
- canonical,
440
- robots,
441
- og,
442
- twitter,
443
- jsonLd,
444
- issues
445
- };
446
- };
447
- /** A non-empty `issues` list fails `pagegraph inspect --live` (exit 1). */
448
- const hasBlockingIssues = (report) => report.issues.length > 0;
449
- //#endregion
450
- export { contentSignal as a, renderSitemap as c, hasStructuralViolations as i, inspectHtml as n, inspectNode as o, checkGraph as r, renderRobots as s, hasBlockingIssues as t };
451
-
452
- //# sourceMappingURL=inspect-html-CHuoiO2s.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"inspect-html-CHuoiO2s.js","names":[],"sources":["../src/core/projections.ts","../src/core/checks.ts","../src/core/inspect-html.ts"],"sourcesContent":["/**\n * Projections of the SEO graph: sitemap.xml, robots.txt, and single-node\n * inspection. Pure functions — the origin, the host's indexability, and the\n * robots disallow list are injected by the caller, never read from the\n * environment or a generated file.\n */\n\nimport type { SeoEdge, SeoGraph, SeoNode } from \"./graph\";\n\nexport interface ProjectionConfig {\n origin: string;\n indexable: boolean;\n}\n\nexport interface RobotsConfig extends ProjectionConfig {\n /** Path prefixes to disallow on an indexable host (e.g. the app-only groups). */\n disallow: ReadonlyArray<string>;\n /**\n * Origin-wide Content-Signal preferences\n * (https://contentsignals.org/), emitted as `Content-Signal: <value>` under\n * `User-agent: *` on an indexable host. Omit for none. The plugin never\n * invents a default — pass the policy you want, e.g.\n * `\"search=yes, ai-input=yes, ai-train=yes\"`.\n */\n contentSignal?: string | undefined;\n /**\n * Extra full lines in the indexable `User-agent: *` group, after\n * Content-Signal (if any) and before Allow/Disallow. Use for directives the\n * plugin does not model, or to compose {@link contentSignal} yourself.\n */\n directives?: ReadonlyArray<string> | undefined;\n /**\n * Last-mile override: receives the rendered robots.txt and returns the file\n * to emit. Runs for indexable and preview hosts. Use when you need to wrap\n * or replace the default body rather than add group lines.\n */\n transform?: ((robots: string) => string) | undefined;\n}\n\nexport interface NodeReport {\n node: SeoNode;\n inSitemap: boolean;\n incoming: Array<SeoEdge>;\n outgoing: Array<SeoEdge>;\n}\n\nconst escapeXml = (value: string): string =>\n value\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&apos;\");\n\n/**\n * A node belongs in the sitemap when it declares a positive sitemap policy, is not\n * a redirect, is not robots-noindexed, and is not a param template (a route whose\n * path still contains a `$` segment — those exist only so their instances inherit).\n */\nfunction isSitemapEligible(node: SeoNode): boolean {\n if (node.policy.redirectTo !== undefined) return false;\n if (node.policy.robots?.includes(\"noindex\")) return false;\n if (!node.policy.sitemap) return false; // false or absent\n if (node.path.includes(\"$\")) return false;\n return true;\n}\n\n/** Canonical absolute URL for a node under the given origin. */\nfunction urlForNode(origin: string, node: SeoNode): string {\n return node.path === \"/\" ? origin : `${origin}${node.path}`;\n}\n\n/**\n * Instance lastmod: the most recent date the page's frontmatter carries. A\n * collection whose instances carry no dates (docs, a manifest-driven gallery)\n * emits no `<lastmod>` at all.\n */\nfunction instanceLastmod(node: SeoNode): string | undefined {\n const instance = node.instance;\n if (!instance) return undefined;\n const date = instance.modifiedAt ?? instance.publishedAt;\n return date ? new Date(date).toISOString() : undefined;\n}\n\nfunction renderUrlEntry(url: string, lastmod: string | undefined, node: SeoNode): string {\n const sitemap = node.policy.sitemap;\n // isSitemapEligible guarantees a positive policy before this runs.\n const { changeFrequency, priority } = sitemap as { changeFrequency: string; priority: number };\n const lines = [` <url>`, ` <loc>${escapeXml(url)}</loc>`];\n if (lastmod) lines.push(` <lastmod>${lastmod}</lastmod>`);\n lines.push(\n ` <changefreq>${changeFrequency}</changefreq>`,\n ` <priority>${priority.toFixed(1)}</priority>`,\n ` </url>`,\n );\n return lines.join(\"\\n\");\n}\n\n/**\n * Render sitemap.xml from the graph. Structural route entries emit no `<lastmod>`\n * (a route has no publish date); content instances emit it from their frontmatter.\n * Route entries are sorted by path, then instances follow in collection order.\n * `indexable` is intentionally unused — the sitemap body is host-independent;\n * robots.txt is what gates crawling.\n */\nexport function renderSitemap(graph: SeoGraph, cfg: ProjectionConfig): string {\n const nodes = [...graph.nodes.values()];\n const staticNodes = nodes\n .filter((node) => node.source === \"route\" && isSitemapEligible(node))\n .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));\n const instanceNodes = nodes.filter((node) => node.source !== \"route\" && isSitemapEligible(node));\n\n const seen = new Set<string>();\n const entries: Array<string> = [];\n for (const node of [...staticNodes, ...instanceNodes]) {\n const url = urlForNode(cfg.origin, node);\n const key = url.toLowerCase().replace(/\\/$/, \"\");\n if (seen.has(key)) continue;\n seen.add(key);\n entries.push(renderUrlEntry(url, instanceLastmod(node), node));\n }\n\n return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\\n${entries.join(\n \"\\n\",\n )}\\n</urlset>\\n`;\n}\n\n/** Format a Content-Signal robots.txt directive from the preference list. */\nexport function contentSignal(value: string): string {\n return `Content-Signal: ${value}`;\n}\n\nconst groupLines = (cfg: RobotsConfig): Array<string> => {\n const lines: Array<string> = [];\n if (cfg.contentSignal !== undefined && cfg.contentSignal !== \"\") {\n lines.push(contentSignal(cfg.contentSignal));\n }\n for (const directive of cfg.directives ?? []) {\n if (directive !== \"\") lines.push(directive);\n }\n return lines;\n};\n\n/**\n * Render robots.txt. A non-indexable host (previews) gets a disallow-all\n * with no Sitemap line; an indexable host disallows exactly the prefixes the caller\n * passes. Pages that declare `robots: noindex` are intentionally NOT added as\n * Disallow entries — a Disallow would stop crawlers reaching the page to read its\n * `noindex, follow` meta, so the graph's per-node robots policy never feeds this\n * list. `graph` is unused — kept for signature parity with the other projections,\n * which callers load the graph once for and pass to each.\n *\n * Origin-wide group directives (`contentSignal`, `directives`) are indexable-host\n * only. {@link RobotsConfig.transform} always runs last so a consumer can override\n * the whole file.\n */\nexport function renderRobots(_graph: SeoGraph, cfg: RobotsConfig): string {\n const rendered = cfg.indexable\n ? [\n \"User-agent: *\",\n ...groupLines(cfg),\n \"Allow: /\",\n ...cfg.disallow.map((path) => `Disallow: ${path}`),\n \"\",\n `Sitemap: ${cfg.origin}/sitemap.xml`,\n `Host: ${cfg.origin}`,\n \"\",\n ].join(\"\\n\")\n : [\"User-agent: *\", \"Disallow: /\", \"\"].join(\"\\n\");\n return cfg.transform === undefined ? rendered : cfg.transform(rendered);\n}\n\n/** Inspect a single node: its declaration, sitemap eligibility, and edges. */\nexport function inspectNode(graph: SeoGraph, path: string): NodeReport | undefined {\n const node = graph.nodes.get(path);\n if (!node) return undefined;\n return {\n node,\n inSitemap: isSitemapEligible(node),\n incoming: graph.edges.filter((edge) => edge.to === path),\n outgoing: graph.edges.filter((edge) => edge.from === path),\n };\n}\n","/**\n * The SEO check engine: a set of rules run against the derived {@link SeoGraph}.\n * Pure — no I/O, no `clientEnv`, no React. The CLI's `pagegraph check` command and the\n * vitest suite both call {@link checkGraph}; nothing else derives correctness.\n *\n * Two severities, mapped to the CLI's exit contract:\n * - `structural` — a declaration is internally broken (a link points nowhere, a\n * card would render empty, a page contradicts its own robots/sitemap intent).\n * Any structural violation fails `pagegraph check` (exit 1); these must not ship.\n * - `editorial` — a quality smell (duplicate or mis-sized titles/descriptions).\n * Reported as warnings; `pagegraph check` still exits 0 when only these are present.\n *\n * The graph only knows what declarations and frontmatter carry, so the rules are\n * scoped to that: per-node title/description live only on collection *instances*,\n * never on structural route nodes (whose head tags are composed at render time and\n * are not in the graph). Rules are data-driven and listed once in\n * {@link CHECK_RULES}.\n */\n\nimport type { SitemapPolicy } from \"./declare\";\nimport type { SeoGraph, SeoNode } from \"./graph\";\n\nexport type Severity = \"structural\" | \"editorial\";\n\nexport interface Violation {\n severity: Severity;\n rule: string;\n path?: string | undefined;\n message: string;\n fix?: string | undefined;\n}\n\n/** A single finding before its rule's `severity`/`rule` name are attached. */\ninterface RawViolation {\n path?: string | undefined;\n message: string;\n fix?: string | undefined;\n}\n\ninterface CheckRule {\n readonly name: string;\n readonly severity: Severity;\n readonly evaluate: (graph: SeoGraph) => ReadonlyArray<RawViolation>;\n}\n\n/** A positive sitemap policy — the author asked for this page to be indexed. */\nconst hasPositiveSitemap = (sitemap: SitemapPolicy | false | undefined): sitemap is SitemapPolicy =>\n sitemap !== undefined && sitemap !== false;\n\n/** Content and manifest instances carry page-level title/description. */\nconst isInstance = (node: SeoNode): boolean => node.source !== \"route\";\n\n/** Group instance nodes by a present, non-empty string field for duplicate detection. */\nconst groupInstancesBy = (\n graph: SeoGraph,\n field: (node: SeoNode) => string | undefined,\n): Map<string, Array<SeoNode>> => {\n const groups = new Map<string, Array<SeoNode>>();\n for (const node of graph.nodes.values()) {\n if (!isInstance(node)) continue;\n const value = field(node)?.trim();\n if (!value) continue;\n const bucket = groups.get(value);\n if (bucket) bucket.push(node);\n else groups.set(value, [node]);\n }\n return groups;\n};\n\nconst DESCRIPTION_MAX = 160;\nconst DESCRIPTION_MIN = 50;\n\n/**\n * Every check, in one place. `checkGraph` runs them in order and stamps each\n * finding with its rule name and severity, so the output is grouped by rule and\n * deterministic (nodes iterate in graph insertion order, edges in array order).\n */\nconst CHECK_RULES: ReadonlyArray<CheckRule> = [\n {\n name: \"path-owner-collision\",\n severity: \"structural\",\n evaluate: (graph) =>\n (graph.collisions ?? []).map((collision) => ({\n path: collision.path,\n message: `Canonical path \"${collision.path}\" is owned by multiple sources: ${collision.sources.join(\", \")}.`,\n fix: \"Give every concrete page one canonical path and one graph owner.\",\n })),\n },\n {\n name: \"canonical-path-collision\",\n severity: \"structural\",\n evaluate: (graph) => {\n const groups = new Map<string, Array<string>>();\n for (const path of graph.nodes.keys()) {\n const canonical = path.toLowerCase().replace(/\\/$/, \"\") || \"/\";\n const paths = groups.get(canonical);\n if (paths) paths.push(path);\n else groups.set(canonical, [path]);\n }\n return [...groups.values()]\n .filter((paths) => paths.length > 1)\n .flatMap((paths) =>\n paths.map((path) => ({\n path,\n message: `Canonical path collides with ${paths.filter((candidate) => candidate !== path).join(\", \")}.`,\n fix: \"Use one lowercase, trailing-slash-normalized canonical path.\",\n })),\n );\n },\n },\n {\n name: \"self-edge\",\n severity: \"structural\",\n evaluate: (graph) =>\n graph.edges\n .filter((edge) => edge.from === edge.to)\n .map((edge) => ({\n path: edge.from,\n message: `${edge.type} edge points back to its own source node.`,\n fix: \"Remove the self-reference from the canonical manifest or route declaration.\",\n })),\n },\n {\n name: \"duplicate-edge\",\n severity: \"structural\",\n evaluate: (graph) => {\n const seen = new Set<string>();\n const duplicates: Array<RawViolation> = [];\n for (const edge of graph.edges) {\n const key = `${edge.from}\\u0000${edge.to}\\u0000${edge.type}`;\n if (seen.has(key)) {\n duplicates.push({\n path: edge.from,\n message: `Duplicate ${edge.type} edge from \"${edge.from}\" to \"${edge.to}\".`,\n fix: \"Declare each graph relationship exactly once.\",\n });\n } else {\n seen.add(key);\n }\n }\n return duplicates;\n },\n },\n {\n // A `related` or `redirect` edge points at a path with no node — the target\n // route/page was renamed or deleted and the declaration wasn't updated.\n name: \"dead-edge\",\n severity: \"structural\",\n evaluate: (graph) =>\n graph.edges\n .filter((edge) => edge.type !== \"crumb-parent\" && !graph.nodes.has(edge.to))\n .map((edge) => ({\n path: edge.from,\n message: `${edge.type} edge from \"${edge.from}\" points at \"${edge.to}\", which is not a node in the graph.`,\n fix: `Update the ${edge.type === \"redirect\" ? \"redirectTo\" : \"related\"} target on the \"${edge.from}\" route, or restore \"${edge.to}\".`,\n })),\n },\n {\n // A content instance with no usable title can't render a legible <title> or card.\n name: \"instance-missing-title\",\n severity: \"structural\",\n evaluate: (graph) =>\n [...graph.nodes.values()]\n .filter((node) => isInstance(node) && !node.instance?.title.trim())\n .map((node) => ({\n path: node.path,\n message: `Content page \"${node.path}\" has no title.`,\n fix: \"Add a `title` to the page frontmatter.\",\n })),\n },\n {\n // The declaration asks for the page to be in the sitemap yet also marks it\n // noindex — contradictory intent. The projection resolves it (noindex wins,\n // excluded), but the declaration should say one thing.\n name: \"sitemap-noindex-contradiction\",\n severity: \"structural\",\n evaluate: (graph) =>\n [...graph.nodes.values()]\n .filter(\n (node) =>\n hasPositiveSitemap(node.policy.sitemap) &&\n node.policy.robots?.toLowerCase().includes(\"noindex\"),\n )\n .map((node) => ({\n path: node.path,\n message: `\"${node.path}\" declares a sitemap policy but its robots value is \"${node.policy.robots}\".`,\n fix: \"Drop the sitemap policy (or set `sitemap: false`) on a noindex page, or remove the noindex robots value.\",\n })),\n },\n {\n // Robots declarations are house-convention lowercase: the sitemap projection\n // matches `includes(\"noindex\")` literally, so a miscased value (\"Noindex\")\n // would silently stay sitemap-eligible. This gate makes that unrepresentable.\n name: \"robots-not-lowercase\",\n severity: \"structural\",\n evaluate: (graph) =>\n [...graph.nodes.values()]\n .filter(\n (node) =>\n node.policy.robots !== undefined &&\n node.policy.robots !== node.policy.robots.toLowerCase(),\n )\n .map((node) => ({\n path: node.path,\n message: `\"${node.path}\" declares robots \"${node.policy.robots}\" — robots values must be lowercase.`,\n fix: 'Lowercase the robots declaration (e.g. \"noindex, follow\").',\n })),\n },\n {\n // A `related` card for a route target renders from that route's `link`\n // metadata; without it the card has no title/description and renders empty.\n // (Content-instance targets render from their frontmatter, so they're exempt.)\n name: \"related-target-missing-link\",\n severity: \"structural\",\n evaluate: (graph) => {\n const linklessTargets = new Set<string>();\n for (const edge of graph.edges) {\n if (edge.type !== \"related\") continue;\n const target = graph.nodes.get(edge.to);\n if (target && target.source === \"route\" && target.policy.link === undefined) {\n linklessTargets.add(edge.to);\n }\n }\n return [...linklessTargets].map((path) => ({\n path,\n message: `Route \"${path}\" is a related-link target but declares no link metadata; its card would render empty.`,\n fix: `Add \\`link: { title, description }\\` to the \"${path}\" route's staticData.seo.`,\n }));\n },\n },\n {\n // A redirect/alias node should never advertise itself in the sitemap.\n name: \"redirect-in-sitemap\",\n severity: \"structural\",\n evaluate: (graph) =>\n [...graph.nodes.values()]\n .filter(\n (node) => node.policy.redirectTo !== undefined && hasPositiveSitemap(node.policy.sitemap),\n )\n .map((node) => ({\n path: node.path,\n message: `Redirect \"${node.path}\" (→ \"${node.policy.redirectTo}\") also declares a sitemap policy.`,\n fix: \"Remove the sitemap policy from the redirect route; only its target belongs in the sitemap.\",\n })),\n },\n {\n name: \"duplicate-title\",\n severity: \"editorial\",\n evaluate: (graph) =>\n [...groupInstancesBy(graph, (node) => node.instance?.title).entries()]\n .filter(([, nodes]) => nodes.length > 1)\n .flatMap(([title, nodes]) =>\n nodes.map((node) => ({\n path: node.path,\n message: `Title \"${title}\" is shared by ${nodes.length} pages.`,\n fix: \"Give each page a distinct title.\",\n })),\n ),\n },\n {\n name: \"duplicate-description\",\n severity: \"editorial\",\n evaluate: (graph) =>\n [...groupInstancesBy(graph, (node) => node.instance?.description).entries()]\n .filter(([, nodes]) => nodes.length > 1)\n .flatMap(([, nodes]) =>\n nodes.map((node) => ({\n path: node.path,\n message: `Description is shared by ${nodes.length} pages.`,\n fix: \"Write a distinct meta description for each page.\",\n })),\n ),\n },\n {\n // Meta descriptions outside ~50–160 chars either get truncated in SERPs or\n // read as too thin.\n name: \"description-length\",\n severity: \"editorial\",\n evaluate: (graph) =>\n [...graph.nodes.values()].flatMap((node) => {\n if (!isInstance(node)) return [];\n const description = node.instance?.description?.trim();\n if (!description) return [];\n if (description.length > DESCRIPTION_MAX) {\n return [\n {\n path: node.path,\n message: `Description is ${description.length} chars (max ${DESCRIPTION_MAX}); it will be truncated in results.`,\n fix: `Trim the description to ${DESCRIPTION_MAX} characters or fewer.`,\n },\n ];\n }\n if (description.length < DESCRIPTION_MIN) {\n return [\n {\n path: node.path,\n message: `Description is ${description.length} chars (min ${DESCRIPTION_MIN}); it reads as thin.`,\n fix: `Expand the description to at least ${DESCRIPTION_MIN} characters.`,\n },\n ];\n }\n return [];\n }),\n },\n];\n\n/** Run every rule against the graph and return the flat list of violations. */\nexport function checkGraph(graph: SeoGraph): Array<Violation> {\n return CHECK_RULES.flatMap((rule) =>\n rule.evaluate(graph).map((raw) => ({\n severity: rule.severity,\n rule: rule.name,\n path: raw.path,\n message: raw.message,\n fix: raw.fix,\n })),\n );\n}\n\n/** Structural violations fail `pagegraph check`; editorial-only stays green. */\nexport const hasStructuralViolations = (violations: ReadonlyArray<Violation>): boolean =>\n violations.some((violation) => violation.severity === \"structural\");\n","/**\n * Read a rendered `<head>` and validate it: title, meta\n * (description/robots/og/twitter), canonical link, and `application/ld+json`\n * blocks, with a minimal per-type JSON-LD check.\n *\n * This is the pure half of `pagegraph inspect --live` — the half worth having on its\n * own. The CLI fetches a URL and hands the body here; a test suite can render a\n * page and hand *that* here, asserting the head it actually ships. Both get the\n * same verdict, because it is the same function.\n *\n * No HTML-parsing dependency, by design: a `<head>` is small and well-formed, so\n * string/regex scanning is enough, and the package's core stays zero-dependency\n * (which is also why the object guard below is hand-rolled — `effect/Predicate`\n * is not reachable from this entry). It is honest about its limits: it does not\n * build a DOM, so exotic markup (commented-out tags, CDATA, attributes spanning\n * constructs) is out of scope. This validates *rendered output*, not arbitrary\n * HTML.\n *\n * `issues` are blocking: a non-empty list makes `pagegraph inspect --live` exit 1.\n * Required tags are `<title>`, `meta[name=description]`, and\n * `link[rel=canonical]`; any JSON-LD that fails to parse or fails its minimal\n * schema is also blocking.\n */\n\nexport interface JsonLdReport {\n type: string;\n valid: boolean;\n errors: Array<string>;\n}\n\nexport interface LiveHeadReport {\n url: string;\n status: number;\n title?: string | undefined;\n description?: string | undefined;\n canonical?: string | undefined;\n robots?: string | undefined;\n og: Record<string, string>;\n twitter: Record<string, string>;\n jsonLd: Array<JsonLdReport>;\n issues: Array<string>;\n}\n\nconst ENTITIES: Record<string, string> = {\n \"&amp;\": \"&\",\n \"&lt;\": \"<\",\n \"&gt;\": \">\",\n \"&quot;\": '\"',\n \"&#39;\": \"'\",\n \"&apos;\": \"'\",\n};\n\nconst decodeEntities = (value: string): string =>\n value.replace(/&(?:amp|lt|gt|quot|#39|apos);/g, (match) => ENTITIES[match] ?? match);\n\n/** Pull double/single-quoted attributes off a single tag string. */\nconst parseAttrs = (tag: string): Record<string, string> => {\n const attrs: Record<string, string> = {};\n const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')/g;\n let match: RegExpExecArray | null;\n while ((match = re.exec(tag)) !== null) {\n attrs[match[1]!.toLowerCase()] = decodeEntities(match[2] ?? match[3] ?? \"\");\n }\n return attrs;\n};\n\n/** Normalize a JSON-LD `@type` (string or array) to a single readable label. */\nconst typeName = (value: unknown): string => {\n if (typeof value === \"string\") return value;\n if (Array.isArray(value))\n return value.filter((v) => typeof v === \"string\").join(\", \") || \"unknown\";\n return \"unknown\";\n};\n\nconst isObject = (value: unknown): value is Record<string, unknown> =>\n // NOTE: This zero-dependency core entry cannot import Effect; JSON-LD validation happens immediately after this shallow narrowing.\n typeof value === \"object\" && value !== null && !Array.isArray(value);\n\n/** Flatten a parsed JSON-LD payload (single object, array, or `@graph`) to items. */\nconst collectItems = (parsed: unknown): Array<Record<string, unknown>> => {\n if (Array.isArray(parsed)) return parsed.filter(isObject);\n if (isObject(parsed)) {\n if (Array.isArray(parsed[\"@graph\"])) return parsed[\"@graph\"].filter(isObject);\n return [parsed];\n }\n return [];\n};\n\nconst countQuestions = (mainEntity: unknown): number => {\n if (!Array.isArray(mainEntity)) return 0;\n return mainEntity.filter((entry) => isObject(entry) && typeName(entry[\"@type\"]) === \"Question\")\n .length;\n};\n\nconst validateItemListElements = (value: unknown): Array<string> => {\n if (!Array.isArray(value) || value.length < 1) {\n return [\"ItemList needs at least one `itemListElement` entry.\"];\n }\n const errors: Array<string> = [];\n value.forEach((entry, index) => {\n if (!isObject(entry) || typeName(entry[\"@type\"]) !== \"ListItem\") {\n errors.push(`ItemList entry ${index + 1} is not a ListItem.`);\n return;\n }\n if (entry[\"position\"] !== index + 1) {\n errors.push(`ItemList entry ${index + 1} has an invalid position.`);\n }\n if (!entry[\"name\"] || !entry[\"url\"]) {\n errors.push(`ItemList entry ${index + 1} needs a name and URL.`);\n }\n });\n return errors;\n};\n\n/** Minimal per-type validation — enough to catch an empty or malformed block. */\nconst validateItem = (item: Record<string, unknown>): JsonLdReport => {\n const type = typeName(item[\"@type\"]);\n const errors: Array<string> = [];\n\n if (type === \"Article\" || type === \"NewsArticle\" || type === \"BlogPosting\") {\n if (!item[\"headline\"]) errors.push(\"Article is missing `headline`.\");\n if (!item[\"datePublished\"]) errors.push(\"Article is missing `datePublished`.\");\n } else if (type === \"FAQPage\") {\n if (countQuestions(item[\"mainEntity\"]) < 1) {\n errors.push(\"FAQPage needs at least one Question in `mainEntity`.\");\n }\n } else if (type === \"BreadcrumbList\") {\n const items = item[\"itemListElement\"];\n if (!Array.isArray(items) || items.length < 2) {\n errors.push(\"BreadcrumbList needs at least two `itemListElement` entries.\");\n }\n } else if (type === \"ItemList\") {\n errors.push(...validateItemListElements(item[\"itemListElement\"]));\n const items = item[\"itemListElement\"];\n if (Array.isArray(items) && item[\"numberOfItems\"] !== items.length) {\n errors.push(\"ItemList `numberOfItems` does not match its entries.\");\n }\n }\n\n return { type, valid: errors.length === 0, errors };\n};\n\nconst validateLdJson = (raw: string): Array<JsonLdReport> => {\n let parsed: unknown;\n // NOTE: JSON.parse boundary: converts the native parse throw into an unparseable JsonLdReport value in a pure sync validator\n try {\n parsed = JSON.parse(raw);\n } catch (cause) {\n return [\n {\n type: \"unparseable\",\n valid: false,\n errors: [`JSON parse error: ${cause instanceof Error ? cause.message : String(cause)}`],\n },\n ];\n }\n const items = collectItems(parsed);\n if (items.length === 0) {\n return [{ type: \"unknown\", valid: false, errors: [\"No JSON-LD object found in block.\"] }];\n }\n return items.map(validateItem);\n};\n\n/** Parse a rendered HTML document's `<head>` into a report. Pure. */\nexport const inspectHtml = (url: string, status: number, html: string): LiveHeadReport => {\n const headMatch = html.match(/<head[^>]*>([\\s\\S]*?)<\\/head>/i);\n const head = headMatch ? headMatch[1]! : html;\n\n const titleMatch = head.match(/<title[^>]*>([\\s\\S]*?)<\\/title>/i);\n const title = titleMatch ? decodeEntities(titleMatch[1]!.trim()) : undefined;\n\n const og: Record<string, string> = {};\n const twitter: Record<string, string> = {};\n let description: string | undefined;\n let robots: string | undefined;\n\n for (const tag of head.match(/<meta\\b[^>]*>/gi) ?? []) {\n const attrs = parseAttrs(tag);\n const content = attrs[\"content\"];\n if (content === undefined) continue;\n const property = attrs[\"property\"];\n const name = attrs[\"name\"];\n if (property?.startsWith(\"og:\")) og[property] = content;\n else if (name?.startsWith(\"twitter:\")) twitter[name] = content;\n else if (name === \"description\") description = content;\n else if (name === \"robots\") robots = content;\n }\n\n let canonical: string | undefined;\n for (const tag of head.match(/<link\\b[^>]*>/gi) ?? []) {\n const attrs = parseAttrs(tag);\n if (attrs[\"rel\"] === \"canonical\") canonical = attrs[\"href\"];\n }\n\n const jsonLd: Array<JsonLdReport> = [];\n const scriptRe = /<script\\b[^>]*type=[\"']application\\/ld\\+json[\"'][^>]*>([\\s\\S]*?)<\\/script>/gi;\n let scriptMatch: RegExpExecArray | null;\n while ((scriptMatch = scriptRe.exec(head)) !== null) {\n jsonLd.push(...validateLdJson(scriptMatch[1]!.trim()));\n }\n\n const issues: Array<string> = [];\n if (status >= 400) issues.push(`Fetch returned HTTP ${status}.`);\n if (!title) issues.push(\"Missing <title>.\");\n if (!description) issues.push(\"Missing meta description.\");\n if (!canonical) issues.push(\"Missing canonical link.\");\n for (const block of jsonLd) {\n if (!block.valid)\n issues.push(...block.errors.map((error) => `JSON-LD (${block.type}): ${error}`));\n }\n\n return { url, status, title, description, canonical, robots, og, twitter, jsonLd, issues };\n};\n\n/** A non-empty `issues` list fails `pagegraph inspect --live` (exit 1). */\nexport const hasBlockingIssues = (report: LiveHeadReport): boolean => report.issues.length > 0;\n"],"mappings":"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"}