diff --git a/.changeset/leadtype-next-adapter.md b/.changeset/leadtype-next-adapter.md new file mode 100644 index 0000000..fade2fd --- /dev/null +++ b/.changeset/leadtype-next-adapter.md @@ -0,0 +1,11 @@ +--- +"leadtype": minor +--- + +Add `leadtype/next` framework adapter and formalize the core/adapter boundary. + +`leadtype/next` exposes three server-only helpers for Next.js App Router: `createGenerateStaticParams(...)`, `createLoadPageData(...)`, and `createDocsRouteHandler(...)`. The route handler wraps `createAgentMarkdownResponse` so a docs app can serve raw markdown, handle `Accept: text/markdown` negotiation, and detect AI user agents from a one-line `route.ts`. The companion `leadtype/next/client` subpath exports a `useLeadtypeSearch` React hook plus a framework-free `createSearchClient` factory that lazy-loads `search-index.json` / `search-content.json` and runs BM25 per keystroke. + +`react` is now an optional peer dependency for `leadtype/next/client`. Server-only consumers never pull in React. + +Documents the core/adapter boundary in a new `docs/reference/architecture` page: leadtype core has zero framework runtime deps, adapters live at flat `leadtype/` subpaths, and **no leadtype package — core or adapter — ever ships rendered DOM**. State primitives (hooks, composables, stores, handler factories) are allowed; ``-style components are not. The docs also name the planned native adapter shapes for Nuxt, SvelteKit, Astro, TanStack Start, Vue search, and Svelte search without exporting those APIs yet. The boundary is now enforced by tests in `packages/leadtype/src/internal/package-surface.test.ts` that scan import graphs and fail if framework runtimes leak into core or one adapter imports from another. diff --git a/README.md b/README.md index 61a3f74..a9fea4c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ flowchart LR bundle_out --> offline_agents ``` -leadtype is **not a docs website framework**. Bring your own UI — Next.js, TanStack Start, Astro, anything — and let leadtype handle conversion, validation, search, and the agent-facing outputs that website frameworks don't ship. +leadtype is **not a docs website framework**. Bring your own UI — Next.js, TanStack Start, Astro, Nuxt, SvelteKit, Vue, Svelte, anything — and let leadtype handle conversion, validation, search, and the agent-facing outputs that website frameworks don't ship. ## Choose your path @@ -63,6 +63,7 @@ Full docs at [leadtype.dev](https://leadtype.dev/docs): - [Add search](https://leadtype.dev/docs/build/add-search) - [Frontmatter](https://leadtype.dev/docs/authoring/frontmatter) - [CLI reference](https://leadtype.dev/docs/reference/cli) +- [Architecture](https://leadtype.dev/docs/reference/architecture) — core package boundary and framework adapter rules - [Methodology](https://leadtype.dev/docs/methodology) — how leadtype differs from Fumadocs, Starlight, and Mintlify ## Repo layout diff --git a/bun.lock b/bun.lock index b3dfb55..640d6ec 100644 --- a/bun.lock +++ b/bun.lock @@ -154,6 +154,7 @@ "@cloudflare/tanstack-ai": "^0.1.7", "@tanstack/ai": "^0.15.0", "@types/node": "^22.0.0", + "@types/react": "^19.0.0", "@typescript/native-preview": "7.0.0-dev.20260509.2", "ai": "^6.0.177", "bash-tool": "1.3.16", @@ -163,6 +164,7 @@ "mdast-util-mdx": "3.0.0", "mdast-util-mdx-jsx": "3.2.0", "mdast-util-mdxjs-esm": "2.0.1", + "react": "^19.0.0", "rollup": "^4.40.0", "rollup-plugin-dts": "^6.2.1", "rollup-plugin-esbuild": "^6.2.1", @@ -177,6 +179,7 @@ "fumadocs-core": ">=15.0.0", "jiti": ">=2.0.0", "just-bash": ">=2.14.5", + "react": ">=18.0.0", "typescript": ">=5.0.0", }, "optionalPeers": [ @@ -187,6 +190,7 @@ "fumadocs-core", "jiti", "just-bash", + "react", "typescript", ], }, diff --git a/docs/build/use-the-source-primitive.mdx b/docs/build/use-the-source-primitive.mdx index 01b6d59..241d8d6 100644 --- a/docs/build/use-the-source-primitive.mdx +++ b/docs/build/use-the-source-primitive.mdx @@ -40,6 +40,8 @@ Plus an MDX integration for your bundler (`@next/mdx`, `@astrojs/mdx`, `@mdx-js/ ### Next App Router +The `leadtype/next` adapter wraps the common Next wiring so the page, route handler, and search hook each become a one-line export. Use it as the recommended path; fall back to calling `createDocsSource()` directly only when you need behavior the adapter doesn't expose. + ```ts title="next.config.mjs" import createMDX from "@next/mdx"; import { createMdxSourcePlugins } from "leadtype/mdx"; @@ -57,14 +59,20 @@ export default createMDX({ ```tsx title="app/docs/[[...slug]]/page.tsx" import { notFound } from "next/navigation"; import { MDXRemote } from "next-mdx-remote-client/rsc"; +import { + createGenerateStaticParams, + createLoadPageData, +} from "leadtype/next"; import { source } from "@/lib/source"; import { mdxComponents } from "@/lib/mdx-components"; +const loadPageData = createLoadPageData({ source }); +export const generateStaticParams = createGenerateStaticParams({ source }); + export default async function DocsPage({ params, }: { params: Promise<{ slug?: string[] }> }) { - const { slug = [] } = await params; - const page = await source.loadPage(slug); + const page = await loadPageData((await params).slug); if (!page) notFound(); return ( @@ -75,13 +83,21 @@ export default async function DocsPage({ ); } +``` -export async function generateStaticParams() { - const pages = await source.listPages(); - return pages.map((page) => ({ slug: page.slug })); -} +Add a sibling `route.ts` to serve raw markdown and content negotiation: + +```ts title="app/docs/[[...slug]]/route.ts" +import { createDocsRouteHandler } from "leadtype/next"; +import manifest from "@/generated/agent-readability.json" with { type: "json" }; + +export const GET = createDocsRouteHandler({ + manifest: { ...manifest, version: 1 } as const, +}); ``` +Pair with `useLeadtypeSearch` from `leadtype/next/client` when building a search input — see the [search recipe](/docs/build/add-search). + ### Astro Content Collections ```ts title="astro.config.mjs" @@ -190,7 +206,31 @@ function DocsCatchAllRoute() { Generate `docs-pages.json` at build time by calling `createDocsSource().listPages()` from a build script and writing each page's `slug`, `urlPath`, and `globKey` (path relative to the catch-all route, POSIX separators). -### Vite + `@mdx-js/rollup` (works for Vue, Solid, Svelte starters) +There is no `leadtype/tanstack-start` export yet. The future adapter should wrap this same shape with TanStack Router route helpers, server functions, and React state helpers. TanStack AI answer streaming already lives separately under `leadtype/search/tanstack`. + +### Vue + Vite + +Use a Vue content or MDX plugin that exposes a unified/remark plugin list, then pass Leadtype's source preset into that plugin. Leadtype supplies the transforms, route/search data, and tag prop contracts; your Vue app owns rendering. + +```ts title="vite.config.ts" +import vue from "@vitejs/plugin-vue"; +import { createMdxSourcePlugins } from "leadtype/mdx"; +import { defineConfig } from "vite"; + +const leadtypeRemarkPlugins = createMdxSourcePlugins(); + +export default defineConfig({ + plugins: [ + vue(), + // Add `leadtypeRemarkPlugins` to your Vue MDX/content plugin's + // remark/unified plugin list. + ], +}); +``` + +For client search today, fetch `/docs/search-index.json` and `/docs/search-content.json`, then call `searchDocs()` from `leadtype/search`. A future `leadtype/search/vue` helper should expose the same behavior as a Vue composable. + +### Vite + `@mdx-js/rollup` (React, Preact, Solid, or custom JSX runtimes) ```ts title="vite.config.ts" import mdx from "@mdx-js/rollup"; @@ -204,6 +244,8 @@ export default { }; ``` +> Nuxt, SvelteKit, Astro, TanStack Start, Vue, and Svelte recipes wire `createDocsSource()` manually today. Dedicated `leadtype/nuxt`, `leadtype/sveltekit`, `leadtype/astro`, `leadtype/tanstack-start`, `leadtype/search/vue`, and `leadtype/search/svelte` adapters with native handler factories and state primitives are tracked under issue [#41](https://github.com/inthhq/leadtype/issues/41) and the search-helper follow-up [#45](https://github.com/inthhq/leadtype/issues/45). + ### Nuxt ```ts title="nuxt.config.ts" @@ -215,6 +257,8 @@ export default defineNuxtConfig({ }); ``` +Use Nitro routes for generated markdown and Agent Readability artifacts. For search, load the generated JSON files from a Vue composable and pass them to `searchDocs()` from `leadtype/search`; a future `leadtype/nuxt` adapter should make that composable copy-paste small without owning UI. + ### SvelteKit + `mdsvex` ```ts title="svelte.config.js" @@ -227,6 +271,8 @@ export default { }; ``` +Use `+page.server.ts` to call `source.loadPage(slug)`, `entries()` to prerender known routes from `source.listPages()`, and `+server.ts` for markdown or JSON artifact responses. For search, load generated JSON into a store or rune and call `searchDocs()`; a future `leadtype/search/svelte` helper should keep that state primitive UI-free. + ### Pattern for any other host If your framework's MDX integration accepts a remark plugin list, leadtype works. Three pieces every time: diff --git a/docs/docs.config.ts b/docs/docs.config.ts index 9a3c826..cbb6ac9 100644 --- a/docs/docs.config.ts +++ b/docs/docs.config.ts @@ -20,6 +20,7 @@ export default defineDocsConfig({ { urlPath: "/docs/build/add-search" }, { urlPath: "/docs/build/optimize-docs-for-agents" }, { urlPath: "/docs/package-docs/bundle" }, + { urlPath: "/docs/reference/architecture" }, ], agentGuidance: "Open /docs/llms.txt to route the task, then use /llms-full.txt only when page-level markdown is not enough.", diff --git a/docs/methodology.mdx b/docs/methodology.mdx index c09b1f3..f44929e 100644 --- a/docs/methodology.mdx +++ b/docs/methodology.mdx @@ -24,7 +24,7 @@ Choose a website framework when the main job is publishing a polished docs UI qu - The **MDX tag contract** — typed prop shapes for ``, ``, ``, ``, and the rest of the custom tags (see [`leadtype/mdx`](/docs/reference/mdx)). - A **build-time source preset** that expands `` partials, resolves ``, and strips authoring `import`s — without flattening live components. - A **framework-neutral source primitive** (`createDocsSource`) exposing navigation, page loading, search index building, and include resolution. -- A thin **fumadocs adapter** (`leadtype/fumadocs`) wiring the source into fumadocs's `Source` interface. +- **Framework adapters and recipes** — thin, optional packages or copyable recipes that wire the source primitive into native host conventions: Next App Router, Fumadocs, Nuxt/Nitro, SvelteKit, Astro, TanStack Start, Vue, and Svelte. Adapters export **state and routing primitives only** — hooks, composables, stores, route-handler factories, static-param helpers, pure data helpers — never rendered DOM. - The agent / LLM pipeline: MDX-to-markdown conversion, `llms.txt`, root `llms-full.txt` fallback context, markdown mirrors, sitemap, and AGENTS.md bundles. - A static, edge-safe **search index** plus optional source-grounded answer streaming. - **Lint rules** for frontmatter, navigation metadata, and internal links. @@ -32,8 +32,8 @@ Choose a website framework when the main job is publishing a polished docs UI qu ## What leadtype does not own -- Visual UI, theming, or component styling. -- Runtime component implementations — your docs app implements components against the tag contract (see [Components](/docs/authoring/components) and [`leadtype/mdx`](/docs/reference/mdx)). +- **Rendered DOM, ever.** No leadtype package — core or adapter — ships ``, ``, ``, ``, or any component that emits markup. This is permanent, not a "for now." Consumers implement components against the tag contract from [`leadtype/mdx`](/docs/reference/mdx); leadtype supplies the data, types, and wiring. The one-line test for any new adapter: *does it return data, state, or a function, or does it return JSX?* If JSX, it doesn't ship. +- Visual UI, theming, CSS, design tokens, or component styling. - Routing, hosting, deployment, or analytics. - The MDX compiler — leadtype produces the source preset; your bundler (Next App Router, Vite, fumadocs-mdx, Astro) does the compile. diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx new file mode 100644 index 0000000..2a3207d --- /dev/null +++ b/docs/reference/architecture.mdx @@ -0,0 +1,92 @@ +--- +title: "Architecture" +description: "The core / adapter boundary — what ships where, and the rules adapters must follow." +group: reference +--- + +# Architecture + +Leadtype is split into a **framework-neutral core** and a small set of **thin adapters** that wire the core into specific host frameworks. The rules below are the contract — they tell you where new code lives, what it can import, and what it can never ship. + +## The three layers + +### Core + +| Subpath | Purpose | +| --- | --- | +| `leadtype` | `createDocsSource`, `defineDocsConfig`, agent-readability types. | +| `leadtype/mdx` | Tag types (`CalloutProps`, `TabsProps`, …) and `createMdxSourcePlugins()`. | +| `leadtype/remark` | Remark plugins (include, type-table, agent flattening). | +| `leadtype/convert` | MDX → markdown conversion. | +| `leadtype/llm` | `llms.txt`, `llms-full.txt`, sitemap, AGENTS.md, agent-readability manifest. | +| `leadtype/llm/readability` | Runtime helpers (`createAgentMarkdownResponse`, content negotiation). | +| `leadtype/search` | Static BM25 index + edge-safe runtime. | +| `leadtype/search/{node,vercel,tanstack,cloudflare,ai,bash}` | Host-runtime adapters for the search/answer pipeline. | +| `leadtype/lint` | Frontmatter, navigation, and link validation. | + +Core has **zero framework runtime dependencies**. It imports no React, Vue, Svelte, Next, Nuxt, SvelteKit, Astro, or Solid code. Anything published under one of the subpaths above must stay framework-neutral. + +### Host-runtime adapters (`leadtype/search/*`) + +The `leadtype/search/*` subpaths are scoped by **where the code runs** — Node, Vercel Edge, Cloudflare Workers, TanStack runtime, the bash tool — not by which UI framework wraps them. They cover the answer-streaming/search read path on their target runtime. + +### Framework adapters + +| Subpath | Host | What it exports | +| --- | --- | --- | +| `leadtype/fumadocs` | fumadocs-core | `fumadocsSource()` — maps `DocsSource` to fumadocs's `Source` interface. | +| `leadtype/next` | Next.js App Router (server) | `createGenerateStaticParams`, `createLoadPageData`, `createDocsRouteHandler`. No React. | +| `leadtype/next/client` | Next.js App Router (client) | `useLeadtypeSearch` React hook, `createSearchClient` vanilla factory. | + +Framework adapters are thin. They wire the core primitives into the host's native conventions and declare their host package as an **optional peer dependency**. A consumer that doesn't use fumadocs never installs fumadocs-core; a consumer that doesn't use Next never installs React. + +## Planned adapter shapes + +The boundary applies to every framework, including adapters that are not exported yet: + +| Future subpath | Native host shape | +| --- | --- | +| `leadtype/nuxt` | Nitro route helpers, build-time source wiring, and Vue composables. | +| `leadtype/sveltekit` | `+page.server.ts`, `+server.ts`, `entries()`, and Svelte stores/runes. | +| `leadtype/astro` | `getStaticPaths()`, endpoint helpers, Content Collections interop, and island-friendly client helpers. | +| `leadtype/tanstack-start` | TanStack Router route helpers, static route manifests, server functions, and React state helpers. | +| `leadtype/search/vue` | Vue composables over generated `search-index.json` and `search-content.json`. | +| `leadtype/search/svelte` | Svelte stores/runes over generated search artifacts. | + +These are future public surfaces, not hidden exports. Until they ship, use the recipes in [Use the source primitive](/docs/build/use-the-source-primitive), which wire the same core primitives into each framework directly. + +`leadtype/tanstack-start` is intentionally separate from `leadtype/search/tanstack`: the former would adapt docs routing and source data to TanStack Start, while the latter is the existing TanStack AI answer-streaming runtime. + +## Rules + +### Flat naming + +Adapters live under `leadtype/`. There is no `leadtype/adapters/*` nesting. Sub-runtimes (server vs. client, hook vs. handler) use a second segment: `leadtype/next`, `leadtype/next/client`. + +### No rendered DOM + +**No leadtype package — core or adapter — emits rendered DOM. Ever.** Adapters ship state primitives (hooks, composables, stores), route-handler factories, and pure data helpers. Anything that renders markup — ``, ``, ``, theme tokens, CSS — does not ship under the `leadtype` name. Consumers implement components against the tag contract from [`leadtype/mdx`](/docs/reference/mdx); leadtype supplies the data and the wiring. + +When reviewing a new adapter PR, ask: *does this return data, state, or a function, or does it return JSX?* If JSX, the PR doesn't ship. + +### Dependency rule + +An adapter at `leadtype/` may import: + +1. Other leadtype core entry points (`leadtype`, `leadtype/search`, `leadtype/llm/readability`, etc.). +2. Its **declared optional peer** (e.g. `leadtype/fumadocs` may import `fumadocs-core`; `leadtype/next/client` may import `react`). + +Adapters may **not** import from another adapter — `leadtype/next` never reaches into `leadtype/fumadocs`. They compose with core, not each other. + +### Enforcement + +The contract above is enforced by tests in `packages/leadtype/src/internal/package-surface.test.ts`. The suite scans every file under `src/`, fails if a framework runtime import leaks into a non-adapter directory, and fails if one adapter directory imports from another. + +## Adding a new framework adapter + +1. Pick the subpath: `leadtype/` (e.g. `leadtype/nuxt`, `leadtype/sveltekit`, `leadtype/astro`, `leadtype/tanstack-start`). Split into `` (server) and `/client` (or `/composables`, etc.) only when the framework has distinct server/client runtimes that share no peer dependency. +2. Declare the host package and any framework UI runtime (`vue`, `svelte`, `solid-js`) as **optional peer dependencies** in `packages/leadtype/package.json`. Mark each one optional in `peerDependenciesMeta`. +3. Add the entry to `rollup.config.ts` `entries` and to `package.json` `exports`. +4. Update the entry-point list in `package-surface.test.ts` so the test enforces the new subpath. +5. Export **state and routing primitives only** — no rendered DOM. +6. Add tests covering handler factories with synthetic `Request` objects and state primitives with a stub backing implementation. Don't require a running framework runtime in unit tests. diff --git a/packages/leadtype/README.md b/packages/leadtype/README.md index 1633ad4..dcfd220 100644 --- a/packages/leadtype/README.md +++ b/packages/leadtype/README.md @@ -61,6 +61,11 @@ Full docs at [leadtype.dev](https://leadtype.dev/docs). Highlights: | `leadtype/search/tanstack` | TanStack AI answer streaming. | | `leadtype/search/cloudflare` | Cloudflare AI Gateway / Workers AI adapter. | | `leadtype/lint` | `lintDocs` and the `leadtype lint` CLI. | +| `leadtype/fumadocs` | Adapter mapping `createDocsSource()` to fumadocs's `Source` interface. | +| `leadtype/next` | Next.js App Router server adapter — `createDocsRouteHandler`, `createGenerateStaticParams`, `createLoadPageData`. | +| `leadtype/next/client` | Next.js client hook — `useLeadtypeSearch` and the framework-free `createSearchClient`. | + +Framework adapters are thin and ship **state and routing primitives only** — no rendered DOM. See the [architecture reference](https://leadtype.dev/docs/reference/architecture) for the boundary contract and how to add more frameworks (`leadtype/nuxt`, `leadtype/sveltekit`, `leadtype/astro`, `leadtype/tanstack-start`, `leadtype/search/vue`, and `leadtype/search/svelte` are tracked under [#41](https://github.com/inthhq/leadtype/issues/41) / [#45](https://github.com/inthhq/leadtype/issues/45)). The `leadtype` binary wraps `generate` and `lint`. Use the library entry points when you need custom plugin order, base URL precedence, or alternate output paths. diff --git a/packages/leadtype/package.json b/packages/leadtype/package.json index 11244dd..5a880d8 100644 --- a/packages/leadtype/package.json +++ b/packages/leadtype/package.json @@ -37,6 +37,14 @@ "types": "./dist/i18n/index.d.ts", "import": "./dist/i18n/index.js" }, + "./next": { + "types": "./dist/next/index.d.ts", + "import": "./dist/next/index.js" + }, + "./next/client": { + "types": "./dist/next/client.d.ts", + "import": "./dist/next/client.js" + }, "./remark": { "types": "./dist/remark/index.d.ts", "import": "./dist/remark/index.js" @@ -126,9 +134,11 @@ "fumadocs-core": "^15.0.0", "jiti": "^2.7.0", "just-bash": "2.14.5", + "@types/react": "^19.0.0", "mdast-util-mdx": "3.0.0", "mdast-util-mdx-jsx": "3.2.0", "mdast-util-mdxjs-esm": "2.0.1", + "react": "^19.0.0", "rollup": "^4.40.0", "rollup-plugin-dts": "^6.2.1", "rollup-plugin-esbuild": "^6.2.1", @@ -143,6 +153,7 @@ "fumadocs-core": ">=15.0.0", "jiti": ">=2.0.0", "just-bash": ">=2.14.5", + "react": ">=18.0.0", "typescript": ">=5.0.0" }, "peerDependenciesMeta": { @@ -167,6 +178,9 @@ "just-bash": { "optional": true }, + "react": { + "optional": true + }, "typescript": { "optional": true } diff --git a/packages/leadtype/rollup.config.ts b/packages/leadtype/rollup.config.ts index 18cc111..35cdc53 100644 --- a/packages/leadtype/rollup.config.ts +++ b/packages/leadtype/rollup.config.ts @@ -8,6 +8,8 @@ const entries = { "mdx/index": "src/mdx/index.ts", "fumadocs/index": "src/fumadocs/index.ts", "i18n/index": "src/i18n/index.ts", + "next/index": "src/next/index.ts", + "next/client": "src/next/client.ts", "remark/index": "src/remark/index.ts", "convert/index": "src/convert/index.ts", "llm/index": "src/llm/index.ts", diff --git a/packages/leadtype/src/internal/package-surface.test.ts b/packages/leadtype/src/internal/package-surface.test.ts index 0f0227f..d770835 100644 --- a/packages/leadtype/src/internal/package-surface.test.ts +++ b/packages/leadtype/src/internal/package-surface.test.ts @@ -1,9 +1,65 @@ import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { glob } from "tinyglobby"; import { describe, expect, it } from "vitest"; import packageJson from "../../package.json"; const exportedPaths = Object.keys(packageJson.exports); +// Adapter directories are allowed to import from their declared optional peer. +// Everything else under `src/` must stay framework-neutral. +const ADAPTER_DIRECTORIES = ["fumadocs/", "next/"] as const; + +// Banned framework runtimes. Adapter directories may import their matching +// peer (e.g. `next/`'s adapter may import from `react`); core code may not. +const FRAMEWORK_RUNTIME_IMPORTS = [ + "react", + "react-dom", + "next/", + "next$", + "nuxt/", + "nuxt$", + "@nuxt/", + "vue", + "@vue/", + "svelte", + "@sveltejs/", + "astro", + "solid-js", + "@solidjs/", +] as const; + +const IMPORT_PATTERN = + /(?:^|\n)\s*(?:import|export)(?:\s+type)?\s+(?:[^"']+from\s+)?["']([^"']+)["']/g; +const MODULE_CALL_PATTERN = /\b(?:import|require)\(\s*["']([^"']+)["']\s*\)/g; + +function readSrc(relativePath: string): string { + return readFileSync(new URL(`../${relativePath}`, import.meta.url), "utf8"); +} + +function extractImportSpecifiers(source: string): string[] { + const specifiers: string[] = []; + for (const pattern of [IMPORT_PATTERN, MODULE_CALL_PATTERN]) { + for (const match of source.matchAll(pattern)) { + if (match[1]) { + specifiers.push(match[1]); + } + } + } + return specifiers; +} + +function matchesBannedSpecifier(specifier: string, banned: string): boolean { + if (banned.endsWith("/")) { + return specifier === banned.slice(0, -1) || specifier.startsWith(banned); + } + if (banned.endsWith("$")) { + return specifier === banned.slice(0, -1); + } + return specifier === banned || specifier.startsWith(`${banned}/`); +} + describe("package surface", () => { it("matches the documented entry-point list", () => { const expectedExportedPaths = [ @@ -11,6 +67,8 @@ describe("package surface", () => { "./mdx", "./fumadocs", "./i18n", + "./next", + "./next/client", "./remark", "./convert", "./llm", @@ -29,17 +87,19 @@ describe("package surface", () => { expect(new Set(exportedPaths)).toEqual(new Set(expectedExportedPaths)); }); - it("does not expose framework-specific runtime component adapters", () => { + it("does not expose framework-specific runtime UI component adapters", () => { + // Leadtype ships state primitives (hooks, composables, stores) under + // framework subpaths but never rendered DOM. The framework subpath itself + // (./next, ./fumadocs) is allowed; bare `./react`, `./vue`, `./svelte` + // entries would imply UI components. expect(exportedPaths).not.toContain("./react"); expect(exportedPaths).not.toContain("./vue"); expect(exportedPaths).not.toContain("./svelte"); + expect(exportedPaths).not.toContain("./solid"); }); it("keeps optional TypeScript loading out of the remark entry import path", () => { - const typeTableSource = readFileSync( - new URL("../remark/plugins/type-table.remark.ts", import.meta.url), - "utf8" - ); + const typeTableSource = readSrc("remark/plugins/type-table.remark.ts"); expect(typeTableSource).not.toContain('import * as ts from "typescript"'); expect(typeTableSource).toContain('import type * as ts from "typescript"'); @@ -47,14 +107,14 @@ describe("package surface", () => { it("keeps provider answer subpaths free of bash adapters", () => { const providerEntryPaths = [ - "../search/ai-index.ts", - "../search/cloudflare-index.ts", - "../search/tanstack-index.ts", - "../search/vercel-index.ts", + "search/ai-index.ts", + "search/cloudflare-index.ts", + "search/tanstack-index.ts", + "search/vercel-index.ts", ] as const; for (const entryPath of providerEntryPaths) { - const source = readFileSync(new URL(entryPath, import.meta.url), "utf8"); + const source = readSrc(entryPath); expect(source).not.toContain("vercel-bash"); expect(source).not.toContain("tanstack-bash"); expect(source).not.toContain("docs-bash"); @@ -62,3 +122,92 @@ describe("package surface", () => { } }); }); + +describe("core/adapter boundary", () => { + // Lazily resolved so the test files themselves can be skipped from the scan. + const srcRoot = fileURLToPath(new URL("../", import.meta.url)); + + async function listSourceFiles(): Promise { + const matches = await glob("**/*.ts", { + cwd: srcRoot, + onlyFiles: true, + absolute: true, + }); + return matches.filter((file) => !file.endsWith(".test.ts")); + } + + function relative(file: string): string { + return path.relative(srcRoot, file); + } + + function isAdapterFile(relativePath: string): boolean { + return ADAPTER_DIRECTORIES.some((dir) => relativePath.startsWith(dir)); + } + + it("does not let framework runtimes leak into core modules", async () => { + const files = await listSourceFiles(); + const violations: Array<{ file: string; specifier: string }> = []; + + for (const file of files) { + const relativePath = relative(file); + if (isAdapterFile(relativePath)) { + continue; + } + const source = readFileSync(file, "utf8"); + for (const specifier of extractImportSpecifiers(source)) { + for (const banned of FRAMEWORK_RUNTIME_IMPORTS) { + if (matchesBannedSpecifier(specifier, banned)) { + violations.push({ file: relativePath, specifier }); + } + } + } + } + + expect(violations).toEqual([]); + }); + + it("keeps adapter directories from importing each other", async () => { + const files = await listSourceFiles(); + const violations: Array<{ file: string; specifier: string }> = []; + + for (const file of files) { + const relativePath = relative(file); + if (!isAdapterFile(relativePath)) { + continue; + } + const ownDir = ADAPTER_DIRECTORIES.find((dir) => + relativePath.startsWith(dir) + ); + const source = readFileSync(file, "utf8"); + for (const specifier of extractImportSpecifiers(source)) { + if (!specifier.startsWith(".")) { + continue; + } + // Resolve relative imports to a src-rooted path. Adapter files may + // only walk into core (`../source`, `../search/search`) — never into + // a sibling adapter directory. + const resolved = path + .relative(srcRoot, path.resolve(path.dirname(file), specifier)) + .replaceAll(path.sep, "/"); + for (const other of ADAPTER_DIRECTORIES) { + if (other === ownDir) { + continue; + } + if (resolved.startsWith(other) || resolved === other.slice(0, -1)) { + violations.push({ file: relativePath, specifier }); + } + } + } + } + + expect(violations).toEqual([]); + }); + + it("declares no framework runtimes in `dependencies`", () => { + const deps = Object.keys(packageJson.dependencies); + for (const banned of FRAMEWORK_RUNTIME_IMPORTS) { + const baseName = banned.replace(/[/$]$/, ""); + expect(deps).not.toContain(baseName); + } + }); +}); diff --git a/packages/leadtype/src/next/client.test.ts b/packages/leadtype/src/next/client.test.ts new file mode 100644 index 0000000..8dd3cd0 --- /dev/null +++ b/packages/leadtype/src/next/client.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + createDocsSearchIndex, + type DocsSearchBundle, + type DocsSearchDocument, +} from "../search/search"; +import { createSearchClient } from "./client"; + +function buildSearchBundle(): DocsSearchBundle { + const docs: DocsSearchDocument[] = [ + { + id: "/docs/quickstart", + title: "Quickstart", + description: "Five-minute happy path.", + urlPath: "/docs/quickstart", + absoluteUrl: "https://example.com/docs/quickstart", + relativePath: "quickstart", + content: "# Quickstart\n\nBuild your first leadtype docs site here.", + }, + { + id: "/docs/install", + title: "Install", + description: "Install the package.", + urlPath: "/docs/install", + absoluteUrl: "https://example.com/docs/install", + relativePath: "install", + content: "# Install\n\nUse bun add leadtype to install the package.", + }, + ]; + const index = createDocsSearchIndex(docs); + return { + index, + content: index.content ?? { + version: index.version, + generatedAt: index.generatedAt, + chunks: [], + }, + }; +} + +// Module-level cache in client.ts persists across tests; reset by passing +// distinct URLs per test case so cache keys don't collide. +let testCounter = 0; +function nextScope(): { + indexUrl: string; + contentUrl: string; +} { + testCounter += 1; + return { + indexUrl: `/test-${testCounter}/search-index.json`, + contentUrl: `/test-${testCounter}/search-content.json`, + }; +} + +describe("createSearchClient", () => { + let originalFetch: typeof globalThis.fetch | undefined; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + if (originalFetch) { + globalThis.fetch = originalFetch; + } + }); + + it("returns no results for empty queries without fetching", async () => { + let calls = 0; + const fetchImpl = () => { + calls += 1; + return Promise.resolve(new Response("{}")); + }; + const client = createSearchClient("docs", { + ...nextScope(), + fetch: fetchImpl as typeof fetch, + }); + expect(await client.search("")).toEqual([]); + expect(await client.search(" ")).toEqual([]); + expect(calls).toBe(0); + }); + + it("loads artifacts and returns BM25 results", async () => { + const bundle = buildSearchBundle(); + const scope = nextScope(); + const fetchImpl = ((input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.endsWith(scope.indexUrl)) { + return Promise.resolve(Response.json(bundle.index)); + } + if (url.endsWith(scope.contentUrl)) { + return Promise.resolve(Response.json(bundle.content)); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) as typeof fetch; + + const client = createSearchClient("docs", { + indexUrl: scope.indexUrl, + contentUrl: scope.contentUrl, + fetch: fetchImpl, + }); + const results = await client.search("install"); + expect(results.length).toBeGreaterThan(0); + expect(results[0]?.title).toBe("Install"); + }); + + it("treats a missing content file as a soft failure", async () => { + const bundle = buildSearchBundle(); + const scope = nextScope(); + const fetchImpl = ((input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.endsWith(scope.indexUrl)) { + return Promise.resolve(Response.json(bundle.index)); + } + // Content file 404s — BM25 index should still work. + return Promise.resolve(new Response(null, { status: 404 })); + }) as typeof fetch; + + const client = createSearchClient("docs", { + indexUrl: scope.indexUrl, + contentUrl: scope.contentUrl, + fetch: fetchImpl, + }); + const results = await client.search("quickstart"); + expect(results.length).toBeGreaterThan(0); + }); + + it("surfaces an error when the index fetch fails", async () => { + const scope = nextScope(); + const fetchImpl = (() => + Promise.resolve( + new Response(null, { status: 500, statusText: "Server Error" }) + )) as typeof fetch; + const client = createSearchClient("docs", { + ...scope, + fetch: fetchImpl, + }); + await expect(client.search("hello")).rejects.toThrow( + /failed to fetch.*500/ + ); + }); + + it("preload() prefetches without running a query", async () => { + const bundle = buildSearchBundle(); + const scope = nextScope(); + let indexHits = 0; + const fetchImpl = ((input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.endsWith(scope.indexUrl)) { + indexHits += 1; + return Promise.resolve(Response.json(bundle.index)); + } + return Promise.resolve(Response.json(bundle.content)); + }) as typeof fetch; + const client = createSearchClient("docs", { + ...scope, + fetch: fetchImpl, + }); + await client.preload(); + expect(indexHits).toBe(1); + // Subsequent search() shouldn't refetch the index — module-level cache. + await client.search("install"); + expect(indexHits).toBe(1); + }); +}); diff --git a/packages/leadtype/src/next/client.ts b/packages/leadtype/src/next/client.ts new file mode 100644 index 0000000..c767728 --- /dev/null +++ b/packages/leadtype/src/next/client.ts @@ -0,0 +1,317 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + DocsSearchContentStore, + DocsSearchIndex, + DocsSearchResult, +} from "../search/search"; +import { searchDocs } from "../search/search"; + +export type { DocsSearchResult } from "../search/search"; + +/** + * Options shared by the vanilla search client and React search hook. + */ +export type SearchClientOptions = { + /** + * URL for the generated search index JSON. + * + * @defaultValue `/${collection}/search-index.json` + */ + indexUrl?: string; + + /** + * URL for the generated search content JSON. + * + * @defaultValue `/${collection}/search-content.json` + */ + contentUrl?: string; + + /** + * Maximum number of search results returned per query. + */ + limit?: number; + + /** + * Fetch implementation used to load generated artifacts. + * + * @defaultValue `globalThis.fetch` + */ + fetch?: typeof fetch; +}; + +/** + * Framework-neutral search client over generated Leadtype search artifacts. + */ +export type SearchClient = { + /** + * Run a search query. + * + * @remarks + * The first non-empty query loads the generated artifacts. Subsequent calls + * reuse the module-level artifact cache for the same index/content URLs. + */ + search(query: string): Promise; + + /** + * Load generated search artifacts before the first query. + */ + preload(): Promise; +}; + +type LoadedArtifacts = { + index: DocsSearchIndex; + content: DocsSearchContentStore | undefined; +}; + +const artifactCache = new Map>(); + +function cacheKey(indexUrl: string, contentUrl: string): string { + return `${indexUrl}|${contentUrl}`; +} + +async function fetchJson(url: string, fetchImpl: typeof fetch): Promise { + const response = await fetchImpl(url); + if (!response.ok) { + throw new Error( + `leadtype/next/client: failed to fetch ${url} (${response.status} ${response.statusText})` + ); + } + return (await response.json()) as T; +} + +async function loadArtifacts( + indexUrl: string, + contentUrl: string, + fetchImpl: typeof fetch +): Promise { + const key = cacheKey(indexUrl, contentUrl); + const cached = artifactCache.get(key); + if (cached) { + return await cached; + } + const promise = (async () => { + const [index, content] = await Promise.all([ + fetchJson(indexUrl, fetchImpl), + // The content file is optional — the BM25 index runs without it, only + // excerpts go missing. Treat a 404 as "no content store" rather than + // failing the whole search. + fetchJson(contentUrl, fetchImpl).catch( + () => undefined + ), + ]); + return { index, content }; + })(); + artifactCache.set(key, promise); + try { + return await promise; + } catch (error) { + artifactCache.delete(key); + throw error; + } +} + +/** + * Build a framework-free search client. Use directly from a worker, plain + * script, or web component. Reused by `useLeadtypeSearch` so the BM25 path + * has a single implementation. + * + * @param collection - Collection or URL prefix used for default artifact URLs. + * @param options - Search artifact URLs, result limit, and fetch override. + * + * @example + * ```ts + * const client = createSearchClient("docs"); + * const results = await client.search("install"); + * ``` + */ +export function createSearchClient( + collection: string, + options: SearchClientOptions = {} +): SearchClient { + const indexUrl = options.indexUrl ?? `/${collection}/search-index.json`; + const contentUrl = options.contentUrl ?? `/${collection}/search-content.json`; + const fetchImpl = options.fetch ?? globalThis.fetch; + if (!fetchImpl) { + throw new Error( + "leadtype/next/client: no fetch implementation available. Pass `options.fetch` when running in an environment without globalThis.fetch." + ); + } + return { + async search(query) { + const trimmed = query.trim(); + if (trimmed.length === 0) { + return []; + } + const { index, content } = await loadArtifacts( + indexUrl, + contentUrl, + fetchImpl + ); + return searchDocs(index, trimmed, { + limit: options.limit, + content, + }); + }, + async preload() { + await loadArtifacts(indexUrl, contentUrl, fetchImpl); + }, + }; +} + +export type UseLeadtypeSearchOptions = SearchClientOptions & { + /** + * Delay in milliseconds between `search()` calls and BM25 execution. + * + * @defaultValue `120` + */ + debounceMs?: number; +}; + +/** + * State returned by {@link useLeadtypeSearch}. + */ +export type UseLeadtypeSearchReturn = { + /** + * Current query string. + */ + query: string; + + /** + * Update the query and schedule a debounced search. + */ + search(query: string): void; + + /** + * Latest search results. + */ + results: DocsSearchResult[]; + + /** + * Search lifecycle state. + */ + status: "idle" | "loading" | "ready" | "error"; + + /** + * Most recent loading or search error. + */ + error: Error | null; +}; + +const DEFAULT_DEBOUNCE_MS = 120; + +/** + * React hook returning a debounced search state object. + * + * Lazy-loads the search artifacts on first non-empty query and caches them in + * a module-level map so route changes and remounts don't refetch. Pass + * `options.indexUrl` / `options.contentUrl` to override the default URLs. + * + * @param collection - Collection or URL prefix used for default artifact URLs. + * @param options - Search artifact URLs, result limit, fetch override, and debounce. + * + * @example + * ```tsx + * "use client"; + * import { useLeadtypeSearch } from "leadtype/next/client"; + * + * export function DocsSearch() { + * const { query, search, results, status } = useLeadtypeSearch("docs"); + * return ( + * <> + * search(event.target.value)} /> + * {status === "loading" ? "Loading..." : null} + *
    {results.map((result) =>
  • {result.title}
  • )}
