pagegraph 0.8.4 → 0.10.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.
@@ -88,6 +99,7 @@ export const Route = createFileRoute("/pricing")({
88
99
  sitemap: { priority: 0.9, changeFrequency: "weekly" },
89
100
  related: ["/features", "/docs"],
90
101
  link: { title: "Pricing", description: "Simple volume pricing." },
102
+ modifiedAt: "2026-09-29", // sitemap <lastmod> and the freshness report
91
103
  },
92
104
  },
93
105
  head: (ctx) =>
@@ -349,9 +361,15 @@ severities:
349
361
  - **structural** — internally broken declarations; these fail `pagegraph check` (exit 1):
350
362
  dead or duplicate edges, path collisions, a redirect in the sitemap, a
351
363
  sitemap/noindex contradiction, a `related` target with no `link` card, an
352
- instance without a title, or an unmet contextual-link coverage rule.
364
+ instance without a title, a date that is not ISO 8601, or an unmet
365
+ contextual-link coverage rule.
353
366
  - **editorial** — quality smells, reported but non-failing: duplicate or mis-sized
354
- titles and descriptions.
367
+ titles and descriptions, and pages older than the freshness policy.
368
+
369
+ Each sitemap entry's `<lastmod>` is the page's last declared change: an
370
+ instance's `modifiedAt`, else its `publishedAt`; a route's `modifiedAt`. A param
371
+ route's date is never inherited by its instances. Undated pages emit no
372
+ `<lastmod>`.
355
373
 
356
374
  ## Coverage gate (Vite)
357
375
 
