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/dist/index.js
CHANGED
|
@@ -1,139 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
/** Merge a route's declaration into an existing same-path node (deeper route wins). */
|
|
18
|
-
function mergeSeo(base, override) {
|
|
1
|
+
import { t as applyTitleTemplate } from "./declare-D5o7hwmv.js";
|
|
2
|
+
import { t as buildSeoGraph } from "./graph-Bcsourlv.js";
|
|
3
|
+
import { n as graphToJson, t as graphFromJson } from "./wire-B4RQMc4e.js";
|
|
4
|
+
import { i as lastModified, n as inspectNode, o as renderRobots, r as isSitemapEligible, s as renderSitemap, t as contentSignal } from "./projections-D0oOFKiY.js";
|
|
5
|
+
import { i as checkRenderedCoverage, n as checkFreshness, o as hasStructuralViolations, r as checkGraph, s as freshnessReport, t as checkCoverage } from "./checks-uZk5Em9A.js";
|
|
6
|
+
import { a as RENDERED_EDGE_ARTIFACT_SCHEMA_VERSION, c as diffLinkGraph, d as renderedGraphFromEdges, f as hasBlockingIssues, i as renderedCheckTargets, l as extractAnchors, n as checkRenderedContent, o as buildRenderedGraph, p as inspectHtml, r as extractPageContent, s as decodeRenderedEdgeArtifact, t as checkPageContent, u as normalizePath } from "./content-kcEnQdfi.js";
|
|
7
|
+
import { n as selectPageHeadNodes, t as pageHeads } from "./page-heads-6y-CARRQ.js";
|
|
8
|
+
//#region src/core/collections.ts
|
|
9
|
+
function contentCollection(options) {
|
|
10
|
+
const entries = options.pages.map((page) => {
|
|
11
|
+
const entry = options.entry(page);
|
|
12
|
+
return {
|
|
13
|
+
...entry,
|
|
14
|
+
path: entry.path ?? page.url
|
|
15
|
+
};
|
|
16
|
+
});
|
|
19
17
|
return {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
link: override.link ?? base.link,
|
|
26
|
-
redirectTo: override.redirectTo ?? base.redirectTo,
|
|
27
|
-
modifiedAt: override.modifiedAt ?? base.modifiedAt
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Walk the route tree building structural nodes and crumb-parent edges. `crumbStack`
|
|
32
|
-
* holds the paths of crumb-declaring ancestors so each crumb node links to its nearest
|
|
33
|
-
* crumb ancestor down the real route-parent chain (matching render-time breadcrumbs).
|
|
34
|
-
*/
|
|
35
|
-
function walkRoutes(route, parentPath, isRoot, nodes, edges, crumbStack) {
|
|
36
|
-
const path = isRoot ? "/" : joinPath(parentPath, route.options.path);
|
|
37
|
-
const seo = route.options.staticData?.seo;
|
|
38
|
-
if (seo) {
|
|
39
|
-
const existing = nodes.get(path);
|
|
40
|
-
if (existing) {
|
|
41
|
-
existing.policy = mergeSeo(existing.policy, seo);
|
|
42
|
-
existing.kind = existing.policy.kind;
|
|
43
|
-
} else nodes.set(path, {
|
|
44
|
-
path,
|
|
45
|
-
kind: seo.kind,
|
|
46
|
-
source: "route",
|
|
47
|
-
policy: { ...seo }
|
|
48
|
-
});
|
|
49
|
-
const nearestCrumbAncestor = crumbStack[crumbStack.length - 1];
|
|
50
|
-
if (seo.crumb !== void 0 && nearestCrumbAncestor !== void 0) edges.push({
|
|
51
|
-
from: path,
|
|
52
|
-
to: nearestCrumbAncestor,
|
|
53
|
-
type: "crumb-parent"
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
const pushedCrumb = seo?.crumb !== void 0;
|
|
57
|
-
if (pushedCrumb) crumbStack.push(path);
|
|
58
|
-
for (const child of route.children ?? []) walkRoutes(child, path, false, nodes, edges, crumbStack);
|
|
59
|
-
if (pushedCrumb) crumbStack.pop();
|
|
60
|
-
}
|
|
61
|
-
/**
|
|
62
|
-
* Add one collection's instance nodes, inheriting kind + sitemap policy from the
|
|
63
|
-
* collection route's declaration, then append the edges it declares. A path already
|
|
64
|
-
* owned by another node is a collision: the first owner keeps the path and the
|
|
65
|
-
* conflict is reported (the `path-owner-collision` check turns it into a violation).
|
|
66
|
-
*/
|
|
67
|
-
function addCollection(nodes, edges, collisions, collection) {
|
|
68
|
-
const collectionNode = nodes.get(collection.route);
|
|
69
|
-
const kind = collectionNode?.kind ?? FALLBACK_KIND;
|
|
70
|
-
const sitemap = collectionNode?.policy.sitemap;
|
|
71
|
-
/**
|
|
72
|
-
* Paths this collection lost to an earlier owner. Their instances never enter
|
|
73
|
-
* the graph, so any edge declared out of them would dangle — and the dead-edge
|
|
74
|
-
* check only validates an edge's `to`, so nothing downstream would catch it.
|
|
75
|
-
*/
|
|
76
|
-
const rejected = /* @__PURE__ */ new Set();
|
|
77
|
-
for (const instance of collection.instances) {
|
|
78
|
-
const existing = nodes.get(instance.path);
|
|
79
|
-
if (existing) {
|
|
80
|
-
collisions.push({
|
|
81
|
-
path: instance.path,
|
|
82
|
-
sources: [existing.source, collection.source]
|
|
83
|
-
});
|
|
84
|
-
rejected.add(instance.path);
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
nodes.set(instance.path, {
|
|
88
|
-
path: instance.path,
|
|
89
|
-
kind,
|
|
90
|
-
source: collection.source,
|
|
91
|
-
policy: {
|
|
92
|
-
kind,
|
|
93
|
-
sitemap
|
|
94
|
-
},
|
|
95
|
-
instance: {
|
|
96
|
-
title: instance.title,
|
|
97
|
-
description: instance.description,
|
|
98
|
-
publishedAt: instance.publishedAt,
|
|
99
|
-
modifiedAt: instance.modifiedAt
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
for (const edge of collection.edges ?? []) {
|
|
104
|
-
if (rejected.has(edge.from)) continue;
|
|
105
|
-
edges.push(edge);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* Build the SEO graph from route declarations and content collections.
|
|
110
|
-
*
|
|
111
|
-
* Synchronous: the caller materializes its collections before calling, so there is
|
|
112
|
-
* no async work here. Callers own the origin.
|
|
113
|
-
*/
|
|
114
|
-
function buildSeoGraph(input) {
|
|
115
|
-
const nodes = /* @__PURE__ */ new Map();
|
|
116
|
-
const edges = [];
|
|
117
|
-
const collisions = [];
|
|
118
|
-
walkRoutes(input.routeTree, "/", true, nodes, edges, []);
|
|
119
|
-
for (const collection of input.collections ?? []) addCollection(nodes, edges, collisions, collection);
|
|
120
|
-
for (const node of nodes.values()) {
|
|
121
|
-
if (node.source !== "route") continue;
|
|
122
|
-
for (const to of node.policy.related ?? []) edges.push({
|
|
123
|
-
from: node.path,
|
|
18
|
+
route: options.route,
|
|
19
|
+
source: options.source,
|
|
20
|
+
instances: entries.map(({ related: _related, ...instance }) => instance),
|
|
21
|
+
edges: [...entries.flatMap((entry) => [...new Set(entry.related ?? [])].map((to) => ({
|
|
22
|
+
from: entry.path,
|
|
124
23
|
to,
|
|
125
24
|
type: "related"
|
|
126
|
-
})
|
|
127
|
-
if (node.policy.redirectTo !== void 0) edges.push({
|
|
128
|
-
from: node.path,
|
|
129
|
-
to: node.policy.redirectTo,
|
|
130
|
-
type: "redirect"
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
return {
|
|
134
|
-
nodes,
|
|
135
|
-
edges,
|
|
136
|
-
collisions
|
|
25
|
+
}))), ...options.edges ?? []]
|
|
137
26
|
};
|
|
138
27
|
}
|
|
139
28
|
//#endregion
|
|
@@ -282,6 +171,6 @@ function resolveRouteLink(router, path) {
|
|
|
282
171
|
return Object.values(routesById).find((route) => route.fullPath === path)?.options.staticData?.seo?.link;
|
|
283
172
|
}
|
|
284
173
|
//#endregion
|
|
285
|
-
export { RENDERED_EDGE_ARTIFACT_SCHEMA_VERSION, buildRenderedGraph, buildSeoGraph, candidateSourceText, checkCoverage, checkFreshness, checkGraph, checkPageContent, checkRenderedContent, checkRenderedCoverage, contentSignal, decodeRenderedEdgeArtifact, decodeRenderedEdges, diffLinkGraph, extractAnchors, extractPageContent, freshnessReport, generateLinkCandidates, hasBlockingIssues, hasStructuralViolations, inspectHtml, inspectNode, isSitemapEligible, lastModified, matchesClusterFilter, normalizePath, renderRobots, renderSitemap, renderedCheckTargets, renderedGraphFromEdges, resolveRouteLink, undirectedEdgeKey };
|
|
174
|
+
export { RENDERED_EDGE_ARTIFACT_SCHEMA_VERSION, applyTitleTemplate, buildRenderedGraph, buildSeoGraph, candidateSourceText, checkCoverage, checkFreshness, checkGraph, checkPageContent, checkRenderedContent, checkRenderedCoverage, contentCollection, contentSignal, decodeRenderedEdgeArtifact, decodeRenderedEdges, diffLinkGraph, extractAnchors, extractPageContent, freshnessReport, generateLinkCandidates, graphFromJson, graphToJson, hasBlockingIssues, hasStructuralViolations, inspectHtml, inspectNode, isSitemapEligible, lastModified, matchesClusterFilter, normalizePath, pageHeads, renderRobots, renderSitemap, renderedCheckTargets, renderedGraphFromEdges, resolveRouteLink, selectPageHeadNodes, undirectedEdgeKey };
|
|
286
175
|
|
|
287
176
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/core/graph.ts","../src/core/link-candidates.ts","../src/core/resolve-route-link.ts"],"sourcesContent":["/**\n * SEO graph — the derived model that projections (sitemap, robots), the CLI, and\n * the check engine all read from. Built from route declarations (`staticData.seo`)\n * plus consumer-supplied content collections. This module is pure: no React, no\n * env, no knowledge of where instances come from. Origins and env-derived values\n * are injected by the callers of the projections, never read here.\n *\n * A node is one of:\n * - a structural route (`source: \"route\"`), keyed by its normalized full path,\n * merging a layout route's declaration (crumb) with its index child's (kind,\n * sitemap policy) when both resolve to the same URL;\n * - a content instance (`source` = the collection's label), keyed by the page URL\n * and carrying page-level metadata.\n *\n * Edges: `crumb-parent` (breadcrumb ancestry), `related` (deliberate cross-links),\n * `collection-member` (membership in a curated set), and `redirect` (route aliases).\n * The route walk emits crumb/related/redirect edges from declarations; a collection\n * may declare any additional edges its instances need.\n */\n\nimport type { AnyRoute } from \"@tanstack/react-router\";\n\nimport type { PublicPath, RouteSeo, SeoKind } from \"./declare\";\n\n/**\n * Where a node came from: `\"route\"` for a structural route declaration, or the\n * `source` label of the collection that produced the instance (e.g. \"blog\").\n */\nexport type SeoSource = string;\n\nexport interface SeoNode {\n /** Canonical path, no origin (e.g. \"/pricing\", \"/blog/my-post\"). */\n path: string;\n kind: SeoKind;\n source: SeoSource;\n /** Route-declared policy, or synthesized (kind + inherited sitemap) for instances. */\n policy: RouteSeo;\n instance?:\n | {\n title: string;\n description?: string | undefined;\n publishedAt?: string | undefined;\n modifiedAt?: string | undefined;\n }\n | undefined;\n}\n\nexport type SeoEdgeType = \"crumb-parent\" | \"related\" | \"redirect\" | \"collection-member\";\n\nexport interface SeoEdge {\n from: string;\n to: string;\n type: SeoEdgeType;\n}\n\nexport interface SeoGraph {\n nodes: Map<string, SeoNode>;\n edges: Array<SeoEdge>;\n /** Exact-path ownership conflicts encountered while assembling graph sources. */\n collisions?: ReadonlyArray<{ path: string; sources: ReadonlyArray<SeoSource> }> | undefined;\n}\n\n/** One concrete page produced by a collection. */\nexport interface SeoInstance {\n /** Canonical path of the page (e.g. \"/blog/my-post\"). */\n readonly path: string;\n readonly title: string;\n readonly description?: string | undefined;\n readonly publishedAt?: string | undefined;\n readonly modifiedAt?: string | undefined;\n}\n\nexport interface SeoCollection {\n /**\n * The param route these instances render through (e.g. \"/blog/$slug\"). Instances\n * inherit this route's declared policy (kind + sitemap) — the graph reads it from\n * the structural node, so declarations stay the single source of truth.\n */\n readonly route: PublicPath;\n /** The `source` stamped on every node this collection produces (e.g. \"blog\"). */\n readonly source: SeoSource;\n readonly instances: ReadonlyArray<SeoInstance>;\n /** Edges the collection declares between its instances and the rest of the graph. */\n readonly edges?: ReadonlyArray<SeoEdge> | undefined;\n}\n\nexport interface BuildSeoGraphInput {\n readonly routeTree: AnyRoute;\n readonly collections?: ReadonlyArray<SeoCollection> | undefined;\n}\n\n/** Kind for instances whose collection route carries no declaration to inherit. */\nconst FALLBACK_KIND: SeoKind = \"page\";\n\n/** Structural view of a route we walk — the fields present before router init(). */\ninterface WalkableRoute {\n readonly options: {\n readonly path?: string | undefined;\n readonly staticData?: { readonly seo?: RouteSeo | undefined } | undefined;\n };\n readonly children?: ReadonlyArray<WalkableRoute> | undefined;\n}\n\n/**\n * Join a child's local path onto its parent's computed full path with the same\n * semantics as TanStack's route init (relative segments, index \"/\" inherits the\n * parent, pathless/group routes are transparent), then normalize trailing slashes.\n */\nfunction joinPath(parent: string, seg: string | undefined): string {\n if (seg === undefined) return parent; // pathless layout / route group\n if (seg === \"/\") return parent; // index route resolves to its parent's URL\n const trimmed = seg.replace(/^\\/+/, \"\").replace(/\\/+$/, \"\");\n const joined = `${parent === \"/\" ? \"\" : parent}/${trimmed}`;\n return joined.replace(/\\/{2,}/g, \"/\");\n}\n\n/** Merge a route's declaration into an existing same-path node (deeper route wins). */\nfunction mergeSeo(base: RouteSeo, override: RouteSeo): RouteSeo {\n return {\n kind: override.kind,\n crumb: override.crumb ?? base.crumb,\n sitemap: override.sitemap ?? base.sitemap,\n robots: override.robots ?? base.robots,\n related: override.related ?? base.related,\n link: override.link ?? base.link,\n redirectTo: override.redirectTo ?? base.redirectTo,\n modifiedAt: override.modifiedAt ?? base.modifiedAt,\n };\n}\n\n/**\n * Walk the route tree building structural nodes and crumb-parent edges. `crumbStack`\n * holds the paths of crumb-declaring ancestors so each crumb node links to its nearest\n * crumb ancestor down the real route-parent chain (matching render-time breadcrumbs).\n */\nfunction walkRoutes(\n route: WalkableRoute,\n parentPath: string,\n isRoot: boolean,\n nodes: Map<string, SeoNode>,\n edges: Array<SeoEdge>,\n crumbStack: Array<string>,\n): void {\n const path = isRoot ? \"/\" : joinPath(parentPath, route.options.path);\n const seo = route.options.staticData?.seo;\n\n if (seo) {\n const existing = nodes.get(path);\n if (existing) {\n existing.policy = mergeSeo(existing.policy, seo);\n existing.kind = existing.policy.kind;\n } else {\n nodes.set(path, { path, kind: seo.kind, source: \"route\", policy: { ...seo } });\n }\n\n const nearestCrumbAncestor = crumbStack[crumbStack.length - 1];\n if (seo.crumb !== undefined && nearestCrumbAncestor !== undefined) {\n edges.push({ from: path, to: nearestCrumbAncestor, type: \"crumb-parent\" });\n }\n }\n\n const pushedCrumb = seo?.crumb !== undefined;\n if (pushedCrumb) crumbStack.push(path);\n for (const child of route.children ?? []) {\n walkRoutes(child, path, false, nodes, edges, crumbStack);\n }\n if (pushedCrumb) crumbStack.pop();\n}\n\n/**\n * Add one collection's instance nodes, inheriting kind + sitemap policy from the\n * collection route's declaration, then append the edges it declares. A path already\n * owned by another node is a collision: the first owner keeps the path and the\n * conflict is reported (the `path-owner-collision` check turns it into a violation).\n */\nfunction addCollection(\n nodes: Map<string, SeoNode>,\n edges: Array<SeoEdge>,\n collisions: Array<{ path: string; sources: ReadonlyArray<SeoSource> }>,\n collection: SeoCollection,\n): void {\n const collectionNode = nodes.get(collection.route);\n const kind = collectionNode?.kind ?? FALLBACK_KIND;\n const sitemap = collectionNode?.policy.sitemap;\n\n /**\n * Paths this collection lost to an earlier owner. Their instances never enter\n * the graph, so any edge declared out of them would dangle — and the dead-edge\n * check only validates an edge's `to`, so nothing downstream would catch it.\n */\n const rejected = new Set<string>();\n\n for (const instance of collection.instances) {\n const existing = nodes.get(instance.path);\n if (existing) {\n collisions.push({ path: instance.path, sources: [existing.source, collection.source] });\n rejected.add(instance.path);\n continue;\n }\n nodes.set(instance.path, {\n path: instance.path,\n kind,\n source: collection.source,\n policy: { kind, sitemap },\n instance: {\n title: instance.title,\n description: instance.description,\n publishedAt: instance.publishedAt,\n modifiedAt: instance.modifiedAt,\n },\n });\n }\n\n for (const edge of collection.edges ?? []) {\n if (rejected.has(edge.from)) continue;\n edges.push(edge);\n }\n}\n\n/**\n * Build the SEO graph from route declarations and content collections.\n *\n * Synchronous: the caller materializes its collections before calling, so there is\n * no async work here. Callers own the origin.\n */\nexport function buildSeoGraph(input: BuildSeoGraphInput): SeoGraph {\n const nodes = new Map<string, SeoNode>();\n const edges: Array<SeoEdge> = [];\n const collisions: Array<{ path: string; sources: ReadonlyArray<SeoSource> }> = [];\n\n walkRoutes(input.routeTree as unknown as WalkableRoute, \"/\", true, nodes, edges, []);\n\n for (const collection of input.collections ?? []) {\n addCollection(nodes, edges, collisions, collection);\n }\n\n for (const node of nodes.values()) {\n if (node.source !== \"route\") continue;\n for (const to of node.policy.related ?? []) {\n edges.push({ from: node.path, to, type: \"related\" });\n }\n if (node.policy.redirectTo !== undefined) {\n edges.push({ from: node.path, to: node.policy.redirectTo, type: \"redirect\" });\n }\n }\n\n return { nodes, edges, collisions };\n}\n","/**\n * Pure candidate generation for contextual cross-links.\n *\n * Given the declared graph, enumerate `(source, destination)` pairs that are\n * *plausible* contextual links and *not already connected*. A pair is plausible\n * when the two pages sit in the same cluster: a shared top-level section (e.g.\n * `/blog/a` and `/blog/b` under `/blog`) or, for root-level pages that share no\n * section, the same `kind`. Only sitemap-eligible pages are candidates — a\n * noindex, redirect, or param template is not a link surface.\n *\n * The plan is a proposal, never an application: nothing here writes a\n * declaration. Effect- and framework-free so it runs in the CLI, a Worker, or a\n * test on the same graph.\n */\n\nimport type { SeoGraph, SeoNode } from \"./graph\";\nimport type { AnchorRegion, SimpleEdge } from \"./links\";\nimport { isSitemapEligible } from \"./projections\";\n\n/** One proposed contextual link, in the direction the link would be authored. */\nexport interface LinkCandidatePair {\n readonly source: string;\n readonly destination: string;\n /** The cluster the pair shares: a top-level section, or `kind:<kind>`. */\n readonly cluster: string;\n /** Why the pair is plausible, e.g. `same top-level section \"/blog\"`. */\n readonly reason: string;\n}\n\nexport interface LinkCandidateOptions {\n /** Maximum candidates to return after ordering (default 50). */\n readonly limit?: number;\n /** Restrict to these clusters; a filter may be a section or a bare kind. */\n readonly clusters?: ReadonlyArray<string>;\n /** Anchors already served in HTML. Only body-region edges exclude their own direction. */\n readonly renderedEdges?: ReadonlyArray<SimpleEdge & { readonly region?: AnchorRegion }>;\n}\n\n/** One cluster and how many candidate pairs it contributes (before `limit`). */\nexport interface LinkClusterSummary {\n readonly key: string;\n readonly candidates: number;\n}\n\nexport interface LinkCandidateResult {\n readonly candidates: ReadonlyArray<LinkCandidatePair>;\n /** Candidate pairs before `limit` was applied (after cluster filters). */\n readonly total: number;\n readonly truncated: boolean;\n readonly clusters: ReadonlyArray<LinkClusterSummary>;\n}\n\n/**\n * Canonical path key: query and hash stripped, trailing slashes removed, \"/\"\n * preserved. This is the same path key the graph and the rendered-link core\n * use, so a served anchor like `/blog/a?ref=nav` still matches the graph pair\n * `/blog/a`.\n */\nconst normalize = (path: string): string => {\n const pathname = path.split(/[?#]/, 1)[0] ?? \"\";\n const trimmed = pathname.replace(/\\/+$/, \"\");\n return trimmed === \"\" ? \"/\" : trimmed;\n};\n\n/** Directionless pair key, so an edge in either direction means \"connected\". */\nexport const undirectedEdgeKey = (from: string, to: string): string => {\n const [a, b] = [normalize(from), normalize(to)].sort();\n return `${a}\\u0000${b}`;\n};\n\nconst directedEdgeKey = (from: string, to: string): string => `${normalize(from)}\\u0000${normalize(to)}`;\n\n/** First path segment, or undefined for the root page. */\nconst topSegment = (path: string): string | undefined => {\n const segment = path.split(\"/\").filter(Boolean)[0];\n return segment === undefined || segment === \"\" ? undefined : segment;\n};\n\n/** A page with no nested segment (`/`, `/pricing`, `/blog`) is root-level. */\nconst isRootLevel = (path: string): boolean => path.split(\"/\").filter(Boolean).length <= 1;\n\n/**\n * The cluster a pair shares, or undefined when they share none. Section-first:\n * a shared top-level section wins. The kind fallback is local to root-level\n * pages, so it never pairs two nested pages from different sections.\n */\nexport const candidateClusterOf = (a: SeoNode, b: SeoNode): { key: string; reason: string } | undefined => {\n const aSegment = topSegment(a.path);\n const bSegment = topSegment(b.path);\n if (aSegment !== undefined && aSegment === bSegment) {\n return { key: aSegment, reason: `same top-level section \"/${aSegment}\"` };\n }\n if (isRootLevel(a.path) && isRootLevel(b.path) && a.kind === b.kind) {\n return { key: `kind:${a.kind}`, reason: `same kind \"${a.kind}\"` };\n }\n return undefined;\n};\n\n/** A `--cluster` filter matches a section by name, or a kind via its bare label. */\nexport const matchesClusterFilter = (cluster: string, filter: string): boolean => {\n const normalized = filter.replace(/^\\/+/, \"\").toLowerCase();\n const key = cluster.toLowerCase();\n return key === normalized || key === `kind:${normalized}`;\n};\n\n/** Human reason string for a candidate's source page, used when handing a plan to Jev. */\nexport const candidateSourceText = (node: SeoNode): string =>\n node.instance?.description?.trim() ||\n node.instance?.title?.trim() ||\n node.policy.link?.description?.trim() ||\n node.policy.link?.title?.trim() ||\n node.path;\n\n/**\n * Enumerate reviewable contextual-link candidates from the declared graph.\n *\n * Excluded: self-pairs, pages that are not sitemap-eligible, pairs already\n * declared as a `related` edge, and — when {@link LinkCandidateOptions.renderedEdges}\n * is supplied — pairs already rendered as an anchor. Queries, hashes, and\n * trailing slashes are normalized so both sides compare by the graph's path key.\n */\nexport const generateLinkCandidates = (\n graph: SeoGraph,\n options: LinkCandidateOptions = {},\n): LinkCandidateResult => {\n const limit = options.limit ?? 50;\n const filters = options.clusters ?? [];\n // Legacy bare edges have no region. Treat them as body links so a v1 edge dump\n // remains conservative; region-aware artifacts retain nav/header/footer edges.\n const rendered = new Set(\n (options.renderedEdges ?? []).filter((edge) => edge.region === undefined || edge.region === \"body\")\n .map((edge) => directedEdgeKey(edge.from, edge.to)),\n );\n const declared = new Set(\n graph.edges\n .filter((edge) => edge.type === \"related\")\n .map((edge) => directedEdgeKey(edge.from, edge.to)),\n );\n\n const eligible = [...graph.nodes.values()].filter(isSitemapEligible);\n\n const all: Array<LinkCandidatePair> = [];\n for (let i = 0; i < eligible.length; i++) {\n for (let j = i + 1; j < eligible.length; j++) {\n const a = eligible[i]!;\n const b = eligible[j]!;\n const cluster = candidateClusterOf(a, b);\n if (cluster === undefined) continue;\n if (filters.length > 0 && !filters.some((filter) => matchesClusterFilter(cluster.key, filter))) {\n continue;\n }\n for (const [source, destination] of [[a.path, b.path], [b.path, a.path]]) {\n const key = directedEdgeKey(source!, destination!);\n if (declared.has(key) || rendered.has(key)) continue;\n all.push({ source: source!, destination: destination!, cluster: cluster.key, reason: cluster.reason });\n }\n }\n }\n\n const compare = (x: LinkCandidatePair, y: LinkCandidatePair): number =>\n x.cluster < y.cluster\n ? -1\n : x.cluster > y.cluster\n ? 1\n : x.source < y.source\n ? -1\n : x.source > y.source\n ? 1\n : x.destination < y.destination\n ? -1\n : x.destination > y.destination\n ? 1\n : 0;\n all.sort(compare);\n\n const counts = new Map<string, number>();\n for (const pair of all) counts.set(pair.cluster, (counts.get(pair.cluster) ?? 0) + 1);\n\n const candidates = all.slice(0, limit);\n return {\n candidates,\n total: all.length,\n truncated: all.length > candidates.length,\n clusters: [...counts.entries()].map(([key, count]) => ({ key, candidates: count })),\n };\n};\n\n/**\n * Decode a rendered-edge dump supplied to the CLI: either a bare array of\n * `{ from, to }` edges, or an object carrying `edges` (or `internalEdges`).\n * Throws on any other shape — this is user input, not a provider payload.\n */\nexport const decodeRenderedEdges = (input: unknown): ReadonlyArray<SimpleEdge & { readonly region?: AnchorRegion }> => {\n const array =\n Array.isArray(input)\n ? input\n : input !== null && typeof input === \"object\" && Array.isArray((input as { edges?: unknown }).edges)\n ? (input as { edges: ReadonlyArray<unknown> }).edges\n : input !== null &&\n typeof input === \"object\" &&\n Array.isArray((input as { internalEdges?: unknown }).internalEdges)\n ? (input as { internalEdges: ReadonlyArray<unknown> }).internalEdges\n : undefined;\n\n if (array === undefined) {\n throw new Error(\n \"expected an array of { from, to } edges, or an object with an `edges` array\",\n );\n }\n\n return array.map((item) => {\n if (item === null || typeof item !== \"object\") {\n throw new Error(\"each rendered edge must be an object with string `from` and `to`\");\n }\n const { from, to, region } = item as { from?: unknown; to?: unknown; region?: unknown };\n if (typeof from !== \"string\" || typeof to !== \"string\") {\n throw new Error(\"each rendered edge must have string `from` and `to`\");\n }\n if (region !== undefined && ![\"nav\", \"header\", \"footer\", \"body\"].includes(region as string)) {\n throw new Error(\"rendered edge region must be nav, header, footer, or body\");\n }\n return region === undefined ? { from, to } : { from, to, region: region as AnchorRegion };\n });\n};\n","import type { AnyRoute, AnyRouter } from \"@tanstack/react-router\";\n\nimport type { RouteSeo } from \"./declare\";\n\ninterface RouteMaps {\n routesByPath: Record<string, AnyRoute | undefined>;\n routesById: Record<string, AnyRoute>;\n}\n\n/**\n * Resolve a route's declared `seo.link` card (title + description) by its full\n * path.\n *\n * `useRouter()` returns a Router typed to this app's exact route tree, so\n * `routesByPath` is keyed by the literal `FileRouteTypes[\"fullPaths\"]` union —\n * but callers here (declared `related` targets) hold arbitrary runtime path\n * strings, not that literal type. `routesByPath` and `routesById` are plain\n * Records on every Router instance regardless of which route tree it's\n * parameterized over, so this narrows to that structural shape once, at this\n * boundary, instead of threading an `AnyRoute` cast through every call site.\n */\nexport function resolveRouteLink(router: AnyRouter, path: string): RouteSeo[\"link\"] {\n const { routesByPath, routesById } = router as unknown as RouteMaps;\n\n const direct = routesByPath[path];\n if (direct) return direct.options.staticData?.seo?.link;\n\n const byFullPath = Object.values(routesById).find((route) => route.fullPath === path);\n return byFullPath?.options.staticData?.seo?.link;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/core/collections.ts","../src/core/link-candidates.ts","../src/core/resolve-route-link.ts"],"sourcesContent":["/**\n * Content collections — pages a param route renders from content (MDX, a CMS,\n * a manifest) — in the shape the graph reads. Works with any source whose\n * pages carry a URL, which covers Fumadocs `loader().getPages()` directly.\n */\n\nimport type { PublicPath } from \"./declare\";\nimport type { SeoCollection, SeoEdge, SeoInstance, SeoSource } from \"./graph\";\n\n/** What a page contributes: its instance metadata plus the paths it links to deliberately. */\nexport interface ContentEntry extends Omit<SeoInstance, \"path\"> {\n /** Defaults to the page's `url`. */\n readonly path?: string | undefined;\n /** Deliberate cross-links, emitted as `related` edges from this page. */\n readonly related?: ReadonlyArray<string> | undefined;\n}\n\nexport interface ContentCollectionOptions<Page> {\n /** The param route these pages render through, e.g. `/blog/$slug`. */\n readonly route: PublicPath;\n /** The `source` label stamped on every node, e.g. `\"blog\"`. */\n readonly source: SeoSource;\n readonly pages: ReadonlyArray<Page>;\n /** Map one page to its entry; decode frontmatter here. */\n readonly entry: (page: Page) => ContentEntry;\n /** Edges beyond `related` (crumb parents, collection membership). */\n readonly edges?: ReadonlyArray<SeoEdge> | undefined;\n}\n\nexport function contentCollection<Page extends { readonly url: string }>(\n options: ContentCollectionOptions<Page>,\n): SeoCollection {\n const entries = options.pages.map((page) => {\n const entry = options.entry(page);\n return { ...entry, path: entry.path ?? page.url };\n });\n return {\n route: options.route,\n source: options.source,\n instances: entries.map(({ related: _related, ...instance }) => instance),\n edges: [\n ...entries.flatMap((entry) =>\n [...new Set(entry.related ?? [])].map((to): SeoEdge => ({ from: entry.path, to, type: \"related\" })),\n ),\n ...(options.edges ?? []),\n ],\n };\n}\n","/**\n * Pure candidate generation for contextual cross-links.\n *\n * Given the declared graph, enumerate `(source, destination)` pairs that are\n * *plausible* contextual links and *not already connected*. A pair is plausible\n * when the two pages sit in the same cluster: a shared top-level section (e.g.\n * `/blog/a` and `/blog/b` under `/blog`) or, for root-level pages that share no\n * section, the same `kind`. Only sitemap-eligible pages are candidates — a\n * noindex, redirect, or param template is not a link surface.\n *\n * The plan is a proposal, never an application: nothing here writes a\n * declaration. Effect- and framework-free so it runs in the CLI, a Worker, or a\n * test on the same graph.\n */\n\nimport type { SeoGraph, SeoNode } from \"./graph\";\nimport type { AnchorRegion, SimpleEdge } from \"./links\";\nimport { isSitemapEligible } from \"./projections\";\n\n/** One proposed contextual link, in the direction the link would be authored. */\nexport interface LinkCandidatePair {\n readonly source: string;\n readonly destination: string;\n /** The cluster the pair shares: a top-level section, or `kind:<kind>`. */\n readonly cluster: string;\n /** Why the pair is plausible, e.g. `same top-level section \"/blog\"`. */\n readonly reason: string;\n}\n\nexport interface LinkCandidateOptions {\n /** Maximum candidates to return after ordering (default 50). */\n readonly limit?: number;\n /** Restrict to these clusters; a filter may be a section or a bare kind. */\n readonly clusters?: ReadonlyArray<string>;\n /** Anchors already served in HTML. Only body-region edges exclude their own direction. */\n readonly renderedEdges?: ReadonlyArray<SimpleEdge & { readonly region?: AnchorRegion }>;\n}\n\n/** One cluster and how many candidate pairs it contributes (before `limit`). */\nexport interface LinkClusterSummary {\n readonly key: string;\n readonly candidates: number;\n}\n\nexport interface LinkCandidateResult {\n readonly candidates: ReadonlyArray<LinkCandidatePair>;\n /** Candidate pairs before `limit` was applied (after cluster filters). */\n readonly total: number;\n readonly truncated: boolean;\n readonly clusters: ReadonlyArray<LinkClusterSummary>;\n}\n\n/**\n * Canonical path key: query and hash stripped, trailing slashes removed, \"/\"\n * preserved. This is the same path key the graph and the rendered-link core\n * use, so a served anchor like `/blog/a?ref=nav` still matches the graph pair\n * `/blog/a`.\n */\nconst normalize = (path: string): string => {\n const pathname = path.split(/[?#]/, 1)[0] ?? \"\";\n const trimmed = pathname.replace(/\\/+$/, \"\");\n return trimmed === \"\" ? \"/\" : trimmed;\n};\n\n/** Directionless pair key, so an edge in either direction means \"connected\". */\nexport const undirectedEdgeKey = (from: string, to: string): string => {\n const [a, b] = [normalize(from), normalize(to)].sort();\n return `${a}\\u0000${b}`;\n};\n\nconst directedEdgeKey = (from: string, to: string): string => `${normalize(from)}\\u0000${normalize(to)}`;\n\n/** First path segment, or undefined for the root page. */\nconst topSegment = (path: string): string | undefined => {\n const segment = path.split(\"/\").filter(Boolean)[0];\n return segment === undefined || segment === \"\" ? undefined : segment;\n};\n\n/** A page with no nested segment (`/`, `/pricing`, `/blog`) is root-level. */\nconst isRootLevel = (path: string): boolean => path.split(\"/\").filter(Boolean).length <= 1;\n\n/**\n * The cluster a pair shares, or undefined when they share none. Section-first:\n * a shared top-level section wins. The kind fallback is local to root-level\n * pages, so it never pairs two nested pages from different sections.\n */\nexport const candidateClusterOf = (a: SeoNode, b: SeoNode): { key: string; reason: string } | undefined => {\n const aSegment = topSegment(a.path);\n const bSegment = topSegment(b.path);\n if (aSegment !== undefined && aSegment === bSegment) {\n return { key: aSegment, reason: `same top-level section \"/${aSegment}\"` };\n }\n if (isRootLevel(a.path) && isRootLevel(b.path) && a.kind === b.kind) {\n return { key: `kind:${a.kind}`, reason: `same kind \"${a.kind}\"` };\n }\n return undefined;\n};\n\n/** A `--cluster` filter matches a section by name, or a kind via its bare label. */\nexport const matchesClusterFilter = (cluster: string, filter: string): boolean => {\n const normalized = filter.replace(/^\\/+/, \"\").toLowerCase();\n const key = cluster.toLowerCase();\n return key === normalized || key === `kind:${normalized}`;\n};\n\n/** Human reason string for a candidate's source page, used when handing a plan to Jev. */\nexport const candidateSourceText = (node: SeoNode): string =>\n node.instance?.description?.trim() ||\n node.instance?.title?.trim() ||\n node.policy.link?.description?.trim() ||\n node.policy.link?.title?.trim() ||\n node.path;\n\n/**\n * Enumerate reviewable contextual-link candidates from the declared graph.\n *\n * Excluded: self-pairs, pages that are not sitemap-eligible, pairs already\n * declared as a `related` edge, and — when {@link LinkCandidateOptions.renderedEdges}\n * is supplied — pairs already rendered as an anchor. Queries, hashes, and\n * trailing slashes are normalized so both sides compare by the graph's path key.\n */\nexport const generateLinkCandidates = (\n graph: SeoGraph,\n options: LinkCandidateOptions = {},\n): LinkCandidateResult => {\n const limit = options.limit ?? 50;\n const filters = options.clusters ?? [];\n // Legacy bare edges have no region. Treat them as body links so a v1 edge dump\n // remains conservative; region-aware artifacts retain nav/header/footer edges.\n const rendered = new Set(\n (options.renderedEdges ?? []).filter((edge) => edge.region === undefined || edge.region === \"body\")\n .map((edge) => directedEdgeKey(edge.from, edge.to)),\n );\n const declared = new Set(\n graph.edges\n .filter((edge) => edge.type === \"related\")\n .map((edge) => directedEdgeKey(edge.from, edge.to)),\n );\n\n const eligible = [...graph.nodes.values()].filter(isSitemapEligible);\n\n const all: Array<LinkCandidatePair> = [];\n for (let i = 0; i < eligible.length; i++) {\n for (let j = i + 1; j < eligible.length; j++) {\n const a = eligible[i]!;\n const b = eligible[j]!;\n const cluster = candidateClusterOf(a, b);\n if (cluster === undefined) continue;\n if (filters.length > 0 && !filters.some((filter) => matchesClusterFilter(cluster.key, filter))) {\n continue;\n }\n for (const [source, destination] of [[a.path, b.path], [b.path, a.path]]) {\n const key = directedEdgeKey(source!, destination!);\n if (declared.has(key) || rendered.has(key)) continue;\n all.push({ source: source!, destination: destination!, cluster: cluster.key, reason: cluster.reason });\n }\n }\n }\n\n const compare = (x: LinkCandidatePair, y: LinkCandidatePair): number =>\n x.cluster < y.cluster\n ? -1\n : x.cluster > y.cluster\n ? 1\n : x.source < y.source\n ? -1\n : x.source > y.source\n ? 1\n : x.destination < y.destination\n ? -1\n : x.destination > y.destination\n ? 1\n : 0;\n all.sort(compare);\n\n const counts = new Map<string, number>();\n for (const pair of all) counts.set(pair.cluster, (counts.get(pair.cluster) ?? 0) + 1);\n\n const candidates = all.slice(0, limit);\n return {\n candidates,\n total: all.length,\n truncated: all.length > candidates.length,\n clusters: [...counts.entries()].map(([key, count]) => ({ key, candidates: count })),\n };\n};\n\n/**\n * Decode a rendered-edge dump supplied to the CLI: either a bare array of\n * `{ from, to }` edges, or an object carrying `edges` (or `internalEdges`).\n * Throws on any other shape — this is user input, not a provider payload.\n */\nexport const decodeRenderedEdges = (input: unknown): ReadonlyArray<SimpleEdge & { readonly region?: AnchorRegion }> => {\n const array =\n Array.isArray(input)\n ? input\n : input !== null && typeof input === \"object\" && Array.isArray((input as { edges?: unknown }).edges)\n ? (input as { edges: ReadonlyArray<unknown> }).edges\n : input !== null &&\n typeof input === \"object\" &&\n Array.isArray((input as { internalEdges?: unknown }).internalEdges)\n ? (input as { internalEdges: ReadonlyArray<unknown> }).internalEdges\n : undefined;\n\n if (array === undefined) {\n throw new Error(\n \"expected an array of { from, to } edges, or an object with an `edges` array\",\n );\n }\n\n return array.map((item) => {\n if (item === null || typeof item !== \"object\") {\n throw new Error(\"each rendered edge must be an object with string `from` and `to`\");\n }\n const { from, to, region } = item as { from?: unknown; to?: unknown; region?: unknown };\n if (typeof from !== \"string\" || typeof to !== \"string\") {\n throw new Error(\"each rendered edge must have string `from` and `to`\");\n }\n if (region !== undefined && ![\"nav\", \"header\", \"footer\", \"body\"].includes(region as string)) {\n throw new Error(\"rendered edge region must be nav, header, footer, or body\");\n }\n return region === undefined ? { from, to } : { from, to, region: region as AnchorRegion };\n });\n};\n","import type { AnyRoute, AnyRouter } from \"@tanstack/react-router\";\n\nimport type { RouteSeo } from \"./declare\";\n\ninterface RouteMaps {\n routesByPath: Record<string, AnyRoute | undefined>;\n routesById: Record<string, AnyRoute>;\n}\n\n/**\n * Resolve a route's declared `seo.link` card (title + description) by its full\n * path.\n *\n * `useRouter()` returns a Router typed to this app's exact route tree, so\n * `routesByPath` is keyed by the literal `FileRouteTypes[\"fullPaths\"]` union —\n * but callers here (declared `related` targets) hold arbitrary runtime path\n * strings, not that literal type. `routesByPath` and `routesById` are plain\n * Records on every Router instance regardless of which route tree it's\n * parameterized over, so this narrows to that structural shape once, at this\n * boundary, instead of threading an `AnyRoute` cast through every call site.\n */\nexport function resolveRouteLink(router: AnyRouter, path: string): RouteSeo[\"link\"] {\n const { routesByPath, routesById } = router as unknown as RouteMaps;\n\n const direct = routesByPath[path];\n if (direct) return direct.options.staticData?.seo?.link;\n\n const byFullPath = Object.values(routesById).find((route) => route.fullPath === path);\n return byFullPath?.options.staticData?.seo?.link;\n}\n"],"mappings":";;;;;;;;AA6BA,SAAgB,kBACd,SACe;CACf,MAAM,UAAU,QAAQ,MAAM,KAAK,SAAS;EAC1C,MAAM,QAAQ,QAAQ,MAAM,IAAI;EAChC,OAAO;GAAE,GAAG;GAAO,MAAM,MAAM,QAAQ,KAAK;EAAI;CAClD,CAAC;CACD,OAAO;EACL,OAAO,QAAQ;EACf,QAAQ,QAAQ;EAChB,WAAW,QAAQ,KAAK,EAAE,SAAS,UAAU,GAAG,eAAe,QAAQ;EACvE,OAAO,CACL,GAAG,QAAQ,SAAS,UAClB,CAAC,GAAG,IAAI,IAAI,MAAM,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,QAAiB;GAAE,MAAM,MAAM;GAAM;GAAI,MAAM;EAAU,EAAE,CACpG,GACA,GAAI,QAAQ,SAAS,CAAC,CACxB;CACF;AACF;;;;;;;;;ACWA,MAAM,aAAa,SAAyB;CAE1C,MAAM,WADW,KAAK,MAAM,QAAQ,CAAC,CAAC,CAAC,MAAM,GAAA,CACpB,QAAQ,QAAQ,EAAE;CAC3C,OAAO,YAAY,KAAK,MAAM;AAChC;;AAGA,MAAa,qBAAqB,MAAc,OAAuB;CACrE,MAAM,CAAC,GAAG,KAAK,CAAC,UAAU,IAAI,GAAG,UAAU,EAAE,CAAC,CAAC,CAAC,KAAK;CACrD,OAAO,GAAG,EAAE,QAAQ;AACtB;AAEA,MAAM,mBAAmB,MAAc,OAAuB,GAAG,UAAU,IAAI,EAAE,QAAQ,UAAU,EAAE;;AAGrG,MAAM,cAAc,SAAqC;CACvD,MAAM,UAAU,KAAK,MAAM,GAAG,CAAC,CAAC,OAAO,OAAO,CAAC,CAAC;CAChD,OAAO,YAAY,KAAA,KAAa,YAAY,KAAK,KAAA,IAAY;AAC/D;;AAGA,MAAM,eAAe,SAA0B,KAAK,MAAM,GAAG,CAAC,CAAC,OAAO,OAAO,CAAC,CAAC,UAAU;;;;;;AAOzF,MAAa,sBAAsB,GAAY,MAA4D;CACzG,MAAM,WAAW,WAAW,EAAE,IAAI;CAClC,MAAM,WAAW,WAAW,EAAE,IAAI;CAClC,IAAI,aAAa,KAAA,KAAa,aAAa,UACzC,OAAO;EAAE,KAAK;EAAU,QAAQ,4BAA4B,SAAS;CAAG;CAE1E,IAAI,YAAY,EAAE,IAAI,KAAK,YAAY,EAAE,IAAI,KAAK,EAAE,SAAS,EAAE,MAC7D,OAAO;EAAE,KAAK,QAAQ,EAAE;EAAQ,QAAQ,cAAc,EAAE,KAAK;CAAG;AAGpE;;AAGA,MAAa,wBAAwB,SAAiB,WAA4B;CAChF,MAAM,aAAa,OAAO,QAAQ,QAAQ,EAAE,CAAC,CAAC,YAAY;CAC1D,MAAM,MAAM,QAAQ,YAAY;CAChC,OAAO,QAAQ,cAAc,QAAQ,QAAQ;AAC/C;;AAGA,MAAa,uBAAuB,SAClC,KAAK,UAAU,aAAa,KAAK,KACjC,KAAK,UAAU,OAAO,KAAK,KAC3B,KAAK,OAAO,MAAM,aAAa,KAAK,KACpC,KAAK,OAAO,MAAM,OAAO,KAAK,KAC9B,KAAK;;;;;;;;;AAUP,MAAa,0BACX,OACA,UAAgC,CAAC,MACT;CACxB,MAAM,QAAQ,QAAQ,SAAS;CAC/B,MAAM,UAAU,QAAQ,YAAY,CAAC;CAGrC,MAAM,WAAW,IAAI,KAClB,QAAQ,iBAAiB,CAAC,EAAA,CAAG,QAAQ,SAAS,KAAK,WAAW,KAAA,KAAa,KAAK,WAAW,MAAM,CAAC,CAChG,KAAK,SAAS,gBAAgB,KAAK,MAAM,KAAK,EAAE,CAAC,CACtD;CACA,MAAM,WAAW,IAAI,IACnB,MAAM,MACH,QAAQ,SAAS,KAAK,SAAS,SAAS,CAAC,CACzC,KAAK,SAAS,gBAAgB,KAAK,MAAM,KAAK,EAAE,CAAC,CACtD;CAEA,MAAM,WAAW,CAAC,GAAG,MAAM,MAAM,OAAO,CAAC,CAAC,CAAC,OAAO,iBAAiB;CAEnE,MAAM,MAAgC,CAAC;CACvC,KAAK,IAAI,IAAI,GAAG,IAAI,SAAS,QAAQ,KACnC,KAAK,IAAI,IAAI,IAAI,GAAG,IAAI,SAAS,QAAQ,KAAK;EAC5C,MAAM,IAAI,SAAS;EACnB,MAAM,IAAI,SAAS;EACnB,MAAM,UAAU,mBAAmB,GAAG,CAAC;EACvC,IAAI,YAAY,KAAA,GAAW;EAC3B,IAAI,QAAQ,SAAS,KAAK,CAAC,QAAQ,MAAM,WAAW,qBAAqB,QAAQ,KAAK,MAAM,CAAC,GAC3F;EAEF,KAAK,MAAM,CAAC,QAAQ,gBAAgB,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,GAAG,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,GAAG;GACxE,MAAM,MAAM,gBAAgB,QAAS,WAAY;GACjD,IAAI,SAAS,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,GAAG;GAC5C,IAAI,KAAK;IAAU;IAAsB;IAAc,SAAS,QAAQ;IAAK,QAAQ,QAAQ;GAAO,CAAC;EACvG;CACF;CAGF,MAAM,WAAW,GAAsB,MACrC,EAAE,UAAU,EAAE,UACV,KACA,EAAE,UAAU,EAAE,UACZ,IACA,EAAE,SAAS,EAAE,SACX,KACA,EAAE,SAAS,EAAE,SACX,IACA,EAAE,cAAc,EAAE,cAChB,KACA,EAAE,cAAc,EAAE,cAChB,IACA;CAChB,IAAI,KAAK,OAAO;CAEhB,MAAM,yBAAS,IAAI,IAAoB;CACvC,KAAK,MAAM,QAAQ,KAAK,OAAO,IAAI,KAAK,UAAU,OAAO,IAAI,KAAK,OAAO,KAAK,KAAK,CAAC;CAEpF,MAAM,aAAa,IAAI,MAAM,GAAG,KAAK;CACrC,OAAO;EACL;EACA,OAAO,IAAI;EACX,WAAW,IAAI,SAAS,WAAW;EACnC,UAAU,CAAC,GAAG,OAAO,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,YAAY;GAAE;GAAK,YAAY;EAAM,EAAE;CACpF;AACF;;;;;;AAOA,MAAa,uBAAuB,UAAmF;CACrH,MAAM,QACJ,MAAM,QAAQ,KAAK,IACf,QACA,UAAU,QAAQ,OAAO,UAAU,YAAY,MAAM,QAAS,MAA8B,KAAK,IAC9F,MAA4C,QAC7C,UAAU,QACR,OAAO,UAAU,YACjB,MAAM,QAAS,MAAsC,aAAa,IACjE,MAAoD,gBACrD,KAAA;CAEV,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MACR,6EACF;CAGF,OAAO,MAAM,KAAK,SAAS;EACzB,IAAI,SAAS,QAAQ,OAAO,SAAS,UACnC,MAAM,IAAI,MAAM,kEAAkE;EAEpF,MAAM,EAAE,MAAM,IAAI,WAAW;EAC7B,IAAI,OAAO,SAAS,YAAY,OAAO,OAAO,UAC5C,MAAM,IAAI,MAAM,qDAAqD;EAEvE,IAAI,WAAW,KAAA,KAAa,CAAC;GAAC;GAAO;GAAU;GAAU;EAAM,CAAC,CAAC,SAAS,MAAgB,GACxF,MAAM,IAAI,MAAM,2DAA2D;EAE7E,OAAO,WAAW,KAAA,IAAY;GAAE;GAAM;EAAG,IAAI;GAAE;GAAM;GAAY;EAAuB;CAC1F,CAAC;AACH;;;;;;;;;;;;;;;AC1MA,SAAgB,iBAAiB,QAAmB,MAAgC;CAClF,MAAM,EAAE,cAAc,eAAe;CAErC,MAAM,SAAS,aAAa;CAC5B,IAAI,QAAQ,OAAO,OAAO,QAAQ,YAAY,KAAK;CAGnD,OADmB,OAAO,OAAO,UAAU,CAAC,CAAC,MAAM,UAAU,MAAM,aAAa,IAChE,CAAC,EAAE,QAAQ,YAAY,KAAK;AAC9C"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/build-only/message.ts
|
|
2
|
+
/**
|
|
3
|
+
* Build-only entries resolve here under the `workerd`, `worker`, and `browser`
|
|
4
|
+
* export conditions. They load the app through Vite, parse routes with a native
|
|
5
|
+
* parser, or drive Node I/O, so a runtime bundle that reaches one would fail at
|
|
6
|
+
* request time with an unrelated crash. Throwing at import names the mistake.
|
|
7
|
+
*/
|
|
8
|
+
const buildOnlyError = (entry) => /* @__PURE__ */ new Error(`"${entry}" is build-time only and cannot be imported from a Worker or browser bundle. Keep it in vite.config.ts, seo.config.ts, or other build/CLI modules, and import runtime helpers (renderSitemap, renderRobots, pageHeads, the graph types) from "pagegraph".`);
|
|
9
|
+
//#endregion
|
|
10
|
+
export { buildOnlyError as t };
|
|
11
|
+
|
|
12
|
+
//# sourceMappingURL=message-DT_CjUdt.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"message-DT_CjUdt.js","names":[],"sources":["../src/build-only/message.ts"],"sourcesContent":["/**\n * Build-only entries resolve here under the `workerd`, `worker`, and `browser`\n * export conditions. They load the app through Vite, parse routes with a native\n * parser, or drive Node I/O, so a runtime bundle that reaches one would fail at\n * request time with an unrelated crash. Throwing at import names the mistake.\n */\nexport const buildOnlyError = (entry: string): Error =>\n new Error(\n `\"${entry}\" is build-time only and cannot be imported from a Worker or browser bundle. ` +\n `Keep it in vite.config.ts, seo.config.ts, or other build/CLI modules, and import runtime ` +\n `helpers (renderSitemap, renderRobots, pageHeads, the graph types) from \"pagegraph\".`,\n );\n"],"mappings":";;;;;;;AAMA,MAAa,kBAAkB,0BAC7B,IAAI,MACF,IAAI,MAAM,0PAGZ"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { a as globToRegExp } from "./checks-uZk5Em9A.js";
|
|
2
|
+
//#region src/core/page-heads.ts
|
|
3
|
+
/** Select page nodes before the host resolves metadata; sitemap-disabled hubs remain pages. */
|
|
4
|
+
function selectPageHeadNodes(graph, options = {}) {
|
|
5
|
+
const excludes = (options.exclude ?? []).map(globToRegExp);
|
|
6
|
+
return [...graph.nodes.values()].filter((node) => node.kind !== "layout" && (node.policy.sitemap !== void 0 || node.instance !== void 0) && !excludes.some((pattern) => pattern.test(node.path)) && (options.indexable === false || !node.path.includes("$") && node.policy.redirectTo === void 0 && !node.policy.robots?.toLowerCase().split(/[\s,]+/).includes("noindex")));
|
|
7
|
+
}
|
|
8
|
+
/** Extract resolved heads, naming the selected page when metadata is missing or blank. */
|
|
9
|
+
function pageHeads(graph, options = {}) {
|
|
10
|
+
return selectPageHeadNodes(graph, options).map((node) => {
|
|
11
|
+
const title = node.head?.title;
|
|
12
|
+
const description = node.head?.description;
|
|
13
|
+
if (!title?.trim()) throw new Error(`Invalid page head for ${node.path}: missing title`);
|
|
14
|
+
if (!description?.trim()) throw new Error(`Invalid page head for ${node.path}: missing description`);
|
|
15
|
+
return {
|
|
16
|
+
path: node.path,
|
|
17
|
+
title,
|
|
18
|
+
description
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
//#endregion
|
|
23
|
+
export { selectPageHeadNodes as n, pageHeads as t };
|
|
24
|
+
|
|
25
|
+
//# sourceMappingURL=page-heads-6y-CARRQ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"page-heads-6y-CARRQ.js","names":[],"sources":["../src/core/page-heads.ts"],"sourcesContent":["import { globToRegExp } from \"./checks\";\nimport type { SeoGraph, SeoNode } from \"./graph\";\n\n/** Plain document metadata, without framework or schema dependencies. */\nexport interface PageHead {\n readonly path: string;\n readonly title: string;\n readonly description: string;\n}\n\nexport interface PageHeadsOptions {\n /** Path globs: `*` matches a segment, `**` matches across segments. */\n readonly exclude?: ReadonlyArray<string>;\n /** Exclude noindex pages, redirects and parameter templates. Defaults to true. */\n readonly indexable?: boolean;\n}\n\n/** Select page nodes before the host resolves metadata; sitemap-disabled hubs remain pages. */\nexport function selectPageHeadNodes(graph: SeoGraph, options: PageHeadsOptions = {}): Array<SeoNode> {\n const excludes = (options.exclude ?? []).map(globToRegExp);\n return [...graph.nodes.values()].filter((node) =>\n node.kind !== \"layout\" &&\n (node.policy.sitemap !== undefined || node.instance !== undefined) &&\n !excludes.some((pattern) => pattern.test(node.path)) &&\n (options.indexable === false || (\n !node.path.includes(\"$\") &&\n node.policy.redirectTo === undefined &&\n !node.policy.robots?.toLowerCase().split(/[\\s,]+/).includes(\"noindex\")\n )),\n );\n}\n\n/** Extract resolved heads, naming the selected page when metadata is missing or blank. */\nexport function pageHeads(graph: SeoGraph, options: PageHeadsOptions = {}): Array<PageHead> {\n return selectPageHeadNodes(graph, options).map((node) => {\n const title = node.head?.title;\n const description = node.head?.description;\n if (!title?.trim()) throw new Error(`Invalid page head for ${node.path}: missing title`);\n if (!description?.trim()) throw new Error(`Invalid page head for ${node.path}: missing description`);\n return { path: node.path, title, description };\n });\n}\n"],"mappings":";;;AAkBA,SAAgB,oBAAoB,OAAiB,UAA4B,CAAC,GAAmB;CACnG,MAAM,YAAY,QAAQ,WAAW,CAAC,EAAA,CAAG,IAAI,YAAY;CACzD,OAAO,CAAC,GAAG,MAAM,MAAM,OAAO,CAAC,CAAC,CAAC,QAAQ,SACvC,KAAK,SAAS,aACb,KAAK,OAAO,YAAY,KAAA,KAAa,KAAK,aAAa,KAAA,MACxD,CAAC,SAAS,MAAM,YAAY,QAAQ,KAAK,KAAK,IAAI,CAAC,MAClD,QAAQ,cAAc,SACrB,CAAC,KAAK,KAAK,SAAS,GAAG,KACvB,KAAK,OAAO,eAAe,KAAA,KAC3B,CAAC,KAAK,OAAO,QAAQ,YAAY,CAAC,CAAC,MAAM,QAAQ,CAAC,CAAC,SAAS,SAAS,EAEzE;AACF;;AAGA,SAAgB,UAAU,OAAiB,UAA4B,CAAC,GAAoB;CAC1F,OAAO,oBAAoB,OAAO,OAAO,CAAC,CAAC,KAAK,SAAS;EACvD,MAAM,QAAQ,KAAK,MAAM;EACzB,MAAM,cAAc,KAAK,MAAM;EAC/B,IAAI,CAAC,OAAO,KAAK,GAAG,MAAM,IAAI,MAAM,yBAAyB,KAAK,KAAK,gBAAgB;EACvF,IAAI,CAAC,aAAa,KAAK,GAAG,MAAM,IAAI,MAAM,yBAAyB,KAAK,KAAK,sBAAsB;EACnG,OAAO;GAAE,MAAM,KAAK;GAAM;GAAO;EAAY;CAC/C,CAAC;AACH"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { a as SeoGraph, s as SeoNode } from "./graph-BxCY90vq.js";
|
|
2
|
+
//#region src/core/page-heads.d.ts
|
|
3
|
+
/** Plain document metadata, without framework or schema dependencies. */
|
|
4
|
+
interface PageHead {
|
|
5
|
+
readonly path: string;
|
|
6
|
+
readonly title: string;
|
|
7
|
+
readonly description: string;
|
|
8
|
+
}
|
|
9
|
+
interface PageHeadsOptions {
|
|
10
|
+
/** Path globs: `*` matches a segment, `**` matches across segments. */
|
|
11
|
+
readonly exclude?: ReadonlyArray<string>;
|
|
12
|
+
/** Exclude noindex pages, redirects and parameter templates. Defaults to true. */
|
|
13
|
+
readonly indexable?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** Select page nodes before the host resolves metadata; sitemap-disabled hubs remain pages. */
|
|
16
|
+
declare function selectPageHeadNodes(graph: SeoGraph, options?: PageHeadsOptions): Array<SeoNode>;
|
|
17
|
+
/** Extract resolved heads, naming the selected page when metadata is missing or blank. */
|
|
18
|
+
declare function pageHeads(graph: SeoGraph, options?: PageHeadsOptions): Array<PageHead>;
|
|
19
|
+
//#endregion
|
|
20
|
+
export { selectPageHeadNodes as i, PageHeadsOptions as n, pageHeads as r, PageHead as t };
|
|
21
|
+
//# sourceMappingURL=page-heads-DNkjMUEN.d.ts.map
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
export { parseDeclaredDate as a, lastModified as i, inspectNode as n, renderRobots as o, isSitemapEligible as r, renderSitemap as s, contentSignal as t };
|
|
141
|
+
|
|
142
|
+
//# sourceMappingURL=projections-D0oOFKiY.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"projections-D0oOFKiY.js","names":[],"sources":["../src/core/projections.ts"],"sourcesContent":["/**\n * Projections of the SEO graph: sitemap.xml, robots.txt, and single-node\n * inspection. Pure functions — the origin, the host's indexability, and the\n * robots disallow list are injected by the caller, never read from the\n * environment or a generated file.\n */\n\nimport type { SeoEdge, SeoGraph, SeoNode } from \"./graph\";\n\nexport interface ProjectionConfig {\n origin: string;\n indexable: boolean;\n}\n\nexport interface RobotsConfig extends ProjectionConfig {\n /** Path prefixes to disallow on an indexable host (e.g. the app-only groups). */\n disallow: ReadonlyArray<string>;\n /**\n * Origin-wide Content-Signal preferences\n * (https://contentsignals.org/), emitted as `Content-Signal: <value>` under\n * `User-agent: *` on an indexable host. Omit for none. The plugin never\n * invents a default — pass the policy you want, e.g.\n * `\"search=yes, ai-input=yes, ai-train=yes\"`.\n */\n contentSignal?: string | undefined;\n /**\n * Extra full lines in the indexable `User-agent: *` group, after\n * Content-Signal (if any) and before Allow/Disallow. Use for directives the\n * plugin does not model, or to compose {@link contentSignal} yourself.\n */\n directives?: ReadonlyArray<string> | undefined;\n /**\n * Last-mile override: receives the rendered robots.txt and returns the file\n * to emit. Runs for indexable and preview hosts. Use when you need to wrap\n * or replace the default body rather than add group lines.\n */\n transform?: ((robots: string) => string) | undefined;\n}\n\nexport interface NodeReport {\n node: SeoNode;\n inSitemap: boolean;\n incoming: Array<SeoEdge>;\n outgoing: Array<SeoEdge>;\n}\n\nconst escapeXml = (value: string): string =>\n value\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n\n/**\n * A node belongs in the sitemap when it declares a positive sitemap policy, is not\n * a redirect, is not robots-noindexed, and is not a param template (a route whose\n * path still contains a `$` segment — those exist only so their instances inherit).\n */\nexport function isSitemapEligible(node: SeoNode): boolean {\n if (node.policy.redirectTo !== undefined) return false;\n if (node.policy.robots?.includes(\"noindex\")) return false;\n if (!node.policy.sitemap) return false; // false or absent\n if (node.path.includes(\"$\")) return false;\n return true;\n}\n\n/** Canonical absolute URL for a node under the given origin. */\nfunction urlForNode(origin: string, node: SeoNode): string {\n return node.path === \"/\" ? origin : `${origin}${node.path}`;\n}\n\n/**\n * The date a node's content last changed, as declared: an instance's\n * `modifiedAt`, else its `publishedAt`; a route's own `modifiedAt`. A node with\n * no date (a docs page without frontmatter dates, an undated route) returns\n * undefined, and the sitemap omits its `<lastmod>`.\n */\nexport function lastModified(node: SeoNode): string | undefined {\n if (node.instance) return node.instance.modifiedAt ?? node.instance.publishedAt;\n return node.policy.modifiedAt;\n}\n\n/** An ISO 8601 date, or a date-time that states its offset. */\nconst ISO_DATE = /^(\\d{4})-(\\d{2})-(\\d{2})(?:T\\d{2}:\\d{2}(?::\\d{2}(?:\\.\\d+)?)?(?:Z|[+-]\\d{2}:\\d{2}))?$/;\n\n/**\n * Parse a declared date to epoch milliseconds, or undefined when it is not an\n * ISO 8601 date (`2026-09-29`) or offset date-time (`2026-09-29T10:00:00Z`) on a\n * real calendar day. `Date.parse` alone would accept \"Sep 29\" in the host's\n * zone and roll \"2026-02-30\" into March; either would publish a wrong\n * `<lastmod>`. The `invalid-date` check reports the undefined case, so\n * projections that meet one fail loudly.\n */\nexport function parseDeclaredDate(value: string): number | undefined {\n const match = ISO_DATE.exec(value);\n if (match === null) return undefined;\n const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];\n const calendar = new Date(Date.UTC(year, month - 1, day));\n if (calendar.getUTCMonth() !== month - 1 || calendar.getUTCDate() !== day) return undefined;\n const time = Date.parse(value);\n return Number.isNaN(time) ? undefined : time;\n}\n\nfunction sitemapLastmod(node: SeoNode): string | undefined {\n const date = lastModified(node);\n if (date === undefined) return undefined;\n const time = parseDeclaredDate(date);\n if (time === undefined) {\n throw new Error(`\"${node.path}\" declares an invalid date \"${date}\"; run \\`pagegraph check\\`.`);\n }\n return new Date(time).toISOString();\n}\n\nfunction renderUrlEntry(url: string, lastmod: string | undefined, node: SeoNode): string {\n const sitemap = node.policy.sitemap;\n // isSitemapEligible guarantees a positive policy before this runs.\n const { changeFrequency, priority } = sitemap as { changeFrequency: string; priority: number };\n const lines = [` <url>`, ` <loc>${escapeXml(url)}</loc>`];\n if (lastmod) lines.push(` <lastmod>${lastmod}</lastmod>`);\n lines.push(\n ` <changefreq>${changeFrequency}</changefreq>`,\n ` <priority>${priority.toFixed(1)}</priority>`,\n ` </url>`,\n );\n return lines.join(\"\\n\");\n}\n\n/**\n * Render sitemap.xml from the graph. Each entry's `<lastmod>` is its\n * {@link lastModified} date: frontmatter for content instances, the declared\n * `modifiedAt` for routes. Route entries are sorted by path, then instances follow in collection order.\n * `indexable` is intentionally unused — the sitemap body is host-independent;\n * robots.txt is what gates crawling.\n */\nexport function renderSitemap(graph: SeoGraph, cfg: ProjectionConfig): string {\n const nodes = [...graph.nodes.values()];\n const staticNodes = nodes\n .filter((node) => node.source === \"route\" && isSitemapEligible(node))\n .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));\n const instanceNodes = nodes.filter((node) => node.source !== \"route\" && isSitemapEligible(node));\n\n const seen = new Set<string>();\n const entries: Array<string> = [];\n for (const node of [...staticNodes, ...instanceNodes]) {\n const url = urlForNode(cfg.origin, node);\n const key = url.toLowerCase().replace(/\\/$/, \"\");\n if (seen.has(key)) continue;\n seen.add(key);\n entries.push(renderUrlEntry(url, sitemapLastmod(node), node));\n }\n\n return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\\n${entries.join(\n \"\\n\",\n )}\\n</urlset>\\n`;\n}\n\n/** Format a Content-Signal robots.txt directive from the preference list. */\nexport function contentSignal(value: string): string {\n return `Content-Signal: ${value}`;\n}\n\nconst groupLines = (cfg: RobotsConfig): Array<string> => {\n const lines: Array<string> = [];\n if (cfg.contentSignal !== undefined && cfg.contentSignal !== \"\") {\n lines.push(contentSignal(cfg.contentSignal));\n }\n for (const directive of cfg.directives ?? []) {\n if (directive !== \"\") lines.push(directive);\n }\n return lines;\n};\n\n/**\n * Render robots.txt. A non-indexable host (previews) gets a disallow-all\n * with no Sitemap line; an indexable host disallows exactly the prefixes the caller\n * passes. Pages that declare `robots: noindex` are intentionally NOT added as\n * Disallow entries — a Disallow would stop crawlers reaching the page to read its\n * `noindex, follow` meta, so the graph's per-node robots policy never feeds this\n * list. `graph` is unused — kept for signature parity with the other projections,\n * which callers load the graph once for and pass to each.\n *\n * Origin-wide group directives (`contentSignal`, `directives`) are indexable-host\n * only. {@link RobotsConfig.transform} always runs last so a consumer can override\n * the whole file.\n */\nexport function renderRobots(_graph: SeoGraph, cfg: RobotsConfig): string {\n const rendered = cfg.indexable\n ? [\n \"User-agent: *\",\n ...groupLines(cfg),\n \"Allow: /\",\n ...cfg.disallow.map((path) => `Disallow: ${path}`),\n \"\",\n `Sitemap: ${cfg.origin}/sitemap.xml`,\n `Host: ${cfg.origin}`,\n \"\",\n ].join(\"\\n\")\n : [\"User-agent: *\", \"Disallow: /\", \"\"].join(\"\\n\");\n return cfg.transform === undefined ? rendered : cfg.transform(rendered);\n}\n\n/** Inspect a single node: its declaration, sitemap eligibility, and edges. */\nexport function inspectNode(graph: SeoGraph, path: string): NodeReport | undefined {\n const node = graph.nodes.get(path);\n if (!node) return undefined;\n return {\n node,\n inSitemap: isSitemapEligible(node),\n incoming: graph.edges.filter((edge) => edge.to === path),\n outgoing: graph.edges.filter((edge) => edge.from === path),\n };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { a as SeoGraph, r as SeoEdge, s as SeoNode } from "./graph-BxCY90vq.js";
|
|
2
|
+
//#region src/core/projections.d.ts
|
|
3
|
+
interface ProjectionConfig {
|
|
4
|
+
origin: string;
|
|
5
|
+
indexable: boolean;
|
|
6
|
+
}
|
|
7
|
+
interface RobotsConfig extends ProjectionConfig {
|
|
8
|
+
/** Path prefixes to disallow on an indexable host (e.g. the app-only groups). */
|
|
9
|
+
disallow: ReadonlyArray<string>;
|
|
10
|
+
/**
|
|
11
|
+
* Origin-wide Content-Signal preferences
|
|
12
|
+
* (https://contentsignals.org/), emitted as `Content-Signal: <value>` under
|
|
13
|
+
* `User-agent: *` on an indexable host. Omit for none. The plugin never
|
|
14
|
+
* invents a default — pass the policy you want, e.g.
|
|
15
|
+
* `"search=yes, ai-input=yes, ai-train=yes"`.
|
|
16
|
+
*/
|
|
17
|
+
contentSignal?: string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Extra full lines in the indexable `User-agent: *` group, after
|
|
20
|
+
* Content-Signal (if any) and before Allow/Disallow. Use for directives the
|
|
21
|
+
* plugin does not model, or to compose {@link contentSignal} yourself.
|
|
22
|
+
*/
|
|
23
|
+
directives?: ReadonlyArray<string> | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Last-mile override: receives the rendered robots.txt and returns the file
|
|
26
|
+
* to emit. Runs for indexable and preview hosts. Use when you need to wrap
|
|
27
|
+
* or replace the default body rather than add group lines.
|
|
28
|
+
*/
|
|
29
|
+
transform?: ((robots: string) => string) | undefined;
|
|
30
|
+
}
|
|
31
|
+
interface NodeReport {
|
|
32
|
+
node: SeoNode;
|
|
33
|
+
inSitemap: boolean;
|
|
34
|
+
incoming: Array<SeoEdge>;
|
|
35
|
+
outgoing: Array<SeoEdge>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A node belongs in the sitemap when it declares a positive sitemap policy, is not
|
|
39
|
+
* a redirect, is not robots-noindexed, and is not a param template (a route whose
|
|
40
|
+
* path still contains a `$` segment — those exist only so their instances inherit).
|
|
41
|
+
*/
|
|
42
|
+
declare function isSitemapEligible(node: SeoNode): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The date a node's content last changed, as declared: an instance's
|
|
45
|
+
* `modifiedAt`, else its `publishedAt`; a route's own `modifiedAt`. A node with
|
|
46
|
+
* no date (a docs page without frontmatter dates, an undated route) returns
|
|
47
|
+
* undefined, and the sitemap omits its `<lastmod>`.
|
|
48
|
+
*/
|
|
49
|
+
declare function lastModified(node: SeoNode): string | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Render sitemap.xml from the graph. Each entry's `<lastmod>` is its
|
|
52
|
+
* {@link lastModified} date: frontmatter for content instances, the declared
|
|
53
|
+
* `modifiedAt` for routes. Route entries are sorted by path, then instances follow in collection order.
|
|
54
|
+
* `indexable` is intentionally unused — the sitemap body is host-independent;
|
|
55
|
+
* robots.txt is what gates crawling.
|
|
56
|
+
*/
|
|
57
|
+
declare function renderSitemap(graph: SeoGraph, cfg: ProjectionConfig): string;
|
|
58
|
+
/** Format a Content-Signal robots.txt directive from the preference list. */
|
|
59
|
+
declare function contentSignal(value: string): string;
|
|
60
|
+
/**
|
|
61
|
+
* Render robots.txt. A non-indexable host (previews) gets a disallow-all
|
|
62
|
+
* with no Sitemap line; an indexable host disallows exactly the prefixes the caller
|
|
63
|
+
* passes. Pages that declare `robots: noindex` are intentionally NOT added as
|
|
64
|
+
* Disallow entries — a Disallow would stop crawlers reaching the page to read its
|
|
65
|
+
* `noindex, follow` meta, so the graph's per-node robots policy never feeds this
|
|
66
|
+
* list. `graph` is unused — kept for signature parity with the other projections,
|
|
67
|
+
* which callers load the graph once for and pass to each.
|
|
68
|
+
*
|
|
69
|
+
* Origin-wide group directives (`contentSignal`, `directives`) are indexable-host
|
|
70
|
+
* only. {@link RobotsConfig.transform} always runs last so a consumer can override
|
|
71
|
+
* the whole file.
|
|
72
|
+
*/
|
|
73
|
+
declare function renderRobots(_graph: SeoGraph, cfg: RobotsConfig): string;
|
|
74
|
+
/** Inspect a single node: its declaration, sitemap eligibility, and edges. */
|
|
75
|
+
declare function inspectNode(graph: SeoGraph, path: string): NodeReport | undefined;
|
|
76
|
+
//#endregion
|
|
77
|
+
export { inspectNode as a, renderRobots as c, contentSignal as i, renderSitemap as l, ProjectionConfig as n, isSitemapEligible as o, RobotsConfig as r, lastModified as s, NodeReport as t };
|
|
78
|
+
//# sourceMappingURL=projections-DeEFe7XT.d.ts.map
|