MDX: Support referencing heading in <include>

This commit is contained in:
Fuma Nama
2025-08-30 18:17:06 +08:00
parent 2242688f4e
commit 9cb829c4da
6 changed files with 167 additions and 104 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-mdx': patch
---
Support referencing heading in `<include>` without sections
-1
View File
@@ -72,7 +72,6 @@
"@types/react-dom": "^19.1.8",
"algoliasearch": "5.36.0",
"eslint-config-custom": "workspace:*",
"fumadocs-obsidian": "workspace:*",
"gray-matter": "^4.0.3",
"next-validate-link": "^1.5.2",
"postcss": "^8.5.6",
+3 -1
View File
@@ -63,6 +63,7 @@
"types:check": "tsc --noEmit"
},
"dependencies": {
"unified": "^11.0.5",
"@mdx-js/mdx": "^3.1.0",
"@standard-schema/spec": "^1.0.0",
"chokidar": "^4.0.3",
@@ -71,6 +72,8 @@
"js-yaml": "^4.1.0",
"lru-cache": "^11.1.0",
"picocolors": "^1.1.1",
"remark-mdx": "^3.1.0",
"remark-parse": "^11.0.0",
"tinyexec": "^1.0.1",
"tinyglobby": "^0.2.14",
"unist-util-visit": "^5.0.0",
@@ -90,7 +93,6 @@
"react": "^19.1.1",
"rollup": "^4.49.0",
"tsconfig": "workspace:*",
"unified": "^11.0.5",
"vfile": "^6.0.3",
"vite": "^7.1.3",
"webpack": "^5.101.3"
+104 -89
View File
@@ -1,10 +1,22 @@
import type { Processor, Transformer } from 'unified';
import { type Processor, type Transformer, unified } from 'unified';
import { visit } from 'unist-util-visit';
import type { Code, Root, RootContent } from 'mdast';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { fumaMatter } from '@/utils/fuma-matter';
import type { DataMap } from '@/utils/build-mdx';
import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx';
import remarkParse from 'remark-parse';
import remarkMdx from 'remark-mdx';
import { remarkHeading } from 'fumadocs-core/mdx-plugins';
export interface Params {
lang?: string;
meta?: string;
}
const mdProcessor = unified().use(remarkParse).use(remarkHeading);
const mdxProcessor = mdProcessor.use(remarkMdx);
function flattenNode(node: RootContent): string {
if ('children' in node)
@@ -30,6 +42,8 @@ function parseSpecifier(specifier: string): {
// do a swallow extract
function extractSection(root: Root, section: string): Root | undefined {
let nodes: RootContent[] | undefined;
for (const node of root.children) {
if (
node.type === 'mdxJsxFlowElement' &&
@@ -41,117 +55,118 @@ function extractSection(root: Root, section: string): Root | undefined {
attr.value === section,
)
) {
return {
type: 'root',
children: node.children,
};
nodes = node.children;
break;
}
if (node.type === 'heading' && node.data?.hProperties === section) {
nodes = [node];
continue;
}
if (!nodes) continue;
if (node.type === 'heading') break;
nodes.push(node);
}
if (nodes)
return {
type: 'root',
children: nodes,
};
}
export function remarkInclude(this: Processor): Transformer<Root, Root> {
const TagName = 'include';
async function update(
this: Processor,
tree: Root,
directory: string,
async function embedContent(
file: string,
heading: string | undefined,
params: Params,
data: Partial<DataMap>,
) {
let content: string;
try {
content = (await fs.readFile(file)).toString();
} catch (e) {
throw new Error(
`failed to read file ${file}\n${e instanceof Error ? e.message : String(e)}`,
{ cause: e },
);
}
const ext = path.extname(file);
data._compiler?.addDependency(file);
if (params.lang || (ext !== '.md' && ext !== '.mdx')) {
const lang = params.lang ?? ext.slice(1);
return {
type: 'code',
lang,
meta: params.meta,
value: content,
data: {},
} satisfies Code;
}
const processor = ext === '.mdx' ? mdxProcessor : mdProcessor;
let parsed = await processor.run(
processor.parse(fumaMatter(content).content),
);
if (heading) {
const extracted = extractSection(parsed, 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.`,
);
parsed = extracted;
}
await update(parsed, path.dirname(file), data);
return parsed;
}
async function update(tree: Root, directory: string, data: Partial<DataMap>) {
const queue: Promise<void>[] = [];
visit(
tree,
['mdxJsxFlowElement', 'mdxJsxTextElement'],
(node, _, parent) => {
let specifier: string | undefined;
(_node, _, parent) => {
const node = _node as MdxJsxFlowElement | MdxJsxTextElement;
if (node.name !== TagName) return;
const params: Record<string, string | null> = {};
const specifier = flattenNode(node);
if (specifier.length === 0) return 'skip';
if (
(node.type === 'mdxJsxFlowElement' ||
node.type === 'mdxJsxTextElement') &&
node.name === TagName
) {
const value = flattenNode(node);
if (value.length > 0) {
for (const attr of node.attributes) {
if (
attr.type === 'mdxJsxAttribute' &&
(typeof attr.value === 'string' || attr.value === null)
) {
params[attr.name] = attr.value;
}
}
specifier = value;
for (const attr of node.attributes) {
if (
attr.type === 'mdxJsxAttribute' &&
(typeof attr.value === 'string' || attr.value === null)
) {
params[attr.name] = attr.value;
}
}
if (!specifier) return;
const { file, section } = parseSpecifier(specifier);
const { file: relativePath, section } = parseSpecifier(specifier);
const targetPath = path.resolve(
const file = path.resolve(
'cwd' in params ? process.cwd() : directory,
file,
relativePath,
);
const asCode =
params.lang || (!file.endsWith('.md') && !file.endsWith('.mdx'));
queue.push(
fs
.readFile(targetPath)
.then((buffer) => buffer.toString())
.then(async (content) => {
data._compiler?.addDependency(targetPath);
if (asCode) {
const lang = params.lang ?? path.extname(file).slice(1);
Object.assign(node, {
type: 'code',
lang,
meta: params.meta,
value: content,
data: {},
} satisfies Code);
return;
}
const processor = data._processor
? data._processor.getProcessor(
targetPath.endsWith('.md') ? 'md' : 'mdx',
)
: this;
let parsed = processor.parse(fumaMatter(content).content) as Root;
if (section) {
const extracted = extractSection(parsed, section);
if (!extracted)
throw new Error(
`Cannot find section ${section} in ${file}, make sure you have encapsulated the section in a <section id="${section}"> tag`,
);
parsed = extracted;
}
await update.call(
processor as any,
parsed,
path.dirname(targetPath),
data,
);
Object.assign(
parent && parent.type === 'paragraph' ? parent : node,
parsed,
);
})
.catch((e) => {
throw new Error(
`failed to read file ${targetPath}\n${e instanceof Error ? e.message : String(e)}`,
{ cause: e },
);
}),
embedContent(file, section, params, data).then((replace) => {
Object.assign(
parent && parent.type === 'paragraph' ? parent : node,
replace,
);
}),
);
return 'skip';
@@ -162,6 +177,6 @@ export function remarkInclude(this: Processor): Transformer<Root, Root> {
}
return async (tree, file) => {
await update.call(this, tree, path.dirname(file.path), file.data);
await update(tree, path.dirname(file.path), file.data);
};
}
-7
View File
@@ -52,10 +52,6 @@ declare module 'vfile' {
* The compiler object from loader
*/
_compiler?: CompilerOptions;
_processor?: {
getProcessor: (format: 'md' | 'mdx') => Processor;
};
}
}
@@ -98,9 +94,6 @@ export async function buildMDX(
...data,
frontmatter,
_compiler,
_processor: {
getProcessor,
},
},
});
}
+55 -6
View File
@@ -234,9 +234,6 @@ importers:
eslint-config-custom:
specifier: workspace:*
version: link:../../packages/eslint-config-custom
fumadocs-obsidian:
specifier: workspace:*
version: link:../../packages/obsidian
gray-matter:
specifier: ^4.0.3
version: 4.0.3
@@ -443,6 +440,52 @@ importers:
specifier: ^5.9.2
version: 5.9.2
examples/obsidian:
dependencies:
fumadocs-core:
specifier: workspace:*
version: link:../../packages/core
fumadocs-mdx:
specifier: workspace:*
version: link:../../packages/mdx
fumadocs-obsidian:
specifier: workspace:*
version: link:../../packages/obsidian
fumadocs-ui:
specifier: workspace:*
version: link:../../packages/ui
next:
specifier: 15.5.2
version: 15.5.2(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
react:
specifier: 19.1.1
version: 19.1.1
react-dom:
specifier: 19.1.1
version: 19.1.1([email protected])
devDependencies:
'@tailwindcss/postcss':
specifier: ^4.1.12
version: 4.1.12
'@types/mdx':
specifier: ^2.0.13
version: 2.0.13
'@types/react':
specifier: ^19.1.12
version: 19.1.12
'@types/react-dom':
specifier: ^19.1.8
version: 19.1.8(@types/[email protected])
postcss:
specifier: ^8.5.6
version: 8.5.6
tailwindcss:
specifier: ^4.1.12
version: 4.1.12
typescript:
specifier: ^5.9.2
version: 5.9.2
examples/openapi:
dependencies:
fumadocs-core:
@@ -1415,12 +1458,21 @@ importers:
picocolors:
specifier: ^1.1.1
version: 1.1.1
remark-mdx:
specifier: ^3.1.0
version: 3.1.0
remark-parse:
specifier: ^11.0.0
version: 11.0.0
tinyexec:
specifier: ^1.0.1
version: 1.0.1
tinyglobby:
specifier: ^0.2.14
version: 0.2.14
unified:
specifier: ^11.0.5
version: 11.0.5
unist-util-visit:
specifier: ^5.0.0
version: 5.0.0
@@ -1467,9 +1519,6 @@ importers:
tsconfig:
specifier: workspace:*
version: link:../tsconfig
unified:
specifier: ^11.0.5
version: 11.0.5
vfile:
specifier: ^6.0.3
version: 6.0.3