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 +93 -2
- package/dist/audit.d.ts +1 -0
- package/dist/audit.js +27 -5
- package/dist/audit.js.map +1 -1
- package/dist/cli.js +3410 -2728
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +14 -1
- package/dist/config.js.map +1 -1
- package/dist/content-BE9KCTgb.js +1391 -0
- package/dist/content-BE9KCTgb.js.map +1 -0
- package/dist/{checks-_X8yjMvV.d.ts → content-DVL2iyGU.d.ts} +264 -126
- package/dist/index.d.ts +12 -5
- package/dist/index.js +4 -535
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/links-DAa8KVzg.js +0 -332
- package/dist/links-DAa8KVzg.js.map +0 -1
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
|
|
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
package/dist/audit.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
};
|