diff --git a/.changeset/docs-project-runtime.md b/.changeset/docs-project-runtime.md new file mode 100644 index 00000000..2de13439 --- /dev/null +++ b/.changeset/docs-project-runtime.md @@ -0,0 +1,23 @@ +--- +"leadtype": minor +--- + +Add `createDocsProject()` — the resolved project config as a runtime source. + +`createDocsSource()` describes one content directory, so an app restates what its config already says: the content root, navigation, mounts, the frontmatter schema, and for a multi-collection project all of that per collection plus route prefixes and source-owned inheritance. Two descriptions of one project drift, and when they do the rendered site and the generated agent artifacts disagree about what exists. + +A project reads the same resolved config the artifact pipeline reads, and returns a superset of `DocsSource`, so every first-party adapter accepts it unchanged: + +```ts +const source = await createDocsProject({ + config: docsConfig, + configPath: "docs/docs.config.ts", + baseUrl: "https://example.com", +}); +``` + +Multi-collection projects get one merged, route-aware page API — `listPages()` tags each page with its collection, `loadPage()` accepts a collection-local slug or the full route — plus `project.collections`, `project.sources`, and `project.getSource(key)` for custom integrations. Source-owned config inheritance now runs through one shared implementation, so human rendering and generated artifacts cannot resolve it differently. + +Remote collections are cache-only: a missing, unverifiable, or wrong-revision cache fails with a diagnostic naming `leadtype sync` rather than cloning inside a request. Route collisions name both collections. + +`createDocsSource()` stays fully supported and is what the project is built on. `leadtype init` now scaffolds the project primitive. diff --git a/apps/astro-example/src/lib/source.ts b/apps/astro-example/src/lib/source.ts index 6cc28c57..9ac1edb6 100644 --- a/apps/astro-example/src/lib/source.ts +++ b/apps/astro-example/src/lib/source.ts @@ -1,13 +1,17 @@ import path from "node:path"; -import { createDocsSource } from "leadtype"; +import { createDocsProject } from "leadtype"; import docsConfig from "../../../../docs/docs.config"; const repoRoot = path.resolve(process.cwd(), "../.."); -export const source = await createDocsSource({ - contentDir: path.join(repoRoot, "docs"), - nav: docsConfig.navigation, - mounts: docsConfig.mounts, +// The project reads the same resolved config the artifact pipeline reads, so +// navigation, mounts, the frontmatter schema, and the OpenAPI overlay are +// stated once — in the config — rather than restated here and left to drift. +// `configPath` fixes both the content root and where the config's relative +// paths resolve, using the same rules the CLI uses. +export const source = await createDocsProject({ + config: docsConfig, + configPath: path.join(repoRoot, "docs", "docs.config.ts"), baseUrl: "http://localhost:4321", typeTableBasePath: repoRoot, }); diff --git a/apps/fumadocs-example/lib/source.ts b/apps/fumadocs-example/lib/source.ts index 6146b10c..6aa9aedc 100644 --- a/apps/fumadocs-example/lib/source.ts +++ b/apps/fumadocs-example/lib/source.ts @@ -1,27 +1,28 @@ import { resolve } from "node:path"; import { loader } from "fumadocs-core/source"; +import { createDocsProject } from "leadtype"; import { fumadocsSource } from "leadtype/fumadocs"; import docsConfig from "../../../docs/docs.config"; // process.cwd() is the app root when Next runs build/dev. const repoRoot = resolve(process.cwd(), "..", ".."); -const contentDir = resolve(repoRoot, "docs"); /** - * fumadocs source backed by leadtype/fumadocs. It reads the repo-root - * Leadtype docs, uses the same curated navigation as the other examples, and - * resolves `` / `` relative to the repo root. - * - * Passing `openapi` stages generated API reference pages into a temp copy of - * the docs and appends their navigation — the authored docs are untouched. + * The project resolves the repo-root docs from the config: content root, + * curated navigation, mounts, and the OpenAPI overlay all come from + * `docs/docs.config.ts` rather than being restated here, so this app and the + * generated agent artifacts describe the same docs. */ +const project = await createDocsProject({ + config: docsConfig, + configPath: resolve(repoRoot, "docs", "docs.config.ts"), + typeTableBasePath: repoRoot, +}); + +// The adapter takes the project directly — a project satisfies `DocsSource`. const fumadocsSourceResult = await fumadocsSource({ - contentDir, + source: project, includeMetaJson: false, - nav: docsConfig.navigation, - mounts: docsConfig.mounts, - openapi: docsConfig.openapi, - typeTableBasePath: repoRoot, }); export const source = loader({ @@ -29,5 +30,5 @@ export const source = loader({ source: fumadocsSourceResult, }); -/** Underlying leadtype DocsSource — call loadPage/buildSearchIndex/resolveInclude on this. */ +/** Underlying leadtype source — call loadPage/buildSearchIndex/resolveInclude on this. */ export const leadtypeSource = fumadocsSourceResult.leadtype; diff --git a/docs/integrations/integrate-with-fumadocs.mdx b/docs/integrations/integrate-with-fumadocs.mdx index 102cfa79..30413b4e 100644 --- a/docs/integrations/integrate-with-fumadocs.mdx +++ b/docs/integrations/integrate-with-fumadocs.mdx @@ -22,6 +22,25 @@ export const source = loader({ baseUrl: "/docs", source: fumaSource }); export const leadtypeSource = fumaSource.leadtype; ``` +If your docs are described by a Leadtype config, pass a project instead of a content directory. The adapter accepts any `DocsSource`, and a project is one — so the content root, navigation, mounts, and OpenAPI overlay come from the config rather than being restated here: + +```ts title="lib/source.ts" +// @noErrors - `../docs/docs.config` is your project's own config file. +import { loader } from "fumadocs-core/source"; +import { createDocsProject } from "leadtype"; +import { fumadocsSource } from "leadtype/fumadocs"; +import docsConfig from "../docs/docs.config"; + +const project = await createDocsProject({ + config: docsConfig, + configPath: "docs/docs.config.ts", +}); + +const fumaSource = await fumadocsSource({ source: project }); +export const source = loader({ baseUrl: "/docs", source: fumaSource }); +export const leadtypeSource = fumaSource.leadtype; +``` + ```ts title="next.config.mjs" import createMDX from "@next/mdx"; import { createMdxSourcePlugins } from "leadtype/mdx"; diff --git a/docs/paths.lock.json b/docs/paths.lock.json index 12302dc1..1b1fe9d0 100644 --- a/docs/paths.lock.json +++ b/docs/paths.lock.json @@ -59,7 +59,7 @@ }, { "path": "/docs/integrations/integrate-with-fumadocs", - "hash": "d80047caddd99c81" + "hash": "23f6af7223091f5c" }, { "path": "/docs/package-docs/bundle", @@ -107,7 +107,7 @@ }, { "path": "/docs/pipeline/use-the-source-primitive", - "hash": "0749b497916da7c1" + "hash": "9d11498866b40b2d" }, { "path": "/docs/pipeline/validate-in-ci", diff --git a/docs/pipeline/use-the-source-primitive.mdx b/docs/pipeline/use-the-source-primitive.mdx index 911759e5..73f8eaff 100644 --- a/docs/pipeline/use-the-source-primitive.mdx +++ b/docs/pipeline/use-the-source-primitive.mdx @@ -27,6 +27,57 @@ export const source = await createDocsSource({ Wire `createMdxSourcePlugins()` into your bundler's remark stack, then call `source.loadPage(slug)` from your framework's page renderer. The "Wire into your framework" section below has minimal setups for each host. +## Start from your config instead + +`createDocsSource()` describes **one content directory**. That is the right primitive for a custom integration, but it means your app restates what your config already says — the content root, the navigation, the mounts, the frontmatter schema — and a multi-collection app restates it per collection, plus route prefixes and source-owned inheritance. + +Two descriptions of one project drift. When they do, the rendered site and the generated agent artifacts disagree about what exists — which is the failure "one content graph" exists to prevent. + +`createDocsProject()` reads the same resolved config the artifact pipeline reads: + +```ts title="lib/source.ts" +// @noErrors - `../docs/docs.config` is your project's own config file. +import { createDocsProject } from "leadtype"; +import docsConfig from "../docs/docs.config"; + +export const source = await createDocsProject({ + config: docsConfig, + configPath: "docs/docs.config.ts", + baseUrl: "https://example.com", +}); +``` + +It returns a **superset of `DocsSource`**, so every first-party adapter takes it unchanged — `createGenerateStaticParams({ source })`, `createLoadPageData({ source })`, and the rest keep working with no parallel code path. + +`configPath` fixes both the content root and where the config's relative paths resolve, using the same rules the CLI uses: a `docs.config.*` sits inside the docs directory, a `leadtype.config.*` sits at the project root above it. Pass `configDir` and `contentDir` instead when you want to be explicit. + +### What it adds + +```ts +// @noErrors - illustrative; `project` comes from createDocsProject above. +project.collections; // resolved collections, in config order +project.sources; // the acquisition graph +project.getSource("changelog"); // one collection's DocsSource +const page = await project.loadPage("changelog/1-0"); +page?.collection; // "changelog" +``` + +A multi-collection app gets one merged, route-aware page API: `listPages()` returns every collection's pages tagged with where they came from, and `loadPage()` accepts either a collection-local slug (`1-0`) or the full route (`changelog/1-0`), so one handler can serve everything or you can mount a route per collection. + +### Remote collections are cache-only + +A request handler must never clone a repository. A collection whose cache is missing, unverifiable, or holds a different revision than the config asks for fails with a diagnostic naming `leadtype sync` — it does not quietly fetch: + +```text +createDocsProject: the cache for collection "docs" holds +https://github.com/acme/acme.git@v0.9, but the config asks for +https://github.com/acme/acme.git@main. Run `leadtype sync --refresh`. +``` + +### Keep using `createDocsSource()` when + +You are wiring a single content directory that no Leadtype config describes, or you need to construct a source from paths computed at runtime. The lower-level primitive is fully supported and is what the project is built on. + ## Install ```sh diff --git a/packages/leadtype/src/cli/generate.ts b/packages/leadtype/src/cli/generate.ts index 0e014c0d..df8d320f 100644 --- a/packages/leadtype/src/cli/generate.ts +++ b/packages/leadtype/src/cli/generate.ts @@ -4,7 +4,6 @@ import { cp, mkdir, mkdtemp, readFile, rm, rmdir } from "node:fs/promises"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import path from "node:path"; -import { pathToFileURL } from "node:url"; import { glob as fg } from "tinyglobby"; import type { Pluggable, PluggableList } from "unified"; import { @@ -14,6 +13,18 @@ import { inferNavigationFromContent, mergeInferenceReports, } from "../config/infer"; +import { + DOCS_CONFIG_FILENAMES, + importConfigModule, + inheritCollectionSourceConfigs, + isPlainRecord, + isStringArray, + LEADTYPE_CONFIG_FILENAMES, + SOURCE_CONFIG_INHERIT_FIELDS, + validateDocsGroups, + validateDocsMounts, + validateDocsNav, +} from "../config/inherit"; import { formatDeprecationWarning, normalizeDocsConfig, @@ -52,7 +63,6 @@ import type { DocsGroup, DocsLlmsConfig, DocsNavEntry, - DocsNavIncludeEntry, DocsNavigation, DocsNavNode, DocsNavPageEntry, @@ -102,7 +112,6 @@ import { import type { GenerateDocsSearchFilesResult } from "../search/node"; import { generateDocsSearchFiles } from "../search/node"; import { - type ResolvedCollection, resolveAllCollections, type SyncMode, syncCollections, @@ -112,36 +121,10 @@ import { watchInputs } from "./watch"; const DEFAULT_DOCS_DIR = "docs"; const DEFAULT_OUT_DIR = "public"; -const DOCS_CONFIG_FILENAMES = [ - "docs.config.ts", - "docs.config.js", - "docs.config.mjs", - "docs.config.cjs", -] as const; -const SOURCE_CONFIG_INHERIT_FIELDS = new Set([ - "navigation", - "groups", - "frontmatterSchema", - "flatteners", -]); -const DEFAULT_SOURCE_CONFIG_INHERIT: SourceConfigInheritField[] = [ - "navigation", - "groups", - "frontmatterSchema", - "flatteners", - "mounts", -]; -const LEADTYPE_CONFIG_FILENAMES = [ - "leadtype.config.ts", - "leadtype.config.js", - "leadtype.config.mjs", - "leadtype.config.cjs", -] as const; const GROUP_SEPARATOR_PATTERN = /[-_]+/g; const INFER_GROUPS_READ_BATCH_SIZE = 32; const TITLE_CASE_PATTERN = /\b\w/g; const FORMAT_VALUES = new Set(["text", "json"]); -const NAV_SORT_VALUES = new Set(["order", "path", "title"]); const FEED_FORMAT_VALUES = new Set(["rss", "atom"]); const MCP_FLAG_DEPRECATION_MESSAGE = "--mcp is deprecated as a generate shortcut and will be removed in the next major version"; @@ -360,14 +343,6 @@ type CollectionFrontmatterSchema = { schema: DocsFrontmatterSchema; }; -type SourceOwnedConfigFields = { - flatteners?: PluggableList; - frontmatterSchema?: DocsFrontmatterSchema; - groups?: DocsGroup[]; - mounts?: DocsPathMount[]; - navigation?: DocsNavEntry[]; -}; - const GENERATE_USAGE = `leadtype generate — convert MDX and produce site or package-bundle artifacts Usage: @@ -581,10 +556,6 @@ async function inferGroups(docsDir: string): Promise { })); } -function isPlainRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function validateOptionalStringField( value: Record, field: string, @@ -895,30 +866,6 @@ function validateAgentsConfig( return value as DocsConfig["agents"]; } -function validateDocsMounts( - value: unknown, - configPath: string -): DocsPathMount[] | undefined { - if (value === undefined) { - return; - } - if (!Array.isArray(value)) { - throw new Error(`docs config at "${configPath}": mounts must be an array`); - } - for (const mount of value) { - if ( - !isPlainRecord(mount) || - typeof mount.pathPrefix !== "string" || - typeof mount.urlPrefix !== "string" - ) { - throw new Error( - `docs config at "${configPath}": mounts entries must be { pathPrefix, urlPrefix } objects` - ); - } - } - return value as DocsPathMount[]; -} - function validateDocsFeeds( value: unknown, configPath: string @@ -1020,125 +967,6 @@ function validateDocsFeeds( return value as DocsFeedConfig[]; } -function validateDocsGroups(value: unknown): DocsGroup[] | undefined { - if (!Array.isArray(value)) { - return; - } - for (const group of value) { - if (!isPlainRecord(group)) { - return; - } - if (typeof group.slug !== "string" || typeof group.title !== "string") { - return; - } - if ( - group.children !== undefined && - validateDocsGroups(group.children) === undefined - ) { - return; - } - } - return value as DocsGroup[]; -} - -function validateDocsNavPageEntry( - value: unknown -): DocsNavPageEntry | undefined { - if (typeof value === "string") { - return value; - } - if (!isPlainRecord(value) || typeof value.include !== "string") { - return; - } - if ( - value.exclude !== undefined && - !(typeof value.exclude === "string" || isStringArray(value.exclude)) - ) { - return; - } - if ( - value.sort !== undefined && - !( - isStringArray(value.sort) && - value.sort.every((sortKey) => NAV_SORT_VALUES.has(sortKey)) - ) - ) { - return; - } - if (value.required !== undefined && typeof value.required !== "boolean") { - return; - } - return value as DocsNavIncludeEntry; -} - -function validateDocsNavNode(value: unknown): DocsNavNode | undefined { - if (!isPlainRecord(value) || typeof value.title !== "string") { - return; - } - if (value.slug !== undefined && typeof value.slug !== "string") { - return; - } - if ( - value.description !== undefined && - typeof value.description !== "string" - ) { - return; - } - if (value.base !== undefined && typeof value.base !== "string") { - return; - } - if (value.pages !== undefined && !Array.isArray(value.pages)) { - return; - } - if (Array.isArray(value.pages)) { - for (const page of value.pages) { - if (validateDocsNavPageEntry(page) === undefined) { - return; - } - } - } - if ( - value.children !== undefined && - validateDocsNavNodes(value.children) === undefined - ) { - return; - } - return value as DocsNavNode; -} - -function validateDocsNavNodes(value: unknown): DocsNavNode[] | undefined { - if (value === undefined) { - return; - } - if (!Array.isArray(value)) { - return; - } - for (const node of value) { - if (validateDocsNavNode(node) === undefined) { - return; - } - } - return value as DocsNavNode[]; -} - -function validateDocsNav(value: unknown): DocsNavEntry[] | undefined { - if (value === undefined) { - return; - } - if (!Array.isArray(value)) { - return; - } - for (const entry of value) { - if ( - validateDocsNavNode(entry) === undefined && - validateDocsNavPageEntry(entry) === undefined - ) { - return; - } - } - return value as DocsNavEntry[]; -} - function validateSourceConfigInheritance( value: unknown, configPath: string, @@ -1310,10 +1138,6 @@ function validateCollections( return out; } -function isStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every((v) => typeof v === "string"); -} - /** * Validate `sources` by checking the source-owned acquisition fields, then * reusing the collection validator on each child with the source's fields @@ -1671,183 +1495,6 @@ function validateDocsConfig(value: unknown, configPath: string): DocsConfig { }; } -async function importConfigModule(configPath: string): Promise { - if (configPath.endsWith(".ts")) { - let createJiti: typeof import("jiti").createJiti; - try { - ({ createJiti } = await import("jiti")); - } catch { - throw new Error( - `loading TypeScript docs config at "${configPath}" requires the optional peer dependency \`jiti\`. Install it (\`bun add -D jiti\`) or use a .js/.mjs/.cjs config.` - ); - } - const jiti = createJiti(import.meta.url, { moduleCache: false }); - return jiti.import(configPath, { default: true }); - } - - const mod = (await import(pathToFileURL(configPath).href)) as { - default?: unknown; - }; - return mod.default ?? mod; -} - -function validateSourceOwnedConfigFields( - value: unknown, - configPath: string, - collectionKey: string -): SourceOwnedConfigFields { - if (!isPlainRecord(value)) { - throw new Error(`source config at "${configPath}" must export an object`); - } - - const groups = validateDocsGroups(value.groups); - const navigation = validateDocsNav(value.navigation); - const mounts = validateDocsMounts(value.mounts, configPath); - if (value.groups !== undefined && !groups) { - throw new Error( - `source config at "${configPath}" for collection "${collectionKey}" must export groups as an array of { slug, title } entries` - ); - } - if (value.navigation !== undefined && !navigation) { - throw new Error( - `source config at "${configPath}" for collection "${collectionKey}" must export navigation as an array of page entries or navigation nodes` - ); - } - if (value.flatteners !== undefined && !Array.isArray(value.flatteners)) { - throw new Error( - `source config at "${configPath}" for collection "${collectionKey}" must export flatteners as an array of remark plugins` - ); - } - - return { - ...(groups ? { groups } : {}), - ...(navigation ? { navigation } : {}), - ...(mounts ? { mounts } : {}), - ...(value.frontmatterSchema === undefined - ? {} - : { - frontmatterSchema: value.frontmatterSchema as DocsFrontmatterSchema, - }), - ...(value.flatteners === undefined - ? {} - : { flatteners: value.flatteners as PluggableList }), - }; -} - -function resolveSourceConfigPaths(entry: ResolvedCollection): string[] { - const sourceConfig = entry.collection.inheritConfig; - if (!sourceConfig) { - return []; - } - const baseDir = entry.absoluteDir; - if (typeof sourceConfig === "object" && sourceConfig.path) { - if (path.isAbsolute(sourceConfig.path)) { - throw new Error( - `collection "${entry.key}" inheritConfig.path must be relative to the collection dir` - ); - } - const configPath = path.resolve(baseDir, sourceConfig.path); - const relativePath = path.relative(baseDir, configPath); - if ( - !relativePath || - relativePath.startsWith("..") || - path.isAbsolute(relativePath) - ) { - throw new Error( - `collection "${entry.key}" inheritConfig.path must stay inside the collection dir` - ); - } - return [configPath]; - } - return DOCS_CONFIG_FILENAMES.map((filename) => path.join(baseDir, filename)); -} - -function sourceConfigInheritFields( - collection: DocsCollection -): SourceConfigInheritField[] { - const sourceConfig = collection.inheritConfig; - if (typeof sourceConfig !== "object" || !sourceConfig.inherit) { - return DEFAULT_SOURCE_CONFIG_INHERIT; - } - return sourceConfig.inherit; -} - -async function loadCollectionSourceConfig( - entry: ResolvedCollection -): Promise { - const candidates = resolveSourceConfigPaths(entry); - const configPath = candidates.find((candidate) => existsSync(candidate)); - if (!configPath) { - throw new Error( - `collection "${entry.key}" inheritConfig is enabled but no source config was found. Expected ${candidates.map((candidate) => `"${candidate}"`).join(", ")}.` - ); - } - - try { - const imported = await importConfigModule(configPath); - return validateSourceOwnedConfigFields(imported, configPath, entry.key); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error( - `failed to load source config for collection "${entry.key}" at "${configPath}": ${message}` - ); - } -} - -function mergeInheritedSourceConfig( - collection: DocsCollection, - sourceConfig: SourceOwnedConfigFields -): DocsCollection { - const inherit = new Set(sourceConfigInheritFields(collection)); - return { - ...collection, - ...(inherit.has("navigation") && - collection.navigation === undefined && - sourceConfig.navigation !== undefined - ? { navigation: sourceConfig.navigation } - : {}), - ...(inherit.has("groups") && - collection.groups === undefined && - sourceConfig.groups !== undefined - ? { groups: sourceConfig.groups } - : {}), - ...(inherit.has("frontmatterSchema") && - collection.frontmatterSchema === undefined && - sourceConfig.frontmatterSchema !== undefined - ? { frontmatterSchema: sourceConfig.frontmatterSchema } - : {}), - ...(inherit.has("flatteners") && - collection.flatteners === undefined && - sourceConfig.flatteners !== undefined - ? { flatteners: sourceConfig.flatteners } - : {}), - ...(inherit.has("mounts") && - collection.mounts === undefined && - sourceConfig.mounts !== undefined - ? { mounts: sourceConfig.mounts } - : {}), - }; -} - -async function inheritCollectionSourceConfigs( - collections: Record, - configDir: string -): Promise> { - const resolved = resolveAllCollections(collections, configDir); - const next: Record = { ...collections }; - for (const entry of resolved) { - if (!entry.collection.inheritConfig) { - continue; - } - const sourceConfig = await loadCollectionSourceConfig(entry); - next[entry.key] = mergeInheritedSourceConfig( - entry.collection, - sourceConfig - ); - } - return next; -} - async function loadDocsConfigFromDir( dir: string, filenames: readonly string[] diff --git a/packages/leadtype/src/cli/init-templates.ts b/packages/leadtype/src/cli/init-templates.ts index 0f501197..2e9bb6f1 100644 --- a/packages/leadtype/src/cli/init-templates.ts +++ b/packages/leadtype/src/cli/init-templates.ts @@ -145,13 +145,12 @@ export default withMdx({ pageExtensions: ["ts", "tsx", "mdx"] }); }, { path: "lib/source.ts", - contents: `import path from "node:path"; -import { createDocsSource } from "leadtype"; + contents: `import { createDocsProject } from "leadtype"; import docsConfig from "../docs/docs.config"; -export const source = await createDocsSource({ - contentDir: path.resolve(process.cwd(), "docs"), - nav: docsConfig.navigation, +export const source = await createDocsProject({ + config: docsConfig, + configDir: process.cwd(), baseUrl: ${JSON.stringify(baseUrl)}, }); `, @@ -249,13 +248,12 @@ export default defineConfig({ }, { path: "src/lib/source.ts", - contents: `import path from "node:path"; -import { createDocsSource } from "leadtype"; + contents: `import { createDocsProject } from "leadtype"; import docsConfig from "../../docs/docs.config"; -export const source = await createDocsSource({ - contentDir: path.resolve(process.cwd(), "docs"), - nav: docsConfig.navigation, +export const source = await createDocsProject({ + config: docsConfig, + configDir: process.cwd(), baseUrl: ${JSON.stringify(baseUrl)}, }); `, @@ -326,16 +324,15 @@ function nuxtPlan( ...(options.webmcp ? [nuxtWebMcpFile()] : []), { path: "lib/source.ts", - contents: `import path from "node:path"; -import { createDocsSource } from "leadtype"; + contents: `import { createDocsProject } from "leadtype"; import docsConfig from "../docs/docs.config"; -let sourcePromise: ReturnType | undefined; +let sourcePromise: ReturnType | undefined; export function getSource() { - sourcePromise ??= createDocsSource({ - contentDir: path.resolve(process.cwd(), "docs"), - nav: docsConfig.navigation, + sourcePromise ??= createDocsProject({ + config: docsConfig, + configDir: process.cwd(), baseUrl: ${JSON.stringify(baseUrl)}, }); return sourcePromise; @@ -465,13 +462,12 @@ export default { }, { path: "src/lib/source.ts", - contents: `import path from "node:path"; -import { createDocsSource } from "leadtype"; + contents: `import { createDocsProject } from "leadtype"; import docsConfig from "../../docs/docs.config"; -export const source = await createDocsSource({ - contentDir: path.resolve(process.cwd(), "docs"), - nav: docsConfig.navigation, +export const source = await createDocsProject({ + config: docsConfig, + configDir: process.cwd(), baseUrl: ${JSON.stringify(baseUrl)}, }); `, diff --git a/packages/leadtype/src/config/inherit.ts b/packages/leadtype/src/config/inherit.ts new file mode 100644 index 00000000..6af4816d --- /dev/null +++ b/packages/leadtype/src/config/inherit.ts @@ -0,0 +1,400 @@ +/** + * Config loading and source-owned inheritance — shared by build and runtime. + * + * A docs site that pins a source repository inherits that repo's content-owned + * config: its navigation, frontmatter schema, flatteners, and mounts. If the + * artifact pipeline and the rendered site each implemented that inheritance, + * the two would eventually disagree about what a page is called and where it + * lives — which is the exact failure "one content graph" exists to prevent. + * + * So there is one implementation, here, and both sides call it. + */ + +import { existsSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import type { PluggableList } from "unified"; +import type { DocsPathMount } from "../internal/docs-url"; +import type { + DocsCollection, + DocsGroup, + DocsNavEntry, + DocsNavIncludeEntry, + DocsNavNode, + DocsNavPageEntry, + SourceConfigInheritField, +} from "../llm"; +import { type ResolvedCollection, resolveAllCollections } from "../sync/sync"; +import type { DocsFrontmatterSchema } from "../transformers"; + +export const DOCS_CONFIG_FILENAMES = [ + "docs.config.ts", + "docs.config.js", + "docs.config.mjs", + "docs.config.cjs", +] as const; + +export const LEADTYPE_CONFIG_FILENAMES = [ + "leadtype.config.ts", + "leadtype.config.js", + "leadtype.config.mjs", + "leadtype.config.cjs", +] as const; + +const SOURCE_CONFIG_INHERIT_FIELDS = new Set([ + "navigation", + "groups", + "frontmatterSchema", + "flatteners", +]); + +const DEFAULT_SOURCE_CONFIG_INHERIT: SourceConfigInheritField[] = [ + "navigation", + "groups", + "frontmatterSchema", + "flatteners", + "mounts", +]; + +const NAV_SORT_VALUES = new Set(["order", "path", "title"]); + +/** The content-owned fields a docs site may inherit from a source repository. */ +export type SourceOwnedConfigFields = { + flatteners?: PluggableList; + frontmatterSchema?: DocsFrontmatterSchema; + groups?: DocsGroup[]; + mounts?: DocsPathMount[]; + navigation?: DocsNavEntry[]; +}; + +export { DEFAULT_SOURCE_CONFIG_INHERIT, SOURCE_CONFIG_INHERIT_FIELDS }; + +export function isPlainRecord( + value: unknown +): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((v) => typeof v === "string"); +} + +export function validateDocsMounts( + value: unknown, + configPath: string +): DocsPathMount[] | undefined { + if (value === undefined) { + return; + } + if (!Array.isArray(value)) { + throw new Error(`docs config at "${configPath}": mounts must be an array`); + } + for (const mount of value) { + if ( + !isPlainRecord(mount) || + typeof mount.pathPrefix !== "string" || + typeof mount.urlPrefix !== "string" + ) { + throw new Error( + `docs config at "${configPath}": mounts entries must be { pathPrefix, urlPrefix } objects` + ); + } + } + return value as DocsPathMount[]; +} + +export function validateDocsGroups(value: unknown): DocsGroup[] | undefined { + if (!Array.isArray(value)) { + return; + } + for (const group of value) { + if (!isPlainRecord(group)) { + return; + } + if (typeof group.slug !== "string" || typeof group.title !== "string") { + return; + } + if ( + group.children !== undefined && + validateDocsGroups(group.children) === undefined + ) { + return; + } + } + return value as DocsGroup[]; +} + +function validateDocsNavPageEntry( + value: unknown +): DocsNavPageEntry | undefined { + if (typeof value === "string") { + return value; + } + if (!isPlainRecord(value) || typeof value.include !== "string") { + return; + } + if ( + value.exclude !== undefined && + !(typeof value.exclude === "string" || isStringArray(value.exclude)) + ) { + return; + } + if ( + value.sort !== undefined && + !( + isStringArray(value.sort) && + value.sort.every((sortKey) => NAV_SORT_VALUES.has(sortKey)) + ) + ) { + return; + } + if (value.required !== undefined && typeof value.required !== "boolean") { + return; + } + return value as DocsNavIncludeEntry; +} + +function validateDocsNavNode(value: unknown): DocsNavNode | undefined { + if (!isPlainRecord(value) || typeof value.title !== "string") { + return; + } + if (value.slug !== undefined && typeof value.slug !== "string") { + return; + } + if ( + value.description !== undefined && + typeof value.description !== "string" + ) { + return; + } + if (value.base !== undefined && typeof value.base !== "string") { + return; + } + if (value.pages !== undefined && !Array.isArray(value.pages)) { + return; + } + if (Array.isArray(value.pages)) { + for (const page of value.pages) { + if (validateDocsNavPageEntry(page) === undefined) { + return; + } + } + } + if ( + value.children !== undefined && + validateDocsNavNodes(value.children) === undefined + ) { + return; + } + return value as DocsNavNode; +} + +function validateDocsNavNodes(value: unknown): DocsNavNode[] | undefined { + if (value === undefined) { + return; + } + if (!Array.isArray(value)) { + return; + } + for (const node of value) { + if (validateDocsNavNode(node) === undefined) { + return; + } + } + return value as DocsNavNode[]; +} + +export function validateDocsNav(value: unknown): DocsNavEntry[] | undefined { + if (value === undefined) { + return; + } + if (!Array.isArray(value)) { + return; + } + for (const entry of value) { + if ( + validateDocsNavNode(entry) === undefined && + validateDocsNavPageEntry(entry) === undefined + ) { + return; + } + } + return value as DocsNavEntry[]; +} + +export function validateSourceOwnedConfigFields( + value: unknown, + configPath: string, + collectionKey: string +): SourceOwnedConfigFields { + if (!isPlainRecord(value)) { + throw new Error(`source config at "${configPath}" must export an object`); + } + + const groups = validateDocsGroups(value.groups); + const navigation = validateDocsNav(value.navigation); + const mounts = validateDocsMounts(value.mounts, configPath); + if (value.groups !== undefined && !groups) { + throw new Error( + `source config at "${configPath}" for collection "${collectionKey}" must export groups as an array of { slug, title } entries` + ); + } + if (value.navigation !== undefined && !navigation) { + throw new Error( + `source config at "${configPath}" for collection "${collectionKey}" must export navigation as an array of page entries or navigation nodes` + ); + } + if (value.flatteners !== undefined && !Array.isArray(value.flatteners)) { + throw new Error( + `source config at "${configPath}" for collection "${collectionKey}" must export flatteners as an array of remark plugins` + ); + } + + return { + ...(groups ? { groups } : {}), + ...(navigation ? { navigation } : {}), + ...(mounts ? { mounts } : {}), + ...(value.frontmatterSchema === undefined + ? {} + : { + frontmatterSchema: value.frontmatterSchema as DocsFrontmatterSchema, + }), + ...(value.flatteners === undefined + ? {} + : { flatteners: value.flatteners as PluggableList }), + }; +} + +export async function importConfigModule(configPath: string): Promise { + if (configPath.endsWith(".ts")) { + let createJiti: typeof import("jiti").createJiti; + try { + ({ createJiti } = await import("jiti")); + } catch { + throw new Error( + `loading TypeScript docs config at "${configPath}" requires the optional peer dependency \`jiti\`. Install it (\`bun add -D jiti\`) or use a .js/.mjs/.cjs config.` + ); + } + const jiti = createJiti(import.meta.url, { moduleCache: false }); + return jiti.import(configPath, { default: true }); + } + + const mod = (await import(pathToFileURL(configPath).href)) as { + default?: unknown; + }; + return mod.default ?? mod; +} + +function resolveSourceConfigPaths(entry: ResolvedCollection): string[] { + const sourceConfig = entry.collection.inheritConfig; + if (!sourceConfig) { + return []; + } + const baseDir = entry.absoluteDir; + if (typeof sourceConfig === "object" && sourceConfig.path) { + if (path.isAbsolute(sourceConfig.path)) { + throw new Error( + `collection "${entry.key}" inheritConfig.path must be relative to the collection dir` + ); + } + const configPath = path.resolve(baseDir, sourceConfig.path); + const relativePath = path.relative(baseDir, configPath); + if ( + !relativePath || + relativePath.startsWith("..") || + path.isAbsolute(relativePath) + ) { + throw new Error( + `collection "${entry.key}" inheritConfig.path must stay inside the collection dir` + ); + } + return [configPath]; + } + return DOCS_CONFIG_FILENAMES.map((filename) => path.join(baseDir, filename)); +} + +function sourceConfigInheritFields( + collection: DocsCollection +): SourceConfigInheritField[] { + const sourceConfig = collection.inheritConfig; + if (typeof sourceConfig !== "object" || !sourceConfig.inherit) { + return DEFAULT_SOURCE_CONFIG_INHERIT; + } + return sourceConfig.inherit; +} + +async function loadCollectionSourceConfig( + entry: ResolvedCollection +): Promise { + const candidates = resolveSourceConfigPaths(entry); + const configPath = candidates.find((candidate) => existsSync(candidate)); + if (!configPath) { + throw new Error( + `collection "${entry.key}" inheritConfig is enabled but no source config was found. Expected ${candidates.map((candidate) => `"${candidate}"`).join(", ")}.` + ); + } + + try { + const imported = await importConfigModule(configPath); + return validateSourceOwnedConfigFields(imported, configPath, entry.key); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `failed to load source config for collection "${entry.key}" at "${configPath}": ${message}` + ); + } +} + +function mergeInheritedSourceConfig( + collection: DocsCollection, + sourceConfig: SourceOwnedConfigFields +): DocsCollection { + const inherit = new Set(sourceConfigInheritFields(collection)); + return { + ...collection, + ...(inherit.has("navigation") && + collection.navigation === undefined && + sourceConfig.navigation !== undefined + ? { navigation: sourceConfig.navigation } + : {}), + ...(inherit.has("groups") && + collection.groups === undefined && + sourceConfig.groups !== undefined + ? { groups: sourceConfig.groups } + : {}), + ...(inherit.has("frontmatterSchema") && + collection.frontmatterSchema === undefined && + sourceConfig.frontmatterSchema !== undefined + ? { frontmatterSchema: sourceConfig.frontmatterSchema } + : {}), + ...(inherit.has("flatteners") && + collection.flatteners === undefined && + sourceConfig.flatteners !== undefined + ? { flatteners: sourceConfig.flatteners } + : {}), + ...(inherit.has("mounts") && + collection.mounts === undefined && + sourceConfig.mounts !== undefined + ? { mounts: sourceConfig.mounts } + : {}), + }; +} + +export async function inheritCollectionSourceConfigs( + collections: Record, + configDir: string +): Promise> { + const resolved = resolveAllCollections(collections, configDir); + const next: Record = { ...collections }; + for (const entry of resolved) { + if (!entry.collection.inheritConfig) { + continue; + } + const sourceConfig = await loadCollectionSourceConfig(entry); + next[entry.key] = mergeInheritedSourceConfig( + entry.collection, + sourceConfig + ); + } + return next; +} diff --git a/packages/leadtype/src/fumadocs/index.ts b/packages/leadtype/src/fumadocs/index.ts index b709a275..f21fd9f5 100644 --- a/packages/leadtype/src/fumadocs/index.ts +++ b/packages/leadtype/src/fumadocs/index.ts @@ -87,10 +87,22 @@ export type LeadtypeFumadocsSource = Source & { * const source = await fumadocsSource({ contentDir: "./content/docs" }); * const loader = loader({ baseUrl: "/docs", source }); */ +/** + * Accepts either a source description to build, or an already-resolved + * `DocsSource` — which `createDocsProject()` satisfies. Passing a project is + * the shorter path: it already knows the content root, navigation, mounts, and + * OpenAPI overlay from the config, so none of that has to be restated here + * only to drift from it later. + */ +export type FumadocsSourceConfig = + | (CreateDocsSourceConfig & { includeMetaJson?: boolean }) + | { source: DocsSource; includeMetaJson?: boolean }; + export async function fumadocsSource( - config: CreateDocsSourceConfig & { includeMetaJson?: boolean } + config: FumadocsSourceConfig ): Promise { - const leadtype = await createDocsSource(config); + const leadtype = + "source" in config ? config.source : await createDocsSource(config); const metas = await leadtype.listPages(); const pageFiles = metas.map((meta) => ({ diff --git a/packages/leadtype/src/index.ts b/packages/leadtype/src/index.ts index d6ea485b..328e17c6 100644 --- a/packages/leadtype/src/index.ts +++ b/packages/leadtype/src/index.ts @@ -120,6 +120,13 @@ export type { StagedOpenApiDocs, StageOpenApiDocsConfig, } from "./openapi"; +export { + type CreateDocsProjectConfig, + createDocsProject, + type DocsProject, + type DocsProjectPage, + type DocsProjectPageMeta, +} from "./project"; export { type CreateDocsSourceConfig, createDocsSource, diff --git a/packages/leadtype/src/project/index.ts b/packages/leadtype/src/project/index.ts new file mode 100644 index 00000000..7a5b1c66 --- /dev/null +++ b/packages/leadtype/src/project/index.ts @@ -0,0 +1,437 @@ +/** + * `createDocsProject()` — the resolved project as a runtime source. + * + * `createDocsSource()` describes one content directory. That is the right + * primitive for a custom integration, but it means an app repeats what its + * config already says — the content root, the navigation, the base URL — and a + * multi-collection app repeats it per collection, plus mounts, prefixes, and + * source-owned inheritance. Two descriptions of one project drift, and when + * they do the rendered site and the generated agent artifacts disagree. + * + * A project reads the same resolved config the artifact pipeline reads, and + * exposes it through the `DocsSource` contract so existing framework adapters + * accept it unchanged: + * + * ```ts + * const project = await createDocsProject({ config: docsConfig }); + * + * createGenerateStaticParams({ source: project }); // adapters, as-is + * project.collections; // plus the resolved graph + * project.getSource("changelog"); // and per-collection access + * ``` + * + * Remote collections are **cache-only** here. A request handler must never + * clone a repository, so a missing or stale cache is a diagnostic naming + * `leadtype sync`, not an implicit network call. + */ + +import { existsSync } from "node:fs"; +import path from "node:path"; +import type { PluggableList } from "unified"; +import { inheritCollectionSourceConfigs } from "../config/inherit"; +import { normalizeDocsConfig } from "../config/normalize"; +import type { ResolvedDocsCollection, ResolvedSource } from "../config/types"; +import type { DocsI18nConfig, LocaleCode } from "../i18n"; +import { + type DocsPathMount, + normalizeBaseUrl, + normalizeDocsPath, + toAbsoluteUrl, +} from "../internal/docs-url"; +import type { DocsConfig } from "../llm"; +import type { DocsNavigation } from "../llm/readability"; +import { + type CreateDocsSearchIndexOptions, + createDocsSearchIndex, + type DocsSearchBundle, + type DocsSearchDocument, +} from "../search/search"; +import { + type CreateDocsSourceConfig, + createDocsSource, + type DocsPage, + type DocsPageMeta, + type DocsSource, +} from "../source"; +import { readSyncManifest, resolveCollection } from "../sync/sync"; +import type { DocsFrontmatter, DocsTransformerOptions } from "../transformers"; + +/** A page, plus which collection it came from. */ +export type DocsProjectPageMeta< + TFrontmatter extends DocsFrontmatter = DocsFrontmatter, +> = DocsPageMeta & { + /** Collection id this page belongs to. */ + collection: string; +}; + +export type DocsProjectPage< + TFrontmatter extends DocsFrontmatter = DocsFrontmatter, +> = DocsPage & { collection: string }; + +/** + * A `DocsSource` over the whole project. Structurally a superset, so anything + * that accepts a source accepts a project. + */ +export type DocsProject< + TFrontmatter extends DocsFrontmatter = DocsFrontmatter, +> = Omit, "listPages" | "loadPage"> & { + /** The resolved collections, in config order. */ + collections: ResolvedDocsCollection[]; + /** The resolved acquisition graph. */ + sources: ResolvedSource[]; + listPages(): Promise[]>; + loadPage( + slug: string | string[] + ): Promise | null>; + /** The underlying source for one collection. Throws on an unknown id. */ + getSource(collectionKey: string): DocsSource; +}; + +export type CreateDocsProjectConfig< + TFrontmatter extends DocsFrontmatter = DocsFrontmatter, +> = { + /** The project's docs config, as authored. */ + config: DocsConfig; + /** + * Path to the config file this came from. Preferred over `configDir`: it + * fixes both where relative paths resolve *and* where the content root is, + * using the same rules the CLI uses — a `docs.config.*` sits inside the docs + * directory, a `leadtype.config.*` sits at the project root above it. + */ + configPath?: string; + /** + * Directory relative paths resolve against. Defaults to the directory of + * `configPath`, else `process.cwd()`. + */ + configDir?: string; + /** + * Content root for a single-source project, where the config declares no + * collections. Defaults to the directory holding a `docs.config.*`, or + * `/docs` otherwise. + */ + contentDir?: string; + baseUrl?: string; + locale?: LocaleCode; + i18n?: DocsI18nConfig; + remarkPlugins?: PluggableList; + typeTableBasePath?: string; + typeTableStrict?: boolean; + toc?: CreateDocsSourceConfig["toc"]; + searchIndex?: CreateDocsSearchIndexOptions; + transformers?: DocsTransformerOptions["transformers"]; +}; + +const DEFAULT_CONTENT_DIRNAME = "docs"; +const LEADING_SLASH = /^\//; + +function collectionMounts( + routePrefix: string, + extra: DocsPathMount[] | undefined +): DocsPathMount[] { + // Every page in a collection hangs off its route prefix, so the collection's + // own root is the base mount. Collection-declared mounts stay relative to it. + return [ + { pathPrefix: "", urlPrefix: routePrefix }, + ...(extra ?? []).map((mount) => ({ + pathPrefix: normalizeDocsPath(mount.pathPrefix), + urlPrefix: mount.urlPrefix, + })), + ]; +} + +/** + * Resolve a collection's content directory without touching the network. + * + * For a remote collection that means reading the sync cache and checking the + * manifest still matches the configured repository and ref — a cache left over + * from a different ref would otherwise render stale content that looks fine. + */ +async function resolveCollectionDir( + collection: ResolvedDocsCollection, + config: DocsConfig, + configDir: string, + fallbackContentDir: string +): Promise { + const authored = config.collections?.[collection.key]; + if (!authored) { + return fallbackContentDir; + } + const resolved = resolveCollection(collection.key, authored, configDir); + if (!resolved.remote) { + if (!existsSync(resolved.absoluteDir)) { + throw new Error( + `createDocsProject: collection "${collection.key}" points at "${resolved.absoluteDir}", which does not exist. Check its \`dir\` in your docs config.` + ); + } + return resolved.absoluteDir; + } + + const { repository, ref, cacheDir } = resolved.remote; + if (!existsSync(path.join(cacheDir, ".git"))) { + throw new Error( + `createDocsProject: collection "${collection.key}" reads ${repository}@${ref}, which has not been synced (no checkout at "${cacheDir}"). Run \`leadtype sync\` first — the runtime never clones.` + ); + } + const manifest = await readSyncManifest(cacheDir); + if (!manifest) { + throw new Error( + `createDocsProject: the cache for collection "${collection.key}" at "${cacheDir}" has no sync manifest, so its revision can't be verified. Run \`leadtype sync --refresh\`.` + ); + } + if (manifest.repository !== repository || manifest.ref !== ref) { + throw new Error( + `createDocsProject: the cache for collection "${collection.key}" holds ${manifest.repository}@${manifest.ref}, but the config asks for ${repository}@${ref}. Run \`leadtype sync --refresh\`.` + ); + } + if (!existsSync(resolved.absoluteDir)) { + throw new Error( + `createDocsProject: collection "${collection.key}" expects "${authored.dir}" inside ${repository}@${ref}, but "${resolved.absoluteDir}" does not exist. Check the collection's \`dir\`.` + ); + } + return resolved.absoluteDir; +} + +export async function createDocsProject< + TFrontmatter extends DocsFrontmatter = DocsFrontmatter, +>( + input: CreateDocsProjectConfig +): Promise> { + const configDir = path.resolve( + input.configDir ?? + (input.configPath ? path.dirname(input.configPath) : process.cwd()) + ); + + // Source-owned inheritance runs before normalization, exactly as generation + // does it — same function, so the rendered site and the generated artifacts + // cannot end up with different navigation for the same collection. + const withInheritance: DocsConfig = input.config.collections + ? { + ...input.config, + collections: await inheritCollectionSourceConfigs( + input.config.collections, + configDir + ), + } + : input.config; + + const { config, resolved } = normalizeDocsConfig(withInheritance, { + configDir, + }); + + // A `docs.config.*` lives *inside* the docs directory; a `leadtype.config.*` + // lives at the project root above it. Same rule the CLI's config lookup + // uses, so a project and a `generate` run agree on the content root. + const configIsSourceOwned = input.configPath + ? path.basename(input.configPath).startsWith("docs.config.") + : false; + const fallbackContentDir = path.resolve( + input.contentDir ?? + (configIsSourceOwned + ? configDir + : path.join(configDir, DEFAULT_CONTENT_DIRNAME)) + ); + + const shared = { + baseUrl: input.baseUrl, + locale: input.locale, + i18n: input.i18n ?? config.i18n, + remarkPlugins: input.remarkPlugins, + typeTableStrict: input.typeTableStrict ?? config.typeTableStrict, + toc: input.toc, + searchIndex: input.searchIndex, + // A config's `transformers` are declared against its own frontmatter type, + // which the caller re-states as `TFrontmatter`. The two agree by + // construction, but the config type has already erased its parameter. + transformers: (input.transformers ?? + config.transformers) as DocsTransformerOptions["transformers"], + } satisfies Partial>; + + const sourcesByCollection = new Map>(); + for (const collection of resolved.collections) { + const contentDir = await resolveCollectionDir( + collection, + config, + configDir, + fallbackContentDir + ); + const authored = config.collections?.[collection.key]; + sourcesByCollection.set( + collection.key, + await createDocsSource({ + contentDir, + ...shared, + ...(collection.navigation ? { nav: collection.navigation } : {}), + ...(collection.groups ? { groups: collection.groups } : {}), + ...(collection.frontmatterSchema + ? { + frontmatterSchema: + collection.frontmatterSchema as CreateDocsSourceConfig["frontmatterSchema"], + } + : {}), + // A single-source project mounts at its route prefix like any other; + // a multi-collection project gets one mount set per collection, which + // is what makes each collection's URLs correct on its own. + mounts: collectionMounts(collection.routePrefix, collection.mounts), + ...(input.typeTableBasePath + ? { typeTableBasePath: input.typeTableBasePath } + : {}), + ...(config.openapi && resolved.mode === "single-source" + ? { openapi: config.openapi, openapiCwd: configDir } + : {}), + ...(authored?.flatteners ? {} : {}), + }) + ); + } + + function getSource(collectionKey: string): DocsSource { + const source = sourcesByCollection.get(collectionKey); + if (!source) { + throw new Error( + `createDocsProject: unknown collection "${collectionKey}". Declared collections: ${[...sourcesByCollection.keys()].join(", ") || "(none)"}.` + ); + } + return source; + } + + let cachedPages: DocsProjectPageMeta[] | null = null; + + async function listPages(): Promise[]> { + if (cachedPages) { + return cachedPages; + } + const pages: DocsProjectPageMeta[] = []; + const byUrlPath = new Map(); + for (const [collection, source] of sourcesByCollection) { + for (const page of await source.listPages()) { + const owner = byUrlPath.get(page.urlPath); + if (owner) { + throw new Error( + `createDocsProject: collections "${owner}" and "${collection}" both resolve "${page.urlPath}". Give them distinct route prefixes.` + ); + } + byUrlPath.set(page.urlPath, collection); + pages.push({ ...page, collection }); + } + } + cachedPages = pages; + return pages; + } + + /** + * Resolve a slug against the merged route space. + * + * Two slug shapes reach here and both must work. A route mounted per + * collection hands over a collection-local slug (`1-0`), which is what + * `createDocsSource` produces and what existing adapters pass. A route + * mounted across collections hands over the full route (`changelog/1-0`). + * Matching the local slug first keeps single-source behaviour identical to + * `createDocsSource`; falling back to the route path is what lets a + * multi-collection app serve everything from one handler. + */ + async function loadPage( + slugInput: string | string[] + ): Promise | null> { + const segments = ( + Array.isArray(slugInput) ? slugInput : slugInput.split("/") + ).filter(Boolean); + const wanted = segments.join("/"); + const pages = await listPages(); + const target = + pages.find((page) => page.slug.join("/") === wanted) ?? + pages.find((page) => page.urlPath.replace(LEADING_SLASH, "") === wanted); + if (!target) { + return null; + } + const page = await getSource(target.collection).loadPage(target.slug); + return page ? { ...page, collection: target.collection } : null; + } + + async function getNavigation(): Promise { + // Each collection resolves its own navigation with its own mounts, so the + // merge is a concatenation in config order rather than a re-derivation. + const manifests = await Promise.all( + [...sourcesByCollection.values()].map((source) => source.getNavigation()) + ); + const first = manifests[0]; + return { + groups: manifests.flatMap((manifest) => manifest.groups), + ungrouped: manifests.flatMap((manifest) => manifest.ungrouped), + unknown: manifests.flatMap((manifest) => manifest.unknown), + ...(first?.locale ? { locale: first.locale } : {}), + }; + } + + async function buildSearchIndex(): Promise { + const sources = [...sourcesByCollection.values()]; + const single = sources[0]; + if (!single) { + throw new Error("createDocsProject: no collections to index."); + } + if (sources.length === 1) { + return await single.buildSearchIndex(); + } + + // A search index is inverted: its term postings point at document and + // chunk positions. Concatenating two finished indexes would leave every + // posting in the second one pointing at the wrong document, so the merged + // index is built once over every collection's documents. + const baseUrl = normalizeBaseUrl(input.baseUrl); + const metas = await listPages(); + const documents: DocsSearchDocument[] = []; + for (const meta of metas) { + if (meta.isFallback) { + continue; + } + const loaded = await getSource(meta.collection).loadPage(meta.slug); + if (!loaded) { + continue; + } + documents.push({ + id: meta.urlPath, + title: meta.title, + description: meta.description, + urlPath: meta.urlPath, + absoluteUrl: toAbsoluteUrl(meta.urlPath, baseUrl), + relativePath: meta.relativePath, + ...(meta.locale ? { locale: meta.locale } : {}), + ...(meta.sourceLocale ? { sourceLocale: meta.sourceLocale } : {}), + ...(meta.logicalPath ? { logicalPath: meta.logicalPath } : {}), + frontmatter: loaded.frontmatter, + content: loaded.markdown, + }); + } + + const index = createDocsSearchIndex(documents, { + ...input.searchIndex, + transformers: + shared.transformers as DocsTransformerOptions["transformers"], + }); + return { + index, + content: index.content ?? { + version: index.version, + generatedAt: index.generatedAt, + chunks: [], + }, + }; + } + + const primary = getSource(resolved.collections[0]?.key ?? ""); + + return { + contentDir: primary.contentDir, + collections: resolved.collections, + sources: resolved.sources, + getSource, + getNavigation, + listPages, + loadPage, + buildSearchIndex, + resolveInclude: primary.resolveInclude, + async cleanup(): Promise { + await Promise.all( + [...sourcesByCollection.values()].map((source) => source.cleanup()) + ); + }, + }; +} diff --git a/packages/leadtype/src/project/project.test.ts b/packages/leadtype/src/project/project.test.ts new file mode 100644 index 00000000..8e7b2510 --- /dev/null +++ b/packages/leadtype/src/project/project.test.ts @@ -0,0 +1,324 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { DocsConfig } from "../llm"; +import { writeSyncManifest } from "../sync/sync"; +import { createDocsProject } from "./index"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })) + ); +}); + +async function fixture(files: Record): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "leadtype-project-")); + tempDirs.push(dir); + for (const [relativePath, contents] of Object.entries(files)) { + const filePath = path.join(dir, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, contents, "utf8"); + } + return dir; +} + +function page(title: string): string { + return `---\ntitle: "${title}"\ndescription: "About ${title}."\n---\n\n${title} body.\n`; +} + +const product = { name: "Acme", tagline: "Acme does one useful thing." }; + +describe("single-source project", () => { + const config: DocsConfig = { + product, + navigation: ["index", { title: "Guides", base: "guides", pages: ["auth"] }], + }; + + it("needs no content root, navigation, or mounts beyond the config", async () => { + const dir = await fixture({ + "docs/index.mdx": page("Home"), + "docs/guides/auth.mdx": page("Auth"), + }); + + const project = await createDocsProject({ config, configDir: dir }); + const pages = await project.listPages(); + + expect(pages.map((entry) => entry.urlPath).sort()).toEqual([ + "/docs", + "/docs/guides/auth", + ]); + expect(pages.every((entry) => entry.collection === "docs")).toBe(true); + }); + + it("satisfies the DocsSource contract adapters depend on", async () => { + const dir = await fixture({ "docs/index.mdx": page("Home") }); + const project = await createDocsProject({ config, configDir: dir }); + + // Adapters take `{ source }` structurally; a project must fit unchanged. + for (const method of [ + "getNavigation", + "listPages", + "loadPage", + "buildSearchIndex", + "resolveInclude", + "cleanup", + ] as const) { + expect(typeof project[method]).toBe("function"); + } + expect(typeof project.contentDir).toBe("string"); + }); + + it("loads a page by slug with its collection", async () => { + const dir = await fixture({ + "docs/index.mdx": page("Home"), + "docs/guides/auth.mdx": page("Auth"), + }); + const project = await createDocsProject({ config, configDir: dir }); + + const loaded = await project.loadPage("guides/auth"); + expect(loaded?.title).toBe("Auth"); + expect(loaded?.collection).toBe("docs"); + expect(loaded?.markdown).toContain("Auth body."); + expect(await project.loadPage("nope")).toBeNull(); + }); + + it("uses the config's navigation for the resolved tree", async () => { + const dir = await fixture({ + "docs/index.mdx": page("Home"), + "docs/guides/auth.mdx": page("Auth"), + }); + const project = await createDocsProject({ config, configDir: dir }); + + const navigation = await project.getNavigation(); + expect(navigation.groups.map((group) => group.title)).toEqual(["Guides"]); + }); +}); + +describe("multi-collection project", () => { + const config: DocsConfig = { + product, + collections: { + docs: { dir: "content/docs", routePrefix: "/docs" }, + changelog: { dir: "content/changelog", routePrefix: "/changelog" }, + }, + }; + + async function multiFixture(): Promise { + return await fixture({ + "content/docs/index.mdx": page("Docs"), + "content/docs/auth.mdx": page("Auth"), + "content/changelog/1-0.mdx": page("1.0"), + }); + } + + it("merges collections into one route-aware page list with provenance", async () => { + const project = await createDocsProject({ + config, + configDir: await multiFixture(), + }); + + const pages = await project.listPages(); + expect( + pages.map((entry) => [entry.urlPath, entry.collection]).sort() + ).toEqual([ + ["/changelog/1-0", "changelog"], + ["/docs", "docs"], + ["/docs/auth", "docs"], + ]); + }); + + it("loads a page from any collection without a per-collection map", async () => { + const project = await createDocsProject({ + config, + configDir: await multiFixture(), + }); + + const release = await project.loadPage("changelog/1-0"); + expect(release?.collection).toBe("changelog"); + expect(release?.markdown).toContain("1.0 body."); + + const doc = await project.loadPage("auth"); + expect(doc?.collection).toBe("docs"); + }); + + it("exposes the resolved collections and acquisition graph", async () => { + const project = await createDocsProject({ + config, + configDir: await multiFixture(), + }); + + expect(project.collections.map((entry) => entry.key)).toEqual([ + "docs", + "changelog", + ]); + expect(project.sources).toEqual([ + { id: "local", kind: "local", collectionKeys: ["docs", "changelog"] }, + ]); + }); + + it("hands back one collection's source for a custom integration", async () => { + const project = await createDocsProject({ + config, + configDir: await multiFixture(), + }); + + const changelog = project.getSource("changelog"); + expect((await changelog.listPages()).map((entry) => entry.urlPath)).toEqual( + ["/changelog/1-0"] + ); + expect(() => project.getSource("nope")).toThrow( + /unknown collection "nope"/ + ); + }); + + it("indexes every collection for search", async () => { + const project = await createDocsProject({ + config, + configDir: await multiFixture(), + }); + + const bundle = await project.buildSearchIndex(); + + // Document entries are positional tuples; index 3 is `urlPath`. + const URL_PATH = 3; + expect( + bundle.index.documents.map((entry) => entry[URL_PATH]).sort() + ).toEqual(["/changelog/1-0", "/docs", "/docs/auth"]); + + // One inverted index over every collection, not two concatenated ones — + // postings from a merged pair would point at the wrong documents. + const postings = Object.values(bundle.index.terms).flat(); + expect(postings.length).toBeGreaterThan(0); + expect(bundle.index.chunks.length).toBeGreaterThanOrEqual(3); + }); + + it("rejects colliding route prefixes before any content is read", async () => { + const dir = await fixture({ + "content/a/index.mdx": page("A"), + "content/b/index.mdx": page("B"), + }); + + await expect( + createDocsProject({ + config: { + product, + collections: { + a: { dir: "content/a", routePrefix: "/docs" }, + b: { dir: "content/b", routePrefix: "/docs/" }, + }, + }, + configDir: dir, + }) + ).rejects.toThrow(/collections "a" and "b" share routePrefix "\/docs"/); + }); + + it("reports a mount-induced route collision naming both collections", async () => { + // Distinct prefixes, so normalization passes — but a mount lands one + // collection's page on the other's route, which only shows up once pages + // are enumerated. + const dir = await fixture({ + "content/a/index.mdx": page("A"), + "content/b/shared/index.mdx": page("B"), + }); + const project = await createDocsProject({ + config: { + product, + collections: { + a: { dir: "content/a", routePrefix: "/docs" }, + b: { + dir: "content/b", + routePrefix: "/other", + mounts: [{ pathPrefix: "shared", urlPrefix: "/docs" }], + }, + }, + }, + configDir: dir, + }); + + await expect(project.listPages()).rejects.toThrow( + /collections "a" and "b" both resolve "\/docs"/ + ); + }); + + it("fails with the offending path when a collection dir is missing", async () => { + const dir = await fixture({ "content/docs/index.mdx": page("Docs") }); + await expect( + createDocsProject({ + config: { + product, + collections: { + docs: { dir: "content/missing", routePrefix: "/docs" }, + }, + }, + configDir: dir, + }) + ).rejects.toThrow(/does not exist. Check its `dir`/); + }); +}); + +describe("remote collections are cache-only", () => { + const remoteConfig: DocsConfig = { + product, + collections: { + docs: { + repository: "https://github.com/acme/acme.git", + ref: "main", + cacheDir: ".leadtype/acme", + dir: "docs", + routePrefix: "/docs", + }, + }, + }; + + it("tells you to sync instead of cloning during a request", async () => { + const dir = await fixture({ "placeholder.txt": "" }); + await expect( + createDocsProject({ config: remoteConfig, configDir: dir }) + ).rejects.toThrow(/has not been synced.*leadtype sync.*never clones/s); + }); + + it("rejects a cache holding a different revision than the config asks for", async () => { + const dir = await fixture({ + ".leadtype/acme/.git/HEAD": "ref: refs/heads/other\n", + ".leadtype/acme/docs/index.mdx": page("Stale"), + }); + await writeSyncManifest(path.join(dir, ".leadtype/acme"), { + version: 1, + repository: "https://github.com/acme/acme.git", + ref: "v0.9", + commit: "abc1234", + syncedAt: "2026-01-01T00:00:00.000Z", + }); + + await expect( + createDocsProject({ config: remoteConfig, configDir: dir }) + ).rejects.toThrow( + /holds https:\/\/github.com\/acme\/acme.git@v0\.9.*asks for.*@main/s + ); + }); + + it("reads a matching cache without touching the network", async () => { + const dir = await fixture({ + ".leadtype/acme/.git/HEAD": "ref: refs/heads/main\n", + ".leadtype/acme/docs/index.mdx": page("Cached"), + }); + await writeSyncManifest(path.join(dir, ".leadtype/acme"), { + version: 1, + repository: "https://github.com/acme/acme.git", + ref: "main", + commit: "abc1234", + syncedAt: "2026-01-01T00:00:00.000Z", + }); + + const project = await createDocsProject({ + config: remoteConfig, + configDir: dir, + }); + expect((await project.listPages()).map((entry) => entry.urlPath)).toEqual([ + "/docs", + ]); + }); +});