pagegraph 0.6.0 → 0.6.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.
- package/README.md +62 -1
- package/dist/audit.js +1 -1
- package/dist/checks-6noG_xTS.d.ts +313 -0
- package/dist/cli.js +393 -90
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/index.d.ts +2 -75
- package/dist/index.js +47 -13
- package/dist/index.js.map +1 -1
- package/dist/{links-sGbLkl-7.js → links-BFJc-pJ9.js} +152 -18
- package/dist/links-BFJc-pJ9.js.map +1 -0
- package/package.json +1 -1
- package/dist/checks-BfsQtKga.d.ts +0 -163
- package/dist/links-sGbLkl-7.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import * as NFS from "node:fs";
|
|
3
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import * as NodeChildProcess from "node:child_process";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { PassThrough } from "node:stream";
|
|
@@ -34612,7 +34612,7 @@ const runWith = (command, config) => {
|
|
|
34612
34612
|
};
|
|
34613
34613
|
//#endregion
|
|
34614
34614
|
//#region package.json
|
|
34615
|
-
var version = "0.6.
|
|
34615
|
+
var version = "0.6.1";
|
|
34616
34616
|
//#endregion
|
|
34617
34617
|
//#region src/core/projections.ts
|
|
34618
34618
|
const escapeXml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
@@ -34928,19 +34928,17 @@ const globToRegExp = (glob) => {
|
|
|
34928
34928
|
return new RegExp(`^${source}$`);
|
|
34929
34929
|
};
|
|
34930
34930
|
/**
|
|
34931
|
-
* Enforce contextual-link coverage rules
|
|
34932
|
-
* "money" pages each needs `minInbound` incoming `related` edges. Only
|
|
34933
|
-
* `related` edges count — breadcrumb ancestry is navigation, not context.
|
|
34931
|
+
* Enforce contextual-link coverage rules over an explicit surface.
|
|
34934
34932
|
*
|
|
34935
34933
|
* A rule that matches no sitemap-eligible page is itself a violation: a typo or
|
|
34936
34934
|
* a rule aimed at a noindex page would otherwise pass silently forever.
|
|
34937
34935
|
*/
|
|
34938
|
-
|
|
34936
|
+
const checkCoverageRules = (surface, rules) => {
|
|
34939
34937
|
const violations = [];
|
|
34940
34938
|
for (const rule of rules) {
|
|
34941
34939
|
const matcher = globToRegExp(rule.path);
|
|
34942
|
-
const matched =
|
|
34943
|
-
const eligible =
|
|
34940
|
+
const matched = surface.paths.filter((path) => matcher.test(path));
|
|
34941
|
+
const eligible = surface.eligiblePaths.filter((path) => matcher.test(path));
|
|
34944
34942
|
if (eligible.length === 0) {
|
|
34945
34943
|
violations.push({
|
|
34946
34944
|
severity: "structural",
|
|
@@ -34950,19 +34948,55 @@ function checkCoverage(graph, rules) {
|
|
|
34950
34948
|
});
|
|
34951
34949
|
continue;
|
|
34952
34950
|
}
|
|
34953
|
-
for (const
|
|
34954
|
-
const inbound =
|
|
34951
|
+
for (const path of eligible) {
|
|
34952
|
+
const inbound = surface.inbound(path);
|
|
34955
34953
|
if (inbound >= rule.minInbound) continue;
|
|
34956
34954
|
violations.push({
|
|
34957
34955
|
severity: "structural",
|
|
34958
34956
|
rule: "inbound-link-coverage",
|
|
34959
|
-
path
|
|
34960
|
-
message: `"${
|
|
34961
|
-
fix: `Add ${rule.minInbound - inbound} contextual (
|
|
34957
|
+
path,
|
|
34958
|
+
message: `"${path}" has ${inbound} incoming contextual link(s); the coverage rule requires ${rule.minInbound}.`,
|
|
34959
|
+
fix: `Add ${rule.minInbound - inbound} contextual link(s) pointing at "${path}".`
|
|
34962
34960
|
});
|
|
34963
34961
|
}
|
|
34964
34962
|
}
|
|
34965
34963
|
return violations;
|
|
34964
|
+
};
|
|
34965
|
+
/** The sitemap-eligible path set of a declared graph, in graph insertion order. */
|
|
34966
|
+
const eligiblePathsOf = (graph) => [...graph.nodes.values()].filter(isSitemapEligible).map((node) => node.path);
|
|
34967
|
+
/**
|
|
34968
|
+
* Enforce contextual-link coverage against the *declared* graph: only `related`
|
|
34969
|
+
* edges count — breadcrumb ancestry is navigation, not context.
|
|
34970
|
+
*/
|
|
34971
|
+
function checkCoverage(graph, rules) {
|
|
34972
|
+
return checkCoverageRules({
|
|
34973
|
+
paths: [...graph.nodes.keys()],
|
|
34974
|
+
eligiblePaths: eligiblePathsOf(graph),
|
|
34975
|
+
inbound: (path) => graph.edges.filter((edge) => edge.type === "related" && edge.to === path).length
|
|
34976
|
+
}, rules);
|
|
34977
|
+
}
|
|
34978
|
+
/**
|
|
34979
|
+
* Enforce contextual-link coverage against the *rendered* graph: only same-origin
|
|
34980
|
+
* body-region anchors count. Nav, header, and footer anchors are site chrome, so
|
|
34981
|
+
* a page linked solely from them has no contextual inbound link — the same
|
|
34982
|
+
* distinction the declared pass draws by counting `related` edges alone.
|
|
34983
|
+
*
|
|
34984
|
+
* The target universe is the declared graph's sitemap-eligible pages: the globs
|
|
34985
|
+
* in `seo.config.ts` are written against declared paths, and the crawl is what
|
|
34986
|
+
* supplies the served edges. Pass the crawl's `internalEdges` (each edge carries
|
|
34987
|
+
* its `region`).
|
|
34988
|
+
*/
|
|
34989
|
+
function checkRenderedCoverage(graph, renderedEdges, rules) {
|
|
34990
|
+
const inbound = /* @__PURE__ */ new Map();
|
|
34991
|
+
for (const edge of renderedEdges) {
|
|
34992
|
+
if (edge.region !== "body") continue;
|
|
34993
|
+
inbound.set(edge.to, (inbound.get(edge.to) ?? 0) + 1);
|
|
34994
|
+
}
|
|
34995
|
+
return checkCoverageRules({
|
|
34996
|
+
paths: [...graph.nodes.keys()],
|
|
34997
|
+
eligiblePaths: eligiblePathsOf(graph),
|
|
34998
|
+
inbound: (path) => inbound.get(path) ?? 0
|
|
34999
|
+
}, rules);
|
|
34966
35000
|
}
|
|
34967
35001
|
/** Structural violations fail `pagegraph check`; editorial-only stays green. */
|
|
34968
35002
|
const hasStructuralViolations = (violations) => violations.some((violation) => violation.severity === "structural");
|
|
@@ -35289,6 +35323,17 @@ const renderLinksReport = (report) => {
|
|
|
35289
35323
|
lines.push(` rendered not declared ${report.declared.renderedNotDeclared.length}`);
|
|
35290
35324
|
for (const edge of report.declared.renderedNotDeclared) lines.push(edgeLine(edge));
|
|
35291
35325
|
}
|
|
35326
|
+
if (report.coverage !== void 0) {
|
|
35327
|
+
const { coverage } = report;
|
|
35328
|
+
lines.push("", `Rendered coverage (${coverage.rules} rule(s)):`);
|
|
35329
|
+
if (coverage.ok) lines.push(" ✓ every coverage rule is satisfied by the served anchors.");
|
|
35330
|
+
else for (const violation of coverage.violations) {
|
|
35331
|
+
const where = violation.path ? ` ${violation.path}` : "";
|
|
35332
|
+
lines.push(` ✗ [${violation.rule}]${where}`);
|
|
35333
|
+
lines.push(` ${violation.message}`);
|
|
35334
|
+
if (violation.fix) lines.push(` fix: ${violation.fix}`);
|
|
35335
|
+
}
|
|
35336
|
+
}
|
|
35292
35337
|
if (crawl.failures.length > 0) {
|
|
35293
35338
|
lines.push("", `${crawl.failures.length} page(s) could not be fetched:`);
|
|
35294
35339
|
for (const failure of crawl.failures) lines.push(` ${failure.url} ${failure.error}`);
|
|
@@ -36214,6 +36259,30 @@ const breadthFirstDepth = (edges, root) => {
|
|
|
36214
36259
|
return depth;
|
|
36215
36260
|
};
|
|
36216
36261
|
/**
|
|
36262
|
+
* Assemble a {@link RenderedGraph} from page/edge sets. Edges are already
|
|
36263
|
+
* same-origin and path-keyed by the callers, so this is the shared, pure tail of
|
|
36264
|
+
* {@link buildRenderedGraph} and {@link renderedGraphFromEdges}.
|
|
36265
|
+
*/
|
|
36266
|
+
const graphFromEdges = (nodes, edges, root) => {
|
|
36267
|
+
const rootKey = root.replace(/\/+$/, "") || "/";
|
|
36268
|
+
const internalEdges = edges;
|
|
36269
|
+
const contextualEdges = edges.filter((edge) => edge.region === "body");
|
|
36270
|
+
const incoming = new Set(internalEdges.map((edge) => edge.to));
|
|
36271
|
+
const orphans = [...nodes].filter((path) => path !== rootKey && !incoming.has(path)).sort();
|
|
36272
|
+
const depthByPath = breadthFirstDepth(internalEdges, rootKey);
|
|
36273
|
+
let maxDepth = null;
|
|
36274
|
+
for (const value of depthByPath.values()) if (maxDepth === null || value > maxDepth) maxDepth = value;
|
|
36275
|
+
return {
|
|
36276
|
+
nodes: [...nodes],
|
|
36277
|
+
edges,
|
|
36278
|
+
internalEdges,
|
|
36279
|
+
contextualEdges,
|
|
36280
|
+
orphans,
|
|
36281
|
+
depthByPath,
|
|
36282
|
+
maxDepth
|
|
36283
|
+
};
|
|
36284
|
+
};
|
|
36285
|
+
/**
|
|
36217
36286
|
* Build the rendered graph from crawled pages. `origin` fixes the graph's
|
|
36218
36287
|
* same-origin boundary — pages and anchors on another origin are dropped, not
|
|
36219
36288
|
* silently folded in — and `root` is the normalized path the depth BFS starts
|
|
@@ -36222,7 +36291,6 @@ const breadthFirstDepth = (edges, root) => {
|
|
|
36222
36291
|
*/
|
|
36223
36292
|
const buildRenderedGraph = (origin, pages, root = "/") => {
|
|
36224
36293
|
const graphOrigin = new URL(origin).origin;
|
|
36225
|
-
const rootKey = root.replace(/\/+$/, "") || "/";
|
|
36226
36294
|
const edges = [];
|
|
36227
36295
|
const nodes = /* @__PURE__ */ new Set();
|
|
36228
36296
|
for (const page of pages) {
|
|
@@ -36252,21 +36320,39 @@ const buildRenderedGraph = (origin, pages, root = "/") => {
|
|
|
36252
36320
|
});
|
|
36253
36321
|
}
|
|
36254
36322
|
}
|
|
36255
|
-
|
|
36256
|
-
|
|
36257
|
-
|
|
36258
|
-
|
|
36259
|
-
|
|
36260
|
-
|
|
36261
|
-
|
|
36262
|
-
|
|
36263
|
-
|
|
36264
|
-
|
|
36265
|
-
|
|
36266
|
-
|
|
36267
|
-
|
|
36268
|
-
|
|
36269
|
-
|
|
36323
|
+
return graphFromEdges(nodes, edges, root);
|
|
36324
|
+
};
|
|
36325
|
+
/** A URL or an already-normalized graph path key both collapse to the path key. */
|
|
36326
|
+
const pathKeyOf = (value) => {
|
|
36327
|
+
try {
|
|
36328
|
+
return normalizePath(new URL(value));
|
|
36329
|
+
} catch {
|
|
36330
|
+
return (value.split(/[?#]/, 1)[0] ?? "").replace(/\/+$/, "") || "/";
|
|
36331
|
+
}
|
|
36332
|
+
};
|
|
36333
|
+
/**
|
|
36334
|
+
* Rebuild a {@link RenderedGraph} from a rendered-edge set alone — the artifact
|
|
36335
|
+
* `pagegraph links verify --emit-rendered` writes. Path keys are normalized the
|
|
36336
|
+
* same way as a crawl, and `nodes` carries the rendered page paths the artifact
|
|
36337
|
+
* recorded, so an edge-less page (a redirect target with no internal links) stays
|
|
36338
|
+
* a node and its orphan/depth report survives replay. Without `nodes` the node set
|
|
36339
|
+
* falls back to the edge endpoints.
|
|
36340
|
+
*/
|
|
36341
|
+
const renderedGraphFromEdges = (edges, root = "/", nodes = []) => {
|
|
36342
|
+
const nodeSet = new Set(nodes.map(pathKeyOf));
|
|
36343
|
+
const kept = [];
|
|
36344
|
+
for (const edge of edges) {
|
|
36345
|
+
const from = pathKeyOf(edge.from);
|
|
36346
|
+
const to = pathKeyOf(edge.to);
|
|
36347
|
+
nodeSet.add(from);
|
|
36348
|
+
nodeSet.add(to);
|
|
36349
|
+
kept.push({
|
|
36350
|
+
from,
|
|
36351
|
+
to,
|
|
36352
|
+
region: edge.region
|
|
36353
|
+
});
|
|
36354
|
+
}
|
|
36355
|
+
return graphFromEdges(nodeSet, kept, root);
|
|
36270
36356
|
};
|
|
36271
36357
|
const edgeKey = (edge) => `${edge.from}\u0000${edge.to}`;
|
|
36272
36358
|
const normalizeEdge = (edge) => ({
|
|
@@ -36289,6 +36375,97 @@ const diffLinkGraph = (declared, rendered) => {
|
|
|
36289
36375
|
renderedContextualCount: rendered.contextualEdges.length
|
|
36290
36376
|
};
|
|
36291
36377
|
};
|
|
36378
|
+
const ARTIFACT_KIND = "links-rendered";
|
|
36379
|
+
const REGIONS = /* @__PURE__ */ new Set([
|
|
36380
|
+
"nav",
|
|
36381
|
+
"footer",
|
|
36382
|
+
"header",
|
|
36383
|
+
"body"
|
|
36384
|
+
]);
|
|
36385
|
+
const decodeArtifactEdges = (input) => {
|
|
36386
|
+
if (!Array.isArray(input)) throw new Error("`edges` must be an array of { from, to, region } edges");
|
|
36387
|
+
return input.map((item) => {
|
|
36388
|
+
if (item === null || typeof item !== "object") throw new Error("each artifact edge must be an object with string `from` and `to`");
|
|
36389
|
+
const { from, to, region } = item;
|
|
36390
|
+
if (typeof from !== "string" || typeof to !== "string") throw new Error("each artifact edge must have string `from` and `to`");
|
|
36391
|
+
if (region !== void 0 && !REGIONS.has(region)) throw new Error(`artifact edge region must be one of nav, footer, header, body (received ${JSON.stringify(region)})`);
|
|
36392
|
+
return {
|
|
36393
|
+
from,
|
|
36394
|
+
to,
|
|
36395
|
+
region: region ?? "body"
|
|
36396
|
+
};
|
|
36397
|
+
});
|
|
36398
|
+
};
|
|
36399
|
+
const decodeArtifactNodes = (input) => {
|
|
36400
|
+
if (input === void 0) return [];
|
|
36401
|
+
if (!Array.isArray(input) || !input.every((path) => typeof path === "string")) throw new Error("rendered-edge artifact `nodes` must be an array of paths");
|
|
36402
|
+
return input;
|
|
36403
|
+
};
|
|
36404
|
+
const decodeArtifactFailures = (input) => {
|
|
36405
|
+
if (input === void 0) return [];
|
|
36406
|
+
if (!Array.isArray(input)) throw new Error("rendered-edge artifact `crawl.failures` must be an array");
|
|
36407
|
+
return input.map((item) => {
|
|
36408
|
+
if (item === null || typeof item !== "object") throw new Error("each artifact failure must be an object with string `url` and `error`");
|
|
36409
|
+
const { url, error } = item;
|
|
36410
|
+
if (typeof url !== "string" || typeof error !== "string") throw new Error("each artifact failure must have string `url` and `error`");
|
|
36411
|
+
return {
|
|
36412
|
+
url,
|
|
36413
|
+
error
|
|
36414
|
+
};
|
|
36415
|
+
});
|
|
36416
|
+
};
|
|
36417
|
+
const requiredAbsoluteUrl = (value, field) => {
|
|
36418
|
+
if (typeof value !== "string" || value === "") throw new Error(`rendered-edge artifact must carry a non-empty string \`${field}\``);
|
|
36419
|
+
try {
|
|
36420
|
+
new URL(value);
|
|
36421
|
+
} catch {
|
|
36422
|
+
throw new Error(`rendered-edge artifact \`${field}\` must be an absolute URL (received ${JSON.stringify(value)})`);
|
|
36423
|
+
}
|
|
36424
|
+
return value;
|
|
36425
|
+
};
|
|
36426
|
+
const requiredInteger = (value, field, minimum) => {
|
|
36427
|
+
if (!Number.isSafeInteger(value) || value < minimum) throw new Error(`rendered-edge artifact \`${field}\` must be an integer >= ${minimum} (received ${JSON.stringify(value)})`);
|
|
36428
|
+
return value;
|
|
36429
|
+
};
|
|
36430
|
+
/**
|
|
36431
|
+
* Decode a rendered-edge artifact from user input. Throws on any other shape —
|
|
36432
|
+
* this is user input, not a provider payload. `root`, `bodyTruncated`,
|
|
36433
|
+
* `truncatedPages`, `failures`, and `nodes` are optional so a hand-written edge
|
|
36434
|
+
* dump that only carries provenance can still be replayed.
|
|
36435
|
+
*/
|
|
36436
|
+
const decodeRenderedEdgeArtifact = (input) => {
|
|
36437
|
+
if (input === null || typeof input !== "object") throw new Error("rendered-edge artifact must be an object");
|
|
36438
|
+
const value = input;
|
|
36439
|
+
if (value["kind"] !== ARTIFACT_KIND) throw new Error(`rendered-edge artifact \`kind\` must be "${ARTIFACT_KIND}" (received ${JSON.stringify(value["kind"])})`);
|
|
36440
|
+
if (value["schemaVersion"] !== 1) throw new Error(`unsupported rendered-edge artifact schemaVersion: ${JSON.stringify(value["schemaVersion"])}`);
|
|
36441
|
+
const origin = requiredAbsoluteUrl(value["origin"], "origin");
|
|
36442
|
+
const seed = requiredAbsoluteUrl(value["seed"], "seed");
|
|
36443
|
+
const crawl = value["crawl"];
|
|
36444
|
+
if (crawl === null || typeof crawl !== "object") throw new Error("rendered-edge artifact must carry a `crawl` object");
|
|
36445
|
+
const provenance = crawl;
|
|
36446
|
+
const truncatedPages = provenance["truncatedPages"];
|
|
36447
|
+
if (truncatedPages !== void 0 && (!Array.isArray(truncatedPages) || !truncatedPages.every((url) => typeof url === "string"))) throw new Error("rendered-edge artifact `truncatedPages` must be an array of URLs");
|
|
36448
|
+
const bodyTruncated = provenance["bodyTruncated"];
|
|
36449
|
+
if (bodyTruncated !== void 0 && typeof bodyTruncated !== "boolean") throw new Error("rendered-edge artifact `bodyTruncated` must be a boolean");
|
|
36450
|
+
if (typeof provenance["truncated"] !== "boolean") throw new Error("rendered-edge artifact `crawl.truncated` must be a boolean");
|
|
36451
|
+
return {
|
|
36452
|
+
kind: ARTIFACT_KIND,
|
|
36453
|
+
schemaVersion: 1,
|
|
36454
|
+
origin,
|
|
36455
|
+
seed,
|
|
36456
|
+
crawl: {
|
|
36457
|
+
pages: requiredInteger(provenance["pages"], "crawl.pages", 0),
|
|
36458
|
+
root: typeof provenance["root"] === "string" ? provenance["root"] : pathKeyOf(seed),
|
|
36459
|
+
limit: requiredInteger(provenance["limit"], "crawl.limit", 1),
|
|
36460
|
+
truncated: provenance["truncated"],
|
|
36461
|
+
bodyTruncated: bodyTruncated === true,
|
|
36462
|
+
truncatedPages: truncatedPages ?? [],
|
|
36463
|
+
failures: decodeArtifactFailures(provenance["failures"])
|
|
36464
|
+
},
|
|
36465
|
+
nodes: decodeArtifactNodes(value["nodes"]),
|
|
36466
|
+
edges: decodeArtifactEdges(value["edges"])
|
|
36467
|
+
};
|
|
36468
|
+
};
|
|
36292
36469
|
//#endregion
|
|
36293
36470
|
//#region src/audit/network.ts
|
|
36294
36471
|
const blockedIpv4Addresses = new BlockList();
|
|
@@ -41531,110 +41708,234 @@ const decodeLinkCandidates = (input) => decodeUnknownSync(ArraySchema(LinkCandid
|
|
|
41531
41708
|
//#region src/cli/commands/links.ts
|
|
41532
41709
|
const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
|
|
41533
41710
|
const positive = (name, value) => Number.isSafeInteger(value) && value > 0 ? succeed$2(value) : fail$3(new SeoCliError({ message: `--${name} must be a positive integer` }));
|
|
41534
|
-
const urlArg = String$1("url").pipe(withDescription("Absolute http(s) URL of the site homepage to crawl"));
|
|
41711
|
+
const urlArg = String$1("url").pipe(withDescription("Absolute http(s) URL of the site homepage to crawl (omit with --rendered)"), optional);
|
|
41535
41712
|
const limitFlag = Int("limit").pipe(withDescription$2("Maximum pages to fetch, including the homepage"), withDefault(100));
|
|
41536
41713
|
const allowPrivate = Boolean$1("allow-private").pipe(withDescription$2("Allow localhost and private addresses (local development only)"), withDefault(false));
|
|
41537
41714
|
const requestTimeoutMs = Int("request-timeout-ms").pipe(withDescription$2("HTTP request timeout in milliseconds"), withDefault(15e3));
|
|
41538
41715
|
const maxBodyBytes = Int("max-body-bytes").pipe(withDescription$2("Maximum captured response bytes per page"), withDefault(2e6));
|
|
41539
41716
|
/**
|
|
41540
|
-
*
|
|
41541
|
-
*
|
|
41542
|
-
*
|
|
41543
|
-
*
|
|
41544
|
-
*
|
|
41717
|
+
* Load the app's declared graph when a `seo.config.ts` exists and derive the two
|
|
41718
|
+
* things `verify` needs from it: the declared `related` edges for the diff, and —
|
|
41719
|
+
* under `--assert-coverage` — the rendered coverage assertion. Both are computed
|
|
41720
|
+
* inside one scoped graph acquisition so the Vite loader is acquired once.
|
|
41721
|
+
*
|
|
41722
|
+
* Without `--assert-coverage` the declared graph is optional enrichment: a
|
|
41723
|
+
* missing or broken config, an invalid origin, or an origin mismatch each become a
|
|
41724
|
+
* warning, because verifying served HTML works on any deployed site. Under
|
|
41725
|
+
* `--assert-coverage` every one of those is a hard failure — an assertion must
|
|
41726
|
+
* know which pages the rules target and that this crawl is the same site, and a
|
|
41727
|
+
* truncated crawl is refused before the graph is even loaded.
|
|
41545
41728
|
*/
|
|
41546
|
-
const
|
|
41547
|
-
|
|
41548
|
-
|
|
41729
|
+
const analyzeDeclared = (crawlOrigin, renderedEdges, assertCoverage, crawlState) => gen(function* () {
|
|
41730
|
+
if (assertCoverage) {
|
|
41731
|
+
if (crawlState.truncated) return yield* new SeoCliError({ message: "Refusing to assert rendered coverage on a truncated crawl; re-run with a higher --limit." });
|
|
41732
|
+
if (crawlState.failures > 0) return yield* new SeoCliError({ message: `Refusing to assert rendered coverage: ${crawlState.failures} page(s) failed to fetch, so their anchors are missing.` });
|
|
41733
|
+
if (crawlState.bodyTruncated) return yield* new SeoCliError({ message: "Refusing to assert rendered coverage when a crawled body was truncated; raise --max-body-bytes." });
|
|
41734
|
+
}
|
|
41735
|
+
const warnings = [];
|
|
41736
|
+
const config = yield* loadSeoConfigOptional.pipe(catchTag("SeoCliError", (error) => assertCoverage ? fail$3(error) : sync(() => {
|
|
41737
|
+
warnings.push(`Ignoring declared graph: ${error.message}`);
|
|
41738
|
+
})));
|
|
41739
|
+
if (config === void 0) {
|
|
41740
|
+
if (assertCoverage) return yield* new SeoCliError({ message: "Coverage assertion needs a seo.config.ts declaring `coverage` rules." });
|
|
41741
|
+
return {
|
|
41742
|
+
declaredEdges: void 0,
|
|
41743
|
+
coverage: void 0,
|
|
41744
|
+
warnings
|
|
41745
|
+
};
|
|
41746
|
+
}
|
|
41549
41747
|
let configOrigin;
|
|
41550
41748
|
try {
|
|
41551
41749
|
configOrigin = new URL(config.origin).origin;
|
|
41552
41750
|
} catch {
|
|
41751
|
+
const message = `Ignoring declared graph: seo.config.ts origin "${config.origin}" is not a valid URL.`;
|
|
41752
|
+
if (assertCoverage) return yield* new SeoCliError({ message });
|
|
41753
|
+
warnings.push(message);
|
|
41553
41754
|
return {
|
|
41554
|
-
|
|
41555
|
-
|
|
41755
|
+
declaredEdges: void 0,
|
|
41756
|
+
coverage: void 0,
|
|
41757
|
+
warnings
|
|
41556
41758
|
};
|
|
41557
41759
|
}
|
|
41558
|
-
const
|
|
41559
|
-
return {
|
|
41560
|
-
|
|
41561
|
-
graph
|
|
41562
|
-
|
|
41563
|
-
|
|
41760
|
+
const rules = config.coverage ?? [];
|
|
41761
|
+
if (assertCoverage && rules.length === 0) return yield* new SeoCliError({ message: "seo.config.ts declares no `coverage` rules; nothing to assert." });
|
|
41762
|
+
if (configOrigin !== crawlOrigin) {
|
|
41763
|
+
const mismatch = `declared graph origin ${configOrigin} differs from crawled origin ${crawlOrigin}`;
|
|
41764
|
+
if (assertCoverage) return yield* new SeoCliError({ message: `Cannot assert coverage: ${mismatch}.` });
|
|
41765
|
+
warnings.push(`${mismatch.charAt(0).toUpperCase()}${mismatch.slice(1)}; skipping the declared-vs-rendered diff.`);
|
|
41766
|
+
return {
|
|
41767
|
+
declaredEdges: void 0,
|
|
41768
|
+
coverage: void 0,
|
|
41769
|
+
warnings
|
|
41770
|
+
};
|
|
41771
|
+
}
|
|
41772
|
+
return yield* scoped(gen(function* () {
|
|
41773
|
+
const graph = yield* acquireGraph(config);
|
|
41774
|
+
return {
|
|
41775
|
+
declaredEdges: graph.edges.filter((edge) => edge.type === "related").map((edge) => ({
|
|
41564
41776
|
from: edge.from,
|
|
41565
41777
|
to: edge.to
|
|
41566
|
-
}))
|
|
41567
|
-
|
|
41568
|
-
|
|
41569
|
-
|
|
41570
|
-
|
|
41571
|
-
|
|
41572
|
-
}
|
|
41778
|
+
})),
|
|
41779
|
+
coverage: assertCoverage ? {
|
|
41780
|
+
rules: rules.length,
|
|
41781
|
+
violations: checkRenderedCoverage(graph, renderedEdges, rules)
|
|
41782
|
+
} : void 0,
|
|
41783
|
+
warnings
|
|
41784
|
+
};
|
|
41785
|
+
}));
|
|
41786
|
+
});
|
|
41787
|
+
/** Read and decode a saved rendered-edge artifact; the file is user input. */
|
|
41788
|
+
const readRenderedArtifact = (file) => try_({
|
|
41789
|
+
try: () => decodeRenderedEdgeArtifact(JSON.parse(readFileSync(file, "utf8"))),
|
|
41790
|
+
catch: (cause) => new SeoCliError({ message: `Could not read rendered artifact from ${file}: ${messageOf(cause)}` })
|
|
41791
|
+
});
|
|
41792
|
+
/** Write a rendered-edge artifact, creating its parent directory when needed. */
|
|
41793
|
+
const writeRenderedArtifact = (file, artifact) => try_({
|
|
41794
|
+
try: () => {
|
|
41795
|
+
mkdirSync(dirname(resolve(file)), { recursive: true });
|
|
41796
|
+
writeFileSync(file, `${JSON.stringify(artifact, null, 2)}\n`, "utf8");
|
|
41797
|
+
},
|
|
41798
|
+
catch: (cause) => new SeoCliError({ message: `Could not write rendered artifact to ${file}: ${messageOf(cause)}` })
|
|
41799
|
+
});
|
|
41573
41800
|
const linksVerifyCommand = make$9("verify", {
|
|
41574
41801
|
url: urlArg,
|
|
41575
41802
|
json: jsonFlag,
|
|
41576
41803
|
limit: limitFlag,
|
|
41577
41804
|
allowPrivate,
|
|
41578
41805
|
requestTimeoutMs,
|
|
41579
|
-
maxBodyBytes
|
|
41806
|
+
maxBodyBytes,
|
|
41807
|
+
emitRendered: String$2("emit-rendered").pipe(withDescription$2("Write the crawled rendered-edge artifact to this path (crawl mode)"), optional$1),
|
|
41808
|
+
rendered: String$2("rendered").pipe(withDescription$2("Replay a saved rendered-edge artifact instead of crawling"), optional$1),
|
|
41809
|
+
assertCoverage: Boolean$1("assert-coverage").pipe(withDescription$2("Assert seo.config.ts coverage rules against the rendered anchors; exit 1 on unmet (refuses truncated crawls)"), withDefault(false))
|
|
41580
41810
|
}).pipe(withDescription$1("Crawl served HTML and report homepage depth, rendered orphans, and the declared-vs-rendered gap"), withExamples([
|
|
41581
41811
|
{
|
|
41582
41812
|
command: "pagegraph links verify https://example.com",
|
|
41583
41813
|
description: "Crawl the homepage and report the rendered link graph"
|
|
41584
41814
|
},
|
|
41585
41815
|
{
|
|
41586
|
-
command: "pagegraph links verify https://example.com --limit 25",
|
|
41587
|
-
description: "Bound the crawl to 25 pages"
|
|
41816
|
+
command: "pagegraph links verify https://example.com --limit 25 --json | jq",
|
|
41817
|
+
description: "Bound the crawl to 25 pages and emit versioned JSON"
|
|
41588
41818
|
},
|
|
41589
41819
|
{
|
|
41590
|
-
command: "pagegraph links verify https://example.com --json
|
|
41591
|
-
description: "
|
|
41820
|
+
command: "pagegraph links verify https://example.com --emit-rendered rendered.json",
|
|
41821
|
+
description: "Crawl once and save the rendered-edge artifact"
|
|
41822
|
+
},
|
|
41823
|
+
{
|
|
41824
|
+
command: "pagegraph links verify --rendered rendered.json --assert-coverage",
|
|
41825
|
+
description: "Assert seo.config.ts coverage offline against a saved crawl (exit 1 on unmet)"
|
|
41826
|
+
},
|
|
41827
|
+
{
|
|
41828
|
+
command: "pagegraph links verify https://example.com --assert-coverage",
|
|
41829
|
+
description: "Crawl and assert coverage against the served anchors in one pass"
|
|
41592
41830
|
},
|
|
41593
41831
|
{
|
|
41594
41832
|
command: "pagegraph links verify http://localhost:3000 --allow-private",
|
|
41595
41833
|
description: "Verify a local app"
|
|
41596
41834
|
}
|
|
41597
41835
|
]), withHandler(fn("SeoCli.linksVerify")(function* (options) {
|
|
41598
|
-
const
|
|
41599
|
-
const
|
|
41600
|
-
const
|
|
41601
|
-
|
|
41602
|
-
|
|
41603
|
-
limit: checkedLimit,
|
|
41604
|
-
timeoutMs: checkedTimeout,
|
|
41605
|
-
maxBodyBytes: checkedMaxBody,
|
|
41606
|
-
allowPrivate: options.allowPrivate
|
|
41607
|
-
}),
|
|
41608
|
-
catch: (cause) => new SeoCliError({ message: `Could not crawl ${options.url}: ${messageOf(cause)}` })
|
|
41609
|
-
});
|
|
41610
|
-
if (crawl.pages.length === 0) {
|
|
41611
|
-
const reason = crawl.failures[0]?.error ?? "no pages fetched";
|
|
41612
|
-
return yield* new SeoCliError({ message: `No page could be crawled at ${options.url}: ${reason}` });
|
|
41613
|
-
}
|
|
41614
|
-
const graph = buildRenderedGraph(crawl.origin, crawl.pages, crawl.root);
|
|
41836
|
+
const emitPath = getOrUndefined$1(options.emitRendered);
|
|
41837
|
+
const artifactPath = getOrUndefined$1(options.rendered);
|
|
41838
|
+
const url = getOrUndefined$1(options.url);
|
|
41839
|
+
if (emitPath !== void 0 && artifactPath !== void 0) return yield* new SeoCliError({ message: "--emit-rendered writes a fresh crawl; --rendered replays one. Use one or the other." });
|
|
41840
|
+
if (url === void 0 && artifactPath === void 0) return yield* new SeoCliError({ message: "Provide a homepage URL to crawl, or --rendered <path> to replay a saved crawl." });
|
|
41615
41841
|
const warnings = [];
|
|
41616
|
-
|
|
41617
|
-
|
|
41618
|
-
let
|
|
41619
|
-
|
|
41620
|
-
|
|
41621
|
-
|
|
41622
|
-
|
|
41623
|
-
|
|
41624
|
-
|
|
41625
|
-
|
|
41626
|
-
|
|
41627
|
-
|
|
41628
|
-
|
|
41842
|
+
let origin;
|
|
41843
|
+
let seed;
|
|
41844
|
+
let graph;
|
|
41845
|
+
let renderedEdges;
|
|
41846
|
+
let crawlReport;
|
|
41847
|
+
if (artifactPath !== void 0) {
|
|
41848
|
+
const artifact = yield* readRenderedArtifact(artifactPath);
|
|
41849
|
+
if (url !== void 0) {
|
|
41850
|
+
let requestedOrigin;
|
|
41851
|
+
try {
|
|
41852
|
+
requestedOrigin = new URL(url).origin;
|
|
41853
|
+
} catch {
|
|
41854
|
+
return yield* new SeoCliError({ message: `Not a valid URL: ${url}` });
|
|
41855
|
+
}
|
|
41856
|
+
if (requestedOrigin !== new URL(artifact.origin).origin) return yield* new SeoCliError({ message: `--rendered artifact origin ${artifact.origin} does not match ${url}.` });
|
|
41857
|
+
}
|
|
41858
|
+
origin = artifact.origin;
|
|
41859
|
+
seed = artifact.seed;
|
|
41860
|
+
graph = renderedGraphFromEdges(artifact.edges, artifact.crawl.root, artifact.nodes);
|
|
41861
|
+
renderedEdges = artifact.edges;
|
|
41862
|
+
crawlReport = {
|
|
41863
|
+
pages: artifact.crawl.pages,
|
|
41864
|
+
limit: artifact.crawl.limit,
|
|
41865
|
+
truncated: artifact.crawl.truncated,
|
|
41866
|
+
failures: artifact.crawl.failures,
|
|
41867
|
+
bodyTruncated: artifact.crawl.bodyTruncated,
|
|
41868
|
+
truncatedPages: artifact.crawl.truncatedPages
|
|
41869
|
+
};
|
|
41870
|
+
} else {
|
|
41871
|
+
const checkedLimit = yield* positive("limit", options.limit);
|
|
41872
|
+
const checkedTimeout = yield* positive("request-timeout-ms", options.requestTimeoutMs);
|
|
41873
|
+
const checkedMaxBody = yield* positive("max-body-bytes", options.maxBodyBytes);
|
|
41874
|
+
const crawl = yield* tryPromise({
|
|
41875
|
+
try: () => crawlRenderedPages(url, {
|
|
41876
|
+
limit: checkedLimit,
|
|
41877
|
+
timeoutMs: checkedTimeout,
|
|
41878
|
+
maxBodyBytes: checkedMaxBody,
|
|
41879
|
+
allowPrivate: options.allowPrivate
|
|
41880
|
+
}),
|
|
41881
|
+
catch: (cause) => new SeoCliError({ message: `Could not crawl ${url}: ${messageOf(cause)}` })
|
|
41882
|
+
});
|
|
41883
|
+
if (crawl.pages.length === 0) return yield* new SeoCliError({ message: `No page could be crawled at ${url}: ${crawl.failures[0]?.error ?? "no pages fetched"}` });
|
|
41884
|
+
graph = buildRenderedGraph(crawl.origin, crawl.pages, crawl.root);
|
|
41885
|
+
renderedEdges = graph.internalEdges;
|
|
41886
|
+
origin = crawl.origin;
|
|
41887
|
+
seed = crawl.seed;
|
|
41888
|
+
crawlReport = {
|
|
41629
41889
|
pages: crawl.pages.length,
|
|
41630
41890
|
limit: crawl.limit,
|
|
41631
41891
|
truncated: crawl.truncated,
|
|
41632
41892
|
failures: crawl.failures,
|
|
41633
41893
|
bodyTruncated: crawl.truncatedBodies.length > 0,
|
|
41634
41894
|
truncatedPages: crawl.truncatedBodies
|
|
41635
|
-
}
|
|
41895
|
+
};
|
|
41896
|
+
if (emitPath !== void 0) {
|
|
41897
|
+
const artifact = {
|
|
41898
|
+
kind: "links-rendered",
|
|
41899
|
+
schemaVersion: 1,
|
|
41900
|
+
origin: crawl.origin,
|
|
41901
|
+
seed: crawl.seed,
|
|
41902
|
+
crawl: {
|
|
41903
|
+
pages: crawl.pages.length,
|
|
41904
|
+
root: crawl.root,
|
|
41905
|
+
limit: crawl.limit,
|
|
41906
|
+
truncated: crawl.truncated,
|
|
41907
|
+
bodyTruncated: crawl.truncatedBodies.length > 0,
|
|
41908
|
+
truncatedPages: crawl.truncatedBodies,
|
|
41909
|
+
failures: crawl.failures
|
|
41910
|
+
},
|
|
41911
|
+
nodes: graph.nodes,
|
|
41912
|
+
edges: graph.internalEdges
|
|
41913
|
+
};
|
|
41914
|
+
yield* writeRenderedArtifact(emitPath, artifact);
|
|
41915
|
+
yield* logInfo(`Wrote rendered edges to ${emitPath}`);
|
|
41916
|
+
}
|
|
41917
|
+
}
|
|
41918
|
+
if (crawlReport.truncatedPages.length > 0) warnings.push(`${crawlReport.truncatedPages.length} page body/bodies exceeded --max-body-bytes; anchors past the cutoff are missing.`);
|
|
41919
|
+
const analysis = yield* analyzeDeclared(origin, renderedEdges, options.assertCoverage, {
|
|
41920
|
+
truncated: crawlReport.truncated,
|
|
41921
|
+
bodyTruncated: crawlReport.bodyTruncated,
|
|
41922
|
+
failures: crawlReport.failures.length
|
|
41923
|
+
});
|
|
41924
|
+
warnings.push(...analysis.warnings);
|
|
41925
|
+
const diff = analysis.declaredEdges === void 0 ? void 0 : diffLinkGraph(analysis.declaredEdges, graph);
|
|
41926
|
+
const coverage = analysis.coverage === void 0 ? void 0 : {
|
|
41927
|
+
rules: analysis.coverage.rules,
|
|
41928
|
+
ok: analysis.coverage.violations.length === 0,
|
|
41929
|
+
violations: analysis.coverage.violations
|
|
41930
|
+
};
|
|
41931
|
+
const report = {
|
|
41932
|
+
kind: "links-verify",
|
|
41933
|
+
schemaVersion: 1,
|
|
41934
|
+
origin,
|
|
41935
|
+
seed,
|
|
41936
|
+
crawl: crawlReport,
|
|
41636
41937
|
rendered: {
|
|
41637
|
-
pages:
|
|
41938
|
+
pages: crawlReport.pages,
|
|
41638
41939
|
internalEdges: graph.internalEdges.length,
|
|
41639
41940
|
contextualEdges: graph.contextualEdges.length,
|
|
41640
41941
|
maxDepth: graph.maxDepth,
|
|
@@ -41646,10 +41947,12 @@ const linksVerifyCommand = make$9("verify", {
|
|
|
41646
41947
|
declaredNotRendered: diff.declaredNotRendered,
|
|
41647
41948
|
renderedNotDeclared: diff.renderedNotDeclared
|
|
41648
41949
|
},
|
|
41950
|
+
...coverage === void 0 ? {} : { coverage },
|
|
41649
41951
|
warnings
|
|
41650
41952
|
};
|
|
41651
41953
|
if (options.json) yield* printJson(report);
|
|
41652
41954
|
else yield* printText(renderLinksReport(report));
|
|
41955
|
+
if (coverage !== void 0 && !coverage.ok) return yield* new SeoCliError({ message: `${coverage.violations.length} rendered coverage violation(s) — the served anchors do not satisfy seo.config.ts.` });
|
|
41653
41956
|
})));
|
|
41654
41957
|
const decideFile = String$1("file").pipe(withDescription("JSON file with an array of link candidates (default: stdin)"), optional);
|
|
41655
41958
|
const thresholdFlag = Finite("threshold").pipe(withDescription$2("Probability above which an answer is confident; below is review (0.5 < t < 1)"), withDefault(DEFAULT_LINK_THRESHOLD));
|