mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 23:49:51 +00:00
MDX: Implement last modified time on Async Mode
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-mdx': patch
|
||||
---
|
||||
|
||||
Support last modified time on Async Mode
|
||||
@@ -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,
|
||||
|
||||
@@ -49,6 +49,7 @@ export const blog = defineCollections({
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
lastModifiedTime: 'git',
|
||||
mdxOptions: {
|
||||
rehypeCodeOptions: {
|
||||
lazy: true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function start(
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ValidationError) {
|
||||
err.print();
|
||||
console.error(err.toStringFormatted());
|
||||
} else {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
@@ -34,9 +34,7 @@ export function createMDX({
|
||||
|
||||
return (nextConfig: NextConfig = {}): NextConfig => {
|
||||
const mdxLoaderOptions: MDXLoaderOptions = {
|
||||
_ctx: {
|
||||
configPath,
|
||||
},
|
||||
configPath,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -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 ?? {},
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface AsyncRuntimeFile {
|
||||
info: FileInfo;
|
||||
data: Record<string, unknown>;
|
||||
content: string;
|
||||
lastModified?: Date;
|
||||
}
|
||||
|
||||
type DocOut<Schema extends StandardSchemaV1> = Override<
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface LoadedConfig {
|
||||
global?: GlobalConfig;
|
||||
|
||||
_mdx_loader?: {
|
||||
cachedProcessorOptions?: ProcessorOptions;
|
||||
cachedOptions?: ProcessorOptions;
|
||||
};
|
||||
|
||||
_mdx_async?: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+6
-9
@@ -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]))
|
||||
|
||||
Reference in New Issue
Block a user