pagegraph 0.11.0 → 0.13.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.
Files changed (100) hide show
  1. package/README.md +403 -148
  2. package/dist/audit.d.ts +2 -2
  3. package/dist/audit.js +4 -4
  4. package/dist/build-only/audit.d.ts +1 -0
  5. package/dist/build-only/audit.js +6 -0
  6. package/dist/build-only/audit.js.map +1 -0
  7. package/dist/build-only/claims.d.ts +1 -0
  8. package/dist/build-only/claims.js +6 -0
  9. package/dist/build-only/claims.js.map +1 -0
  10. package/dist/build-only/config.d.ts +1 -0
  11. package/dist/build-only/config.js +6 -0
  12. package/dist/build-only/config.js.map +1 -0
  13. package/dist/build-only/oxlint.d.ts +1 -0
  14. package/dist/build-only/oxlint.js +6 -0
  15. package/dist/build-only/oxlint.js.map +1 -0
  16. package/dist/build-only/tanstack-start.d.ts +1 -0
  17. package/dist/build-only/tanstack-start.js +6 -0
  18. package/dist/build-only/tanstack-start.js.map +1 -0
  19. package/dist/build-only/vite.d.ts +1 -0
  20. package/dist/build-only/vite.js +6 -0
  21. package/dist/build-only/vite.js.map +1 -0
  22. package/dist/{checks-nk8ZRRIA.js → checks-BS-aDXB_.js} +4 -142
  23. package/dist/checks-BS-aDXB_.js.map +1 -0
  24. package/dist/claims-Bch7Lxlo.d.ts +92 -0
  25. package/dist/claims-Bv7voeEV.js +431 -0
  26. package/dist/claims-Bv7voeEV.js.map +1 -0
  27. package/dist/claims.d.ts +94 -0
  28. package/dist/claims.js +2 -0
  29. package/dist/cli.js +10368 -8485
  30. package/dist/cli.js.map +1 -1
  31. package/dist/config.d.ts +4 -83
  32. package/dist/config.js +3 -122
  33. package/dist/config.js.map +1 -1
  34. package/dist/{content-CB_kHmJ3.js → content-CHd0X4s6.js} +3 -2
  35. package/dist/{content-CB_kHmJ3.js.map → content-CHd0X4s6.js.map} +1 -1
  36. package/dist/{content-DVL2iyGU.d.ts → content-DgNr7apS.d.ts} +5 -130
  37. package/dist/declare-D5o7hwmv.js +9 -0
  38. package/dist/declare-D5o7hwmv.js.map +1 -0
  39. package/dist/document-CztEu3Vd.d.ts +109 -0
  40. package/dist/document-Dvemcv7V.js +379 -0
  41. package/dist/document-Dvemcv7V.js.map +1 -0
  42. package/dist/facts-1OKclPOk.js +115 -0
  43. package/dist/facts-1OKclPOk.js.map +1 -0
  44. package/dist/graph-D1PIndsC.d.ts +12238 -0
  45. package/dist/graph-DMNOLXLu.js +172 -0
  46. package/dist/graph-DMNOLXLu.js.map +1 -0
  47. package/dist/index-C5w9ripz.d.ts +204 -0
  48. package/dist/index.d.ts +6 -196
  49. package/dist/index.js +55 -134
  50. package/dist/index.js.map +1 -1
  51. package/dist/inline-BbS93GAT.js +682 -0
  52. package/dist/inline-BbS93GAT.js.map +1 -0
  53. package/dist/llms-Dhr_5u2O.js +19 -0
  54. package/dist/llms-Dhr_5u2O.js.map +1 -0
  55. package/dist/markdown-C6dRE9zD.js +168 -0
  56. package/dist/markdown-C6dRE9zD.js.map +1 -0
  57. package/dist/markdown-path-lJB9BAVZ.js +6 -0
  58. package/dist/markdown-path-lJB9BAVZ.js.map +1 -0
  59. package/dist/message-CuBjgZ74.js +12 -0
  60. package/dist/message-CuBjgZ74.js.map +1 -0
  61. package/dist/oxlint.d.ts +58 -0
  62. package/dist/oxlint.js +202 -0
  63. package/dist/oxlint.js.map +1 -0
  64. package/dist/page-heads-BfjWQvau.d.ts +108 -0
  65. package/dist/page-heads-BgbaIjj9.js +25 -0
  66. package/dist/page-heads-BgbaIjj9.js.map +1 -0
  67. package/dist/projections-Bk-qOOsR.d.ts +78 -0
  68. package/dist/projections-D0oOFKiY.js +142 -0
  69. package/dist/projections-D0oOFKiY.js.map +1 -0
  70. package/dist/react.d.ts +27 -12051
  71. package/dist/react.js +14 -4
  72. package/dist/react.js.map +1 -1
  73. package/dist/route-config-35ZEzkNC.js +308 -0
  74. package/dist/route-config-35ZEzkNC.js.map +1 -0
  75. package/dist/route-config-wneg6ktn.d.ts +29 -0
  76. package/dist/tanstack-start/index.d.ts +56 -0
  77. package/dist/tanstack-start/index.js +717 -0
  78. package/dist/tanstack-start/index.js.map +1 -0
  79. package/dist/tanstack-start/markdown.d.ts +12 -0
  80. package/dist/tanstack-start/markdown.js +149 -0
  81. package/dist/tanstack-start/markdown.js.map +1 -0
  82. package/dist/tanstack-start/prerender-worker.d.ts +2 -0
  83. package/dist/tanstack-start/prerender-worker.js +7 -0
  84. package/dist/tanstack-start/prerender-worker.js.map +1 -0
  85. package/dist/tanstack-start/react.d.ts +7 -0
  86. package/dist/tanstack-start/react.js +12 -0
  87. package/dist/tanstack-start/react.js.map +1 -0
  88. package/dist/tanstack-start/server.d.ts +20 -0
  89. package/dist/tanstack-start/server.js +77 -0
  90. package/dist/tanstack-start/server.js.map +1 -0
  91. package/dist/vite-graph-loader-CsGiYNWE.js +105 -0
  92. package/dist/vite-graph-loader-CsGiYNWE.js.map +1 -0
  93. package/dist/vite-graph-loader-DZCwejuJ.d.ts +133 -0
  94. package/dist/vite.d.ts +2 -28
  95. package/dist/vite.js +1 -307
  96. package/dist/wire-B4RQMc4e.js +26 -0
  97. package/dist/wire-B4RQMc4e.js.map +1 -0
  98. package/package.json +70 -7
  99. package/dist/checks-nk8ZRRIA.js.map +0 -1
  100. package/dist/vite.js.map +0 -1
