pagegraph 0.5.1 → 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 CHANGED
@@ -22,8 +22,7 @@ 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 |
@@ -33,11 +32,16 @@ bun add -D lighthouse # only for `pagegraph audit` performanc
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,82 @@ 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 candidates # propose contextual links from the declared graph
309
+ pagegraph links decide <file> # answer typed link questions with Jev (TYPESAFE_API_KEY)
301
310
  pagegraph sitemap # print sitemap.xml
302
311
  pagegraph robots # print robots.txt
303
312
  ```
304
313
 
305
314
  Stdout is data, stderr is status — `pagegraph check --json | jq` just works.
306
315
 
316
+ ### Verify the rendered link graph
317
+
318
+ `pagegraph links verify` crawls a site's served HTML — through the same DNS-pinned,
319
+ private-IP-blocked HTTP path as `pagegraph audit` — and reports what a crawler
320
+ actually receives: the real homepage depth, the pages with no incoming internal
321
+ edge (rendered orphans), and, when the app has a `seo.config.ts`, the
322
+ declared-vs-rendered link diff. It is bounded with `--limit` and needs no
323
+ framework or config:
324
+
325
+ ```bash
326
+ pagegraph links verify https://example.com
327
+ pagegraph links verify https://example.com --limit 25 --json | jq
328
+ ```
329
+
330
+ ### Propose contextual links
331
+
332
+ `pagegraph links candidates` reads the declared graph and proposes
333
+ `(source, destination)` pairs that are plausible contextual links and not already
334
+ connected. Pages are grouped into clusters — a shared top-level section, or the
335
+ same `kind` for root-level pages — and only sitemap-eligible pages are proposed.
336
+ Pairs already declared as a `related` edge are excluded, and `--rendered` accepts
337
+ a JSON dump of already-served anchors (`[{ from, to }]` or `{ edges: [...] }`) to
338
+ exclude those too.
339
+
340
+ The output is a reviewable plan: human text by default, versioned JSON with
341
+ `--json`. Nothing is applied. `--limit` and `--cluster` bound the plan, and
342
+ `--decide` optionally hands the candidates to Jev for a recommendation and
343
+ confidence per pair (requires `TYPESAFE_API_KEY`):
344
+
345
+ ```bash
346
+ pagegraph links candidates
347
+ pagegraph links candidates --cluster blog --limit 20
348
+ pagegraph links candidates --rendered rendered.json --json | jq
349
+ pagegraph links candidates --decide
350
+ ```
351
+
352
+ ### Decide with Jev
353
+
354
+ `pagegraph links decide` answers two typed questions per candidate — *does the source have a genuine
355
+ reason to link to the destination?* and *is descriptive anchor text already in the copy?* — and
356
+ routes anything inside the confidence band `(1−t, t)` to a `review` bucket instead of auto-applying.
357
+
358
+ ```bash
359
+ pagegraph links decide candidates.json --threshold 0.9
360
+ pagegraph links decide candidates.json --threshold 0.9 --json | jq
361
+ cat candidates.json | pagegraph links decide
362
+ ```
363
+
364
+ Decisions run through [Jev](https://typesafe.ai) via `@effect/ai-typesafe` (model `jev-latest`),
365
+ bundled into the CLI. The API key is read from the **`TYPESAFE_API_KEY` environment variable** — it
366
+ is deliberately not a `seo.config.ts` field, so keys never live in the repo; `seo.config.ts` carries
367
+ non-secret decision settings only. Without a key the command exits 1 with a clear message and an
368
+ empty stdout.
369
+
370
+ ### Contextual-link coverage
371
+
372
+ Declare a contextual-link coverage policy in `seo.config.ts` (a `coverage` array
373
+ of `{ path, minInbound }`), or pass repeatable `--require-inbound "<path-glob>=<n>"`
374
+ flags to override it for one run. `pagegraph check` then fails (exit 1) unless
375
+ every sitemap-eligible page matching the glob has at least `minInbound` incoming
376
+ `related` edges. `*` matches within a path segment and `**` crosses segments; a
377
+ rule that matches no sitemap-eligible page is itself a violation.
378
+
379
+ ```bash
380
+ pagegraph check --require-inbound "/pricing=2" --require-inbound "/features/*=1"
381
+ ```
382
+
307
383
  ### Audit any website
308
384
 
309
385
  `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 { r as extractAnchors } from "./links-sGbLkl-7.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) {