MDX: Replace lastModifiedTime option with lastModified plugin

This commit is contained in:
Fuma Nama
2025-11-15 23:47:37 +08:00
parent 44448920d3
commit bc93578c9b
17 changed files with 276 additions and 194 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-mdx': major
---
Replace `lastModifiedTime` option with `lastModified` plugin
+2 -1
View File
@@ -8,6 +8,7 @@ import {
import { z } from 'zod';
import type { ElementContent } from 'hast';
import jsonSchema from 'fumadocs-mdx/plugins/json-schema';
import lastModified from 'fumadocs-mdx/plugins/last-modified';
import type { ShikiTransformer } from 'shiki';
export const docs = defineDocs({
@@ -63,11 +64,11 @@ function transformerEscape(): ShikiTransformer {
}
export default defineConfig({
lastModifiedTime: 'git',
plugins: [
jsonSchema({
insert: true,
}),
lastModified(),
],
mdxOptions: async () => {
const { rehypeCodeDefaultOptions } = await import(
+2 -2
View File
@@ -82,7 +82,8 @@
"unified": "^11.0.5",
"unist-util-remove-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
"zod": "^4.1.12"
"zod": "^4.1.12",
"vfile": "^6.0.3"
},
"devDependencies": {
"@fumadocs/mdx-remote": "workspace:*",
@@ -105,7 +106,6 @@
"remark-stringify": "^11.0.0",
"rollup": "^4.52.5",
"tsconfig": "workspace:*",
"vfile": "^6.0.3",
"vite": "^7.2.0",
"webpack": "^5.102.1"
},
-6
View File
@@ -82,12 +82,6 @@ export interface GlobalConfig {
*/
mdxOptions?: GlobalConfigMDXOptions | (() => Promise<GlobalConfigMDXOptions>);
/**
* Fetch last modified time with specified version control
* @defaultValue 'none'
*/
lastModifiedTime?: 'git' | 'none';
/**
* specify a directory to access & store cache (disabled during development mode).
*
+96 -11
View File
@@ -1,8 +1,14 @@
import type { LoadedConfig } from '@/config/build';
import type {
DocCollectionItem,
LoadedConfig,
MetaCollectionItem,
} from '@/config/build';
import path from 'node:path';
import fs from 'node:fs/promises';
import type { FSWatcher } from 'chokidar';
import { removeFileCache } from './utils/codegen/cache';
import { validate } from './utils/validation';
import type { VFile } from 'vfile';
type Awaitable<T> = T | Promise<T>;
@@ -41,6 +47,30 @@ export interface Plugin {
this: PluginContext,
server: ServerContext,
) => Awaitable<void>;
/**
* Transform frontmatter/metadata
*/
metadata?: (
this: PluginContext & {
collection: DocCollectionItem | MetaCollectionItem;
filePath: string;
},
data: unknown,
) => Awaitable<unknown | void>;
doc?: {
/**
* Transform `vfile` on compilation stage
*/
vfile?: (
this: PluginContext & {
collection: DocCollectionItem;
filePath: string;
},
file: VFile,
) => Awaitable<VFile | void>;
};
}
export type PluginOption = Awaitable<Plugin | PluginOption[] | false>;
@@ -91,14 +121,15 @@ export function createCore(
let config: LoadedConfig;
let plugins: Plugin[];
return {
function createPluginContext(): PluginContext {
return {
core,
...options,
};
}
const core = {
_options: options,
getPluginContext(): PluginContext {
return {
core: this,
...options,
};
},
/**
* Convenient cache store, reset when config changes
*/
@@ -111,8 +142,9 @@ export function createCore(
...(config.global.plugins ?? []),
]);
const ctx = createPluginContext();
for (const plugin of plugins) {
const out = await plugin.config?.call(this.getPluginContext(), config);
const out = await plugin.config?.call(ctx, config);
if (out) config = out;
}
@@ -132,19 +164,22 @@ export function createCore(
if (event === 'change') removeFileCache(file);
});
const ctx = createPluginContext();
for (const plugin of plugins) {
await plugin.configureServer?.call(this.getPluginContext(), server);
await plugin.configureServer?.call(ctx, server);
}
},
async emit({ filterPlugin = () => true }: EmitOptions = {}): Promise<
EmitEntry[]
> {
const ctx = createPluginContext();
return (
await Promise.all(
plugins.map((plugin) => {
if (!filterPlugin(plugin) || !plugin.emit) return [];
return plugin.emit.call(this.getPluginContext());
return plugin.emit.call(ctx);
}),
)
).flat();
@@ -163,7 +198,57 @@ 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}`,
);
}
const ctx = {
...createPluginContext(),
filePath,
collection,
};
for (const plugin of plugins) {
data = (await plugin.metadata?.call(ctx, data)) ?? data;
}
return data;
},
doc: {
async vfile(
collection: DocCollectionItem,
filePath: string,
file: VFile,
): Promise<VFile> {
const ctx = {
...createPluginContext(),
filePath,
collection,
};
for (const plugin of plugins) {
file = (await plugin.doc?.vfile?.call(ctx, file)) ?? file;
}
return file;
},
},
};
return core;
}
export type Core = ReturnType<typeof createCore>;
+1 -1
View File
@@ -153,7 +153,7 @@ export function toWebpack(loader: Loader): WebpackLoader {
}
} catch (error) {
if (error instanceof ValidationError) {
return callback(new Error(error.toStringFormatted()));
return callback(new Error(await error.toStringFormatted()));
}
if (!(error instanceof Error)) throw error;
+3
View File
@@ -4,6 +4,7 @@ import type { LoadedConfig } from '@/config/build';
export interface ConfigLoader {
getConfig: () => LoadedConfig | Promise<LoadedConfig>;
core: Core;
}
export function createStandaloneConfigLoader({
@@ -43,6 +44,7 @@ export function createStandaloneConfigLoader({
}
return {
core,
async getConfig() {
const hash = await getConfigHash();
if (loaded && loaded.hash === hash) return loaded.config;
@@ -61,6 +63,7 @@ export function createStandaloneConfigLoader({
*/
export function createIntegratedConfigLoader(core: Core): ConfigLoader {
return {
core,
getConfig() {
return core.getConfig();
},
+49 -49
View File
@@ -1,5 +1,5 @@
import { createProcessor, type ProcessorOptions } from '@mdx-js/mdx';
import type { VFile } from 'vfile';
import { createProcessor } from '@mdx-js/mdx';
import { VFile } from 'vfile';
import { remarkInclude } from '@/loaders/mdx/remark-include';
import type { StructuredData } from 'fumadocs-core/mdx-plugins';
import type { TOCItemType } from 'fumadocs-core/toc';
@@ -10,26 +10,22 @@ import {
type PostprocessOptions,
remarkPostprocess,
} from '@/loaders/mdx/remark-postprocess';
import type { Core } from '@/core';
import type { DocCollectionItem } from '@/config/build';
type Processor = ReturnType<typeof createProcessor>;
const cache = new Map<string, Processor>();
interface BuildMDXOptions extends ProcessorOptions {
interface BuildMDXOptions {
/**
* Specify a file path for source
*/
filePath: string;
source: string;
frontmatter?: Record<string, unknown>;
/**
* Custom Vfile data
*/
data?: Record<string, unknown>;
environment: 'bundler' | 'runtime';
isDevelopment: boolean;
_compiler?: CompilerOptions;
postprocess?: Partial<PostprocessOptions>;
}
export interface CompilerOptions {
@@ -82,45 +78,50 @@ declare module 'vfile' {
interface DataMap extends FumadocsDataMap {}
}
/**
* @param cacheKey - key to cache processor
* @param source - mdx content
* @param options - MDX options
*/
export async function buildMDX(
cacheKey: string,
source: string,
options: BuildMDXOptions,
core: Core,
collection: DocCollectionItem | undefined,
{
filePath,
frontmatter,
source,
_compiler,
environment,
isDevelopment,
}: BuildMDXOptions,
): Promise<VFile> {
const { filePath, frontmatter, data, _compiler, ...rest } = options;
const mdxOptions = await core
.getConfig()
.getMDXOptions(collection, environment);
function getProcessor(format: 'md' | 'mdx') {
const key = `${cacheKey}:${format}`;
const cache = core.cache as Map<string, Processor>;
const key = `build-mdx:${collection?.name ?? 'global'}:${format}`;
let processor = cache.get(key);
if (!processor) {
const postprocessOptions: PostprocessOptions = {
_format: format,
...collection?.postprocess,
valueToExport: [
...(collection?.postprocess?.valueToExport ?? []),
'structuredData',
'extractedReferences',
'frontmatter',
'lastModified',
'_markdown',
'_mdast',
],
};
processor = createProcessor({
outputFormat: 'program',
...rest,
development: isDevelopment,
...mdxOptions,
remarkPlugins: [
remarkInclude,
...(rest.remarkPlugins ?? []),
[
remarkPostprocess,
{
_format: format,
...options.postprocess,
valueToExport: [
...(options.postprocess?.valueToExport ?? []),
'structuredData',
'extractedReferences',
'frontmatter',
'lastModified',
'_markdown',
'_mdast',
],
} satisfies PostprocessOptions,
],
...(mdxOptions.remarkPlugins ?? []),
[remarkPostprocess, postprocessOptions],
],
format,
});
@@ -131,16 +132,15 @@ export async function buildMDX(
return processor;
}
return getProcessor(
options.format ?? (filePath.endsWith('.mdx') ? 'mdx' : 'md'),
).process({
let vfile = new VFile({
value: source,
path: filePath,
data: {
...data,
frontmatter,
_compiler,
_getProcessor: getProcessor,
},
data: { frontmatter, _compiler, _getProcessor: getProcessor },
});
if (collection) {
vfile = await core.doc.vfile(collection, filePath, vfile);
}
return getProcessor(filePath.endsWith('.mdx') ? 'mdx' : 'md').process(vfile);
}
+14 -40
View File
@@ -1,10 +1,8 @@
import { fumaMatter } from '@/utils/fuma-matter';
import { validate } from '@/utils/validation';
import { getGitTimestamp } from '@/utils/git-timestamp';
import type { SourceMap } from 'rollup';
import type { Loader } from '@/loaders/adapter';
import { z } from 'zod';
import type { DocCollectionItem, LoadedConfig } from '@/config/build';
import type { DocCollectionItem } from '@/config/build';
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
@@ -79,15 +77,12 @@ export function createMdxLoader(configLoader: ConfigLoader): Loader {
break;
}
if (docCollection?.schema) {
matter.data = await validate(
docCollection.schema,
if (docCollection) {
matter.data = await configLoader.core.metadata(
docCollection,
filePath,
value,
matter.data,
{
source: value,
path: filePath,
},
`invalid frontmatter in ${filePath}`,
);
}
@@ -98,28 +93,18 @@ export function createMdxLoader(configLoader: ConfigLoader): Loader {
};
}
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 { buildMDX } = await import('@/loaders/mdx/build-mdx');
const compiled = await buildMDX(
`${getConfigHash(config)}:${parsed.collection ?? 'global'}`,
'\n'.repeat(lineOffset) + matter.content,
{
development: isDevelopment,
...(await config.getMDXOptions(docCollection, 'bundler')),
postprocess: docCollection?.postprocess,
data,
filePath,
frontmatter: matter.data as Record<string, unknown>,
_compiler: compiler,
},
);
const compiled = await buildMDX(configLoader.core, docCollection, {
isDevelopment,
source: '\n'.repeat(lineOffset) + matter.content,
filePath,
frontmatter: matter.data as Record<string, unknown>,
_compiler: compiler,
environment: 'bundler',
});
const out = {
code: String(compiled.value),
@@ -132,17 +117,6 @@ export function createMdxLoader(configLoader: ConfigLoader): Loader {
};
}
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');
}
+5 -6
View File
@@ -1,7 +1,6 @@
import type { Loader, LoaderInput } from '@/loaders/adapter';
import type { ConfigLoader } from '@/loaders/config';
import { dump, load } from 'js-yaml';
import { validate } from '@/utils/validation';
import { z } from 'zod';
import { metaLoaderGlob } from '.';
import type { MetaCollectionItem } from '@/config/build';
@@ -55,12 +54,12 @@ export function createMetaLoader(
}
const data = parse(filePath, source);
if (metaCollection?.schema) {
return validate(
metaCollection.schema,
if (metaCollection) {
return configLoader.core.metadata(
metaCollection,
filePath,
source,
data,
{ path: filePath, source },
`invalid data in ${filePath}`,
);
}
+1 -1
View File
@@ -214,7 +214,7 @@ function createNextCore(options: Required<CreateMDXOptions>): Core {
await core.emitAndWrite(...args);
} catch (err) {
if (err instanceof ValidationError) {
console.error(err.toStringFormatted());
console.error(await err.toStringFormatted());
} else {
console.error(err);
}
+13 -28
View File
@@ -1,4 +1,4 @@
import type { Plugin } from '@/core';
import type { Core, Plugin } from '@/core';
import type {
LoadedConfig,
CollectionItem,
@@ -13,8 +13,6 @@ import { createHash } from 'crypto';
import type { LazyEntry } from '@/runtime/dynamic';
import type { EmitEntry } from '@/core';
import { fumaMatter } from '@/utils/fuma-matter';
import { getGitTimestamp } from '@/utils/git-timestamp';
import { validate } from '@/utils/validation';
export interface IndexFilePluginOptions {
target?: 'default' | 'vite';
@@ -120,7 +118,7 @@ export default function indexFile(
out.push(
toEmitEntry(
'dynamic.ts',
generateDynamicIndexFile(makeCodeGen(), config, this.configPath),
generateDynamicIndexFile(this.core, makeCodeGen()),
),
);
@@ -215,16 +213,13 @@ async function generateServerIndexFile(
return codegen.toString();
}
async function generateDynamicIndexFile(
codegen: CodeGen,
config: LoadedConfig,
configPath: string,
) {
async function generateDynamicIndexFile(core: Core, codegen: CodeGen) {
const { configPath } = core._options;
codegen.lines.push(
`import { fromConfigDynamic } from 'fumadocs-mdx/runtime/dynamic';`,
`import * as Config from '${codegen.formatImportPath(configPath)}';`,
'',
`const create = fromConfigDynamic(Config);`,
`const create = await fromConfigDynamic(Config);`,
);
async function generateCollectionObjectEntry(
@@ -234,21 +229,12 @@ async function generateDynamicIndexFile(
const fullPath = path.join(collection.dir, file);
const content = await readFileWithCache(fullPath).catch(() => '');
const parsed = fumaMatter(content);
let data = parsed.data;
if (collection.schema) {
data = await validate(
collection.schema,
parsed.data,
{ path: fullPath, source: parsed.content },
`invalid frontmatter in ${fullPath}`,
);
}
let lastModified: Date | undefined;
if (config.global?.lastModifiedTime === 'git') {
lastModified = await getGitTimestamp(fullPath);
}
const data = await core.metadata(
collection,
fullPath,
content,
parsed.data,
);
const hash = createHash('md5').update(content).digest('hex');
const infoStr: string[] = [
@@ -261,9 +247,8 @@ async function generateDynamicIndexFile(
path: file,
},
data,
lastModified,
hash,
} satisfies LazyEntry<unknown>)) {
} satisfies LazyEntry)) {
infoStr.push(`${k}: ${JSON.stringify(v)}`);
}
@@ -302,7 +287,7 @@ async function generateDynamicIndexFile(
}
await codegen.pushAsync(
config.collectionList.map(async (collection) => {
core.getConfig().collectionList.map(async (collection) => {
const obj = await generateCollectionObject(collection);
if (!obj) return;
+63
View File
@@ -0,0 +1,63 @@
import path from 'node:path';
import { x } from 'tinyexec';
import type { Plugin } from '@/core';
const cache = new Map<string, Promise<Date>>();
export interface LastModifiedPluginOptions {
/**
* Version control to obtain the last modified time.
*
* - `git`: Requires `git` to be installed.
*
* If you are using Vercel, please set `VERCEL_DEEP_CLONE` environment variable to `true`.
*
* @defaultValue 'git'
*/
versionControl?: 'git';
}
/**
* Injects `lastModified` property to page data.
*
* By default, it uses Git to obtain the last modified time.
*/
export default function lastModified(
options: LastModifiedPluginOptions = {},
): Plugin {
const { versionControl = 'git' } = options;
return {
name: 'last-modified',
doc: {
async vfile(file) {
if (versionControl === 'git') {
const timestamp = await getGitTimestamp(this.filePath);
if (timestamp === undefined) return;
file.data.lastModified = timestamp.getTime();
}
},
},
};
}
async function getGitTimestamp(file: string): Promise<Date | undefined> {
const cached = cache.get(file);
if (cached) return cached;
const timePromise = (async () => {
const out = await x(
'git',
['log', '-1', '--pretty="%ai"', path.relative(process.cwd(), file)],
{
throwOnError: true,
},
);
return new Date(out.stdout);
})();
cache.set(file, timePromise);
return timePromise.catch(() => undefined);
}
+18 -16
View File
@@ -5,11 +5,12 @@ import { pathToFileURL } from 'node:url';
import { fumaMatter } from '@/utils/fuma-matter';
import fs from 'node:fs/promises';
import { type FileInfo, fromConfig } from './server';
import { type CoreOptions, createCore } from '@/core';
export interface LazyEntry<Data> {
export interface LazyEntry<Data = unknown> {
info: FileInfo;
data: Data;
lastModified?: Date;
hash?: string;
}
@@ -17,12 +18,18 @@ export type CreateDynamic<Config> = ReturnType<
typeof fromConfigDynamic<Config>
>;
export function fromConfigDynamic<Config>(configExports: Config) {
const config = buildConfig(configExports as Record<string, unknown>);
export async function fromConfigDynamic<Config>(
configExports: Config,
coreOptions: CoreOptions,
) {
const core = await createCore(coreOptions).init({
config: buildConfig(configExports as Record<string, unknown>),
});
const create = fromConfig<Config>();
function getDocCollection(name: string): DocCollectionItem | undefined {
const collection = config.getCollection(name);
const collection = core.getConfig().getCollection(name);
if (!collection) return;
if (collection.type === 'docs') return collection.docs;
@@ -36,20 +43,15 @@ export function fromConfigDynamic<Config>(configExports: Config) {
const head: Record<string, () => unknown> = {};
const body: Record<string, () => Promise<unknown>> = {};
async function compile({ info, lastModified, data }: LazyEntry<unknown>) {
const mdxOptions = await config.getMDXOptions(collection, 'runtime');
async function compile({ info, data }: LazyEntry<unknown>) {
const raw = (await fs.readFile(info.fullPath)).toString();
const { content } = fumaMatter(raw);
const compiled = await buildMDX(collection.name, content, {
...mdxOptions,
development: false,
frontmatter: data as Record<string, unknown>,
postprocess: collection!.postprocess,
data: {
lastModified,
},
const compiled = await buildMDX(core, collection, {
filePath: info.fullPath,
source: fumaMatter(raw).content,
frontmatter: data as Record<string, unknown>,
isDevelopment: false,
environment: 'runtime',
});
return (await executeMdx(String(compiled.value), {
-30
View File
@@ -1,30 +0,0 @@
import path from 'node:path';
import { x } from 'tinyexec';
const cache = new Map<string, Date>();
/**
* Requires `git` to be installed
*
* if you are using Vercel, please set `VERCEL_DEEP_CLONE` environment variable to `true`
*/
export async function getGitTimestamp(file: string): Promise<Date | undefined> {
const cached = cache.get(file);
if (cached) return cached;
try {
const out = await x(
'git',
['log', '-1', '--pretty="%ai"', path.relative(process.cwd(), file)],
{
throwOnError: true,
},
);
const time = new Date(out.stdout);
cache.set(file, time);
return time;
} catch {
return;
}
}
+3 -2
View File
@@ -1,6 +1,5 @@
import type { CollectionSchema } from '@/config';
import type { StandardSchemaV1 } from '@standard-schema/spec';
import picocolors from 'picocolors';
export class ValidationError extends Error {
title: string;
@@ -15,7 +14,9 @@ export class ValidationError extends Error {
this.issues = issues;
}
toStringFormatted() {
async toStringFormatted() {
const picocolors = await import('picocolors');
return [
picocolors.bold(`[MDX] ${this.title}:`),
...this.issues.map((issue) =>
+1 -1
View File
@@ -92,7 +92,7 @@ export default async function mdx(
}
} catch (e) {
if (e instanceof ValidationError) {
throw new Error(e.toStringFormatted());
throw new Error(await e.toStringFormatted());
}
throw e;