pagegraph 0.8.4 → 0.9.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
@@ -43,6 +43,17 @@ OpenCode SDK and its private Effect runtime only when invoked. `vite` stays a pe
43
43
  load your app through Vite at runtime — and `lighthouse` is only needed by `pagegraph audit`.
44
44
  Importing `pagegraph/audit` programmatically still needs `effect`.
45
45
 
46
+ ### Agent skill
47
+
48
+ The Pagegraph CLI serves its bundled TanStack Start integration skill:
49
+
50
+ ```bash
51
+ pagegraph skills list
52
+ pagegraph skills get core
53
+ ```
54
+
55
+ The skill is maintained at [skill-data/core/SKILL.md](skill-data/core/SKILL.md) and compiled into the CLI, so the instructions match the installed Pagegraph version. `skills get core` prints Markdown to stdout; `--json` returns a structured payload.
56
+
46
57
  ## Quick start
47
58
 
48
59
  **1. Bind your site identity once.** Route files never see an origin or a brand name.
@@ -423,7 +434,12 @@ private-IP-blocked HTTP path as `pagegraph audit` — and reports what a crawler
423
434
  actually receives: the real homepage depth, the pages with no incoming internal
424
435
  edge (rendered orphans), and, when the app has a `seo.config.ts`, the
425
436
  declared-vs-rendered link diff. It is bounded with `--limit` and needs no
426
- framework or config:
437
+ framework or config. Discovery reads same-origin `robots.txt` and bounded
438
+ sitemaps before following page anchors, so a sitemap-only page can appear as an
439
+ orphan. The JSON `crawl` block records skipped URLs, discovery failures, and
440
+ sitemap truncation. Link-followed non-HTML alternates are recorded in `nonHtml`
441
+ without counting as HTML page failures; a sitemap-listed or declared page served
442
+ as non-HTML still blocks a coverage assertion:
427
443
 
428
444
  ```bash
429
445
  pagegraph links verify https://example.com
@@ -456,6 +472,11 @@ and the provenance a gate needs to trust it:
456
472
  "bodyTruncated": false,
457
473
  "truncatedPages": [],
458
474
  "failures": [],
475
+ "nonHtml": [{ "url": "https://example.com/auth.md", "finalUrl": "https://example.com/auth.md", "contentType": "text/markdown" }],
476
+ "discoveryFailures": [],
477
+ "skipped": [],
478
+ "sitemapTruncated": false,
479
+ "sitemapUsed": true,
459
480
  },
460
481
  "nodes": ["/", "/pricing"],
461
482
  "edges": [{ "from": "/", "to": "/pricing", "region": "body" }],
@@ -481,6 +502,7 @@ pagegraph links verify --rendered .seo/rendered.json --assert-coverage --json |
481
502
  The command exits non-zero when any rule is unmet, and **refuses to assert** (also
482
503
  non-zero) rather than reporting a false pass when the crawl outran `--limit`, a
483
504
  page failed to fetch (its anchors are missing), a body hit `--max-body-bytes`, the
505
+ robots or sitemap discovery is incomplete, the
484
506
  config declares no `coverage`, or the artifact's origin differs from
485
507
  `seo.config.ts`. Under `--assert-coverage` the report gains a `coverage` block
486
508
  (`{ rules, ok, violations }`); the default `--json` summary is unchanged.
@@ -491,9 +513,11 @@ config declares no `coverage`, or the artifact's origin differs from
491
513
  `(source, destination)` pairs that are plausible contextual links and not already
492
514
  connected. Pages are grouped into clusters — a shared top-level section, or the
493
515
  same `kind` for root-level pages — and only sitemap-eligible pages are proposed.
494
- Pairs already declared as a `related` edge are excluded, and `--rendered` accepts
495
- a JSON dump of already-served anchors (`[{ from, to }]` or `{ edges: [...] }`) to
496
- exclude those too.
516
+ Each direction is evaluated separately. A declared `related` edge or served
517
+ body-region anchor excludes only its own direction. Nav, header, and footer
518
+ anchors do not exclude contextual suggestions. `--rendered` accepts a JSON dump
519
+ of already-served anchors (`[{ from, to, region }]` or `{ edges: [...] }`).
520
+ Legacy edges without a `region` are treated as body links.
497
521
 
498
522
  The output is a deterministic, reviewable plan: human text by default and
499
523
  versioned JSON with `--json`. Nothing is applied; `--limit` and `--cluster`
@@ -507,6 +531,43 @@ pagegraph links candidates --rendered rendered.json --json | jq
507
531
  pagegraph improve links --page "/docs/**"
508
532
  ```
509
533
 
