pagegraph 0.11.0 → 0.12.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 (65) hide show
  1. package/README.md +140 -137
  2. package/dist/audit.d.ts +1 -1
  3. package/dist/audit.js +1 -1
  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/config.d.ts +1 -0
  8. package/dist/build-only/config.js +6 -0
  9. package/dist/build-only/config.js.map +1 -0
  10. package/dist/build-only/tanstack-start.d.ts +1 -0
  11. package/dist/build-only/tanstack-start.js +6 -0
  12. package/dist/build-only/tanstack-start.js.map +1 -0
  13. package/dist/build-only/vite.d.ts +1 -0
  14. package/dist/build-only/vite.js +6 -0
  15. package/dist/build-only/vite.js.map +1 -0
  16. package/dist/{checks-nk8ZRRIA.js → checks-uZk5Em9A.js} +3 -141
  17. package/dist/checks-uZk5Em9A.js.map +1 -0
  18. package/dist/cli.js +393 -287
  19. package/dist/cli.js.map +1 -1
  20. package/dist/config.d.ts +7 -66
  21. package/dist/config.js +3 -122
  22. package/dist/config.js.map +1 -1
  23. package/dist/{content-DVL2iyGU.d.ts → content-B1cPtL8b.d.ts} +4 -128
  24. package/dist/{content-CB_kHmJ3.js → content-kcEnQdfi.js} +3 -2
  25. package/dist/{content-CB_kHmJ3.js.map → content-kcEnQdfi.js.map} +1 -1
  26. package/dist/declare-D5o7hwmv.js +9 -0
  27. package/dist/declare-D5o7hwmv.js.map +1 -0
  28. package/dist/declare-DVgdEdOL.d.ts +12127 -0
  29. package/dist/graph-Bcsourlv.js +162 -0
  30. package/dist/graph-Bcsourlv.js.map +1 -0
  31. package/dist/graph-BxCY90vq.d.ts +104 -0
  32. package/dist/graph-CBIY71w6.d.ts +57 -0
  33. package/dist/index.d.ts +37 -73
  34. package/dist/index.js +23 -134
  35. package/dist/index.js.map +1 -1
  36. package/dist/message-DT_CjUdt.js +12 -0
  37. package/dist/message-DT_CjUdt.js.map +1 -0
  38. package/dist/page-heads-6y-CARRQ.js +25 -0
  39. package/dist/page-heads-6y-CARRQ.js.map +1 -0
  40. package/dist/page-heads-DNkjMUEN.d.ts +21 -0
  41. package/dist/projections-D0oOFKiY.js +142 -0
  42. package/dist/projections-D0oOFKiY.js.map +1 -0
  43. package/dist/projections-DeEFe7XT.d.ts +78 -0
  44. package/dist/react.d.ts +26 -12051
  45. package/dist/react.js +12 -3
  46. package/dist/react.js.map +1 -1
  47. package/dist/route-config-35ZEzkNC.js +308 -0
  48. package/dist/route-config-35ZEzkNC.js.map +1 -0
  49. package/dist/route-config-wneg6ktn.d.ts +29 -0
  50. package/dist/tanstack-start/index.d.ts +40 -0
  51. package/dist/tanstack-start/index.js +368 -0
  52. package/dist/tanstack-start/index.js.map +1 -0
  53. package/dist/tanstack-start/server.d.ts +14 -0
  54. package/dist/tanstack-start/server.js +53 -0
  55. package/dist/tanstack-start/server.js.map +1 -0
  56. package/dist/vite-graph-loader-01p1WxEq.js +104 -0
  57. package/dist/vite-graph-loader-01p1WxEq.js.map +1 -0
  58. package/dist/vite-graph-loader-Btgjz5RH.d.ts +56 -0
  59. package/dist/vite.d.ts +2 -28
  60. package/dist/vite.js +1 -307
  61. package/dist/wire-B4RQMc4e.js +26 -0
  62. package/dist/wire-B4RQMc4e.js.map +1 -0
  63. package/package.json +27 -5
  64. package/dist/checks-nk8ZRRIA.js.map +0 -1
  65. 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,27 @@ 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
