diff --git a/.changeset/cuddly-lamps-count.md b/.changeset/cuddly-lamps-count.md
new file mode 100644
index 000000000..47116b433
--- /dev/null
+++ b/.changeset/cuddly-lamps-count.md
@@ -0,0 +1,5 @@
+---
+'fumadocs-mdx': minor
+---
+
+Support reusing content with `include` tag
diff --git a/examples/next-mdx/content/docs/index.mdx b/examples/next-mdx/content/docs/index.mdx
index 4fecf9d76..8aab91a9e 100644
--- a/examples/next-mdx/content/docs/index.mdx
+++ b/examples/next-mdx/content/docs/index.mdx
@@ -31,3 +31,5 @@ Hello World dsasfdafsd
| very **important** | Hey |
| _Surprisingly_ | Fumadocs |
| very long text that looks weird | hello world hello world hello world |
+
+./test.mdx
diff --git a/packages/mdx/package.json b/packages/mdx/package.json
index 1f35e8505..1f60545d1 100644
--- a/packages/mdx/package.json
+++ b/packages/mdx/package.json
@@ -50,6 +50,7 @@
"fast-glob": "^3.3.1",
"gray-matter": "^4.0.3",
"micromatch": "^4.0.8",
+ "unist-util-visit": "^5.0.0",
"zod": "^3.24.1"
},
"devDependencies": {
@@ -60,6 +61,7 @@
"@types/react": "^19.0.1",
"eslint-config-custom": "workspace:*",
"fumadocs-core": "workspace:*",
+ "mdast-util-mdx-jsx": "^3.1.3",
"next": "^15.1.1",
"tsconfig": "workspace:*",
"unified": "^11.0.5",
diff --git a/packages/mdx/src/config/load.ts b/packages/mdx/src/config/load.ts
index ba9223d8a..3bdb10c44 100644
--- a/packages/mdx/src/config/load.ts
+++ b/packages/mdx/src/config/load.ts
@@ -1,9 +1,10 @@
import * as path from 'node:path';
import { pathToFileURL } from 'node:url';
import { build } from 'esbuild';
-import { validateConfig } from '@/config/validate';
import type { DocCollection, MetaCollection } from '@/config/define';
import { type GlobalConfig } from '@/config/types';
+import type { ProcessorOptions } from '@mdx-js/mdx';
+import { getDefaultMDXOptions } from '@/utils/mdx-options';
export function findConfigFile(): string {
return path.resolve('source.config.ts');
@@ -11,6 +12,7 @@ export function findConfigFile(): string {
export interface LoadedConfig {
collections: Map;
+ defaultMdxOptions: ProcessorOptions;
global?: GlobalConfig;
_runtime: {
@@ -48,7 +50,7 @@ export async function loadConfig(configPath: string): Promise {
}
const url = pathToFileURL(outputPath);
- const [err, config] = validateConfig(
+ const [err, config] = buildConfig(
// every call to `loadConfig` will cause the previous cache to be ignored
(await import(`${url.toString()}?hash=${Date.now().toString()}`)) as Record<
string,
@@ -59,3 +61,46 @@ export async function loadConfig(configPath: string): Promise {
if (err !== null) throw new Error(err);
return config;
}
+
+function buildConfig(
+ config: Record,
+): [err: string, value: null] | [err: null, value: LoadedConfig] {
+ const collections: LoadedConfig['collections'] = new Map();
+ let globalConfig: LoadedConfig['global'];
+
+ for (const [k, v] of Object.entries(config)) {
+ if (!v) {
+ continue;
+ }
+
+ if (typeof v === 'object' && '_doc' in v && v._doc === 'collections') {
+ collections.set(
+ k,
+ v as unknown as InternalMetaCollection | InternalDocCollection,
+ );
+ continue;
+ }
+
+ if (k === 'default') {
+ globalConfig = v as GlobalConfig;
+ continue;
+ }
+
+ return [
+ `Unknown export "${k}", you can only export collections from source configuration file.`,
+ null,
+ ];
+ }
+
+ return [
+ null,
+ {
+ global: globalConfig,
+ collections,
+ defaultMdxOptions: getDefaultMDXOptions(globalConfig?.mdxOptions ?? {}),
+ _runtime: {
+ files: new Map(),
+ },
+ },
+ ];
+}
diff --git a/packages/mdx/src/config/validate.ts b/packages/mdx/src/config/validate.ts
deleted file mode 100644
index a4560f2b0..000000000
--- a/packages/mdx/src/config/validate.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import {
- type InternalDocCollection,
- type InternalMetaCollection,
- type LoadedConfig,
-} from '@/config/load';
-import { type GlobalConfig } from '@/config/types';
-
-export function validateConfig(
- config: Record,
-): [err: string, value: null] | [err: null, value: LoadedConfig] {
- const out: LoadedConfig = {
- collections: new Map(),
- _runtime: {
- files: new Map(),
- },
- };
-
- for (const [k, v] of Object.entries(config)) {
- if (!v) {
- continue;
- }
-
- if (typeof v === 'object' && '_doc' in v && v._doc === 'collections') {
- out.collections.set(
- k,
- v as unknown as InternalMetaCollection | InternalDocCollection,
- );
- continue;
- }
-
- if (k === 'default') {
- out.global = v as GlobalConfig;
- continue;
- }
-
- return [
- `Unknown export "${k}", you can only export collections from source configuration file.`,
- null,
- ];
- }
-
- return [null, out];
-}
diff --git a/packages/mdx/src/loader-mdx.ts b/packages/mdx/src/loader-mdx.ts
index d4227ab03..4ce11b7cc 100644
--- a/packages/mdx/src/loader-mdx.ts
+++ b/packages/mdx/src/loader-mdx.ts
@@ -6,7 +6,7 @@ import { type LoaderContext } from 'webpack';
import { type StructuredData } from 'fumadocs-core/mdx-plugins';
import { getConfigHash, loadConfigCached } from '@/config/cached';
import { buildMDX } from '@/utils/build-mdx';
-import { getDefaultMDXOptions, type TransformContext } from '@/config';
+import { type TransformContext } from '@/config';
import { getManifestEntryPath } from '@/map/manifest';
import { formatError } from '@/utils/format-error';
import { getGitTimestamp } from './utils/git-timestamp';
@@ -85,13 +85,11 @@ export default async function loader(
collection = undefined;
}
- const mdxOptions =
- collection?.mdxOptions ??
- getDefaultMDXOptions(config.global?.mdxOptions ?? {});
+ const mdxOptions = collection?.mdxOptions ?? config.defaultMdxOptions;
function getTransformContext(): TransformContext {
return {
- buildMDX: async (v, options = mdxOptions) => {
+ async buildMDX(v, options = mdxOptions) {
const res = await buildMDX(
collectionId ?? 'global',
configHash,
@@ -148,6 +146,7 @@ export default async function loader(
data: {
lastModified: timestamp,
},
+ _compiler: this,
},
);
diff --git a/packages/mdx/src/map/generate.ts b/packages/mdx/src/map/generate.ts
index bd63f4894..95dad8fd1 100644
--- a/packages/mdx/src/map/generate.ts
+++ b/packages/mdx/src/map/generate.ts
@@ -12,7 +12,7 @@ export async function generateJS(
configPath: string,
config: LoadedConfig,
outputPath: string,
- hash: string,
+ configHash: string,
getFrontmatter: (file: string) => Promise,
): Promise {
const outDir = path.dirname(outputPath);
@@ -33,7 +33,7 @@ export async function generateJS(
config._runtime.files.set(file.absolutePath, k);
if (collection.type === 'doc' && collection.async) {
- const importPath = `${toImportPath(file.absolutePath, outDir)}?hash=${hash}&collection=${k}`;
+ const importPath = `${toImportPath(file.absolutePath, outDir)}?hash=${configHash}&collection=${k}`;
const frontmatter = await getFrontmatter(file.absolutePath);
return `toRuntimeAsync(${JSON.stringify(frontmatter)}, () => import(${JSON.stringify(importPath)}), ${JSON.stringify(file)})`;
@@ -43,7 +43,7 @@ export async function generateJS(
imports.push({
type: 'namespace',
name: importName,
- specifier: `${toImportPath(file.absolutePath, outDir)}?collection=${k}&hash=${hash}`,
+ specifier: `${toImportPath(file.absolutePath, outDir)}?collection=${k}&hash=${configHash}`,
});
return `toRuntime("${collection.type}", ${importName}, ${JSON.stringify(file)})`;
@@ -55,6 +55,7 @@ export async function generateJS(
importedCollections.add(k);
}
+ // TODO: remove `transform` API on next major, migrate to runtime transform (e.g. transform & re-export in `source.ts`)
return collection.transform
? `export const ${k} = await Promise.all([${resolvedItems.join(', ')}].map(v => c_${k}.transform(v, ${config.global ? 'c_default' : 'undefined'})));`
: `export const ${k} = [${resolvedItems.join(', ')}];`;
diff --git a/packages/mdx/src/utils/build-mdx.ts b/packages/mdx/src/utils/build-mdx.ts
index b7abcf3e3..64db440c6 100644
--- a/packages/mdx/src/utils/build-mdx.ts
+++ b/packages/mdx/src/utils/build-mdx.ts
@@ -1,10 +1,16 @@
import { createProcessor, type ProcessorOptions } from '@mdx-js/mdx';
import type { VFile } from 'vfile';
+import type { Transformer } from 'unified';
+import { visit } from 'unist-util-visit';
+import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
+import type { Literal } from 'mdast';
+import { readFileSync } from 'node:fs';
+import * as path from 'node:path';
+import matter from 'gray-matter';
-const cache = new Map<
- string,
- { processor: ReturnType; configHash: string }
->();
+type Processor = ReturnType;
+
+const cache = new Map();
export interface MDXOptions extends ProcessorOptions {
/**
@@ -23,12 +29,41 @@ export interface MDXOptions extends ProcessorOptions {
* Custom Vfile data
*/
data?: Record;
+
+ _compiler?: CompilerOptions;
+}
+
+interface CompilerOptions {
+ addDependency: (file: string) => void;
}
function cacheKey(group: string, format: string): string {
return `${group}:${format}`;
}
+function remarkInclude(this: Processor, options: CompilerOptions): Transformer {
+ return (tree, file) => {
+ visit(tree, 'mdxJsxFlowElement', (node: MdxJsxFlowElement) => {
+ if (node.name === 'include') {
+ const child = node.children.at(0) as Literal | undefined;
+
+ if (!child || child.type !== 'text') return;
+ const specifier = child.value;
+
+ const targetPath = path.resolve(path.dirname(file.path), specifier);
+
+ const content = readFileSync(targetPath);
+ const parsed = this.parse(matter(content).content);
+
+ options.addDependency(targetPath);
+ Object.assign(node, parsed);
+ }
+
+ return 'skip';
+ });
+ };
+}
+
/**
* @param group - The cache group of MDX content, usually the collection name
* @param configHash - config hash
@@ -58,6 +93,12 @@ export function buildMDX(
outputFormat: 'program',
development: process.env.NODE_ENV === 'development',
...rest,
+ remarkPlugins: [
+ options._compiler
+ ? ([remarkInclude, options._compiler] as any)
+ : null,
+ ...(rest.remarkPlugins ?? []),
+ ],
format,
}),
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 43519ab4c..f71d88178 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -782,6 +782,9 @@ importers:
micromatch:
specifier: ^4.0.8
version: 4.0.8
+ unist-util-visit:
+ specifier: ^5.0.0
+ version: 5.0.0
zod:
specifier: ^3.24.1
version: 3.24.1
@@ -807,6 +810,9 @@ importers:
fumadocs-core:
specifier: workspace:*
version: link:../core
+ mdast-util-mdx-jsx:
+ specifier: ^3.1.3
+ version: 3.1.3
next:
specifier: ^15.1.1
version: 15.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@19.0.0-beta-df7b47d-20241124)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)