pagegraph 0.5.1 → 0.6.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 { a as SeoGraph } from "./graph-BlLoEOw2.js";
1
+ import { d as SeoGraph, t as CoverageRule } from "./checks-BfsQtKga.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. */
@@ -67,6 +67,12 @@ interface SeoCliConfig {
67
67
  * Runs for indexable and preview hosts.
68
68
  */
69
69
  readonly transform?: ((robots: string) => string) | undefined;
70
+ /**
71
+ * Contextual-link coverage policy for `pagegraph check`: every sitemap-eligible
72
+ * page matching a rule's `path` glob needs at least `minInbound` incoming
73
+ * `related` edges. A `--require-inbound` flag overrides this per invocation.
74
+ */
75
+ readonly coverage?: ReadonlyArray<CoverageRule> | undefined;
70
76
  /** How the CLI gets the graph. {@link viteGraphLoader} covers the Vite-app case. */
71
77
  readonly loadGraph: SeoGraphLoader;
72
78
  }
@@ -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 { SeoGraphLoader } from \"./vite-graph-loader\";\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 /** 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;;;;ACrGF,MAAa,mBAAmB,WAAuC"}
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 { SeoGraphLoader } from \"./vite-graph-loader\";\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 /** 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;;;;AC9FF,MAAa,mBAAmB,WAAuC"}
package/dist/index.d.ts CHANGED
@@ -1,5 +1,78 @@
1
- import { a as SeoGraph, c as SeoSource, d as Register, f as RouteSeo, i as SeoEdgeType, l as buildSeoGraph, m as SitemapPolicy, n as SeoCollection, o as SeoInstance, p as SeoKind, r as SeoEdge, s as SeoNode, t as BuildSeoGraphInput, u as PublicPath } from "./graph-BlLoEOw2.js";
1
+ import { _ as Register, a as checkGraph, b as SitemapPolicy, c as SeoCollection, d as SeoGraph, f as SeoInstance, g as PublicPath, h as buildSeoGraph, i as checkCoverage, l as SeoEdge, m as SeoSource, n as Severity, o as hasStructuralViolations, p as SeoNode, r as Violation, s as BuildSeoGraphInput, t as CoverageRule, u as SeoEdgeType, v as RouteSeo, y as SeoKind } from "./checks-BfsQtKga.js";
2
2
  import { AnyRouter } from "@tanstack/react-router";