+ ## What runs where
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
+ Every entry belongs to one place. Runtime entries are safe in a Worker, an SSR server, or a
32
+ browser; build entries load your app through Vite, parse routes natively, or drive Node I/O.
33
+
34
+ | Entry | Runs in | Exports | Peers |
35
+ | ------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- |
36
+ | `pagegraph` | runtime (Worker, SSR, browser) | `buildSeoGraph`, `contentCollection`, `renderSitemap`, `renderRobots`, `pageHeads`, `graphToJson`/`graphFromJson`, checks, `inspectHtml` | — |
37
+ | `pagegraph/react` | runtime | `createSeo` → `seo.head`, `seoHead`, `Breadcrumbs`, JSON-LD generators | `react`, `@tanstack/react-router` |
38
+ | `pagegraph/tanstack-start/server` | runtime (Start server routes) | `robotsTxt`, `sitemapXml`, `seoGraph`, `seoSite` | the `pagegraph()` plugin |
39
+ | `pagegraph/tanstack-start` | build (`vite.config.ts`, `seo.config.ts`) | `pagegraph()` Vite plugin, `tanstackStartGraph`, `evaluateAppGraph` | `vite`, `@tanstack/router-generator` |
40
+ | `pagegraph/vite` | build (`vite.config.ts`) | `seoRouteConfig` coverage gate | `vite`, `@tanstack/router-generator` |
41
+ | `pagegraph/config` | build (`seo.config.ts`, CLI) | `defineSeoConfig`, `viteGraphLoader`, `loadPageHeads` | `vite` |
42
+ | `pagegraph/audit` | build / CLI (Node or Bun) | Audit services, scanner protocol, rules, and report schemas | `effect` |
43
+ | `pagegraph` bin | CLI (Bun) | CLI over the same graph | bundled |
44
+
45
+ Build entries resolve to a stub under the `workerd`, `worker`, and `browser` export conditions.
46
+ A Worker or browser bundle that reaches one fails at import with an error naming the entry,
47
+ instead of crashing on a native binding when a request first runs the code. The runtime entries
48
+ have **zero runtime dependencies** beyond their listed peers; a test pins each entry's import
49
+ graph.
39
50
 
40
51
  The CLI **bundles PageGraph's Effect and TypeSafe provider runtime**, so it does not depend on the
41
52
  app's Effect version; it runs on [Bun](https://bun.sh) (`bunx pagegraph`). Agentic workflows load the
@@ -46,8 +57,9 @@ Effect and Drizzle versions. Executor plugins must use OpenCode's Effect version
46
57
  Bun and network access to npm are required for the first install. The pinned OpenCode client's
47
58
  HTTP error handling retains the method, path, status, and response body for diagnostics;
48
59
  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
60
+ Discovery-only research gets one continuation to complete provider evidence. Every workflow failure
61
+ after run creation writes `failure.json` in the run directory, with the stage, session ID when available,
62
+ transcript, and cause chain. `vite` stays a peer — graph commands
51
63
  load your app through Vite at runtime — and `lighthouse` is only needed by `pagegraph audit`.
52
64
  Importing `pagegraph/audit` programmatically needs `effect@^4.0.0`. The Effect and TypeSafe
53
65
  peers remain optional for other library entry points and the bundled CLI.
@@ -63,7 +75,7 @@ pagegraph skills get core
63
75
 
64
76
  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
77
 
66
- ## Quick start
78
+ ## Quick start (TanStack Start)
67
79
 
68
80
  **1. Bind your site identity once.** Route files never see an origin or a brand name.
69
81
 
@@ -71,7 +83,7 @@ The skill is maintained at [skill-data/core/SKILL.md](skill-data/core/SKILL.md)
71
83
  // lib/seo.ts