@@ -401,6 +419,8 @@ export default defineSeoConfig({
401
419
 
402
420
  ```bash
403
421
  pagegraph check # CI gate — exit 1 on structural violations
422
+ pagegraph check --site <url> # also check every declared page's rendered HTML
423
+ pagegraph stale # refresh queue: sitemap pages past the freshness policy
404
424
  pagegraph graph # the graph as a tree · --format mermaid | json
405
425
  pagegraph inspect /pricing # one node: policy, sitemap status, in/out edges
406
426
  pagegraph inspect <url> --live # fetch a deployed page, validate its rendered <head>
@@ -423,7 +443,12 @@ private-IP-blocked HTTP path as `pagegraph audit` — and reports what a crawler
423
443
  actually receives: the real homepage depth, the pages with no incoming internal
424
444
  edge (rendered orphans), and, when the app has a `seo.config.ts`, the
425
445
  declared-vs-rendered link diff. It is bounded with `--limit` and needs no
426
- framework or config:
446
+ framework or config. Discovery reads same-origin `robots.txt` and bounded
447
+ sitemaps before following page anchors, so a sitemap-only page can appear as an
448
+ orphan. The JSON `crawl` block records skipped URLs, discovery failures, and
449
+ sitemap truncation. Link-followed non-HTML alternates are recorded in `nonHtml`
450
+ without counting as HTML page failures; a sitemap-listed or declared page served
451
+ as non-HTML still blocks a coverage assertion:
427
452
 
428
453
  ```bash
429
454
  pagegraph links verify https://example.com
@@ -456,6 +481,11 @@ and the provenance a gate needs to trust it:
456
481
  "bodyTruncated": false,
457
482
  "truncatedPages": [],
458
483
  "failures": [],
484
+ "nonHtml": [{ "url": "https://example.com/auth.md", "finalUrl": "https://example.com/auth.md", "contentType": "text/markdown" }],
485
+ "discoveryFailures": [],
486
+ "skipped": [],
487
+ "sitemapTruncated": false,
488
+ "sitemapUsed": true,
459
489
  },
460
490
  "nodes": ["/", "/pricing"],
461
491
  "edges": [{ "from": "/", "to": "/pricing", "region": "body" }],
@@ -481,6 +511,7 @@ pagegraph links verify --rendered .seo/rendered.json --assert-coverage --json |
481
511
  The command exits non-zero when any rule is unmet, and **refuses to assert** (also
482
512
  non-zero) rather than reporting a false pass when the crawl outran `--limit`, a
483
513
  page failed to fetch (its anchors are missing), a body hit `--max-body-bytes`, the
514
+ robots or sitemap discovery is incomplete, the
484
515
  config declares no `coverage`, or the artifact's origin differs from
485
516
  `seo.config.ts`. Under `--assert-coverage` the report gains a `coverage` block
486
517
  (`{ rules, ok, violations }`); the default `--json` summary is unchanged.
@@ -491,9 +522,11 @@ config declares no `coverage`, or the artifact's origin differs from
491
522
  `(source, destination)` pairs that are plausible contextual links and not already
492
523
  connected. Pages are grouped into clusters — a shared top-level section, or the
493
524
  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.
525
+ Each direction is evaluated separately. A declared `related` edge or served
526
+ body-region anchor excludes only its own direction. Nav, header, and footer
527
+ anchors do not exclude contextual suggestions. `--rendered` accepts a JSON dump
528
+ of already-served anchors (`[{ from, to, region }]` or `{ edges: [...] }`).
529
+ Legacy edges without a `region` are treated as body links.
497
530
 
498
531
  The output is a deterministic, reviewable plan: human text by default and
499
532
  versioned JSON with `--json`. Nothing is applied; `--limit` and `--cluster`
@@ -507,6 +540,43 @@ pagegraph links candidates --rendered rendered.json --json | jq
507
540
  pagegraph improve links --page "/docs/**"
508
541
  ```
509
542
 
543
+ For content-backed placements, opt in to a bounded same-origin probe. The configured
544
+ `origin` must match `--site`. Each schema-version-2 result carries a sentence
545
+ from served main content, an exact anchor phrase inside it, a target sentence,
546
+ and score components. Unreadable and over-limit pages are reported as skipped.
547
+ Code samples and tabular comparisons are excluded. An anchor is proposed only
548
+ when its distinct content words occur together in a target sentence, so review
549
+ the placement and its context before editing. Repeated passages and clause
550
+ fragments are excluded; named destinations require a destination-specific anchor.
551
+ The home and about pages are omitted as destinations because site navigation already leads there.
552
+ The pair-only command above remains offline and emits schema version 1.
553
+ The page budget selects low-inbound candidate pairs first. Use repeatable
554
+ `--target /exact/path` to put a known weak destination within the budget. Add
555
+ `--source /exact/path` when you have a likely source and want to guarantee the
556
+ pair is probed even on a large site; raise `--page-limit` for more pairs.
557
+ For a site with many legal, comparison, or other low-inbound pages, use
558
+ `--cluster blog` (or another section) to spend the same page budget on an
559
+ editorial cluster. A zero-result broad probe means no placement passed the
560
+ evidence rules within its selected pages; it does not mean the whole site has
561
+ no possible links.
562
+
563
+ ```bash
564
+ pagegraph links verify https://example.com --json
565
+ pagegraph links candidates --site https://example.com --page-limit 25 --json > /tmp/pagegraph-suggestions.json
566
+ pagegraph links candidates --site https://example.com --cluster blog --page-limit 25 --json
567
+ pagegraph links candidates --site https://example.com --source /blog/guide --target /blog/weak --page-limit 25 --json > /tmp/pagegraph-suggestions.json
568
+ pagegraph improve links --suggestions /tmp/pagegraph-suggestions.json --dry-run
569
+ pagegraph improve links --suggestions /tmp/pagegraph-suggestions.json
570
+ ```
571
+
572
+ `improve links` validates the artifact against the configured origin and graph,
573
+ then asks Jev to judge the suggested placements. Only accepted `add` or `update`
574
+ decisions can open an edit turn; skipped and review-band links remain in the run
575
+ artifact without authorizing an edit. Before an accepted edit, Pagegraph checks
576
+ that the source sentence and target passage still occur in served copy. Regenerate
577
+ the artifact if either page changed. For a local site, pass `--allow-private` to
578
+ both `links candidates --site` and `improve links --suggestions`.
579
+
510
580
  ### Contextual-link coverage
511
581
 
512
582
  Declare a contextual-link coverage policy in `seo.config.ts` (a `coverage` array
@@ -524,6 +594,83 @@ pagegraph check --require-inbound "/pricing=2" --require-inbound "/features/*=1"
524
594
  actually served, assert the same policy on the rendered graph — see
525
595
  [Assert coverage on the rendered graph](#assert-coverage-on-the-rendered-graph).
526
596
 
597
+ ### Check rendered content
598
+
599
+ The declared graph cannot see what a page renders: headings come from
600
+ components, and the `<title>`, JSON-LD, and visible text are composed at render
601
+ time. `pagegraph check --site <url>` fetches every sitemap-eligible page (and
602
+ every concrete page that declares `noindex`) from a running server and checks
603
+ the server-rendered HTML — what a crawler receives before any script runs. A
604
+ server error or failed request is retried once, because a dev server's first
605
+ render of a route can fail while it compiles.
606
+
607
+ ```bash
608
+ pagegraph check --site http://localhost:3000 --allow-private
609
+ pagegraph check --site https://staging.example.com --json | jq '.violations'
610
+ ```
611
+
612
+ | Rule | Severity | Fails when |
613
+ | --- | --- | --- |
614
+ | `rendered-page-unavailable` | structural | The page errors, is not HTML, is truncated, or redirects off its declared path |
615
+ | `rendered-robots-mismatch` | structural | An indexable page renders a `noindex` robots meta, or a `noindex` declaration renders none |
616
+ | `rendered-canonical-mismatch` | structural | The canonical is missing or its path is not the declared path |
617
+ | `missing-h1`, `multiple-h1`, `empty-h1` | structural | The page does not have exactly one H1 with text |
618
+ | `faq-not-visible` | structural | A FAQPage question or answer is not in the rendered text |
619
+ | `offer-price-not-visible` | structural | An Offer or AggregateOffer price does not appear on the page |
620
+ | `heading-level-skip` | editorial | The main-content outline skips a level (h2 → h4) |
621
+ | `h1-title-mismatch` | editorial | The H1 and `<title>` share no significant term |
622
+ | `webpage-head-mismatch` | editorial | A WebPage `name` or `description` disagrees with the head |
623
+ | `thin-content` | editorial | Main content is under the page's `content.minWords` floor |
624
+
625
+ Google requires structured data to describe content the page visibly shows, so
626
+ a FAQ answer that exists only in JSON-LD or in hydration data (an accordion that
627
+ mounts answers on open, say) is structural. Comparisons ignore punctuation,
628
+ case, and markup; prices match as numbers (`19` matches `$19/mo` and `19.00`,
629
+ not `$199`). Canonicals compare by path, and robots are read from the meta tag
630
+ only, so a local or preview host that sends `X-Robots-Tag: noindex` everywhere
631
+ can still run the check.
632
+
633
+ Word-count floors are project policy, declared per path glob; a page matching
634
+ several rules must meet the highest:
635
+
636
+ ```ts
637
+ export default defineSeoConfig({
638
+ // ...
639
+ content: {
640
+ minWords: [
641
+ { path: "/blog/*", minWords: 500 },
642
+ { path: "/compare/*", minWords: 800 },
643
+ ],
644
+ },
645
+ });
646
+ ```
647
+
648
+ `pagegraph audit` runs the heading and structured-data rules on every audited
649
+ HTML page too. The robots, canonical, and word-count rules need the declared
650
+ graph, so they stay in `check`.
651
+
652
+ ### Freshness
653
+
654
+ Declare a freshness policy to turn old pages into editorial `stale-page`
655
+ findings in `pagegraph check`, and list the refresh queue with `pagegraph stale`:
656
+
657
+ ```ts
658
+ export default defineSeoConfig({
659
+ // ...
660
+ freshness: { maxAgeDays: 90 },
661
+ });
662
+ ```
663
+
664
+ ```bash
665
+ pagegraph stale # stale pages, oldest first; undated pages counted
666
+ pagegraph stale --max-age-days 180 --json
667
+ ```
668
+
669
+ Only sitemap-eligible pages are judged. A page without a date is reported as
670
+ undated rather than stale; give content its `modifiedAt` (or a route its
671
+ `staticData.seo.modifiedAt`) to bring it into the report and the sitemap's
672
+ `<lastmod>`.
673
+
527
674
  ### Audit any website
528
675
 
529
676
  `pagegraph audit` is framework-independent and does not need `seo.config.ts`. It
@@ -584,6 +731,8 @@ Everything the CLI checks is a pure function you can call from a test:
584
731
  ```ts
585
732
  import {
586
733
  checkGraph,
734
+ checkPageContent,
735
+ extractPageContent,
587
736
  hasStructuralViolations,
588
737
  inspectHtml,
589
738
  } from "pagegraph";
@@ -593,6 +742,9 @@ expect(hasStructuralViolations(checkGraph(loadSeoGraph()))).toBe(false);
593
742
  // render a page however you like, then assert the head it actually ships
594
743
  const report = inspectHtml(url, 200, html);
595
744
  expect(report.issues).toEqual([]);
745
+
746
+ // …and the content it ships: one H1, visible FAQ answers, shown prices
747
+ expect(checkPageContent(extractPageContent(html))).toEqual([]);
596
748
  ```
597
749
 
598
750
  ## TanStack Start example
package/dist/audit.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import "./content-DVL2iyGU.js";
1
2
  import * as Context from "effect/Context";
2
3
  import * as Effect from "effect/Effect";
3
4
  import * as Layer from "effect/Layer";
@@ -103,6 +104,12 @@ interface ProbeOptions {
103
104
  * probe field that grows with document size; the audit report leaves it unset.
104
105
  */
105
106
  readonly captureAnchors?: boolean | undefined;
107
+ /** Capture bounded text for robots and sitemap discovery. Never enabled for audit reports. */
108
+ readonly captureBody?: boolean | undefined;
109
+ /** Refuse cross-origin redirects during a same-origin crawl. */
110
+ readonly sameOrigin?: string | undefined;
111
+ /** Optional per-redirect crawl policy, checked before each network request. */
112
+ readonly allowUrl?: ((url: URL) => boolean) | undefined;
106
113
  }
107
114
  interface AuditOptions {
108
115
  readonly concurrency?: number;
package/dist/audit.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as extractAnchors } from "./links-BFJc-pJ9.js";
1
+ import { l as extractAnchors, r as extractPageContent, t as checkPageContent } from "./content-DbyJoRpj.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";
@@ -110,6 +110,22 @@ const pageRules = [
110
110
  return [finding(input, "invalid-canonical", "Page canonical is not a valid URL.", "Use an absolute or resolvable canonical URL.", canonical)];
111
111
  }
112
112
  }
113
+ },
114
+ {
115
+ id: "content",
116
+ evaluate: (observation, input) => {
117
+ const content = record(record(pageEvidence(observation.evidence))?.["document"])?.["content"];
118
+ const list = record(content)?.["findings"];
119
+ if (!Array.isArray(list)) return [];
120
+ return list.flatMap((entry) => {
121
+ const rule = stringAt(entry, "rule");
122
+ const message = stringAt(entry, "message");
123
+ const fix = stringAt(entry, "fix");
124
+ const severity = stringAt(entry, "severity");
125
+ if (rule === void 0 || message === void 0 || fix === void 0) return [];
126
+ return [finding(input, rule, message, fix, void 0, severity === "editorial" ? "editorial" : "structural")];
127
+ });
128
+ }
113
129
  }
114
130
  ];
115
131
  const evaluateRules = (rules, observation, input) => rules.flatMap((rule) => rule.evaluate(observation, input));
@@ -864,10 +880,11 @@ const canonicalUrl = (html) => {
864
880
  return null;
865
881
  };
866
882
  const countMatches = (value, pattern) => [...value.matchAll(pattern)].length;
867
- const extractDocumentSignals = (body, contentType, requestedUrl) => {
868
- const isHtml = contentType.includes("text/html");
883
+ const extractDocumentSignals = (body, contentType, requestedUrl, bodyTruncated = false) => {
884
+ const isHtml = contentType.includes("text/html") || contentType.includes("application/xhtml+xml");
869
885
  const isMarkdown = contentType.includes("text/markdown");
870
886
  const looksJson = contentType.includes("json") || requestedUrl.pathname.endsWith(".json");
887
+ const content = isHtml && !bodyTruncated ? extractPageContent(body) : null;
871
888
  const titleMatch = isHtml ? /<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(body) : null;
872
889
  let jsonValid = null;
873
890
  if (looksJson && body.trim().length > 0) try {
@@ -885,8 +902,13 @@ const extractDocumentSignals = (body, contentType, requestedUrl) => {
885
902
  modulePreloadCount: isHtml ? countMatches(body, /<link\b[^>]*rel\s*=\s*["'][^"']*modulepreload[^"']*["'][^>]*>/gi) : 0,
886
903
  scriptCount: isHtml ? countMatches(body, /<script\b/gi) : 0,
887
904
  stylesheetCount: isHtml ? countMatches(body, /<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*>/gi) : 0,
888
- wordCount: isMarkdown ? body.trim().split(/\s+/).filter(Boolean).length : null,
889
- jsonValid
905
+ wordCount: isMarkdown ? body.trim().split(/\s+/).filter(Boolean).length : content?.wordCount ?? null,
906
+ jsonValid,
907
+ content: content === null ? null : {
908
+ h1: content.h1,
909
+ outline: content.outline,
910
+ findings: checkPageContent(content)
911
+ }
890
912
  };
891
913
  };
892
914
  const readBoundedBody = async (response, maxBodyBytes) => {
@@ -960,6 +982,8 @@ const probeHttp = async (request, options) => {
960
982
  let currentUrl = request.url;
961
983
  let response = null;
962
984
  for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
985
+ if (options.sameOrigin !== void 0 && currentUrl.origin !== options.sameOrigin) throw new Error(`Redirected off-origin to ${currentUrl.href}`);
986
+ if (options.allowUrl !== void 0 && !options.allowUrl(currentUrl)) throw new Error(`Redirected to robots-disallowed URL ${currentUrl.href}`);
963
987
  response = await requestPinned(currentUrl, request, options);
964
988
  headersAt = performance.now();
965
989
  const location = response.headers.location;
@@ -977,7 +1001,7 @@ const probeHttp = async (request, options) => {
977
1001
  } : await readBoundedBody(response, options.maxBodyBytes);
978
1002
  const contentType = typeof response.headers["content-type"] === "string" ? response.headers["content-type"] : "";
979
1003
  const body = new TextDecoder().decode(bounded.bytes);
980
- const anchors = options.captureAnchors === true && contentType.includes("text/html") ? extractAnchors(body, currentUrl.href) : void 0;
1004
+ const anchors = options.captureAnchors === true && (contentType === "" || /text\/html|application\/xhtml\+xml/i.test(contentType)) ? extractAnchors(body, currentUrl.href) : void 0;
981
1005
  return {
982
1006
  kind: request.kind,
983
1007
  method: request.method,
@@ -994,7 +1018,8 @@ const probeHttp = async (request, options) => {
994
1018
  capturedBodySha256: bounded.bytes.byteLength === 0 ? null : createHash("sha256").update(bounded.bytes).digest("hex"),
995
1019
  bodyTruncated: bounded.truncated,
996
1020
  bodyExcerpt: bodyExcerpt(body, contentType),
997
- document: body.length === 0 ? null : extractDocumentSignals(body, contentType, currentUrl),
1021
+ ...options.captureBody === true ? { body } : {},
1022
+ document: body.length === 0 ? null : extractDocumentSignals(body, contentType, currentUrl, bounded.truncated),
998
1023
  anchors,
999
1024
  error: null
1000
1025
  };
@@ -1015,6 +1040,7 @@ const probeHttp = async (request, options) => {
1015
1040
  capturedBodySha256: null,
1016
1041
  bodyTruncated: false,
1017
1042
  bodyExcerpt: null,
1043
+ ...options.captureBody === true ? { body: void 0 } : {},
1018
1044
  document: null,
1019
1045
  error: errorMessage$2(error)
1020
1046
  };