diff --git a/.changeset/type-table-source-root.md b/.changeset/type-table-source-root.md new file mode 100644 index 0000000..ee692d4 --- /dev/null +++ b/.changeset/type-table-source-root.md @@ -0,0 +1,9 @@ +--- +"leadtype": patch +--- + +Default `` and `` path resolution to the Leadtype source root instead of `process.cwd()/docs`. + +This fixes generated docs for source roots such as `.c15t` or `.leadtype`, where `path="./packages/..."` should resolve against the configured source root. Source-MDX consumers can now pass `typeTableBasePath` / `typeTableStrict` through `createDocsSource()` or use `createMdxSourcePlugins()` for bundler-level configuration. Failed type extraction now emits a visible warning by default and can fail generation in strict mode. + +This changes the bare `mdxSourcePlugins` default for bundler consumers: when Leadtype can see the source MDX file path, it derives the base path from the first `docs` path segment instead of always using `process.cwd()/docs`. Projects that intentionally keep referenced TypeScript files under their docs folder should switch to `createMdxSourcePlugins({ typeTableBasePath: path.resolve(process.cwd(), "docs") })`. diff --git a/apps/example/vite.config.ts b/apps/example/vite.config.ts index 1998807..f88490b 100644 --- a/apps/example/vite.config.ts +++ b/apps/example/vite.config.ts @@ -1,8 +1,10 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import mdx from "@mdx-js/rollup"; import tailwindcss from "@tailwindcss/vite"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import viteReact from "@vitejs/plugin-react"; -import { mdxSourcePlugins } from "leadtype/mdx"; +import { createMdxSourcePlugins } from "leadtype/mdx"; import type { Root } from "mdast"; import { nitro } from "nitro/vite"; import remarkFrontmatter from "remark-frontmatter"; @@ -10,6 +12,9 @@ import remarkGfm from "remark-gfm"; import { defineConfig, searchForWorkspaceRoot } from "vite"; import viteTsConfigPaths from "vite-tsconfig-paths"; +const configDir = dirname(fileURLToPath(import.meta.url)); +const typeTableBasePath = resolve(configDir, "..", ".."); + function stripYamlFrontmatter() { return (tree: Root) => { if (!tree.children) { @@ -37,14 +42,14 @@ export default defineConfig({ ...mdx({ providerImportSource: "@mdx-js/react", remarkPlugins: [ - // Frontmatter parsing first (mdxSourcePlugins expects bodies only). + // Frontmatter parsing first (Leadtype's source preset expects bodies only). remarkFrontmatter, stripYamlFrontmatter, remarkGfm, // Leadtype's MDX-source preset: expand , resolve // , strip authoring `import`s. Keeps every // other custom tag as live JSX for the React components below. - ...mdxSourcePlugins, + ...createMdxSourcePlugins({ typeTableBasePath }), ], }), enforce: "pre", diff --git a/apps/fumadocs-example/lib/source.ts b/apps/fumadocs-example/lib/source.ts index 2231210..aa98baf 100644 --- a/apps/fumadocs-example/lib/source.ts +++ b/apps/fumadocs-example/lib/source.ts @@ -16,7 +16,7 @@ const contentDir = resolve( * fumadocs source backed by leadtype/fumadocs. Walks `.docs-src/c15t/docs`, * picks up both `.mdx` pages and the c15t-authored `meta.json` files, and * resolves `` / `` at build time via - * `mdxSourcePlugins` (wired in `next.config.mjs`). + * `createMdxSourcePlugins()` (wired in `next.config.mjs`). */ const fumadocsSourceResult = await fumadocsSource({ contentDir }); diff --git a/apps/fumadocs-example/next.config.mjs b/apps/fumadocs-example/next.config.mjs index ef355e3..f437a8a 100644 --- a/apps/fumadocs-example/next.config.mjs +++ b/apps/fumadocs-example/next.config.mjs @@ -1,13 +1,26 @@ +import { resolve } from "node:path"; import createMDX from "@next/mdx"; import { rehypeCode } from "fumadocs-core/mdx-plugins"; -import { mdxSourcePlugins } from "leadtype/mdx"; +import { createMdxSourcePlugins } from "leadtype/mdx"; import remarkFrontmatter from "remark-frontmatter"; import remarkGfm from "remark-gfm"; +const typeTableBasePath = resolve( + process.cwd(), + "..", + "..", + ".docs-src", + "c15t" +); + const withMDX = createMDX({ options: { // Frontmatter parsing must precede leadtype's stack (it expects bodies). - remarkPlugins: [remarkFrontmatter, remarkGfm, ...mdxSourcePlugins], + remarkPlugins: [ + remarkFrontmatter, + remarkGfm, + ...createMdxSourcePlugins({ typeTableBasePath }), + ], // Shiki-based highlighter from fumadocs-core. Pairs with fumadocs-ui's // codeblock CSS so tokens, copy button, and frame styling all kick in. rehypePlugins: [rehypeCode], diff --git a/docs/build/build-a-docs-site.mdx b/docs/build/build-a-docs-site.mdx index 14d57b7..273c05b 100644 --- a/docs/build/build-a-docs-site.mdx +++ b/docs/build/build-a-docs-site.mdx @@ -22,7 +22,7 @@ Leadtype offers two integration shapes for a docs site. Pick the one that fits h | Path | When to choose | What you wire | | --- | --- | --- | -| **Source primitive** | Most cases. You're building a Next, Vite, Astro, or Fumadocs app and compile MDX with your bundler. | `createDocsSource()` (or `leadtype/fumadocs`) + `mdxSourcePlugins` | +| **Source primitive** | Most cases. You're building a Next, Vite, Astro, or Fumadocs 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 | The two paths are not mutually exclusive — you can use the source primitive for your UI and still run `leadtype generate` for the `llms.txt` and agent-readability artifacts. diff --git a/docs/build/integrate-with-fumadocs.mdx b/docs/build/integrate-with-fumadocs.mdx index cda892c..e904342 100644 --- a/docs/build/integrate-with-fumadocs.mdx +++ b/docs/build/integrate-with-fumadocs.mdx @@ -28,10 +28,15 @@ export const leadtypeSource = fumaSource.leadtype; ```ts title="next.config.mjs" import createMDX from "@next/mdx"; -import { mdxSourcePlugins } from "leadtype/mdx"; +import { createMdxSourcePlugins } from "leadtype/mdx"; +import path from "node:path"; + +const typeTableBasePath = path.resolve(process.cwd(), ".c15t"); export default createMDX({ - options: { remarkPlugins: [...mdxSourcePlugins] }, + options: { + remarkPlugins: [...createMdxSourcePlugins({ typeTableBasePath })], + }, })({ pageExtensions: ["ts", "tsx", "mdx"] }); ``` @@ -71,18 +76,21 @@ Leadtype's MDX-source preset expands ``, resolves ` ```ts title="next.config.mjs" import createMDX from "@next/mdx"; -import { mdxSourcePlugins } from "leadtype/mdx"; +import { createMdxSourcePlugins } from "leadtype/mdx"; +import path from "node:path"; + +const typeTableBasePath = path.resolve(process.cwd(), ".c15t"); const withMDX = createMDX({ options: { - remarkPlugins: [...mdxSourcePlugins], + remarkPlugins: [...createMdxSourcePlugins({ typeTableBasePath })], }, }); export default withMDX({ pageExtensions: ["ts", "tsx", "mdx"] }); ``` -The same plugin list works with `@mdx-js/rollup`, fumadocs-mdx, and any other MDX compiler that accepts a remark plugin list. +The same plugin list works with `@mdx-js/rollup`, fumadocs-mdx, and any other MDX compiler that accepts a remark plugin list. Set `typeTableBasePath` to the source root that contains files referenced by ``. ## Implement the tag components @@ -131,7 +139,7 @@ export default async function Page({ } ``` -If you prefer fumadocs's built-in page resolution, call `source.getPage(slug)` and import the source `.mdx` directly through fumadocs-mdx as you normally would — the `mdxSourcePlugins` preset will resolve includes during MDX compilation. +If you prefer fumadocs's built-in page resolution, call `source.getPage(slug)` and import the source `.mdx` directly through fumadocs-mdx as you normally would — the `createMdxSourcePlugins()` preset will resolve includes during MDX compilation. ## Add search diff --git a/docs/build/use-the-source-primitive.mdx b/docs/build/use-the-source-primitive.mdx index b3b8e76..01b6d59 100644 --- a/docs/build/use-the-source-primitive.mdx +++ b/docs/build/use-the-source-primitive.mdx @@ -24,7 +24,7 @@ export const source = await createDocsSource({ }); ``` -Wire `mdxSourcePlugins` 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. +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. ## Install @@ -36,16 +36,21 @@ Plus an MDX integration for your bundler (`@next/mdx`, `@astrojs/mdx`, `@mdx-js/ ## Wire into your framework -`mdxSourcePlugins` expands `` partials and resolves `` at build time, while leaving every custom tag (``, ``, ``, …) as JSX for your runtime components. +`createMdxSourcePlugins()` expands `` partials and resolves `` at build time, while leaving every custom tag (``, ``, ``, …) as JSX for your runtime components. Set `typeTableBasePath` to the source root that contains referenced TypeScript files; use `path.resolve(process.cwd(), "docs")` only when those files intentionally live under your docs folder. ### Next App Router ```ts title="next.config.mjs" import createMDX from "@next/mdx"; -import { mdxSourcePlugins } from "leadtype/mdx"; +import { createMdxSourcePlugins } from "leadtype/mdx"; +import path from "node:path"; + +const typeTableBasePath = path.resolve(process.cwd(), ".c15t"); export default createMDX({ - options: { remarkPlugins: [...mdxSourcePlugins] }, + options: { + remarkPlugins: [...createMdxSourcePlugins({ typeTableBasePath })], + }, })({ pageExtensions: ["ts", "tsx", "mdx"] }); ``` @@ -82,10 +87,10 @@ export async function generateStaticParams() { ```ts title="astro.config.mjs" import { defineConfig } from "astro/config"; import mdx from "@astrojs/mdx"; -import { mdxSourcePlugins } from "leadtype/mdx"; +import { createMdxSourcePlugins } from "leadtype/mdx"; export default defineConfig({ - integrations: [mdx({ remarkPlugins: [...mdxSourcePlugins] })], + integrations: [mdx({ remarkPlugins: [...createMdxSourcePlugins()] })], }); ``` @@ -97,7 +102,7 @@ Use Astro's native content collection schema for typed frontmatter. Call `source import mdx from "@mdx-js/rollup"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import viteReact from "@vitejs/plugin-react"; -import { mdxSourcePlugins } from "leadtype/mdx"; +import { createMdxSourcePlugins } from "leadtype/mdx"; import remarkFrontmatter from "remark-frontmatter"; import { defineConfig } from "vite"; @@ -106,7 +111,7 @@ export default defineConfig({ { ...mdx({ providerImportSource: "@mdx-js/react", - remarkPlugins: [remarkFrontmatter, ...mdxSourcePlugins], + remarkPlugins: [remarkFrontmatter, ...createMdxSourcePlugins()], }), enforce: "pre", }, @@ -189,11 +194,11 @@ Generate `docs-pages.json` at build time by calling `createDocsSource().listPage ```ts title="vite.config.ts" import mdx from "@mdx-js/rollup"; -import { mdxSourcePlugins } from "leadtype/mdx"; +import { createMdxSourcePlugins } from "leadtype/mdx"; export default { plugins: [ - mdx({ remarkPlugins: [...mdxSourcePlugins] }), + mdx({ remarkPlugins: [...createMdxSourcePlugins()] }), // ...your framework plugin: viteReact / vue / solid / svelte ], }; @@ -202,11 +207,11 @@ export default { ### Nuxt ```ts title="nuxt.config.ts" -import { mdxSourcePlugins } from "leadtype/mdx"; +import { createMdxSourcePlugins } from "leadtype/mdx"; export default defineNuxtConfig({ modules: ["@nuxtjs/mdc"], - mdc: { remarkPlugins: [...mdxSourcePlugins] }, + mdc: { remarkPlugins: [...createMdxSourcePlugins()] }, }); ``` @@ -214,11 +219,11 @@ export default defineNuxtConfig({ ```ts title="svelte.config.js" import { mdsvex } from "mdsvex"; -import { mdxSourcePlugins } from "leadtype/mdx"; +import { createMdxSourcePlugins } from "leadtype/mdx"; export default { extensions: [".svelte", ".svx", ".mdx"], - preprocess: mdsvex({ remarkPlugins: [...mdxSourcePlugins] }), + preprocess: mdsvex({ remarkPlugins: [...createMdxSourcePlugins()] }), }; ``` @@ -226,7 +231,7 @@ export default { If your framework's MDX integration accepts a remark plugin list, leadtype works. Three pieces every time: -1. Add `mdxSourcePlugins` to the remark list so `` and `` resolve at build time. +1. Add `createMdxSourcePlugins()` to the remark list so `` and `` resolve at build time. 2. Implement components against the [tag types from `leadtype/mdx`](/docs/reference/mdx). 3. Call `createDocsSource()` if you want navigation, search, or programmatic page loading. @@ -320,13 +325,13 @@ For provider-specific search (Vercel AI, TanStack, Cloudflare), feed the bundle ## Troubleshooting -- **`` tags survive into the rendered output.** You forgot to add `mdxSourcePlugins` to your MDX compiler's remark plugin list. -- **`` renders unresolved.** The source preset converts these to `` only when `extractTypeFromFile` succeeds. Make sure `typescript` is installed in the docs app and `basePath` resolves to the project that contains the type. +- **`` tags survive into the rendered output.** You forgot to add `createMdxSourcePlugins()` to your MDX compiler's remark plugin list. +- **`` renders unresolved.** The source preset converts these to `` only when `extractTypeFromFile` succeeds. Make sure `typescript` is installed in the docs app and `typeTableBasePath` resolves to the project that contains the type. - **TOC links don't scroll.** Rendered heading IDs don't match. Wire `slugifyDocsHeading` into your heading components. - **Sidebar order doesn't match `llms.txt`.** Your app and the CLI are loading different `docs.config.ts` files. Centralize the config and import it in both. ## Reference -- [`leadtype/mdx`](/docs/reference/mdx) — tag types, `mdxSourcePlugins`, include helpers +- [`leadtype/mdx`](/docs/reference/mdx) — tag types, `createMdxSourcePlugins()`, include helpers - [`createDocsSource`](/docs/reference/source) — full API surface for the primitive - [`leadtype/fumadocs`](/docs/build/integrate-with-fumadocs) — fumadocs adapter recipe diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index bae1e39..32f1e36 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -53,7 +53,7 @@ Pick the recipe that matches your stack. Each one shows the wiring on top of the diff --git a/docs/reference/mdx.mdx b/docs/reference/mdx.mdx index 75d00b8..ad9bd44 100644 --- a/docs/reference/mdx.mdx +++ b/docs/reference/mdx.mdx @@ -11,7 +11,7 @@ The `leadtype/mdx` subpath is the **consumer-facing MDX surface** — everything It exports three things: 1. **Tag type contracts** — typed prop shapes for every custom MDX tag. -2. **`mdxSourcePlugins`** — a remark preset that performs build-time resolution only (expand includes, resolve ``, strip authoring `import`s) and leaves every other custom tag as JSX. +2. **`createMdxSourcePlugins()` / `mdxSourcePlugins`** — a remark preset that performs build-time resolution only (expand includes, resolve ``, strip authoring `import`s) and leaves every other custom tag as JSX. 3. **Include-resolution helpers** — `resolveInclude`, `parseIncludeSpecifier`, `extractMdxSection` for direct use. Pair it with `leadtype/remark` (markdown flattening for the agent/LLM pipeline) — they are sibling surfaces, not alternatives. Most projects use both. @@ -20,15 +20,22 @@ Pair it with `leadtype/remark` (markdown flattening for the agent/LLM pipeline) ```ts import createMDX from "@next/mdx"; -import { mdxSourcePlugins } from "leadtype/mdx"; +import { createMdxSourcePlugins } from "leadtype/mdx"; +import path from "node:path"; + +const typeTableBasePath = path.resolve(process.cwd(), ".c15t"); const withMDX = createMDX({ options: { - remarkPlugins: [...mdxSourcePlugins], + remarkPlugins: [ + ...createMdxSourcePlugins({ typeTableBasePath }), + ], }, }); ``` +Use the `mdxSourcePlugins` constant only when the default path inference is enough for your project. When `` should resolve from a known source root (`.c15t`, `.leadtype`, the repo root, or a docs folder), prefer `createMdxSourcePlugins({ typeTableBasePath })`. If you intentionally keep type files inside `docs/`, pass `path.resolve(process.cwd(), "docs")`. + The preset is intentionally minimal — only the transforms that **must** run at build time: | Plugin | Purpose | @@ -218,7 +225,7 @@ export function TypeTable({ properties, title, description }: TypeTableProps) { } ``` -`` requires `typescript` to be installed as an optional peer dep in your docs project. +`` requires `typescript` to be installed as an optional peer dep in your docs project. Failed extraction emits a visible warning by default; pass `typeTableStrict: true` to `createMdxSourcePlugins()` when a missing type should fail the build. ## Include resolution diff --git a/docs/reference/remark.mdx b/docs/reference/remark.mdx index fb9a84e..b1074a1 100644 --- a/docs/reference/remark.mdx +++ b/docs/reference/remark.mdx @@ -131,7 +131,7 @@ Nested includes are supported. The plugin keeps the included file's directory as ### remarkTypeTableToMarkdown (with basePath) -`` reads a TypeScript file at conversion time. When the file path is relative, the plugin needs a `basePath` to resolve from. Override the default plugin entry to pass options: +`` reads a TypeScript file at conversion time. When the file path is relative, Leadtype defaults the base path to the source root inferred from the current MDX file. Override the plugin entry when your conversion script should resolve from a specific root or fail on missing types: ```ts import { @@ -141,7 +141,10 @@ import { const remarkPlugins = [ ...defaultRemarkPlugins.filter((p) => p !== remarkTypeTableToMarkdown), - [remarkTypeTableToMarkdown, { basePath: process.cwd() }], + [ + remarkTypeTableToMarkdown, + { basePath: process.cwd(), strict: true }, + ], ]; ``` diff --git a/docs/reference/source.mdx b/docs/reference/source.mdx index 63dbcc9..b93281f 100644 --- a/docs/reference/source.mdx +++ b/docs/reference/source.mdx @@ -35,7 +35,9 @@ For fumadocs specifically, use the thin [`leadtype/fumadocs`](/docs/build/integr | `baseUrl` | `string` | Used for absolute URLs in TOC and search index. | | `groups` | `DocsGroup[]` | Doc groups for navigation. Empty groups = all pages ungrouped. | | `mounts` | `DocsPathMount[]` | Multi-mount routing (advanced). | -| `remarkPlugins` | `PluggableList` | Defaults to `mdxSourcePlugins`. Pass `[]` to skip transforms. | +| `remarkPlugins` | `PluggableList` | Defaults to Leadtype's source preset. Pass `[]` to skip transforms. | +| `typeTableBasePath` | `string` | Base directory for `` / ``. Defaults to the parent of `contentDir`. | +| `typeTableStrict` | `boolean` | Throw when a referenced type cannot be extracted instead of emitting a visible warning. | | `toc` | `DocsTableOfContentsOptions \| false` | TOC tuning; `false` skips TOC entirely. | | `searchIndex` | `CreateDocsSearchIndexOptions` | Search-index tuning. | @@ -97,7 +99,7 @@ Convenience wrapper around `resolveInclude` from `leadtype/mdx` with `fromDir` d ## Choosing between `loadPage` and direct `.mdx` imports -For most bundler-driven consumers (Next App Router, Vite, Astro), you'll **import source `.mdx` directly** and let the bundler's MDX pipeline compile it with `mdxSourcePlugins`. `loadPage()` is for cases where you need the resolved markdown body or AST programmatically: +For most bundler-driven consumers (Next App Router, Vite, Astro), you'll **import source `.mdx` directly** and let the bundler's MDX pipeline compile it with `createMdxSourcePlugins()`. `loadPage()` is for cases where you need the resolved markdown body or AST programmatically: - Server-rendering through `next-mdx-remote` instead of bundler-native MDX - Streaming partials to an agent / LLM at runtime @@ -111,10 +113,15 @@ For most bundler-driven consumers (Next App Router, Vite, Astro), you'll **impor ```ts title="next.config.mjs" import createMDX from "@next/mdx"; -import { mdxSourcePlugins } from "leadtype/mdx"; +import { createMdxSourcePlugins } from "leadtype/mdx"; +import path from "node:path"; + +const typeTableBasePath = path.resolve(process.cwd(), ".c15t"); export default createMDX({ - options: { remarkPlugins: [...mdxSourcePlugins] }, + options: { + remarkPlugins: [...createMdxSourcePlugins({ typeTableBasePath })], + }, })({ pageExtensions: ["ts", "tsx", "mdx"] }); ``` @@ -134,10 +141,10 @@ export default async function Page({ params }) { ```ts title="astro.config.mjs" import { defineConfig } from "astro/config"; import mdx from "@astrojs/mdx"; -import { mdxSourcePlugins } from "leadtype/mdx"; +import { createMdxSourcePlugins } from "leadtype/mdx"; export default defineConfig({ - integrations: [mdx({ remarkPlugins: [...mdxSourcePlugins] })], + integrations: [mdx({ remarkPlugins: [...createMdxSourcePlugins()] })], }); ``` @@ -149,16 +156,22 @@ Use Astro's built-in content collection schema for typed frontmatter; use `creat import mdx from "@mdx-js/rollup"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import viteReact from "@vitejs/plugin-react"; -import { mdxSourcePlugins } from "leadtype/mdx"; +import { createMdxSourcePlugins } from "leadtype/mdx"; +import path from "node:path"; import remarkFrontmatter from "remark-frontmatter"; import { defineConfig } from "vite"; +const typeTableBasePath = path.resolve(process.cwd(), ".c15t"); + export default defineConfig({ plugins: [ { ...mdx({ providerImportSource: "@mdx-js/react", - remarkPlugins: [remarkFrontmatter, ...mdxSourcePlugins], + remarkPlugins: [ + remarkFrontmatter, + ...createMdxSourcePlugins({ typeTableBasePath }), + ], }), enforce: "pre", }, @@ -174,11 +187,11 @@ Pair with a TanStack Router catch-all (`src/routes/docs/$.tsx`) that consumes a ```ts title="vite.config.ts" import mdx from "@mdx-js/rollup"; -import { mdxSourcePlugins } from "leadtype/mdx"; +import { createMdxSourcePlugins } from "leadtype/mdx"; export default { plugins: [ - mdx({ remarkPlugins: [...mdxSourcePlugins] }), + mdx({ remarkPlugins: [...createMdxSourcePlugins()] }), // ...your framework plugin: viteReact / vue / solid / svelte ], }; @@ -187,12 +200,12 @@ export default { ### Nuxt ```ts title="nuxt.config.ts" -import { mdxSourcePlugins } from "leadtype/mdx"; +import { createMdxSourcePlugins } from "leadtype/mdx"; export default defineNuxtConfig({ modules: ["@nuxtjs/mdc"], mdc: { - remarkPlugins: [...mdxSourcePlugins], + remarkPlugins: [...createMdxSourcePlugins()], }, }); ``` @@ -201,11 +214,11 @@ export default defineNuxtConfig({ ```ts title="svelte.config.js" import { mdsvex } from "mdsvex"; -import { mdxSourcePlugins } from "leadtype/mdx"; +import { createMdxSourcePlugins } from "leadtype/mdx"; export default { extensions: [".svelte", ".svx", ".mdx"], - preprocess: mdsvex({ remarkPlugins: [...mdxSourcePlugins] }), + preprocess: mdsvex({ remarkPlugins: [...createMdxSourcePlugins()] }), }; ``` @@ -217,7 +230,7 @@ See the dedicated [Integrate with Fumadocs](/docs/build/integrate-with-fumadocs) If your framework's MDX integration accepts a remark plugin list, leadtype works. Three pieces: -1. Add `mdxSourcePlugins` to the remark list so `` / `` resolve at build time. +1. Add `createMdxSourcePlugins()` to the remark list so `` / `` resolve at build time. 2. Implement components against the [tag types from `leadtype/mdx`](/docs/reference/mdx) — intersect with your framework's child type. 3. Optionally call `createDocsSource()` for navigation, search, and programmatic page loading. diff --git a/packages/leadtype/README.md b/packages/leadtype/README.md index 96a486f..1633ad4 100644 --- a/packages/leadtype/README.md +++ b/packages/leadtype/README.md @@ -51,6 +51,7 @@ Full docs at [leadtype.dev](https://leadtype.dev/docs). Highlights: | --- | --- | | `leadtype` | `defineDocsConfig` — the config helper. | | `leadtype/convert` | MDX-to-markdown conversion. | +| `leadtype/mdx` | Source-MDX tag types, include helpers, and `createMdxSourcePlugins()`. | | `leadtype/remark` | `defaultRemarkPlugins` plus individual plugins. | | `leadtype/llm` | `generateLlmsTxt`, `generateLLMFullContextFiles`, `generateAgentsMd`, `resolveDocsNavigation`. | | `leadtype/search` | Edge-safe search runtime, content readers, request guards. | diff --git a/packages/leadtype/src/cli.test.ts b/packages/leadtype/src/cli.test.ts index 535dae3..037b40b 100644 --- a/packages/leadtype/src/cli.test.ts +++ b/packages/leadtype/src/cli.test.ts @@ -784,6 +784,40 @@ Initial release. ); }); + it("fails generation when typeTableStrict extraction fails", async () => { + const srcDir = await createTempDir(); + const outDir = await createTempDir(); + const capture = createCapture(); + + await mkdir(path.join(srcDir, "docs"), { recursive: true }); + await writeFile( + path.join(srcDir, "docs", "docs.config.ts"), + `export default { + product: { + name: "Configured Product", + summary: "Configured product summary.", + }, + groups: [{ slug: "guides", title: "Guides" }], + typeTableStrict: true, +};` + ); + await writeMdxPage( + srcDir, + "quickstart.mdx", + 'title: "Quickstart"\ndescription: "Start here."\ngroup: guides', + '' + ); + + const code = await runCli( + ["generate", "--src", srcDir, "--out", outDir], + capture.io + ); + + expect(code).toBe(1); + expect(capture.stderr).toContain('Could not extract "MissingProps"'); + expect(capture.stderr).toContain("Failed to convert 1 docs file(s)."); + }); + it("filters generated docs by include path globs", async () => { const outDir = await createTempDir(); const capture = createCapture(); diff --git a/packages/leadtype/src/cli/generate.ts b/packages/leadtype/src/cli/generate.ts index eb65edb..734c955 100644 --- a/packages/leadtype/src/cli/generate.ts +++ b/packages/leadtype/src/cli/generate.ts @@ -107,12 +107,26 @@ type GenerateResult = { srcDir: string; }; -function createGenerateRemarkPlugins(sourceRoot: string): PluggableList { +function createGenerateRemarkPlugins({ + sourceRoot, + typeTableBasePath, + typeTableStrict, +}: { + sourceRoot: string; + typeTableBasePath?: string; + typeTableStrict?: boolean; +}): PluggableList { const plugins: PluggableList = [remarkInclude]; for (const plugin of defaultRemarkPlugins) { plugins.push( plugin === remarkTypeTableToMarkdown - ? ([remarkTypeTableToMarkdown, { basePath: sourceRoot }] as Pluggable) + ? ([ + remarkTypeTableToMarkdown, + { + basePath: typeTableBasePath ?? sourceRoot, + strict: typeTableStrict, + }, + ] as Pluggable) : plugin ); } @@ -128,6 +142,8 @@ type ResolvedGenerateMetadata = { configPath?: string; groups: DocsGroup[]; product: ProductInfo; + typeTableBasePath?: string; + typeTableStrict?: boolean; }; const GENERATE_USAGE = `leadtype generate — convert MDX and produce site or package-bundle artifacts @@ -340,7 +356,18 @@ function validateDocsConfig(value: unknown, configPath: string): DocsConfig { `docs config at "${configPath}" must export groups as an array of { slug, title } entries` ); } - return { groups, product }; + return { + groups, + product, + typeTableBasePath: + typeof value.typeTableBasePath === "string" + ? value.typeTableBasePath + : undefined, + typeTableStrict: + typeof value.typeTableStrict === "boolean" + ? value.typeTableStrict + : undefined, + }; } async function importConfigModule(configPath: string): Promise { @@ -456,6 +483,10 @@ async function resolveGenerateMetadata( configPath: loadedConfig.path, groups: loadedConfig.config.groups, product: applyProductOverrides(loadedConfig.config.product, args), + typeTableBasePath: loadedConfig.config.typeTableBasePath + ? path.resolve(srcDir, loadedConfig.config.typeTableBasePath) + : undefined, + typeTableStrict: loadedConfig.config.typeTableStrict, }; } @@ -794,12 +825,13 @@ export async function runGenerateCommand( try { const metadata = await resolveGenerateMetadata(srcDir, docsDirs, args); sourceMirror = await createSourceMirror(srcDir, docsSources, args); - const { groups, product } = metadata.configPath - ? metadata - : { - ...metadata, - groups: await inferGroups(sourceMirror.docsDir), - }; + const { groups, product, typeTableBasePath, typeTableStrict } = + metadata.configPath + ? metadata + : { + ...metadata, + groups: await inferGroups(sourceMirror.docsDir), + }; const navigation = await resolveDocsNavigation({ srcDir: sourceMirror.srcDir, @@ -816,8 +848,13 @@ export async function runGenerateCommand( await convertAllMdx({ srcDir: sourceMirror.docsDir, outDir: path.join(outDir, "docs"), - remarkPlugins: createGenerateRemarkPlugins(srcDir), + remarkPlugins: createGenerateRemarkPlugins({ + sourceRoot: srcDir, + typeTableBasePath, + typeTableStrict, + }), enrichFrontmatterFromGit: args.enrichGit, + failOnError: typeTableStrict, }); let result: GenerateResult; diff --git a/packages/leadtype/src/convert/convert.ts b/packages/leadtype/src/convert/convert.ts index 435d06a..f87b9e1 100644 --- a/packages/leadtype/src/convert/convert.ts +++ b/packages/leadtype/src/convert/convert.ts @@ -252,6 +252,8 @@ export type MdxToMarkdownOptions = { * `min(cpuCount, 16)` with a floor of 2. */ concurrency?: number; + /** Throw after batch conversion if any file fails. */ + failOnError?: boolean; }; type GitEnrichment = { @@ -676,4 +678,8 @@ export async function convertAllMdx( }, }, }); + + if (failed > 0 && config.failOnError) { + throw new Error(`Failed to convert ${failed} docs file(s).`); + } } diff --git a/packages/leadtype/src/llm/llm.ts b/packages/leadtype/src/llm/llm.ts index f03a10e..b25e4dd 100644 --- a/packages/leadtype/src/llm/llm.ts +++ b/packages/leadtype/src/llm/llm.ts @@ -138,6 +138,13 @@ export type DocsGroup = { export type DocsConfig = { product: ProductInfo; groups: DocsGroup[]; + /** + * Optional base directory for ExtractedTypeTable / AutoTypeTable path + * resolution during generation. Relative values are resolved from `--src`. + */ + typeTableBasePath?: string; + /** Throw during generation when a referenced type cannot be extracted. */ + typeTableStrict?: boolean; }; /** diff --git a/packages/leadtype/src/mdx/index.ts b/packages/leadtype/src/mdx/index.ts index 08012bc..cbbb5c5 100644 --- a/packages/leadtype/src/mdx/index.ts +++ b/packages/leadtype/src/mdx/index.ts @@ -7,10 +7,10 @@ * - **Tag type contracts** for every custom MDX tag (`CalloutProps`, * `TabsProps`, `TypeTableProps`, …). Implement components against these * in your renderer. - * - **`mdxSourcePlugins`** — remark preset for compiling source MDX in a - * host bundler (Next, Vite, fumadocs, …). Expands includes, resolves - * ``, strips authoring `import`s; preserves every - * other custom tag as JSX. + * - **`createMdxSourcePlugins()` / `mdxSourcePlugins`** — remark preset for + * compiling source MDX in a host bundler (Next, Vite, fumadocs, …). + * Expands includes, resolves ``, strips authoring + * `import`s; preserves every other custom tag as JSX. * - **`resolveInclude` / `parseIncludeSpecifier` / `extractMdxSection`** — * low-level include-resolution helpers, framework-neutral. * @@ -39,7 +39,11 @@ export { resolveIncludePath, } from "../remark/plugins/include.remark"; // Source preset for bundler consumers -export { mdxSourcePlugins } from "./source-preset"; +export { + createMdxSourcePlugins, + type MdxSourcePluginsOptions, + mdxSourcePlugins, +} from "./source-preset"; // Tag type contracts export type { AccordionItemProps, diff --git a/packages/leadtype/src/mdx/source-preset.ts b/packages/leadtype/src/mdx/source-preset.ts index 97f9699..a39cfef 100644 --- a/packages/leadtype/src/mdx/source-preset.ts +++ b/packages/leadtype/src/mdx/source-preset.ts @@ -21,15 +21,43 @@ import { remarkInclude } from "../remark/plugins/include.remark"; import { remarkRemoveImports } from "../remark/plugins/remove-imports.remark"; import { remarkResolveTypeTableJsx } from "../remark/plugins/type-table-jsx.remark"; +export type MdxSourcePluginsOptions = { + /** Base directory used to resolve ExtractedTypeTable / AutoTypeTable paths. */ + typeTableBasePath?: string; + /** Throw when a referenced type cannot be extracted. */ + typeTableStrict?: boolean; + /** Emit a visible warning node when type extraction fails. Defaults to true. */ + typeTableWarnOnFailure?: boolean; +}; + /** * Default remark plugin list for compiling source MDX in a host bundler. * Order matters: includes expand first (so type-table / placeholder passes * see merged content), then type-table extraction, then placeholder resolution, * then import stripping. */ -export const mdxSourcePlugins: PluggableList = [ - remarkInclude, - remarkResolveTypeTableJsx, - remarkResolveDocPlaceholders, - remarkRemoveImports, -]; +export function createMdxSourcePlugins( + options: MdxSourcePluginsOptions = {} +): PluggableList { + return [ + remarkInclude, + [ + remarkResolveTypeTableJsx, + { + basePath: options.typeTableBasePath, + strict: options.typeTableStrict, + warnOnFailure: options.typeTableWarnOnFailure, + }, + ], + remarkResolveDocPlaceholders, + remarkRemoveImports, + ]; +} + +/** + * Back-compatible source preset with no explicit type-table base path. For + * projects whose source root is not the current working directory, prefer + * `createMdxSourcePlugins({ typeTableBasePath })` so `` + * paths resolve from the same root as `createDocsSource({ contentDir })`. + */ +export const mdxSourcePlugins: PluggableList = createMdxSourcePlugins(); diff --git a/packages/leadtype/src/remark/libs/generic-processor.ts b/packages/leadtype/src/remark/libs/generic-processor.ts index 22bd7b9..e8a625b 100644 --- a/packages/leadtype/src/remark/libs/generic-processor.ts +++ b/packages/leadtype/src/remark/libs/generic-processor.ts @@ -1,5 +1,6 @@ import type { Parent, Root, RootContent } from "mdast"; import { SKIP, visit } from "unist-util-visit"; +import type { VFile } from "vfile"; import { hasName } from "./guards"; import type { MdxNode } from "./types"; @@ -9,7 +10,8 @@ import type { MdxNode } from "./types"; type ComponentProcessor = ( node: MdxNode, index: number, - parent: Parent + parent: Parent, + file?: VFile ) => RootContent[] | undefined; /** @@ -28,10 +30,10 @@ export function createJsxComponentProcessor( componentName: string | string[], processor: ComponentProcessor, removeIfEmpty = true -): (tree: Root) => Root { +): (tree: Root, file?: VFile) => Root { const names = Array.isArray(componentName) ? componentName : [componentName]; - return (tree: Root): Root => { + return (tree: Root, file?: VFile): Root => { visit( tree, ["mdxJsxFlowElement", "mdxJsxTextElement"], @@ -45,7 +47,7 @@ export function createJsxComponentProcessor( return; } - const result = processor(node as MdxNode, index, parent); + const result = processor(node as MdxNode, index, parent, file); // If processor returns void, assume it handled replacement internally if (result === undefined) { @@ -79,14 +81,15 @@ export function createSimpleJsxComponentProcessor( processor: ( node: MdxNode, index: number, - parent: Parent + parent: Parent, + file?: VFile ) => RootContent | null, removeIfEmpty = true ) { return createJsxComponentProcessor( componentName, - (node, index, parent) => { - const result = processor(node, index, parent); + (node, index, parent, file) => { + const result = processor(node, index, parent, file); return result ? [result] : []; }, removeIfEmpty diff --git a/packages/leadtype/src/remark/plugins/type-table-jsx.remark.ts b/packages/leadtype/src/remark/plugins/type-table-jsx.remark.ts index 38252c7..7231450 100644 --- a/packages/leadtype/src/remark/plugins/type-table-jsx.remark.ts +++ b/packages/leadtype/src/remark/plugins/type-table-jsx.remark.ts @@ -11,17 +11,23 @@ * Use this in the `mdxSourcePlugins` preset shipped from `leadtype/mdx`. */ -import { resolve } from "node:path"; -import type { Root } from "mdast"; +import type { Paragraph, Root, RootContent } from "mdast"; import type { MdxJsxFlowElement } from "mdast-util-mdx"; -import { getAttributeValue, hasName, type MdxNode } from "../libs"; -import { extractTypeFromFile } from "./type-table.remark"; - -const DEFAULT_EXTRACTED_TYPE_BASE_PATH = "docs"; +import type { VFile } from "vfile"; +import { createText, getAttributeValue, hasName, type MdxNode } from "../libs"; +import { + createTypeTableExtractionFailureMessage, + extractTypeFromFile, + resolveDefaultTypeTableBasePath, +} from "./type-table.remark"; export type RemarkResolveTypeTableJsxOptions = { /** Base directory used to resolve relative `path=` attributes. */ basePath?: string; + /** Throw when extraction fails instead of emitting a visible warning node. */ + strict?: boolean; + /** Emit a visible warning node when extraction fails. Defaults to true. */ + warnOnFailure?: boolean; }; type AttrValueExpression = { @@ -81,25 +87,40 @@ function buildTypeTableNode(opts: { }; } +function getVFilePath(file?: VFile): string | undefined { + return typeof file?.path === "string" && file.path.length > 0 + ? file.path + : undefined; +} + +function createWarningParagraph(message: string): Paragraph { + return { + type: "paragraph", + children: [ + { type: "strong", children: [createText("Warning:")] }, + createText(` ${message}`), + ], + }; +} + export function remarkResolveTypeTableJsx( options: RemarkResolveTypeTableJsxOptions = {} -): (tree: Root) => Root { - const defaultBasePath = resolve( - process.cwd(), - DEFAULT_EXTRACTED_TYPE_BASE_PATH - ); - const basePath = options.basePath ?? defaultBasePath; - - return (tree: Root): Root => { +): (tree: Root, file?: VFile) => Root { + return (tree: Root, file?: VFile): Root => { + const basePath = + options.basePath ?? resolveDefaultTypeTableBasePath(getVFilePath(file)); const replace = ( parentChildren: Root["children"], index: number, - replacement: MdxJsxFlowElement + replacement: RootContent | RootContent[] ) => { + const replacements = Array.isArray(replacement) + ? replacement + : [replacement]; parentChildren.splice( index, 1, - replacement as unknown as Root["children"][number] + ...(replacements as unknown as Root["children"]) ); }; @@ -134,20 +155,38 @@ export function remarkResolveTypeTableJsx( } // Always rewrite the tag to `` so consumers only ever - // implement one runtime component. If extraction failed, `properties` - // is `{}` and `name`/`path` are still passed through — the consumer's - // TypeTable can render a placeholder for that case. + // implement one runtime component. If extraction fails, emit a + // visible warning before the placeholder table unless strict mode + // has been enabled. const extracted = extractTypeFromFile(path, name, overrideBasePath); + const typeTableNode = buildTypeTableNode({ + properties: extracted ?? {}, + title, + description, + name, + path, + }); + if (!extracted) { + const message = createTypeTableExtractionFailureMessage({ + basePath: overrideBasePath, + path, + typeName: name, + }); + if (options.strict) { + throw new Error(message); + } + if (options.warnOnFailure ?? true) { + replace(parentChildren, index, [ + createWarningParagraph(message), + typeTableNode as unknown as RootContent, + ]); + continue; + } + } replace( parentChildren, index, - buildTypeTableNode({ - properties: extracted ?? {}, - title, - description, - name, - path, - }) + typeTableNode as unknown as RootContent ); continue; } diff --git a/packages/leadtype/src/remark/plugins/type-table.remark.ts b/packages/leadtype/src/remark/plugins/type-table.remark.ts index f98e8be..4dd9439 100644 --- a/packages/leadtype/src/remark/plugins/type-table.remark.ts +++ b/packages/leadtype/src/remark/plugins/type-table.remark.ts @@ -1,10 +1,12 @@ import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; -import { resolve } from "node:path"; +import { normalize, resolve } from "node:path"; import JSON5 from "json5"; import type { RootContent, Table } from "mdast"; import type { MdxJsxFlowElement, MdxJsxTextElement } from "mdast-util-mdx"; import type * as ts from "typescript"; +import type { VFile } from "vfile"; +import { logger } from "../../internal/logger"; import { createHeading, createJsxComponentProcessor, @@ -152,6 +154,10 @@ type TypeTableOptions = { includeRequired?: boolean; /** Base path to resolve relative file paths for ExtractedTypeTable components. */ basePath?: string; + /** Throw when an ExtractedTypeTable / AutoTypeTable reference cannot be resolved. */ + strict?: boolean; + /** Emit a stderr warning when extraction fails. Defaults to true. */ + warnOnFailure?: boolean; }; const TABLE_HEADING_DEPTH = 3 as const; @@ -163,13 +169,83 @@ const IMPORT_TYPE_PATTERN = /import\(["']([^"']+)["']\)\.(\w+)/; const JSDOC_PATTERN = /\/\*\*[\s\S]*?\*\//; const TRAILING_SLASHES_PATTERN = /\/+$/; -const DEFAULT_EXTRACTED_TYPE_BASE_PATH = "docs"; +const DEFAULT_DOCS_DIR = "docs"; type ParsedProperty = { name: string; property: ObjectType; }; +function getVFilePath(file?: VFile): string | undefined { + return typeof file?.path === "string" && file.path.length > 0 + ? file.path + : undefined; +} + +export function resolveDefaultTypeTableBasePath(sourcePath?: string): string { + if (!sourcePath) { + return process.cwd(); + } + + const normalizedPath = sourcePath.replaceAll("\\", "/"); + const segments = normalizedPath.split("/"); + const docsIndex = segments.indexOf(DEFAULT_DOCS_DIR); + if (docsIndex > 0) { + return normalize(segments.slice(0, docsIndex).join("/") || "/"); + } + + return process.cwd(); +} + +export function createTypeTableExtractionFailureMessage({ + basePath, + path, + typeName, +}: { + basePath?: string; + path: string; + typeName: string; +}): string { + const basePathHint = basePath ? ` using base path "${basePath}"` : ""; + return `ExtractedTypeTable: Could not extract "${typeName}" from "${path}"${basePathHint}. Verify the path/name and that the file is included by your tsconfig.`; +} + +function reportTypeTableExtractionFailure({ + basePath, + path, + strict, + typeName, + warnOnFailure, +}: { + basePath?: string; + path: string; + strict?: boolean; + typeName: string; + warnOnFailure?: boolean; +}): string { + const message = createTypeTableExtractionFailureMessage({ + basePath, + path, + typeName, + }); + + if (strict) { + throw new Error(message); + } + + if (warnOnFailure ?? true) { + logger.warn({ + human: { message }, + json: { + event: "type_table.extraction_failed", + fields: { basePath, path, typeName }, + }, + }); + } + + return message; +} + /** * Parse a JavaScript object literal from an MDX attribute value expression. * This handles the properties object that gets passed to the TypeTable component. @@ -758,6 +834,13 @@ function processExtractedTypeTableNode( content.push(table); } } else { + const failureMessage = reportTypeTableExtractionFailure({ + basePath: overrideBasePath || options.basePath, + path: extractedTypePath, + strict: options.strict, + typeName: extractedTypeName, + warnOnFailure: options.warnOnFailure, + }); // Fallback to simple info table if extraction failed const infoTable = createTable( ["Property", "Value"], @@ -771,11 +854,7 @@ function processExtractedTypeTableNode( content.push(infoTable); // Add a note about this being an ExtractedTypeTable - content.push( - createParagraph( - `*ExtractedTypeTable: Could not extract \`${extractedTypeName}\` from \`${extractedTypePath}\`. Verify the path/name and that the file is included by your tsconfig.*` - ) - ); + content.push(createParagraph(`*${failureMessage}*`)); } return content; @@ -897,13 +976,18 @@ export const remarkTypeTableToMarkdown = ( includeDescriptions: true, includeDefaults: true, includeRequired: true, - basePath: resolve(process.cwd(), DEFAULT_EXTRACTED_TYPE_BASE_PATH), + warnOnFailure: true, }; - const resolved = { ...defaults, ...opts }; return createJsxComponentProcessor( ["TypeTable", "ExtractedTypeTable", "AutoTypeTable"], - (node) => { + (node, _index, _parent, file) => { + const resolved = { + ...defaults, + ...opts, + basePath: + opts.basePath ?? resolveDefaultTypeTableBasePath(getVFilePath(file)), + }; if (isExtractedTypeTableNode(node)) { return processExtractedTypeTableNode(node, resolved); } diff --git a/packages/leadtype/src/remark/remark-output.test.ts b/packages/leadtype/src/remark/remark-output.test.ts index d2a2f05..edd3357 100644 --- a/packages/leadtype/src/remark/remark-output.test.ts +++ b/packages/leadtype/src/remark/remark-output.test.ts @@ -8,6 +8,7 @@ import { remarkInclude, remarkTypeTableToMarkdown, } from "./index"; +import { resolveDefaultTypeTableBasePath } from "./plugins/type-table.remark"; const tempDirs: string[] = []; @@ -77,13 +78,13 @@ describe("remark markdown output", () => { expect(result.markdown).not.toContain('appearsClicking **"Customize"**'); }); - it("resolves ExtractedTypeTable paths from docs by default", async () => { + it("resolves ExtractedTypeTable paths from the source root by default", async () => { const projectDir = await createTempProject(); const previousCwd = process.cwd(); try { await writeProjectFile( projectDir, - "docs/types.ts", + "types.ts", `export interface PipelineOptions { /** Source directory for docs. */ srcDir: string; @@ -113,7 +114,7 @@ describe("remark markdown output", () => { try { await writeProjectFile( projectDir, - "docs/types.ts", + "types.ts", `export interface ConsentBannerProps { /** Content to display as the banner's title. */ title?: string; @@ -139,6 +140,18 @@ describe("remark markdown output", () => { } }); + it("uses the first docs segment when deriving the fallback type-table base path", () => { + expect( + resolveDefaultTypeTableBasePath("/repo/docs/reference/docs/page.mdx") + ).toBe("/repo"); + expect(resolveDefaultTypeTableBasePath("docs/reference/page.mdx")).toBe( + process.cwd() + ); + expect(resolveDefaultTypeTableBasePath("/repo/content/page.mdx")).toBe( + process.cwd() + ); + }); + it("converts card grids with interactive cards into markdown lists", async () => { const sourcePath = await createTempMdxFile( "index.mdx", diff --git a/packages/leadtype/src/source/index.ts b/packages/leadtype/src/source/index.ts index 10f8720..537aa7e 100644 --- a/packages/leadtype/src/source/index.ts +++ b/packages/leadtype/src/source/index.ts @@ -9,7 +9,7 @@ * - Next App Router: import the source object, call `loadPage(slug)` from a * server component, render `result.ast` with `@mdx-js/mdx`. * - Vite + @mdx-js/rollup: import source `.mdx` directly through the bundler - * with `mdxSourcePlugins`; this primitive provides nav + search. + * with `createMdxSourcePlugins()`; this primitive provides nav + search. * * The primitive does **no I/O on construction** beyond a directory scan for * `listPages()`. Page bodies are loaded on demand. @@ -38,7 +38,7 @@ import type { } from "../llm"; import { extractDocsTableOfContents, resolveDocsNavigation } from "../llm"; import type { DocsNavigation } from "../llm/readability"; -import { mdxSourcePlugins } from "../mdx/source-preset"; +import { createMdxSourcePlugins } from "../mdx/source-preset"; import { type IncludeResolution, type ResolveIncludeOptions, @@ -97,11 +97,19 @@ export type CreateDocsSourceConfig = { /** Multi-mount configuration; matches `resolveDocsNavigation`. */ mounts?: DocsPathMount[]; /** - * Remark plugins to apply when loading pages. Defaults to `mdxSourcePlugins` - * (expand includes, resolve ``, strip authoring `import`s). + * Remark plugins to apply when loading pages. Defaults to Leadtype's source + * preset (expand includes, resolve ``, strip authoring `import`s). * Pass `[]` to skip transforms. */ remarkPlugins?: PluggableList; + /** + * Base directory for `` / `` + * resolution. Defaults to the parent of `contentDir`, matching a source + * root such as `.c15t` for `.c15t/docs`. + */ + typeTableBasePath?: string; + /** Throw when a referenced type cannot be extracted. */ + typeTableStrict?: boolean; /** TOC extraction options. Pass `false` to skip TOC computation entirely. */ toc?: DocsTableOfContentsOptions | false; /** Search-index tuning. */ @@ -212,7 +220,18 @@ export async function createDocsSource( } const baseUrl = normalizeBaseUrl(config.baseUrl); - const remarkPlugins = config.remarkPlugins ?? mdxSourcePlugins; + const contentParentDir = path.dirname(contentDir); + const defaultTypeTableBasePath = + contentParentDir === contentDir ? contentDir : contentParentDir; + const typeTableBasePath = path.resolve( + config.typeTableBasePath ?? defaultTypeTableBasePath + ); + const remarkPlugins = + config.remarkPlugins ?? + createMdxSourcePlugins({ + typeTableBasePath, + typeTableStrict: config.typeTableStrict, + }); const tocOptions: DocsTableOfContentsOptions | false = config.toc === false ? false : (config.toc ?? {}); diff --git a/packages/leadtype/src/source/source.test.ts b/packages/leadtype/src/source/source.test.ts index 632d6ee..07b7e05 100644 --- a/packages/leadtype/src/source/source.test.ts +++ b/packages/leadtype/src/source/source.test.ts @@ -11,13 +11,18 @@ async function writeMdx(filePath: string, content: string): Promise { describe("createDocsSource", () => { let contentDir: string; + const extraTempDirs: string[] = []; beforeEach(async () => { contentDir = await mkdtemp(path.join(tmpdir(), "leadtype-source-")); }); afterEach(async () => { - await rm(contentDir, { force: true, recursive: true }); + await Promise.all( + [contentDir, ...extraTempDirs.splice(0)].map(async (dir) => { + await rm(dir, { force: true, recursive: true }); + }) + ); }); it("lists every .md / .mdx page under contentDir with stable slug derivation", async () => { @@ -110,6 +115,141 @@ describe("createDocsSource", () => { expect(page?.markdown).toContain("bun add leadtype"); }); + it("resolves AutoTypeTable paths from the source root by default", async () => { + const sourceRoot = await mkdtemp( + path.join(tmpdir(), "leadtype-source-root-") + ); + extraTempDirs.push(sourceRoot); + await rm(contentDir, { force: true, recursive: true }); + contentDir = path.join(sourceRoot, "docs"); + await writeMdx( + path.join( + sourceRoot, + "packages/react/src/components/consent-banner/consent-banner.tsx" + ), + `export interface ConsentBannerProps { + /** Banner title shown above consent choices. */ + title?: string; +} +` + ); + await writeMdx( + path.join(contentDir, "reference.mdx"), + '' + ); + + const source = await createDocsSource({ contentDir }); + const page = await source.loadPage("reference"); + + expect(page?.markdown).toContain("title"); + expect(page?.markdown).toContain( + "Banner title shown above consent choices." + ); + expect(page?.markdown).not.toContain( + 'Could not extract "ConsentBannerProps"' + ); + }); + + it("allows typeTableBasePath to override source-root type resolution", async () => { + const sourceRoot = await mkdtemp( + path.join(tmpdir(), "leadtype-source-root-") + ); + const typeRoot = await mkdtemp(path.join(tmpdir(), "leadtype-types-")); + extraTempDirs.push(sourceRoot, typeRoot); + await rm(contentDir, { force: true, recursive: true }); + contentDir = path.join(sourceRoot, "docs"); + await writeMdx( + path.join(typeRoot, "packages/react/types.ts"), + `export interface OverrideProps { + /** Resolved from an explicit type-table base path. */ + enabled: boolean; +} +` + ); + await writeMdx( + path.join(contentDir, "reference.mdx"), + '' + ); + + const source = await createDocsSource({ + contentDir, + typeTableBasePath: typeRoot, + }); + const page = await source.loadPage("reference"); + + expect(page?.markdown).toContain("enabled"); + expect(page?.markdown).toContain( + "Resolved from an explicit type-table base path." + ); + }); + + it("emits a visible warning when source type extraction fails", async () => { + const sourceRoot = await mkdtemp( + path.join(tmpdir(), "leadtype-source-root-") + ); + extraTempDirs.push(sourceRoot); + await rm(contentDir, { force: true, recursive: true }); + contentDir = path.join(sourceRoot, "docs"); + await writeMdx( + path.join(contentDir, "reference.mdx"), + '' + ); + + const source = await createDocsSource({ contentDir }); + const page = await source.loadPage("reference"); + + expect(page?.markdown).toContain("Warning:"); + expect(page?.markdown).toContain('Could not extract "MissingProps"'); + }); + + it("treats an extracted empty interface as a successful type table", async () => { + const sourceRoot = await mkdtemp( + path.join(tmpdir(), "leadtype-source-root-") + ); + extraTempDirs.push(sourceRoot); + await rm(contentDir, { force: true, recursive: true }); + contentDir = path.join(sourceRoot, "docs"); + await writeMdx( + path.join(sourceRoot, "packages/react/types.ts"), + "export interface Marker {}\n" + ); + await writeMdx( + path.join(contentDir, "reference.mdx"), + '' + ); + + const source = await createDocsSource({ + contentDir, + typeTableStrict: true, + }); + const page = await source.loadPage("reference"); + + expect(page?.markdown).toContain(" { + const sourceRoot = await mkdtemp( + path.join(tmpdir(), "leadtype-source-root-") + ); + extraTempDirs.push(sourceRoot); + await rm(contentDir, { force: true, recursive: true }); + contentDir = path.join(sourceRoot, "docs"); + await writeMdx( + path.join(contentDir, "reference.mdx"), + '' + ); + + const source = await createDocsSource({ + contentDir, + typeTableStrict: true, + }); + + await expect(source.loadPage("reference")).rejects.toThrow( + /Could not extract "MissingProps"/ + ); + }); + it("buildSearchIndex emits an index whose document ids match urlPaths", async () => { await writeMdx( path.join(contentDir, "quickstart.mdx"),