72
84
  import { createSeo } from "pagegraph/react";
73
85
 
74
- export const { seoHead } = createSeo({
86
+ const site = createSeo({
75
87
  origin: "https://example.com",
76
88
  site: {
77
89
  name: "Example",
@@ -85,19 +97,17 @@ export const { seoHead } = createSeo({
85
97
  description: "What the company does.",
86
98
  sameAs: ["https://github.com/example"],
87
99
  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
100
  },
96
101
  website: { searchPath: "/docs?q={search_term_string}" },
97
102
  });
103
+
104
+ export const seo = site; // seo.head for static routes
105
+ export const { seoHead } = site;
98
106
  ```
99
107
 
100
- **2. Declare on the route.** Policy in `staticData.seo`, per-page content in `head()`.
108
+ **2. Declare each page once.** `staticData.seo` holds the crawl policy and the head
109
+ together; `seo.head` renders it. The graph and the checks read the same
110
+ `staticData.seo.head` — the page and the graph cannot disagree.
101
111
 
102
112
  ```tsx
103
113
  export const Route = createFileRoute("/pricing")({
@@ -109,124 +119,112 @@ export const Route = createFileRoute("/pricing")({
109
119
  related: ["/features", "/docs"],
110
120
  link: { title: "Pricing", description: "Simple volume pricing." },
111
121
  modifiedAt: "2026-09-29", // sitemap <lastmod> and the freshness report
122
+ head: {
123
+ title: "Pricing — Example",
124
+ description: "Simple volume pricing.",
125
+ faqs: pricingFaqs,
126
+ },
112
127
  },
113
128
  },
114
- head: (ctx) =>
115
- seoHead(ctx, {
116
- title: "Pricing — Example",
117
- description: "Simple volume pricing.",
118
- }),
129
+ head: seo.head,
130
+ component: PricingPage,
119
131
  });
120
132
  ```
121
133
 
122
- `seoHead` returns the `meta` + canonical `links` TanStack renders into `<head>` —
134
+ `seo.head` returns the `meta` + canonical `links` TanStack renders into `<head>` —
123
135
  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).
126
-
127
- ### Extensible JSON-LD
136
+ (BreadcrumbList always; Article, FAQPage, Service, ItemList when the head declares
137
+ them). A route that renders from `loaderData` calls `seoHead(ctx, instance)` in its
138
+ own `head()` and declares a `titleTemplate` (`"%s | Example Blog"`): `seoHead` applies it,
139
+ and the graph applies it to the route's collection pages.
128
140
 
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:
141
+ **3. Register the plugin.** It builds the graph once per build (and on change in dev)
142
+ inside your app's Vite pipeline — path aliases, `define`s, and content plugins such as
143
+ Fumadocs apply, with no stubs — and ships it to the server runtime as data.
132
144
 
133
145
  ```ts
134
- import {
135
- createSeo,
136
- defineJsonLd,
137
- extendJsonLd,
138
- jsonLdRef,
139
- } from "pagegraph/react";
140
-
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
- },
146
+ // vite.config.ts
147
+ import { pagegraph } from "pagegraph/tanstack-start";
148
+
149
+ pagegraph({
150
+ origin: "https://example.com",
151
+ indexable: process.env.DEPLOY_ENV === "production", // previews disallow everything
152
+ robots: { contentSignal: "search=yes, ai-input=yes, ai-train=yes" },
153
+ collections: "src/lib/seo/collections.ts",
154
+ routeConfig: {
155
+ outputPath: fileURLToPath(new URL("./src/lib/route-config.ts", import.meta.url)),
156
+ publicGroups: ["(marketing)", "(docs)"],
157
+ enforceCoverageIn: ["(marketing)", "(docs)"],
158
+ alwaysDisallow: ["/dashboard", "/api"],
162
159
  },
163
160
  });
164
-
165
- const siteGraph = seo.generateSiteGraphSchema();
166
161
  ```
167
162
 
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
163
  ```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
- });
164
+ // src/lib/seo/collections.ts — evaluated in the app's Vite pipeline
165
+ import { contentCollection } from "pagegraph";
166
+ import { blogSource } from "../blog-source"; // e.g. a Fumadocs loader()
167
+
168
+ export const collections = () => [
169
+ contentCollection({
170
+ route: "/blog/$slug",
171
+ source: "blog",
172
+ pages: blogSource.getPages(),
173
+ entry: (page) => ({
174
+ title: page.data.title,
175
+ description: page.data.description,
176
+ publishedAt: page.data.date,
177
+ related: page.data.related,
178
+ }),
179
+ }),
180
+ ];
188
181
  ```
189
182
 
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.
183
+ The plugin evaluates every route file that declares `staticData` — never the generated
184
+ route tree — in a Node environment named `pagegraph`. A route that imports a module
185
+ only the server runtime provides (`cloudflare:workers`) fails with its file name; add an
186
+ app-only route to `exclude` (route-file globs).
194
187
 
195
- **3. Serve the projections.** Build the graph from your route tree, render strings.
188
+ **4. Serve robots.txt and sitemap.xml.** One line each; the output depends on the
189
+ deployment's origin and indexability, so they stay routes.
196
190
 
197
191
  ```ts
198
- // lib/seo/graph.ts — also the module the CLI loads
199
- import { buildSeoGraph } from "pagegraph";
192
+ // src/routes/robots[.]txt.ts — sitemap[.]xml.ts is symmetric with sitemapXml
193
+ import { robotsTxt } from "pagegraph/tanstack-start/server";
200
194
 
201
- export const loadSeoGraph = () =>
202
- buildSeoGraph({ routeTree, collections: [blogCollection] });
195
+ export const Route = createFileRoute("/robots.txt")({ server: { handlers: { GET: robotsTxt } } });
203
196
  ```
204
197
 
198
+ **5. Gate CI.** The CLI reads the same graph through `seo.config.ts`:
199
+
205
200
  ```ts
206
- // routes/sitemap[.]xml.ts — robots[.]txt.ts is symmetric
207
- import { renderRobots, renderSitemap } from "pagegraph";
201
+ // seo.config.ts
202
+ import { defineSeoConfig } from "pagegraph/config";
203
+ import { tanstackStartGraph } from "pagegraph/tanstack-start";
208
204
 
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",
205
+ export default defineSeoConfig({
206
+ origin: "https://example.com",
207
+ loadGraph: tanstackStartGraph({ root: import.meta.dirname }),
215
208
  });
216
209
  ```
217
210
 
211
+ ```bash
212
+ pagegraph check
213
+ ```
214
+
215
+ ### Without TanStack Start
216
+
217
+ `buildSeoGraph({ routeTree, collections })` builds the same graph from any TanStack
218
+ Router tree; render it with `renderSitemap(graph, { origin, indexable })` and
219
+ `renderRobots(graph, { origin, indexable, disallow, contentSignal })`, and point
220
+ `seo.config.ts` at it with `viteGraphLoader` (see [CLI](#cli)).
221
+
218
222
  `renderRobots` does not invent a Content-Signal. Pass `contentSignal` for
219
223
  the value (the plugin prefixes `Content-Signal: `), `directives` for extra
220
224
  group lines, and `transform` if you need to wrap or replace the whole file.
221
225
  Preview hosts (`indexable: false`) drop `contentSignal` and `directives`;
222
226
  `transform` still runs.
223
227
 
224
- **4. Gate CI.** The same graph, the same rules, exit 1 on structural violations.
225
-
226
- ```bash
227
- pagegraph check
228
- ```
229
-
230
228
  ## Agentic workflows
231
229
 
232
230
  PageGraph can combine the deterministic route graph with a project-owned embedded OpenCode agent,
@@ -294,6 +292,11 @@ After research is validated, the run directory contains `research.json`: an inco
294
292
  with the collected evidence, decision inputs, and OpenCode transcript. A downstream failure reports
295
293
  its path so the evidence remains available for diagnosis. Only a completed workflow writes
296
294
  `run.json` and `summary.md`; a research checkpoint is not a final recommendation.
295
+ Model JSON drops null properties and array elements before validation. Schema validation collects all
296
+ issues for one read-only repair turn. Every error after run creation writes `failure.json` in that
297
+ run directory with the workflow, stage, available OpenCode session ID, transcript, and full cause
298
+ chain. The error reports the artifact path and a next step; a failed artifact write preserves the
299
+ original error with a note.
297
300
 
298
301
  | Command | Result |
299
302
  | --- | --- |
@@ -382,9 +385,10 @@ route's date is never inherited by its instances. Undated pages emit no
382
385
 
383
386
  ## Coverage gate (Vite)
384
387
 
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`:
388
+ `pagegraph({ routeConfig })` — or `seoRouteConfig` from `pagegraph/vite` in a Router-only
389
+ app — parses the route tree with `@tanstack/router-generator` (the same parser as the
390
+ router) and fails `vite build` when a page in an enforced group has neither `staticData`
391
+ nor `head`:
388
392
 
389
393
  ```ts
390
394
  // vite.config.ts
@@ -399,14 +403,15 @@ seoRouteConfig({
399
403
  ```
400
404
 
401
405
  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).
