pagegraph 0.10.0 → 0.11.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 +40 -4
- package/dist/audit.js +1 -1
- package/dist/checks-nk8ZRRIA.js +490 -0
- package/dist/checks-nk8ZRRIA.js.map +1 -0
- package/dist/cli.js +3281 -2583
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +28 -4
- package/dist/config.js +34 -3
- package/dist/config.js.map +1 -1
- package/dist/{content-DbyJoRpj.js → content-CB_kHmJ3.js} +14 -493
- package/dist/content-CB_kHmJ3.js.map +1 -0
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
- package/dist/content-DbyJoRpj.js.map +0 -1
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ bun add -D lighthouse # only for `pagegraph audit` performance evidence
|
|
|
30
30
|
| `pagegraph` | `buildSeoGraph`, `renderSitemap`, `renderRobots`, `contentSignal`, `checkGraph`, `checkRenderedCoverage`, `decodeRenderedEdgeArtifact`, `inspectHtml` | — |
|
|
31
31
|
| `pagegraph/react` | `createSeo` → `seoHead`, `Breadcrumbs`, JSON-LD generators | `react`, `@tanstack/react-router` |
|
|
32
32
|
| `pagegraph/vite` | `seoRouteConfig` coverage gate | `vite` |
|
|
33
|
-
| `pagegraph/config` | `defineSeoConfig`, `viteGraphLoader` | `vite` |
|
|
33
|
+
| `pagegraph/config` | `defineSeoConfig`, `viteGraphLoader`, `selectPageHeadNodes`, `pageHeads`, `loadPageHeads` | `vite` |
|
|
34
34
|
| `pagegraph/audit` | Audit services, scanner protocol, rules, and report schemas | `effect` |
|
|
35
35
|
| `pagegraph` bin | CLI over the same graph | bundled — runs on Bun |
|
|
36
36
|
|
|
@@ -38,10 +38,19 @@ The core and React import graphs have **zero runtime dependencies** — everythi
|
|
|
38
38
|
peer, and only the entries you import need those peers loaded.
|
|
39
39
|
|
|
40
40
|
The CLI **bundles PageGraph's Effect and TypeSafe provider runtime**, so it does not depend on the
|
|
41
|
-
app's Effect
|
|
42
|
-
OpenCode SDK
|
|
41
|
+
app's Effect version; it runs on [Bun](https://bun.sh) (`bunx pagegraph`). Agentic workflows load the
|
|
42
|
+
OpenCode SDK only when invoked. The first workflow installs pinned OpenCode 2.0.22 into
|
|
43
|
+
`~/.cache/pagegraph/opencode-2.0.22-2`, outside the app's dependency tree; subsequent workflows
|
|
44
|
+
reuse the verified cache. This keeps consumer dependency overrides away from OpenCode's declared
|
|
45
|
+
Effect and Drizzle versions. Executor plugins must use OpenCode's Effect version (`4.0.0-rc.112`).
|
|
46
|
+
Bun and network access to npm are required for the first install. The pinned OpenCode client's
|
|
47
|
+
HTTP error handling retains the method, path, status, and response body for diagnostics;
|
|
48
|
+
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
|
|
43
51
|
load your app through Vite at runtime — and `lighthouse` is only needed by `pagegraph audit`.
|
|
44
|
-
Importing `pagegraph/audit` programmatically
|
|
52
|
+
Importing `pagegraph/audit` programmatically needs `effect@^4.0.0`. The Effect and TypeSafe
|
|
53
|
+
peers remain optional for other library entry points and the bundled CLI.
|
|
45
54
|
|
|
46
55
|
### Agent skill
|
|
47
56
|
|
|
@@ -417,6 +426,33 @@ export default defineSeoConfig({
|
|
|
417
426
|
});
|
|
418
427
|
```
|
|
419
428
|
|
|
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:
|
|
433
|
+
|
|
434
|
+
```ts
|
|
435
|
+
import { loadPageHeads, viteGraphLoader } from "pagegraph/config";
|
|
436
|
+
|
|
437
|
+
const loader = viteGraphLoader<{ exclude: string[] }>({
|
|
438
|
+
root: import.meta.dirname,
|
|
439
|
+
entry: "/lib/seo/graph.ts",
|
|
440
|
+
exportName: "loadHeadGraph",
|
|
441
|
+
});
|
|
442
|
+
const exclude = ["/docs", "/docs/**", "/captured-page"];
|
|
443
|
+
const heads = await loadPageHeads(() => loader({ exclude }), { exclude });
|
|
444
|
+
```
|
|
445
|
+
|
|
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`
|
|
449
|
+
always disposes the acquired graph, including when validation fails. Page nodes
|
|
450
|
+
carry a sitemap declaration or an instance; layouts are excluded. By default,
|
|
451
|
+
selection excludes noindex pages, redirects and parameter templates, while retaining
|
|
452
|
+
sitemap-disabled hubs. Set `indexable: false` to include those nonindexable nodes.
|
|
453
|
+
`exclude` accepts exact paths and path globs (`*` within a segment, `**` across
|
|
454
|
+
segments). Docs are ordinary pages; excluding them is caller policy, not a graph kind.
|
|
455
|
+
|
|
420
456
|
```bash
|
|
421
457
|
pagegraph check # CI gate — exit 1 on structural violations
|
|
422
458
|
pagegraph check --site <url> # also check every declared page's rendered HTML
|
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-CB_kHmJ3.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,490 @@
|
|
|
1
|
+
//#region src/core/projections.ts
|
|
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
|
|
140
|
+
//#region src/core/freshness.ts
|
|
141
|
+
const DAY_MS = 864e5;
|
|
142
|
+
/**
|
|
143
|
+
* Split the graph's sitemap-eligible pages into stale, fresh, undated, and
|
|
144
|
+
* invalid. An invalid date cannot be aged, but it is listed so a report never
|
|
145
|
+
* reads as complete while it skipped a page.
|
|
146
|
+
*/
|
|
147
|
+
function freshnessReport(graph, policy, now) {
|
|
148
|
+
const stale = [];
|
|
149
|
+
const fresh = [];
|
|
150
|
+
const undated = [];
|
|
151
|
+
const invalid = [];
|
|
152
|
+
for (const node of graph.nodes.values()) {
|
|
153
|
+
if (!isSitemapEligible(node)) continue;
|
|
154
|
+
const declared = lastModified(node);
|
|
155
|
+
if (declared === void 0) {
|
|
156
|
+
undated.push(node.path);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const time = parseDeclaredDate(declared);
|
|
160
|
+
if (time === void 0) {
|
|
161
|
+
invalid.push(node.path);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const entry = {
|
|
165
|
+
path: node.path,
|
|
166
|
+
kind: node.kind,
|
|
167
|
+
source: node.source,
|
|
168
|
+
lastModified: new Date(time).toISOString(),
|
|
169
|
+
ageDays: Math.max(0, Math.floor((now.getTime() - time) / DAY_MS))
|
|
170
|
+
};
|
|
171
|
+
(entry.ageDays > policy.maxAgeDays ? stale : fresh).push(entry);
|
|
172
|
+
}
|
|
173
|
+
stale.sort((a, b) => b.ageDays - a.ageDays || (a.path < b.path ? -1 : 1));
|
|
174
|
+
fresh.sort((a, b) => a.ageDays - b.ageDays || (a.path < b.path ? -1 : 1));
|
|
175
|
+
return {
|
|
176
|
+
asOf: now.toISOString(),
|
|
177
|
+
maxAgeDays: policy.maxAgeDays,
|
|
178
|
+
stale,
|
|
179
|
+
fresh,
|
|
180
|
+
undated,
|
|
181
|
+
invalid
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/core/checks.ts
|
|
186
|
+
/** A positive sitemap policy — the author asked for this page to be indexed. */
|
|
187
|
+
const hasPositiveSitemap = (sitemap) => sitemap !== void 0 && sitemap !== false;
|
|
188
|
+
/** Content and manifest instances carry page-level title/description. */
|
|
189
|
+
const isInstance = (node) => node.source !== "route";
|
|
190
|
+
/** Group instance nodes by a present, non-empty string field for duplicate detection. */
|
|
191
|
+
const groupInstancesBy = (graph, field) => {
|
|
192
|
+
const groups = /* @__PURE__ */ new Map();
|
|
193
|
+
for (const node of graph.nodes.values()) {
|
|
194
|
+
if (!isInstance(node)) continue;
|
|
195
|
+
const value = field(node)?.trim();
|
|
196
|
+
if (!value) continue;
|
|
197
|
+
const bucket = groups.get(value);
|
|
198
|
+
if (bucket) bucket.push(node);
|
|
199
|
+
else groups.set(value, [node]);
|
|
200
|
+
}
|
|
201
|
+
return groups;
|
|
202
|
+
};
|
|
203
|
+
const DESCRIPTION_MAX = 160;
|
|
204
|
+
const DESCRIPTION_MIN = 50;
|
|
205
|
+
/**
|
|
206
|
+
* Every check, in one place. `checkGraph` runs them in order and stamps each
|
|
207
|
+
* finding with its rule name and severity, so the output is grouped by rule and
|
|
208
|
+
* deterministic (nodes iterate in graph insertion order, edges in array order).
|
|
209
|
+
*/
|
|
210
|
+
const CHECK_RULES = [
|
|
211
|
+
{
|
|
212
|
+
name: "path-owner-collision",
|
|
213
|
+
severity: "structural",
|
|
214
|
+
evaluate: (graph) => (graph.collisions ?? []).map((collision) => ({
|
|
215
|
+
path: collision.path,
|
|
216
|
+
message: `Canonical path "${collision.path}" is owned by multiple sources: ${collision.sources.join(", ")}.`,
|
|
217
|
+
fix: "Give every concrete page one canonical path and one graph owner."
|
|
218
|
+
}))
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
name: "canonical-path-collision",
|
|
222
|
+
severity: "structural",
|
|
223
|
+
evaluate: (graph) => {
|
|
224
|
+
const groups = /* @__PURE__ */ new Map();
|
|
225
|
+
for (const path of graph.nodes.keys()) {
|
|
226
|
+
const canonical = path.toLowerCase().replace(/\/$/, "") || "/";
|
|
227
|
+
const paths = groups.get(canonical);
|
|
228
|
+
if (paths) paths.push(path);
|
|
229
|
+
else groups.set(canonical, [path]);
|
|
230
|
+
}
|
|
231
|
+
return [...groups.values()].filter((paths) => paths.length > 1).flatMap((paths) => paths.map((path) => ({
|
|
232
|
+
path,
|
|
233
|
+
message: `Canonical path collides with ${paths.filter((candidate) => candidate !== path).join(", ")}.`,
|
|
234
|
+
fix: "Use one lowercase, trailing-slash-normalized canonical path."
|
|
235
|
+
})));
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: "self-edge",
|
|
240
|
+
severity: "structural",
|
|
241
|
+
evaluate: (graph) => graph.edges.filter((edge) => edge.from === edge.to).map((edge) => ({
|
|
242
|
+
path: edge.from,
|
|
243
|
+
message: `${edge.type} edge points back to its own source node.`,
|
|
244
|
+
fix: "Remove the self-reference from the canonical manifest or route declaration."
|
|
245
|
+
}))
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: "duplicate-edge",
|
|
249
|
+
severity: "structural",
|
|
250
|
+
evaluate: (graph) => {
|
|
251
|
+
const seen = /* @__PURE__ */ new Set();
|
|
252
|
+
const duplicates = [];
|
|
253
|
+
for (const edge of graph.edges) {
|
|
254
|
+
const key = `${edge.from}\u0000${edge.to}\u0000${edge.type}`;
|
|
255
|
+
if (seen.has(key)) duplicates.push({
|
|
256
|
+
path: edge.from,
|
|
257
|
+
message: `Duplicate ${edge.type} edge from "${edge.from}" to "${edge.to}".`,
|
|
258
|
+
fix: "Declare each graph relationship exactly once."
|
|
259
|
+
});
|
|
260
|
+
else seen.add(key);
|
|
261
|
+
}
|
|
262
|
+
return duplicates;
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: "dead-edge",
|
|
267
|
+
severity: "structural",
|
|
268
|
+
evaluate: (graph) => graph.edges.filter((edge) => edge.type !== "crumb-parent" && !graph.nodes.has(edge.to)).map((edge) => ({
|
|
269
|
+
path: edge.from,
|
|
270
|
+
message: `${edge.type} edge from "${edge.from}" points at "${edge.to}", which is not a node in the graph.`,
|
|
271
|
+
fix: `Update the ${edge.type === "redirect" ? "redirectTo" : "related"} target on the "${edge.from}" route, or restore "${edge.to}".`
|
|
272
|
+
}))
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
name: "instance-missing-title",
|
|
276
|
+
severity: "structural",
|
|
277
|
+
evaluate: (graph) => [...graph.nodes.values()].filter((node) => isInstance(node) && !node.instance?.title.trim()).map((node) => ({
|
|
278
|
+
path: node.path,
|
|
279
|
+
message: `Content page "${node.path}" has no title.`,
|
|
280
|
+
fix: "Add a `title` to the page frontmatter."
|
|
281
|
+
}))
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
name: "sitemap-noindex-contradiction",
|
|
285
|
+
severity: "structural",
|
|
286
|
+
evaluate: (graph) => [...graph.nodes.values()].filter((node) => hasPositiveSitemap(node.policy.sitemap) && node.policy.robots?.toLowerCase().includes("noindex")).map((node) => ({
|
|
287
|
+
path: node.path,
|
|
288
|
+
message: `"${node.path}" declares a sitemap policy but its robots value is "${node.policy.robots}".`,
|
|
289
|
+
fix: "Drop the sitemap policy (or set `sitemap: false`) on a noindex page, or remove the noindex robots value."
|
|
290
|
+
}))
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: "robots-not-lowercase",
|
|
294
|
+
severity: "structural",
|
|
295
|
+
evaluate: (graph) => [...graph.nodes.values()].filter((node) => node.policy.robots !== void 0 && node.policy.robots !== node.policy.robots.toLowerCase()).map((node) => ({
|
|
296
|
+
path: node.path,
|
|
297
|
+
message: `"${node.path}" declares robots "${node.policy.robots}" — robots values must be lowercase.`,
|
|
298
|
+
fix: "Lowercase the robots declaration (e.g. \"noindex, follow\")."
|
|
299
|
+
}))
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
name: "related-target-missing-link",
|
|
303
|
+
severity: "structural",
|
|
304
|
+
evaluate: (graph) => {
|
|
305
|
+
const linklessTargets = /* @__PURE__ */ new Set();
|
|
306
|
+
for (const edge of graph.edges) {
|
|
307
|
+
if (edge.type !== "related") continue;
|
|
308
|
+
const target = graph.nodes.get(edge.to);
|
|
309
|
+
if (target && target.source === "route" && target.policy.link === void 0) linklessTargets.add(edge.to);
|
|
310
|
+
}
|
|
311
|
+
return [...linklessTargets].map((path) => ({
|
|
312
|
+
path,
|
|
313
|
+
message: `Route "${path}" is a related-link target but declares no link metadata; its card would render empty.`,
|
|
314
|
+
fix: `Add \`link: { title, description }\` to the "${path}" route's staticData.seo.`
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
name: "redirect-in-sitemap",
|
|
320
|
+
severity: "structural",
|
|
321
|
+
evaluate: (graph) => [...graph.nodes.values()].filter((node) => node.policy.redirectTo !== void 0 && hasPositiveSitemap(node.policy.sitemap)).map((node) => ({
|
|
322
|
+
path: node.path,
|
|
323
|
+
message: `Redirect "${node.path}" (→ "${node.policy.redirectTo}") also declares a sitemap policy.`,
|
|
324
|
+
fix: "Remove the sitemap policy from the redirect route; only its target belongs in the sitemap."
|
|
325
|
+
}))
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
name: "invalid-date",
|
|
329
|
+
severity: "structural",
|
|
330
|
+
evaluate: (graph) => [...graph.nodes.values()].flatMap((node) => {
|
|
331
|
+
return (node.instance ? [["publishedAt", node.instance.publishedAt], ["modifiedAt", node.instance.modifiedAt]] : [["modifiedAt", node.policy.modifiedAt]]).flatMap(([field, value]) => value !== void 0 && parseDeclaredDate(value) === void 0 ? [{
|
|
332
|
+
path: node.path,
|
|
333
|
+
message: `${field} "${value}" is not an ISO 8601 date on a real calendar day.`,
|
|
334
|
+
fix: `Write ${field} as an ISO 8601 date, e.g. "2026-09-29".`
|
|
335
|
+
}] : []);
|
|
336
|
+
})
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
name: "duplicate-title",
|
|
340
|
+
severity: "editorial",
|
|
341
|
+
evaluate: (graph) => [...groupInstancesBy(graph, (node) => node.instance?.title).entries()].filter(([, nodes]) => nodes.length > 1).flatMap(([title, nodes]) => nodes.map((node) => ({
|
|
342
|
+
path: node.path,
|
|
343
|
+
message: `Title "${title}" is shared by ${nodes.length} pages.`,
|
|
344
|
+
fix: "Give each page a distinct title."
|
|
345
|
+
})))
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
name: "duplicate-description",
|
|
349
|
+
severity: "editorial",
|
|
350
|
+
evaluate: (graph) => [...groupInstancesBy(graph, (node) => node.instance?.description).entries()].filter(([, nodes]) => nodes.length > 1).flatMap(([, nodes]) => nodes.map((node) => ({
|
|
351
|
+
path: node.path,
|
|
352
|
+
message: `Description is shared by ${nodes.length} pages.`,
|
|
353
|
+
fix: "Write a distinct meta description for each page."
|
|
354
|
+
})))
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
name: "description-length",
|
|
358
|
+
severity: "editorial",
|
|
359
|
+
evaluate: (graph) => [...graph.nodes.values()].flatMap((node) => {
|
|
360
|
+
if (!isInstance(node)) return [];
|
|
361
|
+
const description = node.instance?.description?.trim();
|
|
362
|
+
if (!description) return [];
|
|
363
|
+
if (description.length > DESCRIPTION_MAX) return [{
|
|
364
|
+
path: node.path,
|
|
365
|
+
message: `Description is ${description.length} chars (max ${DESCRIPTION_MAX}); it will be truncated in results.`,
|
|
366
|
+
fix: `Trim the description to ${DESCRIPTION_MAX} characters or fewer.`
|
|
367
|
+
}];
|
|
368
|
+
if (description.length < DESCRIPTION_MIN) return [{
|
|
369
|
+
path: node.path,
|
|
370
|
+
message: `Description is ${description.length} chars (min ${DESCRIPTION_MIN}); it reads as thin.`,
|
|
371
|
+
fix: `Expand the description to at least ${DESCRIPTION_MIN} characters.`
|
|
372
|
+
}];
|
|
373
|
+
return [];
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
];
|
|
377
|
+
/**
|
|
378
|
+
* Run every static rule against the graph, then any caller-supplied
|
|
379
|
+
* {@link CoverageRule}s and freshness policy, and return the flat list of
|
|
380
|
+
* violations. With no options the result is exactly the static rule set.
|
|
381
|
+
*/
|
|
382
|
+
function checkGraph(graph, options = {}) {
|
|
383
|
+
const violations = CHECK_RULES.flatMap((rule) => rule.evaluate(graph).map((raw) => ({
|
|
384
|
+
severity: rule.severity,
|
|
385
|
+
rule: rule.name,
|
|
386
|
+
path: raw.path,
|
|
387
|
+
message: raw.message,
|
|
388
|
+
fix: raw.fix
|
|
389
|
+
})));
|
|
390
|
+
const coverage = options.coverage;
|
|
391
|
+
if (coverage !== void 0 && coverage.length > 0) violations.push(...checkCoverage(graph, coverage));
|
|
392
|
+
if (options.freshness !== void 0) violations.push(...checkFreshness(graph, options.freshness, options.now));
|
|
393
|
+
return violations;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* One editorial `stale-page` violation per sitemap-eligible page whose last
|
|
397
|
+
* declared change is older than the policy allows. Undated pages are not
|
|
398
|
+
* violations: see {@link freshnessReport} for the full picture.
|
|
399
|
+
*/
|
|
400
|
+
function checkFreshness(graph, policy, now) {
|
|
401
|
+
return freshnessReport(graph, policy, now).stale.map((entry) => ({
|
|
402
|
+
severity: "editorial",
|
|
403
|
+
rule: "stale-page",
|
|
404
|
+
path: entry.path,
|
|
405
|
+
message: `Last changed ${entry.lastModified.slice(0, 10)}, ${entry.ageDays} days ago (limit ${policy.maxAgeDays}).`,
|
|
406
|
+
fix: "Review the page; when its content changes, update its modifiedAt."
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
/** Escape a glob for `RegExp`, then expand `**`, `*`, and `?` to path-aware forms. */
|
|
410
|
+
const globToRegExp = (glob) => {
|
|
411
|
+
const source = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/\u0000/g, ".*");
|
|
412
|
+
return new RegExp(`^${source}$`);
|
|
413
|
+
};
|
|
414
|
+
/**
|
|
415
|
+
* Enforce contextual-link coverage rules over an explicit surface.
|
|
416
|
+
*
|
|
417
|
+
* A rule that matches no sitemap-eligible page is itself a violation: a typo or
|
|
418
|
+
* a rule aimed at a noindex page would otherwise pass silently forever.
|
|
419
|
+
*/
|
|
420
|
+
const checkCoverageRules = (surface, rules) => {
|
|
421
|
+
const violations = [];
|
|
422
|
+
for (const rule of rules) {
|
|
423
|
+
const matcher = globToRegExp(rule.path);
|
|
424
|
+
const matched = surface.paths.filter((path) => matcher.test(path));
|
|
425
|
+
const eligible = surface.eligiblePaths.filter((path) => matcher.test(path));
|
|
426
|
+
if (eligible.length === 0) {
|
|
427
|
+
violations.push({
|
|
428
|
+
severity: "structural",
|
|
429
|
+
rule: "coverage-rule-unmatched",
|
|
430
|
+
message: matched.length === 0 ? `Coverage rule "${rule.path}" matches no page in the graph.` : `Coverage rule "${rule.path}" matches only pages that are not sitemap-eligible.`,
|
|
431
|
+
fix: "Point the rule at a sitemap-eligible path, or drop it."
|
|
432
|
+
});
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
for (const path of eligible) {
|
|
436
|
+
const inbound = surface.inbound(path);
|
|
437
|
+
if (inbound >= rule.minInbound) continue;
|
|
438
|
+
violations.push({
|
|
439
|
+
severity: "structural",
|
|
440
|
+
rule: "inbound-link-coverage",
|
|
441
|
+
path,
|
|
442
|
+
message: `"${path}" has ${inbound} incoming contextual link(s); the coverage rule requires ${rule.minInbound}.`,
|
|
443
|
+
fix: `Add ${rule.minInbound - inbound} contextual link(s) pointing at "${path}".`
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return violations;
|
|
448
|
+
};
|
|
449
|
+
/** The sitemap-eligible path set of a declared graph, in graph insertion order. */
|
|
450
|
+
const eligiblePathsOf = (graph) => [...graph.nodes.values()].filter(isSitemapEligible).map((node) => node.path);
|
|
451
|
+
/**
|
|
452
|
+
* Enforce contextual-link coverage against the *declared* graph: only `related`
|
|
453
|
+
* edges count — breadcrumb ancestry is navigation, not context.
|
|
454
|
+
*/
|
|
455
|
+
function checkCoverage(graph, rules) {
|
|
456
|
+
return checkCoverageRules({
|
|
457
|
+
paths: [...graph.nodes.keys()],
|
|
458
|
+
eligiblePaths: eligiblePathsOf(graph),
|
|
459
|
+
inbound: (path) => graph.edges.filter((edge) => edge.type === "related" && edge.to === path).length
|
|
460
|
+
}, rules);
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Enforce contextual-link coverage against the *rendered* graph: only same-origin
|
|
464
|
+
* body-region anchors count. Nav, header, and footer anchors are site chrome, so
|
|
465
|
+
* a page linked solely from them has no contextual inbound link — the same
|
|
466
|
+
* distinction the declared pass draws by counting `related` edges alone.
|
|
467
|
+
*
|
|
468
|
+
* The target universe is the declared graph's sitemap-eligible pages: the globs
|
|
469
|
+
* in `seo.config.ts` are written against declared paths, and the crawl is what
|
|
470
|
+
* supplies the served edges. Pass the crawl's `internalEdges` (each edge carries
|
|
471
|
+
* its `region`).
|
|
472
|
+
*/
|
|
473
|
+
function checkRenderedCoverage(graph, renderedEdges, rules) {
|
|
474
|
+
const inbound = /* @__PURE__ */ new Map();
|
|
475
|
+
for (const edge of renderedEdges) {
|
|
476
|
+
if (edge.region !== "body") continue;
|
|
477
|
+
inbound.set(edge.to, (inbound.get(edge.to) ?? 0) + 1);
|
|
478
|
+
}
|
|
479
|
+
return checkCoverageRules({
|
|
480
|
+
paths: [...graph.nodes.keys()],
|
|
481
|
+
eligiblePaths: eligiblePathsOf(graph),
|
|
482
|
+
inbound: (path) => inbound.get(path) ?? 0
|
|
483
|
+
}, rules);
|
|
484
|
+
}
|
|
485
|
+
/** Structural violations fail `pagegraph check`; editorial-only stays green. */
|
|
486
|
+
const hasStructuralViolations = (violations) => violations.some((violation) => violation.severity === "structural");
|
|
487
|
+
//#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 };
|
|
489
|
+
|
|
490
|
+
//# sourceMappingURL=checks-nk8ZRRIA.js.map
|