MDX: Support remark-directive for Include API

This commit is contained in:
Fuma Nama
2025-09-22 21:45:44 +08:00
parent 6d22648c53
commit 5fc9ee4b9f
16 changed files with 408 additions and 92 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-mdx': patch
---
Support `remark-directive` for Include API
+3 -3
View File
@@ -84,7 +84,7 @@ export default function CustomSearchDialog(props: SharedProps) {
client,
tag,
});
const { root } = useTreeContext();
const { full } = useTreeContext();
const router = useRouter();
const pageTreeActions = useMemo(() => {
const actions: SearchItemType[] = [];
@@ -95,7 +95,7 @@ export default function CustomSearchDialog(props: SharedProps) {
(item) =>
typeof item.name === 'string' &&
item.name.toLowerCase().startsWith(normalized),
root.children,
full.children,
);
if (result) {
@@ -118,7 +118,7 @@ export default function CustomSearchDialog(props: SharedProps) {
}
return actions;
}, [root.children, router, search]);
}, [full.children, router, search]);
return (
<SearchDialog
+81 -10
View File
@@ -5,24 +5,43 @@ description: Reuse content from other files.
## Usage
### Markdown
### Content
To display content from another Markdown/MDX file, specify its path relative to the file itself in the `<include>` tag.
```mdx title="page.mdx"
```mdx tab="MDX"
<include>./another.mdx</include>
```
<Callout>
For Markdown files, you don't need to escape for MDX syntax but note that
you'll need `rehypeRaw` for HTML comments & content.
```md tab="Markdown"
::include[./another.mdx]
```
<Callout title='For Markdown'>
Markdown (`.md`) files do not support JSX syntax. To use `<include />`, you need to configure `remark-directive`.
```ts title="source.config.ts"
import { defineConfig } from 'fumadocs-mdx/config';
import remarkDirective from 'remark-directive';
export default defineConfig({
mdxOptions: {
// [!code ++]
remarkPlugins: [remarkDirective],
},
});
```
You may also need `rehype-raw` for HTML comments & content.
</Callout>
### CodeBlock
For other types of files, it will become a codeblock:
```mdx title="page.mdx"
```mdx tab="MDX"
<include>./script.ts</include>
<include lang="md" meta='title="lib.md"'>
@@ -30,19 +49,33 @@ For other types of files, it will become a codeblock:
</include>
```
```md tab="Markdown"
::include[./script.ts]
::include[page.md]{lang=md meta='title="lib.md"'}
```
### `cwd`
Resolve relative paths from cwd instead of the Markdown file:
Resolve relative paths from cwd instead of the current file:
```mdx
```mdx tab="MDX"
<include cwd lang="tsx" meta='title="lib.ts"'>
./script.ts
</include>
```
```md tab="Markdown"
::include[./script.ts]{cwd lang=tsx meta='title="lib.ts"'}
```
## Extraction
When referencing content files, you can only include a certain part of the document.
### Section
For Markdown files, you can include a section of it:
Encapsulate by a `<section />` tag:
```mdx tab="a.mdx"
## Hello World
@@ -58,7 +91,27 @@ Include the content from `a.mdx`:
<include>a.mdx#test</include>
```
Or with heading id:
In Markdown:
```md tab="a.md"
## Hello World
:::section{#test}
This is included
:::
This is not included.
```
```md tab="b.md"
Include the content from `a.mdx`:
::include[a.md#test]
```
### Heading
Include contents under a certain heading
```mdx tab="a.mdx"
## Included Section
@@ -75,3 +128,21 @@ Content under the heading:
<include>a.mdx#included-section</include>
```
In Markdown:
```md tab="a.md"
## Included Section
I'm here!
## Not Included
Some random text.
```
```md tab="b.md"
Content under the heading:
::include[a.mdx#included-section]
```
+3
View File
@@ -92,9 +92,12 @@
"@types/react": "^19.1.13",
"eslint-config-custom": "workspace:*",
"fumadocs-core": "workspace:*",
"remark-directive": "^4.0.0",
"mdast-util-directive": "^3.1.0",
"mdast-util-mdx-jsx": "^3.2.0",
"next": "^15.5.3",
"react": "^19.1.1",
"remark": "^15.0.1",
"rollup": "^4.50.1",
"tsconfig": "workspace:*",
"vfile": "^6.0.3",
+1 -1
View File
@@ -129,7 +129,7 @@ export async function buildMDX(
}
return getProcessor(
(options.format ?? filePath.endsWith('.mdx')) ? 'mdx' : 'md',
options.format ?? (filePath.endsWith('.mdx') ? 'mdx' : 'md'),
).process({
value: source,
path: filePath,
+98 -68
View File
@@ -1,6 +1,6 @@
import { type Processor, type Transformer, unified } from 'unified';
import { visit } from 'unist-util-visit';
import type { Code, Root, RootContent } from 'mdast';
import type { Code, Node, Root, RootContent } from 'mdast';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { fumaMatter } from '@/utils/fuma-matter';
@@ -9,13 +9,46 @@ import remarkParse from 'remark-parse';
import remarkMdx from 'remark-mdx';
import { remarkHeading } from 'fumadocs-core/mdx-plugins';
import type { DataMap } from 'vfile';
import type { Directives } from 'mdast-util-directive';
export interface Params {
lang?: string;
meta?: string;
}
const baseProcessor = unified().use(remarkHeading);
const ElementLikeTypes: ElementLikeContent['type'][] = [
'mdxJsxFlowElement',
'mdxJsxTextElement',
'containerDirective',
'textDirective',
'leafDirective',
];
type ElementLikeContent = MdxJsxFlowElement | MdxJsxTextElement | Directives;
function isElementLike(node: Node): node is ElementLikeContent {
return ElementLikeTypes.includes(node.type as ElementLikeContent['type']);
}
function parseElementAttributes(
element: ElementLikeContent,
): Record<string, string | null | undefined> {
if (Array.isArray(element.attributes)) {
const attributes: Record<string, string | null> = {};
for (const attr of element.attributes) {
if (
attr.type === 'mdxJsxAttribute' &&
(typeof attr.value === 'string' || attr.value === null)
) {
attributes[attr.name] = attr.value;
}
}
return attributes;
}
return element.attributes ?? {};
}
function flattenNode(node: RootContent): string {
if ('children' in node)
@@ -39,34 +72,39 @@ function parseSpecifier(specifier: string): {
};
}
// do a swallow extract
function extractSection(root: Root, section: string): Root | undefined {
let nodes: RootContent[] | undefined;
let capturingHeadingContent = false;
for (const node of root.children) {
if (
node.type === 'mdxJsxFlowElement' &&
node.name === 'section' &&
node.attributes.some(
(attr) =>
attr.type === 'mdxJsxAttribute' &&
attr.name === 'id' &&
attr.value === section,
)
) {
nodes = node.children;
break;
visit(root, (node) => {
if (node.type === 'heading') {
if (capturingHeadingContent) {
return false;
}
if (node.data?.hProperties?.id === section) {
capturingHeadingContent = true;
nodes = [node];
return 'skip';
}
return;
}
if (node.type === 'heading' && node.data?.hProperties?.id === section) {
nodes = [node];
continue;
if (capturingHeadingContent) {
nodes?.push(node as RootContent);
return 'skip';
}
if (!nodes) continue;
if (node.type === 'heading') break;
nodes.push(node);
}
if (isElementLike(node) && node.name === 'section') {
const attributes = parseElementAttributes(node);
if (attributes.id === section) {
nodes = node.children;
return false;
}
}
});
if (nodes)
return {
@@ -109,70 +147,62 @@ export function remarkInclude(this: Processor): Transformer<Root, Root> {
} satisfies Code;
}
const processor = (data._getProcessor ?? getDefaultProcessor)(
const parser = (data._getProcessor ?? getDefaultProcessor)(
ext === '.mdx' ? 'mdx' : 'md',
);
let parsed = await baseProcessor.run(
processor.parse(fumaMatter(content).content),
);
const parsed = fumaMatter(content);
let mdast = parser.parse({
path: file,
value: parsed.content,
data: { frontmatter: parsed.data as Record<string, unknown> },
});
if (heading) {
const extracted = extractSection(parsed, heading);
// parse headings before extraction
const extracted = extractSection(
await unified().use(remarkHeading).run(mdast),
heading,
);
if (!extracted)
throw new Error(
`Cannot find section ${heading} in ${file}, make sure you have encapsulated the section in a <section id="${heading}"> tag.`,
`Cannot find section ${heading} in ${file}, make sure you have encapsulated the section in a <section id="${heading}"> tag, or a :::section directive with remark-directive configured.`,
);
parsed = extracted;
mdast = extracted;
}
await update(parsed, path.dirname(file), data);
return parsed;
await update(mdast, path.dirname(file), data);
return mdast;
}
async function update(tree: Root, directory: string, data: Partial<DataMap>) {
const queue: Promise<void>[] = [];
visit(
tree,
['mdxJsxFlowElement', 'mdxJsxTextElement'],
(_node, _, parent) => {
const node = _node as MdxJsxFlowElement | MdxJsxTextElement;
if (node.name !== TagName) return;
visit(tree, ElementLikeTypes, (_node, _, parent) => {
const node = _node as ElementLikeContent;
if (node.name !== TagName) return;
const params: Record<string, string | null> = {};
const specifier = flattenNode(node);
if (specifier.length === 0) return 'skip';
const specifier = flattenNode(node);
if (specifier.length === 0) return 'skip';
for (const attr of node.attributes) {
if (
attr.type === 'mdxJsxAttribute' &&
(typeof attr.value === 'string' || attr.value === null)
) {
params[attr.name] = attr.value;
}
}
const attributes = parseElementAttributes(node);
const { file: relativePath, section } = parseSpecifier(specifier);
const file = path.resolve(
'cwd' in attributes ? process.cwd() : directory,
relativePath,
);
const { file: relativePath, section } = parseSpecifier(specifier);
queue.push(
embedContent(file, section, attributes, data).then((replace) => {
Object.assign(
parent && parent.type === 'paragraph' ? parent : node,
replace,
);
}),
);
const file = path.resolve(
'cwd' in params ? process.cwd() : directory,
relativePath,
);
queue.push(
embedContent(file, section, params, data).then((replace) => {
Object.assign(
parent && parent.type === 'paragraph' ? parent : node,
replace,
);
}),
);
return 'skip';
},
);
return 'skip';
});
await Promise.all(queue);
}
+1 -1
View File
@@ -3,4 +3,4 @@ import { _runtimeAsync, buildConfig } from "fumadocs-mdx/runtime/async"
const _sourceConfig = buildConfig(_source)
import { _runtime } from "fumadocs-mdx"
import * as _source from "./config"
export const docs = _runtimeAsync.doc<typeof _source.docs>([{"info":{"path":"index.mdx","fullPath":"$cwd/packages/mdx/test/fixtures/index.mdx"},"data":{},"content":{"body":"# Hello World\n","matter":""}}, {"info":{"path":"folder/test.mdx","fullPath":"$cwd/packages/mdx/test/fixtures/folder/test.mdx"},"data":{},"content":{"body":"","matter":""}}], "docs", _sourceConfig)
export const docs = _runtimeAsync.doc<typeof _source.docs>([{"info":{"path":"index.mdx","fullPath":"$cwd/packages/mdx/test/fixtures/generate-index/index.mdx"},"data":{},"content":{"body":"# Hello World\n","matter":""}}, {"info":{"path":"folder/test.mdx","fullPath":"$cwd/packages/mdx/test/fixtures/generate-index/folder/test.mdx"},"data":{},"content":{"body":"","matter":""}}], "docs", _sourceConfig)
+3 -3
View File
@@ -1,6 +1,6 @@
// @ts-nocheck -- skip type checking
import * as docs_1 from "./folder/test.mdx?collection=docs&hash=hash"
import * as docs_0 from "./index.mdx?collection=docs&hash=hash"
import * as docs_1 from "./generate-index/folder/test.mdx?collection=docs&hash=hash"
import * as docs_0 from "./generate-index/index.mdx?collection=docs&hash=hash"
import { _runtime } from "fumadocs-mdx"
import * as _source from "./config"
export const docs = _runtime.doc<typeof _source.docs>([{ info: {"path":"index.mdx","fullPath":"$cwd/packages/mdx/test/fixtures/index.mdx"}, data: docs_0 }, { info: {"path":"folder/test.mdx","fullPath":"$cwd/packages/mdx/test/fixtures/folder/test.mdx"}, data: docs_1 }]);
export const docs = _runtime.doc<typeof _source.docs>([{ info: {"path":"index.mdx","fullPath":"$cwd/packages/mdx/test/fixtures/generate-index/index.mdx"}, data: docs_0 }, { info: {"path":"folder/test.mdx","fullPath":"$cwd/packages/mdx/test/fixtures/generate-index/folder/test.mdx"}, data: docs_1 }]);
+29
View File
@@ -0,0 +1,29 @@
## Using JSX
Full:
<include>./test.mdx</include>
First Heading:
<include>./test.mdx#heading</include>
In JSX:
<include>./test.mdx#section-in-jsx</include>
In Directive:
<include>./test.mdx#section-in-directive</include>
## Using Directives
In Directive:
:::include
./test.mdx#section-in-directive
:::
Inline Directive:
::include[./test.mdx#section-in-directive]
+75
View File
@@ -0,0 +1,75 @@
## Using JSX
Full:
# Hello World \[#heading]
Hey there!
<section id="section-in-jsx">
This is My Test.
### Nested Heading
```ts
console.log('Hello World');
```
</section>
:::section{#section-in-directive}
### Heading in Directive
some content inside.
:::
First Heading:
# Hello World
Hey there!
<section id="section-in-jsx">
This is My Test.
### Nested Heading
```ts
console.log('Hello World');
```
</section>
:::section{#section-in-directive}
### Heading in Directive
some content inside.
:::
In JSX:
This is My Test.
### Nested Heading
```ts
console.log('Hello World');
```
In Directive:
### Heading in Directive
some content inside.
## Using Directives
In Directive:
### Heading in Directive
some content inside.
Inline Directive:
### Heading in Directive
some content inside.
+23
View File
@@ -0,0 +1,23 @@
# Hello World [#heading]
Hey there!
<section id="section-in-jsx">
This is My Test.
### Nested Heading
```ts
console.log('Hello World');
```
</section>
:::section{#section-in-directive}
### Heading in Directive
some content inside.
:::
+5 -6
View File
@@ -38,21 +38,20 @@ test('format errors', async () => {
}
});
const file = path.dirname(fileURLToPath(import.meta.url));
const dir = path.dirname(fileURLToPath(import.meta.url));
const cases = [
{
name: 'sync',
collection: defineCollections({
type: 'doc',
dir: path.join(file, './fixtures'),
dir: path.join(dir, './fixtures/generate-index'),
}),
},
{
name: 'async',
collection: defineCollections({
type: 'doc',
dir: path.join(file, './fixtures'),
dir: path.join(dir, './fixtures/generate-index'),
async: true,
}),
},
@@ -61,7 +60,7 @@ const cases = [
for (const { name, collection } of cases) {
test(`generate JS index file: ${name}`, async () => {
const out = await generateJS(
path.join(file, './fixtures/config.ts'),
path.join(dir, './fixtures/config.ts'),
{
// @ts-expect-error -- test file
_runtime: {
@@ -70,7 +69,7 @@ for (const { name, collection } of cases) {
collections: new Map([['docs', collection]]),
},
{
relativeTo: path.join(file, './fixtures'),
relativeTo: path.join(dir, './fixtures'),
},
'hash',
);
+30
View File
@@ -0,0 +1,30 @@
import { expect, test } from 'vitest';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import remarkMdx from 'remark-mdx';
import { remarkInclude } from '@/mdx/remark-include';
import remarkDirective from 'remark-directive';
import fs from 'node:fs/promises';
import { remark } from 'remark';
const dir = path.dirname(fileURLToPath(import.meta.url));
const processor = remark()
.use(remarkMdx)
.use(remarkInclude)
.use(remarkDirective);
test('remark include', async () => {
const file = path.join(dir, 'fixtures/remark-include/index.mdx');
const out = await processor.process({
path: file,
value: await fs.readFile(file),
data: {
_getProcessor() {
return processor as any;
},
},
});
await expect(String(out.value)).toMatchFileSnapshot(
'fixtures/remark-include/index.out.mdx',
);
});
+51
View File
@@ -1604,6 +1604,9 @@ importers:
fumadocs-core:
specifier: workspace:*
version: link:../core
mdast-util-directive:
specifier: ^3.1.0
version: 3.1.0
mdast-util-mdx-jsx:
specifier: ^3.2.0
version: 3.2.0
@@ -1613,6 +1616,12 @@ importers:
react:
specifier: ^19.1.1
version: 19.1.1
remark:
specifier: ^15.0.1
version: 15.0.1
remark-directive:
specifier: ^4.0.0
version: 4.0.0
rollup:
specifier: ^4.50.1
version: 4.50.1
@@ -8047,6 +8056,9 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==}
[email protected]:
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
@@ -8145,6 +8157,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
[email protected]:
resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==}
[email protected]:
resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==}
@@ -9230,6 +9245,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==}
[email protected]:
resolution: {integrity: sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==}
[email protected]:
resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==}
@@ -17806,6 +17824,20 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@types/mdast': 4.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
parse-entities: 4.0.2
stringify-entities: 4.0.4
unist-util-visit-parents: 6.0.1
transitivePeerDependencies:
- supports-color
[email protected]:
dependencies:
'@types/mdast': 4.0.4
@@ -18069,6 +18101,16 @@ snapshots:
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
[email protected]:
dependencies:
devlop: 1.1.0
micromark-factory-space: 2.0.1
micromark-factory-whitespace: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
parse-entities: 4.0.2
[email protected]:
dependencies:
fault: 2.0.1
@@ -19492,6 +19534,15 @@ snapshots:
hast-util-to-html: 9.0.5
unified: 11.0.5
[email protected]:
dependencies:
'@types/mdast': 4.0.4
mdast-util-directive: 3.1.0
micromark-extension-directive: 4.0.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
[email protected]:
dependencies:
'@types/mdast': 4.0.4