406
+ (the plugin adds them to robots.txt; a Router-only app feeds them to `renderRobots`) and
407
+ `reservedSegments` (top-level segments an app must not hand out as tenant/org slugs).
404
408
 
405
409
  ## CLI
406
410
 
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:
411
+ The graph commands acquire your graph through `seo.config.ts` at the app root. A
412
+ TanStack Start app uses `tanstackStartGraph` (above), which also supplies the robots
413
+ policy. Otherwise `viteGraphLoader` evaluates your graph module inside a headless Vite
414
+ server, so path aliases, content plugins, and virtual modules all resolve:
410
415
 
411
416
  ```ts
412
417
  // seo.config.ts
@@ -426,26 +431,21 @@ export default defineSeoConfig({
426
431
  });
427
432
  ```
428
433
 
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:
434
+ Build-time consumers of page titles and descriptions read the same graph:
433
435
 
434
436
  ```ts
435
- import { loadPageHeads, viteGraphLoader } from "pagegraph/config";
437
+ import { loadPageHeads } from "pagegraph/config"; // build-only
438
+ import { tanstackStartGraph } from "pagegraph/tanstack-start";
436
439
 
437
- const loader = viteGraphLoader<{ exclude: string[] }>({
438
- root: import.meta.dirname,
439
- entry: "/lib/seo/graph.ts",
440
- exportName: "loadHeadGraph",
440
+ const heads = await loadPageHeads(tanstackStartGraph({ root: import.meta.dirname }), {
441
+ exclude: ["/docs", "/docs/**"],
441
442
  });
442
- const exclude = ["/docs", "/docs/**", "/captured-page"];
443
- const heads = await loadPageHeads(() => loader({ exclude }), { exclude });
444
443
  ```
