MDX: Support disableMetaFile option in Bun plugin

This commit is contained in:
Fuma Nama
2025-11-10 22:29:13 +08:00
parent 9a7fd08ca9
commit 40176ce25f
9 changed files with 2942 additions and 2403 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-mdx': patch
---
Support `disableMetaFile` option in Bun plugin
+1 -1
View File
@@ -3,4 +3,4 @@ import { postInstall } from 'fumadocs-mdx/next';
const configPath = 'source.script.ts';
await postInstall(configPath);
Bun.plugin(createMdxPlugin({ configPath }));
Bun.plugin(createMdxPlugin({ configPath, disableMetaFile: true }));
+1 -1
View File
@@ -19,7 +19,7 @@
"devDependencies": {
"@changesets/cli": "^2.29.7",
"@eslint/js": "^9.39.1",
"bun": "^1.3.1",
"bun": "^1.3.2",
"concurrently": "^9.2.1",
"eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5",
+9 -4
View File
@@ -6,15 +6,20 @@ import { type CoreOptions, createCore, findConfigFile } from '@/core';
import { createIntegratedConfigLoader } from '@/loaders/config';
import { createMetaLoader } from '@/loaders/meta';
import { toBun } from '@/loaders/adapter';
import { mdxLoaderGlob, metaLoaderGlob } from '@/loaders';
export type MdxPluginOptions = Partial<CoreOptions>;
export interface MdxPluginOptions extends Partial<CoreOptions> {
/**
* Skip meta file transformation step
*/
disableMetaFile?: boolean;
}
export function createMdxPlugin(options: MdxPluginOptions = {}): BunPlugin {
const {
environment = 'bun',
outDir = '.source',
configPath = findConfigFile(),
disableMetaFile = false,
} = options;
return {
@@ -30,8 +35,8 @@ export function createMdxPlugin(options: MdxPluginOptions = {}): BunPlugin {
});
const configLoader = createIntegratedConfigLoader(core);
toBun(createMdxLoader(configLoader), mdxLoaderGlob)(build);
toBun(createMetaLoader(configLoader), metaLoaderGlob)(build);
toBun(createMdxLoader(configLoader))(build);
if (!disableMetaFile) toBun(createMetaLoader(configLoader))(build);
},
};
}
+90 -57
View File
@@ -13,9 +13,9 @@ export interface LoaderInput {
development: boolean;
compiler: CompilerOptions;
source: string;
filePath: string;
query: Record<string, string | string[] | undefined>;
getSource: () => string | Promise<string>;
}
export interface LoaderOutput {
@@ -23,19 +23,43 @@ export interface LoaderOutput {
map?: unknown;
}
export type Loader = (input: LoaderInput) => Promise<LoaderOutput | null>;
export interface Loader {
/**
* Filter file paths, the input can be either a file URL or file path.
*
* Must take resource query into consideration.
*/
test?: RegExp;
export function toNode(loader: Loader, test: RegExp): LoadHook {
/**
* Transform input into JavaScript.
*
* Returns:
* - `LoaderOutput`: JavaScript code & source map.
* - `null`: skip the loader. Fallback to default behaviour if possible, otherwise the adapter will try workarounds.
*/
load: (input: LoaderInput) => Promise<LoaderOutput | null>;
bun?: {
/**
* Bun doesn't allow `null` in loaders, this is used as the fallback.
*/
fallback?: (input: LoaderInput) => Promise<Bun.OnLoadResult>;
};
}
export function toNode(loader: Loader): LoadHook {
return async (url, _context, nextLoad): Promise<LoadFnOutput> => {
if (url.startsWith('file:///') && test.test(url)) {
if (url.startsWith('file:///') && (!loader.test || loader.test.test(url))) {
const parsedUrl = new URL(url);
const filePath = fileURLToPath(parsedUrl);
const source = (await fs.readFile(filePath)).toString();
const result = await loader({
const result = await loader.load({
filePath,
query: Object.fromEntries(parsedUrl.searchParams.entries()),
source,
async getSource() {
return (await fs.readFile(filePath)).toString();
},
development: false,
compiler: {
addDependency() {},
@@ -55,32 +79,44 @@ export function toNode(loader: Loader, test: RegExp): LoadHook {
};
}
export type ViteLoader = (
this: TransformPluginContext,
file: string,
query: string,
value: string,
) => Promise<TransformResult | null>;
export interface ViteLoader {
filter: (id: string) => boolean;
transform: (
this: TransformPluginContext,
value: string,
id: 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 {
filter(id) {
return !loader.test || loader.test.test(id);
},
async transform(value, id) {
const [file, query = ''] = id.split('?', 2);
if (result === null) return null;
return {
code: result.code,
map: result.map as SourceMap,
};
const result = await loader.load({
filePath: file,
query: parse(query),
getSource() {
return value;
},
development: this.environment.mode === 'dev',
compiler: {
addDependency: (file) => {
this.addWatchFile(file);
},
},
});
if (result === null) return null;
return {
code: result.code,
map: result.map as SourceMap,
};
},
};
}
@@ -90,13 +126,18 @@ export type WebpackLoader = (
callback: LoaderContext<unknown>['callback'],
) => Promise<void>;
/**
* need to handle the `test` regex in Webpack config instead.
*/
export function toWebpack(loader: Loader): WebpackLoader {
return async function (source, callback) {
try {
const result = await loader({
const result = await loader.load({
filePath: this.resourcePath,
query: parse(this.resourceQuery.slice(1)),
source,
getSource() {
return source;
},
development: this.mode === 'development',
compiler: this,
});
@@ -120,40 +161,32 @@ export function toWebpack(loader: Loader): WebpackLoader {
};
}
export function toBun(loader: Loader, test: RegExp) {
export function toBun(loader: Loader) {
return (build: Bun.PluginBuilder) => {
const queryData = new Map<
string,
Record<string, string | undefined | string[]>
>();
build.onResolve({ filter: test }, (args) => {
build.onLoad({ filter: loader.test ?? /.+/ }, async (args) => {
const [filePath, query = ''] = args.path.split('?', 2);
// TODO: this isn't working because `args.path` still doesn't include query string
queryData.set(filePath, parse(query));
return null;
});
build.onLoad({ filter: test }, async (args) => {
const content = await Bun.file(args.path).text();
const result = await loader({
source: content,
query: queryData.get(args.path) ?? {},
filePath: args.path,
const input: LoaderInput = {
async getSource() {
return Bun.file(filePath).text();
},
query: parse(query),
filePath,
development: false,
compiler: {
addDependency() {},
},
});
};
const result = await loader.load(input);
// must return something, no fallback: https://github.com/oven-sh/bun/issues/5303
if (result === null) {
return {
contents: content,
loader: args.loader,
};
if (!loader.bun?.fallback) {
// it errors, treat this as an exception
return;
}
return loader.bun.fallback(input);
}
return {
+89 -83
View File
@@ -10,6 +10,7 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import type { ConfigLoader } from '@/loaders/config';
import { mdxLoaderGlob } from '..';
const querySchema = z
.object({
@@ -27,103 +28,108 @@ const cacheEntry = z.object({
type CacheEntry = z.infer<typeof cacheEntry>;
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 config = await configLoader.getConfig();
return {
test: mdxLoaderGlob,
async load({
getSource,
development: isDevelopment,
query,
compiler,
filePath,
}) {
const value = await getSource();
const matter = fumaMatter(value);
const parsed = querySchema.parse(query);
const config = await configLoader.getConfig();
let after: (() => Promise<void>) | undefined;
let after: (() => Promise<void>) | undefined;
if (!isDevelopment && config.global.experimentalBuildCache) {
const cacheDir = config.global.experimentalBuildCache;
const cacheKey = `${parsed.hash}_${parsed.collection ?? 'global'}_${generateCacheHash(filePath)}`;
if (!isDevelopment && config.global.experimentalBuildCache) {
const cacheDir = config.global.experimentalBuildCache;
const cacheKey = `${parsed.hash}_${parsed.collection ?? 'global'}_${generateCacheHash(filePath)}`;
const cached = await fs
.readFile(path.join(cacheDir, cacheKey))
.then((content) => cacheEntry.parse(JSON.parse(content.toString())))
.catch(() => null);
const cached = await fs
.readFile(path.join(cacheDir, cacheKey))
.then((content) => cacheEntry.parse(JSON.parse(content.toString())))
.catch(() => null);
if (cached && cached.hash === generateCacheHash(value)) return cached;
after = async () => {
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, cacheKey),
JSON.stringify({
...out,
hash: generateCacheHash(value),
} satisfies CacheEntry),
if (cached && cached.hash === generateCacheHash(value)) return cached;
after = async () => {
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, cacheKey),
JSON.stringify({
...out,
hash: generateCacheHash(value),
} satisfies CacheEntry),
);
};
}
const collection = parsed.collection
? config.getCollection(parsed.collection)
: undefined;
let docCollection: DocCollectionItem | undefined;
switch (collection?.type) {
case 'doc':
docCollection = collection;
break;
case 'docs':
docCollection = collection.docs;
break;
}
if (docCollection?.schema) {
matter.data = await validate(
docCollection.schema,
matter.data,
{
source: value,
path: filePath,
},
`invalid frontmatter in ${filePath}`,
);
};
}
}
const collection = parsed.collection
? config.getCollection(parsed.collection)
: undefined;
if (parsed.only === 'frontmatter') {
return {
code: `export const frontmatter = ${JSON.stringify(matter.data)}`,
map: null,
};
}
let docCollection: DocCollectionItem | undefined;
switch (collection?.type) {
case 'doc':
docCollection = collection;
break;
case 'docs':
docCollection = collection.docs;
break;
}
const data: Record<string, unknown> = {};
if (config.global.lastModifiedTime === 'git') {
data.lastModified = (await getGitTimestamp(filePath))?.getTime();
}
if (docCollection?.schema) {
matter.data = await validate(
docCollection.schema,
matter.data,
// ensure the line number is correct in dev mode
const lineOffset = isDevelopment ? countLines(matter.matter) : 0;
const compiled = await buildMDX(
`${getConfigHash(config)}:${parsed.collection ?? 'global'}`,
'\n'.repeat(lineOffset) + matter.content,
{
source: value,
path: filePath,
development: isDevelopment,
...(docCollection?.mdxOptions ??
(await config.getDefaultMDXOptions())),
postprocess: docCollection?.postprocess,
data,
filePath,
frontmatter: matter.data as Record<string, unknown>,
_compiler: compiler,
},
`invalid frontmatter in ${filePath}`,
);
}
if (parsed.only === 'frontmatter') {
return {
code: `export const frontmatter = ${JSON.stringify(matter.data)}`,
map: null,
const out = {
code: String(compiled.value),
map: compiled.map as SourceMap,
};
}
const data: Record<string, unknown> = {};
if (config.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(
`${getConfigHash(config)}:${parsed.collection ?? 'global'}`,
'\n'.repeat(lineOffset) + matter.content,
{
development: isDevelopment,
...(docCollection?.mdxOptions ?? (await config.getDefaultMDXOptions())),
postprocess: docCollection?.postprocess,
data,
filePath,
frontmatter: matter.data as Record<string, unknown>,
_compiler: compiler,
},
);
const out = {
code: String(compiled.value),
map: compiled.map as SourceMap,
};
await after?.();
return out;
await after?.();
return out;
},
};
}
+67 -47
View File
@@ -3,6 +3,7 @@ import type { ConfigLoader } from '@/loaders/config';
import { dump, load } from 'js-yaml';
import { validate } from '@/utils/validation';
import { z } from 'zod';
import { metaLoaderGlob } from '.';
const querySchema = z
.object({
@@ -22,56 +23,75 @@ export function createMetaLoader(
): Loader {
const { json: resolveJson = 'js', yaml: resolveYaml = 'js' } = resolve;
return async ({ filePath, query, source }) => {
const isJson = filePath.endsWith('.json');
const parsed = querySchema.parse(query);
const collection = parsed.collection
? (await configLoader.getConfig()).getCollection(parsed.collection)
: undefined;
if (!collection) return null;
let data: unknown;
try {
data = isJson ? JSON.parse(source) : load(source);
} catch (e) {
throw new Error(`invalid data in ${filePath}`, { cause: e });
}
let schema;
switch (collection?.type) {
case 'meta':
schema = collection.schema;
break;
case 'docs':
schema = collection.meta.schema;
break;
}
if (schema) {
data = await validate(
schema,
data,
{ path: filePath, source },
`invalid data in ${filePath}`,
);
}
let code: string;
function stringifyOutput(isJson: boolean, data: unknown) {
if (isJson) {
code =
resolveJson === 'json'
? JSON.stringify(data)
: `export default ${JSON.stringify(data)}`;
return resolveJson === 'json'
? JSON.stringify(data)
: `export default ${JSON.stringify(data)}`;
} else {
code =
resolveYaml === 'yaml'
? dump(data)
: `export default ${JSON.stringify(data)}`;
return resolveYaml === 'yaml'
? dump(data)
: `export default ${JSON.stringify(data)}`;
}
}
return {
code,
map: null,
};
return {
test: metaLoaderGlob,
async load({ filePath, query, getSource }) {
const parsed = querySchema.parse(query);
const collection = parsed.collection
? (await configLoader.getConfig()).getCollection(parsed.collection)
: undefined;
if (!collection) return null;
const isJson = filePath.endsWith('.json');
const source = await getSource();
let data: unknown;
try {
data = isJson ? JSON.parse(source) : load(source);
} catch (e) {
throw new Error(`invalid data in ${filePath}`, { cause: e });
}
let schema;
switch (collection?.type) {
case 'meta':
schema = collection.schema;
break;
case 'docs':
schema = collection.meta.schema;
break;
}
if (schema) {
data = await validate(
schema,
data,
{ path: filePath, source },
`invalid data in ${filePath}`,
);
}
return {
code: stringifyOutput(isJson, data),
};
},
bun: {
async fallback({ getSource, filePath }) {
const source = await getSource();
const isJson = filePath.endsWith('.json');
let data: unknown;
try {
data = isJson ? JSON.parse(source) : load(source);
} catch (e) {
throw new Error(`invalid data in ${filePath}`, { cause: e });
}
return {
loader: 'object',
exports: data as Record<string, unknown>,
};
},
},
};
}
+5 -8
View File
@@ -8,7 +8,6 @@ import type { FSWatcher } from 'chokidar';
import { createCore, findConfigFile } from '@/core';
import { createIntegratedConfigLoader } from '@/loaders/config';
import { createMetaLoader } from '@/loaders/meta';
import { mdxLoaderGlob, metaLoaderGlob } from '@/loaders';
const FumadocsDeps = ['fumadocs-core', 'fumadocs-ui', 'fumadocs-openapi'];
@@ -53,7 +52,7 @@ export default async function mdx(
const metaLoader = toVite(
createMetaLoader(configLoader, {
// vite has built-in plugin for JSON files
json: 'json',
json: 'js',
}),
);
@@ -84,14 +83,12 @@ export default async function mdx(
},
async transform(value, id) {
try {
if (metaLoaderGlob.test(id)) {
const [file, query = ''] = id.split('?', 2);
return await metaLoader.call(this, file, query, value);
if (metaLoader.filter(id)) {
return await metaLoader.transform.call(this, value, id);
}
if (mdxLoaderGlob.test(id)) {
const [file, query = ''] = id.split('?', 2);
return await mdxLoader.call(this, file, query, value);
if (mdxLoader.filter(id)) {
return await mdxLoader.transform.call(this, value, id);
}
} catch (e) {
if (e instanceof ValidationError) {
+2675 -2202
View File
File diff suppressed because it is too large Load Diff