Core & OpenAPI: Publish plugins API on loader(), deprecate storage & page tree transformers

This commit is contained in:
Fuma Nama
2025-09-26 23:35:14 +08:00
parent eaf06c4570
commit f04547f503
15 changed files with 322 additions and 198 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'fumadocs-openapi': patch
'fumadocs-core': patch
---
Publish `plugins` API on `loader()`, deprecate storage & page tree transformers
+3 -5
View File
@@ -46,14 +46,12 @@ export const openapi = createOpenAPI({
```
```ts title="lib/source.ts"
import { transformerOpenAPI } from 'fumadocs-openapi/server';
import { openapiPlugin } from 'fumadocs-openapi/server';
import { loader } from 'fumadocs-core/source';
export const source = loader({
// [!code ++:3] adds a badge to each page item in page tree
pageTree: {
transformers: [transformerOpenAPI()],
},
// [!code ++] adds a badge to each page item in page tree
plugins: [openapiPlugin()],
});
```
+2 -4
View File
@@ -2,7 +2,7 @@ import { createMDXSource } from 'fumadocs-mdx';
import type { InferMetaType, InferPageType } from 'fumadocs-core/source';
import { loader } from 'fumadocs-core/source';
import { icons } from 'lucide-react';
import { transformerOpenAPI } from 'fumadocs-openapi/server';
import { openapiPlugin } from 'fumadocs-openapi/server';
import { createElement } from 'react';
import { blog as blogPosts, docs } from '@/.source';
@@ -13,9 +13,7 @@ export const source = loader({
return createElement(icons[icon as keyof typeof icons]);
},
source: docs.toFumadocsSource(),
pageTree: {
transformers: [transformerOpenAPI()],
},
plugins: [openapiPlugin()],
});
export const blog = loader({
+2 -4
View File
@@ -1,12 +1,10 @@
import { createMDXSource } from 'fumadocs-mdx';
import { loader } from 'fumadocs-core/source';
import { transformerOpenAPI } from 'fumadocs-openapi/server';
import { openapiPlugin } from 'fumadocs-openapi/server';
import { docs, meta } from '@/.source';
export const source = loader({
baseUrl: '/docs',
source: createMDXSource(docs, meta),
pageTree: {
transformers: [transformerOpenAPI()],
},
plugins: [openapiPlugin()],
});
+2 -4
View File
@@ -1,11 +1,9 @@
import { loader } from 'fumadocs-core/source';
import { create, docs } from '../../source.generated.js';
import { transformerOpenAPI } from 'fumadocs-openapi/server';
import { openapiPlugin } from 'fumadocs-openapi/server';
export const source = loader({
source: await create.sourceAsync(docs.doc, docs.meta),
baseUrl: '/docs',
pageTree: {
transformers: [transformerOpenAPI()],
},
plugins: [openapiPlugin()],
});
+1
View File
@@ -23,3 +23,4 @@ export {
parseFilePath,
parseFolderPath,
} from './path';
export type * from './plugins';
+10 -11
View File
@@ -3,10 +3,11 @@ import { FileSystem } from './file-system';
import { type VirtualFile } from '@/source/loader';
import { basename, dirname, joinPath, slash, splitPath } from '@/source/path';
import type { I18nConfig } from '@/i18n';
import type { LoaderPlugin } from '@/source/plugins';
export interface LoadOptions {
transformers?: Transformer[];
buildFile: (file: VirtualFile) => MetaFile | PageFile;
plugins?: LoaderPlugin[];
}
export type ContentStorage<
@@ -31,10 +32,7 @@ export interface PageFile<Data extends PageData = PageData> {
data: Data;
}
export type Transformer = (context: {
storage: ContentStorage;
options: LoadOptions;
}) => void;
export type Transformer = (context: { storage: ContentStorage }) => void;
function isLocaleValid(locale: string) {
return locale.length > 0 && !/\d+/.test(locale);
@@ -76,7 +74,7 @@ export function loadFiles(
options: LoadOptions,
i18n: I18nConfig,
): Record<string, ContentStorage> {
const { buildFile, transformers = [] } = options;
const { buildFile, plugins = [] } = options;
const parser = parsers[i18n.parser ?? 'dot'];
const storages: Record<string, ContentStorage> = {};
const normalized = files.map((file) =>
@@ -107,11 +105,12 @@ export function loadFiles(
if (locale === lang) storage.write(path, item);
}
for (const transformer of transformers) {
transformer({
storage,
options,
});
const context = {
storage,
};
for (const plugin of plugins) {
plugin.transformStorage?.(context);
}
storages[lang] = storage;
+35 -63
View File
@@ -5,7 +5,6 @@ import {
loadFiles,
type MetaFile,
type PageFile,
type Transformer,
} from './load-files';
import type { MetaData, PageData, UrlFn } from './types';
import { type BaseOptions, createPageTreeBuilder } from './page-tree/builder';
@@ -18,6 +17,13 @@ import {
parseFilePath,
} from './path';
import { normalizeUrl } from '@/utils/normalize-url';
import { buildPlugins, type LoaderPlugins } from '@/source/plugins';
import { slugsPlugin } from '@/source/plugins/slugs';
import {
compatPlugin,
type LegacyLoaderOptions,
type LegacyPageTreeOptions,
} from '@/source/plugins/compat';
export interface LoaderConfig {
source: SourceConfig;
@@ -29,29 +35,29 @@ export interface SourceConfig {
metaData: MetaData;
}
export interface LoaderOptions<
T extends SourceConfig = SourceConfig,
export type LoaderOptions<
Config extends SourceConfig = SourceConfig,
I18n extends I18nConfig | undefined = I18nConfig | undefined,
> {
baseUrl: string;
icon?: NonNullable<BaseOptions['resolveIcon']>;
slugs?: (info: FileInfo) => string[];
url?: UrlFn;
source: Source<T> | Source<T>[];
// TODO: solve the overlaps between page tree transformers & storage transformers, like a plugin API?
transformers?: Transformer[];
/**
* Additional options for page tree builder
*/
pageTree?: Partial<BaseOptions<T['pageData'], T['metaData']>>;
> = NoInfer<BaseLoaderOptions<Config>> & {
source: Source<Config> | Source<Config>[];
/**
* Configure i18n
*/
i18n?: I18n;
};
interface BaseLoaderOptions<Config extends SourceConfig>
extends LegacyLoaderOptions {
baseUrl: string;
icon?: NonNullable<BaseOptions['resolveIcon']>;
slugs?: (info: FileInfo) => string[];
url?: UrlFn;
/**
* Additional options for page tree builder
*/
pageTree?: Partial<BaseOptions> &
LegacyPageTreeOptions<Config['pageData'], Config['metaData']>;
plugins?: LoaderPlugins<Config['pageData'], Config['metaData']>;
}
export interface Source<Config extends SourceConfig> {
@@ -281,50 +287,19 @@ function createOutput(options: LoaderOptions): LoaderOutput<LoaderConfig> {
console.warn('`loader()` now requires a `baseUrl` option to be defined.');
}
const {
source,
baseUrl = '/',
i18n,
slugs: slugsFn,
url: urlFn,
transformers = [],
} = options;
const { source, baseUrl = '/', i18n, slugs: slugsFn, url: urlFn } = options;
const getUrl: UrlFn = urlFn
? (...args) => normalizeUrl(urlFn(...args))
: createGetUrl(baseUrl, i18n);
const defaultLanguage = i18n?.defaultLanguage ?? '';
const files = loadSource(source);
const transformerSlugs: Transformer = ({ storage }) => {
const indexFiles = new Set<string>();
const taken = new Set<string>();
// for custom slugs function, don't handle conflicting cases like `dir/index.mdx` vs `dir.mdx`
const autoIndex = slugsFn === undefined;
for (const path of storage.getFiles()) {
const file = storage.read(path);
if (!file || file.format !== 'page' || file.slugs) continue;
if (isIndex(path) && autoIndex) {
indexFiles.add(path);
continue;
}
file.slugs = slugsFn ? slugsFn(parseFilePath(path)) : getSlugs(path);
const key = file.slugs.join('/');
if (taken.has(key)) throw new Error('Duplicated slugs');
taken.add(key);
}
for (const path of indexFiles) {
const file = storage.read(path);
if (file?.format !== 'page') continue;
file.slugs = getSlugs(path);
if (taken.has(file.slugs.join('/'))) file.slugs.push('index');
}
};
const plugins = [slugsPlugin(slugsFn)];
if (options.plugins) {
plugins.push(...buildPlugins(options.plugins));
}
if (options.pageTree) {
plugins.push(...compatPlugin(options, options.pageTree));
}
const storages = loadFiles(
files,
@@ -347,7 +322,7 @@ function createOutput(options: LoaderOptions): LoaderOutput<LoaderConfig> {
data: file.data,
} as MetaFile;
},
transformers: [transformerSlugs, ...transformers],
plugins,
},
i18n ?? {
defaultLanguage,
@@ -366,6 +341,7 @@ function createOutput(options: LoaderOptions): LoaderOutput<LoaderConfig> {
pageTree ??= builder.buildI18n({
storages,
resolveIcon: options.icon,
plugins,
...options.pageTree,
});
@@ -500,10 +476,6 @@ function fileToPage<Data = PageData>(
const GroupRegex = /^\(.+\)$/;
function isIndex(file: string) {
return basename(file, extname(file)) === 'index';
}
/**
* Convert file path into slugs, also encode non-ASCII characters, so they can work in pathname
*/
+16 -28
View File
@@ -3,11 +3,8 @@ import type * as PageTree from './definitions';
import type { MetaData, PageData, UrlFn } from '../types';
import type { ContentStorage } from '@/source/load-files';
import { basename, extname, joinPath } from '@/source/path';
import {
legacyTransformer,
type LegacyTransformerOptions,
} from '@/source/page-tree/legacy';
import { transformerFallback } from '@/source/page-tree/transformer-fallback';
import type { LoaderPlugin } from '@/source/plugins';
export interface PageTreeBuilderContext<
Page extends PageData = PageData,
@@ -17,10 +14,10 @@ export interface PageTreeBuilderContext<
* @internal resolve paths without extensions
*/
resolveName: (name: string, format: 'meta' | 'page') => string;
options: BaseOptions<Page, Meta>;
options: BaseOptions;
transformers: PageTreeTransformer<Page, Meta>[];
builder: PageTreeBuilder<Page, Meta>;
builder: PageTreeBuilder;
storage: ContentStorage<Page, Meta>;
getUrl: UrlFn;
@@ -30,10 +27,8 @@ export interface PageTreeBuilderContext<
}
export interface PageTreeTransformer<
// eslint-disable-next-line
Page extends PageData = any,
// eslint-disable-next-line
Meta extends MetaData = any,
Page extends PageData = PageData,
Meta extends MetaData = MetaData,
> {
name?: string;
@@ -58,17 +53,15 @@ export interface PageTreeTransformer<
) => PageTree.Root;
}
export interface BaseOptions<
Page extends PageData = PageData,
Meta extends MetaData = MetaData,
> extends LegacyTransformerOptions<Page, Meta> {
export interface BaseOptions {
id?: string;
/**
* Remove references to the file path of original nodes (`$ref`)
*
* @defaultValue false
*/
noRef?: boolean;
transformers?: PageTreeTransformer<Page, Meta>[];
plugins?: LoaderPlugin[];
resolveIcon?: (icon: string | undefined) => ReactNode | undefined;
/**
* generate fallback page tree
@@ -78,14 +71,10 @@ export interface BaseOptions<
generateFallback?: boolean;
}
export interface PageTreeBuilder<
Page extends PageData = PageData,
Meta extends MetaData = MetaData,
> {
export interface PageTreeBuilder {
build: (
options: BaseOptions<Page, Meta> & {
id?: string;
storage: ContentStorage<Page, Meta>;
options: BaseOptions & {
storage: ContentStorage;
},
) => PageTree.Root;
@@ -93,9 +82,8 @@ export interface PageTreeBuilder<
* Build page tree and fallback to the default language if the localized page doesn't exist
*/
buildI18n: (
options: BaseOptions<Page, Meta> & {
id?: string;
storages: Record<string, ContentStorage<Page, Meta>>;
options: BaseOptions & {
storages: Record<string, ContentStorage>;
},
) => Record<string, PageTree.Root>;
}
@@ -347,10 +335,10 @@ function build(id: string, ctx: PageTreeBuilderContext): PageTree.Root {
export function createPageTreeBuilder(getUrl: UrlFn): PageTreeBuilder {
function getTransformers(options: BaseOptions, generateFallback: boolean) {
const transformers: PageTreeTransformer[] = [legacyTransformer(options)];
const transformers: PageTreeTransformer[] = [];
if (options.transformers) {
transformers.push(...options.transformers);
for (const plugin of options.plugins ?? []) {
if (plugin.transformPageTree) transformers.push(plugin.transformPageTree);
}
if (generateFallback) {
@@ -1,67 +0,0 @@
import type {
MetaData,
MetaFile,
PageData,
PageFile,
PageTreeTransformer,
} from '@/source';
import type * as PageTree from './definitions';
export interface LegacyTransformerOptions<
Page extends PageData,
Meta extends MetaData,
> {
/**
* @deprecated use `transformers` instead
*/
attachFile?: (node: PageTree.Item, file?: PageFile<Page>) => PageTree.Item;
/**
* @deprecated use `transformers` instead
*/
attachFolder?: (
node: PageTree.Folder,
folder: {
children: (PageFile<Page> | MetaFile<Meta>)[];
},
meta?: MetaFile<Meta>,
) => PageTree.Folder;
/**
* @deprecated use `transformers` instead
*/
attachSeparator?: (node: PageTree.Separator) => PageTree.Separator;
}
export function legacyTransformer<Page extends PageData, Meta extends MetaData>(
transformer: LegacyTransformerOptions<Page, Meta>,
): PageTreeTransformer<Page, Meta> {
return {
file(node, file) {
if (!transformer.attachFile) return node;
const content = file ? this.storage.read(file) : undefined;
return transformer.attachFile(
node,
content?.format === 'page' ? content : undefined,
);
},
folder(node, folderPath, metaPath) {
if (!transformer.attachFolder) return node;
const files = this.storage.readDir(folderPath) ?? [];
const meta = metaPath ? this.storage.read(metaPath) : undefined;
return transformer.attachFolder(
node,
{
children: files.flatMap((file) => this.storage.read(file) ?? []),
},
meta?.format === 'meta' ? meta : undefined,
);
},
separator(node) {
if (!transformer.attachSeparator) return node;
return transformer.attachSeparator(node);
},
};
}
+29
View File
@@ -0,0 +1,29 @@
import type { ContentStorage } from '@/source/load-files';
import type { PageTreeTransformer } from '@/source/page-tree/builder';
import type { MetaData, PageData } from '@/source/types';
export type LoaderPlugins<
Page extends PageData,
Meta extends MetaData,
> = LoaderPlugin<Page, Meta>[];
export interface LoaderPlugin<
Page extends PageData = PageData,
Meta extends MetaData = MetaData,
> {
/**
* transform the storage after loading
*/
transformStorage?: (context: { storage: ContentStorage<Page, Meta> }) => void;
/**
* transform the generated page tree
*/
transformPageTree?: PageTreeTransformer<Page, Meta>;
}
export function buildPlugins<Page extends PageData, Meta extends MetaData>(
plugins: LoaderPlugins<Page, Meta>,
): LoaderPlugin<Page, Meta>[] {
return plugins;
}
+118
View File
@@ -0,0 +1,118 @@
import type {
MetaData,
MetaFile,
PageData,
PageFile,
PageTreeTransformer,
Transformer,
} from '@/source';
import type * as PageTree from '../page-tree/definitions';
import type { LoaderPlugin } from '@/source/plugins';
export interface LegacyLoaderOptions {
/**
* @deprecated use `plugins` instead
*/
transformers?: Transformer[];
}
export interface LegacyPageTreeOptions<
Page extends PageData,
Meta extends MetaData,
> {
/**
* @deprecated use `plugins` instead
*/
attachFile?: (node: PageTree.Item, file?: PageFile<Page>) => PageTree.Item;
/**
* @deprecated use `plugins` instead
*/
attachFolder?: (
node: PageTree.Folder,
folder: {
children: (PageFile<Page> | MetaFile<Meta>)[];
},
meta?: MetaFile<Meta>,
) => PageTree.Folder;
/**
* @deprecated use `plugins` instead
*/
attachSeparator?: (node: PageTree.Separator) => PageTree.Separator;
/**
* @deprecated use `plugins` instead.
*/
transformers?: PageTreeTransformer<Page, Meta>[];
}
/**
* legacy features compatibility layer
*/
export function compatPlugin<Page extends PageData, Meta extends MetaData>(
loader: LegacyLoaderOptions,
{
attachFile,
attachSeparator,
attachFolder,
transformers,
}: LegacyPageTreeOptions<Page, Meta>,
) {
const chunk: LoaderPlugin<Page, Meta>[] = [];
chunk.push({
transformPageTree: {
file(node, file) {
if (!attachFile) return node;
const content = file ? this.storage.read(file) : undefined;
return attachFile(
node,
content?.format === 'page' ? content : undefined,
);
},
folder(node, folderPath, metaPath) {
if (!attachFolder) return node;
const files = this.storage.readDir(folderPath) ?? [];
const meta = metaPath ? this.storage.read(metaPath) : undefined;
return attachFolder(
node,
{
children: files.flatMap((file) => this.storage.read(file) ?? []),
},
meta?.format === 'meta' ? meta : undefined,
);
},
separator(node) {
if (!attachSeparator) return node;
return attachSeparator(node);
},
},
});
for (const transformer of loader.transformers ?? []) {
chunk.push(fromStorageTransformer(transformer));
}
for (const transformer of transformers ?? []) {
chunk.push(fromPageTreeTransformer(transformer));
}
return chunk;
}
function fromPageTreeTransformer<Page extends PageData, Meta extends MetaData>(
transformer: PageTreeTransformer<Page, Meta>,
): LoaderPlugin<Page, Meta> {
return {
transformPageTree: transformer,
};
}
function fromStorageTransformer<Page extends PageData, Meta extends MetaData>(
transformer: Transformer,
): LoaderPlugin<Page, Meta> {
return {
transformStorage: transformer,
};
}
+47
View File
@@ -0,0 +1,47 @@
import { type FileInfo, getSlugs, parseFilePath } from '@/source';
import type { LoaderPlugin } from '@/source/plugins';
import { basename, extname } from '@/source/path';
/**
* Generate slugs for pages if missing
*/
export function slugsPlugin(
slugsFn?: (info: FileInfo) => string[],
): LoaderPlugin {
function isIndex(file: string) {
return basename(file, extname(file)) === 'index';
}
return {
transformStorage({ storage }) {
const indexFiles = new Set<string>();
const taken = new Set<string>();
// for custom slugs function, don't handle conflicting cases like `dir/index.mdx` vs `dir.mdx`
const autoIndex = slugsFn === undefined;
for (const path of storage.getFiles()) {
const file = storage.read(path);
if (!file || file.format !== 'page' || file.slugs) continue;
if (isIndex(path) && autoIndex) {
indexFiles.add(path);
continue;
}
file.slugs = slugsFn ? slugsFn(parseFilePath(path)) : getSlugs(path);
const key = file.slugs.join('/');
if (taken.has(key)) throw new Error('Duplicated slugs');
taken.add(key);
}
for (const path of indexFiles) {
const file = storage.read(path);
if (file?.format !== 'page') continue;
file.slugs = getSlugs(path);
if (taken.has(file.slugs.join('/'))) file.slugs.push('index');
}
},
};
}
+50 -11
View File
@@ -1,11 +1,54 @@
import { MethodLabel } from '@/ui/components/method-label';
import type { PageTree } from 'fumadocs-core/server';
import type { PageFile, PageTreeTransformer } from 'fumadocs-core/source';
import type {
LoaderPlugin,
PageFile,
PageTreeTransformer,
} from 'fumadocs-core/source';
/**
* Source API Integration
* Fumadocs Source API integration, pass this to `plugins` array in `loader()`.
*/
export function openapiPlugin(): LoaderPlugin {
return {
transformPageTree: {
file(node, filePath) {
if (!filePath) return node;
const file = this.storage.read(filePath);
if (!file || file.format !== 'page') return node;
const data = file.data;
let method: string | undefined;
if ('_openapi' in data && typeof data._openapi === 'object') {
const meta = data._openapi as {
method?: string;
};
method = meta.method;
}
if (method) {
node.name = (
<>
{node.name}{' '}
<MethodLabel className="ms-auto text-xs text-nowrap">
{method}
</MethodLabel>
</>
);
}
return node;
},
},
};
}
/**
* Source API Integration, add this to page tree builder options.
*
* Add this to page tree builder options
* @deprecated use `openapiPlugin()`
*/
export const attachFile = (
node: PageTree.Item,
@@ -40,13 +83,9 @@ export const attachFile = (
return node;
};
/**
* @deprecated use `openapiPlugin()`
*/
export function transformerOpenAPI(): PageTreeTransformer {
return {
file(node, file) {
if (!file) return node;
const content = this.storage.read(file);
return attachFile(node, content?.format === 'page' ? content : undefined);
},
};
return openapiPlugin().transformPageTree!;
}
+1 -1
View File
@@ -14,7 +14,7 @@ const mapToPackage = {
'utils/use-copy-button.ts': 'fumadocs-ui/utils/use-copy-button',
};
// TODO: in shadcn cli, the order of files matters when writing import paths on consumer's codebase
// in shadcn cli, the order of files matters when writing import paths on consumer's codebase
export const registry: Registry = {
name: 'fumadocs-ui',
dir: srcDir,