445
444
 
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`
445
+ `selectPageHeadNodes(graph, options)` selects the page nodes `pageHeads` reads.
446
+ `pageHeads(graph, options)` returns `{ path, title, description }[]` from each node's
447
+ `head` (a declared `staticData.seo.head`, or a collection instance through its route's title
448
+ template), requiring nonblank titles and descriptions and naming the page on failure. `loadPageHeads`
449
449
  always disposes the acquired graph, including when validation fails. Page nodes
450
450
  carry a sitemap declaration or an instance; layouts are excluded. By default,
451
451
  selection excludes noindex pages, redirects and parameter templates, while retaining
@@ -772,8 +772,11 @@ import {
772
772
  hasStructuralViolations,
773
773
  inspectHtml,
774
774
  } from "pagegraph";
775
+ import { evaluateAppGraph } from "pagegraph/tanstack-start";
775
776
 
776
- expect(hasStructuralViolations(checkGraph(loadSeoGraph()))).toBe(false);
777
+ // the graph the plugin ships, evaluated through your Vite config
778
+ const { graph } = await evaluateAppGraph({ root: appRoot, mode: "production" });
779
+ expect(hasStructuralViolations(checkGraph(graph))).toBe(false);
777
780
 
778
781
  // render a page however you like, then assert the head it actually ships
779
782
  const report = inspectHtml(url, 200, html);
package/dist/audit.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import "./content-DVL2iyGU.js";
1
+ import "./content-B1cPtL8b.js";
2
2
  import * as Context from "effect/Context";
3
3
  import * as Effect from "effect/Effect";
4
4
  import * as Layer from "effect/Layer";
package/dist/audit.js CHANGED
@@ -1,4 +1,4 @@
1
- import { l as extractAnchors, r as extractPageContent, t as checkPageContent } from "./content-CB_kHmJ3.js";
1
+ import { l as extractAnchors, r as extractPageContent, t as checkPageContent } from "./content-kcEnQdfi.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";
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,6 @@
1
+ import { t as buildOnlyError } from "../message-DT_CjUdt.js";
2
+ //#region src/build-only/audit.ts
3
+ throw buildOnlyError("pagegraph/audit");
4
+ //#endregion
5
+
6
+ //# sourceMappingURL=audit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit.js","names":[],"sources":["../../src/build-only/audit.ts"],"sourcesContent":["import { buildOnlyError } from \"./message\";\n\nthrow buildOnlyError(\"pagegraph/audit\");\n"],"mappings":";;AAEA,MAAM,eAAe,iBAAiB"}
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,6 @@
1
+ import { t as buildOnlyError } from "../message-DT_CjUdt.js";
2
+ //#region src/build-only/config.ts
3
+ throw buildOnlyError("pagegraph/config");
4
+ //#endregion
5
+
6
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","names":[],"sources":["../../src/build-only/config.ts"],"sourcesContent":["import { buildOnlyError } from \"./message\";\n\nthrow buildOnlyError(\"pagegraph/config\");\n"],"mappings":";;AAEA,MAAM,eAAe,kBAAkB"}
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,6 @@
1
+ import { t as buildOnlyError } from "../message-DT_CjUdt.js";
2
+ //#region src/build-only/tanstack-start.ts
3
+ throw buildOnlyError("pagegraph/tanstack-start");
4
+ //#endregion
5
+
6
+ //# sourceMappingURL=tanstack-start.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tanstack-start.js","names":[],"sources":["../../src/build-only/tanstack-start.ts"],"sourcesContent":["import { buildOnlyError } from \"./message\";\n\nthrow buildOnlyError(\"pagegraph/tanstack-start\");\n"],"mappings":";;AAEA,MAAM,eAAe,0BAA0B"}
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,6 @@
1
+ import { t as buildOnlyError } from "../message-DT_CjUdt.js";
2
+ //#region src/build-only/vite.ts
3
+ throw buildOnlyError("pagegraph/vite");
4
+ //#endregion
5
+
6
+ //# sourceMappingURL=vite.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vite.js","names":[],"sources":["../../src/build-only/vite.ts"],"sourcesContent":["import { buildOnlyError } from \"./message\";\n\nthrow buildOnlyError(\"pagegraph/vite\");\n"],"mappings":";;AAEA,MAAM,eAAe,gBAAgB"}
@@ -1,142 +1,4 @@
1
- //#region src/core/projections.ts
2
- const escapeXml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3
- /**
4
- * A node belongs in the sitemap when it declares a positive sitemap policy, is not
5
- * a redirect, is not robots-noindexed, and is not a param template (a route whose
6
- * path still contains a `$` segment — those exist only so their instances inherit).
7
- */
8
- function isSitemapEligible(node) {
9
- if (node.policy.redirectTo !== void 0) return false;
10
- if (node.policy.robots?.includes("noindex")) return false;
11
- if (!node.policy.sitemap) return false;
12
- if (node.path.includes("$")) return false;
13
- return true;
14
- }
15
- /** Canonical absolute URL for a node under the given origin. */
16
- function urlForNode(origin, node) {
17
- return node.path === "/" ? origin : `${origin}${node.path}`;
18
- }
19
- /**
20
- * The date a node's content last changed, as declared: an instance's
21
- * `modifiedAt`, else its `publishedAt`; a route's own `modifiedAt`. A node with
22
- * no date (a docs page without frontmatter dates, an undated route) returns
23
- * undefined, and the sitemap omits its `<lastmod>`.
24
- */
25
- function lastModified(node) {
26
- if (node.instance) return node.instance.modifiedAt ?? node.instance.publishedAt;
27
- return node.policy.modifiedAt;
28
- }
29
- /** An ISO 8601 date, or a date-time that states its offset. */
30
- const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2}))?$/;
31
- /**
32
- * Parse a declared date to epoch milliseconds, or undefined when it is not an
33
- * ISO 8601 date (`2026-09-29`) or offset date-time (`2026-09-29T10:00:00Z`) on a
34
- * real calendar day. `Date.parse` alone would accept "Sep 29" in the host's
35
- * zone and roll "2026-02-30" into March; either would publish a wrong
36
- * `<lastmod>`. The `invalid-date` check reports the undefined case, so
37
- * projections that meet one fail loudly.
38
- */
39
- function parseDeclaredDate(value) {
40
- const match = ISO_DATE.exec(value);
41
- if (match === null) return void 0;
42
- const [year, month, day] = [
43
- Number(match[1]),
44
- Number(match[2]),
45
- Number(match[3])
46
- ];
47
- const calendar = new Date(Date.UTC(year, month - 1, day));
48
- if (calendar.getUTCMonth() !== month - 1 || calendar.getUTCDate() !== day) return void 0;
49
- const time = Date.parse(value);
50
- return Number.isNaN(time) ? void 0 : time;
51
- }
52
- function sitemapLastmod(node) {
53
- const date = lastModified(node);
54
- if (date === void 0) return void 0;
55
- const time = parseDeclaredDate(date);
56
- if (time === void 0) throw new Error(`"${node.path}" declares an invalid date "${date}"; run \`pagegraph check\`.`);
57
- return new Date(time).toISOString();
58
- }
59
- function renderUrlEntry(url, lastmod, node) {
60
- const { changeFrequency, priority } = node.policy.sitemap;
61
- const lines = [` <url>`, ` <loc>${escapeXml(url)}</loc>`];
62
- if (lastmod) lines.push(` <lastmod>${lastmod}</lastmod>`);
63
- lines.push(` <changefreq>${changeFrequency}</changefreq>`, ` <priority>${priority.toFixed(1)}</priority>`, ` </url>`);
64
- return lines.join("\n");
65
- }
66
- /**
67
- * Render sitemap.xml from the graph. Each entry's `<lastmod>` is its
68
- * {@link lastModified} date: frontmatter for content instances, the declared
69
- * `modifiedAt` for routes. Route entries are sorted by path, then instances follow in collection order.
70
- * `indexable` is intentionally unused — the sitemap body is host-independent;
71
- * robots.txt is what gates crawling.
72
- */
73
- function renderSitemap(graph, cfg) {
74
- const nodes = [...graph.nodes.values()];
75
- const staticNodes = nodes.filter((node) => node.source === "route" && isSitemapEligible(node)).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
76
- const instanceNodes = nodes.filter((node) => node.source !== "route" && isSitemapEligible(node));
77
- const seen = /* @__PURE__ */ new Set();
78
- const entries = [];
79
- for (const node of [...staticNodes, ...instanceNodes]) {
80
- const url = urlForNode(cfg.origin, node);
81
- const key = url.toLowerCase().replace(/\/$/, "");
82
- if (seen.has(key)) continue;
83
- seen.add(key);
84
- entries.push(renderUrlEntry(url, sitemapLastmod(node), node));
85
- }
86
- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.join("\n")}\n</urlset>\n`;
87
- }
88
- /** Format a Content-Signal robots.txt directive from the preference list. */
89
- function contentSignal(value) {
90
- return `Content-Signal: ${value}`;
91
- }
92
- const groupLines = (cfg) => {
93
- const lines = [];
94
- if (cfg.contentSignal !== void 0 && cfg.contentSignal !== "") lines.push(contentSignal(cfg.contentSignal));
95
- for (const directive of cfg.directives ?? []) if (directive !== "") lines.push(directive);
96
- return lines;
97
- };
98
- /**
99
- * Render robots.txt. A non-indexable host (previews) gets a disallow-all
100
- * with no Sitemap line; an indexable host disallows exactly the prefixes the caller
101
- * passes. Pages that declare `robots: noindex` are intentionally NOT added as
102
- * Disallow entries — a Disallow would stop crawlers reaching the page to read its
103
- * `noindex, follow` meta, so the graph's per-node robots policy never feeds this
104
- * list. `graph` is unused — kept for signature parity with the other projections,
105
- * which callers load the graph once for and pass to each.
106
- *
107
- * Origin-wide group directives (`contentSignal`, `directives`) are indexable-host
108
- * only. {@link RobotsConfig.transform} always runs last so a consumer can override
109
- * the whole file.
110
- */
111
- function renderRobots(_graph, cfg) {
112
- const rendered = cfg.indexable ? [
113
- "User-agent: *",
114
- ...groupLines(cfg),
115
- "Allow: /",
116
- ...cfg.disallow.map((path) => `Disallow: ${path}`),
117
- "",
118
- `Sitemap: ${cfg.origin}/sitemap.xml`,
119
- `Host: ${cfg.origin}`,
120
- ""
121
- ].join("\n") : [
122
- "User-agent: *",
123
- "Disallow: /",
124
- ""
125
- ].join("\n");
126
- return cfg.transform === void 0 ? rendered : cfg.transform(rendered);
127
- }
128
- /** Inspect a single node: its declaration, sitemap eligibility, and edges. */
129
- function inspectNode(graph, path) {
130
- const node = graph.nodes.get(path);
131
- if (!node) return void 0;
132
- return {
133
- node,
134
- inSitemap: isSitemapEligible(node),
135
- incoming: graph.edges.filter((edge) => edge.to === path),
136
- outgoing: graph.edges.filter((edge) => edge.from === path)
137
- };
138
- }
139
- //#endregion
1
+ import { a as parseDeclaredDate, i as lastModified, r as isSitemapEligible } from "./projections-D0oOFKiY.js";
140
2
  //#region src/core/freshness.ts
141
3
  const DAY_MS = 864e5;
142
4
  /**
@@ -485,6 +347,6 @@ function checkRenderedCoverage(graph, renderedEdges, rules) {
485
347
  /** Structural violations fail `pagegraph check`; editorial-only stays green. */
486
348
  const hasStructuralViolations = (violations) => violations.some((violation) => violation.severity === "structural");
487
349
  //#endregion
488
- export { globToRegExp as a, contentSignal as c, lastModified as d, renderRobots as f, checkRenderedCoverage as i, inspectNode as l, checkFreshness as n, hasStructuralViolations as o, renderSitemap as p, checkGraph as r, freshnessReport as s, checkCoverage as t, isSitemapEligible as u };
350
+ export { globToRegExp as a, checkRenderedCoverage as i, checkFreshness as n, hasStructuralViolations as o, checkGraph as r, freshnessReport as s, checkCoverage as t };
489
351
 
490
- //# sourceMappingURL=checks-nk8ZRRIA.js.map
352
+ //# sourceMappingURL=checks-uZk5Em9A.js.map