package/README.md CHANGED
@@ -1,21 +1,22 @@
1
1
  # pagegraph
2
2
 
3
- Route-declared SEO for TanStack Router. Each route declares its SEO policy once, in
4
- `staticData.seo` — the sitemap, `robots.txt`, breadcrumbs, JSON-LD, cross-links, and
5
- the CI check all derive from that one declaration. There is no second place to update,
6
- so nothing drifts. A public page that ships without a declaration **fails the build**.
3
+ Route-declared SEO for TanStack Start and TanStack Router. Each route declares its SEO
4
+ policy and head once, in `staticData.seo` — the `<head>`, sitemap, `robots.txt`,
5
+ breadcrumbs, JSON-LD, cross-links, and the CI check all derive from that one
6
+ declaration. There is no second place to update, so nothing drifts. A public page that
7
+ ships without a declaration **fails the build**.
7
8
 
8
9
  ```mermaid
9
10
  flowchart LR
10
11
  subgraph declare["declare once"]
11
- route["staticData.seo<br/>+ head()"]
12
+ route["staticData.seo (+ head)<br/>head: seo.head"]
12
13
  end
13
- route --> seograph["SeoGraph<br/>(nodes + edges)"]
14
+ route --> seograph["SeoGraph<br/>(built by the Vite plugin)"]
14
15
  seograph --> sitemap["sitemap.xml"]
15
16
  seograph --> robots["robots.txt"]
16
17
  seograph --> check["pagegraph check · CI gate"]
17
18
  route --> head["&lt;head&gt; meta · canonical<br/>Breadcrumbs · JSON-LD"]
18
- gate["seoRouteConfig (vite)"] -. "fails undeclared pages" .-> route
19
+ gate["pagegraph() (vite)"] -. "fails undeclared pages" .-> route
19
20
  ```
20
21
 
21
22
  ## Install
@@ -25,17 +26,201 @@ bun add pagegraph
25
26
  bun add -D lighthouse # only for `pagegraph audit` performance evidence
