From 6e29f8ba0e1d1b1f2ce29785a4334c2eb1ec7873 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Tue, 13 Oct 2026 16:00:13 +0200 Subject: [PATCH] fix: resolve MDX values in Markdown exports --- .changeset/tidy-markdown-values.md | 5 + README.md | 24 ++ docs.config.ts | 3 + package.json | 9 +- pnpm-lock.yaml | 133 +++++++++- src/app/[...slug]/page.test.ts | 24 ++ src/app/llms-full.txt/route.ts | 39 ++- src/app/md/[...slug]/route.markdown.test.ts | 66 +++++ src/app/md/[...slug]/route.test.ts | 4 +- src/app/md/[...slug]/route.ts | 8 +- src/config.test.ts | 18 +- src/config.ts | 4 + src/utils/markdownComponents.tsx | 257 ++++++++++++++++++++ src/utils/markdownSyntax.test.ts | 59 +++++ src/utils/markdownSyntax.ts | 104 ++++++++ src/utils/renderMarkdown.test.tsx | 195 +++++++++++++++ src/utils/renderMarkdown.tsx | 139 +++++++++++ src/utils/site-markdown.ts | 19 ++ 18 files changed, 1067 insertions(+), 43 deletions(-) create mode 100644 .changeset/tidy-markdown-values.md create mode 100644 src/app/md/[...slug]/route.markdown.test.ts create mode 100644 src/utils/markdownComponents.tsx create mode 100644 src/utils/markdownSyntax.test.ts create mode 100644 src/utils/markdownSyntax.ts create mode 100644 src/utils/renderMarkdown.test.tsx create mode 100644 src/utils/renderMarkdown.tsx create mode 100644 src/utils/site-markdown.ts diff --git a/.changeset/tidy-markdown-values.md b/.changeset/tidy-markdown-values.md new file mode 100644 index 000000000..9603ea0ad --- /dev/null +++ b/.changeset/tidy-markdown-values.md @@ -0,0 +1,5 @@ +--- +'@pmndrs/docs': patch +--- + +Resolve MDX scope values in Copy Page, page Markdown endpoints and llms-full.txt through a shared document renderer. Preserve Markdown structure and literal code, provide static representations of built-in components, and support consumer markdownComponents overrides for interactive content. diff --git a/README.md b/README.md index 839eba34a..c53d8d983 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,30 @@ resolve while you edit. Any option from +## Markdown exports + +**Copy Page**, **View as Markdown** (`/.md`), and `llms-full.txt` use the same document renderer. MDX expressions resolve against `scope`, including values inside Markdown tables, JSX attributes and commands. Headings, tables, lists, links, images and code are serialized as Markdown. Formatting is normalized; the MDX files are never rewritten. Inline code and fenced examples remain literal, including their braces and JSX. + +Built-in components have static representations: tabs include every panel, code blocks include their authored commands, Mermaid keeps its diagram source, and theme presets expose their declared values. Color previews expose their role or CSS value, without computing browser styles. Sandpack and live embeds provide a static notice or destination; contributor and backer lists provide links. + +Custom server-renderable components use their rendered content, including async components. A client component that Next exposes as an opaque reference uses a notice and its authored children. Components that need browser APIs, providers, or a particular interactive state should supply a server-renderable alternative in `markdownComponents`: + +```ts +import type { DocsConfig } from '@pmndrs/docs/src/config' +import { Example } from './docs/authoring/Example' +import example from './docs/authoring/Example.json' + +export default { + components: { Example }, + scope: { example }, + markdownComponents: { + Example: ({ initialCount }: { initialCount: number }) => `Count: ${initialCount}`, + }, +} satisfies DocsConfig +``` + +`markdownComponents` overrides both built-in and configured components for Markdown exports only. Keep these alternatives free of client dependencies, including nested client components. Exports describe the authored or initial state at build time; they do not capture a reader's selected tab, edited playground, or theme preferences. Unresolved expressions and rendering errors fail the export with the document path instead of returning raw MDX. + # Releasing Every push to `main` redeploys [docs.pmnd.rs](https://docs.pmnd.rs) via [ci.yml](.github/workflows/ci.yml) — no [changeset](.changeset/) needed for that. diff --git a/docs.config.ts b/docs.config.ts index fc503e19b..b9ae2d1b6 100644 --- a/docs.config.ts +++ b/docs.config.ts @@ -7,4 +7,7 @@ import type { DocsConfig } from './src/config' export default { components: { Example, ExampleTable }, scope: { example }, + markdownComponents: { + Example: ({ initialCount }: { initialCount: number }) => `Count: ${initialCount}`, + }, } satisfies DocsConfig diff --git a/package.json b/package.json index b029755b6..cf5ffd9d8 100644 --- a/package.json +++ b/package.json @@ -35,11 +35,9 @@ "prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-tailwindcss": "^0.8.1", "redis": "^6.3.0", - "rehype-parse": "^9.0.1", "rehype-stringify": "^10.0.1", "shadcn": "^4.21.4", "storybook": "^10.6.2", - "unified": "^11.0.5", "vite": "^8.3.4", "vitest": "^5.0.4" }, @@ -64,6 +62,7 @@ "commander": "^15.0.0", "esbuild": "^0.28.2", "gray-matter": "^4.0.3", + "hast-util-to-mdast": "^10.1.2", "image-size": "^2.0.4", "ink": "^8.0.0", "lodash-es": "^4.18.1", @@ -72,6 +71,7 @@ "material-theme-builder": "^5.2.1", "mcp-handler": "^2.3.0", "mermaid": "^12.1.0", + "micromark-core-commonmark": "^2.0.4", "next": "^16.4.0", "next-mdx-remote": "^6.0.0", "next-themes": "^0.4.6", @@ -79,13 +79,18 @@ "react": "^19.3.0", "react-dom": "^19.3.0", "rehype-github-alerts": "^4.2.0", + "rehype-parse": "^9.0.1", "rehype-prism-plus": "^2.0.2", + "rehype-remark": "^10.0.1", "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", "sanitize-html": "^2.18.0", "tailwind-merge": "^3.7.0", "tailwindcss": "^4.3.3", "tw-animate-css": "^1.4.0", "typescript": "^6.0.3", + "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "xmlbuilder2": "^4.0.3", "zod": "^4.6.7" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cde8c5129..19991b597 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: gray-matter: specifier: ^4.0.3 version: 4.0.3 + hast-util-to-mdast: + specifier: ^10.1.2 + version: 10.1.2 image-size: specifier: ^2.0.4 version: 2.0.4 @@ -92,6 +95,9 @@ importers: mermaid: specifier: ^12.1.0 version: 12.1.0 + micromark-core-commonmark: + specifier: ^2.0.4 + version: 2.0.4 next: specifier: ^16.4.0 version: 16.4.0(@babel/core@7.29.7)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -113,12 +119,24 @@ importers: rehype-github-alerts: specifier: ^4.2.0 version: 4.2.0 + rehype-parse: + specifier: ^9.0.1 + version: 9.0.1 rehype-prism-plus: specifier: ^2.0.2 version: 2.0.2 + rehype-remark: + specifier: ^10.0.1 + version: 10.0.1 remark-gfm: specifier: ^4.0.1 version: 4.0.1 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 + remark-stringify: + specifier: ^11.0.0 + version: 11.0.0 sanitize-html: specifier: ^2.18.0 version: 2.18.0 @@ -134,6 +152,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + unified: + specifier: ^11.0.5 + version: 11.0.5 unist-util-visit: specifier: ^5.1.0 version: 5.1.0 @@ -231,9 +252,6 @@ importers: redis: specifier: ^6.3.0 version: 6.3.0 - rehype-parse: - specifier: ^9.0.1 - version: 9.0.1 rehype-stringify: specifier: ^10.0.1 version: 10.0.1 @@ -243,9 +261,6 @@ importers: storybook: specifier: ^10.6.2 version: 10.6.2(@types/react@19.3.0)(prettier@3.9.10)(react@19.3.0) - unified: - specifier: ^11.0.5 - version: 11.0.5 vite: specifier: ^8.3.4 version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.0)(yaml@2.9.1) @@ -4133,18 +4148,33 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hast-util-embedded@3.0.0: + resolution: {integrity: sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==} + hast-util-from-html@2.0.3: resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} hast-util-from-parse5@8.0.3: resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + hast-util-has-property@3.0.0: + resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==} + + hast-util-is-body-ok-link@3.0.1: + resolution: {integrity: sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==} + hast-util-is-element@3.0.0: resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-minify-whitespace@1.0.1: + resolution: {integrity: sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==} + hast-util-parse-selector@4.0.0: resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + hast-util-phrasing@3.0.1: + resolution: {integrity: sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==} + hast-util-to-estree@3.1.3: resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} @@ -4154,9 +4184,15 @@ packages: hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-mdast@10.1.2: + resolution: {integrity: sha512-FiCRI7NmOvM4y+f5w32jPRzcxDIz+PUqDwEqn1A+1q2cdp3B8Gx7aVrXORdOKjMNDQsD1ogOr896+0jJHW1EFQ==} + hast-util-to-string@3.0.1: resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -5669,6 +5705,9 @@ packages: rehype-github-alerts@4.2.0: resolution: {integrity: sha512-6di6kEu9WUHKLKrkKG2xX6AOuaCMGghg0Wq7MEuM/jBYUPVIq6PJpMe00dxMfU+/YSBtDXhffpDimgDi+BObIQ==} + rehype-minify-whitespace@6.0.2: + resolution: {integrity: sha512-Zk0pyQ06A3Lyxhe9vGtOtzz3Z0+qZ5+7icZ/PL/2x1SHPbKao5oB/g/rlc6BCTajqBb33JcOe71Ye1oFsuYbnw==} + rehype-parse@9.0.1: resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} @@ -5678,6 +5717,9 @@ packages: rehype-recma@1.0.0: resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + rehype-remark@10.0.1: + resolution: {integrity: sha512-EmDndlb5NVwXGfUa4c9GPK+lXeItTilLhE6ADSaQuHr4JUlKw9MidzGzx4HpqZrNCt6vnHmEifXQiiA+CEnjYQ==} + rehype-stringify@10.0.1: resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} @@ -6158,6 +6200,9 @@ packages: trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + trim-trailing-lines@2.1.0: + resolution: {integrity: sha512-5UR5Biq4VlVOtzqkm2AZlgvSlDJtME46uV0br0gENbwN4l5+mMKT4b9gJKqWtuL2zAIqajGJGuvbCbcAJUZqBg==} + trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} @@ -6266,6 +6311,9 @@ packages: unist-util-filter@5.0.1: resolution: {integrity: sha512-pHx7D4Zt6+TsfwylH9+lYhBhzyhEnCXs/lbq/Hstxno5z4gVdyc2WEW0asfjGKPyG4pEKrnBv5hdkO6+aRnQJw==} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -10637,6 +10685,11 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-embedded@3.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-is-element: 3.0.0 + hast-util-from-html@2.0.3: dependencies: '@types/hast': 3.0.5 @@ -10657,14 +10710,38 @@ snapshots: vfile-location: 5.0.3 web-namespaces: 2.0.1 + hast-util-has-property@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-is-body-ok-link@3.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-is-element@3.0.0: dependencies: '@types/hast': 3.0.5 + hast-util-minify-whitespace@1.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-is-element: 3.0.0 + hast-util-whitespace: 3.0.0 + unist-util-is: 6.0.1 + hast-util-parse-selector@4.0.0: dependencies: '@types/hast': 3.0.5 + hast-util-phrasing@3.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-has-property: 3.0.0 + hast-util-is-body-ok-link: 3.0.1 + hast-util-is-element: 3.0.0 + hast-util-to-estree@3.1.3: dependencies: '@types/estree': 1.0.9 @@ -10720,10 +10797,34 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-mdast@10.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + hast-util-phrasing: 3.0.1 + hast-util-to-html: 9.0.5 + hast-util-to-text: 4.0.2 + hast-util-whitespace: 3.0.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-hast: 13.2.1 + mdast-util-to-string: 4.0.0 + rehype-minify-whitespace: 6.0.2 + trim-trailing-lines: 2.1.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + hast-util-to-string@3.0.1: dependencies: '@types/hast': 3.0.5 + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.5 @@ -12473,6 +12574,11 @@ snapshots: hast-util-is-element: 3.0.0 unist-util-visit: 5.1.0 + rehype-minify-whitespace@6.0.2: + dependencies: + '@types/hast': 3.0.5 + hast-util-minify-whitespace: 1.0.1 + rehype-parse@9.0.1: dependencies: '@types/hast': 3.0.5 @@ -12496,6 +12602,14 @@ snapshots: transitivePeerDependencies: - supports-color + rehype-remark@10.0.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + hast-util-to-mdast: 10.1.2 + unified: 11.0.5 + vfile: 6.0.3 + rehype-stringify@10.0.1: dependencies: '@types/hast': 3.0.5 @@ -13160,6 +13274,8 @@ snapshots: trim-lines@3.0.1: {} + trim-trailing-lines@2.1.0: {} + trough@2.2.0: {} ts-api-utils@2.5.0(typescript@6.0.3): @@ -13286,6 +13402,11 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 diff --git a/src/app/[...slug]/page.test.ts b/src/app/[...slug]/page.test.ts index 5ca3100c8..2e8365152 100644 --- a/src/app/[...slug]/page.test.ts +++ b/src/app/[...slug]/page.test.ts @@ -51,6 +51,30 @@ for (const pagePath of pages) { test.describe('custom components', () => { test.use({ disableAutoSnapshot: true }) + test('Copy Page and View as Markdown use the resolved document export', async ({ + page, + context, + }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await page.goto('/authoring/custom-components') + const response = await page.request.get('/authoring/custom-components.md') + expect(response.ok()).toBe(true) + const markdown = await response.text() + expect(markdown).toContain('Count: 3') + expect(markdown).toMatch(/\| compact\s*\| 4\s*\|/) + expect(markdown).toMatch(/\| comfortable\s*\| 12\s*\|/) + + await page.getByRole('button', { name: 'Copy Page', exact: true }).click() + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(markdown) + await page.getByRole('button', { name: 'More page actions', exact: true }).click() + await expect(page.getByRole('menuitem', { name: 'View as Markdown' })).toHaveAttribute( + 'href', + '/authoring/custom-components.md', + ) + const full = await (await page.request.get('/llms-full.txt')).text() + expect(full).toContain(markdown.trim()) + }) + test('the documentation renders async data and hydrates its configured client component', async ({ page, }) => { diff --git a/src/app/llms-full.txt/route.ts b/src/app/llms-full.txt/route.ts index 119adf67e..b8515a946 100644 --- a/src/app/llms-full.txt/route.ts +++ b/src/app/llms-full.txt/route.ts @@ -1,15 +1,9 @@ import { parseDocsMetadata } from '@/utils/docs' +import { getSiteMarkdown } from '@/utils/site-markdown' import { fragment } from 'xmlbuilder2' export const dynamic = 'force-static' -/** - * Basic cleanup of markdown content - */ -function cleanMarkdown(content: string): string { - return content.replace(/]]>/g, ']] >') // Escape CDATA end sequence to prevent breaking XML -} - export async function GET() { const { MDX, NEXT_PUBLIC_LIBNAME, NEXT_PUBLIC_URL } = process.env if (!MDX) throw new Error('MDX env var not set') @@ -27,24 +21,25 @@ Full documentation content. ` // Build XML for each page using fragment mode for efficiency - const pages = docs.map((doc) => { - const url = baseUrl ? `${baseUrl}${doc.url}` : doc.url - const pageContent = `URL: ${url} + const pages = await Promise.all( + docs.map(async (doc) => { + const url = baseUrl ? `${baseUrl}${doc.url}` : doc.url + const pageContent = `URL: ${url} ${doc.description ? `Description: ${doc.description}\n` : ''} -${cleanMarkdown(doc.content)}` - - // Create XML fragment for efficient generation - const page = fragment() - .ele('page', { - path: doc.url, - title: doc.title, +${await getSiteMarkdown(doc, docs, MDX)}` + + // Create XML fragment for efficient generation + const page = fragment().ele('page', { path: doc.url, title: doc.title }) + // Split CDATA boundaries without changing literal code in the document. + const sections = pageContent.split(']]>') + sections.forEach((section, index) => { + page.dat(`${index > 0 ? '>' : ''}${section}${index < sections.length - 1 ? ']]' : ''}`) }) - .dat(pageContent) // Use .dat() instead of .txt() to avoid HTML entity escaping - .up() - // Return the XML string - return page.end({ headless: true }) - }) + // Return the XML string + return page.end({ headless: true }) + }), + ) const fullContent = header + pages.join('\n') + '\n' diff --git a/src/app/md/[...slug]/route.markdown.test.ts b/src/app/md/[...slug]/route.markdown.test.ts new file mode 100644 index 000000000..df9eaad42 --- /dev/null +++ b/src/app/md/[...slug]/route.markdown.test.ts @@ -0,0 +1,66 @@ +import { load } from 'cheerio' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, test, vi } from 'vitest' +import { GET as getMarkdown } from './route' +import { GET as getFullMarkdown } from '../../llms-full.txt/route' + +vi.mock('pmndrs-docs-config', () => ({ + default: { + scope: { + spacing: { '4': { value: '1rem' } }, + registry: { url: 'https://example.com/r/base.json', command: 'npx shadcn add base' }, + }, + }, +})) + +let root: string + +afterEach(async () => { + vi.unstubAllEnvs() + if (root) await rm(root, { recursive: true, force: true }) +}) + +test('exports the same resolved document through the page endpoint and llms-full without rewriting MDX', async () => { + root = await mkdtemp(join(tmpdir(), 'docs-markdown-')) + vi.stubEnv('MDX', root) + vi.stubEnv('NEXT_PUBLIC_LIBNAME', 'Example') + const source = `--- +title: Spacing +--- +## Tokens + +| Token | Value | +| --- | --- | +| 4 | {spacing['4'].value} | + +Registry + +{registry.command} + +\`{spacing['4'].value}\` + +\`\`\`tsx +const example = { value: 'literal', cdata: ']]>' } +\`\`\` +` + const file = join(root, 'spacing.mdx') + await writeFile(file, source) + const response = await getMarkdown(new Request('https://example.com/spacing.md'), { + params: Promise.resolve({ slug: ['spacing.md'] }), + }) + const markdown = await response.text() + expect(markdown).toContain('`1rem`') + expect(markdown).toContain('[Registry](https://example.com/r/base.json)') + expect(markdown).toContain('npx shadcn add base') + expect(markdown).toContain("`{spacing['4'].value}`") + expect(markdown).toContain("const example = { value: 'literal', cdata: ']]>' }") + expect(markdown).toContain('## Tokens') + expect(markdown).not.toContain('') + expect(response.headers.get('Content-Type')).toBe('text/markdown; charset=utf-8') + const full = await (await getFullMarkdown()).text() + const pages = load(full.slice(full.indexOf(' ({ default: {} })) + let root: string let previousMDX: string | undefined diff --git a/src/app/md/[...slug]/route.ts b/src/app/md/[...slug]/route.ts index aa4246414..0da6abb64 100644 --- a/src/app/md/[...slug]/route.ts +++ b/src/app/md/[...slug]/route.ts @@ -1,7 +1,8 @@ import { parseDocsMetadata } from '@/utils/docs' +import { getSiteMarkdown } from '@/utils/site-markdown' // -// Each page as raw markdown, published at the page's URL plus `.md` (`/.md`) -- what the +// Each page as resolved Markdown, published at the page's URL plus `.md` (`/.md`) -- what the // "Copy Page" and "View as Markdown" actions of a page header read. // // A route handler cannot sit next to `[...slug]/page.tsx`, hence its own `/md` prefix -- an @@ -50,10 +51,7 @@ export async function GET(_request: Request, { params }: { params: Promise doc.url === url) if (!doc) return new Response('Not Found', { status: 404 }) - const markdown = `# ${doc.title} - -${doc.description ? `${doc.description}\n\n` : ''}${doc.content.trim()} -` + const markdown = await getSiteMarkdown(doc, docs, MDX) return new Response(markdown, { headers: { diff --git a/src/config.test.ts b/src/config.test.ts index 16a1421e1..2b232e4aa 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -14,9 +14,10 @@ test('accepts plain, async and wrapped React components with shared structured d }), } const data = { spacing: [0, 4, 8] } - const config = validateDocsConfig({ components, scope: { data } }) + const config = validateDocsConfig({ components, markdownComponents: components, scope: { data } }) expect(config.components?.Plain).toBe(components.Plain) expect(config.components?.Memo).toBe(components.Memo) + expect(config.markdownComponents?.Async).toBe(components.Async) expect(config.scope?.data).toBe(data) }) @@ -27,12 +28,15 @@ test.each(['bad-key', '1data', 'opts', 'frontmatter', '_jsxDEV', 'MDXContent', ' }, ) -test.each([null, [], { components: { Invalid: 42 } }, { scope: [] }])( - 'rejects malformed consumer configuration %j', - (value) => { - expect(() => validateDocsConfig(value)).toThrow() - }, -) +test.each([ + null, + [], + { components: { Invalid: 42 } }, + { markdownComponents: { Invalid: 42 } }, + { scope: [] }, +])('rejects malformed consumer configuration %j', (value) => { + expect(() => validateDocsConfig(value)).toThrow() +}) test('rejects an own __proto__ scope key instead of silently dropping its value', () => { expect(() => diff --git a/src/config.ts b/src/config.ts index 861126918..fd5b2e286 100644 --- a/src/config.ts +++ b/src/config.ts @@ -91,6 +91,10 @@ const docsConfigSchema = z.object({ .record(z.string(), component) .optional() .describe('React components added to, or overriding, the built-in MDX components.'), + markdownComponents: z + .record(z.string(), component) + .optional() + .describe('Server-renderable alternatives used only in Markdown exports.'), scope: z .preprocess( (value, context) => { diff --git a/src/utils/markdownComponents.tsx b/src/utils/markdownComponents.tsx new file mode 100644 index 000000000..bb760a8b4 --- /dev/null +++ b/src/utils/markdownComponents.tsx @@ -0,0 +1,257 @@ +import { packageManagers, type PackageManagerCommands } from '@/utils/packageManagers' +import resolveMdxUrl from '@/utils/resolveMdxUrl' +import type { MtbConfig } from 'material-theme-builder' +import { isValidElement, type ComponentProps, type ReactNode } from 'react' + +type ChildrenProps = { children?: ReactNode } + +function Content({ children }: ChildrenProps) { + return
{children}
+} + +function Code({ + children, + className, + title, + ...commands +}: ComponentProps<'pre'> & Partial) { + const suppliedCommands = packageManagers.filter((manager) => commands[manager] !== undefined) + if (suppliedCommands.length) { + return ( +
+ {suppliedCommands.map((manager) => ( +
+

+ {manager} +

+
+              {commands[manager]}
+            
+
+ ))} +
+ ) + } + + return ( +
+ {title && ( +

+ {title} +

+ )} +
+        {isValidElement(children) && children.type === 'code' ? (
+          children
+        ) : (
+          {children}
+        )}
+      
+
+ ) +} + +function Gha({ + children, + keyword = 'NOTE', + title, +}: ChildrenProps & { keyword?: string; title?: string }) { + const kind = ['NOTE', 'TIP', 'IMPORTANT', 'WARNING', 'CAUTION'].includes(keyword) + ? keyword + : 'NOTE' + return ( +
+

+ {`[!${kind}]`} + {title && ( + <> + {' '} + {title} + + )} +

+ {children} +
+ ) +} + +function ThemeSeeds({ + presets = [], +}: { + presets?: { name: string; label?: string; config: MtbConfig }[] +}) { + const options = [ + 'source', + 'colorMatch', + 'scheme', + 'primary', + 'secondary', + 'tertiary', + 'neutral', + 'neutralVariant', + 'error', + ] as const satisfies readonly (keyof MtbConfig)[] + + return ( + + + + + + + + + + {presets.flatMap(({ name, label, config }) => + options.map((option) => { + const value = config[option] + if (value === undefined || (option === 'scheme' && config.colorMatch)) return null + return ( + + + + + + ) + }), + )} + +
PresetOptionValue
{label ?? name} + {option} + + {String(value)} +
+ ) +} + +/** + * Static article representations of the built-in MDX components, without site controls. + * Interactive components expose their authored content or a destination, not browser state. + */ +export function markdownComponents({ + entries, + relFilePath, + baseUrl, +}: { + /** Documentation destinations available to the Entries component. */ + entries: { url: string; title: string; slug: string[] }[] + /** Source-relative path used to resolve image references. */ + relFilePath: string + /** Optional public root of the documentation assets. */ + baseUrl?: string +}) { + function Img({ src, alt = '', title }: ComponentProps<'img'>) { + return ( + {alt} + ) + } + + return { + Badge: ({ href, label, children }: ChildrenProps & { href?: string; label?: string }) => { + const content = ( + <> + {label && <>{label} } + {children} + + ) + return href ? {content} : {content} + }, + Code, + Color: ({ role, color, children }: ChildrenProps & { role?: string; color?: string }) => ( + + {color ?? role} + {children}{' '} + + ), + ColorGroup: Content, + Details: Content, + Entries: ({ excludedGroups = [] }: { excludedGroups?: string[] }) => ( +
    + {entries + .filter(({ slug }) => !excludedGroups.includes(slug[0])) + .map(({ url, title }) => ( +
  • + {title} +
  • + ))} +
+ ), + Gha, + Grid: Content, + Hint: Gha, + Img, + img: Img, + Intro: Content, + Keypoints: ({ title = 'Keypoints', children }: ChildrenProps & { title?: string }) => ( +
+

{title}

+
    {children}
+
+ ), + KeypointsItem: ({ children }: ChildrenProps) =>
  • {children}
  • , + Contributors: ({ owner, repo }: { owner: string; repo: string }) => ( +

    + Contributors +

    + ), + Backers: ({ repo }: { repo: string }) => ( +

    + Backers +

    + ), + McpLiveEmbed: () => ( +

    + MCP server requests, live +

    + ), + Mermaid: ({ chart, children }: ChildrenProps & { chart?: string }) => ( +
    +        {chart ?? children}
    +      
    + ), + Sandpack: ({ children }: ChildrenProps) => ( +
    +

    Interactive code example (available on the documentation website).

    + {children} +
    + ), + Step: ({ children }: ChildrenProps) =>

    {children}

    , + Steps: Content, + Summary: ({ children }: ChildrenProps) => ( +

    + {children} +

    + ), + ThemeSeeds, + Tabs: Content, + TabsList: Content, + TabsTrigger: ({ children }: ChildrenProps) => ( +

    + {children} +

    + ), + TabsContent: Content, + Codesandbox: ({ + id, + title, + description, + children, + }: ChildrenProps & { + id: string + title?: string + description?: string + }) => ( +
    +

    + {title ?? `CodeSandbox ${id}`} +

    + {description &&

    {description}

    } + {children} +
    + ), + } +} diff --git a/src/utils/markdownSyntax.test.ts b/src/utils/markdownSyntax.test.ts new file mode 100644 index 000000000..8fc046fb2 --- /dev/null +++ b/src/utils/markdownSyntax.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from 'vitest' +import { renderMarkdown } from './renderMarkdown' + +test('retains fence metadata and literal braces, autolinks and JSX inside code', async () => { + const code = 'const example = { url: "", jsx: " }', + ].join('\n') + const source = [ + 'Use `{spacing.value}` and `` `literal` {value} ``.', + '', + '````tsx', + code, + '````', + ].join('\n') + const markdown = await renderMarkdown(source, { + config: { scope: { spacing: { value: '1rem' } } }, + }) + + expect(markdown).toContain('`{spacing.value}`') + expect(markdown).toContain('`` `literal` {value} ``') + expect(markdown).toContain(`\n${code}\n`) + expect(markdown).toMatch(/^`{4,}tsx\n/m) + expect(markdown).not.toContain('1rem') + }) + + it('exports every tab, package-manager command, diagram and declared theme preset', async () => { + const markdown = await renderMarkdown( + [ + '', + 'ReactVue', + 'React instructions', + 'Vue instructions', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ].join('\n'), + { + config: { + scope: { + diagram: 'graph TD; A-->B;', + presets: [ + { + name: 'lime', + label: 'Lime', + config: { source: '#aabbcc', colorMatch: true, scheme: 'tonalSpot' }, + }, + { + name: 'purple', + label: 'Purple', + config: { source: '#112233', colorMatch: false, scheme: 'expressive' }, + }, + ], + }, + }, + }, + ) + + expect(markdown).toContain('**React**') + expect(markdown).toContain('**Vue**') + expect(markdown).toContain('React instructions') + expect(markdown).toContain('Vue instructions') + expect(markdown).toContain('```sh\nnpm install example\n```') + expect(markdown).toContain('```sh\npnpm add example\n```') + expect(markdown).toContain('```mermaid\ngraph TD; A-->B;\n```') + expect(markdown).toMatch(/\| Lime\s*\| `source`\s*\| `#aabbcc`\s*\|/) + expect(markdown).toMatch(/\| Purple\s*\| `source`\s*\| `#112233`\s*\|/) + expect(markdown).toContain('`expressive`') + expect(markdown).not.toContain('tonalSpot') + expect(markdown).toContain('`primary`') + expect(markdown).toContain('`#abcdef`') + expect(markdown).not.toContain(' { + async function ServerValue({ value }: { value: string }) { + const resolved = await Promise.resolve(value) + return {resolved} + } + const Interactive = { + $$typeof: Symbol.for('react.client.reference'), + } as unknown as ComponentType<{ children?: ReactNode }> + const config = { + components: { ServerValue, Interactive }, + scope: { value: 'Resolved on server' }, + } + const source = '\n\nAuthored fallback' + const fallback = await renderMarkdown(source, { config }) + expect(fallback).toContain('**Resolved on server**') + expect(fallback).toContain('Interactive: interactive content is available on the website.') + expect(fallback).toContain('Authored fallback') + + const alternate = await renderMarkdown(source, { + config: { + ...config, + markdownComponents: { + Interactive: ({ children }: { children?: ReactNode }) => ( +
    Static representation: {children}
    + ), + }, + }, + }) + expect(alternate).toContain('> Static representation: Authored fallback') + expect(alternate).not.toContain('interactive content is available') + }) + + it('rejects unresolved scope expressions instead of silently exporting source or empty content', async () => { + await expect(renderMarkdown('| Value |\n| --- |\n| {missing.value} |')).rejects.toThrow( + /missing/, + ) + await expect(renderMarkdown('')).rejects.toThrow(/MissingComponent/) + }) + + it('omits executable and decorative HTML without losing surrounding article text', async () => { + const markdown = await renderMarkdown( + [ + 'Before the illustration.', + '', + '', + '', + '', + '', + '', + '', + 'After the illustration.', + ].join('\n'), + ) + + expect(markdown).toContain('Before the illustration.') + expect(markdown).toContain('After the illustration.') + expect(markdown).not.toContain('sentinel') + expect(markdown).not.toContain('decorative text') + expect(markdown).not.toContain('>[number] + +/** + * Compiles trusted MDX into document-only Markdown. Code nodes stay literal; expressions + * and JSX are evaluated with the same scope as the website before semantic serialization. + * @param source - MDX without frontmatter + * @param options - Consumer extensions and document context + */ +export async function renderMarkdown( + source: string, + { + config = {}, + entries = [], + relFilePath = '', + baseUrl, + }: { + /** Consumer components, static alternatives and expression values. */ + config?: DocsConfig + /** Pages available to the Entries component. */ + entries?: Pick[] + /** Current document path relative to the MDX directory. */ + relFilePath?: string + /** Base URL for document assets. */ + baseUrl?: string + } = {}, +) { + const components = { ...markdownComponents({ entries, relFilePath, baseUrl }) } + const customComponents = Object.fromEntries( + Object.entries(config.components ?? {}).map(([name, component]) => { + // Next keeps client components as opaque references in route handlers. Never try to + // execute one here or pretend its current browser state is available at build time. + const isClientReference = + '$$typeof' in component && component.$$typeof === Symbol.for('react.client.reference') + return [ + name, + isClientReference + ? ({ children }: { children?: ReactNode }) => ( +
    +

    {name}: interactive content is available on the website.

    + {children} +
    + ) + : component, + ] + }), + ) + const compiled = await compileMDX({ + source, + options: { + blockJS: false, + scope: config.scope, + mdxOptions: { + remarkPlugins: [remarkGfm, remarkAutolinks], + rehypePlugins: [ + rehypePreserveCodeMeta, + rehypeLink(process.env.BASE_PATH), + rehypeImg(relFilePath, baseUrl), + rehypeInlineFlow(['Badge', 'Color'], { except: ['ColorGroup'] }), + ], + }, + }, + components: { ...components, ...customComponents, ...config.markdownComponents }, + }) + + let failure: unknown + const stream = await renderToReadableStream(compiled.content, { + onError(error) { + failure = error + }, + }) + await stream.allReady + const html = await new Response(stream).text() + if (failure) throw failure + + const markdown = await unified() + .use(rehypeParse, { fragment: true }) + .use(() => (tree: Root) => { + // React text separators must not interrupt words or hide a leading task checkbox. + visit(tree, 'comment', (_node, index, parent) => { + if (parent && index !== undefined) { + parent.children.splice(index, 1) + return index + } + }) + }) + .use(rehypeRemark, { handlers: markdownHandlers }) + .use(remarkGfm) + .use(remarkStringify, { fences: true, bullet: '-', emphasis: '_', rule: '-' }) + .process(html) + return String(markdown) +} + +/** + * Exports a complete page, shared by the clipboard endpoint and llms-full.txt. + * @param doc - Parsed source document + * @param docs - Site entries for directory components + * @param root - MDX directory + * @param config - Consumer configuration + */ +export async function renderDocMarkdown( + doc: ParsedDoc, + docs: ParsedDoc[], + root: string, + config: DocsConfig, +) { + try { + return await renderMarkdown( + `# ${doc.title}\n\n${doc.description ? `${doc.description}\n\n` : ''}${doc.content}`, + { + config, + entries: docs, + relFilePath: `/${relative(root, doc.file).split('\\').join('/')}`, + baseUrl: process.env.MDX_BASEURL, + }, + ) + } catch (error) { + throw new Error(`Cannot export Markdown for ${doc.file}`, { cause: error }) + } +} diff --git a/src/utils/site-markdown.ts b/src/utils/site-markdown.ts new file mode 100644 index 000000000..ef0f638da --- /dev/null +++ b/src/utils/site-markdown.ts @@ -0,0 +1,19 @@ +import { validateDocsConfig } from '@/config' +import consumerConfig from 'pmndrs-docs-config' +import { renderDocMarkdown } from './renderMarkdown' + +const config = validateDocsConfig(consumerConfig) + +/** + * Exports a page with the same statically imported configuration as the website. + * @param doc - Source document + * @param docs - Site directory + * @param root - MDX directory + */ +export function getSiteMarkdown( + doc: Parameters[0], + docs: Parameters[1], + root: string, +) { + return renderDocMarkdown(doc, docs, root, config) +}