Generating Plain Markdown from a Dynamic MDX Documentation Site
A high-level overview of a client-side solution to generate a complete, portable Markdown document from a dynamic MDX page, including content from React components.
MDX offers significant flexibility for documentation sites, particularly within a "docs-as-code" workflow, by allowing the combination of Markdown with dynamic React components. These components can render content from various sources at runtime, such as:
- Reusable content blocks or multi-step "wizard" flows.
- Tables generated from JSON schema definitions.
- API documentation derived from TypeDoc or JSDoc output.
- Embedded code blocks and media assets.
This power, however, introduces a challenge: when a user wants to copy the page content as plain Markdown, the output of these dynamic components is typically not included in the source .mdx file. This article outlines a client-side strategy to solve this problem by transforming a rendered MDX page back into a complete, portable Markdown document.
Note: This article provides a high-level overview of the solution and implementation details for a Next.js-based documentation site, not a comprehensive step-by-step guide.
Solution Overview
The core task is to create a "copy as markdown" feature that correctly processes the dynamic nature of MDX. This involves several key steps, all executed on the client side:
- Locate Source: Identify and fetch the raw
.mdxsource file for the current page. - Conversion Pipeline: Run the raw MDX through a series of transformations.
- Component Expansion: Replace each custom React component tag with its corresponding Markdown output. This may involve fetching additional data, such as other MDX files or JSON schemas.
- Final Output: Assemble the fully-rendered Markdown and provide it to the user, for example, by copying it to the clipboard.
The high-level data flow can be visualized as follows:
┌───────────────┐ fetch raw .mdx ┌──────────────────────┐
│ Copy Button │ ─────────────────────▶ │ prepareMarkdown… │
└─────┬─────────┘ │ 1. Strip Imports │
│ │ 2. Expand Components │
│ write Markdown │ 3. Render Schemas │
▼ │ 4. Render API JSON │
┌───────────────┐ │ 5. Assemble Document │
│ Clipboard/AI │ ◀───────────────────── │ returns Markdown │
└───────────────┘
This entire process is handled client-side, relying on static resources shipped with the site and using regular expressions to parse the MDX and transform components into plain Markdown.
Implementation Strategy
The implementation can be broken down into a few key parts.
1. Trigger Component
First, a UI element, such as a button within a dropdown menu, is needed to initiate the process. This component must determine the path to the current page's source .mdx file. In a Next.js application, this can be derived from the router.
import { useMemo, useCallback } from 'react';
import { useRouter } from 'next/router';
export function MarkdownActionsDropdown() {
const router = useRouter();
const { pathname, asPath } = router;
// Build the raw GitHub URL for the current page's MDX file
const rawMarkdownUrl = useMemo(() => {
if (!pathname) return null;
const filePath = getPageMdxFilePath(pathname); // e.g., /sdk/abc → pages/sdk/abc.mdx
return filePath ? githubRawUrl(filePath) : null;
}, [pathname]);
const handleCopyMarkdown = useCallback(async () => {
if (!rawMarkdownUrl) return;
const response = await fetch(rawMarkdownUrl);
if (!response.ok) {
throw new Error(`Failed to fetch markdown: ${response.status}`);
}
const mdx = await response.text();
// The context path helps resolve versioned content, e.g., /versions/v1.0.0/
const markdown = await prepareMarkdownForCopyAsync(mdx, {
path: asPath ?? pathname ?? ''
});
await navigator.clipboard.writeText(markdown);
}, [rawMarkdownUrl, asPath, pathname]);
// Render UI with the handleCopyMarkdown handler
}
The path context is crucial for versioned documentation, as it allows the converter to fetch the correct version of data files (e.g., TypeDoc JSON) associated with a page.
2. The Conversion Pipeline
The prepareMarkdownForCopyAsync function orchestrates the transformation process in a series of sequential steps to ensure correctness.
export async function prepareMarkdownForCopyAsync(
rawContent: string,
context: { path?: string } = {}
) {
if (!rawContent) return '';
// Step 1: Extract YAML frontmatter
let { content, title, description } = extractFrontMatter(rawContent);
// Step 2: Identify schema imports before removing them
const schemaImports = extractSchemaImports(content);
// Step 3: Remove all MDX import statements
content = content.replace(IMPORT_STATEMENT_PATTERN, '');
// Step 4: Convert simple, stateless components to Markdown
content = convertBoxLinksToMarkdown(content);
content = convertContentSpotlightToMarkdown(content);
content = convertTerminalsToCodeBlocks(content);
// Step 5: Asynchronously expand dynamic components that load external content
content = await replaceSceneComponentsAsync(content, schemaImports);
content = await replaceSchemaComponentsAsync(content, schemaImports, context);
content = await replaceApiSectionsAsync(content, context);
// Step 6: Reassemble the document with frontmatter as headings
return assembleDocument({ title, description, content });
}
Processing simple, stateless components first (like links and code blocks) simplifies the subsequent steps by reducing the number of patterns to match.
A simple conversion function might look like this:
function convertTerminalsToCodeBlocks(content: string): string {
// Matches: <Terminal cmd={['$ npm install', '$ npm start']} />
const terminalPattern = /<Terminal\s+cmd=\{(\[[^\]]+\])\}\s*\/>/g;
return content.replace(terminalPattern, (match, cmdArrayString) => {
// Parse the JavaScript-like array string
const commands = JSON.parse(cmdArrayString.replace(/'/g, '"'));
return '```bash\n' + commands.join('\n') + '\n```';
});
}
This pattern—find component, extract props, return Markdown—is repeated for each component type.
3. Expanding Reusable Content (Scenes)
"Scene" components often encapsulate complex or reusable UI patterns, such as multi-step guides, by importing content from other files.
<!-- Source MDX -->
<Prerequisites />
<Configuration />
<Steps />
To convert this, the pipeline must fetch and transform the content of each scene file.
async function replaceSceneComponentsAsync(content: string) {
// Example for a <Steps /> component
if (content.includes('<Steps />')) {
const stepsMarkdown = await generateStepsMarkdownAsync();
content = content.replace(/<Steps\s*\/>/, stepsMarkdown);
}
return content;
}
async function generateStepsMarkdownAsync() {
const stepFiles = ['scenes/step1.mdx', 'scenes/step2.mdx'];
const sections = [];
for (const filePath of stepFiles) {
const rawMdx = await fetchSceneMdx(filePath);
// Recursively transform the scene's content
const markdown = transformSceneMdx(rawMdx);
sections.push(markdown);
}
return sections.join('\n\n');
}
The transformSceneMdx function re-applies the same conversion logic to handle any nested components within the scene file.
4. Converting API Documentation (TypeDoc/JSDoc)
API sections are often the most complex, as they render deeply nested JSON generated by tools like TypeDoc. A component like <APISection packageName="api-name" /> instructs the application to load a specific JSON file and render its contents.
The conversion requires a systematic transformation of the JSON structure into Markdown:
- Classes become H3 headings with properties and methods.
- Methods are formatted with their signature, description, parameters, and examples.
- Properties are displayed as inline code with their type and description.
- JSDoc Tags (
@param,@returns,@example) are parsed and formatted appropriately.
async function replaceApiSectionsAsync(content: string, context: { path?: string }) {
const apiPattern = /<APISection\s+packageName="([^"]+)"\s*\/>/g;
// Note: This is a simplified example. A real implementation
// would need to handle multiple matches and async replacements carefully.
for (const match of content.matchAll(apiPattern)) {
const packageName = match[1];
const version = resolveVersionFromPath(context.path);
const apiJson = await fetchPackageDataAsync(version, packageName);
const apiMarkdown = renderApiJsonToMarkdown(apiJson);
content = content.replace(match[0], apiMarkdown);
}
return content;
}
function renderApiJsonToMarkdown(apiJson) {
// Logic to iterate over classes, methods, etc., and build a Markdown string.
// ...
}
Because TypeDoc's JSON output is structured and predictable, you can write reliable transformation functions for each element type (class, interface, method).
A Generalizable Pattern
This pipeline architecture is extensible. To support a new custom component, you simply add another transformation function that follows the same pattern:
- Match: Use a regular expression to find all instances of the component tag.
- Fetch: Load any external data the component requires (if any).
- Transform: Convert the component's props and fetched data into a Markdown string.
- Replace: Substitute the original component tag with the generated Markdown.
Conclusion
Implementing a "copy as markdown" feature for a dynamic MDX site is achievable with a client-side conversion pipeline. By treating MDX components as structured data sources, you can systematically deconstruct a page, fetch and transform its dynamic parts, and reassemble it into a clean, portable Markdown document. This approach provides a robust solution that can evolve alongside your documentation.