Core & UI: Introduce page tree fallback API

This commit is contained in:
Fuma Nama
2025-08-20 18:24:53 +08:00
parent 0531bf422f
commit e785f98aaf
13 changed files with 142 additions and 35 deletions
+33
View File
@@ -0,0 +1,33 @@
---
'fumadocs-core': minor
'fumadocs-ui': minor
---
**Introduce page tree `fallback` API**
Page tree is a tree structure.
Previously, when an item is excluded from page tree, it is isolated entirely that you cannot display it at all.
With the new fallback API, isolated pages will go into `fallback` page tree instead:
```json
{
"children": [
{
"type": "page",
"name": "Introduction"
}
],
"fallback": {
"children": [
{
"type": "page",
"name": "Hidden Page"
}
]
}
}
```
Items in `fallback` are invisible unless you've opened its item.
+1 -1
View File
@@ -1,3 +1,3 @@
{
"pages": ["ui", "headless", "mdx", "cli", "openapi"]
"pages": ["ui", "headless", "mdx", "cli"]
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { type ReactNode, useMemo } from 'react';
import type * as PageTree from '@/server/page-tree';
import type * as PageTree from '@/source/page-tree/definitions';
export interface BreadcrumbItem {
name: ReactNode;
+1 -1
View File
@@ -1,6 +1,6 @@
export * from './get-toc';
export * from '../utils/page-tree';
export * as PageTree from './page-tree';
export * as PageTree from '../source/page-tree/definitions';
export * from './git-api';
export * from './metadata';
+1 -1
View File
@@ -1,4 +1,4 @@
import type * as PageTree from '@/server/page-tree';
import type * as PageTree from '@/source/page-tree/definitions';
import type { I18nConfig } from '@/i18n';
import {
type ContentStorage,
@@ -1,6 +1,6 @@
import type { ReactElement } from 'react';
import type { I18nConfig } from '@/i18n';
import type * as PageTree from '../../server/page-tree';
import type * as PageTree from './definitions';
import { joinPath } from '@/utils/path';
import type { MetaData, PageData, UrlFn } from '../types';
import type { ContentStorage } from '@/source/load-files';
@@ -5,6 +5,10 @@ export interface Root {
name: ReactNode;
children: Node[];
/**
* Another page tree that won't be displayed unless being opened.
*/
fallback?: Root;
}
export type Node = Item | Separator | Folder;
+1 -1
View File
@@ -5,7 +5,7 @@ import type {
PageFile,
PageTreeTransformer,
} from '@/source';
import type * as PageTree from '@/server/page-tree';
import type * as PageTree from './definitions';
export interface LegacyTransformerOptions<
Page extends PageData,
@@ -0,0 +1,44 @@
import type { ContentStorage, PageTreeTransformer } from '@/source';
import { FileSystem } from '@/source';
export function transformerFallback(): PageTreeTransformer {
const addedFiles = new Set<string>();
return {
name: 'fumadocs:fallback',
root(root) {
const isolatedStorage: ContentStorage = new FileSystem();
for (const file of this.storage.getFiles()) {
if (addedFiles.has(file)) continue;
const content =
this.localeStorage?.read(file) ?? this.storage.read(file);
if (!content) continue;
isolatedStorage.write(file, content);
}
if (isolatedStorage.getFiles().length === 0) return root;
root.fallback = this.builder.build({
...this.options,
id: `fallback-${root.$id ?? ''}`,
storage: isolatedStorage,
generateFallback: false,
});
addedFiles.clear();
return root;
},
file(node, file) {
if (file) addedFiles.add(file);
return node;
},
folder(node, _dir, metaPath) {
if (metaPath) addedFiles.add(metaPath);
return node;
},
};
}
+25 -23
View File
@@ -1,21 +1,21 @@
import type * as PageTree from '@/server/page-tree';
import type * as PageTree from '@/source/page-tree/definitions';
/**
* Flatten tree to an array of page nodes
*/
export function flattenTree(tree: PageTree.Node[]): PageTree.Item[] {
return tree.flatMap((node) => {
if (node.type === 'separator') return [];
export function flattenTree(nodes: PageTree.Node[]): PageTree.Item[] {
const out: PageTree.Item[] = [];
for (const node of nodes) {
if (node.type === 'folder') {
const child = flattenTree(node.children);
if (node.index) return [node.index, ...child];
return child;
if (node.index) out.push(node.index);
out.push(...flattenTree(node.children));
} else if (node.type === 'page') {
out.push(node);
}
}
return [node];
});
return out;
}
/**
@@ -33,18 +33,17 @@ export function findNeighbour(
} {
const { separateRoot = true } = options ?? {};
const roots = separateRoot ? getPageTreeRoots(tree) : [tree];
if (tree.fallback) roots.push(tree.fallback);
for (const root of roots) {
const list = flattenTree(root.children);
const idx = list.findIndex((item) => item.url === url);
if (idx === -1) continue;
for (let i = 0; i < list.length; i++) {
if (list[i].url === url) {
return {
next: list[i + 1],
previous: list[i - 1],
};
}
}
return {
previous: list[idx - 1],
next: list[idx + 1],
};
}
return {};
@@ -57,10 +56,7 @@ export function getPageTreeRoots(
if (child.type !== 'folder') return [];
const roots = getPageTreeRoots(child);
if (child.root) {
roots.push(child);
}
if (child.root) roots.push(child);
return roots;
});
@@ -70,6 +66,8 @@ export function getPageTreeRoots(
/**
* Separate the folder nodes of a root into multiple roots
*
* @deprecated it's useless
*/
export function separatePageTree(pageTree: PageTree.Root): PageTree.Root[] {
return pageTree.children.flatMap((child) => {
@@ -116,4 +114,8 @@ function findParentFromTree(
return node;
}
}
if ('fallback' in node && node.fallback) {
return findParentFromTree(node.fallback, url);
}
}
+12
View File
@@ -59,5 +59,17 @@
"url": "https://google.com",
},
],
"fallback": {
"$id": "fallback-root",
"children": [
{
"$id": "hidden.mdx",
"name": "Hidden",
"type": "page",
"url": "/hidden",
},
],
"name": "",
},
"name": "",
}
@@ -17,6 +17,7 @@ export interface Option {
icon?: ReactNode;
title: ReactNode;
description?: ReactNode;
unlisted?: boolean;
/**
* Detect from a list of urls
@@ -83,7 +84,8 @@ export function RootToggle({
)}
<PopoverContent className="flex flex-col gap-1 w-(--radix-popover-trigger-width) overflow-hidden p-1">
{options.map((item) => {
const isActive = item === selected;
const isActive = selected && item.url === selected.url;
if (!isActive && item.unlisted) return;
return (
<Link
+15 -5
View File
@@ -19,13 +19,16 @@ const defaultTransform: GetSidebarTabsOptions['transform'] = (option, node) => {
};
export function getSidebarTabs(
pageTree: PageTree.Root,
tree: PageTree.Root,
{ transform = defaultTransform }: GetSidebarTabsOptions = {},
): Option[] {
function findOptions(node: PageTree.Folder): Option[] {
function findOptions(
node: PageTree.Root | PageTree.Folder,
unlisted?: boolean,
): Option[] {
const results: Option[] = [];
if (node.root) {
if ('root' in node && node.root) {
const urls = getFolderUrls(node);
if (urls.size > 0) {
@@ -33,6 +36,7 @@ export function getSidebarTabs(
url: urls.values().next().value ?? '',
title: node.name,
icon: node.icon,
unlisted,
description: node.description,
urls,
};
@@ -43,13 +47,19 @@ export function getSidebarTabs(
}
for (const child of node.children) {
if (child.type === 'folder') results.push(...findOptions(child));
if (child.type === 'folder')
results.push(...findOptions(child, unlisted));
}
return results;
}
return findOptions(pageTree as PageTree.Folder);
const options = findOptions(tree);
if (tree.fallback) {
options.push(...findOptions(tree.fallback, true));
}
return options;
}
function getFolderUrls(