534
+ For content-backed placements, opt in to a bounded same-origin probe. The configured
535
+ `origin` must match `--site`. Each schema-version-2 result carries a sentence
536
+ from served main content, an exact anchor phrase inside it, a target sentence,
537
+ and score components. Unreadable and over-limit pages are reported as skipped.
538
+ Code samples and tabular comparisons are excluded. An anchor is proposed only
539
+ when its distinct content words occur together in a target sentence, so review
540
+ the placement and its context before editing. Repeated passages and clause
541
+ fragments are excluded; named destinations require a destination-specific anchor.
542
+ The home and about pages are omitted as destinations because site navigation already leads there.
543
+ The pair-only command above remains offline and emits schema version 1.
544
+ The page budget selects low-inbound candidate pairs first. Use repeatable
545
+ `--target /exact/path` to put a known weak destination within the budget. Add
546
+ `--source /exact/path` when you have a likely source and want to guarantee the
547
+ pair is probed even on a large site; raise `--page-limit` for more pairs.
548
+ For a site with many legal, comparison, or other low-inbound pages, use
549
+ `--cluster blog` (or another section) to spend the same page budget on an
550
+ editorial cluster. A zero-result broad probe means no placement passed the
551
+ evidence rules within its selected pages; it does not mean the whole site has
552
+ no possible links.
553
+
554
+ ```bash
555
+ pagegraph links verify https://example.com --json
556
+ pagegraph links candidates --site https://example.com --page-limit 25 --json > /tmp/pagegraph-suggestions.json
557
+ pagegraph links candidates --site https://example.com --cluster blog --page-limit 25 --json
558
+ pagegraph links candidates --site https://example.com --source /blog/guide --target /blog/weak --page-limit 25 --json > /tmp/pagegraph-suggestions.json
559
+ pagegraph improve links --suggestions /tmp/pagegraph-suggestions.json --dry-run
560
+ pagegraph improve links --suggestions /tmp/pagegraph-suggestions.json
561
+ ```
562
+
563
+ `improve links` validates the artifact against the configured origin and graph,
564
+ then asks Jev to judge the suggested placements. Only accepted `add` or `update`
565
+ decisions can open an edit turn; skipped and review-band links remain in the run
566
+ artifact without authorizing an edit. Before an accepted edit, Pagegraph checks
567
+ that the source sentence and target passage still occur in served copy. Regenerate
568
+ the artifact if either page changed. For a local site, pass `--allow-private` to
569
+ both `links candidates --site` and `improve links --suggestions`.
570
+
510
571
  ### Contextual-link coverage
511
572
 
512
573
  Declare a contextual-link coverage policy in `seo.config.ts` (a `coverage` array
package/dist/audit.d.ts CHANGED
@@ -103,6 +103,12 @@ interface ProbeOptions {
103
103
  * probe field that grows with document size; the audit report leaves it unset.
104
104
  */
105
105
  readonly captureAnchors?: boolean | undefined;
106
+ /** Capture bounded text for robots and sitemap discovery. Never enabled for audit reports. */
107
+ readonly captureBody?: boolean | undefined;
108
+ /** Refuse cross-origin redirects during a same-origin crawl. */
109
+ readonly sameOrigin?: string | undefined;
110
+ /** Optional per-redirect crawl policy, checked before each network request. */
111
+ readonly allowUrl?: ((url: URL) => boolean) | undefined;
106
112
  }
107
113
  interface AuditOptions {
108
114
  readonly concurrency?: number;
package/dist/audit.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as extractAnchors } from "./links-BFJc-pJ9.js";
1
+ import { a as extractAnchors } from "./links-DAa8KVzg.js";
2
2
  import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import * as Context from "effect/Context";
@@ -865,7 +865,7 @@ const canonicalUrl = (html) => {
865
865
  };
866
866
  const countMatches = (value, pattern) => [...value.matchAll(pattern)].length;
867
867
  const extractDocumentSignals = (body, contentType, requestedUrl) => {
868
- const isHtml = contentType.includes("text/html");
868
+ const isHtml = contentType.includes("text/html") || contentType.includes("application/xhtml+xml");
869
869
  const isMarkdown = contentType.includes("text/markdown");
870
870
  const looksJson = contentType.includes("json") || requestedUrl.pathname.endsWith(".json");
871
871
  const titleMatch = isHtml ? /<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(body) : null;
@@ -960,6 +960,8 @@ const probeHttp = async (request, options) => {
960
960
  let currentUrl = request.url;
961
961
  let response = null;
962
962
  for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
963
+ if (options.sameOrigin !== void 0 && currentUrl.origin !== options.sameOrigin) throw new Error(`Redirected off-origin to ${currentUrl.href}`);
964
+ if (options.allowUrl !== void 0 && !options.allowUrl(currentUrl)) throw new Error(`Redirected to robots-disallowed URL ${currentUrl.href}`);
963
965
  response = await requestPinned(currentUrl, request, options);
964
966
  headersAt = performance.now();
965
967
  const location = response.headers.location;
@@ -977,7 +979,7 @@ const probeHttp = async (request, options) => {
977
979
  } : await readBoundedBody(response, options.maxBodyBytes);
978
980
  const contentType = typeof response.headers["content-type"] === "string" ? response.headers["content-type"] : "";
979
981
  const body = new TextDecoder().decode(bounded.bytes);
980
- const anchors = options.captureAnchors === true && contentType.includes("text/html") ? extractAnchors(body, currentUrl.href) : void 0;
982
+ const anchors = options.captureAnchors === true && (contentType === "" || /text\/html|application\/xhtml\+xml/i.test(contentType)) ? extractAnchors(body, currentUrl.href) : void 0;
981
983
  return {
982
984
  kind: request.kind,
983
985
  method: request.method,
@@ -994,6 +996,7 @@ const probeHttp = async (request, options) => {
994
996
  capturedBodySha256: bounded.bytes.byteLength === 0 ? null : createHash("sha256").update(bounded.bytes).digest("hex"),
995
997
  bodyTruncated: bounded.truncated,
996
998
  bodyExcerpt: bodyExcerpt(body, contentType),
999
+ ...options.captureBody === true ? { body } : {},
997
1000
  document: body.length === 0 ? null : extractDocumentSignals(body, contentType, currentUrl),
998
1001
  anchors,
999
1002
  error: null
@@ -1015,6 +1018,7 @@ const probeHttp = async (request, options) => {
1015
1018
  capturedBodySha256: null,
1016
1019
  bodyTruncated: false,
1017
1020
  bodyExcerpt: null,
1021
+ ...options.captureBody === true ? { body: void 0 } : {},
1018
1022
  document: null,
1019
1023
  error: errorMessage$2(error)
1020
1024
  };