3
+ //#region src/core/links.d.ts
4
+ /**
5
+ * Pure link-graph utilities: read anchors out of rendered HTML, model the
6
+ * rendered graph, compute homepage depth, and diff it against the declared
7
+ * graph. Effect- and framework-free so it runs in the CLI, a Worker, or a test
8
+ * on the same data.
9
+ *
10
+ * This is the "served HTML" half of the SEO graph: the declared graph describes
11
+ * intent, this describes what a crawler actually receives.
12
+ */
13
+ /** Where an anchor sits in the document, coarsely. */
14
+ type AnchorRegion = "nav" | "footer" | "header" | "body";
15
+ interface Anchor {
16
+ readonly href: string;
17
+ readonly text: string;
18
+ readonly region: AnchorRegion;
19
+ /** True when the resolved target shares the page's origin. */
20
+ readonly internal: boolean;
21
+ }
22
+ interface LinkEdge {
23
+ readonly from: string;
24
+ readonly to: string;
25
+ readonly region: AnchorRegion;
26
+ }
27
+ interface RenderedGraph {
28
+ readonly edges: ReadonlyArray<LinkEdge>;
29
+ readonly internalEdges: ReadonlyArray<LinkEdge>;
30
+ /** Same-origin body-region edges: the contextual surface. */
31
+ readonly contextualEdges: ReadonlyArray<LinkEdge>;
32
+ /** Same-origin pages with no incoming internal edge ("reachable only via nav" is not implied). */
33
+ readonly orphans: ReadonlyArray<string>;
34
+ readonly depthByPath: ReadonlyMap<string, number>;
35
+ readonly maxDepth: number | null;
36
+ }
37
+ /** Normalize a same-origin URL to the graph's path key (no query/hash; "/" preserved). */
38
+ declare const normalizePath: (url: URL) => string;
39
+ /**
40
+ * Extract every anchor in document order, tagging each with the region tag it
41
+ * sits inside. Anchor text keeps only its letters and a coarse entity decode;
42
+ * this is a classifier input, not a renderer.
43
+ */
44
+ declare const extractAnchors: (html: string, baseUrl: string) => ReadonlyArray<Anchor>;
45
+ interface RenderedPage {
46
+ readonly url: string;
47
+ readonly anchors: ReadonlyArray<Anchor>;
48
+ }
49
+ /**
50
+ * Build the rendered graph from crawled pages. `origin` fixes the graph's
51
+ * same-origin boundary — pages and anchors on another origin are dropped, not
52
+ * silently folded in — and `root` is the normalized path the depth BFS starts
53
+ * from (the seed's final path when the homepage redirected). Every page path is
54
+ * keyed the way the declared graph keys its routes.
55
+ */
56
+ declare const buildRenderedGraph: (origin: string, pages: ReadonlyArray<RenderedPage>, root?: string) => RenderedGraph;
57
+ interface SimpleEdge {
58
+ readonly from: string;
59
+ readonly to: string;
60
+ }
61
+ interface LinkGraphDiff {
62
+ /** Declared internal edges with no matching rendered anchor. */
63
+ readonly declaredNotRendered: ReadonlyArray<SimpleEdge>;
64
+ /** Rendered contextual edges that no declared edge accounts for. */
65
+ readonly renderedNotDeclared: ReadonlyArray<SimpleEdge>;
66
+ readonly declaredCount: number;
67
+ readonly renderedContextualCount: number;
68
+ }
69
+ /**
70
+ * Diff rendered contextual edges against declared `related`/`crumb` edges. Both
71
+ * sides are path-keyed; the diff is directional and reports each direction
72
+ * separately because they call for different fixes.
73
+ */
74
+ declare const diffLinkGraph: (declared: ReadonlyArray<SimpleEdge>, rendered: RenderedGraph) => LinkGraphDiff;
75
+ //#endregion
3
76
  //#region src/core/projections.d.ts