26
27
  ```
27
28
 
28
- | Entry | Exports | Peers |
29
- | ---------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------- |
30
- | `pagegraph` | `buildSeoGraph`, `renderSitemap`, `renderRobots`, `contentSignal`, `checkGraph`, `checkRenderedCoverage`, `decodeRenderedEdgeArtifact`, `inspectHtml` | — |
31
- | `pagegraph/react` | `createSeo` → `seoHead`, `Breadcrumbs`, JSON-LD generators | `react`, `@tanstack/react-router` |
32
- | `pagegraph/vite` | `seoRouteConfig` coverage gate | `vite` |
33
- | `pagegraph/config` | `defineSeoConfig`, `viteGraphLoader`, `selectPageHeadNodes`, `pageHeads`, `loadPageHeads` | `vite` |
34
- | `pagegraph/audit` | Audit services, scanner protocol, rules, and report schemas | `effect` |
35
- | `pagegraph` bin | CLI over the same graph | bundled — runs on Bun |
29
+ ## Authored pages
36
30
 
37
- The core and React import graphs have **zero runtime dependencies** — everything they use is a
38
- peer, and only the entries you import need those peers loaded.
31
+ Keep authored text beside its React layout. `DocumentProvider` records only its page body when
32
+ supplied a collector; ordinary rendering preserves the layout. `Title` assigns a heading to the
33
+ nearest section or item, `Fact` resolves shared values, and `Visual` describes content for the
34
+ markdown document without rendering its children during capture.
35
+
36
+ ```tsx
37
+ import { renderToStaticMarkup } from "react-dom/server";
38
+ import { defineFacts, fact, documentMarkdown } from "pagegraph";
39
+ import {
40
+ DocumentProvider, T, Title, Section, Fact, createCollector, finishDocument,
41
+ } from "pagegraph/react";
42
+
43
+ const facts = defineFacts({ attempts: fact.number(8) });
44
+ declare module "pagegraph" {
45
+ interface Register { facts: typeof facts }
46
+ }
47
+
48
+ const collector = createCollector({ path: "/email", site: "https://example.com", facts });
49
+ const page = (
50
+ <DocumentProvider collector={collector}>
51
+ <Section kind="prose" id="intro">
52
+ <h1><Title>Email delivery</Title></h1>
53
+ <p><T>Retry up to <Fact id="attempts" /> times.</T></p>
54
+ </Section>
55
+ </DocumentProvider>
56
+ );
57
+ // Your SSR renderer supplies completed HTML, whose markers determine document order.
58
+ const html = renderToStaticMarkup(page);
59
+ const document = finishDocument(collector, {
60
+ title: "Email delivery", description: "Reliable delivery with retries.",
61
+ }, html);
62
+ const markdown = documentMarkdown(document, "https://example.com");
63
+ ```
64
+
65
+ Use `defineSectionKind` for custom formatting; its markdown writer provides `text`, `heading`,
66
+ and `table`:
67
+
68
+ ```ts
69
+ import { defineSectionKind } from "pagegraph";
70
+ const comparison = defineSectionKind({
71
+ kind: "comparison",
72
+ markdown: (section, md) => md.heading(2, section.title)
73
+ + md.table(section.items.map((item) => item.cells)),
74
+ }); // pass kind={comparison} to Section
75
+ ```
76
+
77
+ `Section.Item.Link` records a card's destination, while `CaptureAnchor` is the
78
+ public anchor boundary for router `createLink` integrations. `ForAgents` and `ForHumans` mark
79
+ audience-specific text; markdown excludes human-only content. `messageText` extracts text or
80
+ inline markdown for head metadata. The root entry also provides `hashDocument`,
81
+ `createMarkdownLock`, and pure `llmsMarkdown`/`llmsSection` string builders.
82
+
83
+ ## Rendered markdown with TanStack Start
84
+
85
+ Declare rendered pages on the route that owns `DocumentProvider`. Collection instances inherit
86
+ these fields from their parameter route; the parameter template itself is never captured.
87
+
88
+ ```tsx
89
+ import { createFileRoute } from "@tanstack/react-router";
90
+ import { DocumentProvider, Section, T, Title } from "pagegraph/react";
91
+ import { Link } from "pagegraph/tanstack-start/react";
92
+
93
+ export const Route = createFileRoute("/pricing")({
94
+ staticData: {
95
+ seo: { kind: "page", head: { title: "Pricing", description: "Plans for your team." } },
96
+ markdown: "rendered",
97
+ llms: "Product",
98
+ },
99
+ head: () => ({ meta: [{ title: "Pricing" }, { name: "description", content: "Plans for your team." }] }),
100
+ component: () => (
101
+ <DocumentProvider>
102
+ <Section kind="prose" id="plans">
103
+ <h1><Title>Plans for your team</Title></h1>
104
+ <Link to="/"><T>Back home</T></Link>
105
+ </Section>
106
+ </DocumentProvider>
107
+ ),
108
+ });
109
+ ```
110
+
111
+ `markdown: "source"` identifies pages whose Markdown is supplied by the application; pagegraph
112
+ captures only `"rendered"` pages. `llms` sets an optional llms.txt group. Use the capture-aware
113
+ `Link` for authored links: it uses TanStack's ordinary Link outside a capture and records resolved
114
+ anchor destinations during capture. The plugin adds `source=` locations to authored JSX imported
115
+ from `pagegraph/react`. In development, `DocumentProvider` rejects a current route lacking
116
+ `staticData.markdown: "rendered"`.
117
+
118
+ Configure the plugin and pass its fixed capture page to Start:
119
+
120
+ ```ts
121
+ import { pagegraph } from "pagegraph/tanstack-start";
122
+
123
+ const graph = pagegraph({
124
+ origin: "https://preview.example.com", // this deployment's SEO origin
125
+ collections: "src/collections.ts",
126
+ markdown: {
127
+ origin: "https://example.com", // canonical document and twin links
128
+ serverEntry: "dist/server/index.js", // default; relative to the Vite root
129
+ },
130
+ facts: "src/facts.ts", // exports `facts`; evaluated in the pagegraph environment
131
+ });
132
+
133
+ // In your Vite plugins:
134
+ // tanstackStart({
135
+ // prerender: { enabled: true, autoStaticPathsDiscovery: false, crawlLinks: false, failOnError: true },
136
+ // pages: [...graph.prerenderPages],
137
+ // }), graph
138
+ ```
139
+
140
+ For Cloudflare, wire the non-deployed prerender Worker to the compiled Start server. Add this to
141
+ `cloudflare()` alongside the production Worker's configuration:
142
+
143
+ ```ts
144
+ experimental: {
145
+ prerenderWorker: {
146
+ config: {
147
+ name: "app-markdown-prerender",
148
+ main: "pagegraph/tanstack-start/prerender-worker",
149
+ compatibility_date: "2026-09-20",
150
+ compatibility_flags: ["nodejs_compat"],
151
+ vars: { TSS_PRERENDERING: "true" },
152
+ assets: { binding: "ASSETS", run_worker_first: true },
153
+ },
154
+ },
155
+ },
156
+ ```
157
+
158
+ Call the capture adapter before your normal Start handler, forwarding the same request options:
159
+
160
+ ```ts
161
+ import handler from "@tanstack/react-start/server-entry";
162
+ import { markdownRequest } from "pagegraph/tanstack-start/markdown";
163
+
164
+ export default {
165
+ async fetch(request: Request) {
166
+ return await markdownRequest(request) ?? handler.fetch(request);
167
+ },
168
+ };
169
+ ```
170
+
171
+ Only dev and the prerender Worker capture documents. `GET` and `HEAD` on
172
+ `/__pagegraph/markdown.json` capture all rendered pages; `/pricing.md` and
173
+ `/pricing.document.json` capture one page in dev. Ordinary requests return `null`. In production,
174
+ private capture paths return 404 and Markdown twins fall through to your static asset server.
175
+ `markdownPagePaths()` exposes the concrete rendered page paths.
176
+
177
+ After prerender, the plugin writes `dist/client/pricing.md` (`/` becomes `index.md`),
178
+ `.pagegraph/documents/pricing.json`, and `.pagegraph/heads.json`, and removes the emitted private
179
+ capture response. Stale valid documents at their derived paths are pruned; unrelated files are
180
+ preserved. Ignore the private generated documents and heads in Git. Claims replay is reserved for
181
+ a separate integration.
182
+
183
+ Serve graph-derived llms.txt without rendering pages:
184
+
185
+ ```ts
186
+ import { createFileRoute } from "@tanstack/react-router";
187
+ import { llmsTxt } from "pagegraph/tanstack-start/server";
188
+
189
+ export const Route = createFileRoute("/llms.txt")({
190
+ server: { handlers: { GET: llmsTxt({ sections: ["## Docs\n\nRead our documentation."] }) } },
191
+ });
192
+ ```
193
+
194
+ `llmsSection()` returns the generated section for custom composition. It includes rendered twins
195
+ and explicitly grouped pages, using graph titles and descriptions and `markdown.origin`.
196
+
197
+ ## What runs where
198
+
199
+ Every entry belongs to one place. Runtime entries use portable graph and document code with
200
+ the framework peers listed below; the capture adapter runs on a Start server. Build entries
201
+ load your app through Vite, parse routes natively, or drive Node I/O.
202
+
203
+ | Entry | Runs in | Exports | Peers |
204
+ | ------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- |
205
+ | `pagegraph` | runtime (Worker, SSR, browser) | `buildSeoGraph`, `contentCollection`, `renderSitemap`, `renderRobots`, `pageHeads`, `graphToJson`/`graphFromJson`, checks, `inspectHtml`, document types, facts, markdown and locks | — |
206
+ | `pagegraph/react` | runtime | `createSeo` → `seo.head`, `seoHead`, `Breadcrumbs`, JSON-LD generators, authored page primitives | `react`, `@tanstack/react-router` |
207
+ | `pagegraph/tanstack-start/server` | runtime (Start server routes) | `robotsTxt`, `sitemapXml`, `llmsTxt`, `llmsSection`, `seoGraph`, `seoSite` | the `pagegraph()` plugin |
208
+ | `pagegraph/tanstack-start/markdown` | runtime (Start server) | `markdownRequest`, `markdownPagePaths` | `react`, `react-dom`, `@tanstack/react-start`, `@tanstack/react-router` |
209
+ | `pagegraph/tanstack-start/react` | runtime | capture-aware `Link` | `react`, `@tanstack/react-router` |
210
+ | `pagegraph/tanstack-start/prerender-worker` | non-deployed prerender Worker | compiled Start server | the `pagegraph()` plugin |
211
+ | `pagegraph/tanstack-start` | build (`vite.config.ts`, `pagegraph.config.ts`) | `pagegraph()` Vite plugin, `tanstackStartGraph`, `evaluateAppGraph` | `vite`, `@tanstack/router-generator`, `effect` |
212
+ | `pagegraph/vite` | build (`vite.config.ts`) | `seoRouteConfig` coverage gate | `vite`, `@tanstack/router-generator` |
213
+ | `pagegraph/config` | build (`pagegraph.config.ts`, CLI) | `defineSeoConfig`, `viteGraphLoader`, `loadPageHeads` | `vite` |
214
+ | `pagegraph/claims` | build / CLI (Node or Bun) | `claimsInput`, `claimsFamily`, document resolution, committed claims checks | `effect` |
215
+ | `pagegraph/audit` | build / CLI (Node or Bun) | Audit services, scanner protocol, rules, and report schemas | `effect` |
216
+ | `pagegraph/oxlint` | build (oxlint) | `pagegraph` plugin: `no-bare-text`, `t-children` | — |
217
+ | `pagegraph` bin | CLI (Bun) | CLI over the same graph | bundled |
218
+
219
+ Build entries resolve to a stub under the `workerd`, `worker`, and `browser` export conditions.
220
+ A Worker or browser bundle that reaches one fails at import with an error naming the entry,
221
+ instead of crashing on a native binding when a request first runs the code. The runtime entries
222
+ have **zero runtime dependencies** beyond their listed peers; a test pins each entry's import
223
+ graph.
39
224
 
40
225
  The CLI **bundles PageGraph's Effect and TypeSafe provider runtime**, so it does not depend on the
41
226
  app's Effect version; it runs on [Bun](https://bun.sh) (`bunx pagegraph`). Agentic workflows load the
@@ -46,8 +231,9 @@ Effect and Drizzle versions. Executor plugins must use OpenCode's Effect version
46
231
  Bun and network access to npm are required for the first install. The pinned OpenCode client's
47
232
  HTTP error handling retains the method, path, status, and response body for diagnostics;
48
233
  undeclared error bodies are capped at 16 KiB.
49
- Discovery-only research gets one continuation to complete provider evidence; if it still cannot,
50
- `research-failure.json` preserves the transcript and tool results in the requested output directory. `vite` stays a peer — graph commands
234
+ Discovery-only research gets one continuation to complete provider evidence. Every workflow failure
235
+ after run creation writes `failure.json` in the run directory, with the stage, session ID when available,
236
+ transcript, and cause chain. `vite` stays a peer — graph commands
51
237
  load your app through Vite at runtime — and `lighthouse` is only needed by `pagegraph audit`.
52
238
  Importing `pagegraph/audit` programmatically needs `effect@^4.0.0`. The Effect and TypeSafe
53
239
  peers remain optional for other library entry points and the bundled CLI.
@@ -63,7 +249,7 @@ pagegraph skills get core
63
249
 
64
250
  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.
65
251
 
66
- ## Quick start
252
+ ## Quick start (TanStack Start)
67
253
 
68
254
  **1. Bind your site identity once.** Route files never see an origin or a brand name.
69
255
 
@@ -71,7 +257,7 @@ The skill is maintained at [skill-data/core/SKILL.md](skill-data/core/SKILL.md)
71
257
  // lib/seo.ts
72
258
  import { createSeo } from "pagegraph/react";
73
259
 
74
- export const { seoHead } = createSeo({
260
+ const site = createSeo({
75
261
  origin: "https://example.com",
76
262
  site: {
77
263
  name: "Example",
@@ -85,19 +271,17 @@ export const { seoHead } = createSeo({
85
271
  description: "What the company does.",
86
272
  sameAs: ["https://github.com/example"],
87
273
  contactPoint: { contactType: "support", email: "hi@example.com" },
88
- address: {
89
- streetAddress: "123 Example Street",
90
- addressLocality: "Example City",
91
- addressRegion: "CA",
92
- postalCode: "94105",
93
- addressCountry: "US",
94
- },
95
274
  },
96
275
  website: { searchPath: "/docs?q={search_term_string}" },
97
276
  });
277
+
278
+ export const seo = site; // seo.head for static routes
279
+ export const { seoHead } = site;
98
280
  ```
