diff --git a/.changeset/shared-next-fragments.md b/.changeset/shared-next-fragments.md
new file mode 100644
index 00000000..b6730450
--- /dev/null
+++ b/.changeset/shared-next-fragments.md
@@ -0,0 +1,8 @@
+---
+'@pmndrs/docs': patch
+---
+
+Render HTML fragments through the same Next.js build and static export as the website.
+Fragments retain their output without layout, stylesheets, or scripts, including when read
+from stdin or a single file. These inputs now run a full Next.js build, with build logs sent
+to stderr so stdout remains HTML only.
diff --git a/README.md b/README.md
index 839eba34..795c476b 100644
--- a/README.md
+++ b/README.md
@@ -39,10 +39,14 @@ $ npx @pmndrs/docs@latest build docs out # one .html per .mdx
$ npx @pmndrs/docs@latest build docs out --format website # the whole website, statically exported
```
-`--format fragment` (the default) runs in Node without a Next build. A fragment is the
-compiled MDX and nothing else: no layout, no stylesheet, no script. Mermaid
+Both formats use the same Next.js build and static export. `--format fragment` (the default)
+extracts the compiled MDX from that export: no layout, no stylesheet, no script. Mermaid
diagrams stay fenced blocks, and Sandpack shows its code without the editor.
+Rendering a document runs `next build`, including a single file or stdin. For those inputs,
+stdout contains only the HTML; build logs go to stderr. esbuild is used only to package the
+CLI for publication, not to load consumer components or render documents.
+
`build --help` lists every website option — `--libname`, `--base-path`, `--icon`, `--theme-*`… Each
one falls back to the environment variable it maps to, the same ones
[configuration](docs/getting-started/introduction.mdx#configuration) documents.
diff --git a/docs/getting-started/introduction.mdx b/docs/getting-started/introduction.mdx
index ca392768..48329d55 100644
--- a/docs/getting-started/introduction.mdx
+++ b/docs/getting-started/introduction.mdx
@@ -306,6 +306,19 @@ Then go to: http://localhost:3000
`npx @pmndrs/docs@latest build --help` lists the CLI flags, including `--config` for a configuration outside the repository root.
+Both `--format website` and `--format fragment` use the same Next.js build and static export.
+Fragments contain only the compiled document: no layout, stylesheet or script. The CLI
+copies assets alongside folder fragments and preserves static Mermaid and Sandpack content.
+
+```sh
+$ npx @pmndrs/docs@latest build page.mdx
+$ cat page.mdx | npx @pmndrs/docs@latest build
+```
+
+A single file or stdin writes HTML to stdout and build logs to stderr. These inputs also
+run a full Next.js build. Consumer components and data use the same `docs.config.ts` as
+the website.
+
## Agents
`llms.txt` dumps and the pmndrs MCP server moved to their own page: [Agents](/agents/introduction).
diff --git a/eslint.config.mjs b/eslint.config.mjs
index b840a955..b5c924ce 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -10,8 +10,7 @@ const eslintConfig = [
'react/jsx-no-target-blank': 0,
'import/no-anonymous-default-export': 0,
'react-hooks/set-state-in-effect': 0,
- // `next/image` buys nothing here: `images.unoptimized` is set in next.config.mts,
- // and the CLI renders these components outside Next, where next/image throws.
+ // Images are emitted unchanged: `images.unoptimized` is set in next.config.mts.
'@next/next/no-img-element': 0,
},
},
diff --git a/package.json b/package.json
index 35301614..38d4c121 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"d3-sankey": "^0.12.3",
"d3-selection": "^3.0.0",
"d3-transition": "^3.0.1",
+ "esbuild": "^0.28.2",
"eslint": "^9.39.5",
"eslint-config-next": "^16.4.0",
"eslint-plugin-storybook": "^10.6.2",
@@ -62,7 +63,6 @@
"cmdk": "^1.1.1",
"cn": "^0.4.0",
"commander": "^15.0.0",
- "esbuild": "^0.28.2",
"gray-matter": "^4.0.3",
"image-size": "^2.0.4",
"ink": "^8.0.0",
@@ -138,6 +138,7 @@
"postcss.config.mjs",
"tsconfig.json",
"!src/stories",
+ "!src/test-utils.ts",
"!src/app/api",
"!src/app/mcp/live",
"!**/*.test.*",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cde8c512..f9fc309e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -62,9 +62,6 @@ importers:
commander:
specifier: ^15.0.0
version: 15.0.0
- esbuild:
- specifier: ^0.28.2
- version: 0.28.2
gray-matter:
specifier: ^4.0.3
version: 4.0.3
@@ -198,6 +195,9 @@ importers:
d3-transition:
specifier: ^3.0.1
version: 3.0.1(d3-selection@3.0.0)
+ esbuild:
+ specifier: ^0.28.2
+ version: 0.28.2
eslint:
specifier: ^9.39.5
version: 9.39.5(jiti@2.7.0)
diff --git a/scripts/build-cli.mjs b/scripts/build-cli.mjs
index 068b1ca9..eda58d05 100644
--- a/scripts/build-cli.mjs
+++ b/scripts/build-cli.mjs
@@ -1,10 +1,7 @@
-// Bundles the CLI so that `node` can run what only Next could run: TSX, the `@/` paths of
-// tsconfig, and the CSS and image imports our components make.
+// Bundles the CLI and its Ink terminal UI. Next handles all MDX rendering, CSS and images.
import * as esbuild from 'esbuild'
-import { imageSize } from 'image-size'
-import { readFile } from 'node:fs/promises'
-import { dirname, extname, resolve } from 'node:path'
+import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
@@ -28,45 +25,6 @@ const tsconfigPaths = {
},
}
-/** Stylesheets are the website's business; a fragment carries none. */
-const stylesheets = {
- name: 'stylesheets',
- setup(build) {
- build.onLoad({ filter: /\.css$/ }, () => ({ contents: '', loader: 'js' }))
- },
-}
-
-const MIME = {
- '.svg': 'image/svg+xml',
- '.png': 'image/png',
- '.jpg': 'image/jpeg',
- '.jpeg': 'image/jpeg',
- '.gif': 'image/gif',
- '.webp': 'image/webp',
- '.avif': 'image/avif',
-}
-
-/**
- * Static image imports, in the shape Next gives them — `{ src, width, height }` — so that
- * components need not know which of the two compiled them. `src` is a data URI, since a
- * fragment travels alone.
- */
-const staticImages = {
- name: 'static-images',
- setup(build) {
- build.onLoad({ filter: /\.(svg|png|jpe?g|gif|webp|avif)$/ }, async (args) => {
- const data = await readFile(args.path)
- const { width, height } = imageSize(data)
- const src = `data:${MIME[extname(args.path)]};base64,${data.toString('base64')}`
-
- return {
- contents: `export default ${JSON.stringify({ src, width, height })}`,
- loader: 'js',
- }
- })
- },
-}
-
await esbuild.build({
entryPoints: [resolve(root, 'src/cli/main.ts')],
outfile: resolve(root, 'dist/cli.mjs'),
@@ -76,6 +34,6 @@ await esbuild.build({
target: 'node22.12',
packages: 'external',
jsx: 'automatic',
- plugins: [tsconfigPaths, stylesheets, staticImages],
+ plugins: [tsconfigPaths],
logLevel: 'info',
})
diff --git a/src/cli/fragments.test.ts b/src/cli/fragments.test.ts
new file mode 100644
index 00000000..8b1f804c
--- /dev/null
+++ b/src/cli/fragments.test.ts
@@ -0,0 +1,297 @@
+import { load } from 'cheerio'
+import { execFile } from 'node:child_process'
+import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { promisify } from 'node:util'
+import { afterEach, expect, test } from 'vitest'
+import { buildFragments, extractFragment } from './fragments'
+
+const run = promisify(execFile)
+const packageRoot = fileURLToPath(new URL('../../', import.meta.url))
+const temporaryDirectories: string[] = []
+
+async function fixture(inPackage = false) {
+ const parent = inPackage ? join(packageRoot, '.pmndrs-docs') : tmpdir()
+ await mkdir(parent, { recursive: true })
+ const root = await mkdtemp(join(parent, 'pmndrs-fragments-test-'))
+ temporaryDirectories.push(root)
+ return root
+}
+
+afterEach(async () => {
+ await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true })))
+})
+
+test('extracts only the document body, removing styles and scripts inside it', async () => {
+ const html = await extractFragment(
+ `
Website
+ `,
+ '/unused',
+ )
+
+ const $ = load(html!, {}, false)
+ expect($('h1').text()).toBe('Document')
+ expect($('[data-content="body"]').length).toBe(1)
+ expect($('html, head, body, script, style, link, [data-pmndrs-fragment]').length).toBe(0)
+ expect(html).not.toContain('_next')
+})
+
+test('ignores Next pages that do not contain a fragment wrapper', async () => {
+ expect(await extractFragment('Not found', '/unused')).toBeNull()
+})
+
+test('embeds exported images with a base path and preserves ordinary image URLs', async () => {
+ const root = await fixture()
+ const media = join(root, '_next/static/media/icon.hash.svg')
+ const svg = ' '
+ await mkdir(dirname(media), { recursive: true })
+ await writeFile(media, svg)
+ const html = await extractFragment(
+ ``,
+ root,
+ '/docs',
+ )
+
+ const $ = load(html!, {}, false)
+ expect($('img[alt="imported"]').attr('src')).toBe(
+ `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`,
+ )
+ expect($('img[alt="local"]').attr('src')).toBe('images/local.png')
+ expect($('img[alt="remote"]').attr('src')).toBe('https://example.com/remote.png')
+ expect(html).not.toContain('_next')
+})
+
+test.each([
+ ['/_next/static/media/%2e%2e%2fsecret.svg', 'escapes the exported media directory'],
+ ['/_next/static/media/font.woff2', 'Unsupported exported image'],
+ ['/_next/static/media/missing.png', 'ENOENT'],
+])('rejects unusable exported media %s', async (src, error) => {
+ const root = await fixture()
+ await expect(
+ extractFragment(``, root),
+ ).rejects.toThrow(error)
+})
+
+test.each(['img', 'source'])(
+ 'embeds responsive %s images without changing other candidates',
+ async (tag) => {
+ const root = await fixture()
+ const media = join(root, '_next/static/media/icon.hash.svg')
+ const svg = ' '
+ await mkdir(dirname(media), { recursive: true })
+ await writeFile(media, svg)
+ const imported = '/docs/_next/static/media/icon.hash.svg'
+ const embedded = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
+ const candidates = `${imported}, ${imported} 2x, https://example.com/a,b.svg 3x, data:image/svg+xml,%3Csvg%3E,%3C/svg%3E 4x, local.png 5x`
+ const html = await extractFragment(
+ `<${tag} srcset="${candidates}"> `,
+ root,
+ '/docs',
+ )
+ expect(load(html!, {}, false)(tag).attr('srcset')).toBe(
+ `${embedded}, ${embedded} 2x, https://example.com/a,b.svg 3x, data:image/svg+xml,%3Csvg%3E,%3C/svg%3E 4x, local.png 5x`,
+ )
+ },
+)
+
+test('rejects traversal in responsive image candidates', async () => {
+ const root = await fixture()
+ await expect(
+ extractFragment(
+ '
',
+ root,
+ ),
+ ).rejects.toThrow('escapes the exported media directory')
+})
+
+test('copies assets and symlinked files from a folder without documents', async () => {
+ const root = await fixture()
+ const input = join(root, 'input')
+ const output = join(input, 'out')
+ await mkdir(output, { recursive: true })
+ await writeFile(join(root, 'image.svg'), ' ')
+ await symlink(join(root, 'image.svg'), join(input, 'image.svg'))
+ await writeFile(join(output, 'previous.html'), 'Previous output
')
+
+ expect(await buildFragments({ packageRoot, root: input, outDir: output, env: {} })).toBe(0)
+ expect(await readFile(join(output, 'image.svg'), 'utf8')).toBe(' ')
+ expect((await readdir(output)).sort()).toEqual(['image.svg', 'previous.html'])
+})
+
+test('builds nested MDX documents and static examples through Next and the CLI', async () => {
+ await run(process.execPath, [join(packageRoot, 'scripts/build-cli.mjs')], { cwd: packageRoot })
+ const root = await fixture(true)
+ const input = join(root, 'input')
+ const output = join(root, 'output')
+ await mkdir(join(input, 'guide/example'), { recursive: true })
+ await mkdir(join(root, 'examples/demo'), { recursive: true })
+ await writeFile(
+ join(input, 'index.mdx'),
+ `---
+title: Frontmatter title
+---
+
+> [!NOTE]
+> A callout
+
+A hint
+
+A key point
+
+First
Second
+
+An introduction
+
+
+
+
+
+
+
+
+
+
+
+
+Details
+
+Detail content
+
+
+`,
+ )
+ await writeFile(
+ join(input, 'guide/start.mdx'),
+ `# Body title
+
+## Section one
+
+
+
+\`\`\`js
+const value = 1
+\`\`\`
+
+\`\`\`mermaid
+graph TD;
+A-->B;
+\`\`\`
+
+
+`,
+ )
+ const example = 'export default function App() { return Example }'
+ await writeFile(join(input, 'guide/example/App.tsx'), example)
+ const externalExample = 'export default function App() { return External example }'
+ await writeFile(join(root, 'examples/demo/App.tsx'), externalExample)
+ const asset = Buffer.from([137, 80, 78, 71])
+ await writeFile(join(input, 'guide/dog.png'), asset)
+
+ await writeFile(
+ join(root, 'tokens.json'),
+ JSON.stringify({ initial: 3, rows: [{ value: '1rem' }] }),
+ )
+ await writeFile(join(root, 'consumer.css'), '[data-count] { color: red; }')
+ await writeFile(
+ join(root, 'icon.svg'),
+ ' ',
+ )
+ await writeFile(
+ join(root, 'Counter.tsx'),
+ `
+'use client'
+import { useState } from 'react'
+export function Counter({ value }: { value: number }) {
+ const [count] = useState(value)
+ return
+}`,
+ )
+ await writeFile(
+ join(root, 'docs.config.ts'),
+ `
+import { createElement } from 'react'
+import { Counter } from './Counter'
+import tokens from './tokens.json'
+import icon from './icon.svg'
+import './consumer.css'
+export default {
+ scope: { tokens },
+ components: {
+ Counter,
+ async CustomTable({ rows }: { rows: { value: string }[] }) {
+ const values = await Promise.resolve(rows)
+ return createElement('table', { 'data-consumer-table': true },
+ createElement('tbody', null, values.map(({ value }, index) =>
+ createElement('tr', { key: index }, createElement('td', null, value)))))
+ },
+ ImportedImage() { return createElement('img', { src: icon.src, alt: 'Imported' }) },
+ },
+}`,
+ )
+
+ // Only the fixture configures the build; the developer's docs environment stays outside.
+ const env: NodeJS.ProcessEnv = {
+ NODE_ENV: 'production',
+ PATH: process.env.PATH,
+ HOME: process.env.HOME,
+ TMPDIR: process.env.TMPDIR,
+ NEXT_TELEMETRY_DISABLED: '1',
+ }
+ const { stdout } = await run(
+ process.execPath,
+ [join(packageRoot, 'bin/pmndrs-docs.mjs'), 'build', input, output],
+ { cwd: root, env, maxBuffer: 10 * 1024 * 1024, timeout: 170_000 },
+ )
+ expect(stdout).toBe('')
+
+ expect((await readdir(output)).sort()).toEqual(['guide', 'index.html'])
+ expect((await readdir(join(output, 'guide'))).sort()).toEqual([
+ 'dog.png',
+ 'example',
+ 'start.html',
+ ])
+ expect(await readFile(join(output, 'guide/dog.png'))).toEqual(asset)
+ expect(await readFile(join(output, 'guide/example/App.tsx'), 'utf8')).toBe(example)
+
+ const indexHtml = await readFile(join(output, 'index.html'), 'utf8')
+ const guideHtml = await readFile(join(output, 'guide/start.html'), 'utf8')
+ for (const html of [indexHtml, guideHtml]) {
+ const $ = load(html, {}, false)
+ expect($('html, head, body, script, style, link, [data-pmndrs-fragment]').length).toBe(0)
+ expect(html).not.toContain('/_next/')
+ }
+
+ const index = load(indexHtml, {}, false)
+ expect(index('h1').text()).toBe('Frontmatter title')
+ expect(index('[role="note"]').length).toBeGreaterThanOrEqual(2)
+ expect(index('[data-count]').attr('data-count')).toBe('3')
+ expect(index('[data-consumer-table] td').text()).toBe('1rem')
+ expect(index('img[alt="Imported"]').attr('src')).toMatch(/^data:image\/svg\+xml;base64,/)
+ expect(await readFile(join(root, 'tokens.json'), 'utf8')).toContain('1rem')
+ expect(index('.grid-list').length).toBe(1)
+ expect(index('details > summary').length).toBe(1)
+ expect(index('a[href="https://codesandbox.io/s/new"]').length).toBe(1)
+ expect(index('.sandpack code.language-tsx').text()).toBe(externalExample)
+
+ const guide = load(guideHtml, {}, false)
+ expect(
+ guide('h1')
+ .toArray()
+ .map((heading) => guide(heading).text()),
+ ).toContain('Body title')
+ expect(guide('#section-one').length).toBe(1)
+ expect(guide('img[alt="Local image"]').attr('src')).toBe('dog.png')
+ expect(guide('code.language-js').text()).toContain('const value = 1')
+ expect(guide('pre.language-mermaid code').text()).toContain('graph TD;\nA-->B;')
+ expect(guide('.sandpack').length).toBe(1)
+ expect(guide('.sandpack code.language-tsx').text()).toBe(example)
+}, 180_000)
diff --git a/src/cli/fragments.ts b/src/cli/fragments.ts
new file mode 100644
index 00000000..7c5aa40b
--- /dev/null
+++ b/src/cli/fragments.ts
@@ -0,0 +1,175 @@
+import { load } from 'cheerio'
+import { copyFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
+import { dirname, extname, join, relative, resolve, sep } from 'node:path'
+import { withNextExport } from './website'
+
+const imageTypes: Record = {
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.webp': 'image/webp',
+ '.avif': 'image/avif',
+}
+
+/** Rewrites candidate URLs without splitting commas inside data or ordinary URLs. */
+async function rewriteSrcset(srcset: string, rewrite: (url: string) => Promise) {
+ let position = 0
+ let copied = 0
+ let result = ''
+ while (position < srcset.length) {
+ // The HTML candidate parser skips leading commas and ASCII whitespace, then reads
+ // a URL up to whitespace. Only trailing URL commas separate descriptorless candidates.
+ while (position < srcset.length && /[\t\n\f\r ,]/.test(srcset[position])) position++
+ const start = position
+ while (position < srcset.length && !/[\t\n\f\r ]/.test(srcset[position])) position++
+ let end = position
+ while (end > start && srcset[end - 1] === ',') end--
+ if (end === start) continue
+
+ result += srcset.slice(copied, start) + (await rewrite(srcset.slice(start, end)))
+ copied = end
+ if (end < position) continue
+
+ // Keep descriptors verbatim; a comma outside parentheses starts the next candidate.
+ let parentheses = 0
+ while (position < srcset.length) {
+ const character = srcset[position++]
+ if (character === '(') parentheses++
+ else if (character === ')' && parentheses > 0) parentheses--
+ else if (character === ',' && parentheses === 0) break
+ }
+ }
+ return result + srcset.slice(copied)
+}
+
+/**
+ * Extracts the document rendered by Next, keeping imported images usable without `_next`.
+ *
+ * @param html - A page from the completed static export.
+ * @param exportDir - Root of that export, containing Next's emitted media files.
+ * @param basePath - URL prefix used by the build.
+ */
+export async function extractFragment(html: string, exportDir: string, basePath = '') {
+ const $ = load(html)
+ const content = $('body > [data-pmndrs-fragment]')
+ if (!content.length) return null
+
+ content.find('script, style, link').remove()
+ const prefix = `${basePath}/_next/static/media/`
+ async function embedImage(src: string) {
+ if (!src.startsWith(prefix)) return src
+ const mediaDir = join(exportDir, '_next/static/media')
+ const file = resolve(mediaDir, decodeURIComponent(src.slice(prefix.length)))
+ const path = relative(mediaDir, file)
+ if (path.startsWith(`..${sep}`) || path === '..') {
+ throw new Error(`Image escapes the exported media directory: ${src}`)
+ }
+ const mime = imageTypes[extname(file)]
+ if (!mime) throw new Error(`Unsupported exported image: ${src}`)
+ const data = await readFile(file)
+ return `data:${mime};base64,${data.toString('base64')}`
+ }
+ for (const image of content.find('img[src]').toArray()) {
+ $(image).attr('src', await embedImage($(image).attr('src')!))
+ }
+ for (const image of content.find('img[srcset], source[srcset]').toArray()) {
+ $(image).attr('srcset', await rewriteSrcset($(image).attr('srcset')!, embedImage))
+ }
+
+ return content.html() ?? ''
+}
+
+/** Files under a directory, pruning dependencies and an optional output subtree. */
+async function filesIn(root: string, exclude?: string): Promise {
+ const files: string[] = []
+ for (const entry of await readdir(root, { withFileTypes: true })) {
+ const path = join(root, entry.name)
+ if (path === exclude || entry.name === 'node_modules') continue
+ if (entry.isDirectory()) files.push(...(await filesIn(path, exclude)))
+ else if (entry.isFile() || entry.isSymbolicLink()) files.push(path)
+ }
+ return files
+}
+
+type FragmentOptions = {
+ /** Installed package or checkout containing the Next application. */
+ packageRoot: string
+ /** Configuration shared with website builds. */
+ env: Record
+}
+
+/**
+ * Builds one document through Next and returns its standalone HTML body.
+ *
+ * @param source - MDX including frontmatter.
+ * @param options - Build configuration and original source path for relative references.
+ */
+export async function renderFragment(
+ source: string,
+ { absoluteFilePath, ...options }: FragmentOptions & { absoluteFilePath: string },
+) {
+ return withNextExport(
+ { ...options, fragments: true, source: { content: source, absoluteFilePath } },
+ async (exportDir) => {
+ const html = await extractFragment(
+ await readFile(join(exportDir, 'index.html'), 'utf8'),
+ exportDir,
+ options.env.BASE_PATH,
+ )
+ if (html === null) throw new Error('Next export is missing the document fragment')
+ return html
+ },
+ )
+}
+
+/**
+ * Exports a folder through Next, writing one HTML fragment per document and copying assets.
+ */
+export async function buildFragments({
+ root,
+ outDir,
+ ...options
+}: FragmentOptions & { root: string; outDir: string }) {
+ if (root === outDir || root.startsWith(`${outDir}${sep}`)) {
+ throw new Error('The output folder must not contain the input folder')
+ }
+ const inputs = await filesIn(root, outDir)
+ const copyAssets = async () => {
+ await mkdir(outDir, { recursive: true })
+ for (const path of inputs) {
+ if (/\.mdx?$/.test(path)) continue
+ const file = join(outDir, relative(root, path))
+ await mkdir(dirname(file), { recursive: true })
+ await copyFile(path, file)
+ }
+ }
+ // No document means no rendering work, but an assets-only folder is still valid input.
+ if (!inputs.some((path) => /\.mdx?$/.test(path))) {
+ await copyAssets()
+ return 0
+ }
+ return withNextExport(
+ { ...options, env: { ...options.env, MDX: root }, fragments: true },
+ async (exportDir) => {
+ let count = 0
+ const documentsDir = join(exportDir, 'fragments')
+ for (const path of await filesIn(documentsDir)) {
+ if (!path.endsWith('.html')) continue
+ const html = await extractFragment(
+ await readFile(path, 'utf8'),
+ exportDir,
+ options.env.BASE_PATH,
+ )
+ if (html === null) continue
+ const file = join(outDir, relative(documentsDir, path))
+ await mkdir(dirname(file), { recursive: true })
+ await writeFile(file, html)
+ count++
+ }
+ await copyAssets()
+ return count
+ },
+ )
+}
diff --git a/src/cli/main.ts b/src/cli/main.ts
index 1e7f6632..ad0a4116 100644
--- a/src/cli/main.ts
+++ b/src/cli/main.ts
@@ -1,21 +1,22 @@
-import type { DocsConfig } from '../config'
import { resolveDocsConfig } from '../config.resolve'
-import { loadDocsConfig } from '../config.load'
import { version } from '@/package.json'
-import { MARKDOWN_REGEX, crawl, getDocs } from '@/utils/docs'
import { assertValidUrlTemplate, expandUrlTemplate } from '@/utils/slugify-branch'
import { Command, Option, type OptionValues } from 'commander'
-import { copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
+import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { dirname, join, relative, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
import { corpus, type Page } from './browse.corpus'
import { formatHits, formatMatches } from './browse.print'
import { matchingLines, search } from './browse.search'
import { resolveTarget } from './browse.target'
-import { fragmentComponents, renderFragment, renderToHtml } from './render'
+import { buildFragments, renderFragment } from './fragments'
import * as gitInfo from './git-info'
import { resolveVersionEnv } from './version-env'
import { buildWebsite } from './website'
+/** This package, wherever installed: `dist/cli.mjs` is one folder below its root. */
+const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+
/** A website option, which `version-url` takes too. */
const versionUrlTemplateOption = new Option(
'--version-url-template ',
@@ -142,68 +143,52 @@ function display(path: string) {
return rel.length < path.length ? rel : path
}
-/** Compiles every MDX file of `root` to an HTML fragment, copying everything else as is. */
-async function compileFolder(root: string, outDir: string, config: DocsConfig) {
- process.env.MDX = root
-
- const docs = await getDocs(root, null, false, {
- ...config,
- components: { ...fragmentComponents, ...config.components },
- })
- for (const doc of docs) {
- const file = join(outDir, `${doc.slug.join('/')}.html`)
- await mkdir(dirname(file), { recursive: true })
- await writeFile(file, await renderToHtml(doc.content))
- }
-
- // Assets the docs reference — images, Sandpack folders — travel with them. Anything
- // already under `outDir` is ours, not theirs: a previous run left it there.
- const assets = await crawl(
- root,
- (path) =>
- !path.includes('node_modules') &&
- !MARKDOWN_REGEX.test(path) &&
- !resolve(path).startsWith(`${outDir}/`),
- )
- for (const asset of assets) {
- const file = join(outDir, relative(root, asset))
- await mkdir(dirname(file), { recursive: true })
- await copyFile(asset, file)
- }
-
- return docs.length
-}
-
async function run(input: string | undefined, output: string | undefined, opts: OptionValues) {
const website = opts.format === 'website'
const configPath = resolveDocsConfig(process.cwd(), opts.config)
- const config = website ? {} : await loadDocsConfig(configPath)
+ const from = input ? resolve(process.cwd(), input) : undefined
+ const isDirectory = from ? (await stat(from)).isDirectory() : false
+ const env: Record = { DOCS_CONFIG: configPath ?? '' }
+ for (const option of websiteOptions) {
+ env[option.envVar!] = opts[option.attributeName()]
+ }
+
+ // Resolve git metadata in the consumer checkout before Next runs in its temporary copy.
+ // Set every version variable, including empty ones, so Next does not resolve them again.
+ const versionEnv = resolveVersionEnv(
+ { ...process.env, ...env },
+ {
+ cwd: from ? (isDirectory ? from : dirname(from)) : process.cwd(),
+ git: gitInfo,
+ assertValidUrlTemplate,
+ },
+ )
+ Object.assign(env, versionEnv)
+ const fragmentOptions = { packageRoot, env }
//
// stdin → stdout
//
- if (!input) {
+ if (!from) {
if (website) throw new Error('--format website needs an IN folder')
// Relative references resolve against the working directory, the only folder the caller
// told us about
const html = await renderFragment(await readStdin(), {
+ ...fragmentOptions,
absoluteFilePath: join(process.cwd(), 'stdin.mdx'),
- config,
})
process.stdout.write(html)
return
}
- const from = resolve(process.cwd(), input)
-
- if (!(await stat(from)).isDirectory()) {
+ if (!isDirectory) {
if (website) throw new Error('--format website needs an IN folder')
const html = await renderFragment(await readFile(from, 'utf8'), {
+ ...fragmentOptions,
absoluteFilePath: from,
- config,
})
if (!output) {
process.stdout.write(html)
@@ -220,31 +205,15 @@ async function run(input: string | undefined, output: string | undefined, opts:
const outDir = resolve(process.cwd(), output ?? 'out')
if (!website) {
- const count = await compileFolder(from, outDir, config)
+ const count = await buildFragments({ ...fragmentOptions, root: from, outDir })
console.error(`${count} pages → ${display(outDir)}`)
return
}
- const env: Record = { MDX: from }
- for (const option of websiteOptions) {
- env[option.envVar!] = opts[option.attributeName()]
- }
-
- // Git is asked here, in the library's checkout, and not by the build: that runs in a copy of
- // this package, where git would describe the wrong repository, or none at all. Every variable
- // is set, even empty, which is what tells `next.config.mts` not to resolve them again.
- // Handed the whole environment, not only the options: git reads the CI's own variables
- // (`GITHUB_*`, `VERCEL_GIT_COMMIT_REF`) to tell which branch and commit this is.
- const versionEnv = resolveVersionEnv(
- { ...process.env, ...env },
- { cwd: from, git: gitInfo, assertValidUrlTemplate },
- )
- Object.assign(env, versionEnv)
-
await buildWebsite({
- packageRoot: resolve(dirname(new URL(import.meta.url).pathname), '..'),
+ packageRoot,
outDir,
- env,
+ env: { ...env, MDX: from },
configPath,
})
console.error(`Preview: npx -y serve ${display(outDir)}`)
@@ -277,6 +246,7 @@ Examples:
$ pmndrs-docs build docs out --format website the whole website, statically exported
A fragment is the compiled MDX and nothing else: no layout, no stylesheet, no script.
+Both formats run a Next.js static export, including single files and stdin.
`,
)
.action(run)
diff --git a/src/cli/render.tsx b/src/cli/render.tsx
deleted file mode 100644
index b0ad01b2..00000000
--- a/src/cli/render.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import type { DocToC } from '@/app/[...slug]/DocsContext'
-import type { DocsConfig } from '@/config'
-import { compileMdxContent } from '@/utils/compileMdxContent'
-import matter from 'gray-matter'
-import type { ComponentProps, ReactNode } from 'react'
-import { renderToReadableStream } from 'react-dom/server'
-
-/**
- * The website renders a doc's title from its frontmatter, in the page layout, which is why
- * its `h1` renders nothing. A fragment has no layout, so it must keep the title it is given.
- */
-export const fragmentComponents = {
- h1: (props: ComponentProps<'h1'>) => (
-
- ),
-
- /**
- * Mermaid draws in the browser, and a fragment carries no script. Rather than the empty
- * frame the website would leave, keep the diagram as the fenced block it was written as —
- * which GitHub, and most markdown renderers, draw on their own.
- */
- Mermaid: ({ chart }: { chart: string }) => (
-
- {chart}
-
- ),
-}
-
-/**
- * Renders a React element to an HTML string.
- *
- * `renderToReadableStream` is the only server renderer that awaits async components, which
- * ours are (`Contributors` and `Backers` fetch at compile time).
- */
-export async function renderToHtml(element: ReactNode): Promise {
- let failure: unknown
- const stream = await renderToReadableStream(element, {
- onError(error) {
- failure = error
- },
- })
- await stream.allReady
- const html = await new Response(stream).text()
- if (failure) throw failure
- return html
-}
-
-/**
- * Compiles one MDX source to an HTML fragment — the document body alone, with no layout.
- *
- * @param source - MDX source, frontmatter included
- */
-export async function renderFragment(
- source: string,
- {
- absoluteFilePath,
- baseUrl,
- config = {},
- }: {
- /** Path used to resolve relative references, including for source read from stdin. */
- absoluteFilePath: string
- /** Base URL for MDX URLs, as `MDX_BASEURL` does on the website. */
- baseUrl?: string
- /** Consumer components and data available in the MDX source. */
- config?: DocsConfig
- },
-): Promise {
- const { content, data: frontmatter } = matter(source)
- const title: string = frontmatter.title?.trim() ?? ''
-
- const tableOfContents: DocToC[] = []
- const compiled = await compileMdxContent(title ? `# ${title}\n${content}` : content, {
- relFilePath: '',
- absoluteFilePath,
- baseUrl,
- title,
- url: '',
- tableOfContents,
- entries: [],
- components: { ...fragmentComponents, ...config.components },
- scope: config.scope,
- })
-
- return renderToHtml(compiled.content)
-}
diff --git a/src/cli/website.ts b/src/cli/website.ts
index 58f1f873..3719dbe6 100644
--- a/src/cli/website.ts
+++ b/src/cli/website.ts
@@ -1,13 +1,11 @@
import { resolveDocsConfig } from '../config.resolve'
+import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
-import { cp, mkdir, rm } from 'node:fs/promises'
+import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { createRequire } from 'node:module'
import { dirname, join, resolve } from 'node:path'
-/**
- * What the Next app is made of — the `files` this package publishes, minus the ones a static
- * export has no use for. Missing entries are skipped: `files` is a superset, and not every
- * version of this package has every one of them.
- */
+/** Published application files needed by a Next build. */
const APP_FILES = [
'package.json',
'next.config.mts',
@@ -19,21 +17,16 @@ const APP_FILES = [
'src',
]
-/** Where the docs are staged inside the copy. `src/app/globals.css` names it too. */
+/** The path scanned by the stylesheet's static Tailwind @source declaration. */
const MDX_DIR = 'docs'
-/** The folder the app is copied to and built in, removed once the export is out. */
+/** Parent of the temporary application copies. */
const WORK_DIR = '.pmndrs-docs'
/**
- * Where to build the copy: beside the `node_modules` holding this package's own dependencies.
- *
- * Next has to be resolvable from wherever it is built, and Node finds it by walking up — so
- * building in the caller's folder only works when the caller happens to have Next installed
- * too, which `npx` does not. Symlinking the dependencies in instead is not an option either:
- * Turbopack rejects a link that leaves its project root. So the copy goes where the
- * dependencies already are, whether a package manager nested them beside this package or
- * hoisted them above it.
+ * Finds a writable project location outside the installed package's node_modules directory.
+ * Next must remain resolvable by walking up from the copy, including under npx installations.
+ * @param packageRoot - Installed package or checkout containing the application.
*/
function findWorkDir(packageRoot: string) {
for (let dir = packageRoot; ; dir = dirname(dir)) {
@@ -42,28 +35,45 @@ function findWorkDir(packageRoot: string) {
}
}
-/**
- * What the copy leaves behind, mirroring the `!` entries of `files`: Route Handlers cannot be
- * statically exported, nor the `/mcp/live` page that streams from one of its own; and tests and
- * stories are not part of the app — they reach for `.storybook` and `vitest`, which the app has no
- * reason to carry.
- */
+/** Dynamic MCP routes and development-only modules do not belong in a static export. */
const isExcluded = (path: string) =>
/\.(test|stories)\.[jt]sx?$/.test(path) ||
path.endsWith('src/app/api') ||
path.endsWith('src/app/mcp/live') ||
- path.endsWith('src/stories')
+ path.endsWith('src/stories') ||
+ path.endsWith('src/test-utils.ts')
+
+/**
+ * Copies the application and optional documentation into an isolated Next project.
+ * @param packageRoot - Installed package or checkout containing the application.
+ * @param mdx - Documentation directory copied to Tailwind's static source path.
+ */
+async function stageApp(packageRoot: string, mdx?: string) {
+ const parent = findWorkDir(packageRoot)
+ if (!parent) throw new Error('Cannot find the `next` package to build the documentation with')
+
+ await mkdir(parent, { recursive: true })
+ const workDir = await mkdtemp(join(parent, 'app-'))
+ try {
+ await Promise.all(
+ APP_FILES.filter((path) => existsSync(join(packageRoot, path))).map((path) =>
+ cp(join(packageRoot, path), join(workDir, path), {
+ recursive: true,
+ filter: (source) => !isExcluded(source),
+ }),
+ ),
+ )
+ if (mdx) await cp(mdx, join(workDir, MDX_DIR), { recursive: true })
+ return workDir
+ } catch (error) {
+ await rm(workDir, { recursive: true, force: true })
+ throw error
+ }
+}
/**
- * Statically exports the documentation website.
- *
- * The app is copied out of this package before being built, because Turbopack refuses to
- * process a project rooted under `node_modules` — which is exactly where an installed package
- * lives. The copy doubles as the way `/api` is kept out of the export: it is simply not
- * copied, so nothing has to be moved out of the way and put back.
- *
- * @param env - Website configuration, as the environment variables the app reads
- * (`MDX`, `NEXT_PUBLIC_LIBNAME`, `BASE_PATH`, `ICON`, `THEME_*`…)
+ * Exports the website, placing each Markdown document beside its HTML page.
+ * @param options - Output directory, consumer configuration, and website environment.
*/
export async function buildWebsite({
packageRoot,
@@ -76,72 +86,108 @@ export async function buildWebsite({
configPath?: string
env: Record
}) {
- const workDir = findWorkDir(packageRoot)
- if (!workDir) throw new Error('Cannot find the `next` package to build the website with')
+ await withNextExport({ packageRoot, env, configPath }, async (exportDir) => {
+ // Server rewrites expose /md/.md as /.md; static hosting needs the same files.
+ const markdownDir = join(exportDir, 'md')
+ // Next exports the 404 body for an absent icon; omit that file instead.
+ const iconFile = join(exportDir, 'icon.svg')
+ const icon = env.ICON ?? process.env.ICON
+ const isLeftOut = (source: string) => source === markdownDir || (!icon && source === iconFile)
+ await mkdir(outDir, { recursive: true })
+ await cp(exportDir, outDir, { recursive: true, filter: (source) => !isLeftOut(source) })
+ if (existsSync(markdownDir)) await cp(markdownDir, outDir, { recursive: true })
+ })
+}
+type ExportOptions = {
+ /** Installed package or checkout containing the Next application. */
+ packageRoot: string
+ /** Configuration passed only to the build subprocess. */
+ env: Record
+ /** Consumer module resolved in the caller's working directory. */
+ configPath?: string
+ /** Use the minimal document routes instead of the website layouts. */
+ fragments?: boolean
+ /** A file or stdin document, with its original path for relative references. */
+ source?: { content: string; absoluteFilePath: string }
+}
+
+/**
+ * Runs the same Next static export for both output formats, then removes the staged app.
+ * @param options - Build configuration and optional single-document source.
+ * @param consume - Reads the completed export before its temporary directory is removed.
+ */
+export async function withNextExport(
+ {
+ packageRoot,
+ env,
+ configPath = resolveDocsConfig(process.cwd(), env.DOCS_CONFIG ?? process.env.DOCS_CONFIG),
+ fragments = false,
+ source,
+ }: ExportOptions,
+ consume: (exportDir: string) => Promise,
+) {
const basePath = env.BASE_PATH ?? ''
const distDir = `out${basePath}`
-
- // The Next config and the app read these at module scope, so they must be set before the
- // build is imported. Undefined values are dropped rather than assigned: `process.env`
- // stringifies whatever it is given, and the app would read the literal "undefined".
- const environment: Record = {
+ const environment = {
+ ...process.env,
+ ...Object.fromEntries(Object.entries(env).filter(([, value]) => value !== undefined)),
BASE_PATH: basePath,
DIST_DIR: distDir,
OUTPUT: 'export',
NODE_ENV: 'production',
- // Keep consumer imports at their original paths, even after the MDX folder is staged.
+ // Next imports the original consumer module, preserving its server/client boundaries.
DOCS_CONFIG: configPath ? resolve(configPath) : '',
- }
- for (const [name, value] of Object.entries(env)) {
- if (value !== undefined) environment[name] ??= value
- }
- Object.assign(process.env, environment)
-
- await rm(workDir, { recursive: true, force: true })
- await mkdir(workDir, { recursive: true })
- await Promise.all(
- APP_FILES.filter((path) => existsSync(join(packageRoot, path))).map((path) =>
- cp(join(packageRoot, path), join(workDir, path), {
- recursive: true,
- filter: (source) => !isExcluded(source),
- }),
- ),
- )
-
- // The docs move in with the app, at the path `globals.css` names in its `@source`. That
- // declaration has to be static — Tailwind resolves it at build time, against the
- // stylesheet — so the folder comes to the path rather than the path to the folder.
- if (environment.MDX) {
- const staged = join(workDir, MDX_DIR)
- await cp(environment.MDX, staged, { recursive: true })
- // Absolute: the app resolves a relative one against the working directory, which is the
- // caller's, not this copy.
- process.env.MDX = staged
- }
+ MDX: source ? dirname(source.absoluteFilePath) : (env.MDX ?? process.env.MDX),
+ PMNDRS_FRAGMENT_INPUT: '',
+ PMNDRS_FRAGMENT_PATH: '',
+ } satisfies NodeJS.ProcessEnv
+ // Fragments have no stylesheet to scan. Read their original MDX paths so references such
+ // as Sandpack folder="../examples" can still reach files outside the documentation root.
+ const workDir = await stageApp(packageRoot, fragments ? undefined : environment.MDX)
try {
- // `next` has no public build API. `nextBuild` takes an options object, where the `build`
- // it wraps takes a dozen positional arguments whose order moves between majors — so this
- // is the entry point that survives a Next upgrade best.
- const { nextBuild } = await import('next/dist/cli/next-build.js')
- await nextBuild(
- { mangling: true, experimentalDebugMemoryUsage: false, experimentalBuildMode: 'default' },
- workDir,
- )
+ if (!fragments && environment.MDX) environment.MDX = join(workDir, MDX_DIR)
- // Each page's markdown is exported under `md/` (`src/app/md/[...slug]/route.ts`), but
- // belongs beside the page, at `.md`: the URL a server build rewrites to it.
- const exportDir = join(workDir, distDir)
- const markdownDir = join(exportDir, 'md')
- // Without `ICON`, `/icon.svg` (`src/app/icon.svg/route.ts`) is a 404, but the export writes
- // its body all the same: left out, so the file is missing rather than a broken image.
- const iconFile = join(exportDir, 'icon.svg')
- const isLeftOut = (source: string) =>
- source === markdownDir || (!environment.ICON && source === iconFile)
- await mkdir(outDir, { recursive: true })
- await cp(exportDir, outDir, { recursive: true, filter: (source) => !isLeftOut(source) })
- if (existsSync(markdownDir)) await cp(markdownDir, outDir, { recursive: true })
+ if (fragments) {
+ await cp(join(workDir, 'src/fragments/layout.tsx'), join(workDir, 'src/app/layout.tsx'))
+ await cp(join(workDir, 'src/fragments/page.tsx'), join(workDir, 'src/app/page.tsx'))
+ // Keep DocsContext and other shared modules in place; only the routes change.
+ for (const name of ['page.tsx', 'layout.tsx']) {
+ await rm(join(workDir, 'src/app/[...slug]', name))
+ if (!source) {
+ // An internal prefix avoids collisions with Next's own index and 404 pages.
+ const target = join(workDir, 'src/app/fragments/[...slug]', name)
+ await mkdir(dirname(target), { recursive: true })
+ await cp(join(workDir, 'src/fragments/[...slug]', name), target)
+ }
+ }
+ for (const route of ['llms.txt', 'llms-full.txt', 'md', 'icon.svg']) {
+ await rm(join(workDir, 'src/app', route), { recursive: true, force: true })
+ }
+ if (source) {
+ environment.PMNDRS_FRAGMENT_INPUT = join(workDir, 'fragment.mdx')
+ environment.PMNDRS_FRAGMENT_PATH = source.absoluteFilePath
+ await writeFile(environment.PMNDRS_FRAGMENT_INPUT, source.content)
+ }
+ }
+
+ // The subprocess isolates configuration and sends build logs to stderr, keeping stdout
+ // available for a single HTML fragment. Use Next's CLI rather than its private build API.
+ const next = createRequire(import.meta.url).resolve('next/dist/bin/next')
+ await new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, [next, 'build', workDir], {
+ cwd: workDir,
+ env: environment,
+ stdio: ['ignore', process.stderr, process.stderr],
+ })
+ child.once('error', reject)
+ child.once('close', (code, signal) => {
+ if (code === 0) resolve()
+ else reject(new Error(`Next build failed (${signal ?? `exit ${code}`})`))
+ })
+ })
+ return await consume(join(workDir, distDir))
} finally {
await rm(workDir, { recursive: true, force: true })
}
diff --git a/src/components/mdx/Entries/Entries.test.tsx b/src/components/mdx/Entries/Entries.test.tsx
index 1b6f600a..bde3a55a 100644
--- a/src/components/mdx/Entries/Entries.test.tsx
+++ b/src/components/mdx/Entries/Entries.test.tsx
@@ -1,4 +1,4 @@
-import { renderToHtml } from '@/cli/render'
+import { renderToHtml } from '@/test-utils'
import { load } from 'cheerio'
import { describe, expect, it } from 'vitest'
import { Entries, type Entry } from './Entries'
diff --git a/src/config.load.test.ts b/src/config.load.test.ts
deleted file mode 100644
index f08c5c50..00000000
--- a/src/config.load.test.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import { load } from 'cheerio'
-import { cp, mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
-import { createRequire } from 'node:module'
-import { tmpdir } from 'node:os'
-import { dirname, join } from 'node:path'
-import { afterEach, expect, test } from 'vitest'
-import { loadDocsConfig } from './config.load'
-import { renderFragment } from './cli/render'
-
-const roots: string[] = []
-const require = createRequire(import.meta.url)
-
-async function fixture() {
- const root = await mkdtemp(join(tmpdir(), 'pmndrs-docs-loader-'))
- roots.push(root)
- return root
-}
-
-afterEach(async () => {
- await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
-})
-
-test('loads local TSX, JSON and hooks using the renderer React instance', async () => {
- const root = await fixture()
- await mkdir(join(root, 'node_modules'))
- await cp(dirname(require.resolve('react/package.json')), join(root, 'node_modules/react'), {
- recursive: true,
- })
- await writeFile(join(root, 'tokens.json'), JSON.stringify({ spacing: [{ value: 8 }] }))
- await writeFile(
- join(root, 'Tokens.tsx'),
- `import { useState } from 'react'
-export function Tokens({ rows }: { rows: { value: number }[] }) {
- const [value] = useState(rows[0].value)
- return
-}`,
- )
- const path = join(root, 'docs.config.ts')
- await writeFile(
- path,
- `import { Tokens } from './Tokens'
-import tokens from './tokens.json'
-export default { components: { Tokens }, scope: { tokens } }`,
- )
-
- const config = await loadDocsConfig(path)
- const $ = load(
- await renderFragment(' ', {
- absoluteFilePath: join(root, 'page.mdx'),
- config,
- }),
- )
- expect($('output').attr('data-token-value')).toBe('8')
- expect((await readdir(root)).filter((name) => name.startsWith('.pmndrs-docs-config-'))).toEqual(
- [],
- )
-})
-
-test('shares the renderer React instance with components imported from a consumer package', async () => {
- const root = await fixture()
- const packageDirectory = join(root, 'node_modules/fixture-components')
- await mkdir(packageDirectory, { recursive: true })
- await cp(dirname(require.resolve('react/package.json')), join(root, 'node_modules/react'), {
- recursive: true,
- })
- await writeFile(
- join(packageDirectory, 'package.json'),
- JSON.stringify({ name: 'fixture-components', type: 'commonjs', exports: './index.js' }),
- )
- await writeFile(
- join(packageDirectory, 'index.js'),
- `const { createElement, useState } = require('react')
-const { basename } = require('node:path')
-exports.Counter = function Counter() {
- const [value] = useState(5)
- return createElement('output', { 'data-count': value, 'data-package': basename('/consumer/tokens') })
-}`,
- )
- const path = join(root, 'docs.config.ts')
- await writeFile(
- path,
- `import { Counter } from 'fixture-components'
-export default { components: { Counter } }`,
- )
-
- const $ = load(
- await renderFragment(' ', {
- absoluteFilePath: join(root, 'page.mdx'),
- config: await loadDocsConfig(path),
- }),
- )
- expect($('output').attr('data-count')).toBe('5')
- expect($('output').attr('data-package')).toBe('tokens')
-})
-
-test.each([
- ['export const config = {}', 'expected object'],
- ['export default { scope: { "bad-key": {} } }', 'Use a JavaScript identifier'],
- ['export default { components: { Invalid: 42 } }', 'Expected a React component'],
- ['export default {', 'Build failed'],
-])('reports invalid modules and cleans up the temporary loader: %s', async (source, reason) => {
- const root = await fixture()
- const path = join(root, 'docs.config.ts')
- await writeFile(path, source)
-
- const result = loadDocsConfig(path)
- await expect(result).rejects.toMatchObject({
- message: expect.stringContaining(`Cannot load docs config ${path}`),
- cause: expect.any(Error),
- })
- await expect(result).rejects.toThrow(reason)
- expect(await readdir(root)).toEqual(['docs.config.ts'])
-})
-
-test('requires no consumer module when configuration is absent', async () => {
- expect(await loadDocsConfig()).toEqual({})
-})
diff --git a/src/config.load.ts b/src/config.load.ts
deleted file mode 100644
index 7857d3c2..00000000
--- a/src/config.load.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { validateDocsConfig } from './config'
-import { build } from 'esbuild'
-import { mkdtemp, rm } from 'node:fs/promises'
-import { createRequire } from 'node:module'
-import { dirname, join } from 'node:path'
-import { pathToFileURL } from 'node:url'
-
-/**
- * Loads a trusted consumer module for HTML fragments. Next loads the original module itself
- * for websites so its server/client boundaries remain intact.
- * @param path - Absolute config path, or undefined for the built-in renderer
- */
-export async function loadDocsConfig(path?: string) {
- if (!path) return {}
-
- // Beside the config so runtime requires can resolve the consumer's own node_modules.
- const directory = await mkdtemp(join(dirname(path), '.pmndrs-docs-config-'))
- const outfile = join(directory, 'config.mjs')
- const require = createRequire(import.meta.url)
- try {
- await build({
- entryPoints: [path],
- outfile,
- bundle: true,
- platform: 'node',
- format: 'esm',
- target: 'node22.12',
- jsx: 'automatic',
- banner: {
- js: "import { createRequire as createConfigRequire } from 'node:module'; const require = createConfigRequire(import.meta.url);",
- },
- logLevel: 'silent',
- plugins: [
- {
- name: 'fragment-runtime',
- setup(build) {
- // Hooks must share the renderer's React instance, even with a separate install.
- build.onResolve({ filter: /^(react|react-dom|next)(\/.*)?$/ }, (args) => ({
- path: require.resolve(args.path),
- external: true,
- }))
- // Fragments carry no stylesheets, just as for the built-in components.
- build.onLoad({ filter: /\.css$/ }, () => ({ contents: '', loader: 'js' }))
- },
- },
- ],
- })
- const configModule = await import(pathToFileURL(outfile).href)
- return validateDocsConfig(configModule.default)
- } catch (error) {
- const reason = error instanceof Error ? error.message : String(error)
- throw new Error(`Cannot load docs config ${path}: ${reason}`, { cause: error })
- } finally {
- await rm(directory, { recursive: true, force: true })
- }
-}
diff --git a/src/fragments/Sandpack.tsx b/src/fragments/Sandpack.tsx
new file mode 100644
index 00000000..a821e5d2
--- /dev/null
+++ b/src/fragments/Sandpack.tsx
@@ -0,0 +1,55 @@
+import type { SandpackFiles } from '@codesandbox/sandpack-react'
+import { crawl } from '@/utils/docs'
+import { readFile } from 'node:fs/promises'
+import { extname, relative } from 'node:path'
+
+type SandpackProps = {
+ /** Folder containing example source files. */
+ folder?: string
+ /** Inline source files and display settings. */
+ files?: SandpackFiles
+}
+
+/**
+ * Exposes example source code in fragments, which carry no browser runtime.
+ */
+export async function Sandpack({ folder, files = {} }: SandpackProps) {
+ const sources = { ...files }
+
+ if (folder) {
+ const paths = await crawl(
+ folder,
+ (file) => !file.includes('node_modules') && /\.(?:js|ts|jsx|tsx|css)$/.test(file),
+ )
+ await Promise.all(
+ paths.map(async (file) => {
+ const name = `/${relative(folder, file)}`
+ const settings = sources[name]
+ sources[name] = {
+ ...(typeof settings === 'object' ? settings : {}),
+ code: await readFile(file, 'utf8'),
+ }
+ }),
+ )
+ }
+
+ return (
+
+ {Object.entries(sources)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([name, file]) => {
+ if (typeof file !== 'string' && file.hidden) return null
+ const code = typeof file === 'string' ? file : file.code
+ const language = extname(name).slice(1) || 'text'
+ return (
+
+ {name}
+
+ {code}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/src/fragments/[...slug]/document.test.tsx b/src/fragments/[...slug]/document.test.tsx
new file mode 100644
index 00000000..0152b1b4
--- /dev/null
+++ b/src/fragments/[...slug]/document.test.tsx
@@ -0,0 +1,62 @@
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { compileMDX } from 'next-mdx-remote/rsc'
+import { afterEach, expect, test, vi } from 'vitest'
+import { compileMdxContent, compileMdxFrontmatter } from '@/utils/compileMdxContent'
+import FragmentDocumentPage from './page'
+
+vi.mock('@/fragments/config', () => ({ fragmentConfig: {} }))
+vi.mock('@/utils/compileMdxContent', () => ({
+ compileMdxContent: vi.fn(async (_source, { url }) => ({ content: })),
+ compileMdxFrontmatter: vi.fn(async (source) => ({ content: source })),
+}))
+vi.mock('next-mdx-remote/rsc', () => ({
+ compileMDX: vi.fn(async () => ({ content: null })),
+}))
+
+afterEach(() => {
+ vi.unstubAllEnvs()
+})
+
+test('shares corpus compilation across concurrent and repeated fragment pages', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'pmndrs-fragment-pages-'))
+ try {
+ await Promise.all(
+ ['first', 'second'].map((slug) =>
+ writeFile(join(root, `${slug}.mdx`), `---\ntitle: ${slug}\n---\nBody`),
+ ),
+ )
+ vi.stubEnv('MDX', root)
+
+ const pages = await Promise.all(
+ ['first', 'second'].map((slug) =>
+ FragmentDocumentPage({ params: Promise.resolve({ slug: [slug] }) }),
+ ),
+ )
+ const repeated = await FragmentDocumentPage({
+ params: Promise.resolve({ slug: ['second'] }),
+ })
+
+ expect(pages.map((page) => page.props.children.props['data-url'])).toEqual([
+ '/first',
+ '/second',
+ ])
+ expect(repeated.props.children.props['data-url']).toBe('/second')
+ // Count the metadata/CodeSandbox scan too, not only final body compilation.
+ expect(compileMDX).toHaveBeenCalledTimes(2)
+ expect(compileMdxFrontmatter).toHaveBeenCalledTimes(2)
+ expect(compileMdxContent).toHaveBeenCalledTimes(2)
+ expect(compileMdxContent).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ entries: expect.arrayContaining([
+ expect.objectContaining({ title: 'first', url: '/first', boxes: [] }),
+ expect.objectContaining({ title: 'second', url: '/second', boxes: [] }),
+ ]),
+ }),
+ )
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+})
diff --git a/src/fragments/[...slug]/layout.tsx b/src/fragments/[...slug]/layout.tsx
new file mode 100644
index 00000000..7aa1cb33
--- /dev/null
+++ b/src/fragments/[...slug]/layout.tsx
@@ -0,0 +1,13 @@
+import type { ReactNode } from 'react'
+
+/**
+ * Keeps folder fragments free of the website's navigation and document layout.
+ */
+export default function FragmentDocumentLayout({
+ children,
+}: {
+ /** Compiled document content. */
+ children: ReactNode
+}) {
+ return children
+}
diff --git a/src/fragments/[...slug]/page.tsx b/src/fragments/[...slug]/page.tsx
new file mode 100644
index 00000000..0cdce551
--- /dev/null
+++ b/src/fragments/[...slug]/page.tsx
@@ -0,0 +1,38 @@
+import { fragmentConfig } from '@/fragments/config'
+import { getDocs, parseDocsMetadata } from '@/utils/docs'
+
+type FragmentDocumentProps = {
+ /** Document path assigned by Next during static generation. */
+ params: Promise<{ slug: string[] }>
+}
+
+// These routes run only in an isolated CLI export, with one root and configuration
+// for the worker's lifetime. Share the promise so concurrent pages also reuse the
+// full collection, including rich Entries and CodeSandbox metadata.
+let documents: ReturnType | undefined
+
+/**
+ * Renders each document through the website's MDX pipeline with static components.
+ */
+export default async function FragmentDocumentPage({ params }: FragmentDocumentProps) {
+ const root = process.env.MDX
+ if (!root) throw new Error('MDX env var not set')
+
+ const { slug } = await params
+ const docs = await (documents ??= getDocs(root, null, false, fragmentConfig))
+ const url = `/${slug.join('/')}`.toLowerCase()
+ const doc = docs.find((entry) => entry.url === url)
+ if (!doc) throw new Error(`Doc not found: ${url}`)
+
+ return {doc.content}
+}
+
+/**
+ * Enumerates folder documents for Next's static export.
+ */
+export async function generateStaticParams() {
+ const root = process.env.MDX
+ if (!root || process.env.PMNDRS_FRAGMENT_INPUT) return []
+ const docs = await parseDocsMetadata(root)
+ return docs.map(({ slug }) => ({ slug }))
+}
diff --git a/src/cli/render.test.tsx b/src/fragments/compile.test.tsx
similarity index 87%
rename from src/cli/render.test.tsx
rename to src/fragments/compile.test.tsx
index d04aecc3..7a33e9d6 100644
--- a/src/cli/render.test.tsx
+++ b/src/fragments/compile.test.tsx
@@ -1,10 +1,7 @@
-import { mkdtemp, readFile, readdir, writeFile, mkdir } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, test } from 'vitest'
import { load } from 'cheerio'
-import { main } from './main'
-import { renderFragment } from './render'
+import { renderFragment } from '@/test-utils'
const render = (source: string) =>
renderFragment(source, { absoluteFilePath: join(process.cwd(), 'docs/authoring/fixture.mdx') })
@@ -172,22 +169,3 @@ test('renders the title, which the website takes from the layout instead', async
/]*>From frontmatter<\/h1>/,
)
})
-
-test('compiles a folder to one HTML file per doc, assets alongside', async () => {
- const root = await mkdtemp(join(tmpdir(), 'pmndrs-docs-'))
- const from = join(root, 'in')
- const to = join(root, 'out')
-
- await mkdir(join(from, 'guide'), { recursive: true })
- await writeFile(join(from, 'index.mdx'), '---\ntitle: Home\n---\n\nhello')
- await writeFile(join(from, 'guide/start.mdx'), '# Start\n\n> [!TIP]\n> go')
- await writeFile(join(from, 'guide/dog.png'), 'not really a png')
-
- await main(['build', from, to])
-
- expect((await readdir(to)).sort()).toEqual(['guide', 'index.html'])
- expect((await readdir(join(to, 'guide'))).sort()).toEqual(['dog.png', 'start.html'])
- expect(
- load(await readFile(join(to, 'guide/start.html'), 'utf8'))('[role="note"]').text(),
- ).toContain('Tip')
-})
diff --git a/src/fragments/compile.tsx b/src/fragments/compile.tsx
new file mode 100644
index 00000000..d27e7ac1
--- /dev/null
+++ b/src/fragments/compile.tsx
@@ -0,0 +1,59 @@
+import type { DocToC } from '@/app/[...slug]/DocsContext'
+import type { DocsConfig } from '@/config'
+import { compileMdxContent } from '@/utils/compileMdxContent'
+import matter from 'gray-matter'
+import type { ComponentProps } from 'react'
+import { Sandpack } from './Sandpack'
+
+/**
+ * Keeps titles and static alternatives for components that need browser scripts.
+ */
+export const fragmentComponents = {
+ Sandpack,
+ h1: (props: ComponentProps<'h1'>) => (
+
+ ),
+ Mermaid: ({ chart }: { chart: string }) => (
+
+ {chart}
+
+ ),
+}
+
+/**
+ * Compiles one MDX source for Next to render as a fragment.
+ *
+ * @param source - MDX source, including frontmatter.
+ */
+export async function compileFragment(
+ source: string,
+ {
+ absoluteFilePath,
+ baseUrl,
+ config = {},
+ }: {
+ /** Logical source path used to resolve relative references, including stdin input. */
+ absoluteFilePath: string
+ /** Base URL for resolving MDX images and links. */
+ baseUrl?: string
+ /** Consumer components and named expression values shared with the website. */
+ config?: DocsConfig
+ },
+) {
+ const { content, data: frontmatter } = matter(source)
+ const title: string = frontmatter.title?.trim() ?? ''
+ const tableOfContents: DocToC[] = []
+ const compiled = await compileMdxContent(title ? `# ${title}\n${content}` : content, {
+ relFilePath: '',
+ absoluteFilePath,
+ baseUrl,
+ title,
+ url: '',
+ tableOfContents,
+ entries: [],
+ components: { ...fragmentComponents, ...config.components },
+ scope: config.scope,
+ })
+
+ return compiled.content
+}
diff --git a/src/fragments/config.ts b/src/fragments/config.ts
new file mode 100644
index 00000000..220221c9
--- /dev/null
+++ b/src/fragments/config.ts
@@ -0,0 +1,13 @@
+import { validateDocsConfig } from '@/config'
+import consumerConfig from 'pmndrs-docs-config'
+import { fragmentComponents } from './compile'
+
+const config = validateDocsConfig(consumerConfig)
+
+/**
+ * Uses the website's consumer scope and overrides on top of static fragment defaults.
+ */
+export const fragmentConfig = {
+ ...config,
+ components: { ...fragmentComponents, ...config.components },
+}
diff --git a/src/fragments/layout.tsx b/src/fragments/layout.tsx
new file mode 100644
index 00000000..caf2e36d
--- /dev/null
+++ b/src/fragments/layout.tsx
@@ -0,0 +1,17 @@
+import type { ReactNode } from 'react'
+
+/**
+ * Supplies Next's required document shell around the extractable fragment.
+ */
+export default function FragmentLayout({
+ children,
+}: {
+ /** Fragment route content. */
+ children: ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/fragments/page.tsx b/src/fragments/page.tsx
new file mode 100644
index 00000000..cd60904f
--- /dev/null
+++ b/src/fragments/page.tsx
@@ -0,0 +1,19 @@
+import { compileFragment } from '@/fragments/compile'
+import { fragmentConfig } from '@/fragments/config'
+import { readFile } from 'node:fs/promises'
+
+/**
+ * Renders a single file or stdin input through Next's server renderer.
+ */
+export default async function FragmentPage() {
+ const input = process.env.PMNDRS_FRAGMENT_INPUT
+ if (!input) return null
+
+ const source = await readFile(input, 'utf8')
+ const content = await compileFragment(source, {
+ absoluteFilePath: process.env.PMNDRS_FRAGMENT_PATH ?? input,
+ baseUrl: process.env.MDX_BASEURL,
+ config: fragmentConfig,
+ })
+ return {content}
+}
diff --git a/src/test-utils.ts b/src/test-utils.ts
new file mode 100644
index 00000000..cf46cdc4
--- /dev/null
+++ b/src/test-utils.ts
@@ -0,0 +1,34 @@
+import { compileFragment } from '@/fragments/compile'
+import type { ReactNode } from 'react'
+import { renderToReadableStream } from 'react-dom/server'
+
+/**
+ * Renders test components to HTML after their asynchronous children finish.
+ *
+ * @param element - React content to render.
+ */
+export async function renderToHtml(element: ReactNode) {
+ let failure: unknown
+ const stream = await renderToReadableStream(element, {
+ onError(error) {
+ failure = error
+ },
+ })
+ await stream.allReady
+ const html = await new Response(stream).text()
+ if (failure) throw failure
+ return html
+}
+
+/**
+ * Renders the fragment compiler's output for focused authoring tests.
+ *
+ * @param source - MDX source, including frontmatter.
+ * @param options - Source location and consumer configuration.
+ */
+export async function renderFragment(
+ source: string,
+ options: Parameters[1],
+) {
+ return renderToHtml(await compileFragment(source, options))
+}
diff --git a/src/utils/compileMdxContent.test.ts b/src/utils/compileMdxContent.test.ts
index 52d8aab0..c66aef78 100644
--- a/src/utils/compileMdxContent.test.ts
+++ b/src/utils/compileMdxContent.test.ts
@@ -1,4 +1,4 @@
-import { renderToHtml } from '@/cli/render'
+import { renderToHtml } from '@/test-utils'
import { load } from 'cheerio'
import { join } from 'node:path'
import { renderToString } from 'react-dom/server'
diff --git a/src/utils/docs.test.ts b/src/utils/docs.test.ts
new file mode 100644
index 00000000..a5fb4391
--- /dev/null
+++ b/src/utils/docs.test.ts
@@ -0,0 +1,121 @@
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, expect, test, vi } from 'vitest'
+import { compileMDX } from 'next-mdx-remote/rsc'
+import { compileMdxContent, compileMdxFrontmatter } from './compileMdxContent'
+import { getDocs } from './docs'
+import { getData } from './site-docs'
+
+vi.mock('pmndrs-docs-config', () => ({ default: { scope: { token: 'configured value' } } }))
+
+vi.mock('./compileMdxContent', () => ({
+ compileMdxContent: vi.fn(async (source, options) => {
+ options.tableOfContents.push({ title: options.title, url: options.url })
+ return { content: source }
+ }),
+ compileMdxFrontmatter: vi.fn(async (source) => ({ content: source })),
+}))
+
+vi.mock('next-mdx-remote/rsc', () => ({
+ compileMDX: vi.fn(async () => ({ content: null })),
+}))
+
+const temporaryDirectories: string[] = []
+
+async function fixture() {
+ const root = await mkdtemp(join(tmpdir(), 'pmndrs-docs-selection-'))
+ temporaryDirectories.push(root)
+ await Promise.all([
+ writeFile(
+ join(root, 'first.mdx'),
+ '---\ntitle: First\ndescription: First description\nnav: 1\n---\nFirst body',
+ ),
+ writeFile(
+ join(root, 'second.mdx'),
+ '---\ntitle: Second\ndescription: Second description\nnav: 2\n---\nSecond body',
+ ),
+ ])
+ return root
+}
+
+afterEach(async () => {
+ vi.clearAllMocks()
+ vi.unstubAllEnvs()
+ await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true })))
+})
+
+test('compiles only the selected body while keeping all navigation metadata and entries', async () => {
+ const docs = await getDocs(await fixture(), ['second'])
+
+ expect(compileMdxContent).toHaveBeenCalledTimes(1)
+ expect(compileMdxContent).toHaveBeenCalledWith(
+ expect.stringContaining('Second body'),
+ expect.objectContaining({
+ entries: expect.arrayContaining([
+ expect.objectContaining({ title: 'First', url: '/first' }),
+ expect.objectContaining({ title: 'Second', url: '/second' }),
+ ]),
+ }),
+ )
+ expect(docs).toHaveLength(2)
+ expect(docs[0]).toMatchObject({
+ slug: ['first'],
+ title: 'First',
+ description: 'First description',
+ metadata: { title: 'First', description: 'First description' },
+ nav: 1,
+ content: null,
+ tableOfContents: [],
+ })
+ expect(docs[1].content).toContain('Second body')
+ expect(docs[1].tableOfContents).toEqual([{ title: 'Second', url: '/second' }])
+})
+
+test('enumerates slugs without compiling titles or document bodies', async () => {
+ const root = await fixture()
+ await writeFile(join(root, 'invalid.mdx'), ' {
+ vi.stubEnv('MDX', await fixture())
+ const { doc, docs } = await getData('second')
+
+ expect(doc.url).toBe('/second')
+ expect(compileMdxContent).toHaveBeenCalledTimes(2)
+ expect(compileMdxContent).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ scope: { token: 'configured value' } }),
+ )
+ expect(docs.map(({ content }) => content)).toEqual([
+ expect.stringContaining('First body'),
+ expect.stringContaining('Second body'),
+ ])
+ expect(docs.flatMap(({ tableOfContents }) => tableOfContents)).toEqual([
+ { title: 'First', url: '/first' },
+ { title: 'Second', url: '/second' },
+ ])
+})
+
+test('passes consumer scope and component overrides to the selected document compiler', async () => {
+ const Custom = () => null
+ const config = { scope: { size: 16 }, components: { Custom } }
+ await getDocs(await fixture(), ['second'], false, config)
+
+ expect(compileMdxContent).toHaveBeenCalledWith(
+ expect.stringContaining('Second body'),
+ expect.objectContaining(config),
+ )
+ expect(compileMDX).toHaveBeenCalledWith(
+ expect.objectContaining({ options: expect.objectContaining({ scope: config.scope }) }),
+ )
+})
diff --git a/src/utils/docs.test.tsx b/src/utils/docs.test.tsx
index 6cdd03b6..30344634 100644
--- a/src/utils/docs.test.tsx
+++ b/src/utils/docs.test.tsx
@@ -1,4 +1,4 @@
-import { renderFragment, renderToHtml } from '@/cli/render'
+import { renderFragment, renderToHtml } from '@/test-utils'
import type { DocsConfig } from '@/config'
import { load } from 'cheerio'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
diff --git a/src/utils/docs.tsx b/src/utils/docs.tsx
index 71e973bc..1a7076b4 100644
--- a/src/utils/docs.tsx
+++ b/src/utils/docs.tsx
@@ -73,7 +73,7 @@ export async function parseDocsMetadata(root: string) {
}
/**
- * Fetches all docs, filters to a lib if specified.
+ * Fetches all document metadata and compiles the selected body, or every body when null.
*
* @param root - absolute or relative (to cwd) path to docs folder
*/
@@ -93,6 +93,9 @@ async function _getDocs(
//
const parsedDocs = await parseDocsMetadata(root)
+ if (slugOnly) return parsedDocs.map(({ slug }) => ({ slug }) as Doc)
+
+ const selectedUrl = slugOfInterest ? `/${slugOfInterest.join('/')}`.toLowerCase() : null
const entries = await Promise.all(
parsedDocs.map(async (parsed) => {
@@ -159,14 +162,6 @@ async function _getDocs(
content,
frontmatter,
}) => {
- //
- // "Lightest" version of the doc (for `generateStaticParams`)
- //
-
- if (slugOnly) {
- return { slug } as Doc
- }
-
//
// Common infos (for every `docs`)
//
@@ -205,36 +200,27 @@ async function _getDocs(
// MDX content
//
- // Skip docs other than `slugOfInterest` -- better perfs)
- // if (JSON.stringify(slug) !== JSON.stringify(slugOfInterest)) {
- // return {
- // slug,
- // url,
- // editURL,
- // title,
- // description,
- // nav,
- // } as Doc
- // }
-
//
// inline images
//
const tableOfContents: DocToC[] = []
- const compiledContent = await compileMdxContent(`# ${titleRaw}\n ${content}`, {
- relFilePath,
- absoluteFilePath: file,
- baseUrl: MDX_BASEURL,
- title: titleRaw,
- url,
- tableOfContents,
- entries,
- components: config.components,
- scope: config.scope,
- })
- const contentJsx = compiledContent.content
+ const compiledContent =
+ selectedUrl === null || url === selectedUrl
+ ? await compileMdxContent(`# ${titleRaw}\n ${content}`, {
+ relFilePath,
+ absoluteFilePath: file,
+ baseUrl: MDX_BASEURL,
+ title: titleRaw,
+ url,
+ tableOfContents,
+ entries,
+ components: config.components,
+ scope: config.scope,
+ })
+ : null
+ const contentJsx = compiledContent?.content ?? null
return {
slug,
diff --git a/src/utils/site-docs.ts b/src/utils/site-docs.ts
index 8a232a0a..359efa22 100644
--- a/src/utils/site-docs.ts
+++ b/src/utils/site-docs.ts
@@ -22,7 +22,8 @@ export function getSiteDocs(root: string, slug: string[] | null, slugOnly = fals
export const getData = cache(async (...slug: string[]) => {
const { MDX } = process.env
if (!MDX) throw new Error('MDX env var not set')
- const docs = await getSiteDocs(MDX, slug)
+ // Website search uses every document's table of contents, including other pages.
+ const docs = await getSiteDocs(MDX, null)
const url = `/${slug.join('/')}`.toLowerCase()
const doc = docs.find((doc) => doc.url === url)
if (!doc) throw new Error(`Doc not found: ${url}`)