Core: handle circular reference in page tree

This commit is contained in:
Fuma Nama
2026-01-29 19:19:11 +08:00
parent 71287e103d
commit 6fd7e635a9
6 changed files with 96 additions and 18 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
---
"fumadocs-core": patch
'fumadocs-core': patch
---
[Page Tree] Extract index page from folder
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
handle circular reference in page tree
+19 -17
View File
@@ -152,19 +152,22 @@ function createFlattenPathResolver(storage: ContentStorage) {
function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
const resolveFlattenPath = createFlattenPathResolver(ctx.storage);
/** virtual file path -> output page tree node (if built) */
/** virtual file path -> output page tree node (if cached) */
const pathToNode = new Map<string, PageTree.Node>();
const nodeOwner = new Map<PageTree.Node, { owner: string; priority: number }>();
/** unfinished nodes */
const unfinished = new WeakSet<PageTree.Node>();
const ownerMap = new Map<PageTree.Node, { owner: string; priority: number }>();
/**
* when a node is referenced by multiple folders via `...`, this determines which owner they should belong to.
*
* @returns whether the owner owns the node.
*/
function registerOwner(ownerPath: string, node: PageTree.Node, priority: number): boolean {
const existing = nodeOwner.get(node);
function own(ownerPath: string, node: PageTree.Node, priority: number): boolean {
if (unfinished.has(node)) return false;
const existing = ownerMap.get(node);
if (!existing) {
nodeOwner.set(node, { owner: ownerPath, priority });
ownerMap.set(node, { owner: ownerPath, priority });
return true;
}
if (existing.owner === ownerPath) {
@@ -189,10 +192,8 @@ function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
}
function transferOwner(ownerPath: string, node: PageTree.Node) {
const existing = nodeOwner.get(node);
if (existing) {
existing.owner = ownerPath;
}
const existing = ownerMap.get(node);
if (existing) existing.owner = ownerPath;
}
let _id = 0;
@@ -292,7 +293,7 @@ function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
if (!node) return;
const children = node.index ? [node.index, ...node.children] : node.children;
if (registerOwner(folderPath, node, 2)) {
if (own(folderPath, node, 2)) {
for (const child of children) {
transferOwner(folderPath, child);
outputArray.push(child);
@@ -300,7 +301,7 @@ function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
excludedPaths.add(path);
} else {
for (const child of children) {
if (registerOwner(folderPath, child, 2)) outputArray.push(child);
if (own(folderPath, child, 2)) outputArray.push(child);
}
}
return;
@@ -312,9 +313,8 @@ function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
path = resolveFlattenPath(path, 'page');
node = this.file(path);
}
if (!node) return;
if (registerOwner(folderPath, node, 2)) outputArray.push(node);
if (!node || !own(folderPath, node, 2)) return;
outputArray.push(node);
excludedPaths.add(path);
},
folder(folderPath: string): PageTree.Folder | undefined {
@@ -348,10 +348,11 @@ function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
: undefined,
};
pathToNode.set(folderPath, node);
unfinished.add(node);
if (!(metadata.root ?? isGlobalRoot)) {
const file = this.file(indexPath);
if (file && registerOwner(folderPath, file, 0)) node.index = file;
if (file && own(folderPath, file, 0)) node.index = file;
}
if (metadata.pages) {
@@ -379,7 +380,7 @@ function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
item === restReversed,
);
for (const child of resolvedItem) {
if (registerOwner(folderPath, child, 0)) node.children.push(child);
if (own(folderPath, child, 0)) node.children.push(child);
}
}
} else {
@@ -387,12 +388,13 @@ function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
files,
node.index ? (file) => file !== indexPath : undefined,
)) {
if (registerOwner(folderPath, item, 0)) node.children.push(item);
if (own(folderPath, item, 0)) node.children.push(item);
}
}
node.icon = metadata.icon ?? node.index?.icon;
node.name = metadata.title ?? node.index?.name;
unfinished.delete(node);
if (!node.name) {
const folderName = basename(folderPath);
node.name = pathToName(group.exec(folderName)?.[1] ?? folderName);
@@ -0,0 +1,32 @@
{
"$id": "root",
"children": [
{
"$id": "root:a",
"children": [
{
"$id": "root:a/page.mdx",
"name": "First Page",
"type": "page",
"url": "/a/page",
},
{
"$id": "root:a/b",
"children": [
{
"$id": "root:a/b/page.mdx",
"name": "Second Page",
"type": "page",
"url": "/a/b/page",
},
],
"name": "B",
"type": "folder",
},
],
"name": "A",
"type": "folder",
},
],
"name": "Docs",
}
+34
View File
@@ -0,0 +1,34 @@
import { Source } from '@/source';
export const source: Source = {
files: [
{
type: 'meta',
path: 'meta.json',
data: {
pages: ['a'],
},
},
{
type: 'meta',
path: 'a/b/meta.json',
data: {
pages: ['../', '...'],
},
},
{
type: 'page',
path: 'a/page.mdx',
data: {
title: 'First Page',
},
},
{
type: 'page',
path: 'a/b/page.mdx',
data: {
title: 'Second Page',
},
},
],
};
+5
View File
@@ -98,6 +98,11 @@ const pageTreeTests: {
},
},
},
{
title: 'Circular Reference',
source: (await import('./fixtures/page-trees/circular')).source,
output: './fixtures/page-trees/circular.test.json',
},
];
for (const pageTreeTest of pageTreeTests) {