99
281
 
100
- **2. Declare on the route.** Policy in `staticData.seo`, per-page content in `head()`.
282
+ **2. Declare each page once.** `staticData.seo` holds the crawl policy and the head
283
+ together; `seo.head` renders it. The graph and the checks read the same
284
+ `staticData.seo.head` — the page and the graph cannot disagree.
101
285
 
102
286
  ```tsx
103
287
  export const Route = createFileRoute("/pricing")({
@@ -109,124 +293,111 @@ export const Route = createFileRoute("/pricing")({
109
293
  related: ["/features", "/docs"],
110
294
  link: { title: "Pricing", description: "Simple volume pricing." },
111
295
  modifiedAt: "2026-09-29", // sitemap <lastmod> and the freshness report
296
+ head: {
297
+ title: "Pricing — Example",
298
+ description: "Simple volume pricing.",
299
+ faqs: pricingFaqs,
300
+ },
112
301
  },
113
302
  },
114
- head: (ctx) =>
115
- seoHead(ctx, {
116
- title: "Pricing — Example",
117
- description: "Simple volume pricing.",
118
- }),
303
+ head: seo.head,
304
+ component: PricingPage,
119
305
  });
120
306
  ```
121
307
 
122
- `seoHead` returns the `meta` + canonical `links` TanStack renders into `<head>` —
308
+ `seo.head` returns the `meta` + canonical `links` TanStack renders into `<head>` —
123
309
  title, description, og/twitter cards, robots, and the JSON-LD each page warrants