+ * + * ); + * } + * ``` + */ +export function useLeadtypeSearch( + collection: string, + options: UseLeadtypeSearchOptions = {} +): UseLeadtypeSearchReturn { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [status, setStatus] = + useState("idle"); + const [error, setError] = useState(null); + + // Stash the client in a ref so re-renders don't rebuild it. Recompute when + // the URLs change so consumers can swap collections at runtime if needed. + const clientRef = useRef(null); + const indexUrl = options.indexUrl ?? `/${collection}/search-index.json`; + const contentUrl = options.contentUrl ?? `/${collection}/search-content.json`; + const fetchImpl = options.fetch; + const limit = options.limit; + const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS; + + useEffect(() => { + clientRef.current = createSearchClient(collection, { + indexUrl, + contentUrl, + fetch: fetchImpl, + limit, + }); + }, [collection, indexUrl, contentUrl, fetchImpl, limit]); + + // Track the most recent query so debounced callbacks for stale queries + // discard their results instead of overwriting the latest state. + const latestQueryRef = useRef(""); + const timeoutRef = useRef | null>(null); + + useEffect( + () => () => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + }, + [] + ); + + const search = useCallback( + (next: string) => { + setQuery(next); + latestQueryRef.current = next; + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + const trimmed = next.trim(); + if (trimmed.length === 0) { + setResults([]); + setStatus("idle"); + setError(null); + return; + } + setStatus("loading"); + timeoutRef.current = setTimeout(() => { + const client = clientRef.current; + if (!client) { + return; + } + client + .search(trimmed) + .then((next_results) => { + if (latestQueryRef.current !== next) { + return; + } + setResults(next_results); + setStatus("ready"); + setError(null); + }) + .catch((cause: unknown) => { + if (latestQueryRef.current !== next) { + return; + } + setResults([]); + setStatus("error"); + setError(cause instanceof Error ? cause : new Error(String(cause))); + }); + }, debounceMs); + }, + [debounceMs] + ); + + return { query, search, results, status, error }; +} diff --git a/packages/leadtype/src/next/index.ts b/packages/leadtype/src/next/index.ts new file mode 100644 index 0000000..82ba2a3 --- /dev/null +++ b/packages/leadtype/src/next/index.ts @@ -0,0 +1,180 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import type { + AgentReadabilityManifest, + MarkdownMirrorTarget, +} from "../llm/readability"; +import { createAgentMarkdownResponse } from "../llm/readability"; +import type { DocsPage, DocsSource } from "../source"; + +export type { + AgentReadabilityManifest, + MarkdownMirrorTarget, +} from "../llm/readability"; +export type { DocsPage, DocsSource } from "../source"; + +/** + * Configuration for {@link createGenerateStaticParams}. + */ +export type CreateGenerateStaticParamsConfig = { + /** + * Framework-neutral docs source used to enumerate all known pages. + */ + source: DocsSource; +}; + +/** + * Configuration for {@link createLoadPageData}. + */ +export type CreateLoadPageDataConfig = { + /** + * Framework-neutral docs source used to resolve route slugs. + */ + source: DocsSource; +}; + +/** + * Configuration for {@link createDocsRouteHandler}. + */ +export type CreateDocsRouteHandlerConfig = { + /** + * Agent Readability manifest emitted by `leadtype generate`. + */ + manifest: AgentReadabilityManifest; + + /** + * Directory where `leadtype generate` wrote public artifacts. + * + * @defaultValue `"./public"` + */ + publicDir?: string; + + /** + * Cache-Control header for markdown responses. + * + * Pass `null` to omit the header. + */ + cacheControl?: string | null; + + /** + * Custom markdown reader for a resolved generated markdown target. + * + * @remarks + * Defaults to reading `/` with `node:fs`. + * Override this when serving from a CDN, KV store, in-memory map, or other + * non-filesystem artifact source. + */ + readMarkdownFile?: ( + target: MarkdownMirrorTarget + ) => string | null | undefined | Promise; +}; + +/** + * Build the function Next's App Router expects from `generateStaticParams`. + * + * @example + * ```ts + * export const generateStaticParams = createGenerateStaticParams({ source }); + * ``` + */ +export function createGenerateStaticParams( + config: CreateGenerateStaticParamsConfig +): () => Promise> { + return async () => { + const pages = await config.source.listPages(); + return pages.map((page) => ({ slug: page.slug })); + }; +} + +/** + * Build a page-data loader for a Next server component or `generateMetadata`. + * + * @returns A loader that returns `null` for unknown slugs so callers can use + * Next's `notFound()`. + * + * @example + * ```ts + * const loadPageData = createLoadPageData({ source }); + * const page = await loadPageData(slug); + * ``` + */ +export function createLoadPageData( + config: CreateLoadPageDataConfig +): (slug: string[] | undefined) => Promise { + return async (slug) => await config.source.loadPage(slug ?? []); +} + +function isMissingFileError(error: unknown): boolean { + if (typeof error !== "object" || error === null || !("code" in error)) { + return false; + } + const code = (error as { code?: unknown }).code; + return code === "ENOENT" || code === "ENOTDIR"; +} + +function createDefaultMarkdownReader( + publicDir: string +): (target: MarkdownMirrorTarget) => Promise { + const resolvedPublicDir = path.resolve(publicDir); + return async (target) => { + // `target.filePath` is derived from a path that has already been guarded + // against `..` segments by `resolveMarkdownMirrorTarget`. Resolve once + // more and reject anything that escapes `publicDir` — defense in depth in + // case a future caller passes a hand-built target. + const candidate = path.resolve(resolvedPublicDir, target.filePath); + const relative = path.relative(resolvedPublicDir, candidate); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return null; + } + try { + return await readFile(candidate, "utf8"); + } catch (error) { + if (isMissingFileError(error)) { + return null; + } + throw error; + } + }; +} + +/** + * Build a Next App Router route handler that serves raw markdown for docs + * pages and handles content negotiation (Accept: text/markdown, AI user + * agents, explicit `.md` URLs). + * + * @remarks + * Place the generated handler in a route segment that does not also define a + * `page.tsx`. It returns markdown when the request is agent-readable and a 404 + * response otherwise. + * + * @example + * ```ts + * import { createDocsRouteHandler } from "leadtype/next"; + * import manifest from "@/generated/agent-readability.json" with { type: "json" }; + * + * export const GET = createDocsRouteHandler({ + * manifest: { ...manifest, version: 1 } as const, + * }); + * ``` + */ +export function createDocsRouteHandler( + config: CreateDocsRouteHandlerConfig +): (request: Request) => Promise { + const publicDir = config.publicDir ?? "./public"; + const readMarkdownFile = + config.readMarkdownFile ?? createDefaultMarkdownReader(publicDir); + return async (request) => { + const url = new URL(request.url); + const headers = Object.fromEntries(request.headers); + const response = await createAgentMarkdownResponse({ + urlPath: url.pathname, + method: request.method, + headers, + manifest: config.manifest, + readMarkdownFile, + requestOrigin: url.origin, + cacheControl: config.cacheControl, + }); + return response ?? new Response(null, { status: 404 }); + }; +} diff --git a/packages/leadtype/src/next/next.test.ts b/packages/leadtype/src/next/next.test.ts new file mode 100644 index 0000000..f09bdca --- /dev/null +++ b/packages/leadtype/src/next/next.test.ts @@ -0,0 +1,200 @@ +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { AgentReadabilityManifest } from "../llm/readability"; +import { + createDocsRouteHandler, + createGenerateStaticParams, + createLoadPageData, +} from "./index"; + +function buildManifest(): AgentReadabilityManifest { + return { + version: 1, + generatedAt: "2026-05-13T00:00:00.000Z", + baseUrl: "https://example.com", + product: { name: "Test", summary: "" }, + pages: [ + { + title: "Getting Started", + description: "", + urlPath: "/docs/getting-started", + absoluteUrl: "https://example.com/docs/getting-started", + markdownUrlPath: "/docs/getting-started.md", + markdownAbsoluteUrl: "https://example.com/docs/getting-started.md", + relativePath: "getting-started", + groups: [], + lastModified: "2026-05-13T00:00:00.000Z", + }, + ], + navigation: { groups: [], ungrouped: [], unknown: [] }, + files: { + robotsTxt: "robots.txt", + sitemapMd: "sitemap.md", + sitemapXml: "sitemap.xml", + }, + }; +} + +describe("createDocsRouteHandler", () => { + let publicDir: string; + + beforeEach(async () => { + publicDir = await mkdtemp(path.join(tmpdir(), "leadtype-next-")); + await mkdir(path.join(publicDir, "docs"), { recursive: true }); + await writeFile( + path.join(publicDir, "docs", "getting-started.md"), + "# Getting Started\n\nHello from markdown.\n", + "utf8" + ); + }); + + afterEach(async () => { + // Tmp dirs from `mkdtemp` are auto-collected on macOS; rely on that to + // avoid platform-specific cleanup logic in tests. + }); + + it("serves markdown for an explicit .md request", async () => { + const handler = createDocsRouteHandler({ + manifest: buildManifest(), + publicDir, + }); + const response = await handler( + new Request("https://example.com/docs/getting-started.md") + ); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toContain("text/markdown"); + const body = await response.text(); + expect(body).toContain("Getting Started"); + expect(body).toContain("Hello from markdown."); + }); + + it("serves markdown when Accept: text/markdown is set", async () => { + const handler = createDocsRouteHandler({ + manifest: buildManifest(), + publicDir, + }); + const response = await handler( + new Request("https://example.com/docs/getting-started", { + headers: { Accept: "text/markdown" }, + }) + ); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toContain("text/markdown"); + }); + + it("returns 404 for unknown HTML paths", async () => { + const handler = createDocsRouteHandler({ + manifest: buildManifest(), + publicDir, + }); + const response = await handler( + new Request("https://example.com/docs/nope", { + headers: { Accept: "text/html" }, + }) + ); + expect(response.status).toBe(404); + }); + + it("falls back to the missing-markdown body for unknown .md paths", async () => { + const handler = createDocsRouteHandler({ + manifest: buildManifest(), + publicDir, + }); + const response = await handler( + new Request("https://example.com/docs/nope.md") + ); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toContain("text/markdown"); + const body = await response.text(); + expect(body).toContain("Page not found"); + }); + + it("routes through a custom readMarkdownFile when provided", async () => { + const handler = createDocsRouteHandler({ + manifest: buildManifest(), + readMarkdownFile: () => "custom body", + }); + const response = await handler( + new Request("https://example.com/docs/getting-started.md") + ); + expect(await response.text()).toContain("custom body"); + }); +}); + +describe("createGenerateStaticParams / createLoadPageData", () => { + it("returns the slug list and resolves pages", async () => { + const stubSource = { + contentDir: "/stub", + listPages: async () => [ + { + slug: ["a"], + urlPath: "/docs/a", + relativePath: "a", + extension: ".mdx" as const, + filePath: "/stub/a.mdx", + title: "A", + description: "", + groups: [], + }, + { + slug: ["b", "c"], + urlPath: "/docs/b/c", + relativePath: "b/c", + extension: ".md" as const, + filePath: "/stub/b/c.md", + title: "C", + description: "", + groups: [], + }, + ], + loadPage: async (slug: string | string[]) => { + const key = Array.isArray(slug) ? slug.join("/") : slug; + if (key === "a") { + return { + slug: ["a"], + urlPath: "/docs/a", + relativePath: "a", + extension: ".mdx" as const, + filePath: "/stub/a.mdx", + title: "A", + description: "", + groups: [], + frontmatter: {}, + markdown: "# A", + ast: { type: "root", children: [] } as never, + toc: [], + }; + } + return null; + }, + getNavigation: async () => ({ + groups: [], + ungrouped: [], + unknown: [], + }), + buildSearchIndex: () => { + throw new Error("not used"); + }, + resolveInclude: () => { + throw new Error("not used"); + }, + } as never; + + const generateStaticParams = createGenerateStaticParams({ + source: stubSource, + }); + expect(await generateStaticParams()).toEqual([ + { slug: ["a"] }, + { slug: ["b", "c"] }, + ]); + + const loadPageData = createLoadPageData({ source: stubSource }); + const page = await loadPageData(["a"]); + expect(page?.title).toBe("A"); + expect(await loadPageData(["z"])).toBeNull(); + // Falsy slug should also resolve to null (root catch-all without segments). + expect(await loadPageData(undefined)).toBeNull(); + }); +});