4
77
  interface ProjectionConfig {
5
78
  origin: string;
@@ -35,6 +108,12 @@ interface NodeReport {
35
108
  incoming: Array<SeoEdge>;
36
109
  outgoing: Array<SeoEdge>;
37
110
  }
111
+ /**
112
+ * A node belongs in the sitemap when it declares a positive sitemap policy, is not
113
+ * a redirect, is not robots-noindexed, and is not a param template (a route whose
114
+ * path still contains a `$` segment — those exist only so their instances inherit).
115
+ */
116
+ declare function isSitemapEligible(node: SeoNode): boolean;
38
117
  /**
39
118
  * Render sitemap.xml from the graph. Structural route entries emit no `<lastmod>`
40
119
  * (a route has no publish date); content instances emit it from their frontmatter.
@@ -62,19 +141,57 @@ declare function renderRobots(_graph: SeoGraph, cfg: RobotsConfig): string;
62
141
  /** Inspect a single node: its declaration, sitemap eligibility, and edges. */
63
142
  declare function inspectNode(graph: SeoGraph, path: string): NodeReport | undefined;
64
143
  //#endregion
65
- //#region src/core/checks.d.ts
66
- type Severity = "structural" | "editorial";
67
- interface Violation {
68
- severity: Severity;
69
- rule: string;
70
- path?: string | undefined;
71
- message: string;
72
- fix?: string | undefined;
144
+ //#region src/core/link-candidates.d.ts
145
+ /** One proposed contextual link, in the direction the link would be authored. */
146
+ interface LinkCandidatePair {
147
+ readonly source: string;
148
+ readonly destination: string;
149
+ /** The cluster the pair shares: a top-level section, or `kind:<kind>`. */
150
+ readonly cluster: string;
151
+ /** Why the pair is plausible, e.g. `same top-level section "/blog"`. */
152
+ readonly reason: string;
153
+ }
154
+ interface LinkCandidateOptions {
155
+ /** Maximum candidates to return after ordering (default 50). */
156
+ readonly limit?: number;
157
+ /** Restrict to these clusters; a filter may be a section or a bare kind. */
158
+ readonly clusters?: ReadonlyArray<string>;
159
+ /** Anchors already served in HTML; their pairs are excluded when supplied. */
160
+ readonly renderedEdges?: ReadonlyArray<SimpleEdge>;
161
+ }
162
+ /** One cluster and how many candidate pairs it contributes (before `limit`). */
163
+ interface LinkClusterSummary {
164
+ readonly key: string;
165
+ readonly candidates: number;
73
166
  }
74
- /** Run every rule against the graph and return the flat list of violations. */
75
- declare function checkGraph(graph: SeoGraph): Array<Violation>;
76
- /** Structural violations fail `pagegraph check`; editorial-only stays green. */
77
- declare const hasStructuralViolations: (violations: ReadonlyArray<Violation>) => boolean;
167
+ interface LinkCandidateResult {
168
+ readonly candidates: ReadonlyArray<LinkCandidatePair>;
169
+ /** Candidate pairs before `limit` was applied (after cluster filters). */
170
+ readonly total: number;
171
+ readonly truncated: boolean;
172
+ readonly clusters: ReadonlyArray<LinkClusterSummary>;
173
+ }
174
+ /** Directionless pair key, so an edge in either direction means "connected". */
175
+ declare const undirectedEdgeKey: (from: string, to: string) => string;
176
+ /** A `--cluster` filter matches a section by name, or a kind via its bare label. */
177
+ declare const matchesClusterFilter: (cluster: string, filter: string) => boolean;
178
+ /** Human reason string for a candidate's source page, used when handing a plan to Jev. */
179
+ declare const candidateSourceText: (node: SeoNode) => string;
180
+ /**
181
+ * Enumerate reviewable contextual-link candidates from the declared graph.
182
+ *
183
+ * Excluded: self-pairs, pages that are not sitemap-eligible, pairs already
184
+ * declared as a `related` edge, and — when {@link LinkCandidateOptions.renderedEdges}
185
+ * is supplied — pairs already rendered as an anchor. Queries, hashes, and
186
+ * trailing slashes are normalized so both sides compare by the graph's path key.
187
+ */
188
+ declare const generateLinkCandidates: (graph: SeoGraph, options?: LinkCandidateOptions) => LinkCandidateResult;
189
+ /**
190
+ * Decode a rendered-edge dump supplied to the CLI: either a bare array of
191
+ * `{ from, to }` edges, or an object carrying `edges` (or `internalEdges`).
192
+ * Throws on any other shape — this is user input, not a provider payload.
193
+ */
194
+ declare const decodeRenderedEdges: (input: unknown) => ReadonlyArray<SimpleEdge>;
78
195
  //#endregion
79
196
  //#region src/core/inspect-html.d.ts
80
197
  /**
@@ -137,5 +254,5 @@ declare const hasBlockingIssues: (report: LiveHeadReport) => boolean;
137
254
  */
138
255
  declare function resolveRouteLink(router: AnyRouter, path: string): RouteSeo["link"];
139
256
  //#endregion
140
- export { type BuildSeoGraphInput, type JsonLdReport, type LiveHeadReport, type NodeReport, type ProjectionConfig, type PublicPath, type Register, type RobotsConfig, type RouteSeo, type SeoCollection, type SeoEdge, type SeoEdgeType, type SeoGraph, type SeoInstance, type SeoKind, type SeoNode, type SeoSource, type Severity, type SitemapPolicy, type Violation, buildSeoGraph, checkGraph, contentSignal, hasBlockingIssues, hasStructuralViolations, inspectHtml, inspectNode, renderRobots, renderSitemap, resolveRouteLink };
257
+ export { type Anchor, type AnchorRegion, type BuildSeoGraphInput, type CoverageRule, type JsonLdReport, type LinkCandidateOptions, type LinkCandidatePair, type LinkCandidateResult, type LinkClusterSummary, type LinkEdge, type LinkGraphDiff, type LiveHeadReport, type NodeReport, type ProjectionConfig, type PublicPath, type Register, type RenderedGraph, type RenderedPage, type RobotsConfig, type RouteSeo, type SeoCollection, type SeoEdge, type SeoEdgeType, type SeoGraph, type SeoInstance, type SeoKind, type SeoNode, type SeoSource, type Severity, type SimpleEdge, type SitemapPolicy, type Violation, buildRenderedGraph, buildSeoGraph, candidateSourceText, checkCoverage, checkGraph, contentSignal, decodeRenderedEdges, diffLinkGraph, extractAnchors, generateLinkCandidates, hasBlockingIssues, hasStructuralViolations, inspectHtml, inspectNode, isSitemapEligible, matchesClusterFilter, normalizePath, renderRobots, renderSitemap, resolveRouteLink, undirectedEdgeKey };
141
258
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { i as normalizePath, n as diffLinkGraph, r as extractAnchors, t as buildRenderedGraph } from "./links-sGbLkl-7.js";
1
2
  //#region src/core/graph.ts
2
3
  /** Kind for instances whose collection route carries no declaration to inherit. */
3
4
  const FALLBACK_KIND = "page";
@@ -425,19 +426,186 @@ const CHECK_RULES = [
425
426
  })
426
427
  }
