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/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.0";
34615
+ var version = "0.6.1";
34616
34616
  //#endregion
34617
34617
  //#region src/core/projections.ts
34618
34618
  const escapeXml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
@@ -34928,19 +34928,17 @@ const globToRegExp = (glob) => {
34928
34928
  return new RegExp(`^${source}$`);
34929
34929
  };
34930
34930
  /**
34931
- * Enforce contextual-link coverage rules: a named set of sitemap-eligible
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
- function checkCoverage(graph, rules) {
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 = [...graph.nodes.values()].filter((node) => matcher.test(node.path));
34943
- const eligible = matched.filter(isSitemapEligible);
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 node of eligible) {
34954
- const inbound = graph.edges.filter((edge) => edge.type === "related" && edge.to === node.path).length;
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: node.path,
34960
- message: `"${node.path}" has ${inbound} incoming contextual link(s); the coverage rule requires ${rule.minInbound}.`,
34961
- fix: `Add ${rule.minInbound - inbound} contextual (related) edge(s) pointing at "${node.path}".`
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
- const internalEdges = edges;
36256
- const contextualEdges = edges.filter((edge) => edge.region === "body");
36257
- const incoming = new Set(internalEdges.map((edge) => edge.to));
36258
- const orphans = [...nodes].filter((path) => path !== rootKey && !incoming.has(path)).sort();
36259
- const depthByPath = breadthFirstDepth(internalEdges, rootKey);
36260
- let maxDepth = null;
36261
- for (const value of depthByPath.values()) if (maxDepth === null || value > maxDepth) maxDepth = value;
36262
- return {
36263
- edges,
36264
- internalEdges,
36265
- contextualEdges,
36266
- orphans,
36267
- depthByPath,
36268
- maxDepth
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
- * The declared graph is optional input. A missing `seo.config.ts` means the
41541
- * rendered-only report; a config that exists but fails to load is a warning, not
41542
- * a failure, because verifying served HTML works on any deployed site. Origins
41543
- * are compared before diffing, so a graph from another project is never silently
41544
- * attributed to this crawl.
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 loadDeclaredGraph = gen(function* () {
41547
- const config = yield* loadSeoConfigOptional;
41548
- if (config === void 0) return { kind: "absent" };
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
- kind: "warning",
41555
- message: `Ignoring declared graph: seo.config.ts origin "${config.origin}" is not a valid URL.`
41755
+ declaredEdges: void 0,
41756
+ coverage: void 0,
41757
+ warnings
41556
41758
  };
41557
41759
  }
41558
- const graph = yield* scoped(acquireGraph(config));
41559
- return {
41560
- kind: "loaded",
41561
- graph: {
41562
- origin: configOrigin,
41563
- edges: graph.edges.filter((edge) => edge.type === "related").map((edge) => ({
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
- }).pipe(catchTag("SeoCliError", (error) => succeed$2({
41570
- kind: "warning",
41571
- message: `Ignoring declared graph: ${error.message}`
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 | jq",
41591
- description: "One versioned JSON report on stdout"
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 checkedLimit = yield* positive("limit", options.limit);
41599
- const checkedTimeout = yield* positive("request-timeout-ms", options.requestTimeoutMs);
41600
- const checkedMaxBody = yield* positive("max-body-bytes", options.maxBodyBytes);
41601
- const crawl = yield* tryPromise({
41602
- try: () => crawlRenderedPages(options.url, {
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
- if (crawl.truncatedBodies.length > 0) warnings.push(`${crawl.truncatedBodies.length} page body/bodies exceeded --max-body-bytes; anchors past the cutoff are missing.`);
41617
- const declared = yield* loadDeclaredGraph;
41618
- let declaredEdges;
41619
- if (declared.kind === "warning") warnings.push(declared.message);
41620
- if (declared.kind === "loaded") if (declared.graph.origin === crawl.origin) declaredEdges = declared.graph.edges;
41621
- else warnings.push(`Declared graph origin ${declared.graph.origin} differs from crawled origin ${crawl.origin}; skipping the declared-vs-rendered diff.`);
41622
- const diff = declaredEdges === void 0 ? void 0 : diffLinkGraph(declaredEdges, graph);
41623
- const report = {
41624
- kind: "links-verify",
41625
- schemaVersion: 1,
41626
- origin: crawl.origin,
41627
- seed: crawl.seed,
41628
- crawl: {
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: crawl.pages.length,
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));