MDX: Support plugins in Webpack loader environment

This commit is contained in:
Fuma Nama
2025-10-25 01:03:55 +08:00
parent f622fd6aa9
commit 56332df0d2
9 changed files with 142 additions and 96 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-mdx': patch
---
Support plugins in Webpack loader environment: now plugins can apply changes on MDX options too.
+17 -7
View File
@@ -1,21 +1,31 @@
import type { BunPlugin } from 'bun';
import { createMdxLoader } from '@/loaders/mdx';
import { findConfigFile, resolvedConfig } from '@/loaders/config';
import { findConfigFile } from '@/loaders/config';
import { buildConfig } from '@/config/build';
import { parse } from 'node:querystring';
import { pathToFileURL } from 'node:url';
import { type CoreOptions, createCore } from '@/core';
export interface MdxPluginOptions {
configPath?: string;
}
export type MdxPluginOptions = Partial<CoreOptions>;
export function createMdxPlugin(options: MdxPluginOptions = {}): BunPlugin {
const { configPath = findConfigFile() } = options;
const {
environment = 'bun',
outDir = '.source',
configPath = findConfigFile(),
} = options;
async function getMdxLoader() {
const importPath = pathToFileURL(configPath).href;
const out = buildConfig(await import(importPath));
return createMdxLoader(resolvedConfig(out));
const core = await createCore({
environment,
outDir,
configPath,
}).init({
config: buildConfig(await import(importPath)),
});
return createMdxLoader(core.creatConfigLoader());
}
return {
+19 -3
View File
@@ -1,15 +1,21 @@
import path from 'node:path';
import fs from 'node:fs/promises';
import type { LoadedConfig } from '@/loaders/config';
import { EmitEntry, Plugin, PluginContext, PluginOption } from '@/plugins';
import type { ConfigLoader, LoadedConfig } from '@/loaders/config';
import type { EmitEntry, Plugin, PluginOption } from '@/plugins';
import type { FSWatcher } from 'chokidar';
export interface ServerContext {
watcher?: FSWatcher;
}
export interface CoreOptions {
environment: string;
configPath: string;
outDir: string;
}
export function createCore(
context: PluginContext,
context: CoreOptions,
defaultPlugins: PluginOption[] = [],
) {
let config: LoadedConfig;
@@ -22,6 +28,7 @@ export function createCore(
}
return {
_options: context,
async init(options: { config: LoadedConfig | Promise<LoadedConfig> }) {
config = await options.config;
plugins = [];
@@ -45,6 +52,13 @@ export function createCore(
getConfig() {
return config;
},
creatConfigLoader(): ConfigLoader {
return {
getConfig() {
return config;
},
};
},
async initServer(server: ServerContext) {
for (const plugin of plugins) {
await plugin.configureServer?.call(context, server);
@@ -66,3 +80,5 @@ export function createCore(
},
};
}
export type Core = ReturnType<typeof createCore>;
+35 -28
View File
@@ -7,6 +7,7 @@ import type {
} from '@/config';
import type { ProcessorOptions } from '@mdx-js/mdx';
import fs from 'node:fs/promises';
import { Core } from '@/core';
export interface ConfigLoader {
getConfig: () => LoadedConfig | Promise<LoadedConfig>;
@@ -23,51 +24,47 @@ export function findConfigFile(): string {
return path.resolve('source.config.ts');
}
export function resolvedConfig(loaded: LoadedConfig): ConfigLoader {
return {
getConfig() {
return loaded;
},
};
}
export function staticConfig({
outDir,
configPath,
core,
buildConfig,
}: {
configPath: string;
outDir: string;
core: Core;
buildConfig: boolean;
}): ConfigLoader {
let cached: Promise<LoadedConfig> | undefined;
async function newConfig() {
const { loadConfig } = await import('./load');
await core.init({
config: loadConfig(
core._options.configPath,
core._options.outDir,
buildConfig,
),
});
return core.getConfig();
}
return {
async getConfig() {
if (cached) return cached;
cached = import('./load').then((mod) =>
mod.loadConfig(configPath, outDir, buildConfig),
);
return cached;
return (cached ??= newConfig());
},
};
}
export function dynamicConfig({
outDir,
configPath,
core,
buildConfig,
}: {
configPath: string;
outDir: string;
core: Core;
buildConfig: boolean;
}): ConfigLoader {
let loaded: { config: Promise<LoadedConfig>; hash: string } | undefined;
async function getConfigHash(): Promise<string> {
const stats = await fs.stat(configPath).catch(() => undefined);
const stats = await fs
.stat(core._options.configPath)
.catch(() => undefined);
if (stats) {
return stats.mtime.getTime().toString();
@@ -76,6 +73,19 @@ export function dynamicConfig({
throw new Error('Cannot find config file');
}
async function newConfig() {
const { loadConfig } = await import('./load');
await core.init({
config: loadConfig(
core._options.configPath,
core._options.outDir,
buildConfig,
),
});
return core.getConfig();
}
return {
async getConfig() {
const hash = await getConfigHash();
@@ -83,11 +93,8 @@ export function dynamicConfig({
loaded = {
hash,
config: import('./load').then((mod) =>
mod.loadConfig(configPath, outDir, buildConfig),
),
config: newConfig(),
};
return loaded.config;
},
};
+32 -32
View File
@@ -26,17 +26,6 @@ const cacheEntry = z.object({
type CacheEntry = z.infer<typeof cacheEntry>;
const hashes = new WeakMap<LoadedConfig, string>();
function getConfigHash(config: LoadedConfig) {
let hash = hashes.get(config);
if (hash) return hash;
hash = Date.now().toString();
hashes.set(config, hash);
return hash;
}
export function createMdxLoader(configLoader: ConfigLoader): Loader {
return async ({
source: value,
@@ -47,24 +36,34 @@ export function createMdxLoader(configLoader: ConfigLoader): Loader {
}) => {
const matter = fumaMatter(value);
const parsed = querySchema.parse(query);
const config = await configLoader.getConfig();
const loaded = await configLoader.getConfig();
const cacheDir = isDevelopment
? undefined
: loaded.global.experimentalBuildCache;
const cacheKey = `${parsed.hash}_${parsed.collection ?? 'global'}_${generateCacheHash(filePath)}`;
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 (cacheDir) {
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),
);
};
}
const collection = parsed.collection
? loaded.collections.get(parsed.collection)
? config.collections.get(parsed.collection)
: undefined;
let docCollection: DocCollection | undefined;
@@ -97,7 +96,7 @@ export function createMdxLoader(configLoader: ConfigLoader): Loader {
}
const data: Record<string, unknown> = {};
if (loaded.global.lastModifiedTime === 'git') {
if (config.global.lastModifiedTime === 'git') {
data.lastModified = (await getGitTimestamp(filePath))?.getTime();
}
@@ -105,11 +104,11 @@ export function createMdxLoader(configLoader: ConfigLoader): Loader {
const lineOffset = isDevelopment ? countLines(matter.matter) : 0;
const compiled = await buildMDX(
`${getConfigHash(loaded)}:${parsed.collection ?? 'global'}`,
`${getConfigHash(config)}:${parsed.collection ?? 'global'}`,
'\n'.repeat(lineOffset) + matter.content,
{
development: isDevelopment,
...(docCollection?.mdxOptions ?? (await loaded.getDefaultMDXOptions())),
...(docCollection?.mdxOptions ?? (await config.getDefaultMDXOptions())),
postprocess: docCollection?.postprocess,
data,
filePath,
@@ -123,21 +122,22 @@ export function createMdxLoader(configLoader: ConfigLoader): Loader {
map: compiled.map as SourceMap,
};
if (cacheDir) {
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, cacheKey),
JSON.stringify({
...out,
hash: generateCacheHash(value),
} satisfies CacheEntry),
);
}
await after?.();
return out;
};
}
const hashes = new WeakMap<LoadedConfig, string>();
function getConfigHash(config: LoadedConfig) {
let hash = hashes.get(config);
if (hash) return hash;
hash = Date.now().toString();
hashes.set(config, hash);
return hash;
}
function generateCacheHash(input: string): string {
return createHash('md5').update(input).digest('hex');
}
+8 -2
View File
@@ -1,12 +1,18 @@
import { findConfigFile, staticConfig } from '@/loaders/config';
import { createMdxLoader } from '@/loaders/mdx';
import { toNode } from '@/loaders/adapter';
import { createCore } from '@/core';
const core = createCore({
environment: 'node',
configPath: findConfigFile(),
outDir: '.source',
});
export const load = toNode(
createMdxLoader(
staticConfig({
configPath: findConfigFile(),
outDir: '.source',
core,
buildConfig: true,
}),
),
+2 -6
View File
@@ -1,5 +1,5 @@
import type { LoadedConfig } from '@/loaders/config';
import type { ServerContext } from '@/core';
import type { CoreOptions, ServerContext } from '@/core';
type Awaitable<T> = T | Promise<T>;
@@ -11,11 +11,7 @@ export interface EmitEntry {
content: string;
}
export interface PluginContext {
environment: 'next' | 'vite';
configPath: string;
outDir: string;
}
export type PluginContext = CoreOptions;
export interface Plugin {
/**
+2 -2
View File
@@ -10,7 +10,7 @@ import { validate, ValidationError } from '@/utils/validation';
import * as path from 'node:path';
import { load } from 'js-yaml';
import { createMdxLoader } from '@/loaders/mdx';
import { findConfigFile, resolvedConfig } from '@/loaders/config';
import { findConfigFile } from '@/loaders/config';
import { toVite } from '@/loaders/adapter';
import vite, { type IndexFileOptions } from '@/plugins/vite';
import type { FSWatcher } from 'chokidar';
@@ -54,7 +54,7 @@ export default async function mdx(
const core = await createViteCore(options).init({
config: buildConfig(config),
});
const mdxLoader = toVite(createMdxLoader(resolvedConfig(core.getConfig())));
const mdxLoader = toVite(createMdxLoader(core.creatConfigLoader()));
async function transformMeta(
path: string,
+22 -16
View File
@@ -2,6 +2,7 @@ import { type LoaderContext } from 'webpack';
import { createMdxLoader } from '@/loaders/mdx';
import { dynamicConfig, staticConfig } from '@/loaders/config';
import { toWebpack, type WebpackLoader } from '@/loaders/adapter';
import { createCore } from '@/core';
export interface Options {
configPath: string;
@@ -25,22 +26,27 @@ export default async function loader(
this.cacheable(true);
this.addDependency(configPath);
instance ??= toWebpack(
createMdxLoader(
// the config is built on dev server
isDev
? dynamicConfig({
outDir,
configPath,
buildConfig: false,
})
: staticConfig({
outDir,
configPath,
buildConfig: false,
}),
),
);
if (!instance) {
const core = createCore({
environment: 'webpack',
outDir,
configPath,
});
instance = toWebpack(
createMdxLoader(
isDev
? dynamicConfig({
core,
buildConfig: false,
})
: staticConfig({
core,
buildConfig: false,
}),
),
);
}
await instance.call(this, source, callback);
}