124
- (BreadcrumbList always; Article, FAQPage, Service, ItemList when the instance
125
- declares them).
310
+ (BreadcrumbList always; Article, FAQPage, Service, ItemList when the head declares
311
+ them). A route that renders from `loaderData` calls `seoHead(ctx, instance)` in its
312
+ own `head()` and declares a `titleTemplate` (`"%s | Example Blog"`): `seoHead` applies it,
313
+ and the graph applies it to the route's collection pages.
126
314
 
127
- ### Extensible JSON-LD
128
-
129
- The built-in generators are conveniences, not a closed schema registry. Define any
130
- `schema-dts` entity, link it to the plugin's stable site identities, and compose one
131
- site graph:
315
+ **3. Register the plugin.** It builds the graph once per build (and on change in dev)
316
+ inside your app's Vite pipeline — path aliases, `define`s, and content plugins such as
317
+ Fumadocs apply, with no stubs — and ships it to the server runtime as data.
132
318
 
133
319
  ```ts
134
- import {
135
- createSeo,
136
- defineJsonLd,
137
- extendJsonLd,
138
- jsonLdRef,
139
- } from "pagegraph/react";
320
+ // vite.config.ts
321
+ import { pagegraph } from "pagegraph/tanstack-start";
140
322
 
141
- export const seo = createSeo({
142
- // site, organization, and website as above
143
- jsonLd: {
144
- site: (ids) => [
145
- defineJsonLd({
146
- "@type": "SoftwareApplication",
147
- "@id": "https://example.com/#product",
148
- name: "Example",
149
- applicationCategory: "DeveloperApplication",
150
- operatingSystem: "Web",
151
- provider: jsonLdRef(ids.organization),
152
- }),
153
- ],
154
- transform: (entry) => {
155
- if (entry.kind === "organization") {
156
- return extendJsonLd(entry.document, {
157
- slogan: "Ship with confidence.",
158
- });
159
- }
160
- return entry.document;
161
- },
323
+ pagegraph({
324
+ origin: "https://example.com",
325
+ indexable: process.env.DEPLOY_ENV === "production", // previews disallow everything
326
+ robots: { contentSignal: "search=yes, ai-input=yes, ai-train=yes" },
327
+ collections: "src/lib/seo/collections.ts",
328
+ routeConfig: {
329
+ outputPath: fileURLToPath(new URL("./src/lib/route-config.ts", import.meta.url)),
330
+ publicGroups: ["(marketing)", "(docs)"],
331
+ enforceCoverageIn: ["(marketing)", "(docs)"],
332
+ alwaysDisallow: ["/dashboard", "/api"],
162
333
  },
163
334
  });
164
-
165
- const siteGraph = seo.generateSiteGraphSchema();
166
335
  ```
167
336
 
168
- `generateSiteGraphSchema()` includes Organization, WebSite, and configured site
169
- entities in one `@graph`. Organization and WebSite use stable `#organization` and
170
- `#website` IDs; articles and services provided by the site reference the same
171
- Organization. Existing individual generators remain available and are not transformed.
172
-
173
- Add page-specific entities through `seoHead`:
174
-
175
337
  ```ts
176
- head: (ctx) =>
177
- seo.seoHead(ctx, {
178
- title: "Example for developers",
179
- description: "A focused description of the product.",
180
- jsonLd: [
181
- defineJsonLd({
182
- "@type": "SoftwareApplication",
183
- name: "Example",
184
- url: "https://example.com/product",
185
- }),
186
- ],
187
- });
338
+ // src/lib/seo/collections.ts — evaluated in the app's Vite pipeline
339
+ import { contentCollection } from "pagegraph";
340
+ import { blogSource } from "../blog-source"; // e.g. a Fumadocs loader()
341
+
342
+ export const collections = () => [
343
+ contentCollection({
344
+ route: "/blog/$slug",
345
+ source: "blog",
346
+ pages: blogSource.getPages(),
347
+ entry: (page) => ({
348
+ title: page.data.title,
349
+ description: page.data.description,
350
+ publishedAt: page.data.date,
351
+ related: page.data.related,
352
+ }),
353
+ }),
354
+ ];
188
355
  ```
189
356
 
190
- The transform sees discriminated generated and custom entries plus `origin`,
191
- `canonical`, and `entityIds`. Return the document to keep it, no value (or `false`)
192
- to suppress it, or an array to expand it. Output preserves caller order and never
193
- silently deduplicates entities.
357
+ The plugin evaluates every route file that declares `staticData` — never the generated
358
+ route tree — in a Node environment named `pagegraph`. A route that imports a module
359
+ only the server runtime provides (`cloudflare:workers`) fails with its file name; add an
360
+ app-only route to `exclude` (route-file globs).
194
361
 
195
- **3. Serve the projections.** Build the graph from your route tree, render strings.
362
+ **4. Serve robots.txt and sitemap.xml.** One line each; the output depends on the
363
+ deployment's origin and indexability, so they stay routes.
196
364
 
