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/README.md +80 -4
- package/dist/audit.d.ts +8 -3
- package/dist/audit.js +1931 -1
- package/dist/audit.js.map +1 -0
- package/dist/{graph-BlLoEOw2.d.ts → checks-BfsQtKga.d.ts} +43 -2
- package/dist/cli.js +41973 -35
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +7 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +131 -14
- package/dist/index.js +618 -2
- package/dist/index.js.map +1 -1
- package/dist/links-sGbLkl-7.js +184 -0
- package/dist/links-sGbLkl-7.js.map +1 -0
- package/package.json +13 -8
- package/dist/audit-B96V1x3q.js +0 -1929
- package/dist/audit-B96V1x3q.js.map +0 -1
- package/dist/inspect-html-CHuoiO2s.js +0 -452
- package/dist/inspect-html-CHuoiO2s.js.map +0 -1
- package/dist/main-GFEobQTH.js +0 -750
- package/dist/main-GFEobQTH.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
+
"&": "&",
|
|
612
|
+
"<": "<",
|
|
613
|
+
">": ">",
|
|
614
|
+
""": "\"",
|
|
615
|
+
"'": "'",
|
|
616
|
+
"'": "'"
|
|
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
|