MDX: redesign MDX presets & improve performance

This commit is contained in:
Fuma Nama
2025-11-16 18:59:28 +08:00
parent ebaa67efa1
commit a312d3aee9
14 changed files with 353 additions and 242 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'fumadocs-mdx': major
---
**Replace `getDefaultMDXOptions()` with `applyMdxPreset()`**
This allows Fumadocs MDX to support more presets in the future, and adjust presets for dynamic mode.
+20
View File
@@ -0,0 +1,20 @@
---
'fumadocs-mdx': major
---
**No longer generate `extractedReferences` by default**
You can enable it from `postprocess` option.
```ts
// source.config.ts
import { defineDocs } from 'fumadocs-mdx/config';
export default defineDocs({
docs: {
postprocess: {
extractLinkReferences: true,
},
},
});
```
+7 -8
View File
@@ -7,6 +7,9 @@ import type {
MetaCollection,
} from '@/config/define';
import picomatch from 'picomatch';
import { applyMdxPreset } from '@/config/preset';
export type BuildEnvironment = 'bundler' | 'runtime';
export interface LoadedConfig {
collectionList: CollectionItem[];
@@ -15,7 +18,7 @@ export interface LoadedConfig {
global: GlobalConfig;
getMDXOptions(
collection?: DocCollectionItem,
environment?: 'bundler' | 'runtime',
environment?: BuildEnvironment,
): ProcessorOptions | Promise<ProcessorOptions>;
}
@@ -158,19 +161,15 @@ export function buildConfig(config: Record<string, unknown>): LoadedConfig {
if (collection?.mdxOptions) {
const optionsFn = collection.mdxOptions;
result = typeof optionsFn === 'function' ? optionsFn() : optionsFn;
result =
typeof optionsFn === 'function' ? optionsFn(environment) : optionsFn;
} else {
result = (async () => {
const optionsFn = this.global.mdxOptions;
const options =
typeof optionsFn === 'function' ? await optionsFn() : optionsFn;
if (options?.preset === 'minimal') return options;
const { getDefaultMDXOptions } = await import('@/loaders/mdx/preset');
return getDefaultMDXOptions({
...options,
_withoutBundler: environment === 'runtime',
});
return applyMdxPreset(options)(environment);
})();
}
+7 -10
View File
@@ -1,9 +1,10 @@
import type { StandardSchemaV1 } from '@standard-schema/spec';
import type { DefaultMDXOptions } from '@/loaders/mdx/preset';
import type { MDXPresetOptions } from '@/config/preset';
import type { ProcessorOptions } from '@mdx-js/mdx';
import { frontmatterSchema, metaSchema } from '@/config/zod-4';
import type { PostprocessOptions } from '@/loaders/mdx/remark-postprocess';
import type { PluginOption } from '@/core';
import type { BuildEnvironment } from './build';
export type CollectionSchema<Schema extends StandardSchemaV1, Context> =
| Schema
@@ -39,7 +40,9 @@ export interface DocCollection<
type: 'doc';
postprocess?: Partial<PostprocessOptions>;
mdxOptions?: ProcessorOptions | (() => Promise<ProcessorOptions>);
mdxOptions?:
| ProcessorOptions
| ((environment: BuildEnvironment) => Promise<ProcessorOptions>);
/**
* Load files with async
@@ -65,12 +68,6 @@ export interface DocsCollection<
meta: MetaCollection<MetaSchema>;
}
type GlobalConfigMDXOptions =
| ({ preset?: 'fumadocs' } & DefaultMDXOptions)
| ({
preset: 'minimal';
} & ProcessorOptions);
export interface GlobalConfig {
collections?: Record<string, AnyCollection>;
plugins?: PluginOption[];
@@ -78,9 +75,9 @@ export interface GlobalConfig {
/**
* Configure global MDX options
*
* @remarks `GlobalConfigMDXOptions`
* @remarks `MDXPresetOptions`
*/
mdxOptions?: GlobalConfigMDXOptions | (() => Promise<GlobalConfigMDXOptions>);
mdxOptions?: MDXPresetOptions | (() => Promise<MDXPresetOptions>);
/**
* specify a directory to access & store cache (disabled during development mode).
+1 -4
View File
@@ -1,8 +1,5 @@
export * from './define';
export {
getDefaultMDXOptions,
type DefaultMDXOptions,
} from '@/loaders/mdx/preset';
export * from '@/config/preset';
export * from './zod-4';
export { remarkInclude } from '@/loaders/mdx/remark-include';
+136
View File
@@ -0,0 +1,136 @@
import type { ProcessorOptions } from '@mdx-js/mdx';
import type { Pluggable } from 'unified';
import type * as Plugins from 'fumadocs-core/mdx-plugins';
import type { BuildEnvironment } from './build';
type ResolvePlugins = Pluggable[] | ((v: Pluggable[]) => Pluggable[]);
export type DefaultMDXOptions = Omit<
NonNullable<ProcessorOptions>,
'rehypePlugins' | 'remarkPlugins' | '_ctx'
> & {
rehypePlugins?: ResolvePlugins;
remarkPlugins?: ResolvePlugins;
/**
* Properties to export from `vfile.data`
*/
valueToExport?: string[];
remarkStructureOptions?: Plugins.StructureOptions | false;
remarkHeadingOptions?: Plugins.RemarkHeadingOptions;
remarkImageOptions?: Plugins.RemarkImageOptions | false;
remarkCodeTabOptions?: Plugins.RemarkCodeTabOptions | false;
remarkNpmOptions?: Plugins.RemarkNpmOptions | false;
rehypeCodeOptions?: Plugins.RehypeCodeOptions | false;
};
function pluginOption(
def: (v: Pluggable[]) => (Pluggable | false)[],
options: ResolvePlugins = [],
): Pluggable[] {
const list = def(Array.isArray(options) ? options : []).filter(
Boolean,
) as Pluggable[];
if (typeof options === 'function') {
return options(list);
}
return list;
}
export type MDXPresetOptions =
| ({ preset?: 'fumadocs' } & DefaultMDXOptions)
| ({
preset: 'minimal';
} & ProcessorOptions);
/**
* apply MDX processor presets
*/
export function applyMdxPreset(
options: MDXPresetOptions = {},
): (environment: BuildEnvironment) => Promise<ProcessorOptions> {
return async (environment = 'bundler') => {
if (options.preset === 'minimal') return options;
const plugins = await import('fumadocs-core/mdx-plugins');
const {
valueToExport = [],
rehypeCodeOptions,
remarkImageOptions,
remarkHeadingOptions,
remarkStructureOptions,
remarkCodeTabOptions,
remarkNpmOptions,
...mdxOptions
} = options;
const remarkPlugins = pluginOption(
(v) => [
plugins.remarkGfm,
[
plugins.remarkHeading,
{
generateToc: false,
...remarkHeadingOptions,
},
],
remarkImageOptions !== false && [
plugins.remarkImage,
{
...remarkImageOptions,
useImport:
remarkImageOptions?.useImport ?? environment === 'bundler',
},
],
'remarkCodeTab' in plugins &&
remarkCodeTabOptions !== false && [
plugins.remarkCodeTab,
remarkCodeTabOptions,
],
'remarkNpm' in plugins &&
remarkNpmOptions !== false && [plugins.remarkNpm, remarkNpmOptions],
...v,
remarkStructureOptions !== false && [
plugins.remarkStructure,
remarkStructureOptions,
],
valueToExport.length > 0 &&
(() => {
return (_, file) => {
file.data['mdx-export'] ??= [];
for (const name of valueToExport) {
if (!(name in file.data)) continue;
file.data['mdx-export'].push({
name,
value: file.data[name],
});
}
};
}),
],
mdxOptions.remarkPlugins,
);
const rehypePlugins = pluginOption(
(v) => [
rehypeCodeOptions !== false && [plugins.rehypeCode, rehypeCodeOptions],
...v,
plugins.rehypeToc,
],
mdxOptions.rehypePlugins,
);
return {
...mdxOptions,
outputFormat:
environment === 'runtime' ? 'function-body' : mdxOptions.outputFormat,
remarkPlugins,
rehypePlugins,
};
};
}
+87 -51
View File
@@ -24,6 +24,15 @@ export interface PluginContext extends CoreOptions {
core: Core;
}
export type CompilationContext<Collection> = PluginContext &
TransformOptions<Collection>;
export interface TransformOptions<Collection> {
collection: Collection;
filePath: string;
source: string;
}
export interface Plugin {
name?: string;
@@ -48,26 +57,30 @@ export interface Plugin {
server: ServerContext,
) => Awaitable<void>;
/**
* Transform frontmatter/metadata
*/
metadata?: (
this: PluginContext & {
collection: DocCollectionItem | MetaCollectionItem;
filePath: string;
},
data: unknown,
) => Awaitable<unknown | void>;
meta?: {
/**
* Transform metadata
*/
transform?: (
this: CompilationContext<MetaCollectionItem>,
data: unknown,
) => Awaitable<unknown | void>;
};
doc?: {
/**
* Transform frontmatter
*/
frontmatter?: (
this: CompilationContext<DocCollectionItem>,
data: Record<string, unknown>,
) => Awaitable<Record<string, unknown> | void>;
/**
* Transform `vfile` on compilation stage
*/
vfile?: (
this: PluginContext & {
collection: DocCollectionItem;
filePath: string;
},
this: CompilationContext<DocCollectionItem>,
file: VFile,
) => Awaitable<VFile | void>;
};
@@ -121,6 +134,28 @@ export function createCore(
let config: LoadedConfig;
let plugins: Plugin[];
async function transformMetadata<T>(
{
collection,
filePath,
source,
}: TransformOptions<DocCollectionItem | MetaCollectionItem>,
data: unknown,
): Promise<T> {
if (collection.schema) {
data = await validate(
collection.schema,
data,
{ path: filePath, source },
collection.type === 'doc'
? `invalid frontmatter in ${filePath}`
: `invalid data in ${filePath}`,
);
}
return data as T;
}
const core = {
_options: options,
/**
@@ -187,55 +222,56 @@ export function createCore(
);
console.log(`[MDX] generated files in ${performance.now() - start}ms`);
},
async metadata(
collection: DocCollectionItem | MetaCollectionItem,
filePath: string,
source: string,
data: unknown,
) {
if (collection.schema) {
data = await validate(
collection.schema,
data,
{ path: filePath, source },
collection.type === 'doc'
? `invalid frontmatter in ${filePath}`
: `invalid data in ${filePath}`,
);
}
async transformMeta(
options: TransformOptions<MetaCollectionItem>,
data: unknown,
): Promise<unknown> {
const ctx = {
...pluginContext,
filePath,
collection,
...options,
};
data = await transformMetadata(options, data);
for (const plugin of plugins) {
if (plugin.metadata)
data = (await plugin.metadata.call(ctx, data)) ?? data;
if (plugin.meta?.transform)
data = (await plugin.meta.transform.call(ctx, data)) ?? data;
}
return data;
},
doc: {
async vfile(
collection: DocCollectionItem,
filePath: string,
file: VFile,
): Promise<VFile> {
const ctx = {
...pluginContext,
filePath,
collection,
};
async transformFrontmatter(
options: TransformOptions<DocCollectionItem>,
data: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const ctx = {
...pluginContext,
...options,
};
for (const plugin of plugins) {
if (plugin.doc?.vfile)
file = (await plugin.doc.vfile.call(ctx, file)) ?? file;
}
data = await transformMetadata(options, data);
for (const plugin of plugins) {
if (plugin.doc?.frontmatter)
data = (await plugin.doc.frontmatter.call(ctx, data)) ?? data;
}
return file;
},
return data;
},
async transformVFile(
options: TransformOptions<DocCollectionItem>,
file: VFile,
): Promise<VFile> {
const ctx = {
...pluginContext,
...options,
};
for (const plugin of plugins) {
if (plugin.doc?.vfile)
file = (await plugin.doc.vfile.call(ctx, file)) ?? file;
}
return file;
},
};
+11 -12
View File
@@ -32,6 +32,7 @@ export interface CompilerOptions {
addDependency: (file: string) => void;
}
// TODO: allow plugins to customise the type
export interface CompiledMDXProperties<Frontmatter = Record<string, unknown>> {
frontmatter: Frontmatter;
structuredData: StructuredData;
@@ -42,8 +43,17 @@ export interface CompiledMDXProperties<Frontmatter = Record<string, unknown>> {
* Added by the `last-modified` plugin.
*/
lastModified?: Date;
/**
* Enable from `postprocess` option.
*/
extractedReferences?: ExtractedReference[];
/**
* Enable from `postprocess` option.
*/
_markdown?: string;
/**
* Enable from `postprocess` option.
*/
_mdast?: string;
}
@@ -58,19 +68,12 @@ export interface FumadocsDataMap {
*/
'mdx-export'?: { name: string; value: unknown }[];
extractedReferences: ExtractedReference[];
/**
* [Fumadocs MDX] The compiler object from loader
*/
_compiler?: CompilerOptions;
_getProcessor?: (format: 'md' | 'mdx') => Processor;
/**
* [Fumadocs MDX] Processed Markdown content before `remark-rehype`.
*/
_markdown?: string;
}
declare module 'vfile' {
@@ -106,11 +109,7 @@ export async function buildMDX(
valueToExport: [
...(collection?.postprocess?.valueToExport ?? []),
'structuredData',
'extractedReferences',
'frontmatter',
'lastModified',
'_markdown',
'_mdast',
],
};
@@ -139,7 +138,7 @@ export async function buildMDX(
});
if (collection) {
vfile = await core.doc.vfile(collection, filePath, vfile);
vfile = await core.transformVFile({ collection, filePath, source }, vfile);
}
return getProcessor(filePath.endsWith('.mdx') ? 'mdx' : 'md').process(vfile);
+3 -5
View File
@@ -78,11 +78,9 @@ export function createMdxLoader(configLoader: ConfigLoader): Loader {
}
if (docCollection) {
matter.data = await configLoader.core.metadata(
docCollection,
filePath,
value,
matter.data,
matter.data = await configLoader.core.transformFrontmatter(
{ collection: docCollection, filePath, source: value },
matter.data as Record<string, unknown>,
);
}
-113
View File
@@ -1,113 +0,0 @@
import * as plugins from 'fumadocs-core/mdx-plugins';
import type { ProcessorOptions } from '@mdx-js/mdx';
import type { Pluggable } from 'unified';
type ResolvePlugins = Pluggable[] | ((v: Pluggable[]) => Pluggable[]);
export type DefaultMDXOptions = Omit<
NonNullable<ProcessorOptions>,
'rehypePlugins' | 'remarkPlugins' | '_ctx'
> & {
rehypePlugins?: ResolvePlugins;
remarkPlugins?: ResolvePlugins;
/**
* Properties to export from `vfile.data`
*/
valueToExport?: string[];
remarkStructureOptions?: plugins.StructureOptions | false;
remarkHeadingOptions?: plugins.RemarkHeadingOptions;
remarkImageOptions?: plugins.RemarkImageOptions | false;
remarkCodeTabOptions?: plugins.RemarkCodeTabOptions | false;
remarkNpmOptions?: plugins.RemarkNpmOptions | false;
rehypeCodeOptions?: plugins.RehypeCodeOptions | false;
_withoutBundler?: boolean;
};
function pluginOption(
def: (v: Pluggable[]) => (Pluggable | false)[],
options: ResolvePlugins = [],
): Pluggable[] {
const list = def(Array.isArray(options) ? options : []).filter(
Boolean,
) as Pluggable[];
if (typeof options === 'function') {
return options(list);
}
return list;
}
export function getDefaultMDXOptions({
valueToExport = [],
rehypeCodeOptions,
remarkImageOptions,
remarkHeadingOptions,
remarkStructureOptions,
remarkCodeTabOptions,
remarkNpmOptions,
_withoutBundler = false,
...mdxOptions
}: DefaultMDXOptions): ProcessorOptions {
const remarkPlugins = pluginOption(
(v) => [
plugins.remarkGfm,
[
plugins.remarkHeading,
{
generateToc: false,
...remarkHeadingOptions,
},
],
remarkImageOptions !== false && [
plugins.remarkImage,
{
...remarkImageOptions,
useImport: _withoutBundler ? false : remarkImageOptions?.useImport,
},
],
'remarkCodeTab' in plugins &&
remarkCodeTabOptions !== false && [
plugins.remarkCodeTab,
remarkCodeTabOptions,
],
'remarkNpm' in plugins &&
remarkNpmOptions !== false && [plugins.remarkNpm, remarkNpmOptions],
...v,
remarkStructureOptions !== false && [
plugins.remarkStructure,
remarkStructureOptions,
],
() => {
return (_, file) => {
file.data['mdx-export'] ??= [];
for (const name of valueToExport) {
if (name in file.data)
file.data['mdx-export'].push({ name, value: file.data[name] });
}
};
},
],
mdxOptions.remarkPlugins,
);
const rehypePlugins = pluginOption(
(v) => [
rehypeCodeOptions !== false && [plugins.rehypeCode, rehypeCodeOptions],
...v,
plugins.rehypeToc,
],
mdxOptions.rehypePlugins,
);
return {
...mdxOptions,
outputFormat: _withoutBundler ? 'function-body' : mdxOptions.outputFormat,
remarkPlugins,
rehypePlugins,
};
}
@@ -23,6 +23,11 @@ export interface PostprocessOptions {
*/
includeProcessedMarkdown?: boolean;
/**
* extract link references, export via `extractedReferences`.
*/
extractLinkReferences?: boolean;
/**
* store MDAST and export via `_mdast`.
*/
@@ -43,65 +48,80 @@ export function remarkPostprocess(
_format,
includeProcessedMarkdown = false,
includeMDAST = false,
extractLinkReferences = false,
valueToExport = [],
}: PostprocessOptions,
): Transformer<Root, Root> {
let _stringifyProcessor: Processor | undefined;
const getStringifyProcessor = () => {
if (_format === 'mdx') return this;
// force Markdown processor to stringify MDX nodes
return (_stringifyProcessor ??= this().use(remarkMdx).freeze());
return (_stringifyProcessor ??=
_format === 'mdx'
? this
: // force Markdown processor to stringify MDX nodes
this().use(remarkMdx).freeze());
};
return (tree, file) => {
let title: string | undefined;
const urls: ExtractedReference[] = [];
visit(tree, ['heading', 'link'], (node) => {
if (node.type === 'heading' && node.depth === 1) {
title = flattenNode(node);
}
if (node.type !== 'link') return;
urls.push({
href: node.url,
file.data.frontmatter ??= {};
if (!file.data.frontmatter.title) {
visit(tree, 'heading', (node) => {
if (node.depth === 1) {
file.data.frontmatter!.title = flattenNode(node);
return false;
}
});
return 'skip';
});
if (title) {
file.data.frontmatter ??= {};
if (!file.data.frontmatter.title) file.data.frontmatter.title = title;
}
file.data.extractedReferences = urls;
file.data['mdx-export'] ??= [];
if (extractLinkReferences) {
const urls: ExtractedReference[] = [];
visit(tree, 'link', (node) => {
urls.push({
href: node.url,
});
return 'skip';
});
file.data['mdx-export'].push({
name: 'extractedReferences',
value: urls,
});
}
if (includeProcessedMarkdown) {
const processor = getStringifyProcessor();
file.data._markdown = toMarkdown(tree, {
const markdown = toMarkdown(tree, {
...processor.data('settings'),
// from https://github.com/remarkjs/remark/blob/main/packages/remark-stringify/lib/index.js
extensions: processor.data('toMarkdownExtensions') || [],
});
file.data['mdx-export'].push({
name: '_markdown',
value: markdown,
});
}
if (includeMDAST) {
const options = includeMDAST === true ? {} : includeMDAST;
file.data._mdast = JSON.stringify(
const mdast = JSON.stringify(
options.removePosition ? removePosition(structuredClone(tree)) : tree,
);
file.data['mdx-export'].push({
name: '_mdast',
value: mdast,
});
}
for (const { name, value } of file.data['mdx-export'] ?? []) {
for (const { name, value } of file.data['mdx-export']) {
tree.children.unshift(getMdastExport(name, value));
}
// reset the data to reduce memory usage
file.data['mdx-export'] = [];
for (const name of valueToExport) {
if (!(name in file.data)) continue;
+8 -1
View File
@@ -56,7 +56,14 @@ export function createMetaLoader(
const data = parse(filePath, source);
if (!metaCollection) return data;
return configLoader.core.metadata(metaCollection, filePath, source, data);
return configLoader.core.transformMeta(
{
collection: metaCollection,
filePath,
source,
},
data,
);
};
}
+7 -5
View File
@@ -229,11 +229,13 @@ async function generateDynamicIndexFile(core: Core, codegen: CodeGen) {
const fullPath = path.join(collection.dir, file);
const content = await readFileWithCache(fullPath).catch(() => '');
const parsed = fumaMatter(content);
const data = await core.metadata(
collection,
fullPath,
content,
parsed.data,
const data = await core.transformFrontmatter(
{
collection,
filePath: fullPath,
source: content,
},
parsed.data as Record<string, unknown>,
);
const hash = createHash('md5').update(content).digest('hex');
+9 -3
View File
@@ -18,7 +18,7 @@ export interface LastModifiedPluginOptions {
}
/**
* Injects `lastModified` property to page data.
* Injects `lastModified` property to page exports.
*
* By default, it uses Git to obtain the last modified time.
*/
@@ -32,10 +32,16 @@ export default function lastModified(
doc: {
async vfile(file) {
if (versionControl === 'git') {
const timestamp = await getGitTimestamp(this.filePath);
const timestamp = await getGitTimestamp(this.filePath).then((v) =>
v?.getTime(),
);
if (timestamp === undefined) return;
file.data.lastModified = timestamp.getTime();
file.data['mdx-export'] ??= [];
file.data['mdx-export'].push({
name: 'lastModified',
value: timestamp,
});
}
},
},