197
365
  ```ts
198
- // lib/seo/graph.ts — also the module the CLI loads
199
- import { buildSeoGraph } from "pagegraph";
366
+ // src/routes/robots[.]txt.ts — sitemap[.]xml.ts is symmetric with sitemapXml
367
+ import { robotsTxt } from "pagegraph/tanstack-start/server";
200
368
 
201
- export const loadSeoGraph = () =>
202
- buildSeoGraph({ routeTree, collections: [blogCollection] });
369
+ export const Route = createFileRoute("/robots.txt")({ server: { handlers: { GET: robotsTxt } } });
203
370
  ```
204
371
 
372
+ **5. Gate CI.** The CLI reads the same graph through `pagegraph.config.ts`:
373
+
205
374
  ```ts
206
- // routes/sitemap[.]xml.ts — robots[.]txt.ts is symmetric
207
- import { renderRobots, renderSitemap } from "pagegraph";
208
-
209
- renderSitemap(loadSeoGraph(), { origin, indexable: true });
210
- renderRobots(loadSeoGraph(), {
211
- origin,
212
- indexable: true,
213
- disallow: routeConfig.robotsExclusions,
214
- contentSignal: "search=yes, ai-input=yes, ai-train=yes",
375
+ // pagegraph.config.ts
376
+ import { defineSeoConfig } from "pagegraph/config";
377
+ import { tanstackStartGraph } from "pagegraph/tanstack-start";
378
+
379
+ export default defineSeoConfig({
380
+ loadGraph: tanstackStartGraph({ root: import.meta.dirname }),
215
381
  });
216
382
  ```
217
383
 
