pagegraph 0.5.1 → 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 CHANGED
@@ -22,22 +22,26 @@ flowchart LR
22
22
 
23
23
  ```bash
24
24
  bun add pagegraph
25
- bun add -D effect @effect/platform-bun # only if you use the CLI
26
- bun add -D lighthouse # only for `pagegraph audit` performance evidence
25
+ bun add -D lighthouse # only for `pagegraph audit` performance evidence
27
26
  ```
28
27
 
29
28
  | Entry | Exports | Peers |
30
29
  | ---------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------- |
31
- | `pagegraph` | `buildSeoGraph`, `renderSitemap`, `renderRobots`, `contentSignal`, `checkGraph`, `inspectHtml` | — |
30
+ | `pagegraph` | `buildSeoGraph`, `renderSitemap`, `renderRobots`, `contentSignal`, `checkGraph`, `checkRenderedCoverage`, `decodeRenderedEdgeArtifact`, `inspectHtml` | — |
32
31
  | `pagegraph/react` | `createSeo` → `seoHead`, `Breadcrumbs`, JSON-LD generators | `react`, `@tanstack/react-router` |
33
32
  | `pagegraph/vite` | `seoRouteConfig` coverage gate | `vite` |
34
33
  | `pagegraph/config` | `defineSeoConfig`, `viteGraphLoader` | `vite` |
35
34
  | `pagegraph/audit` | Audit services, scanner protocol, rules, and report schemas | `effect` |
36
- | `pagegraph` bin | CLI over the same graph | `effect`, `@effect/platform-bun` (optional) |
35
+ | `pagegraph` bin | CLI over the same graph | bundled — runs on Bun |
37
36
 
38
37
  The core and React entries have **zero runtime dependencies** — everything above is a
39
38
  peer, and only the entries you import need theirs installed.
40
39
 
40
+ The CLI **bundles Effect and the TypeSafe provider**, so it needs no Effect peers and does not
41
+ depend on the app's Effect RC; it runs on [Bun](https://bun.sh) (`bunx pagegraph`). `vite` stays a
42
+ peer — the graph commands load your app through Vite at runtime — and `lighthouse` is only needed by
43
+ `pagegraph audit`. Importing `pagegraph/audit` programmatically still needs `effect`.
44
+
41
45
  ## Quick start
42
46
 
43
47
  **1. Bind your site identity once.** Route files never see an origin or a brand name.
@@ -245,7 +249,7 @@ severities:
245
249
  - **structural** — internally broken declarations; these fail `pagegraph check` (exit 1):
246
250
  dead or duplicate edges, path collisions, a redirect in the sitemap, a
247
251
  sitemap/noindex contradiction, a `related` target with no `link` card, an
248
- instance without a title.
252
+ instance without a title, or an unmet contextual-link coverage rule.
249
253
  - **editorial** — quality smells, reported but non-failing: duplicate or mis-sized
250
254
  titles and descriptions.
251
255
 
