MDX: Support type-safe collection generation

This commit is contained in:
Fuma Nama
2025-11-17 23:20:53 +08:00
parent b2a9dd9088
commit 52dabc328b
20 changed files with 455 additions and 261 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-mdx': patch
---
Support type-safe collection generation
+4 -2
View File
@@ -26,11 +26,13 @@ export function createMdxPlugin(options: MdxPluginOptions = {}): BunPlugin {
name: 'bun-plugin-fumadocs-mdx',
async setup(build) {
const importPath = pathToFileURL(configPath).href;
const core = await createCore({
const core = createCore({
environment,
outDir,
configPath,
}).init({
});
await core.init({
config: buildConfig(await import(importPath)),
});
+3 -2
View File
@@ -5,11 +5,12 @@ import type { Core } from '@/core';
async function compileConfig(core: Core) {
const { build } = await import('esbuild');
const { configPath, outDir } = core.getOptions();
const transformed = await build({
entryPoints: [{ in: core._options.configPath, out: 'source.config' }],
entryPoints: [{ in: configPath, out: 'source.config' }],
bundle: true,
outdir: core._options.outDir,
outdir: outDir,
target: 'node20',
write: true,
platform: 'node',
+56 -9
View File
@@ -6,9 +6,11 @@ import type {
import path from 'node:path';
import fs from 'node:fs/promises';
import type { FSWatcher } from 'chokidar';
import { removeFileCache } from './utils/codegen/cache';
import { validate } from './utils/validation';
import type { VFile } from 'vfile';
import type { IndexFilePlugin } from './plugins/index-file';
import type { PostprocessOptions } from './config';
import { ident } from './utils/codegen';
type Awaitable<T> = T | Promise<T>;
@@ -33,7 +35,7 @@ export interface TransformOptions<Collection> {
source: string;
}
export interface Plugin {
export interface Plugin extends IndexFilePlugin {
name?: string;
/**
@@ -157,7 +159,6 @@ export function createCore(
}
const core = {
_options: options,
/**
* Convenient cache store, reset when config changes
*/
@@ -166,6 +167,7 @@ export function createCore(
config = await newConfig;
this.cache.clear();
plugins = await getPlugins([
postprocessPlugin(),
...defaultPlugins,
...(config.global.plugins ?? []),
]);
@@ -174,8 +176,9 @@ export function createCore(
const out = await plugin.config?.call(pluginContext, config);
if (out) config = out;
}
return this;
},
getOptions() {
return options;
},
getConfig(): LoadedConfig {
return config;
@@ -186,11 +189,13 @@ export function createCore(
getCompiledConfigPath(): string {
return path.join(options.outDir, 'source.config.mjs');
},
getPlugins() {
return plugins;
},
getPluginContext(): PluginContext {
return pluginContext;
},
async initServer(server: ServerContext): Promise<void> {
server.watcher?.on('all', async (event, file) => {
if (event === 'change') removeFileCache(file);
});
for (const plugin of plugins) {
await plugin.configureServer?.call(pluginContext, server);
}
@@ -284,4 +289,46 @@ export function createCore(
return core;
}
function postprocessPlugin(): Plugin {
const LinkReferenceTypes = `{
/**
* extracted references (e.g. hrefs, paths), useful for analyzing relationships between pages.
*/
extractedReferences?: import('fumadocs-mdx').ExtractedReference[];
}`;
return {
'index-file': {
generateTypeConfig() {
const lines: string[] = [];
lines.push('{');
lines.push(' DocData: {');
for (const collection of this.core.getConfig().collectionList) {
let postprocessOptions: Partial<PostprocessOptions> | undefined;
switch (collection.type) {
case 'doc':
postprocessOptions = collection.postprocess;
break;
case 'docs':
postprocessOptions = collection.docs.postprocess;
break;
}
if (postprocessOptions?.extractLinkReferences) {
lines.push(ident(`${collection.name}: ${LinkReferenceTypes},`, 2));
}
}
lines.push(' }');
lines.push('}');
return lines.join('\n');
},
serverOptions(options) {
options.doc ??= {};
options.doc.passthroughs ??= [];
options.doc.passthroughs.push('extractedReferences');
},
},
};
}
export type Core = ReturnType<typeof createCore>;
+1
View File
@@ -1,2 +1,3 @@
export type * from './core';
export type { ExtractedReference } from '@/loaders/mdx/remark-postprocess';
export type { CompiledMDXProperties } from '@/loaders/mdx/build-mdx';
+1 -1
View File
@@ -27,7 +27,7 @@ export function createStandaloneConfigLoader({
async function getConfigHash(): Promise<string> {
if (mode === 'production') return 'static';
const stats = await fs.stat(core._options.configPath).catch(() => {
const stats = await fs.stat(core.getOptions().configPath).catch(() => {
throw new Error('Cannot find config file');
});
-10
View File
@@ -6,7 +6,6 @@ import type { TOCItemType } from 'fumadocs-core/toc';
import type { FC } from 'react';
import type { MDXProps } from 'mdx/types';
import {
type ExtractedReference,
type PostprocessOptions,
remarkPostprocess,
} from '@/loaders/mdx/remark-postprocess';
@@ -32,21 +31,12 @@ export interface CompilerOptions {
addDependency: (file: string) => void;
}
// TODO: allow plugins to customise the type
export interface CompiledMDXProperties<Frontmatter = Record<string, unknown>> {
frontmatter: Frontmatter;
structuredData: StructuredData;
toc: TOCItemType[];
default: FC<MDXProps>;
/**
* Added by the `last-modified` plugin.
*/
lastModified?: Date;
/**
* Enable from `postprocess` option.
*/
extractedReferences?: ExtractedReference[];
/**
* Enable from `postprocess` option.
*/
+6 -4
View File
@@ -43,7 +43,7 @@ export function createMDX(createOptions: CreateMDXOptions = {}) {
return (nextConfig: NextConfig = {}): NextConfig => {
const loaderOptions: WebpackLoaderOptions = {
...core._options,
...core.getOptions(),
compiledConfigPath: core.getCompiledConfigPath(),
isDev,
};
@@ -133,13 +133,14 @@ async function init(dev: boolean, core: Core): Promise<void> {
async function devServer() {
const { FSWatcher } = await import('chokidar');
const { configPath, outDir } = core.getOptions();
const watcher = new FSWatcher({
ignoreInitial: true,
persistent: true,
ignored: [core._options.outDir],
ignored: [outDir],
});
watcher.add(core._options.configPath);
watcher.add(configPath);
for (const collection of core.getConfig().collectionList) {
if (collection.type === 'docs') {
watcher.add(collection.docs.dir);
@@ -153,8 +154,9 @@ async function init(dev: boolean, core: Core): Promise<void> {
console.log('[MDX] started dev server');
});
const absoluteConfigPath = path.resolve(configPath);
watcher.on('all', async (_event, file) => {
if (path.resolve(file) === path.resolve(core._options.configPath)) {
if (path.resolve(file) === absoluteConfigPath) {
// skip plugin listeners
watcher.removeAllListeners();
+88 -54
View File
@@ -1,4 +1,4 @@
import type { Core, Plugin } from '@/core';
import type { Core, Plugin, PluginContext } from '@/core';
import type {
LoadedConfig,
CollectionItem,
@@ -8,11 +8,12 @@ import type {
import path from 'path';
import { type CodeGen, createCodegen, ident } from '@/utils/codegen';
import { glob } from 'tinyglobby';
import { readFileWithCache } from '@/utils/codegen/cache';
import { readFileWithCache, removeFileCache } from '@/utils/fs-cache';
import { createHash } from 'crypto';
import type { LazyEntry } from '@/runtime/dynamic';
import type { EmitEntry } from '@/core';
import { fumaMatter } from '@/utils/fuma-matter';
import type { ServerOptions } from '@/runtime/server';
export interface IndexFilePluginOptions {
target?: 'default' | 'vite';
@@ -35,6 +36,20 @@ export interface IndexFilePluginOptions {
dynamic?: boolean;
}
export interface IndexFilePlugin {
['index-file']?: {
generateTypeConfig?: (this: PluginContext) => string | void;
serverOptions?: (this: PluginContext, options: ServerOptions) => void;
};
}
interface FileGenContext {
core: Core;
codegen: CodeGen;
serverOptions: ServerOptions;
tc: string;
}
export default function indexFile(
options: IndexFilePluginOptions = {},
): Plugin {
@@ -54,6 +69,33 @@ export default function indexFile(
);
}
function generateConfigs(core: Core): {
serverOptions: ServerOptions;
tc: string;
} {
const serverOptions: ServerOptions = {};
const typeConfigs: string[] = [];
const ctx = core.getPluginContext();
for (const plugin of core.getPlugins()) {
const indexFilePlugin = plugin['index-file'];
if (!indexFilePlugin) continue;
indexFilePlugin.serverOptions?.call(ctx, serverOptions);
const config = indexFilePlugin.generateTypeConfig?.call(ctx);
if (config) typeConfigs.push(config);
}
if (typeConfigs.length === 0) {
typeConfigs.push('{ DocData: {} }');
}
return {
serverOptions,
tc: typeConfigs.join(' & '),
};
}
return {
name: 'index-file',
config(v) {
@@ -64,6 +106,8 @@ export default function indexFile(
if (!server.watcher) return;
server.watcher.on('all', async (event, file) => {
removeFileCache(file);
// dynamic collections always require re-generation on change
if (dynamicCollections.length === 0) {
// vite uses `import.meta.glob`, no need to re-generate
@@ -89,62 +133,56 @@ export default function indexFile(
},
async emit() {
const globCache = new Map<string, Promise<string[]>>();
const makeCodeGen = () =>
createCodegen({
const { serverOptions, tc } = generateConfigs(this.core);
const toEmitEntry = async (
path: string,
content: (ctx: FileGenContext) => Promise<void>,
): Promise<EmitEntry> => {
const codegen = createCodegen({
target,
outDir: this.outDir,
jsExtension: addJsExtension,
globCache,
});
async function toEmitEntry(
path: string,
content: Promise<string>,
): Promise<EmitEntry> {
await content({
core: this.core,
codegen,
serverOptions,
tc,
});
return {
path,
content: await content,
content: codegen.toString(),
};
}
};
const out: Promise<EmitEntry>[] = [
toEmitEntry(
'server.ts',
generateServerIndexFile(makeCodeGen(), config, this.configPath),
),
toEmitEntry('server.ts', generateServerIndexFile),
];
if (dynamic)
out.push(
toEmitEntry(
'dynamic.ts',
generateDynamicIndexFile(this.core, makeCodeGen()),
),
);
out.push(toEmitEntry('dynamic.ts', generateDynamicIndexFile));
if (browser)
out.push(
toEmitEntry(
'browser.ts',
generateBrowserIndexFile(makeCodeGen(), config, this.configPath),
),
);
out.push(toEmitEntry('browser.ts', generateBrowserIndexFile));
return await Promise.all(out);
},
};
}
async function generateServerIndexFile(
codegen: CodeGen,
config: LoadedConfig,
configPath: string,
): Promise<string> {
async function generateServerIndexFile({
core,
codegen,
serverOptions,
tc,
}: FileGenContext) {
codegen.lines.push(
`import { fromConfig } from 'fumadocs-mdx/runtime/server';`,
`import type * as Config from '${codegen.formatImportPath(configPath)}';`,
`import { server } from 'fumadocs-mdx/runtime/server';`,
`import type * as Config from '${codegen.formatImportPath(core.getOptions().configPath)}';`,
'',
`const create = fromConfig<typeof Config>();`,
`const create = server<typeof Config, ${tc}>(${JSON.stringify(serverOptions)});`,
);
async function generateCollectionObject(
@@ -202,24 +240,27 @@ async function generateServerIndexFile(
}
await codegen.pushAsync(
config.collectionList.map(async (collection) => {
core.getConfig().collectionList.map(async (collection) => {
const obj = await generateCollectionObject(collection);
if (!obj) return;
return `\nexport const ${collection.name} = ${obj};`;
}),
);
return codegen.toString();
}
async function generateDynamicIndexFile(core: Core, codegen: CodeGen) {
const { configPath } = core._options;
async function generateDynamicIndexFile({
core,
codegen,
serverOptions,
tc,
}: FileGenContext) {
const { configPath } = core.getOptions();
codegen.lines.push(
`import { fromConfigDynamic } from 'fumadocs-mdx/runtime/dynamic';`,
`import { dynamic } from 'fumadocs-mdx/runtime/dynamic';`,
`import * as Config from '${codegen.formatImportPath(configPath)}';`,
'',
`const create = await fromConfigDynamic(Config);`,
`const create = await dynamic<typeof Config, ${tc}>(Config, ${JSON.stringify(core.getOptions())}, ${JSON.stringify(serverOptions)});`,
);
async function generateCollectionObjectEntry(
@@ -296,20 +337,14 @@ async function generateDynamicIndexFile(core: Core, codegen: CodeGen) {
return `\nexport const ${collection.name} = ${obj};`;
}),
);
return codegen.toString();
}
async function generateBrowserIndexFile(
codegen: CodeGen,
config: LoadedConfig,
configPath: string,
): Promise<string> {
async function generateBrowserIndexFile({ core, codegen, tc }: FileGenContext) {
codegen.lines.push(
`import { fromConfig } from 'fumadocs-mdx/runtime/browser';`,
`import type * as Config from '${codegen.formatImportPath(configPath)}';`,
`import { browser } from 'fumadocs-mdx/runtime/browser';`,
`import type * as Config from '${codegen.formatImportPath(core.getOptions().configPath)}';`,
'',
`const create = fromConfig<typeof Config>();`,
`const create = browser<typeof Config, ${tc}>();`,
);
async function generateCollectionObject(
@@ -331,7 +366,7 @@ async function generateBrowserIndexFile(
codegen.lines.push('const browserCollections = {');
await codegen.pushAsync(
config.collectionList.map(async (collection) => {
core.getConfig().collectionList.map(async (collection) => {
const obj = await generateCollectionObject(collection);
if (!obj) return;
@@ -340,7 +375,6 @@ async function generateBrowserIndexFile(
);
codegen.lines.push('};', 'export default browserCollections;');
return codegen.toString();
}
function generateDocCollectionFrontmatterGlob(
+38 -4
View File
@@ -1,6 +1,7 @@
import path from 'node:path';
import { x } from 'tinyexec';
import type { Plugin } from '@/core';
import { ident } from '@/utils/codegen';
const cache = new Map<string, Promise<Date>>();
@@ -15,24 +16,57 @@ export interface LastModifiedPluginOptions {
* @defaultValue 'git'
*/
versionControl?: 'git';
/**
* Filter the collections to include by names
*/
filter?: (collection: string) => boolean;
}
const ExtendTypes = `{
/**
* Last modified date of document file, obtained from version control.
*
*/
lastModified?: Date;
}`;
/**
* Injects `lastModified` property to page exports.
*/
export default function lastModified(
options: LastModifiedPluginOptions = {},
): Plugin {
const { versionControl = 'git' } = options;
const { versionControl = 'git', filter = () => true } = options;
return {
name: 'last-modified',
'index-file': {
generateTypeConfig() {
const lines: string[] = [];
lines.push('{');
lines.push(' DocData: {');
for (const collection of this.core.getConfig().collectionList) {
if (filter(collection.name)) {
lines.push(ident(`${collection.name}: ${ExtendTypes},`, 2));
}
}
lines.push(' }');
lines.push('}');
return lines.join('\n');
},
serverOptions(options) {
options.doc ??= {};
options.doc.passthroughs ??= [];
options.doc.passthroughs.push('lastModified');
},
},
doc: {
async vfile(file) {
if (!filter(this.collection.name)) return;
if (versionControl === 'git') {
const timestamp = await getGitTimestamp(this.filePath).then((v) =>
v?.getTime(),
);
const timestamp = await getGitTimestamp(this.filePath);
if (timestamp === undefined) return;
file.data['mdx-export'] ??= [];
+41 -30
View File
@@ -1,9 +1,18 @@
import { type ReactNode, type FC, lazy, createElement } from 'react';
import type { CompiledMDXFile } from './server';
import type { StandardSchemaV1 } from '@standard-schema/spec';
import type { DocCollection, DocsCollection } from '@/config/define';
import type { CompiledMDXProperties } from '@/loaders/mdx/build-mdx';
import type { InternalTypeConfig } from './types';
export interface ClientLoaderOptions<Frontmatter, Props> {
type CompiledMDXFile<
Name extends string,
Frontmatter,
TC extends InternalTypeConfig,
> = CompiledMDXProperties<Frontmatter> &
TC['DocData'][Name] &
Record<string, unknown>;
export interface ClientLoaderOptions<Doc, Props> {
/**
* Loader ID (usually your collection name)
*
@@ -15,11 +24,11 @@ export interface ClientLoaderOptions<Frontmatter, Props> {
*/
id?: string;
component: (loaded: CompiledMDXFile<Frontmatter>, props: Props) => ReactNode;
component: (loaded: Doc, props: Props) => ReactNode;
}
export interface ClientLoader<Frontmatter, Props> {
preload: (path: string) => Promise<CompiledMDXFile<Frontmatter>>;
export interface ClientLoader<Doc, Props> {
preload: (path: string) => Promise<Doc>;
/**
* Get a component that renders content with `React.lazy`.
*/
@@ -31,37 +40,39 @@ export interface ClientLoader<Frontmatter, Props> {
useContent: (path: string, props: Props) => ReactNode;
}
export type BrowserCreate<Config> = ReturnType<typeof fromConfig<Config>>;
export type BrowserCreate<Config, TC extends InternalTypeConfig> = ReturnType<
typeof browser<Config, TC>
>;
export interface DocCollectionEntry<
Name extends string = string,
Frontmatter = unknown,
TC extends InternalTypeConfig = InternalTypeConfig,
> {
raw: Record<string, () => Promise<CompiledMDXFile<Name, Frontmatter, TC>>>;
export interface DocCollectionEntry<Frontmatter> {
raw: Record<string, () => Promise<CompiledMDXFile<Frontmatter>>>;
createClientLoader: <Props extends object>(
options: ClientLoaderOptions<Frontmatter, Props>,
) => ClientLoader<Frontmatter, Props>;
options: ClientLoaderOptions<CompiledMDXFile<Name, Frontmatter, TC>, Props>,
) => ClientLoader<CompiledMDXFile<Name, Frontmatter, TC>, Props>;
}
export function fromConfig<Config>() {
export function browser<Config, TC extends InternalTypeConfig>() {
return {
doc<Name extends keyof Config>(
doc<Name extends keyof Config & string>(
_name: Name,
glob: Record<string, () => Promise<unknown>>,
) {
const raw = glob as Record<
string,
() => Promise<CompiledMDXFile<unknown>>
>;
const out: DocCollectionEntry<unknown> = {
raw,
const out: DocCollectionEntry = {
raw: glob as DocCollectionEntry['raw'],
createClientLoader({ id = _name as string, ...options }) {
return createClientLoader(raw, { id, ...options });
return createClientLoader(this.raw, { id, ...options });
},
};
return out as Config[Name] extends
| DocCollection<infer Schema>
| DocsCollection<infer Schema>
? DocCollectionEntry<StandardSchemaV1.InferOutput<Schema>>
? DocCollectionEntry<Name, StandardSchemaV1.InferOutput<Schema>, TC>
: never;
},
};
@@ -70,20 +81,20 @@ export function fromConfig<Config>() {
const loaderStore = new Map<
string,
{
preloaded: Map<string, CompiledMDXFile<any>>;
preloaded: Map<string, CompiledMDXProperties>;
}
>();
export function createClientLoader<Frontmatter, Props extends object = object>(
globEntries: Record<string, () => Promise<CompiledMDXFile<Frontmatter>>>,
options: ClientLoaderOptions<Frontmatter, Props>,
): ClientLoader<Frontmatter, Props> {
export function createClientLoader<
Doc = CompiledMDXProperties,
Props extends object = object,
>(
globEntries: Record<string, () => Promise<Doc>>,
options: ClientLoaderOptions<Doc, Props>,
): ClientLoader<Doc, Props> {
const { id = '', component } = options;
const renderers: Record<string, FC<Props>> = {};
const loaders = new Map<
string,
() => Promise<CompiledMDXFile<Frontmatter>>
>();
const loaders = new Map<string, () => Promise<Doc>>();
const store = loaderStore.get(id) ?? {
preloaded: new Map(),
};
+13 -9
View File
@@ -4,8 +4,9 @@ import { executeMdx } from '@fumadocs/mdx-remote/client';
import { pathToFileURL } from 'node:url';
import { fumaMatter } from '@/utils/fuma-matter';
import fs from 'node:fs/promises';
import { type FileInfo, fromConfig } from './server';
import { server, type ServerOptions } from './server';
import { type CoreOptions, createCore } from '@/core';
import type { FileInfo, InternalTypeConfig } from './types';
export interface LazyEntry<Data = unknown> {
info: FileInfo;
@@ -14,19 +15,22 @@ export interface LazyEntry<Data = unknown> {
hash?: string;
}
export type CreateDynamic<Config> = ReturnType<
typeof fromConfigDynamic<Config>
>;
export type CreateDynamic<
Config,
TC extends InternalTypeConfig = InternalTypeConfig,
> = ReturnType<typeof dynamic<Config, TC>>;
export async function fromConfigDynamic<Config>(
export async function dynamic<Config, TC extends InternalTypeConfig>(
configExports: Config,
coreOptions: CoreOptions,
serverOptions?: ServerOptions,
) {
const core = await createCore(coreOptions).init({
const core = createCore(coreOptions);
await core.init({
config: buildConfig(configExports as Record<string, unknown>),
});
const create = fromConfig<Config>();
const create = server<Config, TC>(serverOptions);
function getDocCollection(name: string): DocCollectionItem | undefined {
const collection = core.getConfig().getCollection(name);
@@ -70,7 +74,7 @@ export async function fromConfigDynamic<Config>(
}
return {
async doc<Name extends keyof Config>(
async doc<Name extends keyof Config & string>(
name: Name,
base: string,
entries: LazyEntry<unknown>[],
@@ -83,7 +87,7 @@ export async function fromConfigDynamic<Config>(
return create.docLazy(name, base, head, body);
},
async docs<Name extends keyof Config>(
async docs<Name extends keyof Config & string>(
name: Name,
base: string,
meta: Record<string, unknown>,
+81 -115
View File
@@ -5,127 +5,80 @@ import type {
VirtualFile,
} from 'fumadocs-core/source';
import * as path from 'node:path';
import type { StructuredData } from 'fumadocs-core/mdx-plugins';
import type { TOCItemType } from 'fumadocs-core/toc';
import type { MDXContent } from 'mdx/types';
import type { ExtractedReference } from '@/loaders/mdx/remark-postprocess';
import type { Root } from 'mdast';
import type { DocCollection, DocsCollection, MetaCollection } from '@/config';
import type { StandardSchemaV1 } from '@standard-schema/spec';
import type { CompiledMDXProperties } from '@/loaders/mdx/build-mdx';
export interface FileInfo {
/**
* virtualized path for Source API
*/
path: string;
/**
* the file path in file system
*/
fullPath: string;
}
export interface DocData {
/**
* Compiled MDX content (as component)
*/
body: MDXContent;
/**
* table of contents generated from content.
*/
toc: TOCItemType[];
/**
* structured data for document search indexing.
*/
structuredData: StructuredData;
/**
* Raw exports from the compiled MDX file.
*/
_exports: Record<string, unknown>;
/**
* Last modified date of document file, obtained from version control.
*
* Added by the `last-modified` plugin.
*/
lastModified?: Date;
/**
* extracted references (e.g. hrefs, paths), useful for analyzing relationships between pages.
*/
extractedReferences?: ExtractedReference[];
}
export interface DocMethods {
/**
* file info
*/
info: FileInfo;
/**
* get document as text.
*
* - `type: raw` - read the original content from file system.
* - `type: processed` - get the processed Markdown content, only available when `includeProcessedMarkdown` is enabled on collection config.
*/
getText: (type: 'raw' | 'processed') => Promise<string>;
getMDAST: () => Promise<Root>;
}
export interface MetaMethods {
/**
* file info
*/
info: FileInfo;
}
import type {
InternalTypeConfig,
DocData,
DocMethods,
FileInfo,
MetaMethods,
} from './types';
export type MetaCollectionEntry<Data> = Data & MetaMethods;
export type DocCollectionEntry<Frontmatter> = DocData &
DocMethods &
export type DocCollectionEntry<
Name extends string = string,
Frontmatter = unknown,
TC extends InternalTypeConfig = InternalTypeConfig,
> = DocData & DocMethods & Frontmatter & TC['DocData'][Name];
export type AsyncDocCollectionEntry<
Name extends string = string,
Frontmatter = unknown,
TC extends InternalTypeConfig = InternalTypeConfig,
> = {
load: () => Promise<DocData & TC['DocData'][Name]>;
} & DocMethods &
Frontmatter;
export type AsyncDocCollectionEntry<Frontmatter> = DocMethods & {
load: () => Promise<DocData>;
} & Frontmatter;
export type CompiledMDXFile<Frontmatter> = CompiledMDXProperties<Frontmatter> &
Record<string, unknown>;
export interface DocsCollectionEntry<
Frontmatter extends PageData,
Meta extends MetaData,
Name extends string = string,
Frontmatter extends PageData = PageData,
Meta extends MetaData = MetaData,
TC extends InternalTypeConfig = InternalTypeConfig,
> {
docs: DocCollectionEntry<Frontmatter>[];
docs: DocCollectionEntry<Name, Frontmatter, TC>[];
meta: MetaCollectionEntry<Meta>[];
toFumadocsSource: () => Source<{
pageData: DocCollectionEntry<Frontmatter>;
pageData: DocCollectionEntry<Name, Frontmatter, TC>;
metaData: MetaCollectionEntry<Meta>;
}>;
}
export interface AsyncDocsCollectionEntry<
Frontmatter extends PageData,
Meta extends MetaData,
Name extends string = string,
Frontmatter extends PageData = PageData,
Meta extends MetaData = MetaData,
TC extends InternalTypeConfig = InternalTypeConfig,
> {
docs: AsyncDocCollectionEntry<Frontmatter>[];
docs: AsyncDocCollectionEntry<Name, Frontmatter, TC>[];
meta: MetaCollectionEntry<Meta>[];
toFumadocsSource: () => Source<{
pageData: AsyncDocCollectionEntry<Frontmatter>;
pageData: AsyncDocCollectionEntry<Name, Frontmatter, TC>;
metaData: MetaCollectionEntry<Meta>;
}>;
}
type AwaitableGlobEntries<T> = Record<string, T | (() => Promise<T>)>;
export type ServerCreate<Config> = ReturnType<typeof fromConfig<Config>>;
export type ServerCreate<
Config,
TC extends InternalTypeConfig = InternalTypeConfig,
> = ReturnType<typeof server<Config, TC>>;
export interface ServerOptions {
doc?: {
passthroughs?: string[];
};
}
export function server<Config, TC extends InternalTypeConfig>(
options: ServerOptions = {},
) {
const { doc: { passthroughs: docPassthroughs = [] } = {} } = options;
export function fromConfig<Config>() {
function fileInfo(file: string, base: string): FileInfo {
if (file.startsWith('./')) {
file = file.slice(2);
@@ -137,43 +90,48 @@ export function fromConfig<Config>() {
};
}
function mapDocData(entry: CompiledMDXFile<any>): DocData {
return {
function mapDocData(entry: CompiledMDXProperties): DocData {
const data: DocData = {
body: entry.default,
toc: entry.toc,
extractedReferences: entry.extractedReferences,
structuredData: entry.structuredData,
lastModified: entry.lastModified,
_exports: entry,
_exports: entry as unknown as Record<string, unknown>,
};
for (const key of docPassthroughs) {
// @ts-expect-error -- handle passthrough properties
data[key] = entry[key];
}
return data;
}
return {
async doc<Name extends keyof Config>(
async doc<Name extends keyof Config & string>(
_name: Name,
base: string,
glob: AwaitableGlobEntries<unknown>,
) {
const out = await Promise.all(
Object.entries(glob).map(async ([k, v]) => {
const data: CompiledMDXFile<unknown> =
const data: CompiledMDXProperties =
typeof v === 'function' ? await v() : v;
return {
...mapDocData(data),
...(data.frontmatter as object),
...createDocMethods(fileInfo(k, base), () => data),
} satisfies DocCollectionEntry<unknown>;
} satisfies DocCollectionEntry;
}),
);
return out as unknown as Config[Name] extends
| DocCollection<infer Schema>
| DocsCollection<infer Schema>
? DocCollectionEntry<StandardSchemaV1.InferOutput<Schema>>[]
? DocCollectionEntry<Name, StandardSchemaV1.InferOutput<Schema>, TC>[]
: never;
},
async docLazy<Name extends keyof Config>(
async docLazy<Name extends keyof Config & string>(
_name: Name,
base: string,
head: AwaitableGlobEntries<unknown>,
@@ -182,7 +140,7 @@ export function fromConfig<Config>() {
const out = await Promise.all(
Object.entries(head).map(async ([k, v]) => {
const data = typeof v === 'function' ? await v() : v;
const content = body[k] as () => Promise<CompiledMDXFile<unknown>>;
const content = body[k] as () => Promise<CompiledMDXProperties>;
return {
...data,
@@ -190,17 +148,21 @@ export function fromConfig<Config>() {
async load() {
return mapDocData(await content());
},
} satisfies AsyncDocCollectionEntry<unknown>;
} satisfies AsyncDocCollectionEntry;
}),
);
return out as unknown as Config[Name] extends
| DocCollection<infer Schema>
| DocsCollection<infer Schema>
? AsyncDocCollectionEntry<StandardSchemaV1.InferOutput<Schema>>[]
? AsyncDocCollectionEntry<
Name,
StandardSchemaV1.InferOutput<Schema>,
TC
>[]
: never;
},
async meta<Name extends keyof Config>(
async meta<Name extends keyof Config & string>(
_name: Name,
base: string,
glob: AwaitableGlobEntries<unknown>,
@@ -223,7 +185,7 @@ export function fromConfig<Config>() {
: never;
},
async docs<Name extends keyof Config>(
async docs<Name extends keyof Config & string>(
name: Name,
base: string,
metaGlob: AwaitableGlobEntries<unknown>,
@@ -235,7 +197,7 @@ export function fromConfig<Config>() {
toFumadocsSource() {
return toFumadocsSource(this.docs, this.meta);
},
} satisfies DocsCollectionEntry<PageData, MetaData>;
} satisfies DocsCollectionEntry;
return entry as Config[Name] extends DocsCollection<
infer Page,
@@ -244,14 +206,16 @@ export function fromConfig<Config>() {
? StandardSchemaV1.InferOutput<Page> extends PageData
? StandardSchemaV1.InferOutput<Meta> extends MetaData
? DocsCollectionEntry<
Name,
StandardSchemaV1.InferOutput<Page>,
StandardSchemaV1.InferOutput<Meta>
StandardSchemaV1.InferOutput<Meta>,
TC
>
: never
: never
: never;
},
async docsLazy<Name extends keyof Config>(
async docsLazy<Name extends keyof Config & string>(
name: Name,
base: string,
metaGlob: AwaitableGlobEntries<unknown>,
@@ -264,7 +228,7 @@ export function fromConfig<Config>() {
toFumadocsSource() {
return toFumadocsSource(this.docs, this.meta);
},
} satisfies AsyncDocsCollectionEntry<PageData, MetaData>;
} satisfies AsyncDocsCollectionEntry;
return entry as Config[Name] extends DocsCollection<
infer Page,
@@ -273,8 +237,10 @@ export function fromConfig<Config>() {
? StandardSchemaV1.InferOutput<Page> extends PageData
? StandardSchemaV1.InferOutput<Meta> extends MetaData
? AsyncDocsCollectionEntry<
Name,
StandardSchemaV1.InferOutput<Page>,
StandardSchemaV1.InferOutput<Meta>
StandardSchemaV1.InferOutput<Meta>,
TC
>
: never
: never
@@ -323,7 +289,7 @@ export function toFumadocsSource<
function createDocMethods(
info: FileInfo,
load: () => CompiledMDXProperties<any> | Promise<CompiledMDXProperties<any>>,
load: () => CompiledMDXProperties | Promise<CompiledMDXProperties>,
): DocMethods {
return {
info,
+69
View File
@@ -0,0 +1,69 @@
import type { StructuredData } from 'fumadocs-core/mdx-plugins/remark-structure';
import type { TOCItemType } from 'fumadocs-core/toc';
import type { Root } from 'mdast';
import type { MDXContent } from 'mdx/types';
export interface DocData {
/**
* Compiled MDX content (as component)
*/
body: MDXContent;
/**
* table of contents generated from content.
*/
toc: TOCItemType[];
/**
* structured data for document search indexing.
*/
structuredData: StructuredData;
/**
* Raw exports from the compiled MDX file.
*/
_exports: Record<string, unknown>;
}
export interface FileInfo {
/**
* virtualized path for Source API
*/
path: string;
/**
* the file path in file system
*/
fullPath: string;
}
export interface DocMethods {
/**
* file info
*/
info: FileInfo;
/**
* get document as text.
*
* - `type: raw` - read the original content from file system.
* - `type: processed` - get the processed Markdown content, only available when `includeProcessedMarkdown` is enabled on collection config.
*/
getText: (type: 'raw' | 'processed') => Promise<string>;
getMDAST: () => Promise<Root>;
}
export interface MetaMethods {
/**
* file info
*/
info: FileInfo;
}
export interface InternalTypeConfig {
/**
* collection name -> collection properties
*/
DocData: Record<string, unknown>;
}
@@ -17,7 +17,7 @@ function toFullPath(file: string) {
return file;
}
export async function readFileWithCache(file: string): Promise<string> {
export function readFileWithCache(file: string): Promise<string> {
const fullPath = toFullPath(file);
const cached = map.get(fullPath);
if (cached) return cached;
+3 -2
View File
@@ -44,9 +44,11 @@ export default async function mdx(
pluginOptions: PluginOptions = {},
): Promise<Plugin> {
const options = applyDefaults(pluginOptions);
const core = await createViteCore(options).init({
const core = createViteCore(options);
await core.init({
config: buildConfig(config),
});
const configLoader = createIntegratedConfigLoader(core);
const mdxLoader = toVite(createMdxLoader(configLoader));
const metaLoader = toVite(
@@ -104,7 +106,6 @@ export default async function mdx(
export async function postInstall(pluginOptions: PluginOptions = {}) {
const { loadConfig } = await import('@/config/load-from-file');
const core = createViteCore(applyDefaults(pluginOptions));
await core.init({
config: loadConfig(core, true),
});
+15 -6
View File
@@ -2,28 +2,37 @@
// @ts-nocheck
import { frontmatter as __fd_glob_1 } from "./generate-index/folder/test.mdx?collection=docs&only=frontmatter"
import { frontmatter as __fd_glob_0 } from "./generate-index/index.mdx?collection=docs&only=frontmatter"
import { fromConfig } from 'fumadocs-mdx/runtime/server';
import { server } from 'fumadocs-mdx/runtime/server';
import type * as Config from './config';
const create = fromConfig<typeof Config>();
const create = server<typeof Config, {
DocData: {
}
}>({"doc":{"passthroughs":["extractedReferences"]}});
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 { dynamic } from 'fumadocs-mdx/runtime/dynamic';
import * as Config from './config';
const create = await fromConfigDynamic(Config);
const create = await dynamic<typeof Config, {
DocData: {
}
}>(Config, {"configPath":"packages/mdx/test/fixtures/config.ts","environment":"test","outDir":"packages/mdx/test/fixtures"}, {"doc":{"passthroughs":["extractedReferences"]}});
```
```ts title="browser.ts"
// @ts-nocheck
import { fromConfig } from 'fumadocs-mdx/runtime/browser';
import { browser } from 'fumadocs-mdx/runtime/browser';
import type * as Config from './config';
const create = fromConfig<typeof Config>();
const create = browser<typeof Config, {
DocData: {
}
}>();
const browserCollections = {
docs: create.doc("docs", {"index.mdx": () => import("./generate-index/index.mdx?collection=docs"), "folder/test.mdx": () => import("./generate-index/folder/test.mdx?collection=docs"), }),
};
+15 -6
View File
@@ -1,28 +1,37 @@
```ts title="server.ts"
// @ts-nocheck
import { default as __fd_glob_0 } from "./generate-index/meta.json?collection=docs"
import { fromConfig } from 'fumadocs-mdx/runtime/server';
import { server } from 'fumadocs-mdx/runtime/server';
import type * as Config from './config';
const create = fromConfig<typeof Config>();
const create = server<typeof Config, {
DocData: {
}
}>({"doc":{"passthroughs":["extractedReferences"]}});
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 { dynamic } from 'fumadocs-mdx/runtime/dynamic';
import * as Config from './config';
const create = await fromConfigDynamic(Config);
const create = await dynamic<typeof Config, {
DocData: {
}
}>(Config, {"configPath":"packages/mdx/test/fixtures/config.ts","environment":"test","outDir":"packages/mdx/test/fixtures"}, {"doc":{"passthroughs":["extractedReferences"]}});
```
```ts title="browser.ts"
// @ts-nocheck
import { fromConfig } from 'fumadocs-mdx/runtime/browser';
import { browser } from 'fumadocs-mdx/runtime/browser';
import type * as Config from './config';
const create = fromConfig<typeof Config>();
const create = browser<typeof Config, {
DocData: {
}
}>();
const browserCollections = {
};
export default browserCollections;
+15 -6
View File
@@ -2,28 +2,37 @@
// @ts-nocheck
import * as __fd_glob_1 from "./generate-index/folder/test.mdx?collection=docs"
import * as __fd_glob_0 from "./generate-index/index.mdx?collection=docs"
import { fromConfig } from 'fumadocs-mdx/runtime/server';
import { server } from 'fumadocs-mdx/runtime/server';
import type * as Config from './config';
const create = fromConfig<typeof Config>();
const create = server<typeof Config, {
DocData: {
}
}>({"doc":{"passthroughs":["extractedReferences"]}});
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 { dynamic } from 'fumadocs-mdx/runtime/dynamic';
import * as Config from './config';
const create = await fromConfigDynamic(Config);
const create = await dynamic<typeof Config, {
DocData: {
}
}>(Config, {"configPath":"packages/mdx/test/fixtures/config.ts","environment":"test","outDir":"packages/mdx/test/fixtures"}, {"doc":{"passthroughs":["extractedReferences"]}});
```
```ts title="browser.ts"
// @ts-nocheck
import { fromConfig } from 'fumadocs-mdx/runtime/browser';
import { browser } from 'fumadocs-mdx/runtime/browser';
import type * as Config from './config';
const create = fromConfig<typeof Config>();
const create = browser<typeof Config, {
DocData: {
}
}>();
const browserCollections = {
docs: create.doc("docs", {"index.mdx": () => import("./generate-index/index.mdx?collection=docs"), "folder/test.mdx": () => import("./generate-index/folder/test.mdx?collection=docs"), }),
};