427
428
  ];
428
- /** Run every rule against the graph and return the flat list of violations. */
429
- function checkGraph(graph) {
430
- return CHECK_RULES.flatMap((rule) => rule.evaluate(graph).map((raw) => ({
429
+ /**
430
+ * Run every static rule against the graph, then any caller-supplied
431
+ * {@link CoverageRule}s, and return the flat list of violations. With no
432
+ * `coverage` option the result is exactly the static rule set.
433
+ */
434
+ function checkGraph(graph, options = {}) {
435
+ const violations = CHECK_RULES.flatMap((rule) => rule.evaluate(graph).map((raw) => ({
431
436
  severity: rule.severity,
432
437
  rule: rule.name,
433
438
  path: raw.path,
434
439
  message: raw.message,
435
440
  fix: raw.fix
436
441
  })));
442
+ const coverage = options.coverage;
443
+ if (coverage !== void 0 && coverage.length > 0) violations.push(...checkCoverage(graph, coverage));
444
+ return violations;
445
+ }
446
+ /** Escape a glob for `RegExp`, then expand `**`, `*`, and `?` to path-aware forms. */
447
+ const globToRegExp = (glob) => {
448
+ const source = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/\u0000/g, ".*");
449
+ return new RegExp(`^${source}$`);
450
+ };
451
+ /**
452
+ * Enforce contextual-link coverage rules: a named set of sitemap-eligible
453
+ * "money" pages each needs `minInbound` incoming `related` edges. Only
454
+ * `related` edges count — breadcrumb ancestry is navigation, not context.
455
+ *
456
+ * A rule that matches no sitemap-eligible page is itself a violation: a typo or
457
+ * a rule aimed at a noindex page would otherwise pass silently forever.
458
+ */
459
+ function checkCoverage(graph, rules) {
460
+ const violations = [];
461
+ for (const rule of rules) {
462
+ const matcher = globToRegExp(rule.path);
463
+ const matched = [...graph.nodes.values()].filter((node) => matcher.test(node.path));
464
+ const eligible = matched.filter(isSitemapEligible);
465
+ if (eligible.length === 0) {
466
+ violations.push({
467
+ severity: "structural",
468
+ rule: "coverage-rule-unmatched",
469
+ message: matched.length === 0 ? `Coverage rule "${rule.path}" matches no page in the graph.` : `Coverage rule "${rule.path}" matches only pages that are not sitemap-eligible.`,
470
+ fix: "Point the rule at a sitemap-eligible path, or drop it."
471
+ });
472
+ continue;
473
+ }
474
+ for (const node of eligible) {
475
+ const inbound = graph.edges.filter((edge) => edge.type === "related" && edge.to === node.path).length;
476
+ if (inbound >= rule.minInbound) continue;
477
+ violations.push({
478
+ severity: "structural",
479
+ rule: "inbound-link-coverage",
480
+ path: node.path,
481
+ message: `"${node.path}" has ${inbound} incoming contextual link(s); the coverage rule requires ${rule.minInbound}.`,
482
+ fix: `Add ${rule.minInbound - inbound} contextual (related) edge(s) pointing at "${node.path}".`
483
+ });
484
+ }
485
+ }
486
+ return violations;
437
487
  }
438
488
  /** Structural violations fail `pagegraph check`; editorial-only stays green. */
439
489
  const hasStructuralViolations = (violations) => violations.some((violation) => violation.severity === "structural");
440
490
  //#endregion
491
+ //#region src/core/link-candidates.ts
492
+ /**
493
+ * Canonical path key: query and hash stripped, trailing slashes removed, "/"
494
+ * preserved. This is the same path key the graph and the rendered-link core
495
+ * use, so a served anchor like `/blog/a?ref=nav` still matches the graph pair
496
+ * `/blog/a`.
497
+ */
498
+ const normalize = (path) => {
499
+ const trimmed = (path.split(/[?#]/, 1)[0] ?? "").replace(/\/+$/, "");
500
+ return trimmed === "" ? "/" : trimmed;
501
+ };
502
+ /** Directionless pair key, so an edge in either direction means "connected". */
503
+ const undirectedEdgeKey = (from, to) => {
504
+ const [a, b] = [normalize(from), normalize(to)].sort();
505
+ return `${a}\u0000${b}`;
506
+ };
507
+ /** First path segment, or undefined for the root page. */
508
+ const topSegment = (path) => {
509
+ const segment = path.split("/").filter(Boolean)[0];
510
+ return segment === void 0 || segment === "" ? void 0 : segment;
511
+ };
512
+ /** A page with no nested segment (`/`, `/pricing`, `/blog`) is root-level. */
513
+ const isRootLevel = (path) => path.split("/").filter(Boolean).length <= 1;
514
+ /**
515
+ * The cluster a pair shares, or undefined when they share none. Section-first:
516
+ * a shared top-level section wins. The kind fallback is local to root-level
517
+ * pages, so it never pairs two nested pages from different sections.
518
+ */
519
+ const clusterOf = (a, b) => {
520
+ const aSegment = topSegment(a.path);
521
+ const bSegment = topSegment(b.path);
522
+ if (aSegment !== void 0 && aSegment === bSegment) return {
523
+ key: aSegment,
524
+ reason: `same top-level section "/${aSegment}"`
525
+ };
526
+ if (isRootLevel(a.path) && isRootLevel(b.path) && a.kind === b.kind) return {
527
+ key: `kind:${a.kind}`,
528
+ reason: `same kind "${a.kind}"`
529
+ };
530
+ };
531
+ /** A `--cluster` filter matches a section by name, or a kind via its bare label. */
532
+ const matchesClusterFilter = (cluster, filter) => {
533
+ const normalized = filter.replace(/^\/+/, "").toLowerCase();
534
+ const key = cluster.toLowerCase();
535
+ return key === normalized || key === `kind:${normalized}`;
536
+ };
537
+ /** Human reason string for a candidate's source page, used when handing a plan to Jev. */
538
+ const candidateSourceText = (node) => node.instance?.description?.trim() || node.instance?.title?.trim() || node.policy.link?.description?.trim() || node.policy.link?.title?.trim() || node.path;
539
+ /**
540
+ * Enumerate reviewable contextual-link candidates from the declared graph.
541
+ *
542
+ * Excluded: self-pairs, pages that are not sitemap-eligible, pairs already
543
+ * declared as a `related` edge, and — when {@link LinkCandidateOptions.renderedEdges}
544
+ * is supplied — pairs already rendered as an anchor. Queries, hashes, and
545
+ * trailing slashes are normalized so both sides compare by the graph's path key.
546
+ */
547
+ const generateLinkCandidates = (graph, options = {}) => {
548
+ const limit = options.limit ?? 50;
549
+ const filters = options.clusters ?? [];
550
+ const rendered = new Set((options.renderedEdges ?? []).map((edge) => undirectedEdgeKey(edge.from, edge.to)));
551
+ const declared = new Set(graph.edges.filter((edge) => edge.type === "related").map((edge) => undirectedEdgeKey(edge.from, edge.to)));
552
+ const eligible = [...graph.nodes.values()].filter(isSitemapEligible);
553
+ const all = [];
554
+ for (let i = 0; i < eligible.length; i++) for (let j = i + 1; j < eligible.length; j++) {
555
+ const a = eligible[i];
556
+ const b = eligible[j];
557
+ const cluster = clusterOf(a, b);
558
+ if (cluster === void 0) continue;
559
+ if (filters.length > 0 && !filters.some((filter) => matchesClusterFilter(cluster.key, filter))) continue;
560
+ const key = undirectedEdgeKey(a.path, b.path);
561
+ if (declared.has(key) || rendered.has(key)) continue;
562
+ all.push({
563
+ source: a.path,
564
+ destination: b.path,
565
+ cluster: cluster.key,
566
+ reason: cluster.reason
567
+ });
568
+ all.push({
569
+ source: b.path,
570
+ destination: a.path,
571
+ cluster: cluster.key,
572
+ reason: cluster.reason
573
+ });
574
+ }
575
+ const compare = (x, y) => x.cluster < y.cluster ? -1 : x.cluster > y.cluster ? 1 : x.source < y.source ? -1 : x.source > y.source ? 1 : x.destination < y.destination ? -1 : x.destination > y.destination ? 1 : 0;
576
+ all.sort(compare);
577
+ const counts = /* @__PURE__ */ new Map();
578
+ for (const pair of all) counts.set(pair.cluster, (counts.get(pair.cluster) ?? 0) + 1);
579
+ const candidates = all.slice(0, limit);
580
+ return {
581
+ candidates,
582
+ total: all.length,
583
+ truncated: all.length > candidates.length,
584
+ clusters: [...counts.entries()].map(([key, count]) => ({
585
+ key,
586
+ candidates: count
587
+ }))
588
+ };
589
+ };
590
+ /**
591
+ * Decode a rendered-edge dump supplied to the CLI: either a bare array of
592
+ * `{ from, to }` edges, or an object carrying `edges` (or `internalEdges`).
593
+ * Throws on any other shape — this is user input, not a provider payload.
594
+ */
595
+ const decodeRenderedEdges = (input) => {
596
+ const array = Array.isArray(input) ? input : input !== null && typeof input === "object" && Array.isArray(input.edges) ? input.edges : input !== null && typeof input === "object" && Array.isArray(input.internalEdges) ? input.internalEdges : void 0;
597
+ if (array === void 0) throw new Error("expected an array of { from, to } edges, or an object with an `edges` array");
598
+ return array.map((item) => {
599
+ if (item === null || typeof item !== "object") throw new Error("each rendered edge must be an object with string `from` and `to`");
600
+ const { from, to } = item;
601
+ if (typeof from !== "string" || typeof to !== "string") throw new Error("each rendered edge must have string `from` and `to`");
602
+ return {
603
+ from,
604
+ to
605
+ };
606
+ });
607
+ };
608
+ //#endregion
441
609
  //#region src/core/inspect-html.ts
442
610
  const ENTITIES = {
443
611
  "&amp;": "&",
@@ -603,6 +771,6 @@ function resolveRouteLink(router, path) {
603
771
  return Object.values(routesById).find((route) => route.fullPath === path)?.options.staticData?.seo?.link;
604
772
  }
605
773
  //#endregion
606
- export { buildSeoGraph, checkGraph, contentSignal, hasBlockingIssues, hasStructuralViolations, inspectHtml, inspectNode, renderRobots, renderSitemap, resolveRouteLink };
774
+ export { buildRenderedGraph, buildSeoGraph, candidateSourceText, checkCoverage, checkGraph, contentSignal, decodeRenderedEdges, diffLinkGraph, extractAnchors, generateLinkCandidates, hasBlockingIssues, hasStructuralViolations, inspectHtml, inspectNode, isSitemapEligible, matchesClusterFilter, normalizePath, renderRobots, renderSitemap, resolveRouteLink, undirectedEdgeKey };
607
775
 
608
776
  //# sourceMappingURL=index.js.map