MDX: Implement last modified time on Async Mode

This commit is contained in:
Fuma Nama
2025-05-23 18:54:48 +08:00
parent 7eba2fd357
commit cd42e788ad
15 changed files with 138 additions and 129 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-mdx': patch
---
Support last modified time on Async Mode
+2 -1
View File
@@ -60,12 +60,13 @@ export default async function Page(props: {
const path = `apps/docs/content/docs/${page.file.path}`;
const preview = page.data.preview;
const { body: Mdx, toc } = await page.data.load();
const { body: Mdx, toc, lastModified } = await page.data.load();
return (
<DocsPage
toc={toc}
full={page.data.full}
lastUpdate={lastModified}
tableOfContent={{
style: 'clerk',
single: false,
+1
View File
@@ -49,6 +49,7 @@ export const blog = defineCollections({
});
export default defineConfig({
lastModifiedTime: 'git',
mdxOptions: {
rehypeCodeOptions: {
lazy: true,
+1 -2
View File
@@ -52,20 +52,19 @@
"@mdx-js/mdx": "^3.1.0",
"@standard-schema/spec": "^1.0.0",
"chokidar": "^4.0.3",
"cross-spawn": "^7.0.6",
"esbuild": "^0.25.4",
"estree-util-value-to-estree": "^3.4.0",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.0",
"lru-cache": "^11.1.0",
"picocolors": "^1.1.1",
"tinyexec": "^1.0.1",
"tinyglobby": "^0.2.13",
"unist-util-visit": "^5.0.0",
"zod": "^3.25.7"
},
"devDependencies": {
"@fumadocs/mdx-remote": "workspace:*",
"@types/cross-spawn": "^6.0.6",
"@types/js-yaml": "^4.0.9",
"@types/mdast": "^4.0.3",
"@types/mdx": "^2.0.13",
+52 -53
View File
@@ -2,34 +2,13 @@ import * as path from 'node:path';
import { parse } from 'node:querystring';
import grayMatter from 'gray-matter';
import { type LoaderContext } from 'webpack';
import { getConfigHash, loadConfig } from '@/utils/config';
import { getConfigHash, loadConfig, type LoadedConfig } from '@/utils/config';
import { buildMDX } from '@/utils/build-mdx';
import { getGitTimestamp } from './utils/git-timestamp';
import { validate } from '@/utils/schema';
import { validate, ValidationError } from '@/utils/schema';
export interface Options {
/**
* @internal
*/
_ctx: {
configPath: string;
};
}
function parseQuery(query: string): {
collection?: string;
hash?: string;
} {
let collection: string | undefined;
let hash: string | undefined;
const parsed = parse(query.slice(1));
if (parsed.collection && typeof parsed.collection === 'string')
collection = parsed.collection;
if (parsed.hash && typeof parsed.hash === 'string') hash = parsed.hash;
return { collection, hash };
configPath: string;
}
/**
@@ -45,15 +24,18 @@ export default async function loader(
this.cacheable(true);
const context = this.context;
const filePath = this.resourcePath;
const { _ctx } = this.getOptions();
const { configPath } = this.getOptions();
const matter = grayMatter(source);
// notice that `resourceQuery` can be missing (e.g. `page.mdx`)
const {
hash: configHash = await getConfigHash(_ctx.configPath),
hash: configHash = await getConfigHash(configPath),
collection: collectionId,
} = parseQuery(this.resourceQuery);
const config = await loadConfig(_ctx.configPath, configHash);
} = parse(this.resourceQuery.slice(1)) as {
hash?: string;
collection?: string;
};
const config = await loadConfig(configPath, configHash);
let collection =
collectionId !== undefined
@@ -65,41 +47,38 @@ export default async function loader(
collection = undefined;
}
let mdxOptions = collection?.mdxOptions;
if (!mdxOptions) {
const { getDefaultMDXOptions } = await import('@/utils/mdx-options');
config._mdx_loader ??= {};
const extendedOptions = config.global?.mdxOptions;
config._mdx_loader.cachedProcessorOptions ??=
typeof extendedOptions === 'function'
? getDefaultMDXOptions(await extendedOptions())
: getDefaultMDXOptions(extendedOptions ?? {});
mdxOptions = config._mdx_loader.cachedProcessorOptions;
}
const mdxOptions =
collection?.mdxOptions ?? (await initGlobalMdxOptions(config));
if (collection?.schema) {
matter.data = (await validate(
collection.schema,
matter.data,
{
source,
path: filePath,
},
`invalid frontmatter in ${filePath}`,
)) as Record<string, unknown>;
try {
matter.data = (await validate(
collection.schema,
matter.data,
{
source,
path: filePath,
},
`invalid frontmatter in ${filePath}`,
)) as Record<string, unknown>;
} 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')
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' ? lines(source) - lines(matter.content) : 0,
this.mode === 'development' ? lines(matter.content) : 0,
);
const file = await buildMDX(
@@ -127,6 +106,26 @@ export default async function loader(
}
}
async function initGlobalMdxOptions(config: LoadedConfig) {
const globalConfig = config.global;
if (!globalConfig) return;
config._mdx_loader ??= {};
const mdxLoader = config._mdx_loader;
if (!mdxLoader.cachedOptions) {
const input = globalConfig?.mdxOptions;
const { getDefaultMDXOptions } = await import('@/utils/mdx-options');
mdxLoader.cachedOptions =
typeof input === 'function'
? getDefaultMDXOptions(await input())
: getDefaultMDXOptions(input ?? {});
}
return mdxLoader.cachedOptions;
}
function lines(s: string) {
let num = 0;
+7
View File
@@ -10,6 +10,7 @@ import { fileCache } from '@/map/file-cache';
import matter from 'gray-matter';
import type { AsyncRuntimeFile } from '@/runtime/types';
import { load } from 'js-yaml';
import { getGitTimestamp } from '@/utils/git-timestamp';
async function readFileWithCache(file: string): Promise<string> {
const cached = fileCache.read<string>('read-file', file);
@@ -114,8 +115,14 @@ export async function generateJS(
)) as Record<string, unknown>;
}
let lastModified: Date | undefined;
if (config.global?.lastModifiedTime === 'git') {
lastModified = await getGitTimestamp(file.absolutePath);
}
return JSON.stringify({
info: file,
lastModified,
data: parsed.data,
content: parsed.content,
} satisfies AsyncRuntimeFile);
+1 -1
View File
@@ -30,7 +30,7 @@ export async function start(
);
} catch (err) {
if (err instanceof ValidationError) {
err.print();
console.error(err.toStringFormatted());
} else {
console.error(err);
}
+1 -3
View File
@@ -34,9 +34,7 @@ export function createMDX({
return (nextConfig: NextConfig = {}): NextConfig => {
const mdxLoaderOptions: MDXLoaderOptions = {
_ctx: {
configPath,
},
configPath,
};
return {
+14 -8
View File
@@ -5,7 +5,7 @@ import {
remarkStructure,
type StructuredData,
} from 'fumadocs-core/mdx-plugins';
import { createMDXSource, _runtime } from '@/runtime/index';
import { _runtime, createMDXSource } from '@/runtime/index';
import type { RuntimeAsync } from '@/runtime/types';
async function initCompiler(config: LoadedConfig, collection: string) {
@@ -18,15 +18,20 @@ async function initCompiler(config: LoadedConfig, collection: string) {
if (!mdxOptions) {
config._mdx_async ??= {};
config._mdx_async.cachedMdxOptions ??=
typeof config.global?.mdxOptions === 'function'
? await config.global.mdxOptions()
: (config.global?.mdxOptions ?? {});
const async = config._mdx_async;
const globalConfig = config.global;
mdxOptions = config._mdx_async.cachedMdxOptions;
if (globalConfig && !async.cachedMdxOptions) {
async.cachedMdxOptions =
typeof globalConfig.mdxOptions === 'function'
? await globalConfig.mdxOptions()
: globalConfig.mdxOptions;
}
mdxOptions = async.cachedMdxOptions;
}
const remarkPlugins = mdxOptions.remarkPlugins ?? [];
const remarkPlugins = mdxOptions?.remarkPlugins ?? [];
return createCompiler({
...mdxOptions,
@@ -41,7 +46,7 @@ export const _runtimeAsync: RuntimeAsync = {
doc(files, collection, config) {
const init = initCompiler(config, collection);
return files.map(({ info: file, data, content }) => {
return files.map(({ info: file, data, content, lastModified }) => {
return {
...data,
_file: file,
@@ -56,6 +61,7 @@ export const _runtimeAsync: RuntimeAsync = {
return {
body: out.body,
toc: out.toc,
lastModified,
structuredData: out.vfile.data.structuredData as StructuredData,
_exports: out.exports ?? {},
};
+1
View File
@@ -12,6 +12,7 @@ export interface AsyncRuntimeFile {
info: FileInfo;
data: Record<string, unknown>;
content: string;
lastModified?: Date;
}
type DocOut<Schema extends StandardSchemaV1> = Override<
+1 -1
View File
@@ -21,7 +21,7 @@ export interface LoadedConfig {
global?: GlobalConfig;
_mdx_loader?: {
cachedProcessorOptions?: ProcessorOptions;
cachedOptions?: ProcessorOptions;
};
_mdx_async?: {
+18 -23
View File
@@ -1,6 +1,5 @@
import path from 'node:path';
import fs from 'node:fs';
import { spawn } from 'cross-spawn';
import { x } from 'tinyexec';
const cache = new Map<string, Date>();
@@ -9,27 +8,23 @@ const cache = new Map<string, Date>();
*
* if you are using Vercel, please set `VERCEL_DEEP_CLONE` environment variable to `true`
*/
export function getGitTimestamp(file: string): Promise<Date | undefined> {
const cachedTimestamp = cache.get(file);
if (cachedTimestamp) return Promise.resolve(cachedTimestamp);
export async function getGitTimestamp(file: string): Promise<Date | undefined> {
const cached = cache.get(file);
if (cached) return cached;
return new Promise((resolve, reject) => {
const cwd = path.dirname(file);
if (!fs.existsSync(cwd)) {
resolve(undefined);
return;
}
const fileName = path.basename(file);
const child = spawn('git', ['log', '-1', '--pretty="%ai"', fileName], {
cwd,
});
try {
const out = await x(
'git',
['log', '-1', '--pretty="%ai"', path.relative(process.cwd(), file)],
{
throwOnError: true,
},
);
let output: Date | undefined;
child.stdout.on('data', (d) => (output = new Date(String(d))));
child.on('close', () => {
if (output) cache.set(file, output);
resolve(output);
});
child.on('error', reject);
});
const time = new Date(out.stdout);
cache.set(file, time);
return time;
} catch {
return;
}
}
+11 -13
View File
@@ -30,22 +30,20 @@ export class ValidationError extends Error {
this.issues = issues;
}
print() {
console.error(
[
`[MDX] ${this.message}:`,
...this.issues.map((issue) =>
picocolors.redBright(
`- ${picocolors.bold(issue.path?.join('.') ?? '*')}: ${issue.message}`,
),
),
].join('\n'),
);
}
toString() {
return `${this.message}:\n${this.issues.map((issue) => ` ${issue.path}: ${issue.message}`).join('\n')}`;
}
toStringFormatted() {
return [
picocolors.bold(`[MDX] ${this.message}:`),
...this.issues.map((issue) =>
picocolors.redBright(
`- ${picocolors.bold(issue.path?.join('.') ?? '*')}: ${issue.message}`,
),
),
].join('\n');
}
}
export async function validate<Schema extends StandardSchemaV1, Context>(
+17 -15
View File
@@ -373,16 +373,28 @@ export function FieldSet({
if (_field === false) return null;
if (!show) {
return (
<FieldHeader {...props} name={name} required={isRequired}>
{toolbar}
<fieldset className="flex flex-col gap-1.5">
<FieldHeader
{...props}
name={name}
type={schemaToString(field)}
required={isRequired}
>
{toolbar}
</FieldHeader>
<button
type="button"
className={cn(buttonVariants({ size: 'sm' }))}
className={cn(
buttonVariants({
size: 'sm',
color: 'secondary',
}),
)}
onClick={() => setShow(true)}
>
Show
</button>
</FieldHeader>
</fieldset>
);
}
@@ -470,17 +482,7 @@ export function FieldSet({
>
{toolbar}
</FieldHeader>
{show ? (
<FieldInput
field={field}
fieldName={fieldName}
isRequired={isRequired}
/>
) : (
<div>
<button onClick={() => setShow((prev) => !prev)}>Show</button>
</div>
)}
<FieldInput field={field} fieldName={fieldName} isRequired={isRequired} />
</fieldset>
);
}
+6 -9
View File
@@ -273,7 +273,7 @@ importers:
version: 0.2.2(@content-collections/[email protected]([email protected]))([email protected])([email protected]([email protected]))([email protected])
'@content-collections/next':
specifier: ^0.2.6
version: 0.2.6(@content-collections/[email protected]([email protected]))([email protected](@opentelemetry/[email protected])([email protected]([email protected]))([email protected]))
version: 0.2.6(@content-collections/[email protected]([email protected]))([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))
'@types/mdx':
specifier: ^2.0.13
version: 2.0.13
@@ -856,7 +856,7 @@ importers:
version: 0.2.2(@content-collections/[email protected]([email protected]))([email protected])([email protected]([email protected]))([email protected])
'@content-collections/next':
specifier: ^0.2.6
version: 0.2.6(@content-collections/[email protected]([email protected]))([email protected](@opentelemetry/[email protected])([email protected]([email protected]))([email protected]))
version: 0.2.6(@content-collections/[email protected]([email protected]))([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))
'@react-router/dev':
specifier: ^7.6.0
version: 7.6.0(@react-router/[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]))([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])
@@ -1010,9 +1010,6 @@ importers:
chokidar:
specifier: ^4.0.3
version: 4.0.3
cross-spawn:
specifier: ^7.0.6
version: 7.0.6
esbuild:
specifier: ^0.25.4
version: 0.25.4
@@ -1031,6 +1028,9 @@ importers:
picocolors:
specifier: ^1.1.1
version: 1.1.1
tinyexec:
specifier: ^1.0.1
version: 1.0.1
tinyglobby:
specifier: ^0.2.13
version: 0.2.13
@@ -1044,9 +1044,6 @@ importers:
'@fumadocs/mdx-remote':
specifier: workspace:*
version: link:../mdx-remote
'@types/cross-spawn':
specifier: ^6.0.6
version: 6.0.6
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
@@ -10717,7 +10714,7 @@ snapshots:
- acorn
- supports-color
'@content-collections/[email protected](@content-collections/[email protected]([email protected]))([email protected](@opentelemetry/[email protected])([email protected]([email protected]))([email protected]))':
'@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]))':
dependencies:
'@content-collections/core': 0.8.2([email protected])
'@content-collections/integrations': 0.2.1(@content-collections/[email protected]([email protected]))