Core: Introduce page tree transformer API

This commit is contained in:
Fuma Nama
2025-08-20 18:01:57 +08:00
parent 8aab0c028e
commit 0531bf422f
12 changed files with 305 additions and 107 deletions
+29
View File
@@ -0,0 +1,29 @@
---
'fumadocs-core': minor
---
**Introduce page tree transformer API**
You can now define page tree transformer.
```ts
export const source = loader({
// ...
pageTree: {
transformers: [{
root(root) {
return root;
},
file(node, file) {
return node;
},
folder(node, dir, metaPath) {
return node;
},
separator(node) {
return node;
}
}]
}
});
```
+12 -7
View File
@@ -193,14 +193,19 @@ import { loader } from 'fumadocs-core/source';
loader({
pageTree: {
attachFile(node, file) {
// you can access its file information
console.log(file?.data);
// JSX nodes are allowed
node.name = <>Some JSX Nodes here</>;
transformers: [
{
file(node, file) {
// you can access its file information
if (file) console.log(file, this.storage.read(file));
return node;
},
// JSX nodes are allowed
node.name = <>Some JSX Nodes here</>;
return node;
},
},
],
},
});
```
@@ -42,13 +42,13 @@ export const openapi = createOpenAPI({
```
```ts title="lib/source.ts"
import { attachFile } from 'fumadocs-openapi/server';
import { transformerOpenAPI } from 'fumadocs-openapi/server';
import { loader } from 'fumadocs-core/source';
export const source = loader({
pageTree: {
// [!code ++] adds a badge to each page item in page tree
attachFile,
transformers: [transformerOpenAPI()],
},
});
```
+2 -2
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 { attachFile } from 'fumadocs-openapi/server';
import { transformerOpenAPI } from 'fumadocs-openapi/server';
import { createElement } from 'react';
import { blog as blogPosts, docs } from '@/.source';
@@ -14,7 +14,7 @@ export const source = loader({
},
source: docs.toFumadocsSource(),
pageTree: {
attachFile,
transformers: [transformerOpenAPI()],
},
});
+2 -2
View File
@@ -1,12 +1,12 @@
import { createMDXSource } from 'fumadocs-mdx';
import { loader } from 'fumadocs-core/source';
import { attachFile } from 'fumadocs-openapi/server';
import { transformerOpenAPI } from 'fumadocs-openapi/server';
import { docs, meta } from '@/.source';
export const source = loader({
baseUrl: '/docs',
source: createMDXSource(docs, meta),
pageTree: {
attachFile,
transformers: [transformerOpenAPI()],
},
});
+7 -1
View File
@@ -1,4 +1,10 @@
export * from './page-tree-builder';
export {
createPageTreeBuilder,
type PageTreeTransformer,
type PageTreeBuilder,
type PageTreeBuilderContext,
type BaseOptions,
} from './page-tree/builder';
export * from './loader';
export { FileSystem } from './file-system';
+4 -1
View File
@@ -9,7 +9,10 @@ export interface LoadOptions {
buildFile: (file: VirtualFile) => MetaFile | PageFile;
}
export type ContentStorage = FileSystem<MetaFile | PageFile>;
export type ContentStorage<
Page extends PageData = PageData,
Meta extends MetaData = MetaData,
> = FileSystem<MetaFile<Meta> | PageFile<Page>>;
export interface MetaFile<Data extends MetaData = MetaData> {
path: string;
+3 -8
View File
@@ -8,10 +8,7 @@ import {
type Transformer,
} from './load-files';
import type { MetaData, PageData, UrlFn } from './types';
import {
type BuildPageTreeOptions,
createPageTreeBuilder,
} from './page-tree-builder';
import { type BaseOptions, createPageTreeBuilder } from './page-tree/builder';
import {
basename,
dirname,
@@ -37,7 +34,7 @@ export interface LoaderOptions<
> {
baseUrl: string;
icon?: NonNullable<BuildPageTreeOptions['resolveIcon']>;
icon?: NonNullable<BaseOptions['resolveIcon']>;
slugs?: (info: FileInfo) => string[];
url?: UrlFn;
@@ -47,9 +44,7 @@ export interface LoaderOptions<
/**
* Additional options for page tree builder
*/
pageTree?: Partial<
Omit<BuildPageTreeOptions<T['pageData'], T['metaData']>, 'storage'>
>;
pageTree?: Partial<BaseOptions<T['pageData'], T['metaData']>>;
/**
* Configure i18n
@@ -1,70 +1,106 @@
import type { ReactElement } from 'react';
import type { I18nConfig } from '@/i18n';
import type * as PageTree from '../server/page-tree';
import type * as PageTree from '../../server/page-tree';
import { joinPath } from '@/utils/path';
import type { MetaData, PageData, UrlFn } from './types';
import type { ContentStorage, MetaFile, PageFile } from '@/source/load-files';
import type { MetaData, PageData, UrlFn } from '../types';
import type { ContentStorage } from '@/source/load-files';
import { basename, extname } from '@/source/path';
import {
legacyTransformer,
type LegacyTransformerOptions,
} from '@/source/page-tree/legacy';
import { transformerFallback } from '@/source/page-tree/transformer-fallback';
interface PageTreeBuilderContext {
storage: ContentStorage;
resolveName: (name: string, format: 'meta' | 'page') => string;
locale?: string;
localeStorage?: ContentStorage;
builder: PageTreeBuilder;
options: BaseOptions;
getUrl: UrlFn;
}
interface BaseOptions<
export interface PageTreeBuilderContext<
Page extends PageData = PageData,
Meta extends MetaData = MetaData,
> {
/**
* @internal
*/
resolveName: (name: string, format: 'meta' | 'page') => string;
options: BaseOptions<Page, Meta>;
transformers: PageTreeTransformer<Page, Meta>[];
builder: PageTreeBuilder<Page, Meta>;
storage: ContentStorage<Page, Meta>;
locale?: string;
localeStorage?: ContentStorage<Page, Meta>;
getUrl: UrlFn;
}
export interface PageTreeTransformer<
// eslint-disable-next-line
Page extends PageData = any,
// eslint-disable-next-line
Meta extends MetaData = any,
> {
name?: string;
file?: (
this: PageTreeBuilderContext<Page, Meta>,
node: PageTree.Item,
filePath?: string,
) => PageTree.Item;
folder?: (
this: PageTreeBuilderContext<Page, Meta>,
node: PageTree.Folder,
folderPath: string,
metaPath?: string,
) => PageTree.Folder;
separator?: (
this: PageTreeBuilderContext<Page, Meta>,
node: PageTree.Separator,
) => PageTree.Separator;
root?: (
this: PageTreeBuilderContext<Page, Meta>,
node: PageTree.Root,
) => PageTree.Root;
}
export interface BaseOptions<
Page extends PageData = PageData,
Meta extends MetaData = MetaData,
> extends LegacyTransformerOptions<Page, Meta> {
/**
* Remove references to the file path of original nodes (`$ref`)
*
* @defaultValue false
*/
noRef?: boolean;
attachFile?: (node: PageTree.Item, file?: PageFile<Page>) => PageTree.Item;
attachFolder?: (
node: PageTree.Folder,
folder: {
children: (PageFile<Page> | MetaFile<Meta>)[];
},
meta?: MetaFile<Meta>,
) => PageTree.Folder;
attachSeparator?: (node: PageTree.Separator) => PageTree.Separator;
transformers?: PageTreeTransformer<Page, Meta>[];
resolveIcon?: (icon: string | undefined) => ReactElement | undefined;
}
export interface BuildPageTreeOptions<
export interface PageTreeBuilder<
Page extends PageData = PageData,
Meta extends MetaData = MetaData,
> extends BaseOptions<Page, Meta> {
storage: ContentStorage;
}
> {
build: (
options: BaseOptions<Page, Meta> & {
id?: string;
export interface BuildPageTreeOptionsWithI18n<
Page extends PageData = PageData,
Meta extends MetaData = MetaData,
> extends BaseOptions<Page, Meta> {
storages: Record<string, ContentStorage>;
i18n: I18nConfig;
}
storage: ContentStorage<Page, Meta>;
export interface PageTreeBuilder {
build: (options: BuildPageTreeOptions) => PageTree.Root;
/**
* generate fallback page tree
*
* @defaultValue true
*/
generateFallback?: boolean;
},
) => PageTree.Root;
/**
* Build page tree and fallback to the default language if the localized page doesn't exist
*/
buildI18n: (
options: BuildPageTreeOptionsWithI18n,
options: BaseOptions<Page, Meta> & {
id?: string;
storages: Record<string, ContentStorage<Page, Meta>>;
i18n: I18nConfig;
},
) => Record<string, PageTree.Root>;
}
@@ -109,20 +145,25 @@ function resolveFolderItem(
ctx: PageTreeBuilderContext,
idx: number,
restNodePaths: Set<string>,
): PageTree.Node[] | typeof rest | typeof restReversed {
): PageTree.Node[] | '...' | 'z...a' {
if (item === rest || item === restReversed) return item;
const { options, resolveName } = ctx;
let match = separator.exec(item);
if (match?.groups) {
const node: PageTree.Separator = {
let node: PageTree.Separator = {
$id: `${folderPath}#${idx}`,
type: 'separator',
icon: options.resolveIcon?.(match.groups.icon),
name: match.groups.name,
};
return [options.attachSeparator?.(node) ?? node];
for (const transformer of ctx.transformers) {
if (!transformer.separator) continue;
node = transformer.separator.call(ctx, node);
}
return [node];
}
match = link.exec(item);
@@ -131,7 +172,7 @@ function resolveFolderItem(
const isRelative =
url.startsWith('/') || url.startsWith('#') || url.startsWith('.');
const node: PageTree.Item = {
let node: PageTree.Item = {
type: 'page',
icon: options.resolveIcon?.(icon),
name,
@@ -139,7 +180,12 @@ function resolveFolderItem(
external: !isRelative,
};
return [options.attachFile?.(node) ?? node];
for (const transformer of ctx.transformers) {
if (!transformer.file) continue;
node = transformer.file.call(ctx, node);
}
return [node];
}
const isExcept = item.startsWith(excludePrefix);
@@ -171,7 +217,7 @@ function buildFolderNode(
isGlobalRoot: boolean,
ctx: PageTreeBuilderContext,
): PageTree.Folder | undefined {
const { storage, localeStorage, options, resolveName } = ctx;
const { storage, localeStorage, options, resolveName, transformers } = ctx;
const files = storage.readDir(folderPath);
if (!files) return;
@@ -183,12 +229,15 @@ function buildFolderNode(
meta = undefined;
}
const isRoot = meta?.data.root ?? isGlobalRoot;
let indexDisabled = false;
let indexDisabled = meta?.data.root ?? isGlobalRoot;
let children: PageTree.Node[];
if (!meta?.data.pages) {
children = buildAll(files, ctx, (file) => isRoot || file !== indexPath);
children = buildAll(
files,
ctx,
(file) => indexDisabled || file !== indexPath,
);
} else {
const restItems = new Set<string>(files);
const resolved = meta.data.pages.flatMap<
@@ -196,7 +245,7 @@ function buildFolderNode(
>((item, i) => resolveFolderItem(folderPath, item, ctx, i, restItems));
// disable folder index if it is used in `pages`
if (!isRoot && !restItems.has(indexPath)) {
if (!indexDisabled && !restItems.has(indexPath)) {
indexDisabled = true;
}
@@ -207,8 +256,7 @@ function buildFolderNode(
const items = buildAll(
files,
ctx,
// index files are not included in ... unless it's a root folder
(file) => (file !== indexPath || isRoot) && restItems.has(file),
(file) => (indexDisabled || file !== indexPath) && restItems.has(file),
item === restReversed,
);
@@ -227,7 +275,7 @@ function buildFolderNode(
name = pathToName(group.exec(folderName)?.[1] ?? folderName);
}
const node: PageTree.Folder = {
let node: PageTree.Folder = {
type: 'folder',
name,
icon: options.resolveIcon?.(meta?.data.icon) ?? index?.icon,
@@ -245,28 +293,24 @@ function buildFolderNode(
: undefined,
};
return (
options.attachFolder?.(
node,
{
get children() {
return files.flatMap((file) => storage.read(file) ?? []);
},
},
meta,
) ?? node
);
for (const transformer of transformers) {
if (!transformer.folder) continue;
node = transformer.folder.call(ctx, node, folderPath, metaPath);
}
return node;
}
function buildFileNode(
path: string,
{ options, getUrl, storage, localeStorage, locale }: PageTreeBuilderContext,
ctx: PageTreeBuilderContext,
): PageTree.Item | undefined {
const { options, getUrl, storage, localeStorage, locale, transformers } = ctx;
const page = localeStorage?.read(path) ?? storage.read(path);
if (page?.format !== 'page') return;
const { title, description, icon } = page.data;
const item: PageTree.Item = {
let item: PageTree.Item = {
$id: path,
type: 'page',
name: title ?? pathToName(basename(path, extname(path))),
@@ -280,20 +324,45 @@ function buildFileNode(
: undefined,
};
return options.attachFile?.(item, page) ?? item;
for (const transformer of transformers) {
if (!transformer.file) continue;
item = transformer.file.call(ctx, item, path);
}
return item;
}
function build(ctx: PageTreeBuilderContext): PageTree.Root {
function build(id: string, ctx: PageTreeBuilderContext): PageTree.Root {
const folder = buildFolderNode('', true, ctx)!;
return {
$id: ctx.locale ?? 'root',
let root: PageTree.Root = {
$id: id,
name: folder.name,
children: folder.children,
};
for (const transformer of ctx.transformers) {
if (!transformer.root) continue;
root = transformer.root.call(ctx, root);
}
return root;
}
export function createPageTreeBuilder(getUrl: UrlFn): PageTreeBuilder {
function getTransformers(options: BaseOptions, generateFallback = true) {
const transformers: PageTreeTransformer[] = [legacyTransformer(options)];
if (options.transformers) {
transformers.push(...options.transformers);
}
if (generateFallback) {
transformers.push(transformerFallback());
}
return transformers;
}
function createFlattenPathResolver(storage: ContentStorage) {
const map = new Map<string, string>();
const files = storage.getFiles();
@@ -310,28 +379,31 @@ export function createPageTreeBuilder(getUrl: UrlFn): PageTreeBuilder {
}
return {
build(options) {
const resolve = createFlattenPathResolver(options.storage);
build({ storage, id, generateFallback, ...options }) {
const resolve = createFlattenPathResolver(storage);
return build({
return build(id ?? 'root', {
transformers: getTransformers(options, generateFallback),
options,
builder: this,
storage: options.storage,
storage,
getUrl,
resolveName(name, format) {
return resolve(name, format) ?? name;
},
});
},
buildI18n({ i18n, ...options }) {
buildI18n({ id, i18n, ...options }) {
const storage = options.storages[i18n.defaultLanguage];
const resolve = createFlattenPathResolver(storage);
const transformers = getTransformers(options);
const entries = i18n.languages.map<[string, PageTree.Root]>((lang) => {
const tree = build({
const tree = build(id ?? lang ?? 'root', {
transformers,
builder: this,
options,
getUrl,
builder: this,
locale: lang,
storage,
localeStorage: options.storages[lang],
@@ -0,0 +1,74 @@
import type {
MetaData,
MetaFile,
PageData,
PageFile,
PageTreeTransformer,
} from '@/source';
import type * as PageTree from '@/server/page-tree';
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.localeStorage?.read(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.localeStorage?.read(metaPath) ?? this.storage.read(metaPath))
: undefined;
return transformer.attachFolder(
node,
{
children: files.flatMap(
(file) =>
this.localeStorage?.read(file) ?? this.storage.read(file) ?? [],
),
},
meta?.format === 'meta' ? meta : undefined,
);
},
separator(node) {
if (!transformer.attachSeparator) return node;
return transformer.attachSeparator(node);
},
};
}
+12 -1
View File
@@ -1,6 +1,6 @@
import { MethodLabel } from '@/ui/components/method-label';
import type { PageTree } from 'fumadocs-core/server';
import type { PageFile } from 'fumadocs-core/source';
import type { PageFile, PageTreeTransformer } from 'fumadocs-core/source';
/**
* Source API Integration
@@ -39,3 +39,14 @@ export const attachFile = (
return node;
};
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);
},
};
}
+7 -4
View File
@@ -24,10 +24,13 @@ export function TreeContextProvider(props: {
// using the id here to make sure this never happens
// eslint-disable-next-line react-hooks/exhaustive-deps
const tree = useMemo(() => props.tree, [props.tree.$id ?? props.tree]);
const path = useMemo(
() => searchPath(tree.children, pathname) ?? [],
[tree, pathname],
);
const path = useMemo(() => {
let result = searchPath(tree.children, pathname);
if (result) return result;
if (tree.fallback) result = searchPath(tree.fallback.children, pathname);
return result ?? [];
}, [tree, pathname]);
const root =
path.findLast((item) => item.type === 'folder' && item.root) ?? tree;