From 188a92f8ff63cceb1f9167afa06aaa6a8abffec1 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Tue, 13 Oct 2026 17:26:23 +0200 Subject: [PATCH 1/2] refactor: render HTML fragments through Next.js --- .changeset/shared-next-fragments.md | 8 + README.md | 8 +- docs/getting-started/introduction.mdx | 13 + eslint.config.mjs | 3 +- package.json | 3 +- pnpm-lock.yaml | 6 +- scripts/build-cli.mjs | 48 +--- src/cli/fragments.test.ts | 265 ++++++++++++++++++ src/cli/fragments.ts | 140 +++++++++ src/cli/main.ts | 96 +++---- src/cli/render.tsx | 85 ------ src/cli/website.ts | 220 +++++++++------ src/components/mdx/Entries/Entries.test.tsx | 2 +- src/config.load.test.ts | 117 -------- src/config.load.ts | 56 ---- src/fragments/Sandpack.tsx | 55 ++++ src/fragments/[...slug]/layout.tsx | 13 + src/fragments/[...slug]/page.tsx | 33 +++ .../compile.test.tsx} | 24 +- src/fragments/compile.tsx | 59 ++++ src/fragments/config.ts | 13 + src/fragments/layout.tsx | 17 ++ src/fragments/page.tsx | 19 ++ src/test-utils.ts | 34 +++ src/utils/compileMdxContent.test.ts | 2 +- src/utils/docs.test.ts | 121 ++++++++ src/utils/docs.test.tsx | 2 +- src/utils/docs.tsx | 52 ++-- src/utils/site-docs.ts | 3 +- 29 files changed, 996 insertions(+), 521 deletions(-) create mode 100644 .changeset/shared-next-fragments.md create mode 100644 src/cli/fragments.test.ts create mode 100644 src/cli/fragments.ts delete mode 100644 src/cli/render.tsx delete mode 100644 src/config.load.test.ts delete mode 100644 src/config.load.ts create mode 100644 src/fragments/Sandpack.tsx create mode 100644 src/fragments/[...slug]/layout.tsx create mode 100644 src/fragments/[...slug]/page.tsx rename src/{cli/render.test.tsx => fragments/compile.test.tsx} (87%) create mode 100644 src/fragments/compile.tsx create mode 100644 src/fragments/config.ts create mode 100644 src/fragments/layout.tsx create mode 100644 src/fragments/page.tsx create mode 100644 src/test-utils.ts create mode 100644 src/utils/docs.test.ts 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..5d633f32 --- /dev/null +++ b/src/cli/fragments.test.ts @@ -0,0 +1,265 @@ +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 +

Document

+ +

Content

+
`, + '/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( + `
+ imported + local + remote +
`, + 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('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 + +![Local image](dog.png) + +\`\`\`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..45ab9051 --- /dev/null +++ b/src/cli/fragments.ts @@ -0,0 +1,140 @@ +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', +} + +/** + * 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/` + for (const image of content.find('img[src]').toArray()) { + const src = $(image).attr('src')! + if (!src.startsWith(prefix)) continue + + 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) + $(image).attr('src', `data:${mime};base64,${data.toString('base64')}`) + } + + 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]/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..8c942c96 --- /dev/null +++ b/src/fragments/[...slug]/page.tsx @@ -0,0 +1,33 @@ +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[] }> +} + +/** + * 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 getDocs(root, slug, 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}`) From ca7f14b9bd538905248b78e473d938adfc44655f Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Tue, 13 Oct 2026 17:59:16 +0200 Subject: [PATCH 2/2] fix: preserve responsive fragment images and reuse compiled docs --- src/cli/fragments.test.ts | 32 ++++++++++++ src/cli/fragments.ts | 45 ++++++++++++++-- src/fragments/[...slug]/document.test.tsx | 62 +++++++++++++++++++++++ src/fragments/[...slug]/page.tsx | 7 ++- 4 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 src/fragments/[...slug]/document.test.tsx diff --git a/src/cli/fragments.test.ts b/src/cli/fragments.test.ts index 5d633f32..8b1f804c 100644 --- a/src/cli/fragments.test.ts +++ b/src/cli/fragments.test.ts @@ -81,6 +81,38 @@ test.each([ ).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') diff --git a/src/cli/fragments.ts b/src/cli/fragments.ts index 45ab9051..7c5aa40b 100644 --- a/src/cli/fragments.ts +++ b/src/cli/fragments.ts @@ -13,6 +13,37 @@ const imageTypes: Record = { '.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`. * @@ -27,10 +58,8 @@ export async function extractFragment(html: string, exportDir: string, basePath content.find('script, style, link').remove() const prefix = `${basePath}/_next/static/media/` - for (const image of content.find('img[src]').toArray()) { - const src = $(image).attr('src')! - if (!src.startsWith(prefix)) continue - + 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) @@ -40,7 +69,13 @@ export async function extractFragment(html: string, exportDir: string, basePath const mime = imageTypes[extname(file)] if (!mime) throw new Error(`Unsupported exported image: ${src}`) const data = await readFile(file) - $(image).attr('src', `data:${mime};base64,${data.toString('base64')}`) + 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() ?? '' 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]/page.tsx b/src/fragments/[...slug]/page.tsx index 8c942c96..0cdce551 100644 --- a/src/fragments/[...slug]/page.tsx +++ b/src/fragments/[...slug]/page.tsx @@ -6,6 +6,11 @@ type FragmentDocumentProps = { 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. */ @@ -14,7 +19,7 @@ export default async function FragmentDocumentPage({ params }: FragmentDocumentP if (!root) throw new Error('MDX env var not set') const { slug } = await params - const docs = await getDocs(root, slug, false, fragmentConfig) + 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}`)