pagegraph 0.5.0 → 0.6.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.
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as contentSignal, c as renderSitemap, i as hasStructuralViolations, n as inspectHtml, o as inspectNode, r as checkGraph, s as renderRobots, t as hasBlockingIssues } from "./inspect-html-CHuoiO2s.js";
1
+ import { i as normalizePath, n as diffLinkGraph, r as extractAnchors, t as buildRenderedGraph } from "./links-sGbLkl-7.js";
2
2
  //#region src/core/graph.ts
3
3
  /** Kind for instances whose collection route carries no declaration to inherit. */
4
4
  const FALLBACK_KIND = "page";
@@ -135,6 +135,622 @@ function buildSeoGraph(input) {
135
135
  };
136
136
  }
137
137
  //#endregion
138
+ //#region src/core/projections.ts
139
+ const escapeXml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
140
+ /**
141
+ * A node belongs in the sitemap when it declares a positive sitemap policy, is not
142
+ * a redirect, is not robots-noindexed, and is not a param template (a route whose
143
+ * path still contains a `$` segment — those exist only so their instances inherit).
144
+ */
145
+ function isSitemapEligible(node) {
146
+ if (node.policy.redirectTo !== void 0) return false;
147
+ if (node.policy.robots?.includes("noindex")) return false;
148
+ if (!node.policy.sitemap) return false;
149
+ if (node.path.includes("$")) return false;
150
+ return true;
151
+ }
152
+ /** Canonical absolute URL for a node under the given origin. */
153
+ function urlForNode(origin, node) {
154
+ return node.path === "/" ? origin : `${origin}${node.path}`;
155
+ }
156
+ /**
157
+ * Instance lastmod: the most recent date the page's frontmatter carries. A
158
+ * collection whose instances carry no dates (docs, a manifest-driven gallery)
159
+ * emits no `<lastmod>` at all.
160
+ */
161
+ function instanceLastmod(node) {
162
+ const instance = node.instance;
163
+ if (!instance) return void 0;
164
+ const date = instance.modifiedAt ?? instance.publishedAt;
165
+ return date ? new Date(date).toISOString() : void 0;
166
+ }
167
+ function renderUrlEntry(url, lastmod, node) {
168
+ const { changeFrequency, priority } = node.policy.sitemap;
169
+ const lines = [` <url>`, ` <loc>${escapeXml(url)}</loc>`];
170
+ if (lastmod) lines.push(` <lastmod>${lastmod}</lastmod>`);
171
+ lines.push(` <changefreq>${changeFrequency}</changefreq>`, ` <priority>${priority.toFixed(1)}</priority>`, ` </url>`);
172
+ return lines.join("\n");
173
+ }
174
+ /**
175
+ * Render sitemap.xml from the graph. Structural route entries emit no `<lastmod>`
176
+ * (a route has no publish date); content instances emit it from their frontmatter.
177
+ * Route entries are sorted by path, then instances follow in collection order.
178
+ * `indexable` is intentionally unused — the sitemap body is host-independent;
179
+ * robots.txt is what gates crawling.
180
+ */
181
+ function renderSitemap(graph, cfg) {
182
+ const nodes = [...graph.nodes.values()];
183
+ const staticNodes = nodes.filter((node) => node.source === "route" && isSitemapEligible(node)).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
184
+ const instanceNodes = nodes.filter((node) => node.source !== "route" && isSitemapEligible(node));
185
+ const seen = /* @__PURE__ */ new Set();
186
+ const entries = [];
187
+ for (const node of [...staticNodes, ...instanceNodes]) {
188
+ const url = urlForNode(cfg.origin, node);
189
+ const key = url.toLowerCase().replace(/\/$/, "");
190
+ if (seen.has(key)) continue;
191
+ seen.add(key);
192
+ entries.push(renderUrlEntry(url, instanceLastmod(node), node));
193
+ }
194
+ 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`;
195
+ }
196
+ /** Format a Content-Signal robots.txt directive from the preference list. */
197
+ function contentSignal(value) {
198
+ return `Content-Signal: ${value}`;
199
+ }
200
+ const groupLines = (cfg) => {
201
+ const lines = [];
202
+ if (cfg.contentSignal !== void 0 && cfg.contentSignal !== "") lines.push(contentSignal(cfg.contentSignal));
203
+ for (const directive of cfg.directives ?? []) if (directive !== "") lines.push(directive);
204
+ return lines;
205
+ };
206
+ /**
207
+ * Render robots.txt. A non-indexable host (previews) gets a disallow-all
208
+ * with no Sitemap line; an indexable host disallows exactly the prefixes the caller
209
+ * passes. Pages that declare `robots: noindex` are intentionally NOT added as
210
+ * Disallow entries — a Disallow would stop crawlers reaching the page to read its
211
+ * `noindex, follow` meta, so the graph's per-node robots policy never feeds this
212
+ * list. `graph` is unused — kept for signature parity with the other projections,
213
+ * which callers load the graph once for and pass to each.
214
+ *
215
+ * Origin-wide group directives (`contentSignal`, `directives`) are indexable-host
216
+ * only. {@link RobotsConfig.transform} always runs last so a consumer can override
217
+ * the whole file.
218
+ */
219
+ function renderRobots(_graph, cfg) {
220
+ const rendered = cfg.indexable ? [
221
+ "User-agent: *",
222
+ ...groupLines(cfg),
223
+ "Allow: /",
224
+ ...cfg.disallow.map((path) => `Disallow: ${path}`),
225
+ "",
226
+ `Sitemap: ${cfg.origin}/sitemap.xml`,
227
+ `Host: ${cfg.origin}`,
228
+ ""
229
+ ].join("\n") : [
230
+ "User-agent: *",
231
+ "Disallow: /",
232
+ ""
233
+ ].join("\n");
234
+ return cfg.transform === void 0 ? rendered : cfg.transform(rendered);
235
+ }
236
+ /** Inspect a single node: its declaration, sitemap eligibility, and edges. */
237
+ function inspectNode(graph, path) {
238
+ const node = graph.nodes.get(path);
239
+ if (!node) return void 0;
240
+ return {
241
+ node,
242
+ inSitemap: isSitemapEligible(node),
243
+ incoming: graph.edges.filter((edge) => edge.to === path),
244
+ outgoing: graph.edges.filter((edge) => edge.from === path)
245
+ };
246
+ }
247
+ //#endregion
248
+ //#region src/core/checks.ts
249
+ /** A positive sitemap policy — the author asked for this page to be indexed. */
250
+ const hasPositiveSitemap = (sitemap) => sitemap !== void 0 && sitemap !== false;
251
+ /** Content and manifest instances carry page-level title/description. */
252
+ const isInstance = (node) => node.source !== "route";
253
+ /** Group instance nodes by a present, non-empty string field for duplicate detection. */
254
+ const groupInstancesBy = (graph, field) => {
255
+ const groups = /* @__PURE__ */ new Map();
256
+ for (const node of graph.nodes.values()) {
257
+ if (!isInstance(node)) continue;
258
+ const value = field(node)?.trim();
259
+ if (!value) continue;
260
+ const bucket = groups.get(value);
261
+ if (bucket) bucket.push(node);
262
+ else groups.set(value, [node]);
263
+ }
264
+ return groups;
265
+ };
266
+ const DESCRIPTION_MAX = 160;
267
+ const DESCRIPTION_MIN = 50;
268
+ /**
269
+ * Every check, in one place. `checkGraph` runs them in order and stamps each
270
+ * finding with its rule name and severity, so the output is grouped by rule and
271
+ * deterministic (nodes iterate in graph insertion order, edges in array order).
272
+ */
273
+ const CHECK_RULES = [
274
+ {
275
+ name: "path-owner-collision",
276
+ severity: "structural",
277
+ evaluate: (graph) => (graph.collisions ?? []).map((collision) => ({
278
+ path: collision.path,
279
+ message: `Canonical path "${collision.path}" is owned by multiple sources: ${collision.sources.join(", ")}.`,
280
+ fix: "Give every concrete page one canonical path and one graph owner."
281
+ }))
282
+ },
283
+ {
284
+ name: "canonical-path-collision",
285
+ severity: "structural",
286
+ evaluate: (graph) => {
287
+ const groups = /* @__PURE__ */ new Map();
288
+ for (const path of graph.nodes.keys()) {
289
+ const canonical = path.toLowerCase().replace(/\/$/, "") || "/";
290
+ const paths = groups.get(canonical);
291
+ if (paths) paths.push(path);
292
+ else groups.set(canonical, [path]);
293
+ }
294
+ return [...groups.values()].filter((paths) => paths.length > 1).flatMap((paths) => paths.map((path) => ({
295
+ path,
296
+ message: `Canonical path collides with ${paths.filter((candidate) => candidate !== path).join(", ")}.`,
297
+ fix: "Use one lowercase, trailing-slash-normalized canonical path."
298
+ })));
299
+ }
300
+ },
301
+ {
302
+ name: "self-edge",
303
+ severity: "structural",
304
+ evaluate: (graph) => graph.edges.filter((edge) => edge.from === edge.to).map((edge) => ({
305
+ path: edge.from,
306
+ message: `${edge.type} edge points back to its own source node.`,
307
+ fix: "Remove the self-reference from the canonical manifest or route declaration."
308
+ }))
309
+ },
310
+ {
311
+ name: "duplicate-edge",
312
+ severity: "structural",
313
+ evaluate: (graph) => {
314
+ const seen = /* @__PURE__ */ new Set();
315
+ const duplicates = [];
316
+ for (const edge of graph.edges) {
317
+ const key = `${edge.from}\u0000${edge.to}\u0000${edge.type}`;
318
+ if (seen.has(key)) duplicates.push({
319
+ path: edge.from,
320
+ message: `Duplicate ${edge.type} edge from "${edge.from}" to "${edge.to}".`,
321
+ fix: "Declare each graph relationship exactly once."
322
+ });
323
+ else seen.add(key);
324
+ }
325
+ return duplicates;
326
+ }
327
+ },
328
+ {
329
+ name: "dead-edge",
330
+ severity: "structural",
331
+ evaluate: (graph) => graph.edges.filter((edge) => edge.type !== "crumb-parent" && !graph.nodes.has(edge.to)).map((edge) => ({
332
+ path: edge.from,
333
+ message: `${edge.type} edge from "${edge.from}" points at "${edge.to}", which is not a node in the graph.`,
334
+ fix: `Update the ${edge.type === "redirect" ? "redirectTo" : "related"} target on the "${edge.from}" route, or restore "${edge.to}".`
335
+ }))
336
+ },
337
+ {
338
+ name: "instance-missing-title",
339
+ severity: "structural",
340
+ evaluate: (graph) => [...graph.nodes.values()].filter((node) => isInstance(node) && !node.instance?.title.trim()).map((node) => ({
341
+ path: node.path,
342
+ message: `Content page "${node.path}" has no title.`,
343
+ fix: "Add a `title` to the page frontmatter."
344
+ }))
345
+ },
346
+ {
347
+ name: "sitemap-noindex-contradiction",
348
+ severity: "structural",
349
+ evaluate: (graph) => [...graph.nodes.values()].filter((node) => hasPositiveSitemap(node.policy.sitemap) && node.policy.robots?.toLowerCase().includes("noindex")).map((node) => ({
350
+ path: node.path,
351
+ message: `"${node.path}" declares a sitemap policy but its robots value is "${node.policy.robots}".`,
352
+ fix: "Drop the sitemap policy (or set `sitemap: false`) on a noindex page, or remove the noindex robots value."
353
+ }))
354
+ },
355
+ {
356
+ name: "robots-not-lowercase",
357
+ severity: "structural",
358
+ evaluate: (graph) => [...graph.nodes.values()].filter((node) => node.policy.robots !== void 0 && node.policy.robots !== node.policy.robots.toLowerCase()).map((node) => ({
359
+ path: node.path,
360
+ message: `"${node.path}" declares robots "${node.policy.robots}" — robots values must be lowercase.`,
361
+ fix: "Lowercase the robots declaration (e.g. \"noindex, follow\")."
362
+ }))
363
+ },
364
+ {
365
+ name: "related-target-missing-link",
366
+ severity: "structural",
367
+ evaluate: (graph) => {
368
+ const linklessTargets = /* @__PURE__ */ new Set();
369
+ for (const edge of graph.edges) {
370
+ if (edge.type !== "related") continue;
371
+ const target = graph.nodes.get(edge.to);
372
+ if (target && target.source === "route" && target.policy.link === void 0) linklessTargets.add(edge.to);
373
+ }
374
+ return [...linklessTargets].map((path) => ({
375
+ path,
376
+ message: `Route "${path}" is a related-link target but declares no link metadata; its card would render empty.`,
377
+ fix: `Add \`link: { title, description }\` to the "${path}" route's staticData.seo.`
378
+ }));
379
+ }
380
+ },
381
+ {
382
+ name: "redirect-in-sitemap",
383
+ severity: "structural",
384
+ evaluate: (graph) => [...graph.nodes.values()].filter((node) => node.policy.redirectTo !== void 0 && hasPositiveSitemap(node.policy.sitemap)).map((node) => ({
385
+ path: node.path,
386
+ message: `Redirect "${node.path}" (→ "${node.policy.redirectTo}") also declares a sitemap policy.`,
387
+ fix: "Remove the sitemap policy from the redirect route; only its target belongs in the sitemap."
388
+ }))
389
+ },
390
+ {
391
+ name: "duplicate-title",
392
+ severity: "editorial",
393
+ evaluate: (graph) => [...groupInstancesBy(graph, (node) => node.instance?.title).entries()].filter(([, nodes]) => nodes.length > 1).flatMap(([title, nodes]) => nodes.map((node) => ({
394
+ path: node.path,
395
+ message: `Title "${title}" is shared by ${nodes.length} pages.`,
396
+ fix: "Give each page a distinct title."
397
+ })))
398
+ },
399
+ {
400
+ name: "duplicate-description",
401
+ severity: "editorial",
402
+ evaluate: (graph) => [...groupInstancesBy(graph, (node) => node.instance?.description).entries()].filter(([, nodes]) => nodes.length > 1).flatMap(([, nodes]) => nodes.map((node) => ({
403
+ path: node.path,
404
+ message: `Description is shared by ${nodes.length} pages.`,
405
+ fix: "Write a distinct meta description for each page."
406
+ })))
407
+ },
408
+ {
409
+ name: "description-length",
410
+ severity: "editorial",
411
+ evaluate: (graph) => [...graph.nodes.values()].flatMap((node) => {
412
+ if (!isInstance(node)) return [];
413
+ const description = node.instance?.description?.trim();
414
+ if (!description) return [];
415
+ if (description.length > DESCRIPTION_MAX) return [{
416
+ path: node.path,
417
+ message: `Description is ${description.length} chars (max ${DESCRIPTION_MAX}); it will be truncated in results.`,
418
+ fix: `Trim the description to ${DESCRIPTION_MAX} characters or fewer.`
419
+ }];
420
+ if (description.length < DESCRIPTION_MIN) return [{
421
+ path: node.path,
422
+ message: `Description is ${description.length} chars (min ${DESCRIPTION_MIN}); it reads as thin.`,
423
+ fix: `Expand the description to at least ${DESCRIPTION_MIN} characters.`
424
+ }];
425
+ return [];
426
+ })
427
+ }
428
+ ];
429
+ /**
430
+ * Run every static rule against the graph, then any caller-supplied
431
+ * {@link CoverageRule}s, and return the flat list of violations. With no
432
+ * `coverage` option the result is exactly the static rule set.
433
+ */
434
+ function checkGraph(graph, options = {}) {
435
+ const violations = CHECK_RULES.flatMap((rule) => rule.evaluate(graph).map((raw) => ({
436
+ severity: rule.severity,
437
+ rule: rule.name,
438
+ path: raw.path,
439
+ message: raw.message,
440
+ fix: raw.fix
441
+ })));
442
+ const coverage = options.coverage;
443
+ if (coverage !== void 0 && coverage.length > 0) violations.push(...checkCoverage(graph, coverage));
444
+ return violations;
445
+ }
446
+ /** Escape a glob for `RegExp`, then expand `**`, `*`, and `?` to path-aware forms. */
447
+ const globToRegExp = (glob) => {
448
+ const source = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/\u0000/g, ".*");
449
+ return new RegExp(`^${source}$`);
450
+ };
451
+ /**
452
+ * Enforce contextual-link coverage rules: a named set of sitemap-eligible
453
+ * "money" pages each needs `minInbound` incoming `related` edges. Only
454
+ * `related` edges count — breadcrumb ancestry is navigation, not context.
455
+ *
456
+ * A rule that matches no sitemap-eligible page is itself a violation: a typo or
457
+ * a rule aimed at a noindex page would otherwise pass silently forever.
458
+ */
459
+ function checkCoverage(graph, rules) {
460
+ const violations = [];
461
+ for (const rule of rules) {
462
+ const matcher = globToRegExp(rule.path);
463
+ const matched = [...graph.nodes.values()].filter((node) => matcher.test(node.path));
464
+ const eligible = matched.filter(isSitemapEligible);
465
+ if (eligible.length === 0) {
466
+ violations.push({
467
+ severity: "structural",
468
+ rule: "coverage-rule-unmatched",
469
+ message: matched.length === 0 ? `Coverage rule "${rule.path}" matches no page in the graph.` : `Coverage rule "${rule.path}" matches only pages that are not sitemap-eligible.`,
470
+ fix: "Point the rule at a sitemap-eligible path, or drop it."
471
+ });
472
+ continue;
473
+ }
474
+ for (const node of eligible) {
475
+ const inbound = graph.edges.filter((edge) => edge.type === "related" && edge.to === node.path).length;
476
+ if (inbound >= rule.minInbound) continue;
477
+ violations.push({
478
+ severity: "structural",
479
+ rule: "inbound-link-coverage",
480
+ path: node.path,
481
+ message: `"${node.path}" has ${inbound} incoming contextual link(s); the coverage rule requires ${rule.minInbound}.`,
482
+ fix: `Add ${rule.minInbound - inbound} contextual (related) edge(s) pointing at "${node.path}".`
483
+ });
484
+ }
485
+ }
486
+ return violations;
487
+ }
488
+ /** Structural violations fail `pagegraph check`; editorial-only stays green. */
489
+ const hasStructuralViolations = (violations) => violations.some((violation) => violation.severity === "structural");
490
+ //#endregion
491
+ //#region src/core/link-candidates.ts
492
+ /**
493
+ * Canonical path key: query and hash stripped, trailing slashes removed, "/"
494
+ * preserved. This is the same path key the graph and the rendered-link core
495
+ * use, so a served anchor like `/blog/a?ref=nav` still matches the graph pair
496
+ * `/blog/a`.
497
+ */
498
+ const normalize = (path) => {
499
+ const trimmed = (path.split(/[?#]/, 1)[0] ?? "").replace(/\/+$/, "");
500
+ return trimmed === "" ? "/" : trimmed;
501
+ };
502
+ /** Directionless pair key, so an edge in either direction means "connected". */
503
+ const undirectedEdgeKey = (from, to) => {
504
+ const [a, b] = [normalize(from), normalize(to)].sort();
505
+ return `${a}\u0000${b}`;
506
+ };
507
+ /** First path segment, or undefined for the root page. */
508
+ const topSegment = (path) => {
509
+ const segment = path.split("/").filter(Boolean)[0];
510
+ return segment === void 0 || segment === "" ? void 0 : segment;
511
+ };
512
+ /** A page with no nested segment (`/`, `/pricing`, `/blog`) is root-level. */
513
+ const isRootLevel = (path) => path.split("/").filter(Boolean).length <= 1;
514
+ /**
515
+ * The cluster a pair shares, or undefined when they share none. Section-first:
516
+ * a shared top-level section wins. The kind fallback is local to root-level
517
+ * pages, so it never pairs two nested pages from different sections.
518
+ */
519
+ const clusterOf = (a, b) => {
520
+ const aSegment = topSegment(a.path);
521
+ const bSegment = topSegment(b.path);
522
+ if (aSegment !== void 0 && aSegment === bSegment) return {
523
+ key: aSegment,
524
+ reason: `same top-level section "/${aSegment}"`
525
+ };
526
+ if (isRootLevel(a.path) && isRootLevel(b.path) && a.kind === b.kind) return {
527
+ key: `kind:${a.kind}`,
528
+ reason: `same kind "${a.kind}"`
529
+ };
530
+ };
531
+ /** A `--cluster` filter matches a section by name, or a kind via its bare label. */
532
+ const matchesClusterFilter = (cluster, filter) => {
533
+ const normalized = filter.replace(/^\/+/, "").toLowerCase();
534
+ const key = cluster.toLowerCase();
535
+ return key === normalized || key === `kind:${normalized}`;
536
+ };
537
+ /** Human reason string for a candidate's source page, used when handing a plan to Jev. */
538
+ const candidateSourceText = (node) => node.instance?.description?.trim() || node.instance?.title?.trim() || node.policy.link?.description?.trim() || node.policy.link?.title?.trim() || node.path;
539
+ /**
540
+ * Enumerate reviewable contextual-link candidates from the declared graph.
541
+ *
542
+ * Excluded: self-pairs, pages that are not sitemap-eligible, pairs already
543
+ * declared as a `related` edge, and — when {@link LinkCandidateOptions.renderedEdges}
544
+ * is supplied — pairs already rendered as an anchor. Queries, hashes, and
545
+ * trailing slashes are normalized so both sides compare by the graph's path key.
546
+ */
547
+ const generateLinkCandidates = (graph, options = {}) => {
548
+ const limit = options.limit ?? 50;
549
+ const filters = options.clusters ?? [];
550
+ const rendered = new Set((options.renderedEdges ?? []).map((edge) => undirectedEdgeKey(edge.from, edge.to)));
551
+ const declared = new Set(graph.edges.filter((edge) => edge.type === "related").map((edge) => undirectedEdgeKey(edge.from, edge.to)));
552
+ const eligible = [...graph.nodes.values()].filter(isSitemapEligible);
553
+ const all = [];
554
+ for (let i = 0; i < eligible.length; i++) for (let j = i + 1; j < eligible.length; j++) {
555
+ const a = eligible[i];
556
+ const b = eligible[j];
557
+ const cluster = clusterOf(a, b);
558
+ if (cluster === void 0) continue;
559
+ if (filters.length > 0 && !filters.some((filter) => matchesClusterFilter(cluster.key, filter))) continue;
560
+ const key = undirectedEdgeKey(a.path, b.path);
561
+ if (declared.has(key) || rendered.has(key)) continue;
562
+ all.push({
563
+ source: a.path,
564
+ destination: b.path,
565
+ cluster: cluster.key,
566
+ reason: cluster.reason
567
+ });
568
+ all.push({
569
+ source: b.path,
570
+ destination: a.path,
571
+ cluster: cluster.key,
572
+ reason: cluster.reason
573
+ });
574
+ }
575
+ const compare = (x, y) => x.cluster < y.cluster ? -1 : x.cluster > y.cluster ? 1 : x.source < y.source ? -1 : x.source > y.source ? 1 : x.destination < y.destination ? -1 : x.destination > y.destination ? 1 : 0;
576
+ all.sort(compare);
577
+ const counts = /* @__PURE__ */ new Map();
578
+ for (const pair of all) counts.set(pair.cluster, (counts.get(pair.cluster) ?? 0) + 1);
579
+ const candidates = all.slice(0, limit);
580
+ return {
581
+ candidates,
582
+ total: all.length,
583
+ truncated: all.length > candidates.length,
584
+ clusters: [...counts.entries()].map(([key, count]) => ({
585
+ key,
586
+ candidates: count
587
+ }))
588
+ };
589
+ };
590
+ /**
591
+ * Decode a rendered-edge dump supplied to the CLI: either a bare array of
592
+ * `{ from, to }` edges, or an object carrying `edges` (or `internalEdges`).
593
+ * Throws on any other shape — this is user input, not a provider payload.
594
+ */
595
+ const decodeRenderedEdges = (input) => {
596
+ const array = Array.isArray(input) ? input : input !== null && typeof input === "object" && Array.isArray(input.edges) ? input.edges : input !== null && typeof input === "object" && Array.isArray(input.internalEdges) ? input.internalEdges : void 0;
597
+ if (array === void 0) throw new Error("expected an array of { from, to } edges, or an object with an `edges` array");
598
+ return array.map((item) => {
599
+ if (item === null || typeof item !== "object") throw new Error("each rendered edge must be an object with string `from` and `to`");
600
+ const { from, to } = item;
601
+ if (typeof from !== "string" || typeof to !== "string") throw new Error("each rendered edge must have string `from` and `to`");
602
+ return {
603
+ from,
604
+ to
605
+ };
606
+ });
607
+ };
608
+ //#endregion
609
+ //#region src/core/inspect-html.ts
610
+ const ENTITIES = {
611
+ "&amp;": "&",
612
+ "&lt;": "<",
613
+ "&gt;": ">",
614
+ "&quot;": "\"",
615
+ "&#39;": "'",
616
+ "&apos;": "'"
617
+ };
618
+ const decodeEntities = (value) => value.replace(/&(?:amp|lt|gt|quot|#39|apos);/g, (match) => ENTITIES[match] ?? match);
619
+ /** Pull double/single-quoted attributes off a single tag string. */
620
+ const parseAttrs = (tag) => {
621
+ const attrs = {};
622
+ const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
623
+ let match;
624
+ while ((match = re.exec(tag)) !== null) attrs[match[1].toLowerCase()] = decodeEntities(match[2] ?? match[3] ?? "");
625
+ return attrs;
626
+ };
627
+ /** Normalize a JSON-LD `@type` (string or array) to a single readable label. */
628
+ const typeName = (value) => {
629
+ if (typeof value === "string") return value;
630
+ if (Array.isArray(value)) return value.filter((v) => typeof v === "string").join(", ") || "unknown";
631
+ return "unknown";
632
+ };
633
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
634
+ /** Flatten a parsed JSON-LD payload (single object, array, or `@graph`) to items. */
635
+ const collectItems = (parsed) => {
636
+ if (Array.isArray(parsed)) return parsed.filter(isObject);
637
+ if (isObject(parsed)) {
638
+ if (Array.isArray(parsed["@graph"])) return parsed["@graph"].filter(isObject);
639
+ return [parsed];
640
+ }
641
+ return [];
642
+ };
643
+ const countQuestions = (mainEntity) => {
644
+ if (!Array.isArray(mainEntity)) return 0;
645
+ return mainEntity.filter((entry) => isObject(entry) && typeName(entry["@type"]) === "Question").length;
646
+ };
647
+ const validateItemListElements = (value) => {
648
+ if (!Array.isArray(value) || value.length < 1) return ["ItemList needs at least one `itemListElement` entry."];
649
+ const errors = [];
650
+ value.forEach((entry, index) => {
651
+ if (!isObject(entry) || typeName(entry["@type"]) !== "ListItem") {
652
+ errors.push(`ItemList entry ${index + 1} is not a ListItem.`);
653
+ return;
654
+ }
655
+ if (entry["position"] !== index + 1) errors.push(`ItemList entry ${index + 1} has an invalid position.`);
656
+ if (!entry["name"] || !entry["url"]) errors.push(`ItemList entry ${index + 1} needs a name and URL.`);
657
+ });
658
+ return errors;
659
+ };
660
+ /** Minimal per-type validation — enough to catch an empty or malformed block. */
661
+ const validateItem = (item) => {
662
+ const type = typeName(item["@type"]);
663
+ const errors = [];
664
+ if (type === "Article" || type === "NewsArticle" || type === "BlogPosting") {
665
+ if (!item["headline"]) errors.push("Article is missing `headline`.");
666
+ if (!item["datePublished"]) errors.push("Article is missing `datePublished`.");
667
+ } else if (type === "FAQPage") {
668
+ if (countQuestions(item["mainEntity"]) < 1) errors.push("FAQPage needs at least one Question in `mainEntity`.");
669
+ } else if (type === "BreadcrumbList") {
670
+ const items = item["itemListElement"];
671
+ if (!Array.isArray(items) || items.length < 2) errors.push("BreadcrumbList needs at least two `itemListElement` entries.");
672
+ } else if (type === "ItemList") {
673
+ errors.push(...validateItemListElements(item["itemListElement"]));
674
+ const items = item["itemListElement"];
675
+ if (Array.isArray(items) && item["numberOfItems"] !== items.length) errors.push("ItemList `numberOfItems` does not match its entries.");
676
+ }
677
+ return {
678
+ type,
679
+ valid: errors.length === 0,
680
+ errors
681
+ };
682
+ };
683
+ const validateLdJson = (raw) => {
684
+ let parsed;
685
+ try {
686
+ parsed = JSON.parse(raw);
687
+ } catch (cause) {
688
+ return [{
689
+ type: "unparseable",
690
+ valid: false,
691
+ errors: [`JSON parse error: ${cause instanceof Error ? cause.message : String(cause)}`]
692
+ }];
693
+ }
694
+ const items = collectItems(parsed);
695
+ if (items.length === 0) return [{
696
+ type: "unknown",
697
+ valid: false,
698
+ errors: ["No JSON-LD object found in block."]
699
+ }];
700
+ return items.map(validateItem);
701
+ };
702
+ /** Parse a rendered HTML document's `<head>` into a report. Pure. */
703
+ const inspectHtml = (url, status, html) => {
704
+ const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
705
+ const head = headMatch ? headMatch[1] : html;
706
+ const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
707
+ const title = titleMatch ? decodeEntities(titleMatch[1].trim()) : void 0;
708
+ const og = {};
709
+ const twitter = {};
710
+ let description;
711
+ let robots;
712
+ for (const tag of head.match(/<meta\b[^>]*>/gi) ?? []) {
713
+ const attrs = parseAttrs(tag);
714
+ const content = attrs["content"];
715
+ if (content === void 0) continue;
716
+ const property = attrs["property"];
717
+ const name = attrs["name"];
718
+ if (property?.startsWith("og:")) og[property] = content;
719
+ else if (name?.startsWith("twitter:")) twitter[name] = content;
720
+ else if (name === "description") description = content;
721
+ else if (name === "robots") robots = content;
722
+ }
723
+ let canonical;
724
+ for (const tag of head.match(/<link\b[^>]*>/gi) ?? []) {
725
+ const attrs = parseAttrs(tag);
726
+ if (attrs["rel"] === "canonical") canonical = attrs["href"];
727
+ }
728
+ const jsonLd = [];
729
+ const scriptRe = /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
730
+ let scriptMatch;
731
+ while ((scriptMatch = scriptRe.exec(head)) !== null) jsonLd.push(...validateLdJson(scriptMatch[1].trim()));
732
+ const issues = [];
733
+ if (status >= 400) issues.push(`Fetch returned HTTP ${status}.`);
734
+ if (!title) issues.push("Missing <title>.");
735
+ if (!description) issues.push("Missing meta description.");
736
+ if (!canonical) issues.push("Missing canonical link.");
737
+ for (const block of jsonLd) if (!block.valid) issues.push(...block.errors.map((error) => `JSON-LD (${block.type}): ${error}`));
738
+ return {
739
+ url,
740
+ status,
741
+ title,
742
+ description,
743
+ canonical,
744
+ robots,
745
+ og,
746
+ twitter,
747
+ jsonLd,
748
+ issues
749
+ };
750
+ };
751
+ /** A non-empty `issues` list fails `pagegraph inspect --live` (exit 1). */
752
+ const hasBlockingIssues = (report) => report.issues.length > 0;
753
+ //#endregion
138
754
  //#region src/core/resolve-route-link.ts
139
755
  /**
140
756
  * Resolve a route's declared `seo.link` card (title + description) by its full
@@ -155,6 +771,6 @@ function resolveRouteLink(router, path) {
155
771
  return Object.values(routesById).find((route) => route.fullPath === path)?.options.staticData?.seo?.link;
156
772
  }
157
773
  //#endregion
158
- export { buildSeoGraph, checkGraph, contentSignal, hasBlockingIssues, hasStructuralViolations, inspectHtml, inspectNode, renderRobots, renderSitemap, resolveRouteLink };
774
+ export { buildRenderedGraph, buildSeoGraph, candidateSourceText, checkCoverage, checkGraph, contentSignal, decodeRenderedEdges, diffLinkGraph, extractAnchors, generateLinkCandidates, hasBlockingIssues, hasStructuralViolations, inspectHtml, inspectNode, isSitemapEligible, matchesClusterFilter, normalizePath, renderRobots, renderSitemap, resolveRouteLink, undirectedEdgeKey };
159
775
 
160
776
  //# sourceMappingURL=index.js.map