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.
- package/README.md +140 -137
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +1 -1
- package/dist/build-only/audit.d.ts +1 -0
- package/dist/build-only/audit.js +6 -0
- package/dist/build-only/audit.js.map +1 -0
- package/dist/build-only/config.d.ts +1 -0
- package/dist/build-only/config.js +6 -0
- package/dist/build-only/config.js.map +1 -0
- package/dist/build-only/tanstack-start.d.ts +1 -0
- package/dist/build-only/tanstack-start.js +6 -0
- package/dist/build-only/tanstack-start.js.map +1 -0
- package/dist/build-only/vite.d.ts +1 -0
- package/dist/build-only/vite.js +6 -0
- package/dist/build-only/vite.js.map +1 -0
- package/dist/{checks-nk8ZRRIA.js → checks-uZk5Em9A.js} +3 -141
- package/dist/checks-uZk5Em9A.js.map +1 -0
- package/dist/cli.js +393 -287
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +7 -66
- package/dist/config.js +3 -122
- package/dist/config.js.map +1 -1
- package/dist/{content-DVL2iyGU.d.ts → content-B1cPtL8b.d.ts} +4 -128
- package/dist/{content-CB_kHmJ3.js → content-kcEnQdfi.js} +3 -2
- package/dist/{content-CB_kHmJ3.js.map → content-kcEnQdfi.js.map} +1 -1
- package/dist/declare-D5o7hwmv.js +9 -0
- package/dist/declare-D5o7hwmv.js.map +1 -0
- package/dist/declare-DVgdEdOL.d.ts +12127 -0
- package/dist/graph-Bcsourlv.js +162 -0
- package/dist/graph-Bcsourlv.js.map +1 -0
- package/dist/graph-BxCY90vq.d.ts +104 -0
- package/dist/graph-CBIY71w6.d.ts +57 -0
- package/dist/index.d.ts +37 -73
- package/dist/index.js +23 -134
- package/dist/index.js.map +1 -1
- package/dist/message-DT_CjUdt.js +12 -0
- package/dist/message-DT_CjUdt.js.map +1 -0
- package/dist/page-heads-6y-CARRQ.js +25 -0
- package/dist/page-heads-6y-CARRQ.js.map +1 -0
- package/dist/page-heads-DNkjMUEN.d.ts +21 -0
- package/dist/projections-D0oOFKiY.js +142 -0
- package/dist/projections-D0oOFKiY.js.map +1 -0
- package/dist/projections-DeEFe7XT.d.ts +78 -0
- package/dist/react.d.ts +26 -12051
- package/dist/react.js +12 -3
- package/dist/react.js.map +1 -1
- package/dist/route-config-35ZEzkNC.js +308 -0
- package/dist/route-config-35ZEzkNC.js.map +1 -0
- package/dist/route-config-wneg6ktn.d.ts +29 -0
- package/dist/tanstack-start/index.d.ts +40 -0
- package/dist/tanstack-start/index.js +368 -0
- package/dist/tanstack-start/index.js.map +1 -0
- package/dist/tanstack-start/server.d.ts +14 -0
- package/dist/tanstack-start/server.js +53 -0
- package/dist/tanstack-start/server.js.map +1 -0
- package/dist/vite-graph-loader-01p1WxEq.js +104 -0
- package/dist/vite-graph-loader-01p1WxEq.js.map +1 -0
- package/dist/vite-graph-loader-Btgjz5RH.d.ts +56 -0
- package/dist/vite.d.ts +2 -28
- package/dist/vite.js +1 -307
- package/dist/wire-B4RQMc4e.js +26 -0
- package/dist/wire-B4RQMc4e.js.map +1 -0
- package/package.json +27 -5
- package/dist/checks-nk8ZRRIA.js.map +0 -1
- 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
|
|
4
|
-
`staticData.seo` — the sitemap, `robots.txt`,
|
|
5
|
-
the CI check all derive from that one
|
|
6
|
-
so nothing drifts. A public page that
|
|
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
|
|
12
|
+
route["staticData.seo (+ head)<br/>head: seo.head"]
|
|
12
13
|
end
|
|
13
|
-
route --> seograph["SeoGraph<br/>(
|
|
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["<head> meta · canonical<br/>Breadcrumbs · JSON-LD"]
|
|
18
|
-
gate["
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
|
50
|
-
`
|
|
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
|
-
|
|
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
|
|
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:
|
|
115
|
-
|
|
116
|
-
title: "Pricing — Example",
|
|
117
|
-
description: "Simple volume pricing.",
|
|
118
|
-
}),
|
|
129
|
+
head: seo.head,
|
|
130
|
+
component: PricingPage,
|
|
119
131
|
});
|
|
120
132
|
```
|
|
121
133
|
|
|
122
|
-
`
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
**
|
|
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
|
-
//
|
|
199
|
-
import {
|
|
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
|
|
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
|
-
//
|
|
207
|
-
import {
|
|
201
|
+
// seo.config.ts
|
|
202
|
+
import { defineSeoConfig } from "pagegraph/config";
|
|
203
|
+
import { tanstackStartGraph } from "pagegraph/tanstack-start";
|
|
208
204
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
`
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
|
437
|
+
import { loadPageHeads } from "pagegraph/config"; // build-only
|
|
438
|
+
import { tanstackStartGraph } from "pagegraph/tanstack-start";
|
|
436
439
|
|
|
437
|
-
const
|
|
438
|
-
|
|
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
|
|
447
|
-
`pageHeads(graph, options)` returns `{ path, title, description }[]
|
|
448
|
-
|
|
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
|
-
|
|
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
package/dist/audit.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { l as extractAnchors, r as extractPageContent, t as checkPageContent } from "./content-
|
|
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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
|
-
|
|
2
|
-
const escapeXml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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,
|
|
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-
|
|
352
|
+
//# sourceMappingURL=checks-uZk5Em9A.js.map
|