MDX: implement Plugins API

This commit is contained in:
Fuma Nama
2025-10-21 21:42:06 +08:00
parent de7fa799ce
commit 3caa5cd918
16 changed files with 408 additions and 244 deletions
+15
View File
@@ -0,0 +1,15 @@
---
'fumadocs-mdx': major
---
**Vite: move `source.generated.ts` to `.source/index.ts`**
**Why:**
- with Fumadocs MDX Plugins, we want to unify the output directory across Vite & Next.js.
- `source.generated.ts` looks ugly compared by `.source`.
**Migrate:**
- run dev server/typegen to generate a `.source` folder.
- import it over the original `source.generated.ts`.
- note that both docs and `create-fumadocs-app` are updated to `.source` folder.
+13
View File
@@ -0,0 +1,13 @@
---
'fumadocs-mdx': minor
---
**Support Plugins API**
Fumadocs MDX is mostly a bundler plugin meant to be used with tools like Vite and Turbopack.
With Fumadocs MDX Plugins, you can extend Fumadocs MDX without worrying the underlying bundler.
It is designed for:
- Generate files from config (e.g. types, index files, JSON schemas)
- Modify received config
+1 -1
View File
@@ -13,7 +13,7 @@ async function start() {
const { postInstall } = await import('./next');
await postInstall(configPath);
} else {
const { postInstall } = await import('./vite/postinstall');
const { postInstall } = await import('./vite');
await postInstall(configPath);
}
}
+3
View File
@@ -3,6 +3,7 @@ import type { DefaultMDXOptions } from '@/loaders/mdx/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 '@/plugins';
export type CollectionSchema<Schema extends StandardSchemaV1, Context> =
| Schema
@@ -68,6 +69,8 @@ type GlobalConfigMDXOptions =
} & ProcessorOptions);
export interface GlobalConfig {
plugins?: PluginOption[];
/**
* Configure global MDX options
*/
-83
View File
@@ -1,83 +0,0 @@
import type { NextConfig } from 'next';
import type { Configuration } from 'webpack';
import { findConfigFile } from '@/loaders/config';
import { start } from './map';
import { type Options as MDXLoaderOptions } from '../loader-mdx';
import type { TurbopackOptions } from 'next/dist/server/config-shared';
export interface CreateMDXOptions {
/**
* Path to source configuration file
*/
configPath?: string;
/**
* Directory for output files
*
* @defaultValue '.source'
*/
outDir?: string;
}
const defaultPageExtensions = ['mdx', 'md', 'jsx', 'js', 'tsx', 'ts'];
export { start };
export function createMDX({
configPath = findConfigFile(),
outDir = '.source',
}: CreateMDXOptions = {}) {
if (process.env._FUMADOCS_MDX !== '1') {
process.env._FUMADOCS_MDX = '1';
void start(process.env.NODE_ENV === 'development', configPath, outDir);
}
return (nextConfig: NextConfig = {}): NextConfig => {
const mdxLoaderOptions: MDXLoaderOptions = {
configPath,
outDir,
};
const turbopack: TurbopackOptions = {
...nextConfig.turbopack,
rules: {
...nextConfig.turbopack?.rules,
'*.{md,mdx}': {
loaders: [
{
loader: 'fumadocs-mdx/loader-mdx',
options: mdxLoaderOptions as any,
},
],
as: '*.js',
},
},
};
return {
...nextConfig,
turbopack,
pageExtensions: nextConfig.pageExtensions ?? defaultPageExtensions,
webpack: (config: Configuration, options) => {
config.resolve ||= {};
config.module ||= {};
config.module.rules ||= [];
config.module.rules.push({
test: /\.mdx?$/,
use: [
options.defaultLoaders.babel,
{
loader: 'fumadocs-mdx/loader-mdx',
options: mdxLoaderOptions,
},
],
});
config.plugins ||= [];
return nextConfig.webpack?.(config, options) ?? config;
},
};
};
}
+182 -16
View File
@@ -1,26 +1,192 @@
import type { NextConfig } from 'next';
import type { Configuration } from 'webpack';
import { findConfigFile, type LoadedConfig } from '@/loaders/config';
import { type Options as MDXLoaderOptions } from '../loader-mdx';
import type {
TurbopackLoaderOptions,
TurbopackOptions,
} from 'next/dist/server/config-shared';
import { createPluginHandler } from '@/plugins';
import * as path from 'node:path';
import { getConfigHash, loadConfig } from '@/loaders/config/load';
import path from 'node:path';
import fs from 'node:fs/promises';
import { generateJS } from '@/next/map/generate';
import { findConfigFile } from '@/loaders/config';
import { removeFileCache } from '@/next/file-cache';
import { ValidationError } from '@/utils/validation';
import next from '@/plugins/next';
import type { EventName } from 'chokidar/handler.js';
export * from './create';
export interface CreateMDXOptions {
/**
* Path to source configuration file
*/
configPath?: string;
/**
* Directory for output files
*
* @defaultValue '.source'
*/
outDir?: string;
}
const defaultPageExtensions = ['mdx', 'md', 'jsx', 'js', 'tsx', 'ts'];
export function createMDX(createOptions: CreateMDXOptions = {}) {
const options = applyDefaults(createOptions);
if (process.env._FUMADOCS_MDX !== '1') {
process.env._FUMADOCS_MDX = '1';
void init(process.env.NODE_ENV === 'development', options);
}
return (nextConfig: NextConfig = {}): NextConfig => {
const mdxLoaderOptions: MDXLoaderOptions = options;
const turbopack: TurbopackOptions = {
...nextConfig.turbopack,
rules: {
...nextConfig.turbopack?.rules,
'*.{md,mdx}': {
loaders: [
{
loader: 'fumadocs-mdx/loader-mdx',
options: mdxLoaderOptions as unknown as TurbopackLoaderOptions,
},
],
as: '*.js',
},
},
};
return {
...nextConfig,
turbopack,
pageExtensions: nextConfig.pageExtensions ?? defaultPageExtensions,
webpack: (config: Configuration, options) => {
config.resolve ||= {};
config.module ||= {};
config.module.rules ||= [];
config.module.rules.push({
test: /\.mdx?$/,
use: [
options.defaultLoaders.babel,
{
loader: 'fumadocs-mdx/loader-mdx',
options: mdxLoaderOptions,
},
],
});
config.plugins ||= [];
return nextConfig.webpack?.(config, options) ?? config;
},
};
};
}
async function init(
dev: boolean,
options: Required<CreateMDXOptions>,
): Promise<void> {
const pluginHandler = createNextPluginHandler(options);
let configHash: string;
let config: LoadedConfig;
async function updateConfig() {
configHash = await getConfigHash(options.configPath);
config = await pluginHandler.init(
await loadConfig(options.configPath, options.outDir, configHash, true),
);
}
async function emitFiles() {
const start = performance.now();
try {
await pluginHandler.emitAndWrite();
} catch (err) {
if (err instanceof ValidationError) {
console.error(err.toStringFormatted());
} else {
console.error(err);
}
}
console.log(`[MDX] updated map file in ${performance.now() - start}ms`);
}
async function devServer() {
const { watcher } = await import('@/next/watcher');
const instance = watcher(options.configPath, config, [options.outDir]);
async function onUpdate(event: EventName, file: string) {
const absolutePath = path.resolve(file);
if (event === 'change') removeFileCache(absolutePath);
if (absolutePath === path.resolve(options.configPath)) {
await updateConfig();
console.log('[MDX] restarting dev server');
await instance.close();
void devServer();
}
await emitFiles();
}
instance.on('ready', () => {
console.log('[MDX] started dev server');
});
instance.on('all', (event, file) => {
void onUpdate(event, file);
});
process.on('exit', () => {
console.log('[MDX] closing dev server');
void instance.close();
});
}
await updateConfig();
await emitFiles();
if (dev) void devServer();
}
export async function postInstall(
configPath = findConfigFile(),
outDir = '.source',
) {
const config = await loadConfig(configPath, outDir, undefined, true);
const outPath = path.join(outDir, 'index.ts');
// clean past results
await fs.rm(outDir, { recursive: true });
await fs.mkdir(outDir, { recursive: true });
const hash = await getConfigHash(configPath);
await fs.writeFile(
outPath,
await generateJS(configPath, config, { relativeTo: outDir }, hash),
const pluginHandler = createNextPluginHandler({
outDir,
configPath,
});
await pluginHandler.init(
await loadConfig(configPath, outDir, undefined, true),
);
await pluginHandler.emitAndWrite();
console.log('[MDX] types generated');
}
function applyDefaults(options: CreateMDXOptions): Required<CreateMDXOptions> {
return {
outDir: options.outDir ?? '.source',
configPath: options.configPath ?? findConfigFile(),
};
}
function createNextPluginHandler({
outDir,
configPath,
}: Required<CreateMDXOptions>) {
return createPluginHandler(
{
environment: 'next',
outDir,
configPath,
},
[next()],
);
}
-82
View File
@@ -1,82 +0,0 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { getConfigHash, loadConfig } from '@/loaders/config/load';
import { generateJS } from '@/next/map/generate';
import { removeFileCache } from '@/next/map/file-cache';
import { ValidationError } from '@/utils/validation';
/**
* Start a MDX server that builds index and manifest files.
*
* In development mode, it starts a file watcher to auto-update output as your input changes.
*/
export async function start(
dev: boolean,
configPath: string,
outDir: string,
): Promise<void> {
// init
let configHash = await getConfigHash(configPath);
let config = await loadConfig(configPath, outDir, configHash, true);
const outPath = path.resolve(outDir, `index.ts`);
async function updateMapFile() {
const start = performance.now();
try {
await fs.writeFile(
outPath,
await generateJS(
configPath,
config,
{ relativeTo: outDir },
configHash,
),
);
} catch (err) {
if (err instanceof ValidationError) {
console.error(err.toStringFormatted());
} else {
console.error(err);
}
}
console.log(`[MDX] updated map file in ${performance.now() - start}ms`);
}
await updateMapFile();
if (dev) {
const { watcher } = await import('@/next/map/watcher');
const instance = watcher(configPath, config, [outPath]);
instance.on('ready', () => {
console.log('[MDX] started dev server');
});
instance.on('all', (event, file) => {
if (typeof file !== 'string') return;
const absolutePath = path.resolve(file);
const onUpdate = async () => {
const isConfigFile = absolutePath === configPath;
if (isConfigFile) {
configHash = await getConfigHash(configPath);
config = await loadConfig(configPath, outDir, configHash, true);
}
if (event === 'change') removeFileCache(absolutePath);
await updateMapFile();
};
void onUpdate();
});
process.on('exit', () => {
console.log('[MDX] closing dev server');
void instance.close();
});
}
}
+84
View File
@@ -0,0 +1,84 @@
import type { LoadedConfig } from '@/loaders/config';
import path from 'node:path';
import fs from 'node:fs/promises';
type Awaitable<T> = T | Promise<T>;
export interface EmitEntry {
/**
* path relative to output directory
*/
path: string;
content: string;
}
export interface PluginContext {
environment: 'next' | 'vite';
configPath: string;
outDir: string;
}
export interface Plugin {
/**
* on config loaded
*/
config?: (
this: PluginContext,
config: LoadedConfig,
) => Awaitable<void | LoadedConfig>;
/**
* Generate files (e.g. types, index file, or JSON schemas)
*/
emit?: (this: PluginContext) => EmitEntry[] | Promise<EmitEntry[]>;
}
export type PluginOption = Awaitable<Plugin | Plugin[] | false>;
export function createPluginHandler(
context: PluginContext,
defaultPlugins: PluginOption[] = [],
) {
const plugins: Plugin[] = [];
async function write(entry: EmitEntry) {
const file = path.join(context.outDir, entry.path);
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, entry.content);
}
return {
async init(config: LoadedConfig): Promise<LoadedConfig> {
if (config.global.plugins) {
defaultPlugins.push(...config.global.plugins);
}
for await (const option of defaultPlugins) {
if (!option) continue;
if (Array.isArray(option)) plugins.push(...option);
else plugins.push(option);
}
for (const plugin of plugins) {
const out = await plugin.config?.call(context, config);
if (out) config = out;
}
return config;
},
async emit(): Promise<EmitEntry[]> {
const out = await Promise.all(
plugins.map((plugin) => {
return plugin.emit?.call(context) ?? [];
}),
);
return out.flat();
},
async emitAndWrite(): Promise<void> {
const entries = await this.emit();
await Promise.all(entries.map(write));
},
};
}
@@ -4,7 +4,7 @@ import { glob } from 'tinyglobby';
import type { LoadedConfig } from '@/loaders/config';
import type { DocCollection, MetaCollection } from '@/config';
import { validate } from '@/utils/validation';
import { readFileWithCache } from '@/next/map/file-cache';
import { readFileWithCache } from '@/next/file-cache';
import { load } from 'js-yaml';
import { getGitTimestamp } from '@/utils/git-timestamp';
import { fumaMatter } from '@/utils/fuma-matter';
@@ -15,8 +15,29 @@ import {
} from '@/utils/import-formatter';
import { getGlobPatterns, isFileSupported } from '@/utils/collections';
import type { FileInfo } from '@/runtime/shared';
import type { Plugin } from '@/plugins/index';
export async function generateJS(
export default function next(): Plugin {
let config: LoadedConfig;
return {
config(v) {
config = v;
},
async emit() {
return [
{
path: 'index.ts',
content: await indexFile(this.configPath, config, {
relativeTo: this.outDir,
}),
},
];
},
};
}
export async function indexFile(
configPath: string,
config: LoadedConfig,
importPath: ImportPathConfig,
@@ -7,11 +7,11 @@ import type {
import { ident, toImportPath } from '@/utils/import-formatter';
import type { LoadedConfig } from '@/loaders/config';
import { getGlobPatterns } from '@/utils/collections';
import path from 'node:path';
import {
generateGlobImport,
type GlobImportOptions,
} from '@/utils/glob-import';
import type { Plugin } from '@/plugins';
export interface IndexFileOptions {
/**
@@ -22,31 +22,39 @@ export interface IndexFileOptions {
* - `false` (default): no fallback.
*/
runtime?: 'bun' | 'node' | false;
/**
* Output index file path
*
* @defaultValue 'source.generated.ts'
*/
out?: string;
/**
* add `.js` extensions to imports, needed for ESM without bundler resolution
*/
addJsExtension?: boolean;
}
export function entry(
export default function vite(options: IndexFileOptions): Plugin {
let config: LoadedConfig;
return {
config(v) {
config = v;
},
emit() {
console.log('[Fumadocs MDX] Generating index files');
return [
{
path: 'index.ts',
content: indexFile(this.configPath, this.outDir, config, options),
},
];
},
};
}
function indexFile(
configPath: string,
outDir: string,
config: LoadedConfig,
options: IndexFileOptions,
) {
const {
out = 'source.generated.ts',
addJsExtension = false,
runtime,
} = options;
const outDir = path.dirname(out);
const { addJsExtension = false, runtime } = options;
const lines = [
'/// <reference types="vite/client" />',
+61 -24
View File
@@ -7,13 +7,13 @@ import {
import { buildConfig } from '@/config/build';
import { parse } from 'node:querystring';
import { validate, ValidationError } from '@/utils/validation';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { load } from 'js-yaml';
import { entry, type IndexFileOptions } from '@/vite/generate';
import { createMdxLoader } from '@/loaders/mdx';
import { resolvedConfig } from '@/loaders/config';
import { findConfigFile, resolvedConfig } from '@/loaders/config';
import { toVite } from '@/loaders/adapter';
import vite, { type IndexFileOptions } from '@/plugins/vite';
import { createPluginHandler } from '@/plugins';
const FumadocsDeps = ['fumadocs-core', 'fumadocs-ui', 'fumadocs-openapi'];
@@ -36,21 +36,24 @@ export interface PluginOptions {
* @defaultValue true
*/
updateViteConfig?: boolean;
/**
* Output directory of generated files
*
* @defaultValue '.source'
*/
outDir?: string;
}
export * from './postinstall';
export default function mdx(
export default async function mdx(
config: Record<string, unknown>,
options: PluginOptions = {},
): Plugin {
const {
generateIndexFile = true,
updateViteConfig = true,
configPath = 'source.config.ts',
} = options;
const loaded = buildConfig(config);
pluginOptions: PluginOptions = {},
): Promise<Plugin> {
const options = applyDefaults(pluginOptions);
const { updateViteConfig } = options;
const pluginHandler = createVitePluginHandler(options);
const loaded = await pluginHandler.init(buildConfig(config));
const mdxLoader = toVite(createMdxLoader(resolvedConfig(loaded)));
async function transformMeta(
@@ -118,17 +121,8 @@ export default function mdx(
} satisfies UserConfig);
},
async buildStart() {
let indexFileOptions = generateIndexFile;
if (!indexFileOptions) return;
if (typeof indexFileOptions !== 'object') indexFileOptions = {};
const { out = 'source.generated.ts' } = indexFileOptions;
console.log('[Fumadocs MDX] Generating index files');
await fs.mkdir(path.dirname(out), { recursive: true });
await fs.writeFile(out, entry(configPath, loaded, indexFileOptions));
await pluginHandler.emitAndWrite();
},
async transform(value, id) {
const [file, query = ''] = id.split('?');
const ext = path.extname(file);
@@ -149,3 +143,46 @@ export default function mdx(
},
};
}
export async function postInstall(
configPath = findConfigFile(),
pluginOptions: PluginOptions = {},
) {
const { loadConfig } = await import('@/loaders/config/load');
const options = applyDefaults(pluginOptions);
const pluginHandler = createVitePluginHandler(options);
await pluginHandler.init(
await loadConfig(configPath, options.outDir, undefined, true),
);
await pluginHandler.emitAndWrite();
console.log('[MDX] types generated');
}
function createVitePluginHandler({
configPath,
outDir,
generateIndexFile,
}: Required<PluginOptions>) {
return createPluginHandler(
{
environment: 'vite',
configPath,
outDir,
},
[
generateIndexFile !== false &&
vite(typeof generateIndexFile === 'object' ? generateIndexFile : {}),
],
);
}
function applyDefaults(options: PluginOptions): Required<PluginOptions> {
return {
updateViteConfig: options.updateViteConfig ?? true,
generateIndexFile: options.generateIndexFile ?? true,
configPath: options.configPath ?? 'source.config.ts',
outDir: options.outDir ?? '.source',
};
}
-17
View File
@@ -1,17 +0,0 @@
import { loadConfig } from '@/loaders/config/load';
import fs from 'node:fs/promises';
import path from 'node:path';
import { entry, type IndexFileOptions } from '@/vite/generate';
import { findConfigFile } from '@/loaders/config';
export async function postInstall(
configPath = findConfigFile(),
options: IndexFileOptions = {},
) {
const { out = 'source.generated.ts' } = options;
const config = await loadConfig(configPath, 'node_modules', undefined, true);
await fs.mkdir(path.dirname(out), { recursive: true });
await fs.writeFile(out, entry(configPath, config, options));
console.log('[MDX] types generated');
}
+2 -2
View File
@@ -3,9 +3,9 @@ import * as path from 'node:path';
import { expect, test } from 'vitest';
import { z } from 'zod';
import { ValidationError } from '@/utils/validation';
import { generateJS } from '@/next/map/generate';
import { defineCollections } from '@/config';
import { fumaMatter } from '@/utils/fuma-matter';
import { indexFile } from '@/plugins/next';
test('format errors', async () => {
const schema = z.object({
@@ -62,7 +62,7 @@ const cases = [
for (const { name, collection } of cases) {
test(`generate JS index file: ${name}`, async () => {
const out = await generateJS(
const out = await indexFile(
path.join(baseDir, './fixtures/config.ts'),
{
// @ts-expect-error -- test file
-1
View File
@@ -255,7 +255,6 @@ export function CodeBlockTabsTrigger({
);
}
// TODO: currently Vite RSC plugin has problem with `asChild` due to children is automatically wrapped in <Fragment />, maybe revisit this in future
export function CodeBlockTab(props: ComponentProps<typeof TabsContent>) {
return <TabsContent {...props} />;
}