MDX: Support runtime loaders: Node.js, Bun

This commit is contained in:
Fuma Nama
2025-09-13 23:15:19 +08:00
parent 015393d51b
commit ea1337412c
11 changed files with 355 additions and 188 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-mdx': minor
---
Support runtime loaders: Node.js, Bun
+9
View File
@@ -42,6 +42,14 @@
"import": "./dist/runtime/vite/server.js",
"types": "./dist/runtime/vite/server.d.ts"
},
"./node/loader": {
"import": "./dist/node/loader.js",
"types": "./dist/node/loader.d.ts"
},
"./bun": {
"import": "./dist/bun/index.js",
"types": "./dist/bun/index.d.ts"
},
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -82,6 +90,7 @@
},
"devDependencies": {
"@fumadocs/mdx-remote": "workspace:*",
"@types/bun": "^1.2.21",
"@types/js-yaml": "^4.0.9",
"@types/mdast": "^4.0.3",
"@types/mdx": "^2.0.13",
+48
View File
@@ -0,0 +1,48 @@
import type { BunPlugin } from 'bun';
import { createMdxLoader } from '@/loaders/mdx';
import { resolvedConfig } from '@/loaders/config-loader';
import { findConfigFile } from '@/utils/config';
import { buildConfig } from '@/config/build';
import { parse } from 'node:querystring';
export interface MdxPluginOptions {
configPath?: string;
}
export function createMdxPlugin(options: MdxPluginOptions = {}): BunPlugin {
const { configPath = findConfigFile() } = options;
async function getMdxLoader() {
const out = buildConfig(await import(configPath));
return createMdxLoader(resolvedConfig(out));
}
return {
name: 'bun-plugin-fumadocs-mdx',
setup(build) {
const mdxLoader = getMdxLoader();
build.onLoad({ filter: /\.mdx(\?.+?)?$/ }, async (args) => {
const [filePath, query] = args.path.split('?', 2);
const content = await Bun.file(filePath).text();
const result = await (
await mdxLoader
)({
source: content,
query: parse(query),
filePath: args.path,
development: false,
compiler: {
addDependency() {},
},
});
return {
contents: result.code,
loader: 'js',
};
});
},
};
}
+7 -89
View File
@@ -1,18 +1,15 @@
import * as path from 'node:path';
import { parse } from 'node:querystring';
import { type LoaderContext } from 'webpack';
import { getConfigHash, loadConfig } from '@/utils/config';
import { buildMDX } from '@/utils/build-mdx';
import { getGitTimestamp } from './utils/git-timestamp';
import { validate, ValidationError } from '@/utils/validation';
import { fumaMatter } from '@/utils/fuma-matter';
import { countLines } from '@/utils/count-lines';
import { createMdxLoader } from '@/loaders/mdx';
import { dynamicConfig } from '@/loaders/config-loader';
import { toWebpack, type WebpackLoader } from '@/loaders/adapter';
export interface Options {
configPath: string;
outDir: string;
}
let instance: WebpackLoader | undefined;
/**
* Load MDX/markdown files
*
@@ -24,87 +21,8 @@ export default async function loader(
callback: LoaderContext<Options>['callback'],
): Promise<void> {
this.cacheable(true);
const context = this.context;
const filePath = this.resourcePath;
const { configPath, outDir } = this.getOptions();
const matter = fumaMatter(source);
instance ??= toWebpack(createMdxLoader(dynamicConfig(configPath, outDir)));
// notice that `resourceQuery` can be missing (e.g. `page.mdx`)
const {
hash: configHash = await getConfigHash(configPath),
collection: collectionId,
} = parse(this.resourceQuery.slice(1)) as {
hash?: string;
collection?: string;
};
const config = await loadConfig(configPath, outDir, configHash);
let collection =
collectionId !== undefined
? config.collections.get(collectionId)
: undefined;
if (collection && collection.type === 'docs') collection = collection.docs;
if (collection && collection.type !== 'doc') {
collection = undefined;
}
let frontmatter = matter.data as Record<string, unknown>;
const mdxOptions =
collection?.mdxOptions ?? (await config.getDefaultMDXOptions());
if (collection?.schema) {
try {
frontmatter = (await validate(
collection.schema,
frontmatter,
{
source,
path: filePath,
},
`invalid frontmatter in ${filePath}`,
)) as typeof frontmatter;
} catch (e) {
if (e instanceof ValidationError) {
return callback(new Error(e.toStringFormatted()));
}
return callback(e as Error);
}
}
let timestamp: number | undefined;
if (config.global?.lastModifiedTime === 'git') {
timestamp = (await getGitTimestamp(filePath))?.getTime();
}
try {
// ensure the line number is correct in dev mode
const lineOffset = '\n'.repeat(
this.mode === 'development' ? countLines(matter.matter) : 0,
);
const file = await buildMDX(
`${configHash}:${collectionId ?? 'global'}`,
lineOffset + matter.content,
{
development: this.mode === 'development',
...mdxOptions,
filePath,
frontmatter,
data: {
lastModified: timestamp,
},
_compiler: this,
},
);
callback(undefined, String(file.value), file.map ?? undefined);
} catch (error) {
if (!(error instanceof Error)) throw error;
const fpath = path.relative(context, filePath);
error.message = `${fpath}:${error.name}: ${error.message}`;
callback(error);
}
await instance.call(this, source, callback);
}
+117
View File
@@ -0,0 +1,117 @@
import type { CompilerOptions } from '@/utils/build-mdx';
import type { LoadFnOutput, LoadHook } from 'node:module';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs/promises';
import type { SourceMap, TransformPluginContext } from 'rollup';
import type { Environment, TransformResult } from 'vite';
import { parse } from 'node:querystring';
import { ValidationError } from '@/utils/validation';
import path from 'node:path';
import type { LoaderContext } from 'webpack';
export interface LoaderInput {
development: boolean;
compiler: CompilerOptions;
source: string;
filePath: string;
query: Record<string, string | string[] | undefined>;
}
export interface LoaderOutput {
code: string;
map?: unknown;
}
export type Loader = (input: LoaderInput) => Promise<LoaderOutput>;
export function toNode(
loader: Loader,
filterByPath: (filePath: string) => boolean,
): LoadHook {
return async (url, _context, nextLoad): Promise<LoadFnOutput> => {
const parsedUrl = new URL(url);
const filePath = fileURLToPath(parsedUrl);
if (filterByPath(filePath)) {
const source = (await fs.readFile(filePath)).toString();
const result = await loader({
filePath,
query: Object.fromEntries(parsedUrl.searchParams.entries()),
source,
development: false,
compiler: {
addDependency() {},
},
});
return {
source: result.code,
format: 'module',
};
}
return nextLoad(url);
};
}
export type ViteLoader = (
this: TransformPluginContext & { environment: Environment },
file: string,
query: string,
value: string,
) => Promise<TransformResult | null>;
export function toVite(loader: Loader): ViteLoader {
return async function (file, query, value) {
const result = await loader({
filePath: file,
query: parse(query),
source: value,
development: this.environment.mode === 'dev',
compiler: {
addDependency: (file) => {
this.addWatchFile(file);
},
},
});
return {
code: result.code,
map: result.map as SourceMap,
};
};
}
export type WebpackLoader = (
this: LoaderContext<unknown>,
source: string,
callback: LoaderContext<unknown>['callback'],
) => Promise<void>;
export function toWebpack(loader: Loader): WebpackLoader {
return async function (source, callback) {
try {
const result = await loader({
filePath: this.resourcePath,
query: parse(this.resourceQuery),
source,
development: this.mode === 'development',
compiler: this,
});
callback(undefined, result.code, result.map as string);
} catch (error) {
if (error instanceof ValidationError) {
return callback(new Error(error.toStringFormatted()));
}
if (!(error instanceof Error)) throw error;
const fpath = path.relative(this.context, this.resourcePath);
error.message = `${fpath}:${error.name}: ${error.message}`;
callback(error);
}
};
}
+28
View File
@@ -0,0 +1,28 @@
import { getConfigHash, loadConfig, LoadedConfig } from '@/utils/config';
export interface ConfigLoader {
getConfig: (hash?: string) => LoadedConfig | Promise<LoadedConfig>;
}
export function resolvedConfig(loaded: LoadedConfig): ConfigLoader {
return {
getConfig() {
return loaded;
},
};
}
export function dynamicConfig(
configPath: string,
outDir: string,
): ConfigLoader {
return {
async getConfig(hash) {
return loadConfig(
configPath,
outDir,
hash ?? (await getConfigHash(configPath)),
);
},
};
}
+98
View File
@@ -0,0 +1,98 @@
import { fumaMatter } from '@/utils/fuma-matter';
import { validate } from '@/utils/validation';
import { getGitTimestamp } from '@/utils/git-timestamp';
import { countLines } from '@/utils/count-lines';
import { buildMDX } from '@/utils/build-mdx';
import type { SourceMap } from 'rollup';
import type { Loader } from '@/loaders/adapter';
import { z } from 'zod';
import type { ConfigLoader } from '@/loaders/config-loader';
const querySchema = z
.object({
only: z.literal(['frontmatter', 'all']).default('all'),
collection: z.string().optional(),
hash: z
.string()
.describe(
'the hash of config, used for revalidation on Turbopack/Webpack.',
)
.optional(),
})
.loose();
export function createMdxLoader(configLoader: ConfigLoader): Loader {
return async ({
source: value,
development: isDevelopment,
query,
compiler,
filePath,
}) => {
const matter = fumaMatter(value);
const parsed = querySchema.parse(query);
const loaded = await configLoader.getConfig(parsed.hash);
const collection = parsed.collection
? loaded.collections.get(parsed.collection)
: undefined;
let schema;
let mdxOptions;
switch (collection?.type) {
case 'doc':
mdxOptions = collection.mdxOptions;
schema = collection.schema;
break;
case 'docs':
mdxOptions = collection.docs.mdxOptions;
schema = collection.docs.schema;
break;
}
if (schema) {
matter.data = await validate(
schema,
matter.data,
{
source: value,
path: filePath,
},
`invalid frontmatter in ${filePath}`,
);
}
if (parsed.only === 'frontmatter') {
return {
code: `export const frontmatter = ${JSON.stringify(matter.data)}`,
map: null,
};
}
const data: Record<string, unknown> = {};
if (loaded.global.lastModifiedTime === 'git') {
data.lastModified = (await getGitTimestamp(filePath))?.getTime();
}
// ensure the line number is correct in dev mode
const lineOffset = isDevelopment ? countLines(matter.matter) : 0;
const compiled = await buildMDX(
`${parsed.hash ?? ''}:${parsed.collection ?? 'global'}`,
'\n'.repeat(lineOffset) + matter.content,
{
development: isDevelopment,
...(mdxOptions ?? (await loaded.getDefaultMDXOptions())),
data,
filePath,
frontmatter: matter.data as Record<string, unknown>,
_compiler: compiler,
},
);
return {
code: String(compiled.value),
map: compiled.map as SourceMap,
};
};
}
+9
View File
@@ -0,0 +1,9 @@
import { findConfigFile } from '@/utils/config';
import { createMdxLoader } from '@/loaders/mdx';
import { dynamicConfig } from '@/loaders/config-loader';
import { toNode } from '@/loaders/adapter';
export const load = toNode(
createMdxLoader(dynamicConfig(findConfigFile(), '.source')),
(filePath) => filePath.endsWith('.md') || filePath.endsWith('.mdx'),
);
+6 -94
View File
@@ -1,33 +1,22 @@
import {
type Environment,
mergeConfig,
type Plugin,
type TransformResult,
type UserConfig,
} from 'vite';
import { buildConfig } from '@/config/build';
import { buildMDX } from '@/utils/build-mdx';
import { parse } from 'node:querystring';
import { countLines } from '@/utils/count-lines';
import { fumaMatter } from '@/utils/fuma-matter';
import { validate, ValidationError } from '@/utils/validation';
import { z } from 'zod';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { load } from 'js-yaml';
import type { SourceMap, TransformPluginContext } from 'rollup';
import { getGitTimestamp } from '@/utils/git-timestamp';
import { entry } from '@/vite/generate';
import { createMdxLoader } from '@/loaders/mdx';
import { resolvedConfig } from '@/loaders/config-loader';
import { toVite } from '@/loaders/adapter';
const FumadocsDeps = ['fumadocs-core', 'fumadocs-ui', 'fumadocs-openapi'];
const querySchema = z
.object({
only: z.literal(['frontmatter', 'all']).default('all'),
collection: z.string().optional(),
})
.loose();
export interface PluginOptions {
/**
* Automatically generate index files for accessing files with `import.meta.glob`.
@@ -56,6 +45,8 @@ export default function mdx(
const { generateIndexFile = true, configPath = 'source.config.ts' } = options;
const loaded = buildConfig(config);
const mdxLoader = toVite(createMdxLoader(resolvedConfig(loaded)));
async function transformMeta(
path: string,
query: string,
@@ -103,85 +94,6 @@ export default function mdx(
};
}
async function transformContent(
this: TransformPluginContext & { environment: Environment },
file: string,
query: string,
value: string,
): Promise<TransformResult | null> {
const matter = fumaMatter(value);
const isDevelopment = this.environment.mode === 'dev';
const parsed = querySchema.parse(parse(query));
const collection = parsed.collection
? loaded.collections.get(parsed.collection)
: undefined;
let schema;
let mdxOptions;
switch (collection?.type) {
case 'doc':
mdxOptions = collection.mdxOptions;
schema = collection.schema;
break;
case 'docs':
mdxOptions = collection.docs.mdxOptions;
schema = collection.docs.schema;
break;
}
if (schema) {
matter.data = await validate(
schema,
matter.data,
{
source: value,
path: file,
},
`invalid frontmatter in ${file}`,
);
}
if (parsed.only === 'frontmatter') {
return {
code: `export const frontmatter = ${JSON.stringify(matter.data)}`,
map: null,
};
}
const data: Record<string, unknown> = {};
if (loaded.global.lastModifiedTime === 'git') {
data.lastModified = (await getGitTimestamp(file))?.getTime();
}
mdxOptions ??= await loaded.getDefaultMDXOptions();
// ensure the line number is correct in dev mode
const lineOffset = isDevelopment ? countLines(matter.matter) : 0;
const compiled = await buildMDX(
parsed.collection ?? 'global',
'\n'.repeat(lineOffset) + matter.content,
{
development: isDevelopment,
...mdxOptions,
data,
filePath: file,
frontmatter: matter.data as Record<string, unknown>,
_compiler: {
addDependency: (file) => {
this.addWatchFile(file);
},
},
},
);
return {
code: String(compiled.value),
map: compiled.map as SourceMap,
};
}
return {
name: 'fumadocs-mdx',
// needed, otherwise other plugins will be executed before our `transform`.
@@ -226,7 +138,7 @@ export default function mdx(
return await transformMeta(file, query, value);
if (['.md', '.mdx'].includes(ext))
return await transformContent.call(this, file, query, value);
return await mdxLoader.call(this, file, query, value);
} catch (e) {
if (e instanceof ValidationError) {
throw new Error(e.toStringFormatted());
+3 -2
View File
@@ -3,13 +3,14 @@ import { defineConfig } from 'tsup';
export default defineConfig({
entry: [
'./src/{index,loader-mdx}.ts',
'./src/{config,next,vite}/index.ts',
'./src/{config,next,vite,bun}/index.ts',
'./src/node/loader.ts',
'./src/config/zod-3.ts',
'./src/runtime/async.ts',
'./src/runtime/vite/{browser,server}.ts',
],
format: ['esm', 'cjs'],
external: ['next', 'typescript'],
external: ['next', 'typescript', 'bun'],
dts: true,
target: 'node18',
});
+25 -3
View File
@@ -306,7 +306,7 @@ importers:
version: 0.2.2(@content-collections/[email protected]([email protected]))([email protected]([email protected]))([email protected])
'@content-collections/next':
specifier: ^0.2.7
version: 0.2.7(@content-collections/[email protected]([email protected]))([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))
version: 0.2.7(@content-collections/[email protected]([email protected]))([email protected](@opentelemetry/[email protected])([email protected]([email protected]))([email protected]))
'@types/mdx':
specifier: ^2.0.13
version: 2.0.13
@@ -1377,7 +1377,7 @@ importers:
version: 0.2.2(@content-collections/[email protected]([email protected]))([email protected]([email protected]))([email protected])
'@content-collections/next':
specifier: ^0.2.7
version: 0.2.7(@content-collections/[email protected]([email protected]))([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))
version: 0.2.7(@content-collections/[email protected]([email protected]))([email protected](@opentelemetry/[email protected])([email protected]([email protected]))([email protected]))
'@react-router/dev':
specifier: ^7.9.1
version: 7.9.1(@react-router/[email protected]([email protected]([email protected]([email protected]))([email protected]))([email protected]))(@types/[email protected])(@vitejs/[email protected]([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected])([email protected])([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))([email protected])
@@ -1574,6 +1574,9 @@ importers:
'@fumadocs/mdx-remote':
specifier: workspace:*
version: link:../mdx-remote
'@types/bun':
specifier: ^1.2.21
version: 1.2.21(@types/[email protected])
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
@@ -4928,6 +4931,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==}
'@types/[email protected]':
resolution: {integrity: sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A==}
'@types/[email protected]':
resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
@@ -5743,6 +5749,11 @@ packages:
[email protected]:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
[email protected]:
resolution: {integrity: sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw==}
peerDependencies:
'@types/react': ^19
[email protected]:
resolution: {integrity: sha512-y0lJ02dS90U3PJm+7KAKY8Se95AQvP5Xm77LouUwrpNOHpv59kBG4SK1+9iE1cAhpUaFipq+0EJ56S6MmE3row==}
cpu: [arm64, x64, aarch64]
@@ -11438,7 +11449,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@content-collections/[email protected](@content-collections/[email protected]([email protected]))([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))':
'@content-collections/[email protected](@content-collections/[email protected]([email protected]))([email protected](@opentelemetry/[email protected])([email protected]([email protected]))([email protected]))':
dependencies:
'@content-collections/core': 0.11.1([email protected])
'@content-collections/integrations': 0.3.0(@content-collections/[email protected]([email protected]))
@@ -14182,6 +14193,12 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected](@types/[email protected])':
dependencies:
bun-types: 1.2.21(@types/[email protected])
transitivePeerDependencies:
- '@types/react'
'@types/[email protected]':
dependencies:
'@types/deep-eql': 4.0.2
@@ -15214,6 +15231,11 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
[email protected](@types/[email protected]):
dependencies:
'@types/node': 24.3.3
'@types/react': 19.1.13
[email protected]:
optionalDependencies:
'@oven/bun-darwin-aarch64': 1.2.21