MDX: make index-file a plugin and optimize re-generation

This commit is contained in:
Fuma Nama
2025-11-13 22:35:58 +08:00
parent b8ba15ee18
commit b9630213df
15 changed files with 657 additions and 707 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-mdx': minor
---
Make `index-file` a plugin and optimize re-generations.
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-mdx': major
---
**[Vite] rename `generateIndexFile` option to `index`**
+5 -1
View File
@@ -41,6 +41,7 @@ export interface DocsCollectionItem extends DocsCollection {
name: string;
meta: MetaCollectionItem;
docs: DocCollectionItem;
hasFile: (filePath: string) => boolean;
}
const SupportedFormats = {
@@ -58,7 +59,10 @@ export function buildCollection(
name,
meta: buildPrimitiveCollection(name, config.meta),
docs: buildPrimitiveCollection(name, config.docs),
};
hasFile(filePath) {
return this.docs.hasFile(filePath) || this.meta.hasFile(filePath);
},
} as DocsCollectionItem;
}
return buildPrimitiveCollection(name, config) as CollectionItem;
+18 -14
View File
@@ -111,10 +111,10 @@ export function createCore(
return this;
},
getConfig() {
getConfig(): LoadedConfig {
return config;
},
async initServer(server: ServerContext) {
async initServer(server: ServerContext): Promise<void> {
server.watcher?.on('all', async (event, file) => {
if (event === 'change') removeFileCache(file);
});
@@ -123,21 +123,25 @@ export function createCore(
await plugin.configureServer?.call(this.getPluginContext(), server);
}
},
async emitAndWrite({
filterPlugin = () => true,
}: EmitOptions = {}): Promise<void> {
async emit({ filterPlugin = () => true }: EmitOptions = {}): Promise<
EmitEntry[]
> {
return (
await Promise.all(
plugins.map((plugin) => {
if (!filterPlugin(plugin) || !plugin.emit) return [];
return plugin.emit.call(this.getPluginContext());
}),
)
).flat();
},
async emitAndWrite(emitOptions?: EmitOptions): Promise<void> {
const start = performance.now();
const out = await Promise.all(
plugins.map((plugin) => {
if (!filterPlugin(plugin) || !plugin.emit) return [];
return plugin.emit.call(this.getPluginContext());
}),
);
const out = await this.emit(emitOptions);
await Promise.all(
out.flat().map(async (entry) => {
out.map(async (entry) => {
const file = path.join(options.outDir, entry.path);
await fs.mkdir(path.dirname(file), { recursive: true });
+9 -7
View File
@@ -11,6 +11,7 @@ import { ValidationError } from '@/utils/validation';
import next from '@/plugins/next';
import { type Core, createCore, findConfigFile } from '@/core';
import { mdxLoaderGlob, metaLoaderGlob } from '@/loaders';
import type { IndexFilePluginOptions } from '@/plugins/index-file';
export interface CreateMDXOptions {
/**
@@ -24,6 +25,8 @@ export interface CreateMDXOptions {
* @defaultValue '.source'
*/
outDir?: string;
index?: IndexFilePluginOptions;
}
const defaultPageExtensions = ['mdx', 'md', 'jsx', 'js', 'tsx', 'ts'];
@@ -187,6 +190,7 @@ export async function postInstall(
outDir = '.source',
) {
const core = await createNextCore({
index: {},
outDir,
configPath,
}).init({
@@ -198,22 +202,20 @@ export async function postInstall(
function applyDefaults(options: CreateMDXOptions): Required<CreateMDXOptions> {
return {
index: {},
outDir: options.outDir ?? '.source',
configPath: options.configPath ?? findConfigFile(),
};
}
function createNextCore({
outDir,
configPath,
}: Required<CreateMDXOptions>): Core {
function createNextCore(options: Required<CreateMDXOptions>): Core {
const core = createCore(
{
environment: 'next',
outDir,
configPath,
outDir: options.outDir,
configPath: options.configPath,
},
[next()],
[next(options)],
);
return {
+390
View File
@@ -0,0 +1,390 @@
import type { Plugin } from '@/core';
import type {
LoadedConfig,
CollectionItem,
DocCollectionItem,
MetaCollectionItem,
} from '@/config/build';
import path from 'path';
import { type CodeGen, createCodegen, ident } from '@/utils/codegen';
import { glob } from 'tinyglobby';
import { readFileWithCache } from '@/utils/codegen/cache';
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';
/**
* add `.js` extensions to imports, needed for ESM without bundler resolution
*/
addJsExtension?: boolean;
browser?: boolean;
}
export default function indexFile(
options: IndexFilePluginOptions = {},
): Plugin {
const { target = 'default', addJsExtension, browser = true } = options;
let config: LoadedConfig;
let dynamicCollections: CollectionItem[];
function isDynamic(collection: CollectionItem) {
return (
(collection.type === 'docs' && collection.docs.dynamic) ||
(collection.type === 'doc' && collection.dynamic)
);
}
return {
name: 'index-file',
config(v) {
config = v;
dynamicCollections = config.collectionList.filter(isDynamic);
},
configureServer(server) {
if (!server.watcher) return;
server.watcher.on('all', async (event, file) => {
// dynamic collections always require re-generation on change
if (dynamicCollections.length === 0) {
// vite uses `import.meta.glob`, no need to re-generate
if (target === 'vite') return;
// only re-generate when adding/deleting entries
if (target === 'default' && event === 'change') return;
}
const updatedCollection = config.collectionList.find((collection) =>
collection.hasFile(file),
);
if (!updatedCollection) return;
if (!isDynamic(updatedCollection)) {
if (target === 'vite') return;
if (target === 'default' && event === 'change') return;
}
await this.core.emitAndWrite({
filterPlugin: (plugin) => plugin.name === 'index-file',
});
});
},
async emit() {
const globCache = new Map<string, Promise<string[]>>();
const makeCodeGen = () =>
createCodegen({
target,
outDir: this.outDir,
jsExtension: addJsExtension,
globCache,
});
async function toEmitEntry(
path: string,
content: Promise<string>,
): Promise<EmitEntry> {
return {
path,
content: await content,
};
}
const out: Promise<EmitEntry>[] = [
toEmitEntry(
'index.ts',
generateServerIndexFile(makeCodeGen(), config, this.configPath),
),
toEmitEntry(
'dynamic.ts',
generateDynamicIndexFile(makeCodeGen(), config, this.configPath),
),
];
if (browser)
out.push(
toEmitEntry(
'browser.ts',
generateBrowserIndexFile(makeCodeGen(), config, this.configPath),
),
);
return await Promise.all(out);
},
};
}
export async function generateServerIndexFile(
codegen: CodeGen,
config: LoadedConfig,
configPath: string,
): Promise<string> {
codegen.lines.push(
`import { fromConfig } from 'fumadocs-mdx/runtime/server';`,
`import type * as Config from '${codegen.formatImportPath(configPath)}';`,
'',
`const create = fromConfig<typeof Config>();`,
);
async function generateCollectionObject(
collection: CollectionItem,
): Promise<string | undefined> {
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),
]);
return `await create.docsLazy("${collection.name}", "${collection.dir}", ${metaGlob}, ${headGlob}, ${bodyGlob})`;
}
const [metaGlob, docGlob] = await Promise.all([
generateMetaCollectionGlob(codegen, collection.meta, true),
generateDocCollectionGlob(codegen, collection.docs, true),
]);
return `await create.docs("${collection.name}", "${collection.dir}", ${metaGlob}, ${docGlob})`;
}
case 'doc':
if (collection.dynamic) return;
if (collection.async) {
const [headGlob, bodyGlob] = await Promise.all([
generateDocCollectionFrontmatterGlob(codegen, collection, true),
generateDocCollectionGlob(codegen, collection),
]);
return `await create.docLazy("${collection.name}", "${collection.dir}", ${headGlob}, ${bodyGlob})`;
}
return `await create.doc("${collection.name}", "${collection.dir}", ${await generateDocCollectionGlob(
codegen,
collection,
true,
)})`;
case 'meta':
return `await create.meta("${collection.name}", "${collection.dir}", ${await generateMetaCollectionGlob(
codegen,
collection,
true,
)})`;
}
}
await codegen.pushAsync(
config.collectionList.map(async (collection) => {
const obj = await generateCollectionObject(collection);
if (!obj) return;
return `\nexport const ${collection.name} = ${obj};`;
}),
);
return codegen.toString();
}
export async function generateDynamicIndexFile(
codegen: CodeGen,
config: LoadedConfig,
configPath: string,
) {
codegen.lines.push(
`import { fromConfigDynamic } from 'fumadocs-mdx/runtime/dynamic';`,
`import * as Config from '${codegen.formatImportPath(configPath)}';`,
'',
`const create = fromConfigDynamic(Config);`,
);
async function generateCollectionObjectEntry(
collection: DocCollectionItem,
file: string,
) {
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 hash = createHash('md5').update(content).digest('hex');
const infoStr: string[] = [
// make sure it's included in vercel/nft
`absolutePath: path.resolve(${JSON.stringify(fullPath)})`,
];
for (const [k, v] of Object.entries({
info: {
fullPath,
path: file,
},
data,
lastModified,
hash,
} satisfies LazyEntry<unknown>)) {
infoStr.push(`${k}: ${JSON.stringify(v)}`);
}
return `{ ${infoStr.join(', ')} }`;
}
async function generateCollectionObject(
parent: CollectionItem,
): Promise<string | undefined> {
let collection: DocCollectionItem | undefined;
if (parent.type === 'doc') collection = parent;
else if (parent.type === 'docs') collection = parent.docs;
if (!collection || !collection.dynamic) return;
const files = await glob(collection.patterns, {
cwd: collection.dir,
});
const entries = await Promise.all(
files.map((file) => generateCollectionObjectEntry(collection, file)),
);
switch (parent.type) {
case 'docs': {
const metaGlob = await generateMetaCollectionGlob(
codegen,
parent.meta,
true,
);
return `await create.docs("${parent.name}", "${parent.dir}", ${metaGlob}, ${entries.join(', ')})`;
}
case 'doc':
return `await create.doc("${collection.name}", "${collection.dir}", ${entries.join(', ')})`;
}
}
await codegen.pushAsync(
config.collectionList.map(async (collection) => {
const obj = await generateCollectionObject(collection);
if (!obj) return;
return `\nexport const ${collection.name} = ${obj};`;
}),
);
return codegen.toString();
}
export async function generateBrowserIndexFile(
codegen: CodeGen,
config: LoadedConfig,
configPath: string,
): Promise<string> {
codegen.lines.push(
`import { fromConfig } from 'fumadocs-mdx/runtime/browser';`,
`import type * as Config from '${codegen.formatImportPath(configPath)}';`,
'',
`const create = fromConfig<typeof Config>(Config);`,
);
async function generateCollectionObject(
collection: CollectionItem,
): Promise<string | undefined> {
switch (collection.type) {
case 'docs': {
if (collection.docs.dynamic) return;
const [docGlob, metaGlob] = await Promise.all([
generateCollectionObject(collection.docs),
generateCollectionObject(collection.meta),
]);
return `{\n${ident(`doc: ${docGlob},\nmeta: ${metaGlob}`)}\n}`;
}
case 'doc':
if (collection.dynamic) return;
return `create.doc("${collection.name}", ${await generateDocCollectionGlob(codegen, collection)})`;
case 'meta':
return `create.meta("${collection.name}", ${await generateMetaCollectionGlob(
codegen,
collection,
)})`;
}
}
await codegen.pushAsync(
config.collectionList.map(async (collection) => {
const obj = await generateCollectionObject(collection);
if (!obj) return;
return `\nexport const ${collection.name} = ${obj};`;
}),
);
return codegen.toString();
}
function generateDocCollectionFrontmatterGlob(
codegen: CodeGen,
collection: DocCollectionItem,
eager = false,
) {
return codegen.generateGlobImport(collection.patterns, {
query: {
collection: collection.name,
only: 'frontmatter',
},
import: 'frontmatter',
base: collection.dir,
eager,
});
}
function generateDocCollectionGlob(
codegen: CodeGen,
collection: DocCollectionItem,
eager = false,
) {
return codegen.generateGlobImport(collection.patterns, {
query: {
collection: collection.name,
},
base: collection.dir,
eager,
});
}
function generateMetaCollectionGlob(
codegen: CodeGen,
collection: MetaCollectionItem,
eager = false,
) {
return codegen.generateGlobImport(collection.patterns, {
query: {
collection: collection.name,
},
import: 'default',
base: collection.dir,
eager,
});
}
+5 -41
View File
@@ -1,43 +1,7 @@
import type { LoadedConfig } from '@/config/build';
import type { Plugin } from '@/core';
import { emitIndexFiles } from '@/utils/codegen';
import type { PluginOption } from '@/core';
import type { CreateMDXOptions } from '@/next';
import indexFile from './index-file';
export default function next(): Plugin {
let config: LoadedConfig;
let shouldEmitOnChange = false;
return {
name: 'next',
config(v) {
config = v;
// always emit again when async mode enabled
shouldEmitOnChange = config.collectionList.some((collection) => {
return (
(collection.type === 'doc' && collection.async) ||
collection.type === 'docs' ||
collection.type === 'meta'
);
});
},
configureServer(server) {
if (!server.watcher) return;
server.watcher.on('all', async (event) => {
if (event === 'change' && !shouldEmitOnChange) return;
await this.core.emitAndWrite({
filterPlugin: (plugin) => plugin.name === 'next',
});
});
},
async emit() {
// TODO: implement meta entries validation.
return emitIndexFiles({
config,
configPath: this.configPath,
outDir: this.outDir,
});
},
};
export default function next(config: CreateMDXOptions): PluginOption {
return [indexFile(config.index)];
}
+18 -83
View File
@@ -1,90 +1,25 @@
import type { LoadedConfig } from '@/config/build';
import type { EmitEntry, Plugin } from '@/core';
import { emitIndexFiles } from '@/utils/codegen';
import type { PluginOption } from '@/core';
import indexFile, { type IndexFilePluginOptions } from './index-file';
import type { PluginOptions } from '@/vite';
export interface IndexFileOptions {
/**
* Runtime compat fallbacks for Vite specific APIs
*
* - `bun`: use Bun-specific APIs.
* - `node`: use Node.js APIs.
* - `false` (default): no fallback.
*/
runtime?: 'bun' | 'node' | false;
/**
* add `.js` extensions to imports, needed for ESM without bundler resolution
*/
addJsExtension?: boolean;
export default function vite(config: Required<PluginOptions>): PluginOption {
const out: PluginOption = [];
/**
* Generate entry point for browser environment
*
* @defaultValue true
*/
browser?: boolean;
if (config.index) {
const options: IndexFilePluginOptions =
config.index === true ? {} : config.index;
out.push(indexFile(applyDefaults(options)));
}
return out;
}
export default function vite({
index,
}: {
index: IndexFileOptions | boolean;
}): Plugin {
let config: LoadedConfig;
let indexOptions: Required<IndexFileOptions> | false;
if (index === false) indexOptions = false;
else indexOptions = applyDefaults(index === true ? {} : index);
function applyDefaults(
options: IndexFilePluginOptions,
): IndexFilePluginOptions {
return {
name: 'vite',
config(v) {
config = v;
},
configureServer(server) {
if (
!server.watcher ||
indexOptions === false ||
indexOptions.runtime === false
)
return;
// for bun/node runtimes, alternative import.meta.glob has to be re-generated on update
server.watcher.on('all', (event, file) => {
if (event === 'change') return;
const isUpdated = config.collectionList.some((collection) => {
if (collection.type === 'docs')
return (
collection.docs.hasFile(file) || collection.meta.hasFile(file)
);
return collection.hasFile(file);
});
if (isUpdated) {
this.core.emitAndWrite({
filterPlugin: (plugin) => plugin.name === 'vite',
});
}
});
},
async emit() {
const out: EmitEntry[] = [];
if (indexOptions === false) return out;
return emitIndexFiles({
config,
configPath: this.configPath,
outDir: this.outDir,
target: indexOptions.runtime === false ? 'vite' : 'default',
browser: indexOptions.browser,
});
},
};
}
function applyDefaults(options: IndexFileOptions): Required<IndexFileOptions> {
return {
addJsExtension: options.addJsExtension ?? false,
browser: options.browser ?? false,
runtime: options.runtime ?? false,
...options,
target: options.target ?? 'vite',
};
}
+153 -332
View File
@@ -1,361 +1,182 @@
import type {
LoadedConfig,
CollectionItem,
DocCollectionItem,
MetaCollectionItem,
} from '@/config/build';
import path from 'path';
import { type CodeGen, createCodegen, ident } from './utils';
import path from 'node:path';
import { glob } from 'tinyglobby';
import { readFileWithCache } from './cache';
import { fumaMatter } from '../fuma-matter';
import { validate } from '../validation';
import { getGitTimestamp } from '../git-timestamp';
import { createHash } from 'crypto';
import type { LazyEntry } from '@/runtime/dynamic';
import type { EmitEntry } from '@/core';
export interface GenerateIndexFileOptions {
target?: 'default' | 'vite';
/**
* add `.js` extensions to imports, needed for ESM without bundler resolution
*/
addJsExtension?: boolean;
export interface GlobImportOptions {
base: string;
query?: Record<string, string>;
import?: string;
eager?: boolean;
}
export interface CodeGenOptions {
target: 'default' | 'vite';
outDir: string;
config: LoadedConfig;
configPath: string;
/**
* add .js extenstion to imports
*/
jsExtension: boolean;
globCache: Map<string, Promise<string[]>>;
}
export async function emitIndexFiles({
browser,
...options
}: GenerateIndexFileOptions & { browser?: boolean }): Promise<EmitEntry[]> {
const out: Promise<EmitEntry>[] = [
generateDynamicIndexFile(options).then((content) => ({
path: 'dynamic.ts',
content,
})),
generateServerIndexFile(options).then((content) => ({
path: 'index.ts',
content,
})),
];
export type CodeGen = ReturnType<typeof createCodegen>;
if (browser !== false)
out.push(
generateBrowserIndexFile(options).then((content) => ({
path: 'browser.ts',
content,
})),
);
/**
* Code generator (one instance per file)
*/
export function createCodegen({
target = 'default',
outDir = '',
jsExtension = false,
globCache = new Map(),
}: Partial<CodeGenOptions>) {
let eagerImportId = 0;
const banner: string[] = ['// @ts-nocheck'];
return await Promise.all(out);
}
export async function generateServerIndexFile(
options: GenerateIndexFileOptions,
): Promise<string> {
const {
target = 'default',
outDir,
addJsExtension = false,
config,
configPath,
} = options;
const codegen = createCodegen({
target,
outDir,
jsExtension: addJsExtension,
});
codegen.lines.push(
`import { fromConfig } from 'fumadocs-mdx/runtime/server';`,
`import type * as Config from '${codegen.formatImportPath(configPath)}';`,
'',
`const create = fromConfig<typeof Config>();`,
);
async function generateCollectionObject(
collection: CollectionItem,
): Promise<string | undefined> {
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),
]);
return `await create.docsLazy("${collection.name}", "${collection.dir}", ${metaGlob}, ${headGlob}, ${bodyGlob})`;
}
const [metaGlob, docGlob] = await Promise.all([
generateMetaCollectionGlob(codegen, collection.meta, true),
generateDocCollectionGlob(codegen, collection.docs, true),
]);
return `await create.docs("${collection.name}", "${collection.dir}", ${metaGlob}, ${docGlob})`;
}
case 'doc':
if (collection.dynamic) return;
if (collection.async) {
const [headGlob, bodyGlob] = await Promise.all([
generateDocCollectionFrontmatterGlob(codegen, collection, true),
generateDocCollectionGlob(codegen, collection),
]);
return `await create.docLazy("${collection.name}", "${collection.dir}", ${headGlob}, ${bodyGlob})`;
}
return `await create.doc("${collection.name}", "${collection.dir}", ${await generateDocCollectionGlob(
codegen,
collection,
true,
)})`;
case 'meta':
return `await create.meta("${collection.name}", "${collection.dir}", ${await generateMetaCollectionGlob(
codegen,
collection,
true,
)})`;
}
if (target === 'vite') {
banner.push('/// <reference types="vite/client" />');
}
await codegen.pushAsync(
config.collectionList.map(async (collection) => {
const obj = await generateCollectionObject(collection);
if (!obj) return;
return {
options: {
target,
outDir,
} as CodeGenOptions,
lines: [] as string[],
addImport(statement: string) {
this.lines.unshift(statement);
},
async pushAsync(insert: Promise<string | undefined>[]) {
for (const line of await Promise.all(insert)) {
if (line === undefined) continue;
return `\nexport const ${collection.name} = ${obj};`;
}),
);
this.lines.push(line);
}
},
return codegen.toString();
}
async generateGlobImport(
patterns: string | string[],
options: GlobImportOptions,
): Promise<string> {
if (target === 'vite') {
return this.generateViteGlobImport(patterns, options);
}
export async function generateDynamicIndexFile(
options: GenerateIndexFileOptions,
) {
const {
target,
outDir,
addJsExtension = false,
config,
configPath,
} = options;
return this.generateNodeGlobImport(patterns, options);
},
const codegen = createCodegen({
target,
outDir,
jsExtension: addJsExtension,
});
codegen.lines.push(
`import { fromConfigDynamic } from 'fumadocs-mdx/runtime/dynamic';`,
`import * as Config from '${codegen.formatImportPath(configPath)}';`,
'',
`const create = fromConfigDynamic(Config);`,
);
async function generateCollectionObjectEntry(
collection: DocCollectionItem,
file: string,
) {
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}`,
generateViteGlobImport(
patterns: string | string[],
{ base, ...rest }: GlobImportOptions,
): string {
patterns = (typeof patterns === 'string' ? [patterns] : patterns).map(
normalizeViteGlobPath,
);
}
let lastModified: Date | undefined;
if (config.global?.lastModifiedTime === 'git') {
lastModified = await getGitTimestamp(fullPath);
}
const hash = createHash('md5').update(content).digest('hex');
const infoStr: string[] = [
// make sure it's included in vercel/nft
`absolutePath: path.resolve(${JSON.stringify(fullPath)})`,
];
for (const [k, v] of Object.entries({
info: {
fullPath,
path: file,
},
data,
lastModified,
hash,
} satisfies LazyEntry<unknown>)) {
infoStr.push(`${k}: ${JSON.stringify(v)}`);
}
return `{ ${infoStr.join(', ')} }`;
}
async function generateCollectionObject(
parent: CollectionItem,
): Promise<string | undefined> {
let collection: DocCollectionItem | undefined;
if (parent.type === 'doc') collection = parent;
else if (parent.type === 'docs') collection = parent.docs;
if (!collection || !collection.dynamic) return;
const files = await glob(collection.patterns, {
cwd: collection.dir,
});
const entries = await Promise.all(
files.map((file) => generateCollectionObjectEntry(collection, file)),
);
switch (parent.type) {
case 'docs': {
const metaGlob = await generateMetaCollectionGlob(
codegen,
parent.meta,
true,
);
return `await create.docs("${parent.name}", "${parent.dir}", ${metaGlob}, ${entries.join(', ')})`;
return `import.meta.glob(${JSON.stringify(patterns)}, ${JSON.stringify(
{
base: normalizeViteGlobPath(path.relative(outDir, base)),
...rest,
},
null,
2,
)})`;
},
async generateNodeGlobImport(
patterns: string | string[],
{
base,
eager = false,
query = {},
import: importName,
}: GlobImportOptions,
): Promise<string> {
const cacheKey = JSON.stringify({ patterns, base });
let files = globCache.get(cacheKey);
if (!files) {
files = glob(patterns, {
cwd: base,
});
globCache.set(cacheKey, files);
}
case 'doc':
return `await create.doc("${collection.name}", "${collection.dir}", ${entries.join(', ')})`;
}
}
await codegen.pushAsync(
config.collectionList.map(async (collection) => {
const obj = await generateCollectionObject(collection);
if (!obj) return;
let code: string = '{';
for (const item of await files) {
const fullPath = path.join(base, item);
const searchParams = new URLSearchParams();
return `\nexport const ${collection.name} = ${obj};`;
}),
);
for (const [k, v] of Object.entries(query)) {
searchParams.set(k, v);
}
return codegen.toString();
}
const importPath =
this.formatImportPath(fullPath) + '?' + searchParams.toString();
if (eager) {
const name = `__fd_glob_${eagerImportId++}`;
this.lines.unshift(
importName
? `import { ${importName} as ${name} } from ${JSON.stringify(importPath)}`
: `import * as ${name} from ${JSON.stringify(importPath)}`,
);
export async function generateBrowserIndexFile(
options: GenerateIndexFileOptions,
): Promise<string> {
const {
target,
outDir,
addJsExtension = false,
config,
configPath,
} = options;
const codegen = createCodegen({
target,
outDir,
jsExtension: addJsExtension,
});
code += `${JSON.stringify(item)}: ${name}, `;
} else {
let line = `${JSON.stringify(item)}: () => import(${JSON.stringify(importPath)})`;
if (importName) {
line += `.then(mod => mod.${importName})`;
}
codegen.lines.push(
`import { fromConfig } from 'fumadocs-mdx/runtime/browser';`,
`import type * as Config from '${codegen.formatImportPath(configPath)}';`,
'',
`const create = fromConfig<typeof Config>(Config);`,
);
async function generateCollectionObject(
collection: CollectionItem,
): Promise<string | undefined> {
switch (collection.type) {
case 'docs': {
if (collection.docs.dynamic) return;
const [docGlob, metaGlob] = await Promise.all([
generateCollectionObject(collection.docs),
generateCollectionObject(collection.meta),
]);
return `{\n${ident(`doc: ${docGlob},\nmeta: ${metaGlob}`)}\n}`;
code += `${line}, `;
}
}
case 'doc':
if (collection.dynamic) return;
return `create.doc("${collection.name}", ${await generateDocCollectionGlob(codegen, collection)})`;
case 'meta':
return `create.meta("${collection.name}", ${await generateMetaCollectionGlob(
codegen,
collection,
)})`;
}
code += '}';
return code;
},
formatImportPath(file: string) {
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') {
filename = file.substring(0, file.length - ext.length);
} else {
filename = file;
}
const importPath = slash(path.relative(outDir, filename));
return importPath.startsWith('.') ? importPath : `./${importPath}`;
},
toString() {
return [...banner, ...this.lines].join('\n');
},
};
}
/**
* convert into POSIX & relative file paths, such that Vite can accept it.
*/
function normalizeViteGlobPath(file: string) {
file = slash(file);
if (file.startsWith('./')) return file;
if (file.startsWith('/')) return `.${file}`;
return `./${file}`;
}
export function slash(path: string): string {
const isExtendedLengthPath = path.startsWith('\\\\?\\');
if (isExtendedLengthPath) {
return path;
}
await codegen.pushAsync(
config.collectionList.map(async (collection) => {
const obj = await generateCollectionObject(collection);
if (!obj) return;
return `\nexport const ${collection.name} = ${obj};`;
}),
);
return codegen.toString();
return path.replaceAll('\\', '/');
}
function generateDocCollectionFrontmatterGlob(
codegen: CodeGen,
collection: DocCollectionItem,
eager = false,
) {
return codegen.generateGlobImport(collection.patterns, {
query: {
collection: collection.name,
only: 'frontmatter',
},
import: 'frontmatter',
base: collection.dir,
eager,
});
}
function generateDocCollectionGlob(
codegen: CodeGen,
collection: DocCollectionItem,
eager = false,
) {
return codegen.generateGlobImport(collection.patterns, {
query: {
collection: collection.name,
},
base: collection.dir,
eager,
});
}
function generateMetaCollectionGlob(
codegen: CodeGen,
collection: MetaCollectionItem,
eager = false,
) {
return codegen.generateGlobImport(collection.patterns, {
query: {
collection: collection.name,
},
import: 'default',
base: collection.dir,
eager,
});
export function ident(code: string, tab: number = 1) {
return code
.split('\n')
.map((v) => ' '.repeat(tab) + v)
.join('\n');
}
-178
View File
@@ -1,178 +0,0 @@
import path from 'node:path';
import { glob } from 'tinyglobby';
export interface GlobImportOptions {
base: string;
query?: Record<string, string>;
import?: string;
eager?: boolean;
}
export interface CodeGenOptions {
target: 'default' | 'vite';
outDir: string;
/**
* add .js extenstion to imports
*/
jsExtension: boolean;
}
export type CodeGen = ReturnType<typeof createCodegen>;
export function createCodegen({
target = 'default',
outDir = '',
jsExtension = false,
}: Partial<CodeGenOptions>) {
let eagerImportId = 0;
const globCache = new Map<string, Promise<string[]>>();
const banner: string[] = ['// @ts-nocheck'];
if (target === 'vite') {
banner.push('/// <reference types="vite/client" />');
}
return {
options: {
target,
outDir,
} as CodeGenOptions,
lines: [] as string[],
addImport(statement: string) {
this.lines.unshift(statement);
},
async pushAsync(insert: Promise<string | undefined>[]) {
for (const line of await Promise.all(insert)) {
if (line === undefined) continue;
this.lines.push(line);
}
},
async generateGlobImport(
patterns: string | string[],
options: GlobImportOptions,
): Promise<string> {
if (target === 'vite') {
return this.generateViteGlobImport(patterns, options);
}
return this.generateNodeGlobImport(patterns, options);
},
generateViteGlobImport(
patterns: string | string[],
{ base, ...rest }: GlobImportOptions,
): string {
patterns = (typeof patterns === 'string' ? [patterns] : patterns).map(
normalizeViteGlobPath,
);
return `import.meta.glob(${JSON.stringify(patterns)}, ${JSON.stringify(
{
base: normalizeViteGlobPath(path.relative(outDir, base)),
...rest,
},
null,
2,
)})`;
},
async generateNodeGlobImport(
patterns: string | string[],
{
base,
eager = false,
query = {},
import: importName,
}: GlobImportOptions,
): Promise<string> {
const cacheKey = JSON.stringify({ patterns, base });
let files = globCache.get(cacheKey);
if (!files) {
files = glob(patterns, {
cwd: base,
});
globCache.set(cacheKey, files);
}
let code: string = '{';
for (const item of await files) {
const fullPath = path.join(base, item);
const searchParams = new URLSearchParams();
for (const [k, v] of Object.entries(query)) {
searchParams.set(k, v);
}
const importPath =
this.formatImportPath(fullPath) + '?' + searchParams.toString();
if (eager) {
const name = `__fd_glob_${eagerImportId++}`;
this.lines.unshift(
importName
? `import { ${importName} as ${name} } from ${JSON.stringify(importPath)}`
: `import * as ${name} from ${JSON.stringify(importPath)}`,
);
code += `${JSON.stringify(item)}: ${name}, `;
} else {
let line = `${JSON.stringify(item)}: () => import(${JSON.stringify(importPath)})`;
if (importName) {
line += `.then(mod => mod.${importName})`;
}
code += `${line}, `;
}
}
code += '}';
return code;
},
formatImportPath(file: string) {
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') {
filename = file.substring(0, file.length - ext.length);
} else {
filename = file;
}
const importPath = slash(path.relative(outDir, filename));
return importPath.startsWith('.') ? importPath : `./${importPath}`;
},
toString() {
return [...banner, ...this.lines].join('\n');
},
};
}
/**
* convert into POSIX & relative file paths, such that Vite can accept it.
*/
function normalizeViteGlobPath(file: string) {
file = slash(file);
if (file.startsWith('./')) return file;
if (file.startsWith('/')) return `.${file}`;
return `./${file}`;
}
export function slash(path: string): string {
const isExtendedLengthPath = path.startsWith('\\\\?\\');
if (isExtendedLengthPath) {
return path;
}
return path.replaceAll('\\', '/');
}
export function ident(code: string, tab: number = 1) {
return code
.split('\n')
.map((v) => ' '.repeat(tab) + v)
.join('\n');
}
+9 -16
View File
@@ -3,21 +3,22 @@ import { buildConfig } from '@/config/build';
import { ValidationError } from '@/utils/validation';
import { createMdxLoader } from '@/loaders/mdx';
import { toVite } from '@/loaders/adapter';
import vite, { type IndexFileOptions } from '@/plugins/vite';
import vite from '@/plugins/vite';
import type { FSWatcher } from 'chokidar';
import { createCore, findConfigFile } from '@/core';
import { createIntegratedConfigLoader } from '@/loaders/config';
import { createMetaLoader } from '@/loaders/meta';
import { IndexFilePluginOptions } from '@/plugins/index-file';
const FumadocsDeps = ['fumadocs-core', 'fumadocs-ui', 'fumadocs-openapi'];
export interface PluginOptions {
/**
* Automatically generate index files for accessing files with `import.meta.glob`.
* Generate index files for accessing content.
*
* @defaultValue true
*/
generateIndexFile?: boolean | IndexFileOptions;
index?: boolean | IndexFilePluginOptions;
/**
* @defaultValue source.config.ts
@@ -114,29 +115,21 @@ export async function postInstall(
await core.emitAndWrite();
}
function createViteCore({
configPath,
outDir,
generateIndexFile,
}: Required<PluginOptions>) {
function createViteCore(options: Required<PluginOptions>) {
return createCore(
{
environment: 'vite',
configPath,
outDir,
configPath: options.configPath,
outDir: options.outDir,
},
[
vite({
index: generateIndexFile,
}),
],
[vite(options)],
);
}
function applyDefaults(options: PluginOptions): Required<PluginOptions> {
return {
updateViteConfig: options.updateViteConfig ?? true,
generateIndexFile: options.generateIndexFile ?? true,
index: options.index ?? true,
configPath: options.configPath ?? 'source.config.ts',
outDir: options.outDir ?? '.source',
};
+8 -8
View File
@@ -1,11 +1,3 @@
```ts title="dynamic.ts"
// @ts-nocheck
import { fromConfigDynamic } from 'fumadocs-mdx/runtime/dynamic';
import * as Config from './config';
const create = fromConfigDynamic(Config);
```
```ts title="index.ts"
// @ts-nocheck
import { frontmatter as __fd_glob_1 } from "./generate-index/folder/test.mdx?collection=docs&only=frontmatter"
@@ -18,6 +10,14 @@ const create = fromConfig<typeof Config>();
export const docs = await create.docLazy("docs", "packages/mdx/test/fixtures/generate-index", {"index.mdx": __fd_glob_0, "folder/test.mdx": __fd_glob_1, }, {"index.mdx": () => import("./generate-index/index.mdx?collection=docs"), "folder/test.mdx": () => import("./generate-index/folder/test.mdx?collection=docs"), });
```
```ts title="dynamic.ts"
// @ts-nocheck
import { fromConfigDynamic } from 'fumadocs-mdx/runtime/dynamic';
import * as Config from './config';
const create = fromConfigDynamic(Config);
```
```ts title="browser.ts"
// @ts-nocheck
import { fromConfig } from 'fumadocs-mdx/runtime/browser';
+8 -8
View File
@@ -1,11 +1,3 @@
```ts title="dynamic.ts"
// @ts-nocheck
import { fromConfigDynamic } from 'fumadocs-mdx/runtime/dynamic';
import * as Config from './config';
const create = fromConfigDynamic(Config);
```
```ts title="index.ts"
// @ts-nocheck
import { default as __fd_glob_0 } from "./generate-index/meta.json?collection=docs"
@@ -17,6 +9,14 @@ const create = fromConfig<typeof Config>();
export const docs = await create.meta("docs", "packages/mdx/test/fixtures/generate-index", {"meta.json": __fd_glob_0, });
```
```ts title="dynamic.ts"
// @ts-nocheck
import { fromConfigDynamic } from 'fumadocs-mdx/runtime/dynamic';
import * as Config from './config';
const create = fromConfigDynamic(Config);
```
```ts title="browser.ts"
// @ts-nocheck
import { fromConfig } from 'fumadocs-mdx/runtime/browser';
+8 -8
View File
@@ -1,11 +1,3 @@
```ts title="dynamic.ts"
// @ts-nocheck
import { fromConfigDynamic } from 'fumadocs-mdx/runtime/dynamic';
import * as Config from './config';
const create = fromConfigDynamic(Config);
```
```ts title="index.ts"
// @ts-nocheck
import * as __fd_glob_1 from "./generate-index/folder/test.mdx?collection=docs"
@@ -18,6 +10,14 @@ const create = fromConfig<typeof Config>();
export const docs = await create.doc("docs", "packages/mdx/test/fixtures/generate-index", {"index.mdx": __fd_glob_0, "folder/test.mdx": __fd_glob_1, });
```
```ts title="dynamic.ts"
// @ts-nocheck
import { fromConfigDynamic } from 'fumadocs-mdx/runtime/dynamic';
import * as Config from './config';
const create = fromConfigDynamic(Config);
```
```ts title="browser.ts"
// @ts-nocheck
import { fromConfig } from 'fumadocs-mdx/runtime/browser';
+16 -11
View File
@@ -5,8 +5,9 @@ import { z } from 'zod';
import { ValidationError } from '@/utils/validation';
import { defineCollections } from '@/config';
import { fumaMatter } from '@/utils/fuma-matter';
import { emitIndexFiles } from '@/utils/codegen';
import { buildConfig } from '@/config/build';
import { createCore } from '@/core';
import indexFile from '@/plugins/index-file';
test('format errors', async () => {
const schema = z.object({
@@ -70,20 +71,24 @@ const cases = [
for (const { name, collection } of cases) {
test(`generate JS index file: ${name}`, async () => {
const out = await emitIndexFiles({
configPath: path.join(baseDir, './fixtures/config.ts'),
const core = createCore(
{
configPath: path.join(baseDir, './fixtures/config.ts'),
environment: 'test',
outDir: path.join(baseDir, './fixtures'),
},
[indexFile()],
);
await core.init({
config: buildConfig({
docs: collection,
}),
outDir: path.join(baseDir, './fixtures'),
});
const markdown = out
.map((entry) => {
const content = entry.content.replaceAll(process.cwd(), '$cwd');
return `\`\`\`ts title="${entry.path}"\n${content}\n\`\`\``;
})
const markdown = (await core.emit())
.map(
(entry) => `\`\`\`ts title="${entry.path}"\n${entry.content}\n\`\`\``,
)
.join('\n\n');
await expect(markdown).toMatchFileSnapshot(