mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 23:49:51 +00:00
MDX: Support disableMetaFile option in Bun plugin
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-mdx': patch
|
||||
---
|
||||
|
||||
Support `disableMetaFile` option in Bun plugin
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+2675
-2202
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user