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/dist/config.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { J as SeoGraph, V as FreshnessPolicy, h as CoverageRule, r as ContentPolicy } from "./content-DVL2iyGU.js";
1
+ import { J as SeoGraph, V as FreshnessPolicy, X as SeoNode, h as CoverageRule, r as ContentPolicy } from "./content-DVL2iyGU.js";
2
2
  import { PluginOption } from "vite";
3
3
  //#region src/config/vite-graph-loader.d.ts
4
4
  /** A graph, plus the release of whatever producing it acquired. */
@@ -12,7 +12,11 @@ interface LoadedSeoGraph {
12
12
  * config file must be writable without Effect, which the CLI keeps behind its
13
13
  * `bin` (an optional peer dependency).
14
14
  */
15
- type SeoGraphLoader = () => Promise<LoadedSeoGraph>;
15
+ /** JSON input that can be supplied by config files to an app graph export. */
16
+ type GraphLoaderInput = null | boolean | number | string | ReadonlyArray<GraphLoaderInput> | {
17
+ readonly [key: string]: GraphLoaderInput;
18
+ };
19
+ type SeoGraphLoader<Input extends GraphLoaderInput | void = void> = (input: Input) => Promise<LoadedSeoGraph>;
16
20
  interface ViteGraphLoaderOptions {
17
21
  /** The app's Vite root — the directory its aliases and plugins resolve against. */
18
22
  readonly root: string;
@@ -39,7 +43,27 @@ interface ViteGraphLoaderOptions {
39
43
  * Build a {@link SeoGraphLoader} that evaluates `entry` in a headless Vite
40
44
  * server rooted at `root`, and returns what its `exportName` export resolves to.
41
45
  */
42
- declare const viteGraphLoader: (options: ViteGraphLoaderOptions) => SeoGraphLoader;
46
+ declare const viteGraphLoader: <Input extends GraphLoaderInput | void = void>(options: ViteGraphLoaderOptions) => SeoGraphLoader<Input>;
47
+ //#endregion
48
+ //#region src/config/page-heads.d.ts
49
+ /** Plain document metadata, without framework or schema dependencies. */
50
+ interface PageHead {
51
+ readonly path: string;
52
+ readonly title: string;
53
+ readonly description: string;
54
+ }
55
+ interface PageHeadsOptions {
56
+ /** Path globs: `*` matches a segment, `**` matches across segments. */
57
+ readonly exclude?: ReadonlyArray<string>;
58
+ /** Exclude noindex pages, redirects and parameter templates. Defaults to true. */
59
+ readonly indexable?: boolean;
60
+ }
61
+ /** Select page nodes before the host resolves metadata; sitemap-disabled hubs remain pages. */
62
+ declare function selectPageHeadNodes(graph: SeoGraph, options?: PageHeadsOptions): Array<SeoNode>;
63
+ /** Extract resolved heads, naming the selected page when metadata is missing or blank. */
64
+ declare function pageHeads(graph: SeoGraph, options?: PageHeadsOptions): Array<PageHead>;
65
+ /** Acquire, extract and release a graph on both success and metadata failure. Bind loader input in a closure. */
66
+ declare function loadPageHeads(loader: SeoGraphLoader, options?: PageHeadsOptions): Promise<Array<PageHead>>;
43
67
  //#endregion
44
68
  //#region src/config/index.d.ts
45
69
  interface SeoWorkflowOpenCodeConfig {
@@ -116,5 +140,5 @@ interface SeoCliConfig {
116
140
  /** Identity — it exists for the type inference and the editor completions. */
117
141
  declare const defineSeoConfig: (config: SeoCliConfig) => SeoCliConfig;
118
142
  //#endregion
119
- export { type LoadedSeoGraph, SeoCliConfig, type SeoGraphLoader, SeoWorkflowConfig, SeoWorkflowContextConfig, SeoWorkflowOpenCodeConfig, type ViteGraphLoaderOptions, defineSeoConfig, viteGraphLoader };
143
+ export { type GraphLoaderInput, type LoadedSeoGraph, type PageHead, type PageHeadsOptions, SeoCliConfig, type SeoGraphLoader, SeoWorkflowConfig, SeoWorkflowContextConfig, SeoWorkflowOpenCodeConfig, type ViteGraphLoaderOptions, defineSeoConfig, loadPageHeads, pageHeads, selectPageHeadNodes, viteGraphLoader };
120
144
  //# sourceMappingURL=config.d.ts.map
package/dist/config.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { a as globToRegExp } from "./checks-nk8ZRRIA.js";
1
2
  import { createServer } from "vite";
2
3
  //#region src/config/vite-graph-loader.ts
3
4
  /**
@@ -79,7 +80,7 @@ const inlineConfigFor = (options) => ({
79
80
  * Build a {@link SeoGraphLoader} that evaluates `entry` in a headless Vite
80
81
  * server rooted at `root`, and returns what its `exportName` export resolves to.
81
82
  */
82
- const viteGraphLoader = (options) => async () => {
83
+ const viteGraphLoader = (options) => async (input) => {
83
84
  const exportName = options.exportName ?? "loadSeoGraph";
84
85
  if (options.env) seedEnv(options.env);
85
86
  const server = await withCleanStdout(() => createServer(inlineConfigFor(options))).catch((cause) => {
@@ -90,7 +91,7 @@ const viteGraphLoader = (options) => async () => {
90
91
  const module = await server.ssrLoadModule(options.entry);
91
92
  const load = module[exportName];
92
93
  if (typeof load !== "function") throw new Error(`${options.entry} has no \`${exportName}\` export (found: ${Object.keys(module).join(", ") || "nothing"}).`);
93
- return await load();
94
+ return await load(input);
94
95
  }).catch(async (cause) => {
95
96
  await withCleanStdout(() => server.close()).catch(() => {});
96
97
  throw new Error(`Could not build the SEO graph: ${messageOf(cause)}`);
@@ -99,10 +100,40 @@ const viteGraphLoader = (options) => async () => {
99
100
  };
100
101
  };
101
102
  //#endregion
103
+ //#region src/config/page-heads.ts
104
+ /** Select page nodes before the host resolves metadata; sitemap-disabled hubs remain pages. */
105
+ function selectPageHeadNodes(graph, options = {}) {
106
+ const excludes = (options.exclude ?? []).map(globToRegExp);
107
+ 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")));
108
+ }
109
+ /** Extract resolved heads, naming the selected page when metadata is missing or blank. */
110
+ function pageHeads(graph, options = {}) {
111
+ return selectPageHeadNodes(graph, options).map((node) => {
112
+ const title = node.instance?.title;
113
+ const description = node.instance?.description;
114
+ if (!title?.trim()) throw new Error(`Invalid page head for ${node.path}: missing title`);
115
+ if (!description?.trim()) throw new Error(`Invalid page head for ${node.path}: missing description`);
116
+ return {
117
+ path: node.path,
118
+ title,
119
+ description
120
+ };
121
+ });
122
+ }
123
+ /** Acquire, extract and release a graph on both success and metadata failure. Bind loader input in a closure. */
124
+ async function loadPageHeads(loader, options = {}) {
125
+ const loaded = await loader();
126
+ try {
127
+ return pageHeads(loaded.graph, options);
128
+ } finally {
129
+ await loaded.dispose();
130
+ }
131
+ }
132
+ //#endregion
102
133
  //#region src/config/index.ts
103
134
  /** Identity — it exists for the type inference and the editor completions. */
104
135
  const defineSeoConfig = (config) => config;
105
136
  //#endregion
106
- export { defineSeoConfig, viteGraphLoader };
137
+ export { defineSeoConfig, loadPageHeads, pageHeads, selectPageHeadNodes, viteGraphLoader };
107
138
 
108
139
  //# sourceMappingURL=config.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","names":[],"sources":["../src/config/vite-graph-loader.ts","../src/config/index.ts"],"sourcesContent":["/**\n * A graph loader that evaluates the host app's own graph module inside a\n * headless Vite server.\n *\n * The SEO graph is built *by the app*: its module reads the generated route\n * tree and whatever content collections it owns, so it pulls in the app's real\n * module graph — path aliases, virtual modules from content plugins, bare\n * imports that only exist inside a server runtime. A CLI cannot just `import()`\n * that from Node or Bun.\n *\n * So this boots a scoped, in-process Vite server (middleware mode, no HMR, no\n * watcher, no HTTP listener), `ssrLoadModule`s the app's graph module, and calls\n * its exported loader. The graph comes back as a live in-process object — there\n * is no serialization boundary for the commands to cross — and the server is\n * closed by {@link LoadedSeoGraph.dispose}.\n *\n * Everything host-shaped is an option: the app supplies its own {@link\n * ViteGraphLoaderOptions.plugins} (a content plugin, an MDX loader), {@link\n * ViteGraphLoaderOptions.stubs} for bare modules that only resolve inside its\n * server runtime, and {@link ViteGraphLoaderOptions.env} for variables its\n * modules parse at import. This file knows about none of them.\n */\n\nimport { createServer, type InlineConfig, type PluginOption } from \"vite\";\n\nimport type { SeoGraph } from \"../core/graph\";\n\n/** A graph, plus the release of whatever producing it acquired. */\nexport interface LoadedSeoGraph {\n readonly graph: SeoGraph;\n /** Called once the command is done with the graph, on success or failure. */\n readonly dispose: () => Promise<void>;\n}\n\n/**\n * Produces the SEO graph for the `pagegraph` CLI. A plain promise on purpose: a\n * config file must be writable without Effect, which the CLI keeps behind its\n * `bin` (an optional peer dependency).\n */\nexport type SeoGraphLoader = () => Promise<LoadedSeoGraph>;\n\nexport interface ViteGraphLoaderOptions {\n /** The app's Vite root — the directory its aliases and plugins resolve against. */\n readonly root: string;\n /** Module exporting the graph loader, root-relative (e.g. `/lib/seo/graph.ts`). */\n readonly entry: string;\n /** Named export on `entry` returning `Promise<SeoGraph>`. Defaults to `loadSeoGraph`. */\n readonly exportName?: string | undefined;\n /** Vite plugins the app's module graph needs — a content/MDX plugin, say. */\n readonly plugins?: ReadonlyArray<PluginOption> | undefined;\n /**\n * Bare or virtual module ids to replace with inert source, so a module that\n * only resolves inside the app's server runtime does not break the load.\n */\n readonly stubs?: Readonly<Record<string, string>> | undefined;\n /**\n * Variables to seed on `process.env` before the app's modules parse it. Vite\n * exposes prefixed values through `import.meta.env`, so this is how a module\n * that demands an env var at import time is satisfied. Existing values win —\n * a real `.env` is never clobbered.\n */\n readonly env?: Readonly<Record<string, string>> | undefined;\n}\n\nconst messageOf = (cause: unknown): string =>\n cause instanceof Error ? cause.message : String(cause);\n\n/**\n * Vite and its plugins write progress to stdout. The CLI reserves stdout for the\n * data plane, so redirect it to stderr for the duration of the load.\n *\n * Both the stream method and `console.log`/`info` are swapped: under Bun,\n * `console.log` writes to fd 1 natively rather than going through\n * `process.stdout.write`, so the stream swap alone would miss a plugin that\n * logs through the console.\n */\nconst withCleanStdout = async <A>(run: () => Promise<A>): Promise<A> => {\n const originalWrite = process.stdout.write.bind(process.stdout);\n const originalLog = console.log;\n const originalInfo = console.info;\n process.stdout.write = process.stderr.write.bind(process.stderr) as typeof process.stdout.write;\n console.log = (...args: Array<unknown>) => console.error(...args);\n console.info = (...args: Array<unknown>) => console.error(...args);\n try {\n return await run();\n } finally {\n process.stdout.write = originalWrite;\n console.log = originalLog;\n console.info = originalInfo;\n }\n};\n\n/** Turn `{ id: source }` into a Vite plugin that resolves and loads each id. */\nconst stubPlugin = (stubs: Readonly<Record<string, string>>): PluginOption => {\n const virtualIdOf = (id: string) => `\\0seo-cli-stub:${id}`;\n const sourceByVirtualId = new Map(\n Object.entries(stubs).map(([id, source]) => [virtualIdOf(id), source]),\n );\n return {\n name: \"seo:stubs\",\n resolveId: (id) => (id in stubs ? virtualIdOf(id) : null),\n load: (id) => sourceByVirtualId.get(id) ?? null,\n };\n};\n\nconst seedEnv = (env: Readonly<Record<string, string>>): void => {\n for (const [key, value] of Object.entries(env)) {\n // oxlint-disable-next-line node/no-process-env -- CLI boundary: seeds the raw env BEFORE the app's own env module parses it; that module is the consumer here, not an option\n process.env[key] ??= value;\n }\n};\n\nconst inlineConfigFor = (options: ViteGraphLoaderOptions): InlineConfig => ({\n configFile: false,\n root: options.root,\n mode: \"production\",\n logLevel: \"error\",\n appType: \"custom\",\n clearScreen: false,\n server: { middlewareMode: true, hmr: false, watch: null },\n resolve: { tsconfigPaths: true },\n plugins: [\n ...(options.stubs === undefined ? [] : [stubPlugin(options.stubs)]),\n ...(options.plugins ?? []),\n ],\n});\n\n/**\n * Build a {@link SeoGraphLoader} that evaluates `entry` in a headless Vite\n * server rooted at `root`, and returns what its `exportName` export resolves to.\n */\nexport const viteGraphLoader =\n (options: ViteGraphLoaderOptions): SeoGraphLoader =>\n async () => {\n const exportName = options.exportName ?? \"loadSeoGraph\";\n if (options.env) seedEnv(options.env);\n\n const server = await withCleanStdout(() => createServer(inlineConfigFor(options))).catch(\n (cause: unknown) => {\n throw new Error(`Could not start the Vite loader: ${messageOf(cause)}`);\n },\n );\n\n // The server is a resource from here on: a failed load must still close it,\n // and a successful one hands the close to the caller as `dispose`.\n const graph = await withCleanStdout(async () => {\n const module = (await server.ssrLoadModule(options.entry)) as Record<string, unknown>;\n const load = module[exportName];\n if (typeof load !== \"function\") {\n throw new Error(\n `${options.entry} has no \\`${exportName}\\` export (found: ${Object.keys(module).join(\", \") || \"nothing\"}).`,\n );\n }\n return (await load()) as SeoGraph;\n }).catch(async (cause: unknown) => {\n // Close on the way out, but never let a close failure bury the real\n // error: the reason the graph did not build is the useful one.\n await withCleanStdout(() => server.close()).catch(() => {});\n throw new Error(`Could not build the SEO graph: ${messageOf(cause)}`);\n });\n\n return { graph, dispose: () => withCleanStdout(() => server.close()) };\n };\n","/**\n * `pagegraph/config` — the `pagegraph` CLI's configuration surface.\n *\n * The CLI is a set of pure views over one graph, but *acquiring* that graph is\n * host knowledge: only the app knows where its graph module lives and what its\n * module needs to evaluate. So the app declares it once in a `seo.config.ts` at\n * its root, which the CLI discovers from the working directory:\n *\n * ```ts\n * // seo.config.ts\n * import { defineSeoConfig, viteGraphLoader } from \"pagegraph/config\";\n * import { routeConfig } from \"./lib/route-config\";\n *\n * export default defineSeoConfig({\n * origin: \"https://example.com\",\n * disallow: routeConfig.robotsExclusions,\n * contentSignal: \"search=yes, ai-input=yes, ai-train=yes\",\n * loadGraph: viteGraphLoader({ root: import.meta.dirname, entry: \"/lib/seo/graph.ts\" }),\n * });\n * ```\n *\n * Nothing here imports Effect. The CLI needs it (an optional peer dependency),\n * but a config file must not: it is the app's file, and the app may not be an\n * Effect app.\n */\n\nexport type { LoadedSeoGraph, SeoGraphLoader, ViteGraphLoaderOptions } from \"./vite-graph-loader\";\nexport { viteGraphLoader } from \"./vite-graph-loader\";\n\nimport type { CoverageRule } from \"../core/checks\";\nimport type { ContentPolicy } from \"../core/content\";\nimport type { FreshnessPolicy } from \"../core/freshness\";\nimport type { SeoGraphLoader } from \"./vite-graph-loader\";\n\nexport interface SeoWorkflowOpenCodeConfig {\n /** Project-owned OpenCode configuration directory, relative to the project root. */\n readonly configDirectory: string;\n /** Default `provider/model` used by agentic workflows. */\n readonly defaultModel: string;\n /** Per-workflow `provider/model` overrides. */\n readonly models?: Readonly<Record<string, string>> | undefined;\n /** Per-completion deadline in milliseconds, including JSON repair; defaults to 180000. */\n readonly timeoutMs?: number | undefined;\n}\n\nexport interface SeoWorkflowContextConfig {\n /** Durable project context included in every workflow prompt. */\n readonly files?: ReadonlyArray<string> | undefined;\n /** Additional context files keyed by workflow id. */\n readonly byWorkflow?: Readonly<Record<string, ReadonlyArray<string>>> | undefined;\n}\n\nexport interface SeoWorkflowConfig {\n readonly opencode: SeoWorkflowOpenCodeConfig;\n readonly context?: SeoWorkflowContextConfig | undefined;\n /** Ignored run-artifact directory, relative to the project root. */\n readonly runsDirectory?: string | undefined;\n}\n\nexport interface SeoCliConfig {\n /**\n * Canonical origin the sitemap and robots projections render under, no\n * trailing slash. The `--origin` flag overrides it per invocation.\n */\n readonly origin: string;\n /**\n * Path prefixes disallowed in robots.txt. Feed it the generated\n * `routeConfig.robotsExclusions` if you run the `pagegraph/vite` plugin.\n */\n readonly disallow: ReadonlyArray<string>;\n /**\n * Origin-wide Content-Signal preferences, forwarded to `renderRobots`.\n * Omit for none. Preview (`--no-indexable`) never emits the line.\n */\n readonly contentSignal?: string | undefined;\n /**\n * Extra robots.txt group lines, forwarded to `renderRobots` as `directives`.\n */\n readonly directives?: ReadonlyArray<string> | undefined;\n /**\n * Last-mile robots.txt override, forwarded to `renderRobots` as `transform`.\n * Runs for indexable and preview hosts.\n */\n readonly transform?: ((robots: string) => string) | undefined;\n /**\n * Contextual-link coverage policy for `pagegraph check`: every sitemap-eligible\n * page matching a rule's `path` glob needs at least `minInbound` incoming\n * `related` edges. A `--require-inbound` flag overrides this per invocation.\n */\n readonly coverage?: ReadonlyArray<CoverageRule> | undefined;\n /**\n * Rendered content policy for `pagegraph check --site`: word-count floors per\n * path glob. The heading, structured-data, robots, and canonical rules need no\n * configuration.\n */\n readonly content?: ContentPolicy | undefined;\n /**\n * Freshness policy: sitemap-eligible pages whose declared `modifiedAt` (or an\n * instance's `publishedAt`) is older than `maxAgeDays` are editorial\n * `stale-page` findings in `pagegraph check` and the refresh queue in\n * `pagegraph stale`.\n */\n readonly freshness?: FreshnessPolicy | undefined;\n /** Agentic workflow host, context, and artifact settings. */\n readonly workflows?: SeoWorkflowConfig | undefined;\n /** How the CLI gets the graph. {@link viteGraphLoader} covers the Vite-app case. */\n readonly loadGraph: SeoGraphLoader;\n}\n\n/** Identity — it exists for the type inference and the editor completions. */\nexport const defineSeoConfig = (config: SeoCliConfig): SeoCliConfig => config;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAgEA,MAAM,aAAa,UACjB,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;;;;;;;;;;AAWvD,MAAM,kBAAkB,OAAU,QAAsC;CACtE,MAAM,gBAAgB,QAAQ,OAAO,MAAM,KAAK,QAAQ,MAAM;CAC9D,MAAM,cAAc,QAAQ;CAC5B,MAAM,eAAe,QAAQ;CAC7B,QAAQ,OAAO,QAAQ,QAAQ,OAAO,MAAM,KAAK,QAAQ,MAAM;CAC/D,QAAQ,OAAO,GAAG,SAAyB,QAAQ,MAAM,GAAG,IAAI;CAChE,QAAQ,QAAQ,GAAG,SAAyB,QAAQ,MAAM,GAAG,IAAI;CACjE,IAAI;EACF,OAAO,MAAM,IAAI;CACnB,UAAU;EACR,QAAQ,OAAO,QAAQ;EACvB,QAAQ,MAAM;EACd,QAAQ,OAAO;CACjB;AACF;;AAGA,MAAM,cAAc,UAA0D;CAC5E,MAAM,eAAe,OAAe,kBAAkB;CACtD,MAAM,oBAAoB,IAAI,IAC5B,OAAO,QAAQ,KAAK,CAAC,CAAC,KAAK,CAAC,IAAI,YAAY,CAAC,YAAY,EAAE,GAAG,MAAM,CAAC,CACvE;CACA,OAAO;EACL,MAAM;EACN,YAAY,OAAQ,MAAM,QAAQ,YAAY,EAAE,IAAI;EACpD,OAAO,OAAO,kBAAkB,IAAI,EAAE,KAAK;CAC7C;AACF;AAEA,MAAM,WAAW,QAAgD;CAC/D,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,GAAG,GAE3C,QAAQ,IAAI,SAAS;AAEzB;AAEA,MAAM,mBAAmB,aAAmD;CAC1E,YAAY;CACZ,MAAM,QAAQ;CACd,MAAM;CACN,UAAU;CACV,SAAS;CACT,aAAa;CACb,QAAQ;EAAE,gBAAgB;EAAM,KAAK;EAAO,OAAO;CAAK;CACxD,SAAS,EAAE,eAAe,KAAK;CAC/B,SAAS,CACP,GAAI,QAAQ,UAAU,KAAA,IAAY,CAAC,IAAI,CAAC,WAAW,QAAQ,KAAK,CAAC,GACjE,GAAI,QAAQ,WAAW,CAAC,CAC1B;AACF;;;;;AAMA,MAAa,mBACV,YACD,YAAY;CACV,MAAM,aAAa,QAAQ,cAAc;CACzC,IAAI,QAAQ,KAAK,QAAQ,QAAQ,GAAG;CAEpC,MAAM,SAAS,MAAM,sBAAsB,aAAa,gBAAgB,OAAO,CAAC,CAAC,CAAC,CAAC,OAChF,UAAmB;EAClB,MAAM,IAAI,MAAM,oCAAoC,UAAU,KAAK,GAAG;CACxE,CACF;CAoBA,OAAO;EAAE,OAAA,MAhBW,gBAAgB,YAAY;GAC9C,MAAM,SAAU,MAAM,OAAO,cAAc,QAAQ,KAAK;GACxD,MAAM,OAAO,OAAO;GACpB,IAAI,OAAO,SAAS,YAClB,MAAM,IAAI,MACR,GAAG,QAAQ,MAAM,YAAY,WAAW,oBAAoB,OAAO,KAAK,MAAM,CAAC,CAAC,KAAK,IAAI,KAAK,UAAU,GAC1G;GAEF,OAAQ,MAAM,KAAK;EACrB,CAAC,CAAC,CAAC,MAAM,OAAO,UAAmB;GAGjC,MAAM,sBAAsB,OAAO,MAAM,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC;GAC1D,MAAM,IAAI,MAAM,kCAAkC,UAAU,KAAK,GAAG;EACtE,CAAC;EAEe,eAAe,sBAAsB,OAAO,MAAM,CAAC;CAAE;AACvE;;;;ACpDF,MAAa,mBAAmB,WAAuC"}
1
+ {"version":3,"file":"config.js","names":[],"sources":["../src/config/vite-graph-loader.ts","../src/config/page-heads.ts","../src/config/index.ts"],"sourcesContent":["/**\n * A graph loader that evaluates the host app's own graph module inside a\n * headless Vite server.\n *\n * The SEO graph is built *by the app*: its module reads the generated route\n * tree and whatever content collections it owns, so it pulls in the app's real\n * module graph — path aliases, virtual modules from content plugins, bare\n * imports that only exist inside a server runtime. A CLI cannot just `import()`\n * that from Node or Bun.\n *\n * So this boots a scoped, in-process Vite server (middleware mode, no HMR, no\n * watcher, no HTTP listener), `ssrLoadModule`s the app's graph module, and calls\n * its exported loader. The graph comes back as a live in-process object — there\n * is no serialization boundary for the commands to cross — and the server is\n * closed by {@link LoadedSeoGraph.dispose}.\n *\n * Everything host-shaped is an option: the app supplies its own {@link\n * ViteGraphLoaderOptions.plugins} (a content plugin, an MDX loader), {@link\n * ViteGraphLoaderOptions.stubs} for bare modules that only resolve inside its\n * server runtime, and {@link ViteGraphLoaderOptions.env} for variables its\n * modules parse at import. This file knows about none of them.\n */\n\nimport { createServer, type InlineConfig, type PluginOption } from \"vite\";\n\nimport type { SeoGraph } from \"../core/graph\";\n\n/** A graph, plus the release of whatever producing it acquired. */\nexport interface LoadedSeoGraph {\n readonly graph: SeoGraph;\n /** Called once the command is done with the graph, on success or failure. */\n readonly dispose: () => Promise<void>;\n}\n\n/**\n * Produces the SEO graph for the `pagegraph` CLI. A plain promise on purpose: a\n * config file must be writable without Effect, which the CLI keeps behind its\n * `bin` (an optional peer dependency).\n */\n/** JSON input that can be supplied by config files to an app graph export. */\nexport type GraphLoaderInput = null | boolean | number | string | ReadonlyArray<GraphLoaderInput> | { readonly [key: string]: GraphLoaderInput };\n\nexport type SeoGraphLoader<Input extends GraphLoaderInput | void = void> = (input: Input) => Promise<LoadedSeoGraph>;\n\nexport interface ViteGraphLoaderOptions {\n /** The app's Vite root — the directory its aliases and plugins resolve against. */\n readonly root: string;\n /** Module exporting the graph loader, root-relative (e.g. `/lib/seo/graph.ts`). */\n readonly entry: string;\n /** Named export on `entry` returning `Promise<SeoGraph>`. Defaults to `loadSeoGraph`. */\n readonly exportName?: string | undefined;\n /** Vite plugins the app's module graph needs — a content/MDX plugin, say. */\n readonly plugins?: ReadonlyArray<PluginOption> | undefined;\n /**\n * Bare or virtual module ids to replace with inert source, so a module that\n * only resolves inside the app's server runtime does not break the load.\n */\n readonly stubs?: Readonly<Record<string, string>> | undefined;\n /**\n * Variables to seed on `process.env` before the app's modules parse it. Vite\n * exposes prefixed values through `import.meta.env`, so this is how a module\n * that demands an env var at import time is satisfied. Existing values win —\n * a real `.env` is never clobbered.\n */\n readonly env?: Readonly<Record<string, string>> | undefined;\n}\n\nconst messageOf = (cause: unknown): string =>\n cause instanceof Error ? cause.message : String(cause);\n\n/**\n * Vite and its plugins write progress to stdout. The CLI reserves stdout for the\n * data plane, so redirect it to stderr for the duration of the load.\n *\n * Both the stream method and `console.log`/`info` are swapped: under Bun,\n * `console.log` writes to fd 1 natively rather than going through\n * `process.stdout.write`, so the stream swap alone would miss a plugin that\n * logs through the console.\n */\nconst withCleanStdout = async <A>(run: () => Promise<A>): Promise<A> => {\n const originalWrite = process.stdout.write.bind(process.stdout);\n const originalLog = console.log;\n const originalInfo = console.info;\n process.stdout.write = process.stderr.write.bind(process.stderr) as typeof process.stdout.write;\n console.log = (...args: Array<unknown>) => console.error(...args);\n console.info = (...args: Array<unknown>) => console.error(...args);\n try {\n return await run();\n } finally {\n process.stdout.write = originalWrite;\n console.log = originalLog;\n console.info = originalInfo;\n }\n};\n\n/** Turn `{ id: source }` into a Vite plugin that resolves and loads each id. */\nconst stubPlugin = (stubs: Readonly<Record<string, string>>): PluginOption => {\n const virtualIdOf = (id: string) => `\\0seo-cli-stub:${id}`;\n const sourceByVirtualId = new Map(\n Object.entries(stubs).map(([id, source]) => [virtualIdOf(id), source]),\n );\n return {\n name: \"seo:stubs\",\n resolveId: (id) => (id in stubs ? virtualIdOf(id) : null),\n load: (id) => sourceByVirtualId.get(id) ?? null,\n };\n};\n\nconst seedEnv = (env: Readonly<Record<string, string>>): void => {\n for (const [key, value] of Object.entries(env)) {\n // oxlint-disable-next-line node/no-process-env -- CLI boundary: seeds the raw env BEFORE the app's own env module parses it; that module is the consumer here, not an option\n process.env[key] ??= value;\n }\n};\n\nconst inlineConfigFor = (options: ViteGraphLoaderOptions): InlineConfig => ({\n configFile: false,\n root: options.root,\n mode: \"production\",\n logLevel: \"error\",\n appType: \"custom\",\n clearScreen: false,\n server: { middlewareMode: true, hmr: false, watch: null },\n resolve: { tsconfigPaths: true },\n plugins: [\n ...(options.stubs === undefined ? [] : [stubPlugin(options.stubs)]),\n ...(options.plugins ?? []),\n ],\n});\n\n/**\n * Build a {@link SeoGraphLoader} that evaluates `entry` in a headless Vite\n * server rooted at `root`, and returns what its `exportName` export resolves to.\n */\nexport const viteGraphLoader =\n <Input extends GraphLoaderInput | void = void>(options: ViteGraphLoaderOptions): SeoGraphLoader<Input> =>\n async (input) => {\n const exportName = options.exportName ?? \"loadSeoGraph\";\n if (options.env) seedEnv(options.env);\n\n const server = await withCleanStdout(() => createServer(inlineConfigFor(options))).catch(\n (cause: unknown) => {\n throw new Error(`Could not start the Vite loader: ${messageOf(cause)}`);\n },\n );\n\n // The server is a resource from here on: a failed load must still close it,\n // and a successful one hands the close to the caller as `dispose`.\n const graph = await withCleanStdout(async () => {\n const module = (await server.ssrLoadModule(options.entry)) as Record<string, unknown>;\n const load = module[exportName];\n if (typeof load !== \"function\") {\n throw new Error(\n `${options.entry} has no \\`${exportName}\\` export (found: ${Object.keys(module).join(\", \") || \"nothing\"}).`,\n );\n }\n return (await load(input)) as SeoGraph;\n }).catch(async (cause: unknown) => {\n // Close on the way out, but never let a close failure bury the real\n // error: the reason the graph did not build is the useful one.\n await withCleanStdout(() => server.close()).catch(() => {});\n throw new Error(`Could not build the SEO graph: ${messageOf(cause)}`);\n });\n\n return { graph, dispose: () => withCleanStdout(() => server.close()) };\n };\n","import { globToRegExp } from \"../core/checks\";\nimport type { SeoGraph, SeoNode } from \"../core/graph\";\nimport type { SeoGraphLoader } from \"./vite-graph-loader\";\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.instance?.title;\n const description = node.instance?.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\n/** Acquire, extract and release a graph on both success and metadata failure. Bind loader input in a closure. */\nexport async function loadPageHeads(loader: SeoGraphLoader, options: PageHeadsOptions = {}): Promise<Array<PageHead>> {\n const loaded = await loader();\n try {\n return pageHeads(loaded.graph, options);\n } finally {\n await loaded.dispose();\n }\n}\n","/**\n * `pagegraph/config` — the `pagegraph` CLI's configuration surface.\n *\n * The CLI is a set of pure views over one graph, but *acquiring* that graph is\n * host knowledge: only the app knows where its graph module lives and what its\n * module needs to evaluate. So the app declares it once in a `seo.config.ts` at\n * its root, which the CLI discovers from the working directory:\n *\n * ```ts\n * // seo.config.ts\n * import { defineSeoConfig, viteGraphLoader } from \"pagegraph/config\";\n * import { routeConfig } from \"./lib/route-config\";\n *\n * export default defineSeoConfig({\n * origin: \"https://example.com\",\n * disallow: routeConfig.robotsExclusions,\n * contentSignal: \"search=yes, ai-input=yes, ai-train=yes\",\n * loadGraph: viteGraphLoader({ root: import.meta.dirname, entry: \"/lib/seo/graph.ts\" }),\n * });\n * ```\n *\n * Nothing here imports Effect. The CLI needs it (an optional peer dependency),\n * but a config file must not: it is the app's file, and the app may not be an\n * Effect app.\n */\n\nexport type { GraphLoaderInput, LoadedSeoGraph, SeoGraphLoader, ViteGraphLoaderOptions } from \"./vite-graph-loader\";\nexport { viteGraphLoader } from \"./vite-graph-loader\";\n\nexport { selectPageHeadNodes, pageHeads, loadPageHeads } from \"./page-heads\";\nexport type { PageHead, PageHeadsOptions } from \"./page-heads\";\n\nimport type { CoverageRule } from \"../core/checks\";\nimport type { ContentPolicy } from \"../core/content\";\nimport type { FreshnessPolicy } from \"../core/freshness\";\nimport type { SeoGraphLoader } from \"./vite-graph-loader\";\n\nexport interface SeoWorkflowOpenCodeConfig {\n /** Project-owned OpenCode configuration directory, relative to the project root. */\n readonly configDirectory: string;\n /** Default `provider/model` used by agentic workflows. */\n readonly defaultModel: string;\n /** Per-workflow `provider/model` overrides. */\n readonly models?: Readonly<Record<string, string>> | undefined;\n /** Per-completion deadline in milliseconds, including JSON repair; defaults to 180000. */\n readonly timeoutMs?: number | undefined;\n}\n\nexport interface SeoWorkflowContextConfig {\n /** Durable project context included in every workflow prompt. */\n readonly files?: ReadonlyArray<string> | undefined;\n /** Additional context files keyed by workflow id. */\n readonly byWorkflow?: Readonly<Record<string, ReadonlyArray<string>>> | undefined;\n}\n\nexport interface SeoWorkflowConfig {\n readonly opencode: SeoWorkflowOpenCodeConfig;\n readonly context?: SeoWorkflowContextConfig | undefined;\n /** Ignored run-artifact directory, relative to the project root. */\n readonly runsDirectory?: string | undefined;\n}\n\nexport interface SeoCliConfig {\n /**\n * Canonical origin the sitemap and robots projections render under, no\n * trailing slash. The `--origin` flag overrides it per invocation.\n */\n readonly origin: string;\n /**\n * Path prefixes disallowed in robots.txt. Feed it the generated\n * `routeConfig.robotsExclusions` if you run the `pagegraph/vite` plugin.\n */\n readonly disallow: ReadonlyArray<string>;\n /**\n * Origin-wide Content-Signal preferences, forwarded to `renderRobots`.\n * Omit for none. Preview (`--no-indexable`) never emits the line.\n */\n readonly contentSignal?: string | undefined;\n /**\n * Extra robots.txt group lines, forwarded to `renderRobots` as `directives`.\n */\n readonly directives?: ReadonlyArray<string> | undefined;\n /**\n * Last-mile robots.txt override, forwarded to `renderRobots` as `transform`.\n * Runs for indexable and preview hosts.\n */\n readonly transform?: ((robots: string) => string) | undefined;\n /**\n * Contextual-link coverage policy for `pagegraph check`: every sitemap-eligible\n * page matching a rule's `path` glob needs at least `minInbound` incoming\n * `related` edges. A `--require-inbound` flag overrides this per invocation.\n */\n readonly coverage?: ReadonlyArray<CoverageRule> | undefined;\n /**\n * Rendered content policy for `pagegraph check --site`: word-count floors per\n * path glob. The heading, structured-data, robots, and canonical rules need no\n * configuration.\n */\n readonly content?: ContentPolicy | undefined;\n /**\n * Freshness policy: sitemap-eligible pages whose declared `modifiedAt` (or an\n * instance's `publishedAt`) is older than `maxAgeDays` are editorial\n * `stale-page` findings in `pagegraph check` and the refresh queue in\n * `pagegraph stale`.\n */\n readonly freshness?: FreshnessPolicy | undefined;\n /** Agentic workflow host, context, and artifact settings. */\n readonly workflows?: SeoWorkflowConfig | undefined;\n /** How the CLI gets the graph. {@link viteGraphLoader} covers the Vite-app case. */\n readonly loadGraph: SeoGraphLoader;\n}\n\n/** Identity — it exists for the type inference and the editor completions. */\nexport const defineSeoConfig = (config: SeoCliConfig): SeoCliConfig => config;\n"],"mappings":"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"}