diff --git a/.changeset/canonical-config-api.md b/.changeset/canonical-config-api.md new file mode 100644 index 00000000..42b9eb61 --- /dev/null +++ b/.changeset/canonical-config-api.md @@ -0,0 +1,11 @@ +--- +"leadtype": minor +--- + +Add a canonical config vocabulary and one resolved-config normalizer. + +`defineLeadtypeConfig` names the project/site config in `leadtype.config.ts`, alongside `defineDocsConfig` for a source repo's content-owned `docs.config.ts`. Three collection fields are renamed: `prefix` → `routePrefix`, `sourceConfig` → `inheritConfig`, `schema` → `frontmatterSchema`. + +Every existing config keeps working. Both spellings normalize to one internal `ResolvedDocsConfig` that generate, sync, lint, score, and the runtime source all read, so no subsystem re-derives the project from raw config. The resolved shape adds a deduped source graph — collections sharing a `(repository, ref)` resolve to one acquisition — and per-field provenance recording whether each value was authored, inherited from a source repo, inferred, or defaulted. + +Deprecated fields carry `@deprecated` guidance in the IDE and warn once per config file at load. Setting an old name and its replacement together is an error naming both rather than a silent precedence rule. Nothing is removed before 1.0. diff --git a/apps/next-example/leadtype.config.ts b/apps/next-example/leadtype.config.ts index 5e2c1094..4dfd689d 100644 --- a/apps/next-example/leadtype.config.ts +++ b/apps/next-example/leadtype.config.ts @@ -1,7 +1,7 @@ import { execSync } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { defineCollection, defineDocsConfig } from "leadtype"; +import { defineCollection, defineLeadtypeConfig } from "leadtype"; const appDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(appDir, "../.."); @@ -33,7 +33,7 @@ function resolveExampleSourceRef(): string { } } -export default defineDocsConfig({ +export default defineLeadtypeConfig({ product: { name: "Leadtype", tagline: @@ -53,8 +53,8 @@ export default defineDocsConfig({ cacheDir: ".leadtype", dir: "docs", mounts: [{ pathPrefix: "changelog", urlPrefix: "/changelog" }], - prefix: "/docs", - sourceConfig: true, + routePrefix: "/docs", + inheritConfig: true, }), }, feeds: [ diff --git a/apps/sveltekit-example/static/feeds/schema.jsonl b/apps/sveltekit-example/static/feeds/schema.jsonl new file mode 100644 index 00000000..5cd6fa06 --- /dev/null +++ b/apps/sveltekit-example/static/feeds/schema.jsonl @@ -0,0 +1,55 @@ +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/concepts/methodology","url":"http://localhost:5173/docs/concepts/methodology","name":"Methodology","description":"Where leadtype fits alongside custom docs apps and frameworks like Fumadocs and Starlight — the portable content and agent-readability layer underneath the host you choose.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/concepts/architecture","url":"http://localhost:5173/docs/concepts/architecture","name":"Architecture","description":"The core / adapter boundary — what ships where, and the rules adapters must follow.","dateModified":"2026-06-29T18:28:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/concepts/config-model","url":"http://localhost:5173/docs/concepts/config-model","name":"The config model","description":"The canonical config vocabulary, the resolved project every subsystem reads, and how older field names keep working.","dateModified":"2026-08-04T08:43:31.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/concepts/evals","url":"http://localhost:5173/docs/concepts/evals","name":"Evals","description":"How Leadtype measures whether bundling agent docs actually helps coding agents — and how the llms.txt defaults were chosen.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/pipeline/configure-sources","url":"http://localhost:5173/docs/pipeline/configure-sources","name":"Configure docs sources","description":"Choose where Leadtype reads MDX from: one local docs folder, multiple mounted folders, or remote git collections pinned to a branch, tag, or commit.","dateModified":"2026-08-03T20:38:04.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/pipeline/collections","url":"http://localhost:5173/docs/pipeline/collections","name":"Collections reference","description":"Detailed defineCollection behavior for multi-source docs: local folders, git repos, filters, schemas, and per-collection navigation.","dateModified":"2026-08-04T07:36:51.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/pipeline/sync-docs-across-repos","url":"http://localhost:5173/docs/pipeline/sync-docs-across-repos","name":"Sync docs across repositories","description":"Keep a separate docs UI repository pinned to a reviewed package-docs source revision.","dateModified":"2026-08-03T20:38:04.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/pipeline/build-a-docs-site","url":"http://localhost:5173/docs/pipeline/build-a-docs-site","name":"Build an agent-ready docs site","description":"Pick the right Leadtype integration shape for a hosted docs site with rendered pages, markdown mirrors, llms.txt, search, and agent metadata.","dateModified":"2026-08-03T20:38:04.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/pipeline/use-the-source-primitive","url":"http://localhost:5173/docs/pipeline/use-the-source-primitive","name":"Use the source primitive","description":"Wire createDocsSource into Next, TanStack Start, Nuxt, Astro, SvelteKit, or any MDX-aware bundler. Same primitive, multiple host shapes.","dateModified":"2026-08-04T08:43:31.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/pipeline/agent-setup-prompts","url":"http://localhost:5173/docs/pipeline/agent-setup-prompts","name":"Agent setup prompts","description":"Copyable prompts that let a coding agent wire Leadtype into your app — local docs, external/multi-repo docs, or a package bundle — adapting to your real layout.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/pipeline/generate-static-artifacts","url":"http://localhost:5173/docs/pipeline/generate-static-artifacts","name":"Generate static artifacts","description":"Run leadtype generate from your build pipeline to write llms.txt, markdown mirrors, search index, sitemap, and agent-readability files to disk.","dateModified":"2026-07-06T21:49:34.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/pipeline/generate-rss-atom-feeds","url":"http://localhost:5173/docs/pipeline/generate-rss-atom-feeds","name":"Generate RSS and Atom feeds","description":"Configure Leadtype to emit RSS and Atom feeds for changelogs, blogs, release notes, or any URL-prefixed generated docs content.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/pipeline/deploy-generated-artifacts","url":"http://localhost:5173/docs/pipeline/deploy-generated-artifacts","name":"Deploy generated artifacts","description":"Serve Leadtype output on common framework and hosting combinations.","dateModified":"2026-07-06T21:49:34.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/pipeline/validate-in-ci","url":"http://localhost:5173/docs/pipeline/validate-in-ci","name":"CI for your docs","description":"leadtype lint verifies every internal link against your real route graph and typechecks code snippets against your installed packages — docs that can't silently rot.","dateModified":"2026-07-06T22:25:20.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/pipeline/redirects","url":"http://localhost:5173/docs/pipeline/redirects","name":"Redirect renamed pages","description":"Track renamed and deleted docs pages with a committed lockfile, emit redirects.json, and serve 308/410 responses.","dateModified":"2026-07-06T21:49:34.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/pipeline/localize-docs","url":"http://localhost:5173/docs/pipeline/localize-docs","name":"Localize docs","description":"Author multi-locale MDX, generate per-locale llms.txt and markdown mirrors, and serve locale-prefixed docs with alternate-locale links.","dateModified":"2026-07-06T21:49:34.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/aeo/overview","url":"http://localhost:5173/docs/aeo/overview","name":"AEO & Agent Readability overview","description":"Every agent-facing artifact leadtype emits, how they map to the agent-readability spec and AEO scoring rubrics, and how to audit a site.","dateModified":"2026-06-13T02:08:36.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/aeo/optimize-docs-for-agents","url":"http://localhost:5173/docs/aeo/optimize-docs-for-agents","name":"Optimize docs for agents","description":"Generate llms.txt, markdown mirrors, JSON-LD inputs, sitemaps, robots.txt, and agent-readability.json from one CLI run.","dateModified":"2026-07-06T20:30:24.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/aeo/generate-artifacts-without-docs","url":"http://localhost:5173/docs/aeo/generate-artifacts-without-docs","name":"Generate artifacts without a docs tree","description":"Emit llms.txt, markdown mirrors, sitemaps, robots.txt, and the agent-readability manifest from an in-memory page list — no .mdx source files required.","dateModified":"2026-07-06T21:49:34.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/aeo/serve-agent-responses","url":"http://localhost:5173/docs/aeo/serve-agent-responses","name":"Serve agent responses","description":"Wire markdown responses, JSON-LD, sitemap, and robots into your framework using the generated agent-readability.json manifest.","dateModified":"2026-07-06T21:49:34.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/reference/skills","url":"http://localhost:5173/docs/reference/skills","name":"Agent skills","description":"Emit a discoverable SKILL.md surface (agentskills.io) from docs.config.ts — an auto docs-skill plus any you declare — to /.well-known/agent-skills and the package bundle.","dateModified":"2026-08-03T20:27:12.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/reference/mcp","url":"http://localhost:5173/docs/reference/mcp","name":"MCP server","description":"Serve docs to MCP clients over stdio or Streamable HTTP — the gate for when it's worth it, the optional-peer-dep and stateless-HTTP gotchas, and how edge/bundled hosts skip the disk path.","dateModified":"2026-06-12T22:00:22.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/reference/webmcp","url":"http://localhost:5173/docs/reference/webmcp","name":"WebMCP","description":"Register generated docs as browser-side WebMCP tools with document.modelContext / navigator.modelContext — separate from the server MCP endpoint.","dateModified":"2026-07-06T21:49:34.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/reference/nlweb","url":"http://localhost:5173/docs/reference/nlweb","name":"NLWeb","description":"Serve an NLWeb /ask endpoint over your generated docs and publish the schema feeds + robots.txt Schemamap directive that make the site conversational for agents.","dateModified":"2026-06-29T16:57:13.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/writing/write-for-agents","url":"http://localhost:5173/docs/writing/write-for-agents","name":"Write for agents & GEO","description":"Authoring for agents and the answer engines that cite you, in two halves: what to write (the non-obvious, not restatement) and how to structure it (lead with the answer, question-form headings, self-contained sections).","dateModified":"2026-07-06T22:25:20.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/writing/frontmatter","url":"http://localhost:5173/docs/writing/frontmatter","name":"Frontmatter","description":"Required fields, optional taxonomy metadata, and how authored MDX becomes a navigation tree.","dateModified":"2026-08-03T23:17:13.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/writing/components","url":"http://localhost:5173/docs/writing/components","name":"Components","description":"MDX components the pipeline knows how to flatten into agent-readable markdown.","dateModified":"2026-07-06T13:38:04.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/search/add-search","url":"http://localhost:5173/docs/search/add-search","name":"Add search","description":"Generate a static docs search index, query it at runtime, and wire a search UI with the React, Vue, or Svelte hooks.","dateModified":"2026-07-06T21:49:34.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/search/ai-answers","url":"http://localhost:5173/docs/search/ai-answers","name":"Stream AI answers","description":"Source-grounded answer streaming over the static index — Vercel AI SDK, TanStack AI, or Cloudflare Workers AI — behind a hardened endpoint.","dateModified":"2026-07-06T21:49:34.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/search/agent-tools","url":"http://localhost:5173/docs/search/agent-tools","name":"Agent search tools","description":"Expose docs as a read-only virtual filesystem so an agent can explore with ls, cat, find, grep, and rg instead of receiving pre-selected chunks.","dateModified":"2026-07-06T21:49:34.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/package-docs/bundle","url":"http://localhost:5173/docs/package-docs/bundle","name":"Bundle docs into a package","description":"Ship agent-readable docs inside an npm tarball — AGENTS.md at the package root plus per-topic .md files.","dateModified":"2026-07-06T21:49:34.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/integrations/framework-matrix","url":"http://localhost:5173/docs/integrations/framework-matrix","name":"Framework integration matrix","description":"Use Leadtype with native-feeling recipes for Next, TanStack Start, Nuxt, Astro, SvelteKit, and Fumadocs.","dateModified":"2026-07-06T21:49:34.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/integrations/integrate-with-fumadocs","url":"http://localhost:5173/docs/integrations/integrate-with-fumadocs","name":"Integrate with Fumadocs","description":"Wire leadtype's content layer into a fumadocs app for nav, search, and includes.","dateModified":"2026-07-07T08:31:05.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/reference/cli","url":"http://localhost:5173/docs/reference/cli","name":"CLI","description":"leadtype init, doctor, nav, generate, sync, lint, mcp, and score — flags, exit codes, and JSON output.","dateModified":"2026-08-04T07:42:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/reference/doctor","url":"http://localhost:5173/docs/reference/doctor","name":"leadtype doctor","description":"Read-only diagnosis of the resolved project — which config was found, where each value came from, which collections share a clone, what routes exist, and which command fixes what.","dateModified":"2026-08-04T07:39:39.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/reference/source","url":"http://localhost:5173/docs/reference/source","name":"createDocsSource","description":"Framework-neutral docs source primitive — navigation, page loader, search index, and include resolver.","dateModified":"2026-07-06T21:49:34.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/reference/llm","url":"http://localhost:5173/docs/reference/llm","name":"LLM files","description":"Generate llms.txt for hosted websites and AGENTS.md for npm-bundled offline reading.","dateModified":"2026-07-06T21:49:34.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/reference/convert","url":"http://localhost:5173/docs/reference/convert","name":"Convert","description":"MDX-to-markdown conversion APIs from leadtype/convert.","dateModified":"2026-07-06T21:49:34.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/reference/lint","url":"http://localhost:5173/docs/reference/lint","name":"Lint rules","description":"Schema, link, and navigation checks. CLI and library API.","dateModified":"2026-07-06T22:25:20.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/reference/frontmatter-transformers","url":"http://localhost:5173/docs/reference/frontmatter-transformers","name":"Frontmatter transformers","description":"Define typed custom frontmatter and lifecycle hooks for Leadtype pipeline data.","dateModified":"2026-07-06T21:49:34.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/reference/mdx","url":"http://localhost:5173/docs/reference/mdx","name":"leadtype/mdx","description":"Tag type contracts and the build-time source preset for consumers rendering MDX themselves.","dateModified":"2026-07-07T08:31:05.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/reference/markdown","url":"http://localhost:5173/docs/reference/markdown","name":"Markdown transforms","description":"The default transform stack that flattens MDX components into markdown.","dateModified":"2026-07-06T21:49:34.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/reference/openapi","url":"http://localhost:5173/docs/reference/openapi","name":"OpenAPI","description":"Generate native MDX API reference pages from OpenAPI 3.x specs.","dateModified":"2026-07-07T20:10:45.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/reference/search","url":"http://localhost:5173/docs/reference/search","name":"Search","description":"API surface for leadtype/search: index generation, runtime query, framework hooks, answer streaming, bash tools, and endpoint guards.","dateModified":"2026-06-04T16:15:13.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/reference/i18n","url":"http://localhost:5173/docs/reference/i18n","name":"i18n","description":"Localization config, locale-aware URL helpers, alternate-locale links, and the per-locale artifact manifest from leadtype/i18n.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/reference/troubleshooting","url":"http://localhost:5173/docs/reference/troubleshooting","name":"Troubleshooting","description":"Common Leadtype errors — missing manifests, unknown groups, broken includes, content negotiation, and the base-url audit mismatch — with fixes.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/changelog/0-4","url":"http://localhost:5173/changelog/0-4","name":"Leadtype 0.4","description":"Release notes for Leadtype 0.4: docs linting from links to typechecked snippets, OpenAPI reference generation, watch mode with incremental builds, redirect tracking, and the native markdown pipeline.","dateModified":"2026-07-07T09:15:32.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/changelog/0-3","url":"http://localhost:5173/changelog/0-3","name":"Leadtype 0.3","description":"Release notes for Leadtype 0.3, focused on browser-side WebMCP docs tools, RSS and Atom feed generation, and URL-prefixed docs content.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/changelog/0-2","url":"http://localhost:5173/changelog/0-2","name":"Leadtype 0.2","description":"Release notes for Leadtype 0.2, released June 3, 2026.","dateModified":"2026-06-07T09:11:55.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/rest-api","url":"http://localhost:5173/docs/rest-api","name":"Leadtype REST API","description":"Generated from docs/openapi/leadtype-api.yaml to dogfood native API reference pages.","dateModified":"2026-07-06T13:38:04.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/rest-api/pages/get-docs-page","url":"http://localhost:5173/docs/rest-api/pages/get-docs-page","name":"Get a docs page","description":"Fetch one generated docs page as agent-readable markdown, including frontmatter. The `urlPath` matches entries returned by search.","dateModified":"2026-07-06T13:38:04.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/rest-api/search/search-docs","url":"http://localhost:5173/docs/rest-api/search/search-docs","name":"Search docs","description":"Search a generated Leadtype docs index and return matching docs chunks.","dateModified":"2026-07-06T13:38:04.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs","url":"http://localhost:5173/docs","name":"Leadtype","description":"Build agent-ready docs from MDX: rendered pages, llms.txt, markdown mirrors, search output, and package-bundled AGENTS.md from the same source.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/quickstart","url":"http://localhost:5173/docs/quickstart","name":"Quickstart","description":"Build an agent-ready docs site from one MDX page: render it, generate llms.txt and markdown mirrors, then verify the output.","dateModified":"2026-08-03T20:47:46.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} +{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:5173/docs/how-it-works","url":"http://localhost:5173/docs/how-it-works","name":"How it works","description":"The mental model: one MDX source, a markdown transform pipeline, two output modes, three audiences.","dateModified":"2026-06-29T18:28:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:5173"}} diff --git a/apps/sveltekit-example/static/mcp.json b/apps/sveltekit-example/static/mcp.json new file mode 100644 index 00000000..1bd6b87c --- /dev/null +++ b/apps/sveltekit-example/static/mcp.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/mcp-server-card/v1.json", + "version": "1.0", + "protocolVersion": "2025-06-18", + "name": "leadtype-docs", + "description": "A docs pipeline that turns one MDX source into a website, agent-readable artifacts, and a search index.", + "serverUrl": "http://localhost:5173/mcp", + "tools": [ + { + "name": "search-docs", + "title": "Search documentation", + "description": "Search the documentation and return ranked results ({ title, urlPath, snippet }). Use get-page to read a full result.", + "annotations": { + "idempotentHint": true, + "readOnlyHint": true + } + }, + { + "name": "get-page", + "title": "Get a documentation page", + "description": "Return the full Markdown of one documentation page by its urlPath (e.g. the urlPath from a search-docs result).", + "annotations": { + "idempotentHint": true, + "readOnlyHint": true + } + } + ], + "serverInfo": { + "name": "leadtype-docs", + "version": "1.0.0", + "description": "A docs pipeline that turns one MDX source into a website, agent-readable artifacts, and a search index.", + "instructions": "Search and read the documentation for A docs pipeline that turns one MDX source into a website, agent-readable artifacts, and a search index." + }, + "transport": { + "type": "streamable-http", + "endpoint": "http://localhost:5173/mcp" + }, + "capabilities": { + "tools": {} + }, + "authentication": { + "required": false + } +} diff --git a/apps/sveltekit-example/static/robots.txt b/apps/sveltekit-example/static/robots.txt new file mode 100644 index 00000000..c5e372de --- /dev/null +++ b/apps/sveltekit-example/static/robots.txt @@ -0,0 +1,211 @@ +User-agent: * +Content-Signal: ai-train=no, search=yes, ai-input=yes +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: OAI-SearchBot +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: ChatGPT-User +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: PerplexityBot +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: Perplexity-User +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: ClaudeBot +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: Claude-SearchBot +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: Claude-User +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: Claude-Web +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: Gemini-Deep-Research +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: DeepSeekBot +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: Meta-ExternalFetcher +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: AmazonBot +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: Amazonbot +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: Bingbot +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: MistralBot +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: AppleBot +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: YouBot +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: GPTBot +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: Google-Extended +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: CCBot +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: ByteSpider +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: Bytespider +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: anthropic-ai +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: MetaExternalAgent +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +User-agent: Applebot-Extended +Allow: / +Allow: /docs/ +Allow: /llms.txt +Allow: /docs/llms.txt +Allow: /sitemap.xml +Allow: /sitemap.md + +Sitemap: http://localhost:5173/sitemap.xml +Schemamap: http://localhost:5173/schema-map.xml diff --git a/apps/sveltekit-example/static/schema-map.xml b/apps/sveltekit-example/static/schema-map.xml new file mode 100644 index 00000000..92fb4200 --- /dev/null +++ b/apps/sveltekit-example/static/schema-map.xml @@ -0,0 +1,8 @@ + + + + http://localhost:5173/feeds/schema.jsonl + application/jsonl + https://schema.org/TechArticle + + diff --git a/apps/sveltekit-example/static/sitemap.md b/apps/sveltekit-example/static/sitemap.md new file mode 100644 index 00000000..1787ffee --- /dev/null +++ b/apps/sveltekit-example/static/sitemap.md @@ -0,0 +1,107 @@ +# Sitemap + +Structured documentation sitemap for Leadtype. + +## Concepts + +- [Methodology](/docs/concepts/methodology): Where leadtype fits alongside custom docs apps and frameworks like Fumadocs and Starlight — the portable content and agent-readability layer underneath the host you choose. +- [Architecture](/docs/concepts/architecture): The core / adapter boundary — what ships where, and the rules adapters must follow. +- [The config model](/docs/concepts/config-model): The canonical config vocabulary, the resolved project every subsystem reads, and how older field names keep working. +- [Evals](/docs/concepts/evals): How Leadtype measures whether bundling agent docs actually helps coding agents — and how the llms.txt defaults were chosen. + +## Docs Pipeline + +### Sources + +- [Configure docs sources](/docs/pipeline/configure-sources): Choose where Leadtype reads MDX from: one local docs folder, multiple mounted folders, or remote git collections pinned to a branch, tag, or commit. +- [Collections reference](/docs/pipeline/collections): Detailed defineCollection behavior for multi-source docs: local folders, git repos, filters, schemas, and per-collection navigation. +- [Sync docs across repositories](/docs/pipeline/sync-docs-across-repos): Keep a separate docs UI repository pinned to a reviewed package-docs source revision. + +### Build + +- [Build an agent-ready docs site](/docs/pipeline/build-a-docs-site): Pick the right Leadtype integration shape for a hosted docs site with rendered pages, markdown mirrors, llms.txt, search, and agent metadata. +- [Use the source primitive](/docs/pipeline/use-the-source-primitive): Wire createDocsSource into Next, TanStack Start, Nuxt, Astro, SvelteKit, or any MDX-aware bundler. Same primitive, multiple host shapes. +- [Agent setup prompts](/docs/pipeline/agent-setup-prompts): Copyable prompts that let a coding agent wire Leadtype into your app — local docs, external/multi-repo docs, or a package bundle — adapting to your real layout. + +### Generate & operate + +- [Generate static artifacts](/docs/pipeline/generate-static-artifacts): Run leadtype generate from your build pipeline to write llms.txt, markdown mirrors, search index, sitemap, and agent-readability files to disk. +- [Generate RSS and Atom feeds](/docs/pipeline/generate-rss-atom-feeds): Configure Leadtype to emit RSS and Atom feeds for changelogs, blogs, release notes, or any URL-prefixed generated docs content. +- [Deploy generated artifacts](/docs/pipeline/deploy-generated-artifacts): Serve Leadtype output on common framework and hosting combinations. +- [CI for your docs](/docs/pipeline/validate-in-ci): leadtype lint verifies every internal link against your real route graph and typechecks code snippets against your installed packages — docs that can't silently rot. +- [Redirect renamed pages](/docs/pipeline/redirects): Track renamed and deleted docs pages with a committed lockfile, emit redirects.json, and serve 308/410 responses. +- [Localize docs](/docs/pipeline/localize-docs): Author multi-locale MDX, generate per-locale llms.txt and markdown mirrors, and serve locale-prefixed docs with alternate-locale links. + +## AEO & Agent Readability + +- [AEO & Agent Readability overview](/docs/aeo/overview): Every agent-facing artifact leadtype emits, how they map to the agent-readability spec and AEO scoring rubrics, and how to audit a site. +- [Optimize docs for agents](/docs/aeo/optimize-docs-for-agents): Generate llms.txt, markdown mirrors, JSON-LD inputs, sitemaps, robots.txt, and agent-readability.json from one CLI run. +- [Generate artifacts without a docs tree](/docs/aeo/generate-artifacts-without-docs): Emit llms.txt, markdown mirrors, sitemaps, robots.txt, and the agent-readability manifest from an in-memory page list — no .mdx source files required. +- [Serve agent responses](/docs/aeo/serve-agent-responses): Wire markdown responses, JSON-LD, sitemap, and robots into your framework using the generated agent-readability.json manifest. +- [Agent skills](/docs/reference/skills): Emit a discoverable SKILL.md surface (agentskills.io) from docs.config.ts — an auto docs-skill plus any you declare — to /.well-known/agent-skills and the package bundle. +- [MCP server](/docs/reference/mcp): Serve docs to MCP clients over stdio or Streamable HTTP — the gate for when it's worth it, the optional-peer-dep and stateless-HTTP gotchas, and how edge/bundled hosts skip the disk path. +- [WebMCP](/docs/reference/webmcp): Register generated docs as browser-side WebMCP tools with document.modelContext / navigator.modelContext — separate from the server MCP endpoint. +- [NLWeb](/docs/reference/nlweb): Serve an NLWeb /ask endpoint over your generated docs and publish the schema feeds + robots.txt Schemamap directive that make the site conversational for agents. + +## Writing for Agents + +- [Write for agents & GEO](/docs/writing/write-for-agents): Authoring for agents and the answer engines that cite you, in two halves: what to write (the non-obvious, not restatement) and how to structure it (lead with the answer, question-form headings, self-contained sections). +- [Frontmatter](/docs/writing/frontmatter): Required fields, optional taxonomy metadata, and how authored MDX becomes a navigation tree. +- [Components](/docs/writing/components): MDX components the pipeline knows how to flatten into agent-readable markdown. + +## Search & AI Answers + +- [Add search](/docs/search/add-search): Generate a static docs search index, query it at runtime, and wire a search UI with the React, Vue, or Svelte hooks. +- [Stream AI answers](/docs/search/ai-answers): Source-grounded answer streaming over the static index — Vercel AI SDK, TanStack AI, or Cloudflare Workers AI — behind a hardened endpoint. +- [Agent search tools](/docs/search/agent-tools): Expose docs as a read-only virtual filesystem so an agent can explore with ls, cat, find, grep, and rg instead of receiving pre-selected chunks. + +## Package Docs + +- [Bundle docs into a package](/docs/package-docs/bundle): Ship agent-readable docs inside an npm tarball — AGENTS.md at the package root plus per-topic .md files. + +## Integrations + +- [Framework integration matrix](/docs/integrations/framework-matrix): Use Leadtype with native-feeling recipes for Next, TanStack Start, Nuxt, Astro, SvelteKit, and Fumadocs. +- [Integrate with Fumadocs](/docs/integrations/integrate-with-fumadocs): Wire leadtype's content layer into a fumadocs app for nav, search, and includes. + +## Reference + +- [CLI](/docs/reference/cli): leadtype init, doctor, nav, generate, sync, lint, mcp, and score — flags, exit codes, and JSON output. +- [leadtype doctor](/docs/reference/doctor): Read-only diagnosis of the resolved project — which config was found, where each value came from, which collections share a clone, what routes exist, and which command fixes what. +- [createDocsSource](/docs/reference/source): Framework-neutral docs source primitive — navigation, page loader, search index, and include resolver. +- [LLM files](/docs/reference/llm): Generate llms.txt for hosted websites and AGENTS.md for npm-bundled offline reading. +- [Convert](/docs/reference/convert): MDX-to-markdown conversion APIs from leadtype/convert. +- [Lint rules](/docs/reference/lint): Schema, link, and navigation checks. CLI and library API. +- [Frontmatter transformers](/docs/reference/frontmatter-transformers): Define typed custom frontmatter and lifecycle hooks for Leadtype pipeline data. +- [leadtype/mdx](/docs/reference/mdx): Tag type contracts and the build-time source preset for consumers rendering MDX themselves. +- [Markdown transforms](/docs/reference/markdown): The default transform stack that flattens MDX components into markdown. +- [OpenAPI](/docs/reference/openapi): Generate native MDX API reference pages from OpenAPI 3.x specs. +- [Search](/docs/reference/search): API surface for leadtype/search: index generation, runtime query, framework hooks, answer streaming, bash tools, and endpoint guards. +- [i18n](/docs/reference/i18n): Localization config, locale-aware URL helpers, alternate-locale links, and the per-locale artifact manifest from leadtype/i18n. +- [Troubleshooting](/docs/reference/troubleshooting): Common Leadtype errors — missing manifests, unknown groups, broken includes, content negotiation, and the base-url audit mismatch — with fixes. + +## Changelog + +- [Leadtype 0.4](/changelog/0-4): Release notes for Leadtype 0.4: docs linting from links to typechecked snippets, OpenAPI reference generation, watch mode with incremental builds, redirect tracking, and the native markdown pipeline. +- [Leadtype 0.3](/changelog/0-3): Release notes for Leadtype 0.3, focused on browser-side WebMCP docs tools, RSS and Atom feed generation, and URL-prefixed docs content. +- [Leadtype 0.2](/changelog/0-2): Release notes for Leadtype 0.2, released June 3, 2026. + +## Leadtype REST API + +Generated from docs/openapi/leadtype-api.yaml to dogfood native API reference pages. + +- [Leadtype REST API](/docs/rest-api): Generated from docs/openapi/leadtype-api.yaml to dogfood native API reference pages. + +### Pages + +- [Get a docs page](/docs/rest-api/pages/get-docs-page): Fetch one generated docs page as agent-readable markdown, including frontmatter. The `urlPath` matches entries returned by search. + +### Search + +- [Search docs](/docs/rest-api/search/search-docs): Search a generated Leadtype docs index and return matching docs chunks. + +## Other + +- [Leadtype](/docs): Build agent-ready docs from MDX: rendered pages, llms.txt, markdown mirrors, search output, and package-bundled AGENTS.md from the same source. +- [Quickstart](/docs/quickstart): Build an agent-ready docs site from one MDX page: render it, generate llms.txt and markdown mirrors, then verify the output. +- [How it works](/docs/how-it-works): The mental model: one MDX source, a markdown transform pipeline, two output modes, three audiences. diff --git a/apps/sveltekit-example/static/sitemap.xml b/apps/sveltekit-example/static/sitemap.xml new file mode 100644 index 00000000..2910e67c --- /dev/null +++ b/apps/sveltekit-example/static/sitemap.xml @@ -0,0 +1,223 @@ + + + + http://localhost:5173/docs/concepts/methodology + 2026-06-11T22:09:01.000Z + + + http://localhost:5173/docs/concepts/architecture + 2026-06-29T18:28:10.000Z + + + http://localhost:5173/docs/concepts/config-model + 2026-08-04T08:43:31.000Z + + + http://localhost:5173/docs/concepts/evals + 2026-06-11T22:09:01.000Z + + + http://localhost:5173/docs/pipeline/configure-sources + 2026-08-03T20:38:04.000Z + + + http://localhost:5173/docs/pipeline/collections + 2026-08-04T07:36:51.000Z + + + http://localhost:5173/docs/pipeline/sync-docs-across-repos + 2026-08-03T20:38:04.000Z + + + http://localhost:5173/docs/pipeline/build-a-docs-site + 2026-08-03T20:38:04.000Z + + + http://localhost:5173/docs/pipeline/use-the-source-primitive + 2026-08-04T08:43:31.000Z + + + http://localhost:5173/docs/pipeline/agent-setup-prompts + 2026-06-11T22:09:01.000Z + + + http://localhost:5173/docs/pipeline/generate-static-artifacts + 2026-07-06T21:49:34.000Z + + + http://localhost:5173/docs/pipeline/generate-rss-atom-feeds + 2026-06-11T22:09:01.000Z + + + http://localhost:5173/docs/pipeline/deploy-generated-artifacts + 2026-07-06T21:49:34.000Z + + + http://localhost:5173/docs/pipeline/validate-in-ci + 2026-07-06T22:25:20.000Z + + + http://localhost:5173/docs/pipeline/redirects + 2026-07-06T21:49:34.000Z + + + http://localhost:5173/docs/pipeline/localize-docs + 2026-07-06T21:49:34.000Z + + + http://localhost:5173/docs/aeo/overview + 2026-06-13T02:08:36.000Z + + + http://localhost:5173/docs/aeo/optimize-docs-for-agents + 2026-07-06T20:30:24.000Z + + + http://localhost:5173/docs/aeo/generate-artifacts-without-docs + 2026-07-06T21:49:34.000Z + + + http://localhost:5173/docs/aeo/serve-agent-responses + 2026-07-06T21:49:34.000Z + + + http://localhost:5173/docs/reference/skills + 2026-08-03T20:27:12.000Z + + + http://localhost:5173/docs/reference/mcp + 2026-06-12T22:00:22.000Z + + + http://localhost:5173/docs/reference/webmcp + 2026-07-06T21:49:34.000Z + + + http://localhost:5173/docs/reference/nlweb + 2026-06-29T16:57:13.000Z + + + http://localhost:5173/docs/writing/write-for-agents + 2026-07-06T22:25:20.000Z + + + http://localhost:5173/docs/writing/frontmatter + 2026-08-03T23:17:13.000Z + + + http://localhost:5173/docs/writing/components + 2026-07-06T13:38:04.000Z + + + http://localhost:5173/docs/search/add-search + 2026-07-06T21:49:34.000Z + + + http://localhost:5173/docs/search/ai-answers + 2026-07-06T21:49:34.000Z + + + http://localhost:5173/docs/search/agent-tools + 2026-07-06T21:49:34.000Z + + + http://localhost:5173/docs/package-docs/bundle + 2026-07-06T21:49:34.000Z + + + http://localhost:5173/docs/integrations/framework-matrix + 2026-07-06T21:49:34.000Z + + + http://localhost:5173/docs/integrations/integrate-with-fumadocs + 2026-07-07T08:31:05.000Z + + + http://localhost:5173/docs/reference/cli + 2026-08-04T07:42:10.000Z + + + http://localhost:5173/docs/reference/doctor + 2026-08-04T07:39:39.000Z + + + http://localhost:5173/docs/reference/source + 2026-07-06T21:49:34.000Z + + + http://localhost:5173/docs/reference/llm + 2026-07-06T21:49:34.000Z + + + http://localhost:5173/docs/reference/convert + 2026-07-06T21:49:34.000Z + + + http://localhost:5173/docs/reference/lint + 2026-07-06T22:25:20.000Z + + + http://localhost:5173/docs/reference/frontmatter-transformers + 2026-07-06T21:49:34.000Z + + + http://localhost:5173/docs/reference/mdx + 2026-07-07T08:31:05.000Z + + + http://localhost:5173/docs/reference/markdown + 2026-07-06T21:49:34.000Z + + + http://localhost:5173/docs/reference/openapi + 2026-07-07T20:10:45.000Z + + + http://localhost:5173/docs/reference/search + 2026-06-04T16:15:13.000Z + + + http://localhost:5173/docs/reference/i18n + 2026-06-11T22:09:01.000Z + + + http://localhost:5173/docs/reference/troubleshooting + 2026-06-11T22:09:01.000Z + + + http://localhost:5173/changelog/0-4 + 2026-07-07T09:15:32.000Z + + + http://localhost:5173/changelog/0-3 + 2026-06-11T22:09:01.000Z + + + http://localhost:5173/changelog/0-2 + 2026-06-07T09:11:55.000Z + + + http://localhost:5173/docs/rest-api + 2026-07-06T13:38:04.000Z + + + http://localhost:5173/docs/rest-api/pages/get-docs-page + 2026-07-06T13:38:04.000Z + + + http://localhost:5173/docs/rest-api/search/search-docs + 2026-07-06T13:38:04.000Z + + + http://localhost:5173/docs + 2026-06-11T22:09:01.000Z + + + http://localhost:5173/docs/quickstart + 2026-08-03T20:47:46.000Z + + + http://localhost:5173/docs/how-it-works + 2026-06-29T18:28:10.000Z + + diff --git a/docs/concepts/config-model.mdx b/docs/concepts/config-model.mdx new file mode 100644 index 00000000..cd92434c --- /dev/null +++ b/docs/concepts/config-model.mdx @@ -0,0 +1,149 @@ +--- +title: "The config model" +description: "The canonical config vocabulary, the resolved project every subsystem reads, and how older field names keep working." +related: + - title: "Collections" + href: "/docs/pipeline/collections" + description: "The multi-source config shape this page describes." + - title: "Configure docs sources" + href: "/docs/pipeline/configure-sources" + description: "Choosing between one folder, mounted folders, and remote collections." + - title: "Architecture" + href: "/docs/concepts/architecture" + description: "The core / adapter boundary the resolved config sits inside." +--- + +Leadtype's config started as a description of one MDX folder. It is now the project model: identity, source acquisition, navigation, feeds, redirects, agent surfaces, and multi-repo orchestration. Growth like that leaves fingerprints — a few fields carry names from the job they used to do. + +This page is the map: what the canonical vocabulary is, what every subsystem actually reads, and what happens to the older names. + +## Two config files, two owners + +The two entry points are structurally identical and differ in *ownership*. + +| File | Helper | Owned by | Declares | +| --- | --- | --- | --- | +| `leadtype.config.ts` (project root) | `defineLeadtypeConfig` | the site that publishes the docs | sources, routes, feeds, redirects, robots policy, agent surfaces | +| `docs.config.ts` (in a docs dir) | `defineDocsConfig` | the repo that owns the content | navigation, frontmatter schema, flatteners, mounts | + +A single-repo project has one file and the distinction does not matter — use whichever name reads better. It starts mattering when a docs site pins a source repo: the source repo ships its own `docs.config.ts`, and the site inherits the content-owned half of it with `inheritConfig`. Site controls never inherit — a source repo cannot set the robots policy of a site that renders it. + +```ts +// leadtype.config.ts — the docs site +import { defineLeadtypeConfig } from "leadtype"; + +export default defineLeadtypeConfig({ + product: { name: "Acme", tagline: "Acme does one useful thing." }, + collections: { + docs: { + repository: "https://github.com/acme/acme.git", + ref: "f4e3c2d1", + dir: "docs", + routePrefix: "/docs", + inheritConfig: true, + }, + }, +}); +``` + +## Canonical field names + +Three collection fields were renamed. Both spellings work; the canonical one is what the docs, `leadtype init`, and every diagnostic use. + +| Canonical | Older name | Why it changed | +| --- | --- | --- | +| `routePrefix` | `prefix` | The value is specifically a **public route** prefix — not a path prefix, an id prefix, or a filesystem prefix. Three different prefixes appear in one multi-collection config; only one of them shows up in a URL. | +| `inheritConfig` | `sourceConfig` | The field declares *inheritance from the source repo*. `sourceConfig` reads like "the config of the source", which is a thing that exists and is not this field. | +| `frontmatterSchema` | `schema` | The top-level field of the same purpose is already `frontmatterSchema`. One concept, one name. | + +`navigation` is the canonical information-architecture field. `groups` still works as legacy taxonomy and fallback navigation — pages declare membership in frontmatter (`group: concepts`) and ungrouped pages still resolve — but a curated `navigation` tree is what drives the sidebar, `llms.txt`, `AGENTS.md`, the sitemap, and agent-readability metadata. + +Shorthands are not deprecations. `agents.mcp: true` is a convenience for `agents.mcp: { enabled: true }`; both are canonical and both keep working. + +### Setting both is an error + +An older name and its replacement in the same collection fails at config load: + +```text +docs config at "/repo/leadtype.config.ts": collection "docs" sets both "prefix" +and "routePrefix". Remove "prefix" — "routePrefix" is the canonical field. +``` + +Silent precedence would make the effective value invisible at the call site, so there isn't any. + +### Removal policy + +Deprecated fields keep working through the next major release, and none of them will be removed before 1.0. Concretely: + +- They stay type-valid, with an IDE-visible `@deprecated` note pointing at the replacement. +- Loading a config that uses them prints **one** warning per config file, listing every rename. +- A clean config prints nothing. + +Renaming is a find-and-replace on your side and the resolved output is byte-for-byte identical — tests in this repo assert exactly that. + +## The resolved config + +Validation checks the shape you authored. **Normalization** turns it into the project model that generate, sync, lint, score, and the runtime source all read. There is one normalizer, so there is one answer to "what is this project?". + +Two things it adds beyond canonical names. + +### A source graph + +Collections declare where their content comes from; **sources** own acquisition. Collections that share a `(repository, ref)` pair resolve to one source listing both keys: + +```ts +{ + sources: [ + { + id: "https://github.com/acme/acme.git#main", + kind: "git", + repository: "https://github.com/acme/acme.git", + ref: "main", + refKind: "mutable", + collectionKeys: ["docs", "changelog"], + }, + ], +} +``` + +That is what sync already did internally — one clone for both collections — now visible in the model. `refKind` distinguishes a pinned commit from a branch or moving tag, because a mutable ref resolves differently on different machines and at different times. + +### Provenance + +Every resolved value records where it came from, so "why is this value what it is?" is answerable without reading the normalizer: + +| Origin | Means | +| --- | --- | +| `explicit` | Authored in this project's config. | +| `inherited` | Came from a source repository's own `docs.config.*`. | +| `inferred` | Derived by Leadtype from content, `package.json`, or another config value. | +| `default` | Nothing was authored; a documented default applies. | + +A value authored under a deprecated name records the name you actually typed: + +```ts +resolved.collections[0].provenance.routePrefix; +// { origin: "explicit", authoredAs: "prefix", configPath: "/repo/leadtype.config.ts" } +``` + +which is how a migration diagnostic can point at your line rather than the canonical name you never wrote. + +### Single-source projects resolve to one collection + +A config with no `collections` map resolves to exactly one collection, so downstream code has a single shape to handle. Its `dir` is deliberately unset: in single-source mode the content root comes from the host — `--docs-dir` for the CLI, `contentDir` for the runtime source — and the config genuinely does not know it. Provenance records that as `origin: "default"` rather than inventing a path. + +## Reading it yourself + +`normalizeDocsConfig` is exported, and `serializeResolvedConfig` gives a JSON-safe view for tooling. Function-valued fields (frontmatter schemas, flatteners, transformers) cannot cross a JSON boundary, so they are reported as presence flags — a reader still needs to know a schema is in play. + +```ts +// @noErrors - `./leadtype.config` is your project's own config file. +import { normalizeDocsConfig, serializeResolvedConfig } from "leadtype"; +import config from "./leadtype.config"; + +const { resolved } = normalizeDocsConfig(config, { + configPath: "/repo/leadtype.config.ts", +}); + +console.log(JSON.stringify(serializeResolvedConfig(resolved), null, 2)); +``` diff --git a/docs/docs.config.ts b/docs/docs.config.ts index 1aa19014..1188f6d1 100644 --- a/docs/docs.config.ts +++ b/docs/docs.config.ts @@ -1,6 +1,8 @@ -import type { DocsConfig } from "leadtype"; +import { defineDocsConfig } from "leadtype"; -const config: DocsConfig = { +// This repo's docs are source-owned content, so `defineDocsConfig` — the same +// helper the docs tell everyone else to use. +const config = defineDocsConfig({ // The documented product — reused across llms.txt, JSON-LD, and the agent card. product: { name: "Leadtype", @@ -187,7 +189,7 @@ const config: DocsConfig = { { title: "Concepts", base: "concepts", - pages: ["methodology", "architecture", "evals"], + pages: ["methodology", "architecture", "config-model", "evals"], }, { title: "Docs Pipeline", @@ -335,6 +337,6 @@ const config: DocsConfig = { ], }, }, -}; +}); export default config; diff --git a/docs/paths.lock.json b/docs/paths.lock.json index f85ab071..c98756cf 100644 --- a/docs/paths.lock.json +++ b/docs/paths.lock.json @@ -37,6 +37,10 @@ "path": "/docs/concepts/architecture", "hash": "fb82fd07401199b9" }, + { + "path": "/docs/concepts/config-model", + "hash": "8585c01de32261b0" + }, { "path": "/docs/concepts/evals", "hash": "a2da3f88be4f6dd3" @@ -67,15 +71,15 @@ }, { "path": "/docs/pipeline/build-a-docs-site", - "hash": "fa37c5640b647f67" + "hash": "80b8e367dfab9d10" }, { "path": "/docs/pipeline/collections", - "hash": "bfa0c797bdb2aeb8" + "hash": "edbdc46e77910614" }, { "path": "/docs/pipeline/configure-sources", - "hash": "8837f15a670b9403" + "hash": "1aa07e71cf54b1f3" }, { "path": "/docs/pipeline/deploy-generated-artifacts", @@ -99,7 +103,7 @@ }, { "path": "/docs/pipeline/sync-docs-across-repos", - "hash": "8a592c53e647530d" + "hash": "31af33cb9bfd1a48" }, { "path": "/docs/pipeline/use-the-source-primitive", @@ -115,7 +119,7 @@ }, { "path": "/docs/reference/cli", - "hash": "98fe8998d4a118c0" + "hash": "7dcd173e5a973493" }, { "path": "/docs/reference/convert", diff --git a/docs/pipeline/build-a-docs-site.mdx b/docs/pipeline/build-a-docs-site.mdx index e146a1f5..7bbcf411 100644 --- a/docs/pipeline/build-a-docs-site.mdx +++ b/docs/pipeline/build-a-docs-site.mdx @@ -27,7 +27,7 @@ deployment shape: | Path | When to choose | What you wire | | --- | --- | --- | -| **Pinned source docs UI** | Recommended for hosted product docs split across a source/package repo and a docs UI repo. Production needs reproducible deploys and reviewed source promotion. | Docs UI `leadtype.config.ts` remote collection with `sourceConfig: true` + `leadtype generate --sync` | +| **Pinned source docs UI** | Recommended for hosted product docs split across a source/package repo and a docs UI repo. Production needs reproducible deploys and reviewed source promotion. | Docs UI `leadtype.config.ts` remote collection with `inheritConfig: true` + `leadtype generate --sync` | | **Source primitive** | Most cases. You're building a Next, TanStack Start, Nuxt, Astro, or SvelteKit app and compile MDX with your bundler. | `createDocsSource()` (or `leadtype/fumadocs`) + `createMdxSourcePlugins()` | | **Static artifacts** | Your runtime needs flat files on disk (CDN-only deploy, static export, agent-only consumption, multi-app sharing). | `leadtype generate` CLI in your build script | @@ -53,14 +53,14 @@ export default defineDocsConfig({ ref: "v1.2.3", cacheDir: ".docs-src/sdk", dir: "docs", - prefix: "/docs", - sourceConfig: true, + routePrefix: "/docs", + inheritConfig: true, }), }, }); ``` -`sourceConfig: true` loads the source repo's `docs/docs.config.ts` after sync, +`inheritConfig: true` loads the source repo's `docs/docs.config.ts` after sync, then inherits only content-owned fields into the collection: `navigation`, legacy `groups`, `frontmatterSchema`, `flatteners`, and `mounts`. The docs UI repo keeps site-owned fields such as `product`, `organization`, `agents`, diff --git a/docs/pipeline/collections.mdx b/docs/pipeline/collections.mdx index f6294dda..e080d5e4 100644 --- a/docs/pipeline/collections.mdx +++ b/docs/pipeline/collections.mdx @@ -35,7 +35,7 @@ import { defineCollection, defineDocsConfig } from "leadtype"; export default defineDocsConfig({ product: { name: "My Product", tagline: "..." }, collections: { - docs: defineCollection({ dir: "./docs", prefix: "/docs" }), + docs: defineCollection({ dir: "./docs", routePrefix: "/docs" }), }, }); ``` @@ -47,14 +47,14 @@ Place this file at the project root (the cwd from which you run `leadtype`). Lea | Field | Type | Default | What it does | | --- | --- | --- | --- | | `dir` | `string` | required | Directory containing the MDX. For local collections, resolved from the config dir. For remote collections, resolved from the cloned repo root. | -| `prefix` | `string` | `"/" + ` | URL prefix where this collection appears in generated artifacts. | +| `routePrefix` | `string` | `"/" + ` | URL prefix where this collection appears in generated artifacts. | | `repository` | `string` | — | `https://` or `git@` URL. Set this to make the collection remote — leadtype clones it on `leadtype sync`. Omit for local collections. | | `ref` | `string` | `"main"` | Branch, tag, or commit SHA to check out. SHAs are detected by pattern (7–40 hex chars) and use a full clone + `checkout`; everything else uses `clone --depth 1 --branch`. | | `cacheDir` | `string` | `.leadtype/sources/@` | Where the clone lives on disk, relative to the config dir. Override this when you want a stable on-disk path for other tooling (e.g. type-table extraction). | -| `sourceConfig` | `true \| object` | — | Remote collections only. After sync, load source-owned docs config from the collection `dir` and inherit MDX-owned fields into this collection. | +| `inheritConfig` | `true \| object` | — | Remote collections only. After sync, load source-owned docs config from the collection `dir` and inherit MDX-owned fields into this collection. | | `include` | `string[]` | all `.mdx` files | Glob patterns relative to `dir`. When set, narrows which files the collection contributes. | | `exclude` | `string[]` | — | Glob patterns relative to `dir`. Files matching are dropped after `include`. | -| `schema` | Valibot `ObjectSchema` | `defaultFrontmatterSchema` | Per-collection frontmatter schema. Lint errors are reported as `[collection:] : …`. | +| `frontmatterSchema` | Valibot `ObjectSchema` | `defaultFrontmatterSchema` | Per-collection frontmatter schema. Lint errors are reported as `[collection:] : …`. | | `navigation` | `DocsNavEntry[]` | — | Per-collection curated navigation tree. Top-level strings become root pages; objects with `title` become groups. | | `groups` | `DocsGroup[]` | — | Legacy/fallback taxonomy (not a second navigation tree) — used when a collection hasn't adopted `navigation`. Slugs must be globally unique across all collections. | | `mounts` | `DocsPathMount[]` | — | Optional path-to-URL mounts inside this collection. Use when a subdirectory such as `changelog` should be canonical at `/changelog` while staying in the same source tree. | @@ -65,8 +65,8 @@ A collection without `repository` is purely local — `dir` is resolved from the ```ts collections: { - docs: defineCollection({ dir: "./docs", prefix: "/docs" }), - changelog: defineCollection({ dir: "./changelog", prefix: "/changelog" }), + docs: defineCollection({ dir: "./docs", routePrefix: "/docs" }), + changelog: defineCollection({ dir: "./changelog", routePrefix: "/changelog" }), } ``` @@ -85,8 +85,8 @@ const c15t = { repository: "https://github.com/c15t/c15t", ref: "main" } as cons export default defineDocsConfig({ product: { name: "c15t", tagline: "..." }, collections: { - docs: defineCollection({ ...c15t, dir: "docs", prefix: "/docs" }), - changelog: defineCollection({ ...c15t, dir: "changelog", prefix: "/changelog" }), + docs: defineCollection({ ...c15t, dir: "docs", routePrefix: "/docs" }), + changelog: defineCollection({ ...c15t, dir: "changelog", routePrefix: "/changelog" }), }, }); ``` @@ -97,7 +97,7 @@ from one or more product repos. ### Inherit source-owned docs config When a package/source repo owns both the MDX files and their information -architecture, set `sourceConfig: true` on the remote collection. This is the +architecture, set `inheritConfig: true` on the remote collection. This is the flagship split-repo docs UI shape: the source repo owns content semantics, and the docs UI repo owns deployment. @@ -123,8 +123,8 @@ export default defineDocsConfig({ ref: "f4e3c2d1", cacheDir: ".docs-src/sdk", dir: "docs", - prefix: "/docs", - sourceConfig: true, + routePrefix: "/docs", + inheritConfig: true, }), }, }); @@ -148,8 +148,8 @@ defineCollection({ ref: "v1.2.3", cacheDir: ".docs-src/sdk", dir: "docs", - prefix: "/docs", - sourceConfig: { + routePrefix: "/docs", + inheritConfig: { path: "config/docs.config.ts", inherit: [ "navigation", @@ -162,7 +162,7 @@ defineCollection({ }); ``` -If `sourceConfig` is enabled and no config file is found, generation fails with +If `inheritConfig` is enabled and no config file is found, generation fails with the collection key and expected path(s). `leadtype generate --offline` works with inherited source config as long as the pinned cache is already present. @@ -190,8 +190,8 @@ collections: { ref: "f4e3c2d1", cacheDir: ".docs-src/sdk", dir: "docs", - prefix: "/docs", - sourceConfig: true, + routePrefix: "/docs", + inheritConfig: true, }), } ``` @@ -219,10 +219,10 @@ const kotlin = { repository: "https://github.com/c15t/kotlin", ref: "main" } as export default defineDocsConfig({ product, collections: { - docs: defineCollection({ ...c15t, dir: "docs", prefix: "/docs" }), - changelog: defineCollection({ ...c15t, dir: "changelog", prefix: "/changelog" }), - swift: defineCollection({ ...swift, dir: "docs", prefix: "/docs/swift" }), - kotlin: defineCollection({ ...kotlin, dir: "docs", prefix: "/docs/kotlin" }), + docs: defineCollection({ ...c15t, dir: "docs", routePrefix: "/docs" }), + changelog: defineCollection({ ...c15t, dir: "changelog", routePrefix: "/changelog" }), + swift: defineCollection({ ...swift, dir: "docs", routePrefix: "/docs/swift" }), + kotlin: defineCollection({ ...kotlin, dir: "docs", routePrefix: "/docs/kotlin" }), }, }); ``` @@ -252,8 +252,8 @@ export default defineDocsConfig({ collections: { changelog: defineCollection({ dir: "./changelog", - prefix: "/changelog", - schema: changelogFrontmatter, + routePrefix: "/changelog", + frontmatterSchema: changelogFrontmatter, }), }, }); @@ -269,7 +269,7 @@ Each collection may declare its own curated `navigation` tree. Use this when a s collections: { docs: defineCollection({ dir: "./docs", - prefix: "/docs", + routePrefix: "/docs", navigation: [ "index", "quickstart", @@ -285,7 +285,7 @@ Legacy `groups` are still supported as fallback taxonomy. Slugs must be globally collections: { docs: defineCollection({ dir: "./docs", - prefix: "/docs", + routePrefix: "/docs", groups: [ { slug: "tutorials", title: "Tutorials" }, { slug: "reference", title: "Reference" }, @@ -304,7 +304,7 @@ If a collection omits both `navigation` and `groups`, leadtype falls back to dis collections: { docs: defineCollection({ dir: "./docs", - prefix: "/docs", + routePrefix: "/docs", exclude: ["drafts/**", "_internal/**"], }), } diff --git a/docs/pipeline/configure-sources.mdx b/docs/pipeline/configure-sources.mdx index fea8b88d..11d54ad9 100644 --- a/docs/pipeline/configure-sources.mdx +++ b/docs/pipeline/configure-sources.mdx @@ -16,7 +16,7 @@ source-owned docs config. | One docs folder | Docs live beside the app or package that publishes them. | `docs/docs.config.ts` + `leadtype generate --src .` | | One docs folder with mounted subtrees | One source tree owns all content, but a subtree needs a top-level URL such as `/changelog`. | `docs/docs.config.ts` `mounts` | | Multiple local folders | Related content lives in sibling folders, such as `docs/` and `changelog/`. | Repeated `--docs-dir` flags | -| Pinned remote collection | Recommended hosted-site shape. A docs UI repo renders a reviewed package/source `ref` and inherits source-owned navigation/schema/flatteners/mounts. | Docs UI `leadtype.config.ts` with `sourceConfig: true` | +| Pinned remote collection | Recommended hosted-site shape. A docs UI repo renders a reviewed package/source `ref` and inherits source-owned navigation/schema/flatteners/mounts. | Docs UI `leadtype.config.ts` with `inheritConfig: true` | | Collections | Sources need separate prefixes, filters, schemas, or local/remote acquisition. | Project-level `leadtype.config.ts` | Use one docs folder when ownership is simple. Use pinned remote collections when @@ -172,12 +172,12 @@ export default defineDocsConfig({ collections: { docs: defineCollection({ dir: "./docs", - prefix: "/docs", + routePrefix: "/docs", navigation: ["index", "quickstart"], }), changelog: defineCollection({ dir: "./changelog", - prefix: "/changelog", + routePrefix: "/changelog", include: ["*.mdx"], }), }, @@ -196,7 +196,7 @@ When `leadtype.config.ts` defines `collections`, the collection map fully descri Set `repository` to make a collection remote. Pin `ref` to the version you want: a branch for moving docs, a tag for release docs, or a commit SHA for fully reproducible output. -For the split-repo docs UI shape, add `sourceConfig: true`. After sync, +For the split-repo docs UI shape, add `inheritConfig: true`. After sync, Leadtype loads `docs.config.{ts,js,mjs,cjs}` from the remote collection `dir` and inherits MDX-owned fields (`navigation`, `groups`, `frontmatterSchema`, `flatteners`, and `mounts`) into the collection. @@ -226,13 +226,13 @@ export default defineDocsConfig({ collections: { platform: defineCollection({ dir: "./docs", - prefix: "/docs", + routePrefix: "/docs", }), sdk: defineCollection({ ...sdkDocs, dir: "docs", - prefix: "/docs/sdk", - sourceConfig: true, + routePrefix: "/docs/sdk", + inheritConfig: true, include: ["quickstart.mdx", "guides/**", "reference/**"], exclude: ["**/internal/**"], }), @@ -242,7 +242,7 @@ export default defineDocsConfig({ If the source repo's `docs/docs.config.ts` declares `mounts: [{ pathPrefix: "changelog", urlPrefix: "/changelog" }]`, the docs UI -repo inherits that mapping through `sourceConfig: true`. A remote page at +repo inherits that mapping through `inheritConfig: true`. A remote page at `docs/changelog/v1.mdx` is generated with canonical URLs `/changelog/v1` and `/changelog/v1.md`, not `/docs/changelog/v1`. @@ -254,8 +254,8 @@ source-owned mount: docs: defineCollection({ ...sdkDocs, dir: "docs", - prefix: "/docs", - sourceConfig: true, + routePrefix: "/docs", + inheritConfig: true, mounts: [{ pathPrefix: "changelog", urlPrefix: "/changelog" }], }); ``` diff --git a/docs/pipeline/sync-docs-across-repos.mdx b/docs/pipeline/sync-docs-across-repos.mdx index a8f8403d..9f96b52a 100644 --- a/docs/pipeline/sync-docs-across-repos.mdx +++ b/docs/pipeline/sync-docs-across-repos.mdx @@ -16,8 +16,8 @@ defineCollection({ ref: "f4e3c2d1", cacheDir: ".docs-src/sdk", dir: "docs", - prefix: "/docs", - sourceConfig: true, + routePrefix: "/docs", + inheritConfig: true, }); ``` @@ -63,14 +63,14 @@ export default defineDocsConfig({ ref: "f4e3c2d1", cacheDir: ".docs-src/sdk", dir: "docs", - prefix: "/docs", - sourceConfig: true, + routePrefix: "/docs", + inheritConfig: true, }), }, }); ``` -With `sourceConfig: true`, the source repo keeps the docs-specific config that +With `inheritConfig: true`, the source repo keeps the docs-specific config that belongs with the content: `navigation`, legacy `groups`, `frontmatterSchema`, source-specific `flatteners`, and same-tree `mounts` such as `docs/changelog` -> `/changelog`. The docs UI repo keeps the deployment pin and @@ -104,9 +104,9 @@ The framework apps in `apps/*` are integration examples. The production Leadtype docs site is a separate docs UI app that renders `leadtype.dev` and syncs the Leadtype source repository into `.leadtype`. -Before `sourceConfig`, that app needed a custom source file (`leadtype-source.json`), +Before `inheritConfig`, that app needed a custom source file (`leadtype-source.json`), a custom clone script, and a custom import of `.leadtype/docs/docs.config.ts` to -reuse the source-owned navigation. With `sourceConfig: true`, the app can move +reuse the source-owned navigation. With `inheritConfig: true`, the app can move that source ownership into first-class Leadtype config: ```ts title="leadtype.config.ts" @@ -131,8 +131,8 @@ export default defineDocsConfig({ ref: "leadtype@0.1.2", cacheDir: ".leadtype", dir: "docs", - prefix: "/docs", - sourceConfig: true, + routePrefix: "/docs", + inheritConfig: true, }), }, }); @@ -309,7 +309,7 @@ Requirements: 1. The package/source repo owns MDX and releases packages. 2. The docs UI repo owns the rendered site. 3. Production docs must be pinned to an explicit released source revision, not the source repo default branch. -4. Configure `leadtype.config.ts` in the docs UI repo with a remote Leadtype collection whose `repository` points at the source repo, whose `ref` is pinned to the released commit SHA, and whose `sourceConfig` is `true`. +4. Configure `leadtype.config.ts` in the docs UI repo with a remote Leadtype collection whose `repository` points at the source repo, whose `ref` is pinned to the released commit SHA, and whose `inheritConfig` is `true`. 5. Keep source-owned `navigation`, legacy `groups`, `frontmatterSchema`, `flatteners`, and `mounts` in the source repo's `docs/docs.config.ts`. 6. Keep the package version near that config when useful for reviewers, PR titles, dependency pins, or release dashboards. 7. Run `leadtype generate --sync` before the docs UI framework build. Use `--offline` only when the cache is already present. diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index adc2c79b..d16e99bd 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -346,15 +346,15 @@ const c15t = { repository: "https://github.com/c15t/c15t", ref: "main" } as cons export default defineDocsConfig({ product, collections: { - docs: defineCollection({ ...c15t, dir: "docs", prefix: "/docs" }), - changelog: defineCollection({ ...c15t, dir: "changelog", prefix: "/changelog" }), + docs: defineCollection({ ...c15t, dir: "docs", routePrefix: "/docs" }), + changelog: defineCollection({ ...c15t, dir: "changelog", routePrefix: "/changelog" }), }, }); ``` …runs exactly one `git clone`, with both collections reading from `/docs` and `/changelog`. -For a split-repo docs UI, add `sourceConfig: true` to a remote collection. After +For a split-repo docs UI, add `inheritConfig: true` to a remote collection. After sync, generation loads the source repo's `docs.config.{ts,js,mjs,cjs}` from the collection `dir` and inherits source-owned `navigation`, legacy `groups`, `frontmatterSchema`, `flatteners`, and `mounts`. Keep site-owned fields such as diff --git a/packages/leadtype/src/cli.test.ts b/packages/leadtype/src/cli.test.ts index fa605b68..3ec38efc 100644 --- a/packages/leadtype/src/cli.test.ts +++ b/packages/leadtype/src/cli.test.ts @@ -2573,7 +2573,7 @@ export default { expect(manifest).not.toContain("Source Navigation"); }); - it("fails clearly when sourceConfig is enabled and no source config exists", async () => { + it("fails clearly when inheritConfig is enabled and no source config exists", async () => { const sourceRepo = await createGitDocsSource({ "docs/index.mdx": '---\ntitle: "Missing Config"\n---\n\nBody.\n', }); @@ -2603,8 +2603,54 @@ export default { capture.io ); expect(code).toBe(1); - expect(capture.stderr).toContain('collection "docs" sourceConfig enabled'); + // Authored with the legacy names, so the run still works — but diagnostics + // speak the canonical vocabulary, and the load warns once about the rename. + expect(capture.stderr).toContain( + 'collection "docs" inheritConfig is enabled' + ); expect(capture.stderr).toContain("docs.config.ts"); + expect(capture.stderr).toContain( + "collections.docs.sourceConfig → collections.docs.inheritConfig" + ); + expect(capture.stderr).toContain( + "collections.docs.prefix → collections.docs.routePrefix" + ); + }); + + it("runs a collections config authored entirely in canonical field names", async () => { + const sourceRepo = await createGitDocsSource({ + "docs/index.mdx": '---\ntitle: "Canonical"\n---\n\nBody.\n', + }); + const srcDir = await createTempDir(); + const outDir = await createTempDir(); + const capture = createCapture(); + + await writeFile( + path.join(srcDir, "leadtype.config.ts"), + `export default { + product: { name: "P", tagline: "S" }, + collections: { + docs: { + repository: ${JSON.stringify(sourceRepo)}, + ref: "main", + cacheDir: ".leadtype/source", + dir: "docs", + routePrefix: "/docs", + }, + }, +};` + ); + + const code = await runCli( + ["generate", "--src", srcDir, "--out", outDir, "--sync"], + capture.io + ); + expect(code).toBe(0); + // A clean config prints no migration noise at all. + expect(capture.stderr).not.toContain("deprecated"); + expect( + await readFile(path.join(outDir, "docs", "index.md"), "utf8") + ).toContain("Canonical"); }); it("uses inherited frontmatterSchema as the collection schema", async () => { diff --git a/packages/leadtype/src/cli/generate.ts b/packages/leadtype/src/cli/generate.ts index 5568c6ea..7874be74 100644 --- a/packages/leadtype/src/cli/generate.ts +++ b/packages/leadtype/src/cli/generate.ts @@ -7,6 +7,11 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { glob as fg } from "tinyglobby"; import type { Pluggable, PluggableList } from "unified"; +import { + formatDeprecationWarning, + normalizeDocsConfig, +} from "../config/normalize"; +import type { ResolvedDocsConfig } from "../config/types"; import { convertAllMdx } from "../convert"; import type { ConvertCacheOptions } from "../convert/incremental"; import { type DocsFeedConfig, generateFeedArtifacts } from "../feed"; @@ -298,8 +303,11 @@ function createGenerateMarkdownTransforms({ } export type LoadedDocsConfig = { + /** The authored config, with deprecated aliases folded onto canonical names. */ config: DocsConfig; path: string; + /** The resolved project: collections, source graph, provenance, deprecations. */ + resolved: ResolvedDocsConfig; }; type ResolvedGenerateMetadata = { @@ -1111,14 +1119,16 @@ function validateDocsNav(value: unknown): DocsNavEntry[] | undefined { function validateSourceConfigInheritance( value: unknown, configPath: string, - collectionKey: string + collectionKey: string, + /** The field name as authored, so the error points at the user's own line. */ + fieldName: string ): void { if (value === undefined || value === true) { return; } if (!isPlainRecord(value)) { throw new Error( - `docs config at "${configPath}": collection "${collectionKey}" sourceConfig must be true or an object` + `docs config at "${configPath}": collection "${collectionKey}" ${fieldName} must be true or an object` ); } if ( @@ -1126,13 +1136,13 @@ function validateSourceConfigInheritance( (typeof value.path !== "string" || value.path.length === 0) ) { throw new Error( - `docs config at "${configPath}": collection "${collectionKey}" sourceConfig.path must be a non-empty string` + `docs config at "${configPath}": collection "${collectionKey}" ${fieldName}.path must be a non-empty string` ); } if (value.inherit !== undefined) { if (!isStringArray(value.inherit)) { throw new Error( - `docs config at "${configPath}": collection "${collectionKey}" sourceConfig.inherit must be an array of supported field names` + `docs config at "${configPath}": collection "${collectionKey}" ${fieldName}.inherit must be an array of supported field names` ); } for (const field of value.inherit) { @@ -1140,7 +1150,7 @@ function validateSourceConfigInheritance( !SOURCE_CONFIG_INHERIT_FIELDS.has(field as SourceConfigInheritField) ) { throw new Error( - `docs config at "${configPath}": collection "${collectionKey}" sourceConfig.inherit contains unsupported field "${field}"` + `docs config at "${configPath}": collection "${collectionKey}" ${fieldName}.inherit contains unsupported field "${field}"` ); } } @@ -1199,17 +1209,24 @@ function validateCollections( `docs config at "${configPath}": collection "${key}" ref must not begin with "-"` ); } - if (entry.prefix !== undefined && typeof entry.prefix !== "string") { - throw new Error( - `docs config at "${configPath}": collection "${key}" prefix must be a string` - ); + // `prefix`/`routePrefix` and `sourceConfig`/`inheritConfig` are the same + // field under two names. Both spellings validate identically here; the + // normalizer folds them together and rejects setting both. + for (const field of ["prefix", "routePrefix"] as const) { + if (entry[field] !== undefined && typeof entry[field] !== "string") { + throw new Error( + `docs config at "${configPath}": collection "${key}" ${field} must be a string` + ); + } } - if (entry.sourceConfig !== undefined && entry.repository === undefined) { - throw new Error( - `docs config at "${configPath}": collection "${key}" sourceConfig is only supported for remote collections` - ); + for (const field of ["sourceConfig", "inheritConfig"] as const) { + if (entry[field] !== undefined && entry.repository === undefined) { + throw new Error( + `docs config at "${configPath}": collection "${key}" ${field} is only supported for remote collections` + ); + } + validateSourceConfigInheritance(entry[field], configPath, key, field); } - validateSourceConfigInheritance(entry.sourceConfig, configPath, key); if ( entry.groups !== undefined && validateDocsGroups(entry.groups) === undefined @@ -1579,7 +1596,7 @@ function validateSourceOwnedConfigFields( } function resolveSourceConfigPaths(entry: ResolvedCollection): string[] { - const sourceConfig = entry.collection.sourceConfig; + const sourceConfig = entry.collection.inheritConfig; if (!sourceConfig) { return []; } @@ -1587,7 +1604,7 @@ function resolveSourceConfigPaths(entry: ResolvedCollection): string[] { if (sourceConfig !== true && sourceConfig.path) { if (path.isAbsolute(sourceConfig.path)) { throw new Error( - `collection "${entry.key}" sourceConfig.path must be relative to the collection dir` + `collection "${entry.key}" inheritConfig.path must be relative to the collection dir` ); } const configPath = path.resolve(baseDir, sourceConfig.path); @@ -1598,7 +1615,7 @@ function resolveSourceConfigPaths(entry: ResolvedCollection): string[] { path.isAbsolute(relativePath) ) { throw new Error( - `collection "${entry.key}" sourceConfig.path must stay inside the collection dir` + `collection "${entry.key}" inheritConfig.path must stay inside the collection dir` ); } return [configPath]; @@ -1609,7 +1626,7 @@ function resolveSourceConfigPaths(entry: ResolvedCollection): string[] { function sourceConfigInheritFields( collection: DocsCollection ): SourceConfigInheritField[] { - const sourceConfig = collection.sourceConfig; + const sourceConfig = collection.inheritConfig; if (!sourceConfig || sourceConfig === true || !sourceConfig.inherit) { return DEFAULT_SOURCE_CONFIG_INHERIT; } @@ -1623,7 +1640,7 @@ async function loadCollectionSourceConfig( const configPath = candidates.find((candidate) => existsSync(candidate)); if (!configPath) { throw new Error( - `collection "${entry.key}" sourceConfig enabled but no source config was found. Expected ${candidates.map((candidate) => `"${candidate}"`).join(", ")}.` + `collection "${entry.key}" inheritConfig is enabled but no source config was found. Expected ${candidates.map((candidate) => `"${candidate}"`).join(", ")}.` ); } @@ -1656,9 +1673,9 @@ function mergeInheritedSourceConfig( ? { groups: sourceConfig.groups } : {}), ...(inherit.has("frontmatterSchema") && - collection.schema === undefined && + collection.frontmatterSchema === undefined && sourceConfig.frontmatterSchema !== undefined - ? { schema: sourceConfig.frontmatterSchema } + ? { frontmatterSchema: sourceConfig.frontmatterSchema } : {}), ...(inherit.has("flatteners") && collection.flatteners === undefined && @@ -1680,7 +1697,7 @@ async function inheritCollectionSourceConfigs( const resolved = resolveAllCollections(collections, configDir); const next: Record = { ...collections }; for (const entry of resolved) { - if (!entry.collection.sourceConfig) { + if (!entry.collection.inheritConfig) { continue; } const sourceConfig = await loadCollectionSourceConfig(entry); @@ -1706,10 +1723,18 @@ async function loadDocsConfigFromDir( try { const imported = await importConfigModule(configPath); - return { - config: validateDocsConfig(imported, configPath), - path: configPath, - }; + // Validation checks the authored shape; normalization folds deprecated + // aliases onto canonical names and derives the resolved project. Every + // consumer downstream of here reads canonical fields only. + const { config, resolved } = normalizeDocsConfig( + validateDocsConfig(imported, configPath), + { configPath, configDir: path.dirname(configPath) } + ); + const loaded: LoadedDocsConfig = { config, path: configPath, resolved }; + // Warn here rather than at each entry point: this *is* the config load, so + // generate, sync, lint, and score all get the same one-time message. + warnConfigDeprecations(loaded); + return loaded; } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error( @@ -1718,6 +1743,42 @@ async function loadDocsConfigFromDir( } } +// Deprecation warnings are per config *file*, not per command: `generate` +// loading the same config twice in a watch loop should not stack warnings, and +// a project whose config is clean should print nothing at all. +const warnedConfigPaths = new Set(); + +/** + * Emit one actionable deprecation warning per config load. Safe to call from + * every CLI entry point — repeat calls for the same file are dropped. + */ +export function warnConfigDeprecations(loaded: LoadedDocsConfig | null): void { + if (!loaded || warnedConfigPaths.has(loaded.path)) { + return; + } + const warning = formatDeprecationWarning(loaded.resolved.deprecations); + if (!warning) { + return; + } + warnedConfigPaths.add(loaded.path); + logger.warn({ + human: { + message: `${loaded.path}: ${warning.message}`, + hint: warning.hint, + }, + json: { + event: "config.deprecated_fields", + fields: { + configPath: loaded.path, + fields: loaded.resolved.deprecations.map((entry) => entry.field), + replacements: loaded.resolved.deprecations.map( + (entry) => entry.replacement + ), + }, + }, + }); +} + /** * Look for `leadtype.config.{ts,js,mjs,cjs}` in the given directory. * Used by the sync CLI; for `generate`, prefer {@link loadDocsConfig}. @@ -1961,14 +2022,14 @@ async function resolveCollectionFrontmatterSchemas( const schemas: CollectionFrontmatterSchema[] = []; const sourcesByKey = new Map(sources.map((source) => [source.input, source])); for (const [key, collection] of Object.entries(collections)) { - if (!collection.schema) { + if (!collection.frontmatterSchema) { continue; } const source = sourcesByKey.get(key); schemas.push({ filePaths: source ? await sourceStagedMdxPaths(source) : undefined, pathPrefix: source?.mountPath ?? "", - schema: collection.schema, + schema: collection.frontmatterSchema, }); } return schemas; @@ -2918,11 +2979,24 @@ async function executeGenerate( loadedConfig.config.collections, configDir ); + // Re-normalize, don't just swap the collections in: `resolved` was + // derived from the pre-inheritance config, so carrying it through + // unchanged leaves every inherited navigation, schema, groups, and + // mounts missing from the resolved model — for the exact + // `inheritConfig: true` shape the docs recommend. Deprecations and the + // acquisition graph stay from the first pass, which is the only one that + // saw the authored aliases and source names. + const renormalized = normalizeDocsConfig( + { ...loadedConfig.config, collections }, + { configPath: loadedConfig.path, configDir } + ); loadedConfig = { - ...loadedConfig, - config: { - ...loadedConfig.config, - collections, + config: renormalized.config, + path: loadedConfig.path, + resolved: { + ...renormalized.resolved, + sources: loadedConfig.resolved.sources, + deprecations: loadedConfig.resolved.deprecations, }, }; docsSources = resolveDocsSourcesFromCollections(collections, configDir); diff --git a/packages/leadtype/src/config/normalize.test.ts b/packages/leadtype/src/config/normalize.test.ts new file mode 100644 index 00000000..e2d4935b --- /dev/null +++ b/packages/leadtype/src/config/normalize.test.ts @@ -0,0 +1,407 @@ +import { describe, expect, it } from "vitest"; +import type { DocsConfig } from "../llm/llm"; +import { formatDeprecationWarning, normalizeDocsConfig } from "./normalize"; +import { serializeResolvedConfig } from "./types"; + +const product = { name: "Acme", tagline: "Acme does one useful thing." }; +const CONFIG_PATH = "/repo/leadtype.config.ts"; + +function normalize(config: DocsConfig) { + return normalizeDocsConfig(config, { configPath: CONFIG_PATH }); +} + +describe("canonical and legacy configs resolve identically", () => { + const canonical: DocsConfig = { + product, + collections: { + docs: { + repository: "https://github.com/acme/acme.git", + ref: "main", + dir: "docs", + routePrefix: "/docs", + inheritConfig: true, + }, + changelog: { + repository: "https://github.com/acme/acme.git", + ref: "main", + dir: "changelog", + routePrefix: "/releases", + }, + }, + }; + + const legacy: DocsConfig = { + product, + collections: { + docs: { + repository: "https://github.com/acme/acme.git", + ref: "main", + dir: "docs", + prefix: "/docs", + sourceConfig: true, + }, + changelog: { + repository: "https://github.com/acme/acme.git", + ref: "main", + dir: "changelog", + // Deliberately unlike the collection key: with `/changelog` the + // `/${key}` fallback produces the same answer, so the assertion below + // would pass even if the alias fold were deleted entirely. + prefix: "/releases", + }, + }, + }; + + it("produces the same collections and sources", () => { + const fromCanonical = normalize(canonical).resolved; + const fromLegacy = normalize(legacy).resolved; + + // Provenance intentionally differs — it records that the legacy config + // authored these fields under their old names — so compare everything else. + const strip = (resolved: typeof fromCanonical) => ({ + mode: resolved.mode, + sources: resolved.sources, + collections: resolved.collections.map( + ({ provenance: _provenance, ...rest }) => rest + ), + }); + + expect(strip(fromLegacy)).toEqual(strip(fromCanonical)); + }); + + it("rewrites the legacy config to canonical field names", () => { + const { config } = normalize(legacy); + expect(config.collections?.docs).toMatchObject({ + routePrefix: "/docs", + inheritConfig: true, + }); + expect(config.collections?.docs).not.toHaveProperty("prefix"); + expect(config.collections?.docs).not.toHaveProperty("sourceConfig"); + }); + + it("records the deprecated fields it folded, with stable ids", () => { + const { resolved } = normalize(legacy); + expect(resolved.deprecations).toEqual([ + { + id: "collection.prefix", + field: "collections.docs.prefix", + replacement: "collections.docs.routePrefix", + message: expect.stringContaining("rename it to routePrefix"), + }, + { + id: "collection.sourceConfig", + field: "collections.docs.sourceConfig", + replacement: "collections.docs.inheritConfig", + message: expect.stringContaining("rename it to inheritConfig"), + }, + { + id: "collection.prefix", + field: "collections.changelog.prefix", + replacement: "collections.changelog.routePrefix", + message: expect.stringContaining("rename it to routePrefix"), + }, + ]); + }); + + it("reports nothing for a fully canonical config", () => { + expect(normalize(canonical).resolved.deprecations).toEqual([]); + }); +}); + +describe("ambiguous old-plus-new combinations", () => { + it("rejects prefix alongside routePrefix, naming both and the winner", () => { + expect(() => + normalize({ + product, + collections: { + docs: { dir: "docs", prefix: "/a", routePrefix: "/b" }, + }, + }) + ).toThrow( + /collection "docs" sets both "prefix" and "routePrefix".*"routePrefix" is the canonical field/s + ); + }); + + it("rejects sourceConfig alongside inheritConfig", () => { + expect(() => + normalize({ + product, + collections: { + docs: { + dir: "docs", + repository: "https://example.com/a.git", + sourceConfig: true, + inheritConfig: true, + }, + }, + }) + ).toThrow(/sets both "sourceConfig" and "inheritConfig"/); + }); + + it("rejects schema alongside frontmatterSchema", () => { + const schema = {} as never; + expect(() => + normalize({ + product, + collections: { + docs: { dir: "docs", schema, frontmatterSchema: schema }, + }, + }) + ).toThrow(/sets both "schema" and "frontmatterSchema"/); + }); +}); + +describe("source graph", () => { + it("dedupes collections that share a repository and ref", () => { + const { resolved } = normalize({ + product, + collections: { + docs: { + repository: "https://github.com/acme/acme.git", + dir: "docs", + routePrefix: "/docs", + }, + changelog: { + repository: "https://github.com/acme/acme.git", + dir: "changelog", + routePrefix: "/changelog", + }, + }, + }); + + expect(resolved.sources).toHaveLength(1); + expect(resolved.sources[0]).toMatchObject({ + kind: "git", + repository: "https://github.com/acme/acme.git", + ref: "main", + collectionKeys: ["docs", "changelog"], + }); + expect( + resolved.collections.map((collection) => collection.sourceId) + ).toEqual([resolved.sources[0].id, resolved.sources[0].id]); + }); + + it("keeps different refs of one repository as separate acquisitions", () => { + const { resolved } = normalize({ + product, + collections: { + stable: { + repository: "https://github.com/acme/acme.git", + ref: "v1", + dir: "docs", + routePrefix: "/docs", + }, + next: { + repository: "https://github.com/acme/acme.git", + ref: "main", + dir: "docs", + routePrefix: "/next", + }, + }, + }); + expect(resolved.sources).toHaveLength(2); + }); + + it("marks a SHA ref as pinned and a branch as mutable", () => { + const { resolved } = normalize({ + product, + collections: { + pinned: { + repository: "https://github.com/acme/acme.git", + ref: "0123456789abcdef0123456789abcdef01234567", + dir: "docs", + routePrefix: "/docs", + }, + moving: { + repository: "https://github.com/acme/other.git", + ref: "main", + dir: "docs", + routePrefix: "/other", + }, + }, + }); + const kinds = Object.fromEntries( + resolved.sources.flatMap((source) => + source.kind === "git" + ? [[source.collectionKeys[0], source.refKind]] + : [] + ) + ); + expect(kinds).toEqual({ pinned: "commit", moving: "mutable" }); + }); + + it("rejects conflicting cacheDir values for one acquisition", () => { + expect(() => + normalize({ + product, + collections: { + docs: { + repository: "https://github.com/acme/acme.git", + dir: "docs", + routePrefix: "/docs", + cacheDir: ".leadtype/a", + }, + changelog: { + repository: "https://github.com/acme/acme.git", + dir: "changelog", + routePrefix: "/changelog", + cacheDir: ".leadtype/b", + }, + }, + }) + ).toThrow(/different cacheDir values/); + }); + + it("groups every local collection under one local source", () => { + const { resolved } = normalize({ + product, + collections: { + docs: { dir: "docs", routePrefix: "/docs" }, + guides: { dir: "guides", routePrefix: "/guides" }, + }, + }); + expect(resolved.sources).toEqual([ + { id: "local", kind: "local", collectionKeys: ["docs", "guides"] }, + ]); + }); +}); + +describe("route prefixes", () => { + it("defaults to the collection key and records that as inferred", () => { + const { resolved } = normalize({ + product, + collections: { guides: { dir: "guides" } }, + }); + expect(resolved.collections[0].routePrefix).toBe("/guides"); + expect(resolved.collections[0].provenance.routePrefix).toMatchObject({ + origin: "inferred", + inferredFrom: "collection key", + }); + }); + + it("records an authored alias as explicit, naming the field it came from", () => { + const { resolved } = normalize({ + product, + collections: { guides: { dir: "guides", prefix: "/handbook" } }, + }); + expect(resolved.collections[0].routePrefix).toBe("/handbook"); + expect(resolved.collections[0].provenance.routePrefix).toMatchObject({ + origin: "explicit", + authoredAs: "prefix", + configPath: CONFIG_PATH, + }); + }); + + it("rejects two collections sharing a prefix", () => { + expect(() => + normalize({ + product, + collections: { + docs: { dir: "docs", routePrefix: "/docs" }, + guides: { dir: "guides", routePrefix: "/docs" }, + }, + }) + ).toThrow(/share routePrefix "\/docs"/); + }); + + it("rejects the site root as a collection prefix", () => { + expect(() => + normalize({ + product, + collections: { docs: { dir: "docs", routePrefix: "/" } }, + }) + ).toThrow(/must not be the site root/); + }); +}); + +describe("single-source projects", () => { + const single: DocsConfig = { + product, + navigation: ["index", "quickstart"], + mounts: [{ pathPrefix: "changelog", urlPrefix: "/changelog" }], + }; + + it("resolves to one collection so downstream code has one shape", () => { + const { resolved } = normalize(single); + expect(resolved.mode).toBe("single-source"); + expect(resolved.collections).toHaveLength(1); + expect(resolved.collections[0]).toMatchObject({ + key: "docs", + routePrefix: "/docs", + sourceId: "local", + navigation: ["index", "quickstart"], + }); + }); + + it("leaves dir unset, because the host supplies the content root", () => { + const { resolved } = normalize(single); + expect(resolved.collections[0].dir).toBeUndefined(); + expect(resolved.collections[0].provenance.dir).toMatchObject({ + origin: "default", + }); + }); + + it("records explicitly authored top-level fields", () => { + const { resolved } = normalize(single); + expect(resolved.provenance.navigation).toMatchObject({ + origin: "explicit", + configPath: CONFIG_PATH, + }); + expect(resolved.provenance.collections).toBeUndefined(); + }); +}); + +describe("serializeResolvedConfig", () => { + it("survives a JSON round trip and flags non-serializable fields", () => { + const { resolved } = normalize({ + product, + collections: { + docs: { + dir: "docs", + routePrefix: "/docs", + frontmatterSchema: {} as never, + navigation: ["index"], + }, + }, + }); + + const serialized = serializeResolvedConfig(resolved); + expect(JSON.parse(JSON.stringify(serialized))).toEqual(serialized); + expect(serialized.collections[0]).toMatchObject({ + hasFrontmatterSchema: true, + hasNavigation: true, + hasGroups: false, + }); + }); +}); + +describe("formatDeprecationWarning", () => { + it("returns null when nothing is deprecated", () => { + expect(formatDeprecationWarning([])).toBeNull(); + }); + + it("lists every rename in one message", () => { + const warning = formatDeprecationWarning([ + { + id: "collection.prefix", + field: "collections.docs.prefix", + replacement: "collections.docs.routePrefix", + message: "…", + }, + { + id: "collection.schema", + field: "collections.docs.schema", + replacement: "collections.docs.frontmatterSchema", + message: "…", + }, + ]); + expect(warning?.message).toContain("2 deprecated fields"); + expect(warning?.message).toContain( + "collections.docs.prefix → collections.docs.routePrefix" + ); + expect(warning?.message).toContain( + "collections.docs.schema → collections.docs.frontmatterSchema" + ); + expect(warning?.hint).toMatch(/next major release/); + }); +}); diff --git a/packages/leadtype/src/config/normalize.ts b/packages/leadtype/src/config/normalize.ts new file mode 100644 index 00000000..72b6adce --- /dev/null +++ b/packages/leadtype/src/config/normalize.ts @@ -0,0 +1,443 @@ +/** + * One normalizer, one resolved project. + * + * Takes a validated `DocsConfig` — authored in either the canonical vocabulary + * or the older one — and produces two things: + * + * 1. A **canonical config**: the same object with deprecated aliases folded + * onto their replacements. Every downstream subsystem reads canonical + * names only, so none of them has to know the aliases exist. + * 2. A **resolved config**: the project model — collections, the deduped + * source graph, and per-field provenance. + * + * Aliases are additive, never ambiguous. Setting a deprecated field and its + * replacement together is an error naming both, rather than a silent + * precedence rule a reader would have to look up. + */ + +import { normalizeUrlPrefix } from "../internal/docs-url"; +import type { DocsCollection, DocsConfig } from "../llm/llm"; +import { isShaRef } from "../sync/sync"; +import { + type ConfigDeprecation, + DEFAULT_COLLECTION_KEY, + DEFAULT_SOURCE_ID, + type FieldProvenance, + type ResolvedDocsCollection, + type ResolvedDocsConfig, + type ResolvedGitSource, + type ResolvedSource, +} from "./types"; + +/** + * Deprecated collection fields and their canonical replacements. Only true + * renames belong here — a convenience shorthand that coexists with a longer + * form (`agents.mcp: true` beside `{ enabled: true }`) is not a deprecation. + */ +const COLLECTION_FIELD_ALIASES = [ + { + id: "collection.prefix", + deprecated: "prefix", + canonical: "routePrefix", + why: "the value is specifically a public route prefix", + }, + { + id: "collection.sourceConfig", + deprecated: "sourceConfig", + canonical: "inheritConfig", + why: "the field declares inheritance from the source repo, not a config object", + }, + { + id: "collection.schema", + deprecated: "schema", + canonical: "frontmatterSchema", + why: "it matches the top-level field of the same purpose", + }, +] as const satisfies readonly { + id: string; + deprecated: keyof DocsCollection; + canonical: keyof DocsCollection; + why: string; +}[]; + +export type NormalizeDocsConfigOptions = { + /** Absolute path of the config file, used in provenance and errors. */ + configPath?: string; + /** Directory relative paths resolve against. Defaults to the config's dir. */ + configDir?: string; +}; + +export type NormalizedDocsConfig = { + /** The authored config with deprecated aliases folded onto canonical names. */ + config: DocsConfig; + resolved: ResolvedDocsConfig; +}; + +function configLabel(configPath: string | undefined): string { + return configPath ? `docs config at "${configPath}"` : "docs config"; +} + +/** + * Fold one collection's deprecated aliases onto their canonical fields, + * collecting a deprecation record for each. Throws when both spellings of the + * same concept are set — precedence would be invisible at the call site. + */ +function applyCollectionAliases( + key: string, + collection: DocsCollection, + configPath: string | undefined, + deprecations: ConfigDeprecation[], + provenance: Record +): DocsCollection { + const next: DocsCollection = { ...collection }; + + for (const alias of COLLECTION_FIELD_ALIASES) { + const authored = collection[alias.deprecated]; + const canonical = collection[alias.canonical]; + if (authored === undefined) { + continue; + } + if (canonical !== undefined) { + throw new Error( + `${configLabel(configPath)}: collection "${key}" sets both "${alias.deprecated}" and "${alias.canonical}". Remove "${alias.deprecated}" — "${alias.canonical}" is the canonical field.` + ); + } + // The alias pair is same-typed by construction (see the satisfies clause + // above), but TypeScript can't relate two dynamically-indexed keys of a + // union, so the write goes through a record view. + const mutable = next as Record; + mutable[alias.canonical] = authored; + // The canonical config must not carry the alias forward — downstream code + // reads canonical names only, and a leftover would be dead state a reader + // might reasonably trust. + delete mutable[alias.deprecated]; + deprecations.push({ + id: alias.id, + field: `collections.${key}.${alias.deprecated}`, + replacement: `collections.${key}.${alias.canonical}`, + message: `collections.${key}.${alias.deprecated} is deprecated — rename it to ${alias.canonical}, because ${alias.why}.`, + }); + provenance[alias.canonical] = { + origin: "explicit", + authoredAs: alias.deprecated, + ...(configPath ? { configPath } : {}), + }; + } + + return next; +} + +function explicit(configPath: string | undefined): FieldProvenance { + return { origin: "explicit", ...(configPath ? { configPath } : {}) }; +} + +function recordExplicit( + provenance: Record, + field: string, + value: unknown, + configPath: string | undefined +): void { + if (value !== undefined && provenance[field] === undefined) { + provenance[field] = explicit(configPath); + } +} + +function resolveCollectionEntry( + key: string, + collection: DocsCollection, + configPath: string | undefined, + sourceId: string +): ResolvedDocsCollection { + const provenance: Record = {}; + for (const field of [ + "dir", + "include", + "exclude", + "frontmatterSchema", + "navigation", + "groups", + "mounts", + "inheritConfig", + ] as const) { + recordExplicit(provenance, field, collection[field], configPath); + } + + const routePrefix = collection.routePrefix ?? `/${key}`; + if (collection.routePrefix === undefined) { + provenance.routePrefix = { + origin: "inferred", + inferredFrom: "collection key", + ...(configPath ? { configPath } : {}), + }; + } else if (provenance.routePrefix === undefined) { + provenance.routePrefix = explicit(configPath); + } + + return { + key, + dir: collection.dir, + routePrefix: normalizeUrlPrefix(routePrefix), + ...(collection.include ? { include: collection.include } : {}), + ...(collection.exclude ? { exclude: collection.exclude } : {}), + ...(collection.frontmatterSchema + ? { frontmatterSchema: collection.frontmatterSchema } + : {}), + ...(collection.navigation ? { navigation: collection.navigation } : {}), + ...(collection.groups ? { groups: collection.groups } : {}), + ...(collection.mounts ? { mounts: collection.mounts } : {}), + ...(collection.inheritConfig === undefined + ? {} + : { inheritConfig: collection.inheritConfig }), + sourceId, + provenance, + }; +} + +/** + * Build the acquisition graph. Two collections pointing at the same + * `(repository, ref)` share one git source — which is what sync already does + * internally, now visible in the model. Conflicting `cacheDir` values for one + * source are rejected here rather than at clone time. + */ +function resolveSources( + collections: Record, + configPath: string | undefined +): { sources: ResolvedSource[]; sourceIdByCollection: Map } { + const sources: ResolvedSource[] = []; + const gitByRepoRef = new Map(); + const sourceIdByCollection = new Map(); + const localKeys: string[] = []; + + for (const [key, collection] of Object.entries(collections)) { + if (!collection.repository) { + localKeys.push(key); + sourceIdByCollection.set(key, DEFAULT_SOURCE_ID); + continue; + } + const ref = collection.ref ?? "main"; + const repoRefKey = `${collection.repository}#${ref}`; + const existing = gitByRepoRef.get(repoRefKey); + if (existing) { + if ( + collection.cacheDir !== undefined && + existing.cacheDir !== undefined && + collection.cacheDir !== existing.cacheDir + ) { + throw new Error( + `${configLabel(configPath)}: collections [${existing.collectionKeys.join(", ")}] and "${key}" target ${collection.repository}@${ref} but set different cacheDir values ("${existing.cacheDir}" vs "${collection.cacheDir}"). Make them match or remove the explicit cacheDir.` + ); + } + existing.cacheDir ??= collection.cacheDir; + existing.collectionKeys.push(key); + sourceIdByCollection.set(key, existing.id); + continue; + } + const source: ResolvedGitSource = { + id: repoRefKey, + kind: "git", + repository: collection.repository, + ref, + refKind: isShaRef(ref) ? "commit" : "mutable", + ...(collection.cacheDir ? { cacheDir: collection.cacheDir } : {}), + collectionKeys: [key], + }; + gitByRepoRef.set(repoRefKey, source); + sources.push(source); + sourceIdByCollection.set(key, source.id); + } + + if (localKeys.length > 0) { + sources.unshift({ + id: DEFAULT_SOURCE_ID, + kind: "local", + collectionKeys: localKeys, + }); + } + + return { sources, sourceIdByCollection }; +} + +const TOP_LEVEL_PROVENANCE_FIELDS = [ + "product", + "organization", + "llms", + "frontmatterSchema", + "transformers", + "flatteners", + "navigation", + "groups", + "mounts", + "feeds", + "collections", + "openapi", + "i18n", + "git", + "agents", + "redirects", + "lint", +] as const; + +export function normalizeDocsConfig( + config: DocsConfig, + options: NormalizeDocsConfigOptions = {} +): NormalizedDocsConfig { + const { configPath } = options; + const deprecations: ConfigDeprecation[] = []; + const provenance: Record = {}; + + for (const field of TOP_LEVEL_PROVENANCE_FIELDS) { + recordExplicit(provenance, field, config[field], configPath); + } + + if (!config.collections) { + // Single-source: the content root comes from the host (`--docs-dir` for the + // CLI, `contentDir` for the runtime source), so the resolved collection + // carries no `dir`. Everything else about it is authored at the top level. + const collectionProvenance: Record = {}; + for (const field of [ + "frontmatterSchema", + "navigation", + "groups", + "mounts", + ] as const) { + recordExplicit(collectionProvenance, field, config[field], configPath); + } + collectionProvenance.dir = { + origin: "default", + inferredFrom: "host content root (--docs-dir / contentDir)", + }; + collectionProvenance.routePrefix = { + origin: "default", + inferredFrom: "single-source default", + }; + + return { + config, + resolved: { + mode: "single-source", + ...(configPath ? { configPath } : {}), + ...(options.configDir ? { configDir: options.configDir } : {}), + product: config.product, + collections: [ + { + key: DEFAULT_COLLECTION_KEY, + routePrefix: "/docs", + ...(config.frontmatterSchema + ? { frontmatterSchema: config.frontmatterSchema } + : {}), + ...(config.navigation ? { navigation: config.navigation } : {}), + ...(config.groups ? { groups: config.groups } : {}), + ...(config.mounts ? { mounts: config.mounts } : {}), + sourceId: DEFAULT_SOURCE_ID, + provenance: collectionProvenance, + }, + ], + sources: [ + { + id: DEFAULT_SOURCE_ID, + kind: "local", + collectionKeys: [DEFAULT_COLLECTION_KEY], + }, + ], + deprecations, + provenance, + }, + }; + } + + const canonicalCollections: Record = {}; + const aliasProvenance = new Map>(); + for (const [key, collection] of Object.entries(config.collections)) { + const collectionAliasProvenance: Record = {}; + canonicalCollections[key] = applyCollectionAliases( + key, + collection, + configPath, + deprecations, + collectionAliasProvenance + ); + aliasProvenance.set(key, collectionAliasProvenance); + } + + const { sources, sourceIdByCollection } = resolveSources( + canonicalCollections, + configPath + ); + + const collections = Object.entries(canonicalCollections).map( + ([key, collection]) => { + const entry = resolveCollectionEntry( + key, + collection, + configPath, + sourceIdByCollection.get(key) ?? DEFAULT_SOURCE_ID + ); + // Alias provenance wins: it knows the field was authored under its old + // name, which is what a migration diagnostic needs to report. + return { + ...entry, + provenance: { + ...entry.provenance, + ...(aliasProvenance.get(key) ?? {}), + }, + }; + } + ); + + assertUniqueRoutePrefixes(collections, configPath); + + return { + config: { ...config, collections: canonicalCollections }, + resolved: { + mode: "multi-source", + ...(configPath ? { configPath } : {}), + ...(options.configDir ? { configDir: options.configDir } : {}), + product: config.product, + collections, + sources, + deprecations, + provenance, + }, + }; +} + +function assertUniqueRoutePrefixes( + collections: ResolvedDocsCollection[], + configPath: string | undefined +): void { + const byPrefix = new Map(); + for (const collection of collections) { + if (collection.routePrefix === "/") { + throw new Error( + `${configLabel(configPath)}: collection "${collection.key}" routePrefix must not be the site root.` + ); + } + const owner = byPrefix.get(collection.routePrefix); + if (owner) { + throw new Error( + `${configLabel(configPath)}: collections "${owner}" and "${collection.key}" share routePrefix "${collection.routePrefix}". Give each collection a distinct public prefix.` + ); + } + byPrefix.set(collection.routePrefix, collection.key); + } +} + +/** + * One actionable warning per config load, listing every deprecated field with + * its replacement. Emitted once by CLI flows — repeating it per subsystem would + * bury the rest of the output. + */ +export function formatDeprecationWarning( + deprecations: ConfigDeprecation[] +): { message: string; hint: string } | null { + if (deprecations.length === 0) { + return null; + } + const renames = deprecations + .map((entry) => `${entry.field} → ${entry.replacement}`) + .join(", "); + return { + message: `docs config uses ${deprecations.length} deprecated field${deprecations.length === 1 ? "" : "s"}: ${renames}`, + hint: "These still work and will keep working until the next major release. Rename them to silence this warning.", + }; +} diff --git a/packages/leadtype/src/config/types.ts b/packages/leadtype/src/config/types.ts new file mode 100644 index 00000000..58520b6b --- /dev/null +++ b/packages/leadtype/src/config/types.ts @@ -0,0 +1,219 @@ +/** + * The resolved project model. + * + * `DocsConfig` is the *authoring* shape: several fields have historical names, + * some concepts can be expressed two ways, and remote acquisition is declared + * per collection even when collections share a repository. `ResolvedDocsConfig` + * is the single *internal* shape every subsystem reads — generate, sync, lint, + * score, the runtime source, and diagnostics — so none of them re-derive the + * project from raw config, and none of them disagree about it. + * + * Two things the resolved shape adds beyond canonical names: + * + * - **A source graph.** Collections point at a source; sources own + * acquisition. Collections sharing `(repository, ref)` resolve to one + * source with both keys listed, which is what sync already does internally. + * - **Provenance.** Every resolved value records where it came from — + * authored here, inherited from a source repo, inferred, or a documented + * default — so a person or an agent can ask "why is this value what it is?" + * without reading the normalizer. + */ + +import type { DocsPathMount } from "../internal/docs-url"; +import type { + DocsGroup, + DocsNavEntry, + ProductInfo, + SourceConfigInheritance, +} from "../llm/llm"; +import type { DocsFrontmatterSchema } from "../transformers"; + +/** Where a resolved value came from. */ +export type ConfigValueOrigin = + /** Authored in the project's own config file. */ + | "explicit" + /** Inherited from a source repository's own `docs.config.*`. */ + | "inherited" + /** Derived by leadtype from content, `package.json`, or another config value. */ + | "inferred" + /** A documented static default; nothing was authored. */ + | "default"; + +export type FieldProvenance = { + origin: ConfigValueOrigin; + /** Absolute path of the config file the value was authored in. */ + configPath?: string; + /** + * The field name as authored, when it differs from the canonical name — + * e.g. `prefix` for a resolved `routePrefix`. Present only for deprecated + * aliases, so diagnostics can point at the exact line a user wrote. + */ + authoredAs?: string; + /** For `inherited` values, the collection key whose source supplied it. */ + inheritedFrom?: string; + /** For `inferred` values, a one-line explanation of the derivation. */ + inferredFrom?: string; +}; + +/** + * A deprecated field that was normalized. Ids are stable so JSON consumers can + * key on them; the message is what a human sees. + */ +export type ConfigDeprecation = { + /** Stable id, e.g. `collection.prefix`. */ + id: string; + /** Field path as authored, e.g. `collections.docs.prefix`. */ + field: string; + /** Canonical field path to move to, e.g. `collections.docs.routePrefix`. */ + replacement: string; + message: string; +}; + +/** A local content directory. Nothing to acquire. */ +export type ResolvedLocalSource = { + id: string; + kind: "local"; + /** Collection keys reading from this source. */ + collectionKeys: string[]; +}; + +/** + * One git acquisition. Collections sharing `(repository, ref)` share this + * entry — the clone happens once, and a failure names every dependent + * collection. + */ +export type ResolvedGitSource = { + id: string; + kind: "git"; + repository: string; + ref: string; + /** + * Whether `ref` pins a specific commit. A branch or a moving tag resolves + * differently on different machines and at different times, which is worth + * surfacing for a production docs build. + */ + refKind: "commit" | "mutable"; + /** Authored `cacheDir` override, relative to the config directory. */ + cacheDir?: string; + collectionKeys: string[]; +}; + +export type ResolvedSource = ResolvedLocalSource | ResolvedGitSource; + +export type ResolvedDocsCollection = { + key: string; + /** + * Directory containing the MDX, as authored, relative to the source root. + * + * Always set in multi-source mode. Absent in single-source mode, where the + * content root is supplied by the host instead — `--docs-dir` for the CLI, or + * `contentDir` for the runtime source — and the config genuinely does not + * know it. Provenance records that. + */ + dir?: string; + /** Public URL prefix, normalized. */ + routePrefix: string; + include?: string[]; + exclude?: string[]; + frontmatterSchema?: DocsFrontmatterSchema; + navigation?: DocsNavEntry[]; + groups?: DocsGroup[]; + mounts?: DocsPathMount[]; + inheritConfig?: SourceConfigInheritance; + /** Id of the entry in {@link ResolvedDocsConfig.sources} this reads from. */ + sourceId: string; + /** Per-field origin, keyed by canonical field name. */ + provenance: Record; +}; + +/** + * Single-source projects declare content at the top level; multi-source + * projects declare a `collections` map. The two are mutually exclusive and + * validated centrally rather than per subsystem. + */ +export type ResolvedProjectMode = "single-source" | "multi-source"; + +export type ResolvedDocsConfig = { + mode: ResolvedProjectMode; + /** Absolute path of the config file this was resolved from. */ + configPath?: string; + /** Directory the config was loaded from; relative paths resolve against it. */ + configDir?: string; + product: ProductInfo; + /** + * Every collection, in declaration order. A single-source project resolves + * to exactly one collection so downstream code has one shape to handle. + */ + collections: ResolvedDocsCollection[]; + /** Deduped acquisition graph. */ + sources: ResolvedSource[]; + /** Deprecated fields that were normalized, in authoring order. */ + deprecations: ConfigDeprecation[]; + /** Provenance for top-level fields, keyed by canonical field name. */ + provenance: Record; +}; + +/** The default collection key a single-source project resolves to. */ +export const DEFAULT_COLLECTION_KEY = "docs"; + +/** The id of the implicit local source a single-source project resolves to. */ +export const DEFAULT_SOURCE_ID = "local"; + +/** + * A JSON-safe view of the resolved config, for `--json` output and snapshots. + * Function-valued fields (frontmatter schemas, flatteners, transformers) can't + * cross a JSON boundary, so they are reported as presence flags rather than + * silently dropped — a reader needs to know a schema is in play even when it + * can't be serialized. + */ +export type SerializableResolvedConfig = { + mode: ResolvedProjectMode; + configPath?: string; + product: ProductInfo; + collections: { + key: string; + dir?: string; + routePrefix: string; + sourceId: string; + include?: string[]; + exclude?: string[]; + hasFrontmatterSchema: boolean; + hasNavigation: boolean; + hasGroups: boolean; + mounts?: DocsPathMount[]; + inheritConfig?: SourceConfigInheritance; + provenance: Record; + }[]; + sources: ResolvedSource[]; + deprecations: ConfigDeprecation[]; + provenance: Record; +}; + +export function serializeResolvedConfig( + resolved: ResolvedDocsConfig +): SerializableResolvedConfig { + return { + mode: resolved.mode, + ...(resolved.configPath ? { configPath: resolved.configPath } : {}), + product: resolved.product, + collections: resolved.collections.map((collection) => ({ + key: collection.key, + ...(collection.dir === undefined ? {} : { dir: collection.dir }), + routePrefix: collection.routePrefix, + sourceId: collection.sourceId, + ...(collection.include ? { include: collection.include } : {}), + ...(collection.exclude ? { exclude: collection.exclude } : {}), + hasFrontmatterSchema: collection.frontmatterSchema !== undefined, + hasNavigation: collection.navigation !== undefined, + hasGroups: collection.groups !== undefined, + ...(collection.mounts ? { mounts: collection.mounts } : {}), + ...(collection.inheritConfig === undefined + ? {} + : { inheritConfig: collection.inheritConfig }), + provenance: collection.provenance, + })), + sources: resolved.sources, + deprecations: resolved.deprecations, + provenance: resolved.provenance, + }; +} diff --git a/packages/leadtype/src/index.ts b/packages/leadtype/src/index.ts index 5886bc8a..bbc53433 100644 --- a/packages/leadtype/src/index.ts +++ b/packages/leadtype/src/index.ts @@ -12,6 +12,25 @@ // - `leadtype/lint` — frontmatter / meta.json validation // - `leadtype/transformers` — frontmatter schemas + lifecycle hook types +export { + formatDeprecationWarning, + type NormalizeDocsConfigOptions, + type NormalizedDocsConfig, + normalizeDocsConfig, +} from "./config/normalize"; +export { + type ConfigDeprecation, + type ConfigValueOrigin, + type FieldProvenance, + type ResolvedDocsCollection, + type ResolvedDocsConfig, + type ResolvedGitSource, + type ResolvedLocalSource, + type ResolvedProjectMode, + type ResolvedSource, + type SerializableResolvedConfig, + serializeResolvedConfig, +} from "./config/types"; export { type DocsFeedConfig, type FeedEntry, @@ -59,9 +78,11 @@ export { defineCollection, defineDocsConfig, defineFrameworkNavigation, + defineLeadtypeConfig, type FrameworkNavigationConfig, type FrameworkNavigationTemplate, type FrameworkNavigationVariant, + type LeadtypeConfig, type LlmsBlock, normalizeAgentReadabilityManifest, type OrganizationInfo, diff --git a/packages/leadtype/src/lint/cli.ts b/packages/leadtype/src/lint/cli.ts index d5b5a659..b13ecaa6 100644 --- a/packages/leadtype/src/lint/cli.ts +++ b/packages/leadtype/src/lint/cli.ts @@ -375,8 +375,8 @@ export async function runLintCommand( ...(externalLinksOptions ? { externalLinks: externalLinksOptions } : {}), - schemas: entry.collection.schema - ? { frontmatter: entry.collection.schema } + schemas: entry.collection.frontmatterSchema + ? { frontmatter: entry.collection.frontmatterSchema } : undefined, }); for (const violation of each.violations) { diff --git a/packages/leadtype/src/llm/index.ts b/packages/leadtype/src/llm/index.ts index 3eed1901..98d8b516 100644 --- a/packages/leadtype/src/llm/index.ts +++ b/packages/leadtype/src/llm/index.ts @@ -50,6 +50,7 @@ export { defineCollection, defineDocsConfig, defineFrameworkNavigation, + defineLeadtypeConfig, extractDocsTableOfContents, type FrameworkNavigationConfig, type FrameworkNavigationTemplate, @@ -61,6 +62,7 @@ export { generateAgentsMd, generateLLMFullContextFiles, generateLlmsTxt, + type LeadtypeConfig, type LLMFullContextConfig, type LlmsBlock, type LlmsProductInfo, diff --git a/packages/leadtype/src/llm/llm.ts b/packages/leadtype/src/llm/llm.ts index 83fd0111..6ad53b38 100644 --- a/packages/leadtype/src/llm/llm.ts +++ b/packages/leadtype/src/llm/llm.ts @@ -381,12 +381,18 @@ export type DocsCollection = { * Ignored for local-only collections. */ cacheDir?: string; + /** + * @deprecated Renamed to {@link DocsCollection.inheritConfig} — the field + * declares *inheritance*, not a config object. Still accepted and normalized; + * setting both is an error. + */ + sourceConfig?: SourceConfigInheritance; /** * For remote collections, load source-owned docs config from the synced * collection directory after sync and inherit content-owned fields into this * collection. */ - sourceConfig?: SourceConfigInheritance; + inheritConfig?: SourceConfigInheritance; /** * Directory containing the MDX. Relative to the repo root for remote * collections, or relative to cwd for local-only collections. @@ -396,15 +402,30 @@ export type DocsCollection = { include?: string[]; /** Optional exclude globs. */ exclude?: string[]; - /** URL prefix. Defaults to `"/" + `. */ + /** + * @deprecated Renamed to {@link DocsCollection.routePrefix} — the value is + * specifically a public route prefix, not a path or id prefix. Still accepted + * and normalized; setting both is an error. + */ prefix?: string; + /** Public URL prefix for this collection. Defaults to `"/" + `. */ + routePrefix?: string; + /** + * @deprecated Renamed to {@link DocsCollection.frontmatterSchema} to match + * the top-level field of the same purpose. Still accepted and normalized; + * setting both is an error. + */ + schema?: DocsFrontmatterSchema; /** * Per-collection frontmatter schema. Defaults to the standard leadtype * frontmatter schema. Errors are reported as * `[collection:] : ...`. */ - schema?: DocsFrontmatterSchema; - /** Per-collection navigation tree. */ + frontmatterSchema?: DocsFrontmatterSchema; + /** + * Per-collection group taxonomy. Legacy fallback navigation — prefer + * {@link DocsCollection.navigation}, which is the canonical IA field. + */ groups?: DocsGroup[]; /** Per-collection curated docs UI and agent navigation tree. */ navigation?: DocsNavEntry[]; @@ -639,9 +660,25 @@ export type DocsAgentsConfig = { }; }; +/** + * Project/site orchestration config, authored in `leadtype.config.ts` at the + * project root. Structurally identical to {@link DocsConfig} — the distinct + * name marks *ownership*: this file belongs to the site that publishes the + * docs, so it owns sources, routes, feeds, redirects, and agent surfaces. + * Source repositories declare their own content-owned config with + * {@link defineDocsConfig}. + */ +export type LeadtypeConfig< + TFrontmatter extends Record = Record, +> = DocsConfig; + /** * Identity helper that gives the config object full IDE autocomplete and * type-checks the docs structure at edit time. + * + * Use this for a source repository's own `docs.config.ts` — the content-owned + * config a docs site can inherit from. For a site or multi-repo project config + * in `leadtype.config.ts`, prefer {@link defineLeadtypeConfig}. */ export function defineDocsConfig< TFrontmatter extends Record = Record, @@ -649,6 +686,18 @@ export function defineDocsConfig< return config; } +/** + * Identity helper for a project/site `leadtype.config.ts`. Accepts the same + * shape as {@link defineDocsConfig}; the separate name keeps the two roles + * legible in a multi-repo setup, where a docs site pins several source repos + * that each ship their own `docs.config.ts`. + */ +export function defineLeadtypeConfig< + TFrontmatter extends Record = Record, +>(config: LeadtypeConfig): LeadtypeConfig { + return config; +} + /** * Identity helper for a single collection. Use with * {@link defineDocsConfig}'s `collections` map. diff --git a/packages/leadtype/src/sync/sync.ts b/packages/leadtype/src/sync/sync.ts index 20bac81c..720da26d 100644 --- a/packages/leadtype/src/sync/sync.ts +++ b/packages/leadtype/src/sync/sync.ts @@ -115,7 +115,7 @@ export function resolveCollection( collection: DocsCollection, configDir: string ): ResolvedCollection { - const urlPrefix = normalizeUrlPrefix(collection.prefix ?? `/${key}`); + const urlPrefix = normalizeUrlPrefix(collection.routePrefix ?? `/${key}`); if (!collection.repository) { return { key,