384
+ ```bash
385
+ pagegraph check
386
+ ```
387
+
388
+ ### Without TanStack Start
389
+
390
+ `buildSeoGraph({ routeTree, collections })` builds the same graph from any TanStack
391
+ Router tree; render it with `renderSitemap(graph, { origin, indexable })` and
392
+ `renderRobots(graph, { origin, indexable, disallow, contentSignal })`, and point
393
+ `pagegraph.config.ts` at it with `viteGraphLoader` (see [CLI](#cli)).
394
+
218
395
  `renderRobots` does not invent a Content-Signal. Pass `contentSignal` for
219
396
  the value (the plugin prefixes `Content-Signal: `), `directives` for extra
220
397
  group lines, and `transform` if you need to wrap or replace the whole file.
221
398
  Preview hosts (`indexable: false`) drop `contentSignal` and `directives`;
222
399
  `transform` still runs.
223
400
 
224
- **4. Gate CI.** The same graph, the same rules, exit 1 on structural violations.
225
-
226
- ```bash
227
- pagegraph check
228
- ```
229
-
230
401
  ## Agentic workflows
231
402
 
232
403
  PageGraph can combine the deterministic route graph with a project-owned embedded OpenCode agent,
@@ -241,7 +412,7 @@ The generated `.pagegraph/opencode` directory contains one `seo` agent and custo
241
412
  the plugin own model and Executor authentication. PageGraph does not copy credentials or assume
242
413
  fixed provider-tool addresses.
243
414
 
244
- Enable workflows in `seo.config.ts`:
415
+ Enable workflows in `pagegraph.config.ts`:
245
416
 
246
417
  ```ts
247
418
  export default defineSeoConfig({
@@ -294,6 +465,11 @@ After research is validated, the run directory contains `research.json`: an inco
294
465
  with the collected evidence, decision inputs, and OpenCode transcript. A downstream failure reports
295
466
  its path so the evidence remains available for diagnosis. Only a completed workflow writes
296
467
  `run.json` and `summary.md`; a research checkpoint is not a final recommendation.
468
+ Model JSON drops null properties and array elements before validation. Schema validation collects all
469
+ issues for one read-only repair turn. Every error after run creation writes `failure.json` in that
470
+ run directory with the workflow, stage, available OpenCode session ID, transcript, and full cause
471
+ chain. The error reports the artifact path and a next step; a failed artifact write preserves the
472
+ original error with a note.
297
473
 
298
474
  | Command | Result |
299
475
  | --- | --- |
@@ -382,9 +558,10 @@ route's date is never inherited by its instances. Undated pages emit no
382
558
 
383
559
  ## Coverage gate (Vite)
384
560
 
385
- `seoRouteConfig` parses the route tree with `@tanstack/router-generator` (the same
386
- parser as the router) and fails `vite build` when a page in an enforced group has
387
- neither `staticData` nor `head`:
561
+ `pagegraph({ routeConfig })` — or `seoRouteConfig` from `pagegraph/vite` in a Router-only
562
+ app — parses the route tree with `@tanstack/router-generator` (the same parser as the
563
+ router) and fails `vite build` when a page in an enforced group has neither `staticData`
564
+ nor `head`:
388
565
 
389
566
  ```ts
390
567
  // vite.config.ts
@@ -399,53 +576,128 @@ seoRouteConfig({
399
576
  ```
400
577
 
401
578
  It also derives a small config module from the route tree: `robotsExclusions`
402
- (feed to `renderRobots`) and `reservedSegments` (top-level segments an app must
403
- not hand out as tenant/org slugs).
579
+ (the plugin adds them to robots.txt; a Router-only app feeds them to `renderRobots`) and
580
+ `reservedSegments` (top-level segments an app must not hand out as tenant/org slugs).
581
+
582
+ ## Claims gate
583
+
584
+ Declare probability rules against `claimsInput` from the build-only `pagegraph/claims` entry.
585
+ Each rule asks whether the section violates your policy:
586
+
587
+ ```ts
588
+ import { Decision } from "effect/ai";
589
+ import { claimsInput } from "pagegraph/claims";
590
+ import { pagegraph } from "pagegraph/tanstack-start";
591
+
592
+ const rules = Decision.make({
593
+ input: claimsInput,
594
+ decisions: {
595
+ unsupportedPromise: Decision.probability({
596
+ instructions: "Does this section promise a capability unsupported by its facts or evidence?",
597
+ }),
598
+ },
599
+ });
600
+
601
+ const graph = pagegraph({
602
+ origin: "https://example.com",
603
+ markdown: { origin: "https://example.com" },
604
+ facts: "src/facts.ts",
605
+ claims: { rules, model: "typesafe/jev", cutoff: 0.8 },
606
+ });
607
+ ```
608
+
609
+ Pass `graph.prerenderPages` to Start as shown in the rendered Markdown setup. The build persists
610
+ captured documents and graph heads, then replays committed answers without a model or credentials.
611
+ Missing answers and violations fail the build and name the page, section, and rule.
612
+
613
+ Run `pagegraph claims check` after changing prose, facts, context, or rules, then review and commit
614
+ `.pagegraph/decisions/claims/`. The cache key includes the rule definition fingerprint, model, and
615
+ input hash. Cached answers are reused; `--refresh` asks again and replaces them. The command reports
616
+ cached and asked counts and exits nonzero on violations. Set `TYPESAFE_API_KEY` when answers need
617
+ asking. `pagegraph check` also replays configured claims without model calls.
618
+
619
+ ```sh
620
+ pagegraph claims check
621
+ pagegraph claims check --refresh
622
+ pagegraph claims check --dev http://localhost:3000
623
+ ```
624
+
625
+ `claims.context` supplies policy text to every input. Inputs include the section's authored
626
+ Markdown, messages, all code-owned facts, and other sections marked as evidence. Page metadata is
627
+ judged as a `head` section, including graph pages outside rendered capture. `excludeHeads` accepts
628
+ path globs to omit metadata while keeping captured sections in the gate. The default cutoff is
629
+ `0.8` only when omitted; any probability at or above the configured cutoff is a violation, and
630
+ values below it pass. Changing the cutoff reuses answers and recalculates verdicts.
631
+
632
+ ## Markdown CLI
633
+
634
+ Inspect captured documents, search page metadata and sections, and maintain a reviewable lock:
635
+
636
+ ```sh
637
+ pagegraph markdown show /pricing
638
+ pagegraph markdown find email
639
+ pagegraph markdown lock
640
+ pagegraph markdown lock --check
641
+ ```
642
+
643
+ `show` prints the page's Markdown twin. `find` reports matching pages and sections.
644
+ `lock` writes `.pagegraph/markdown.lock.json` from document and message hashes, including
645
+ head-only pages; `--check` exits nonzero if the lock is missing or differs. These commands use
646
+ `.pagegraph/documents/` and `.pagegraph/heads.json` from the build. Use `--dev <origin>` to inspect
647
+ the development bundle instead. `--json` emits structured output using the CLI's usual conventions.
648
+ Markdown and claims settings come from `pagegraph()` in Vite, including the canonical Markdown
649
+ origin and facts module. Built-mode commands read Vite with `command: "build"` and
650
+ `mode: "production"`; `--dev` reads `command: "serve"` and `mode: "development"`.
651
+ Put CLI policies and the graph loader in `pagegraph.config.ts`.
404
652
 
405
653
  ## CLI
406
654
 
407
- The graph commands acquire your graph through `seo.config.ts` at the app root —
408
- the `viteGraphLoader` evaluates your graph module inside a headless Vite server,
409
- so path aliases, content plugins, and virtual modules all resolve:
655
+ The graph commands acquire your graph through `pagegraph.config.ts` at the app root.
656
+ Rename an existing `seo.config.ts`, `.js`, or `.mjs` to its `pagegraph.config.*` equivalent.
657
+ The file holds CLI policies and `loadGraph`; site identity and robots policy come from the
658
+ loader result, and Markdown and claims settings come from the Vite plugin. A
659
+ TanStack Start app uses `tanstackStartGraph` (above), which also supplies the robots
660
+ policy and site identity from `pagegraph()`, using build settings in production mode by default.
661
+ Pass `command: "serve", mode: "development"` to inspect a development configuration instead.
662
+ Otherwise `viteGraphLoader` evaluates your graph
663
+ module inside a headless Vite server, so path aliases, content plugins, and virtual modules all resolve:
410
664
 
411
665
  ```ts
412
- // seo.config.ts
666
+ // pagegraph.config.ts
413
667
  import { defineSeoConfig, viteGraphLoader } from "pagegraph/config";
668
+ import { routeConfig } from "./src/lib/route-config";
414
669
 
415
670
  export default defineSeoConfig({
416
- origin: "https://example.com",
417
- disallow: routeConfig.robotsExclusions,
418
- contentSignal: "search=yes, ai-input=yes, ai-train=yes",
419
671
  // Fail `check` unless each named money page has enough contextual links.
420
672
  coverage: [{ path: "/pricing", minInbound: 2 }, { path: "/features/*", minInbound: 1 }],
421
673
  loadGraph: viteGraphLoader({
422
674
  root: import.meta.dirname,
423
675
  entry: "/lib/seo/graph.ts",
424
676
  exportName: "loadSeoGraph",
677
+ site: {
678
+ origin: "https://example.com",
679
+ indexable: true,
680
+ robots: { disallow: routeConfig.robotsExclusions, contentSignal: "search=yes, ai-input=yes, ai-train=yes" },
681
+ },
425
682
  }),
426
683
  });
427
684
  ```
428
685
 
429
- `viteGraphLoader<Input>(options)` returns a loader whose JSON input is passed to
430
- its entry export. Existing zero-argument loaders remain valid. The host resolves
431
- framework-specific route `head()` values into node instances; pagegraph selects
432
- and validates the resulting plain metadata:
686
+ Build-time consumers of page titles and descriptions read the same graph:
433
687
 
434
688
  ```ts
435
- import { loadPageHeads, viteGraphLoader } from "pagegraph/config";
689
+ import { loadPageHeads } from "pagegraph/config"; // build-only
690
+ import { tanstackStartGraph } from "pagegraph/tanstack-start";
436
691
 
437
- const loader = viteGraphLoader<{ exclude: string[] }>({
438
- root: import.meta.dirname,
439
- entry: "/lib/seo/graph.ts",
440
- exportName: "loadHeadGraph",
692
+ const heads = await loadPageHeads(tanstackStartGraph({ root: import.meta.dirname }), {
693
+ exclude: ["/docs", "/docs/**"],
441
694
  });
442
- const exclude = ["/docs", "/docs/**", "/captured-page"];
443
- const heads = await loadPageHeads(() => loader({ exclude }), { exclude });
444
695
  ```
445
696
 
446
- `selectPageHeadNodes(graph, options)` selects nodes before host metadata resolution.
447
- `pageHeads(graph, options)` returns `{ path, title, description }[]`, requiring
448
- nonblank titles and descriptions and naming the page on failure. `loadPageHeads`
697
+ `selectPageHeadNodes(graph, options)` selects the page nodes `pageHeads` reads.
698
+ `pageHeads(graph, options)` returns `{ path, title, description }[]` from each node's
699
+ `head` (a declared `staticData.seo.head`, or a collection instance through its route's title
700
+ template), requiring nonblank titles and descriptions and naming the page on failure. `loadPageHeads`
449
701
  always disposes the acquired graph, including when validation fails. Page nodes
450
702
  carry a sitemap declaration or an instance; layouts are excluded. By default,
451
703
  selection excludes noindex pages, redirects and parameter templates, while retaining
@@ -461,7 +713,7 @@ pagegraph graph # the graph as a tree · --format mermaid | json
461
713
  pagegraph inspect /pricing # one node: policy, sitemap status, in/out edges
462
714
  pagegraph inspect <url> --live # fetch a deployed page, validate its rendered <head>
463
715
  pagegraph links verify <url> # crawl served HTML: depth, orphans, declared-vs-rendered
464
- pagegraph links verify <url> --assert-coverage # also assert seo.config.ts coverage on served anchors
716
+ pagegraph links verify <url> --assert-coverage # also assert pagegraph.config.ts coverage on served anchors
465
717
  pagegraph links verify <url> --emit-rendered <file> # save the rendered edge set
466
718
  pagegraph links candidates # propose contextual links from the declared graph
467
719
  pagegraph research keywords --query "email api" # combine graph, Executor, and Jev evidence
@@ -477,7 +729,7 @@ Stdout is data, stderr is status — `pagegraph check --json | jq` just works.
477
729
  `pagegraph links verify` crawls a site's served HTML — through the same DNS-pinned,
478
730
  private-IP-blocked HTTP path as `pagegraph audit` — and reports what a crawler
479
731
  actually receives: the real homepage depth, the pages with no incoming internal
480
- edge (rendered orphans), and, when the app has a `seo.config.ts`, the
732
+ edge (rendered orphans), and, when the app has a `pagegraph.config.ts`, the
481
733
  declared-vs-rendered link diff. It is bounded with `--limit` and needs no
482
734
  framework or config. Discovery reads same-origin `robots.txt` and bounded
483
735
  sitemaps before following page anchors, so a sitemap-only page can appear as an
@@ -532,7 +784,7 @@ It is also a drop-in input for `pagegraph links candidates --rendered`.
532
784
 
533
785
  ### Assert coverage on the rendered graph
534
786
 
535
- `pagegraph check` evaluates the `seo.config.ts` `coverage` rules against the
787
+ `pagegraph check` evaluates the `pagegraph.config.ts` `coverage` rules against the
536
788
  **declared** graph; `pagegraph links verify --assert-coverage` evaluates the same
537
789
  rules against the anchors a crawler **actually receives**. Only same-origin
538
790
  body-region anchors count — nav, header, and footer links are chrome — which is
@@ -549,7 +801,7 @@ non-zero) rather than reporting a false pass when the crawl outran `--limit`, a
549
801
  page failed to fetch (its anchors are missing), a body hit `--max-body-bytes`, the
550
802
  robots or sitemap discovery is incomplete, the
551
803
  config declares no `coverage`, or the artifact's origin differs from
552
- `seo.config.ts`. Under `--assert-coverage` the report gains a `coverage` block
804
+ `pagegraph.config.ts`. Under `--assert-coverage` the report gains a `coverage` block
553
805
  (`{ rules, ok, violations }`); the default `--json` summary is unchanged.
554
806
 
555
807
  ### Propose contextual links
@@ -615,7 +867,7 @@ both `links candidates --site` and `improve links --suggestions`.
615
867
 
616
868
  ### Contextual-link coverage
617
869
 
618
- Declare a contextual-link coverage policy in `seo.config.ts` (a `coverage` array
870
+ Declare a contextual-link coverage policy in `pagegraph.config.ts` (a `coverage` array
619
871
  of `{ path, minInbound }`), or pass repeatable `--require-inbound "<path-glob>=<n>"`
620
872
  flags to override it for one run. `pagegraph check` then fails (exit 1) unless
621
873
  every sitemap-eligible page matching the glob has at least `minInbound` incoming
@@ -709,7 +961,7 @@ undated rather than stale; give content its `modifiedAt` (or a route its
709
961
 
710
962
  ### Audit any website
711
963
 
712
- `pagegraph audit` is framework-independent and does not need `seo.config.ts`. It
964
+ `pagegraph audit` is framework-independent and does not need `pagegraph.config.ts`. It
713
965
  validates target URLs before making requests, follows redirects through the same
714
966
  validation boundary, inspects the rendered document and discovery files, and
715
967
  can collect Lighthouse evidence through a validating proxy.
@@ -772,8 +1024,11 @@ import {
772
1024
  hasStructuralViolations,
773
1025
  inspectHtml,
774
1026
  } from "pagegraph";
1027
+ import { evaluateAppGraph } from "pagegraph/tanstack-start";
775
1028
 
776
- expect(hasStructuralViolations(checkGraph(loadSeoGraph()))).toBe(false);
1029
+ // the graph the plugin ships, evaluated through your Vite config
1030
+ const { graph } = await evaluateAppGraph({ root: appRoot, mode: "production" });
1031
+ expect(hasStructuralViolations(checkGraph(graph))).toBe(false);
777
1032
 
778
1033
  // render a page however you like, then assert the head it actually ships
779
1034
  const report = inspectHtml(url, 200, html);