pagegraph 0.9.0 → 0.10.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
@@ -99,6 +99,7 @@ export const Route = createFileRoute("/pricing")({
99
99
  sitemap: { priority: 0.9, changeFrequency: "weekly" },
100
100
  related: ["/features", "/docs"],
101
101
  link: { title: "Pricing", description: "Simple volume pricing." },
102
+ modifiedAt: "2026-09-29", // sitemap <lastmod> and the freshness report
102
103
  },
103
104
  },
104
105
  head: (ctx) =>
@@ -360,9 +361,15 @@ severities:
360
361
  - **structural** — internally broken declarations; these fail `pagegraph check` (exit 1):
361
362
  dead or duplicate edges, path collisions, a redirect in the sitemap, a
362
363
  sitemap/noindex contradiction, a `related` target with no `link` card, an
363
- 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.
364
366
  - **editorial** — quality smells, reported but non-failing: duplicate or mis-sized
365
- 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>`.
366
373
 
367
374
  ## Coverage gate (Vite)
368
375
 
@@ -412,6 +419,8 @@ export default defineSeoConfig({
412
419
 
413
420
  ```bash
414
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
415
424
  pagegraph graph # the graph as a tree · --format mermaid | json
416
425
  pagegraph inspect /pricing # one node: policy, sitemap status, in/out edges
417
426
  pagegraph inspect <url> --live # fetch a deployed page, validate its rendered <head>
@@ -585,6 +594,83 @@ pagegraph check --require-inbound "/pricing=2" --require-inbound "/features/*=1"
585
594
  actually served, assert the same policy on the rendered graph — see
586
595
  [Assert coverage on the rendered graph](#assert-coverage-on-the-rendered-graph).
587
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
+
588
674
  ### Audit any website
589
675
 
590
676
  `pagegraph audit` is framework-independent and does not need `seo.config.ts`. It
@@ -645,6 +731,8 @@ Everything the CLI checks is a pure function you can call from a test:
645
731
  ```ts
646
732
  import {
647
733
  checkGraph,
734
+ checkPageContent,
735
+ extractPageContent,
648
736
  hasStructuralViolations,
649
737
  inspectHtml,
650
738
  } from "pagegraph";
@@ -654,6 +742,9 @@ expect(hasStructuralViolations(checkGraph(loadSeoGraph()))).toBe(false);
654
742
  // render a page however you like, then assert the head it actually ships
655
743
  const report = inspectHtml(url, 200, html);
656
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([]);
657
748
  ```
658
749
 
659
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";
package/dist/audit.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as extractAnchors } from "./links-DAa8KVzg.js";
1
+ import { l as extractAnchors, r as extractPageContent, t as checkPageContent } from "./content-BE9KCTgb.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) => {
883
+ const extractDocumentSignals = (body, contentType, requestedUrl, bodyTruncated = false) => {
868
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) => {
@@ -997,7 +1019,7 @@ const probeHttp = async (request, options) => {
997
1019
  bodyTruncated: bounded.truncated,
998
1020
  bodyExcerpt: bodyExcerpt(body, contentType),
999
1021
  ...options.captureBody === true ? { body } : {},
1000
- document: body.length === 0 ? null : extractDocumentSignals(body, contentType, currentUrl),
1022
+ document: body.length === 0 ? null : extractDocumentSignals(body, contentType, currentUrl, bounded.truncated),
1001
1023
  anchors,
1002
1024
  error: null
1003
1025
  };