@@ -285,6 +289,8 @@ export default defineSeoConfig({
285
289
  origin: "https://example.com",
286
290
  disallow: routeConfig.robotsExclusions,
287
291
  contentSignal: "search=yes, ai-input=yes, ai-train=yes",
292
+ // Fail `check` unless each named money page has enough contextual links.
293
+ coverage: [{ path: "/pricing", minInbound: 2 }, { path: "/features/*", minInbound: 1 }],
288
294
  loadGraph: viteGraphLoader({
289
295
  root: import.meta.dirname,
290
296
  entry: "/lib/seo/graph.ts",
@@ -298,12 +304,143 @@ pagegraph check # CI gate — exit 1 on structural violations
298
304
  pagegraph graph # the graph as a tree · --format mermaid | json
299
305
  pagegraph inspect /pricing # one node: policy, sitemap status, in/out edges
300
306
  pagegraph inspect <url> --live # fetch a deployed page, validate its rendered <head>
307
+ pagegraph links verify <url> # crawl served HTML: depth, orphans, declared-vs-rendered
308
+ pagegraph links verify <url> --assert-coverage # also assert seo.config.ts coverage on served anchors
309
+ pagegraph links verify <url> --emit-rendered <file> # save the rendered edge set
310
+ pagegraph links candidates # propose contextual links from the declared graph
311
+ pagegraph links decide <file> # answer typed link questions with Jev (TYPESAFE_API_KEY)
301
312
  pagegraph sitemap # print sitemap.xml
302
313
  pagegraph robots # print robots.txt
303
314
  ```
304
315
 
305
316
  Stdout is data, stderr is status — `pagegraph check --json | jq` just works.
306
317
 
318
+ ### Verify the rendered link graph
319
+
320
+ `pagegraph links verify` crawls a site's served HTML — through the same DNS-pinned,
321
+ private-IP-blocked HTTP path as `pagegraph audit` — and reports what a crawler
322
+ actually receives: the real homepage depth, the pages with no incoming internal
323
+ edge (rendered orphans), and, when the app has a `seo.config.ts`, the
324
+ declared-vs-rendered link diff. It is bounded with `--limit` and needs no
325
+ framework or config:
326
+
327
+ ```bash
328
+ pagegraph links verify https://example.com
329
+ pagegraph links verify https://example.com --limit 25 --json | jq
330
+ ```
331
+
332
+ `--emit-rendered <path>` writes the crawl's raw edge set as a versioned artifact,
333
+ and `--rendered <path>` replays one instead of crawling — so a single crawl can be
334
+ asserted repeatedly in CI without re-fetching:
335
+
336
+ ```bash
337
+ pagegraph links verify https://example.com --emit-rendered .seo/rendered.json
338
+ pagegraph links verify --rendered .seo/rendered.json --json | jq
339
+ ```
340
+
341
+ The artifact preserves every anchor's `region` (`body` vs `nav`/`footer`/`header`)
342
+ and the provenance a gate needs to trust it:
343
+
344
+ ```jsonc
345
+ {
346
+ "kind": "links-rendered",
347
+ "schemaVersion": 1,
348
+ "origin": "https://example.com",
349
+ "seed": "https://example.com/",
350
+ "crawl": {
351
+ "pages": 42,
352
+ "root": "/",
353
+ "limit": 100,
354
+ "truncated": false,
355
+ "bodyTruncated": false,
356
+ "truncatedPages": [],
357
+ "failures": [],
358
+ },
359
+ "nodes": ["/", "/pricing"],
360
+ "edges": [{ "from": "/", "to": "/pricing", "region": "body" }],
361
+ }
362
+ ```
363
+
364
+ It is also a drop-in input for `pagegraph links candidates --rendered`.
365
+
366
+ ### Assert coverage on the rendered graph
367
+
368
+ `pagegraph check` evaluates the `seo.config.ts` `coverage` rules against the
369
+ **declared** graph; `pagegraph links verify --assert-coverage` evaluates the same
370
+ rules against the anchors a crawler **actually receives**. Only same-origin
371
+ body-region anchors count — nav, header, and footer links are chrome — which is
372
+ the served analogue of `check` counting `related` edges alone. The rule's target
373
+ universe is still the declared graph's sitemap-eligible pages.
374
+
375
+ ```bash
376
+ pagegraph links verify https://example.com --assert-coverage
377
+ pagegraph links verify --rendered .seo/rendered.json --assert-coverage --json | jq
378
+ ```
379
+
380
+ The command exits non-zero when any rule is unmet, and **refuses to assert** (also
381
+ non-zero) rather than reporting a false pass when the crawl outran `--limit`, a
382
+ page failed to fetch (its anchors are missing), a body hit `--max-body-bytes`, the
383
+ config declares no `coverage`, or the artifact's origin differs from
384
+ `seo.config.ts`. Under `--assert-coverage` the report gains a `coverage` block
385
+ (`{ rules, ok, violations }`); the default `--json` summary is unchanged.
386
+
387
+ ### Propose contextual links
388
+
389
+ `pagegraph links candidates` reads the declared graph and proposes
390
+ `(source, destination)` pairs that are plausible contextual links and not already
391
+ connected. Pages are grouped into clusters — a shared top-level section, or the
392
+ same `kind` for root-level pages — and only sitemap-eligible pages are proposed.
393
+ Pairs already declared as a `related` edge are excluded, and `--rendered` accepts
394
+ a JSON dump of already-served anchors (`[{ from, to }]` or `{ edges: [...] }`) to
395
+ exclude those too.
396
+
397
+ The output is a reviewable plan: human text by default, versioned JSON with
398
+ `--json`. Nothing is applied. `--limit` and `--cluster` bound the plan, and
399
+ `--decide` optionally hands the candidates to Jev for a recommendation and
400
+ confidence per pair (requires `TYPESAFE_API_KEY`):
401
+
402
+ ```bash
403
+ pagegraph links candidates
404
+ pagegraph links candidates --cluster blog --limit 20
405
+ pagegraph links candidates --rendered rendered.json --json | jq
406
+ pagegraph links candidates --decide
407
+ ```
408
+
409
+ ### Decide with Jev
410
+
411
+ `pagegraph links decide` answers two typed questions per candidate — *does the source have a genuine
412
+ reason to link to the destination?* and *is descriptive anchor text already in the copy?* — and
413
+ routes anything inside the confidence band `(1−t, t)` to a `review` bucket instead of auto-applying.
414
+
415
+ ```bash
416
+ pagegraph links decide candidates.json --threshold 0.9
417
+ pagegraph links decide candidates.json --threshold 0.9 --json | jq
418
+ cat candidates.json | pagegraph links decide
419
+ ```
420
+
421
+ Decisions run through [Jev](https://typesafe.ai) via `@effect/ai-typesafe` (model `jev-latest`),
422
+ bundled into the CLI. The API key is read from the **`TYPESAFE_API_KEY` environment variable** — it
423
+ is deliberately not a `seo.config.ts` field, so keys never live in the repo; `seo.config.ts` carries
424
+ non-secret decision settings only. Without a key the command exits 1 with a clear message and an
425
+ empty stdout.
426
+
427
+ ### Contextual-link coverage
428
+
429
+ Declare a contextual-link coverage policy in `seo.config.ts` (a `coverage` array
430
+ of `{ path, minInbound }`), or pass repeatable `--require-inbound "<path-glob>=<n>"`
431
+ flags to override it for one run. `pagegraph check` then fails (exit 1) unless
432
+ every sitemap-eligible page matching the glob has at least `minInbound` incoming
433
+ `related` edges. `*` matches within a path segment and `**` crosses segments; a
434
+ rule that matches no sitemap-eligible page is itself a violation.
435
+
436
+ ```bash
437
+ pagegraph check --require-inbound "/pricing=2" --require-inbound "/features/*=1"
438
+ ```
439
+
440
+ `pagegraph check` asserts the policy on the declared graph. To prove the links are
441
+ actually served, assert the same policy on the rendered graph — see
442
+ [Assert coverage on the rendered graph](#assert-coverage-on-the-rendered-graph).
443
+
307
444
  ### Audit any website
308
445
 
309
446
  `pagegraph audit` is framework-independent and does not need `seo.config.ts`. It
package/dist/audit.d.ts CHANGED
@@ -98,6 +98,11 @@ interface ProbeOptions {
98
98
  readonly timeoutMs: number;
99
99
  readonly maxBodyBytes: number;
100
100
  readonly allowPrivate: boolean;
101
+ /**
102
+ * Capture resolved anchors from HTML bodies. Opt-in because it is the only
103
+ * probe field that grows with document size; the audit report leaves it unset.
104
+ */
105
+ readonly captureAnchors?: boolean | undefined;
101
106
  }
102
107
  interface AuditOptions {
103
108
  readonly concurrency?: number;
package/dist/audit.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { a as extractAnchors } from "./links-BFJc-pJ9.js";
1
2
  import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import * as Context from "effect/Context";
@@ -976,6 +977,7 @@ const probeHttp = async (request, options) => {
976
977
  } : await readBoundedBody(response, options.maxBodyBytes);
977
978
  const contentType = typeof response.headers["content-type"] === "string" ? response.headers["content-type"] : "";
978
979
  const body = new TextDecoder().decode(bounded.bytes);
980
+ const anchors = options.captureAnchors === true && contentType.includes("text/html") ? extractAnchors(body, currentUrl.href) : void 0;
979
981
  return {
980
982
  kind: request.kind,
981
983
  method: request.method,
@@ -993,6 +995,7 @@ const probeHttp = async (request, options) => {
993
995
  bodyTruncated: bounded.truncated,
994
996
  bodyExcerpt: bodyExcerpt(body, contentType),
995
997
  document: body.length === 0 ? null : extractDocumentSignals(body, contentType, currentUrl),
998
+ anchors,
996
999
  error: null
997
1000
  };
998
1001
  } catch (error) {