MDX: introduce official workspace API

This commit is contained in:
Fuma Nama
2025-12-04 15:02:09 +08:00
parent aa1a8378cf
commit fc0e3dbb02
11 changed files with 158 additions and 141 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-mdx': minor
---
Support Workspaces API [Experimental]
+36 -28
View File
@@ -36,21 +36,26 @@ export type CollectionItem =
interface PrimitiveCollectionItem {
name: string;
cwd: string;
/**
* content directory (absolute)
*/
dir: string;
hasFile: (filePath: string) => boolean;
isFileSupported: (filePath: string) => boolean;
patterns: string[];
}
export type MetaCollectionItem = PrimitiveCollectionItem &
Omit<MetaCollection, 'files'>;
Omit<MetaCollection, 'files' | 'dir'>;
export type DocCollectionItem = PrimitiveCollectionItem &
Omit<DocCollection, 'files'>;
Omit<DocCollection, 'files' | 'dir'>;
export interface DocsCollectionItem extends DocsCollection {
name: string;
export interface DocsCollectionItem
extends
Omit<DocsCollection, 'dir' | 'meta' | 'docs'>,
Omit<PrimitiveCollectionItem, 'patterns'> {
meta: MetaCollectionItem;
docs: DocCollectionItem;
hasFile: (filePath: string) => boolean;
}
const SupportedFormats = {
@@ -64,21 +69,29 @@ export function buildCollection(
cwd: string,
): CollectionItem {
if (collection.type === 'docs') {
return {
...collection,
const docs = buildCollection(
name,
meta: buildCollection(name, collection.meta, cwd),
docs: buildCollection(name, collection.docs, cwd),
hasFile(filePath) {
return this.docs.hasFile(filePath) || this.meta.hasFile(filePath);
},
} as DocsCollectionItem;
}
if (collection.type === 'doc') {
collection.docs,
cwd,
) as DocCollectionItem;
const meta = buildCollection(
name,
collection.meta,
cwd,
) as MetaCollectionItem;
return {
...collection,
...buildPrimitiveCollection(name, collection, cwd),
dir: docs.dir,
name,
meta,
docs,
hasFile(filePath) {
return docs.hasFile(filePath) || meta.hasFile(filePath);
},
isFileSupported(filePath) {
return docs.isFileSupported(filePath) || meta.isFileSupported(filePath);
},
cwd,
};
}
@@ -95,9 +108,10 @@ function buildPrimitiveCollection(
): PrimitiveCollectionItem {
const supportedFormats = SupportedFormats[config.type];
const patterns = config.files ?? [`**/*.{${supportedFormats.join(',')}}`];
let matchers: picomatch.Matcher[];
let matcher: picomatch.Matcher;
return {
dir: path.resolve(cwd, config.dir),
cwd,
name,
patterns,
@@ -105,17 +119,11 @@ function buildPrimitiveCollection(
return supportedFormats.some((format) => filePath.endsWith(`.${format}`));
},
hasFile(filePath) {
matchers ??= (Array.isArray(config.dir) ? config.dir : [config.dir]).map(
(dir) =>
picomatch(patterns, {
cwd: dir,
}),
);
matcher ??= picomatch(patterns, {
cwd: this.dir,
});
return (
this.isFileSupported(filePath) &&
matchers.some((matcher) => matcher(filePath))
);
return this.isFileSupported(filePath) && matcher(filePath);
},
};
}
+7 -19
View File
@@ -23,7 +23,7 @@ export interface EmitEntry {
content: string;
}
export interface PluginContext extends CoreOptions {
export interface PluginContext {
core: Core;
}
@@ -106,8 +106,6 @@ export interface CoreOptions {
environment: string;
configPath: string;
outDir: string;
extend?: (core: Core) => Core;
plugins?: PluginOption[];
/**
@@ -185,7 +183,7 @@ export function createCore(options: CoreOptions) {
return data as T;
}
const core = {
return {
/**
* Convenient cache store, reset when config changes
*/
@@ -223,6 +221,9 @@ export function createCore(options: CoreOptions) {
);
}
},
getWorkspaces() {
return workspaces;
},
getOptions() {
return options;
},
@@ -238,22 +239,15 @@ export function createCore(options: CoreOptions) {
getPlugins() {
return plugins;
},
getCollections(workspace?: string): CollectionItem[] {
if (workspace) return workspaces.get(workspace)?.getCollections() ?? [];
getCollections(): CollectionItem[] {
return Array.from(config.collections.values());
},
getCollection(
name: string,
workspace?: string,
): CollectionItem | undefined {
if (workspace) return workspaces.get(workspace)?.getCollection(name);
getCollection(name: string): CollectionItem | undefined {
return config.collections.get(name);
},
getPluginContext(): PluginContext {
return {
core: this,
...options,
};
},
async initServer(server: ServerContext): Promise<void> {
@@ -364,12 +358,6 @@ export function createCore(options: CoreOptions) {
return file;
},
};
if (options.extend) {
return (options.extend as (v: typeof core) => typeof core)(core);
}
return core;
}
function postprocessPlugin(): Plugin {
+24 -24
View File
@@ -1,10 +1,8 @@
import type { Core } from '@/core';
import fs from 'node:fs/promises';
import type { LoadedConfig } from '@/config/build';
export interface ConfigLoader {
getConfig: () => LoadedConfig | Promise<LoadedConfig>;
core: Core;
getCore(): Promise<Core>;
}
export function createStandaloneConfigLoader({
@@ -22,7 +20,12 @@ export function createStandaloneConfigLoader({
*/
mode: 'dev' | 'production';
}): ConfigLoader {
let loaded: { config: Promise<LoadedConfig>; hash: string } | undefined;
let prev:
| {
hash: string;
init: Promise<void>;
}
| undefined;
async function getConfigHash(): Promise<string> {
if (mode === 'production') return 'static';
@@ -34,26 +37,24 @@ export function createStandaloneConfigLoader({
return stats.mtime.getTime().toString();
}
async function newConfig() {
const { loadConfig } = await import('../config/load-from-file');
await core.init({
config: loadConfig(core, buildConfig),
});
return core.getConfig();
}
return {
core,
async getConfig() {
async getCore() {
const hash = await getConfigHash();
if (loaded && loaded.hash === hash) return loaded.config;
if (!prev || hash !== prev.hash) {
prev = {
hash,
init: (async () => {
const { loadConfig } = await import('../config/load-from-file');
loaded = {
hash,
config: newConfig(),
};
return loaded.config;
await core.init({
config: loadConfig(core, buildConfig),
});
})(),
};
}
await prev.init;
return core;
},
};
}
@@ -63,9 +64,8 @@ export function createStandaloneConfigLoader({
*/
export function createIntegratedConfigLoader(core: Core): ConfigLoader {
return {
core,
getConfig() {
return core.getConfig();
async getCore() {
return core;
},
};
}
+18 -9
View File
@@ -13,6 +13,7 @@ const querySchema = z
.object({
only: z.literal(['frontmatter', 'all']).default('all'),
collection: z.string().optional(),
workspace: z.string().optional(),
})
.loose();
@@ -24,7 +25,7 @@ const cacheEntry = z.object({
type CacheEntry = z.infer<typeof cacheEntry>;
export function createMdxLoader({ core, getConfig }: ConfigLoader): Loader {
export function createMdxLoader({ getCore }: ConfigLoader): Loader {
return {
test: mdxLoaderGlob,
async load({
@@ -34,16 +35,24 @@ export function createMdxLoader({ core, getConfig }: ConfigLoader): Loader {
compiler,
filePath,
}) {
const config = await getConfig();
let core = await getCore();
const value = await getSource();
const matter = fumaMatter(value);
const parsed = querySchema.parse(query);
const {
collection: collectionName,
workspace,
only,
} = querySchema.parse(query);
if (workspace) {
core = core.getWorkspaces().get(workspace) ?? core;
}
let after: (() => Promise<void>) | undefined;
if (!isDevelopment && config.global.experimentalBuildCache) {
const cacheDir = config.global.experimentalBuildCache;
const cacheKey = `${parsed.hash}_${parsed.collection ?? 'global'}_${generateCacheHash(filePath)}`;
const { experimentalBuildCache = false } = core.getConfig().global;
if (!isDevelopment && experimentalBuildCache) {
const cacheDir = experimentalBuildCache;
const cacheKey = `${collectionName ?? 'global'}_${generateCacheHash(filePath)}`;
const cached = await fs
.readFile(path.join(cacheDir, cacheKey))
@@ -63,8 +72,8 @@ export function createMdxLoader({ core, getConfig }: ConfigLoader): Loader {
};
}
const collection = parsed.collection
? core.getCollection(parsed.collection)
const collection = collectionName
? core.getCollection(collectionName)
: undefined;
let docCollection: DocCollectionItem | undefined;
@@ -84,7 +93,7 @@ export function createMdxLoader({ core, getConfig }: ConfigLoader): Loader {
);
}
if (parsed.only === 'frontmatter') {
if (only === 'frontmatter') {
return {
code: `export const frontmatter = ${JSON.stringify(matter.data)}`,
map: null,
+8 -3
View File
@@ -8,6 +8,7 @@ import type { MetaCollectionItem } from '@/config/build';
const querySchema = z
.object({
collection: z.string().optional(),
workspace: z.string().optional(),
})
.loose();
@@ -15,7 +16,7 @@ const querySchema = z
* load meta files, fallback to bundler's built-in plugins when ?collection is unspecified.
*/
export function createMetaLoader(
{ core, getConfig }: ConfigLoader,
{ getCore }: ConfigLoader,
resolve: {
json?: 'json' | 'js';
yaml?: 'yaml' | 'js';
@@ -37,10 +38,14 @@ export function createMetaLoader(
function onMeta(source: string, { filePath, query }: LoaderInput) {
const parsed = querySchema.safeParse(query);
if (!parsed.success || !parsed.data.collection) return null;
const collectionName = parsed.data.collection;
const { collection: collectionName, workspace } = parsed.data;
return async (): Promise<unknown> => {
await getConfig();
let core = await getCore();
if (workspace) {
core = core.getWorkspaces().get(workspace) ?? core;
}
const collection = core.getCollection(collectionName);
let metaCollection: MetaCollectionItem | undefined;
+4 -4
View File
@@ -143,10 +143,10 @@ async function init(dev: boolean, core: Core): Promise<void> {
watcher.add(configPath);
for (const collection of core.getCollections()) {
if (collection.type === 'docs') {
watcher.add(collection.docs.dir);
watcher.add(collection.meta.dir);
} else {
watcher.add(collection.dir);
}
for (const workspace of core.getWorkspaces().values()) {
for (const collection of workspace.getCollections()) {
watcher.add(collection.dir);
}
}
+44 -42
View File
@@ -44,6 +44,7 @@ export interface IndexFilePlugin {
interface FileGenContext {
core: Core;
workspace?: string;
codegen: CodeGen;
serverOptions: ServerOptions;
tc: string;
@@ -131,6 +132,7 @@ export default function indexFile(
},
async emit() {
const globCache = new Map<string, Promise<string[]>>();
const { workspace, outDir } = this.core.getOptions();
const { serverOptions, tc } = generateConfigs(this.core);
const toEmitEntry = async (
@@ -139,7 +141,7 @@ export default function indexFile(
): Promise<EmitEntry> => {
const codegen = createCodegen({
target,
outDir: this.outDir,
outDir: outDir,
jsExtension: addJsExtension,
globCache,
});
@@ -148,6 +150,7 @@ export default function indexFile(
codegen,
serverOptions,
tc,
workspace: workspace?.name,
});
return {
path,
@@ -170,12 +173,8 @@ export default function indexFile(
};
}
async function generateServerIndexFile({
core,
codegen,
serverOptions,
tc,
}: FileGenContext) {
async function generateServerIndexFile(ctx: FileGenContext) {
const { core, codegen, serverOptions, tc } = ctx;
codegen.lines.push(
`import { server } from 'fumadocs-mdx/runtime/server';`,
`import type * as Config from '${codegen.formatImportPath(core.getOptions().configPath)}';`,
@@ -186,51 +185,49 @@ async function generateServerIndexFile({
async function generateCollectionObject(
collection: CollectionItem,
): Promise<string | undefined> {
const base = getBase(collection);
switch (collection.type) {
case 'docs': {
if (collection.docs.dynamic) return;
if (collection.docs.async) {
const [metaGlob, headGlob, bodyGlob] = await Promise.all([
generateMetaCollectionGlob(codegen, collection.meta, true),
generateDocCollectionFrontmatterGlob(
codegen,
collection.docs,
true,
),
generateDocCollectionGlob(codegen, collection.docs),
generateMetaCollectionGlob(ctx, collection.meta, true),
generateDocCollectionFrontmatterGlob(ctx, collection.docs, true),
generateDocCollectionGlob(ctx, collection.docs),
]);
return `await create.docsLazy("${collection.name}", "${collection.dir}", ${metaGlob}, ${headGlob}, ${bodyGlob})`;
return `await create.docsLazy("${collection.name}", "${base}", ${metaGlob}, ${headGlob}, ${bodyGlob})`;
}
const [metaGlob, docGlob] = await Promise.all([
generateMetaCollectionGlob(codegen, collection.meta, true),
generateDocCollectionGlob(codegen, collection.docs, true),
generateMetaCollectionGlob(ctx, collection.meta, true),
generateDocCollectionGlob(ctx, collection.docs, true),
]);
return `await create.docs("${collection.name}", "${collection.dir}", ${metaGlob}, ${docGlob})`;
return `await create.docs("${collection.name}", "${base}", ${metaGlob}, ${docGlob})`;
}
case 'doc':
if (collection.dynamic) return;
if (collection.async) {
const [headGlob, bodyGlob] = await Promise.all([
generateDocCollectionFrontmatterGlob(codegen, collection, true),
generateDocCollectionGlob(codegen, collection),
generateDocCollectionFrontmatterGlob(ctx, collection, true),
generateDocCollectionGlob(ctx, collection),
]);
return `await create.docLazy("${collection.name}", "${collection.dir}", ${headGlob}, ${bodyGlob})`;
return `await create.docLazy("${collection.name}", "${base}", ${headGlob}, ${bodyGlob})`;
}
return `await create.doc("${collection.name}", "${collection.dir}", ${await generateDocCollectionGlob(
codegen,
return `await create.doc("${collection.name}", "${base}", ${await generateDocCollectionGlob(
ctx,
collection,
true,
)})`;
case 'meta':
return `await create.meta("${collection.name}", "${collection.dir}", ${await generateMetaCollectionGlob(
codegen,
return `await create.meta("${collection.name}", "${base}", ${await generateMetaCollectionGlob(
ctx,
collection,
true,
)})`;
@@ -247,12 +244,8 @@ async function generateServerIndexFile({
);
}
async function generateDynamicIndexFile({
core,
codegen,
serverOptions,
tc,
}: FileGenContext) {
async function generateDynamicIndexFile(ctx: FileGenContext) {
const { core, codegen, serverOptions, tc } = ctx;
const { configPath, environment, outDir } = core.getOptions();
// serializable config options
const partialOptions: CoreOptions = {
@@ -269,9 +262,9 @@ async function generateDynamicIndexFile({
async function generateCollectionObjectEntry(
collection: DocCollectionItem,
file: string,
absolutePath: string,
) {
const fullPath = path.join(collection.dir, file);
const fullPath = path.relative(process.cwd(), absolutePath);
const content = await indexFileCache.read(fullPath).catch(() => '');
const parsed = fumaMatter(content);
const data = await core.transformFrontmatter(
@@ -291,7 +284,7 @@ async function generateDynamicIndexFile({
for (const [k, v] of Object.entries({
info: {
fullPath,
path: file,
path: path.relative(collection.dir, absolutePath),
},
data,
hash,
@@ -313,6 +306,7 @@ async function generateDynamicIndexFile({
const files = await glob(collection.patterns, {
cwd: collection.dir,
absolute: true,
});
const entries = await Promise.all(
files.map((file) => generateCollectionObjectEntry(collection, file)),
@@ -321,15 +315,15 @@ async function generateDynamicIndexFile({
switch (parent.type) {
case 'docs': {
const metaGlob = await generateMetaCollectionGlob(
codegen,
ctx,
parent.meta,
true,
);
return `await create.docs("${parent.name}", "${parent.dir}", ${metaGlob}, ${entries.join(', ')})`;
return `await create.docs("${parent.name}", "${getBase(parent)}", ${metaGlob}, ${entries.join(', ')})`;
}
case 'doc':
return `await create.doc("${collection.name}", "${collection.dir}", ${entries.join(', ')})`;
return `await create.doc("${collection.name}", "${getBase(collection)}", ${entries.join(', ')})`;
}
}
@@ -343,7 +337,8 @@ async function generateDynamicIndexFile({
);
}
async function generateBrowserIndexFile({ core, codegen, tc }: FileGenContext) {
async function generateBrowserIndexFile(ctx: FileGenContext) {
const { core, codegen, tc } = ctx;
codegen.lines.push(
`import { browser } from 'fumadocs-mdx/runtime/browser';`,
`import type * as Config from '${codegen.formatImportPath(core.getOptions().configPath)}';`,
@@ -363,7 +358,7 @@ async function generateBrowserIndexFile({ core, codegen, tc }: FileGenContext) {
case 'doc':
if (collection.dynamic) return;
return `create.doc("${collection.name}", ${await generateDocCollectionGlob(codegen, collection)})`;
return `create.doc("${collection.name}", ${await generateDocCollectionGlob(ctx, collection)})`;
}
}
@@ -381,8 +376,12 @@ async function generateBrowserIndexFile({ core, codegen, tc }: FileGenContext) {
codegen.lines.push('};', 'export default browserCollections;');
}
function getBase(collection: CollectionItem) {
return path.relative(process.cwd(), collection.dir);
}
function generateDocCollectionFrontmatterGlob(
codegen: CodeGen,
{ codegen, workspace }: FileGenContext,
collection: DocCollectionItem,
eager = false,
) {
@@ -390,6 +389,7 @@ function generateDocCollectionFrontmatterGlob(
query: {
collection: collection.name,
only: 'frontmatter',
workspace,
},
import: 'frontmatter',
base: collection.dir,
@@ -398,13 +398,14 @@ function generateDocCollectionFrontmatterGlob(
}
function generateDocCollectionGlob(
codegen: CodeGen,
{ codegen, workspace }: FileGenContext,
collection: DocCollectionItem,
eager = false,
) {
return codegen.generateGlobImport(collection.patterns, {
query: {
collection: collection.name,
workspace,
},
base: collection.dir,
eager,
@@ -412,13 +413,14 @@ function generateDocCollectionGlob(
}
function generateMetaCollectionGlob(
codegen: CodeGen,
{ codegen, workspace }: FileGenContext,
collection: MetaCollectionItem,
eager = false,
) {
return codegen.generateGlobImport(collection.patterns, {
query: {
collection: collection.name,
workspace,
},
import: 'default',
base: collection.dir,
+2 -1
View File
@@ -27,6 +27,7 @@ export default function jsonSchema({
return {
configureServer(server) {
const { outDir } = this.core.getOptions();
if (!server.watcher || !insert) return;
server.watcher.on('add', async (file) => {
@@ -55,7 +56,7 @@ export default function jsonSchema({
if ('$schema' in obj) return;
const schemaPath = path.join(
this.outDir,
outDir,
getSchemaPath(parent ? `${parent.name}.meta` : match.name),
);
const updated = {
+4 -5
View File
@@ -3,7 +3,7 @@ import { glob } from 'tinyglobby';
export interface GlobImportOptions {
base: string;
query?: Record<string, string>;
query?: Record<string, string | undefined>;
import?: string;
eager?: boolean;
}
@@ -105,7 +105,7 @@ export function createCodegen({
const searchParams = new URLSearchParams();
for (const [k, v] of Object.entries(query)) {
searchParams.set(k, v);
if (v !== undefined) searchParams.set(k, v);
}
const importPath =
@@ -136,10 +136,9 @@ export function createCodegen({
const ext = path.extname(file);
let filename: string;
if (ext === '.ts' && jsExtension) {
filename = file.substring(0, file.length - ext.length) + '.js';
} else if (ext === '.ts') {
if (ext === '.ts') {
filename = file.substring(0, file.length - ext.length);
if (jsExtension) filename += '.js';
} else {
filename = file;
}
+6 -6
View File
@@ -41,10 +41,7 @@ test('format errors', async () => {
}
});
const baseDir = path.relative(
process.cwd(),
path.dirname(fileURLToPath(import.meta.url)),
);
const baseDir = path.dirname(fileURLToPath(import.meta.url));
const cases: {
name: string;
config: Record<string, unknown>;
@@ -122,9 +119,12 @@ const cases: {
for (const { name, config } of cases) {
test(`generate JS index file: ${name}`, async () => {
const core = createCore({
configPath: path.join(baseDir, './fixtures/config.ts'),
configPath: path.relative(
process.cwd(),
path.join(baseDir, './fixtures/config.ts'),
),
environment: 'test',
outDir: path.join(baseDir, './fixtures'),
outDir: path.relative(process.cwd(), path.join(baseDir, './fixtures')),
plugins: [indexFile()],
});