Core: define the priority to resolve node owner

This commit is contained in:
Fuma Nama
2026-01-21 18:31:55 +08:00
parent 35ad6bcf3a
commit 002548469e
12 changed files with 443 additions and 231 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
[Page Tree Builder] define the priority to resolve node owner
+103 -65
View File
@@ -8,7 +8,7 @@ import type { SourceConfig } from '../source';
export interface PageTreeBuilderContext<Config extends SourceConfig = SourceConfig> {
rootId: string;
generateNodeId: () => string;
options: PageTreeOptions;
noRef: boolean;
transformers: PageTreeTransformer<Config>[];
builder: PageTreeBuilder;
@@ -109,7 +109,7 @@ export function createPageTreeBuilder(loaderConfig: ResolvedLoaderConfig): PageT
rootId,
transformers,
builder: this,
options,
noRef: options.noRef ?? false,
getUrl: url,
locale,
storage,
@@ -142,7 +142,35 @@ function createFlattenPathResolver(storage: ContentStorage) {
function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
const resolveFlattenPath = createFlattenPathResolver(ctx.storage);
const visitedPaths = new Set<string>();
/** virtual file path -> output page tree node (if built) */
const pathToNode = new Map<string, PageTree.Node>();
const nodeOwner = 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);
if (existing) {
if (existing.owner === ownerPath) {
existing.priority = Math.max(existing.priority, priority);
return true;
}
if (existing.priority >= priority) return false;
// return ownership
const folder = pathToNode.get(existing.owner);
if (folder && folder.type === 'folder') {
if (folder.index === node) delete folder.index;
else folder.children = folder.children.filter((child) => child !== node);
}
}
nodeOwner.set(node, { owner: ownerPath, priority });
return true;
}
function nextNodeId(localId = ctx.generateNodeId()) {
return `${ctx.rootId}:${localId}`;
@@ -170,8 +198,16 @@ function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
items.push(...folders);
return items;
},
resolveFolderItem(folderPath: string, item: string): PageTree.Node[] | '...' | 'z...a' {
if (item === rest || item === restReversed) return item;
resolveFolderItem(
folderPath: string,
item: string,
outputArray: (PageTree.Node | '...' | 'z...a')[],
excludedPaths: Set<string>,
) {
if (item === rest || item === restReversed) {
outputArray.push(item);
return;
}
let match = separator.exec(item);
if (match?.groups) {
@@ -186,8 +222,8 @@ function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
if (!transformer.separator) continue;
node = transformer.separator.call(ctx, node);
}
return [node];
outputArray.push(node);
return;
}
match = link.exec(item);
@@ -207,80 +243,83 @@ function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
if (!transformer.file) continue;
node = transformer.file.call(ctx, node);
}
return [node];
outputArray.push(node);
return;
}
const isExcept = item.startsWith(excludePrefix);
const isExtract = !isExcept && item.startsWith(extractPrefix);
let filename = item;
if (isExcept) {
filename = item.slice(excludePrefix.length);
} else if (isExtract) {
filename = item.slice(extractPrefix.length);
if (item.startsWith(excludePrefix)) {
excludedPaths.add(
resolveFlattenPath(joinPath(folderPath, item.slice(excludePrefix.length)), 'page'),
);
return;
}
const path = resolveFlattenPath(joinPath(folderPath, filename), 'page');
if (item.startsWith(extractPrefix)) {
const path = joinPath(folderPath, item.slice(extractPrefix.length));
const dirNode = this.folder(path, false);
if (!dirNode) return;
if (isExcept) {
visitedPaths.add(path);
return [];
for (const child of dirNode.children) {
if (registerOwner(folderPath, child, 1)) outputArray.push(child);
}
excludedPaths.add(path);
return;
}
const dirNode = this.folder(path, false);
if (dirNode) {
return isExtract ? dirNode.children : [dirNode];
const path = resolveFlattenPath(joinPath(folderPath, item), 'page');
const node = this.folder(path, false) ?? this.file(path);
if (node) {
outputArray.push(node);
excludedPaths.add(path);
}
const fileNode = this.file(path);
return fileNode ? [fileNode] : [];
},
folder(folderPath: string, isGlobalRoot: boolean): PageTree.Folder | undefined {
const { storage, options, transformers } = ctx;
const files = storage.readDir(folderPath);
const cached = pathToNode.get(folderPath);
if (cached) return cached as PageTree.Folder;
const files = ctx.storage.readDir(folderPath);
if (!files) return;
const metaPath = resolveFlattenPath(joinPath(folderPath, 'meta'), 'meta');
const indexPath = resolveFlattenPath(joinPath(folderPath, 'index'), 'page');
let meta = storage.read(metaPath);
let meta = ctx.storage.read(metaPath);
if (meta && meta.format !== 'meta') meta = undefined;
const metadata = meta?.data ?? {};
const { root = isGlobalRoot, pages } = metadata;
let index: PageTree.Item | undefined;
let children: PageTree.Node[];
const children: PageTree.Node[] = [];
if (pages) {
const resolved = pages.flatMap<PageTree.Node | typeof rest | typeof restReversed>((item) =>
this.resolveFolderItem(folderPath, item),
);
if (!(metadata.root ?? isGlobalRoot)) {
const file = this.file(indexPath);
if (file && registerOwner(folderPath, file, 0)) index = file;
}
if (!root && !visitedPaths.has(indexPath)) {
index = this.file(indexPath);
if (metadata.pages) {
const outputArray: (PageTree.Node | typeof rest | typeof restReversed)[] = [];
const excludedPaths = new Set<string>();
for (const item of metadata.pages) {
this.resolveFolderItem(folderPath, item, outputArray, excludedPaths);
}
for (let i = 0; i < resolved.length; i++) {
const item = resolved[i];
if (item !== rest && item !== restReversed) continue;
for (const item of outputArray) {
if (item !== rest && item !== restReversed) {
if (item === index) index = undefined;
if (registerOwner(folderPath, item, 3)) children.push(item);
continue;
}
const items = this.buildPaths(
files.filter((file) => !visitedPaths.has(file)),
const resolvedItem = this.buildPaths(
files.filter((file) => !excludedPaths.has(file)),
item === restReversed,
);
resolved.splice(i, 1, ...items);
break;
for (const child of resolvedItem) {
if (registerOwner(folderPath, child, 0)) children.push(child);
}
}
children = resolved as PageTree.Node[];
} else {
if (!root && !visitedPaths.has(indexPath)) {
index = this.file(indexPath);
for (const item of this.buildPaths(files)) {
if (item !== index && registerOwner(folderPath, item, 0)) children.push(item);
}
children = this.buildPaths(files.filter((file) => !visitedPaths.has(file)));
}
let node: PageTree.Folder = {
@@ -301,26 +340,26 @@ function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
children,
$id: nextNodeId(folderPath),
$ref:
!options.noRef && meta
!ctx.noRef && meta
? {
metaFile: metaPath,
}
: undefined,
};
visitedPaths.add(folderPath);
for (const transformer of transformers) {
for (const transformer of ctx.transformers) {
if (!transformer.folder) continue;
node = transformer.folder.call(ctx, node, folderPath, metaPath);
}
pathToNode.set(folderPath, node);
return node;
},
file(path: string): PageTree.Item | undefined {
const { options, getUrl, storage, locale, transformers } = ctx;
const cached = pathToNode.get(path);
if (cached) return cached as PageTree.Item;
const page = storage.read(path);
if (page?.format !== 'page') return;
const page = ctx.storage.read(path);
if (!page || page.format !== 'page') return;
const { title, description, icon } = page.data;
let item: PageTree.Item = {
@@ -329,20 +368,19 @@ function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
name: title ?? pathToName(basename(path, extname(path))),
description,
icon,
url: getUrl(page.slugs, locale),
$ref: !options.noRef
url: ctx.getUrl(page.slugs, ctx.locale),
$ref: !ctx.noRef
? {
file: path,
}
: undefined,
};
visitedPaths.add(path);
for (const transformer of transformers) {
for (const transformer of ctx.transformers) {
if (!transformer.file) continue;
item = transformer.file.call(ctx, item, path);
}
pathToNode.set(path, item);
return item;
},
root(): PageTree.Root {
@@ -18,7 +18,8 @@ export function transformerFallback(): PageTreeTransformer {
if (isolatedStorage.getFiles().length === 0) return root;
root.fallback = this.builder.build(isolatedStorage, {
...this.options,
noRef: this.noRef,
transformers: this.transformers,
id: `fallback-${root.$id ?? ''}`,
generateFallback: false,
});
@@ -0,0 +1,24 @@
{
"$id": "root",
"children": [
{
"$id": "root:test.mdx",
"name": "Hello",
"type": "page",
"url": "/test",
},
{
"$id": "root:hello",
"children": [],
"index": {
"$id": "root:hello/index.mdx",
"name": "Hello",
"type": "page",
"url": "/hello",
},
"name": "Hello",
"type": "folder",
},
],
"name": "Docs",
}
+19
View File
@@ -18,3 +18,22 @@ export const source: Source = {
},
],
};
export const noMeta: Source = {
files: [
{
type: 'page',
path: 'test.mdx',
data: {
title: 'Hello',
},
},
{
type: 'page',
path: 'hello/index.mdx',
data: {
title: 'Hello',
},
},
],
};
@@ -0,0 +1,52 @@
{
"cn": {
"$id": "cn",
"children": [
{
"$id": "cn:test.mdx",
"name": "Hello Chinese",
"type": "page",
"url": "/cn/test",
},
{
"$id": "cn:nested",
"children": [
{
"$id": "cn:nested/test.mdx",
"name": "Nested Page Chinese",
"type": "page",
"url": "/cn/nested/test",
},
],
"name": "Nested Chinese",
"type": "folder",
},
],
"name": "Docs Chinese",
},
"en": {
"$id": "en",
"children": [
{
"$id": "en:test.mdx",
"name": "Hello",
"type": "page",
"url": "/en/test",
},
{
"$id": "en:nested",
"children": [
{
"$id": "en:nested/test.mdx",
"name": "Nested Page",
"type": "page",
"url": "/en/nested/test",
},
],
"name": "Nested",
"type": "folder",
},
],
"name": "Docs English",
},
}
-15
View File
@@ -4,24 +4,15 @@
"children": [
{
"$id": "cn:test.mdx",
"$ref": {
"file": "test.mdx",
},
"name": "Hello Chinese",
"type": "page",
"url": "/cn/test",
},
{
"$id": "cn:nested",
"$ref": {
"metaFile": "nested/meta.json",
},
"children": [
{
"$id": "cn:nested/test.mdx",
"$ref": {
"file": "nested/test.mdx",
},
"name": "Nested Page Chinese",
"type": "page",
"url": "/cn/nested/test",
@@ -38,9 +29,6 @@
"children": [
{
"$id": "en:test.mdx",
"$ref": {
"file": "test.mdx",
},
"name": "Hello",
"type": "page",
"url": "/en/test",
@@ -50,9 +38,6 @@
"children": [
{
"$id": "en:nested/test.mdx",
"$ref": {
"file": "nested/test.mdx",
},
"name": "Nested Page",
"type": "page",
"url": "/en/nested/test",
@@ -0,0 +1,51 @@
{
"$id": "root",
"children": [
{
"$id": "root:a",
"children": [
{
"$id": "root:a/hello.mdx",
"name": "Page A",
"type": "page",
"url": "/a/hello",
},
],
"name": "A",
"type": "folder",
},
{
"$id": "root:b",
"children": [
{
"$id": "root:b/test.mdx",
"name": "Page B (Test)",
"type": "page",
"url": "/b/test",
},
],
"name": "B",
"type": "folder",
},
{
"$id": "root:c",
"children": [
{
"$id": "root:c/test.mdx",
"name": "Page C",
"type": "page",
"url": "/c/test",
},
{
"$id": "root:b/hello.mdx",
"name": "Page B (Hello)",
"type": "page",
"url": "/b/hello",
},
],
"name": "C",
"type": "folder",
},
],
"name": "Docs",
}
+18
View File
@@ -0,0 +1,18 @@
{
"$id": "root",
"children": [
{
"$id": "root:2-2.mdx",
"name": "2.2",
"type": "page",
"url": "/2-2",
},
{
"$id": "root:1-2.mdx",
"name": "1.2",
"type": "page",
"url": "/1-2",
},
],
"name": "Docs",
}
+78
View File
@@ -0,0 +1,78 @@
import { Source } from '@/source';
export const source: Source = {
files: [
{
type: 'meta',
path: 'meta.json',
data: {
pages: ['z...a'],
},
},
{
type: 'page',
path: '1-2.mdx',
data: {
title: '1.2',
},
},
{
type: 'page',
path: '2-2.mdx',
data: {
title: '2.2',
},
},
],
};
// priority to determine the owner of a node (from top to low):
// 1. directly specified (e.g. path in `pages`).
// 2. extracted (e.g. `...dir`).
// 3. rest `...`.
export const withPriority: Source = {
files: [
{
type: 'page',
path: '/a/hello.mdx',
data: {
title: 'Page A',
},
},
{
type: 'meta',
path: 'b/meta.json',
data: {
pages: ['test', '...'],
},
},
{
type: 'page',
path: '/b/hello.mdx',
data: {
title: 'Page B (Hello)',
},
},
{
type: 'page',
path: '/b/test.mdx',
data: {
title: 'Page B (Test)',
},
},
{
type: 'meta',
path: 'c/meta.json',
data: {
pages: ['test', '...../b'],
},
},
{
type: 'page',
path: '/c/test.mdx',
data: {
title: 'Page C',
},
},
],
};
@@ -14,8 +14,12 @@
<Files>
<Folder name="page-trees">
<File name="basic-no-meta.tree.json" />
<File name="basic.tree.json" />
<File name="i18n-dir.test.json" />
<File name="i18n-dir.tree.json" />
<File name="i18n-no-prefix.tree.json" />
@@ -25,5 +29,9 @@
<File name="i18n.tree.json" />
<File name="nested.tree.json" />
<File name="rest-priority.tree.json" />
<File name="rest.tree.json" />
</Folder>
</Files>
+83 -150
View File
@@ -1,5 +1,5 @@
import { expect, test } from 'vitest';
import { createGetUrl, getSlugs, loader } from '@/source';
import { createGetUrl, getSlugs, loader, LoaderOptions, Source } from '@/source';
import type { ReactElement } from 'react';
import { removeUndefined } from '@/utils/remove-undefined';
import { lucideIconsPlugin } from '@/source/plugins/lucide-icons';
@@ -32,19 +32,94 @@ test('Get URL: Base', () => {
expect(getUrl([''])).toBe('/docs');
});
const pageTreeTests: {
title: string;
output: string;
source: Source;
loader?: Partial<LoaderOptions>;
}[] = [
{
title: 'Basic',
source: (await import('./fixtures/page-trees/basic')).source,
output: './fixtures/page-trees/basic.tree.json',
},
{
title: 'Basic: no meta.json',
source: (await import('./fixtures/page-trees/basic')).noMeta,
output: './fixtures/page-trees/basic-no-meta.tree.json',
},
{
title: 'Rest',
source: (await import('./fixtures/page-trees/rest')).source,
output: './fixtures/page-trees/rest.tree.json',
},
{
title: 'Rest: priority',
source: (await import('./fixtures/page-trees/rest')).withPriority,
output: './fixtures/page-trees/rest-priority.tree.json',
},
{
title: 'Nested Directory',
source: (await import('./fixtures/page-trees/nested')).source,
output: './fixtures/page-trees/nested.tree.json',
},
{
title: 'Internationalized Routing',
source: (await import('./fixtures/page-trees/i18n')).source,
output: './fixtures/page-trees/i18n.tree.json',
loader: {
i18n: {
languages: ['cn', 'en'],
defaultLanguage: 'en',
},
},
},
{
title: 'Internationalized Routing: No Prefix',
source: (await import('./fixtures/page-trees/i18n')).source,
output: './fixtures/page-trees/i18n-no-prefix.tree.json',
loader: {
i18n: {
languages: ['cn', 'en'],
defaultLanguage: 'en',
hideLocale: 'default-locale',
},
},
},
{
title: 'Internationalized Routing: dir',
source: (await import('./fixtures/page-trees/i18n-dir')).source,
output: './fixtures/page-trees/i18n-dir.test.json',
loader: {
i18n: {
parser: 'dir',
languages: ['cn', 'en'],
defaultLanguage: 'en',
},
},
},
];
for (const pageTreeTest of pageTreeTests) {
test(`Page Tree: ${pageTreeTest.title}`, async () => {
const source = loader(pageTreeTest.source, {
baseUrl: '/',
pageTree: {
noRef: true,
},
...pageTreeTest.loader,
});
await expect(removeUndefined(source.pageTree, true)).toMatchFileSnapshot(pageTreeTest.output);
});
}
test('Loader: Simple', async () => {
const result = loader({
baseUrl: '/',
pageTree: {
noRef: true,
},
source: (await import('./fixtures/page-trees/basic')).source,
});
await expect(removeUndefined(result.pageTree, true)).toMatchFileSnapshot(
'./fixtures/page-trees/basic.tree.json',
);
expect(result.getPages().length).toBe(1);
expect(result.getPage(['test'])).toBeDefined();
});
@@ -53,16 +128,9 @@ test('Nested Directories', async () => {
const result = loader({
baseUrl: '/',
icon: (v) => v as unknown as ReactElement,
pageTree: {
noRef: true,
},
source: (await import('./fixtures/page-trees/nested')).source,
});
await expect(removeUndefined(result.pageTree, true)).toMatchFileSnapshot(
'./fixtures/page-trees/nested.tree.json',
);
expect(result.getPages().map((page) => page.slugs.join('/'))).toMatchInlineSnapshot(`
[
"test",
@@ -89,9 +157,6 @@ test('Internationalized Routing', async () => {
source: (await import('./fixtures/page-trees/i18n')).source,
});
await expect(removeUndefined(result.pageTree, true)).toMatchFileSnapshot(
'./fixtures/page-trees/i18n.tree.json',
);
await expect(removeUndefined(result.getLanguages(), true)).toMatchFileSnapshot(
'./fixtures/page-trees/i18n.entries.json',
);
@@ -105,145 +170,13 @@ test('Internationalized Routing: Hide Prefix', async () => {
defaultLanguage: 'en',
hideLocale: 'default-locale',
},
pageTree: {
noRef: true,
},
source: (await import('./fixtures/page-trees/i18n')).source,
});
await expect(removeUndefined(result.pageTree, true)).toMatchFileSnapshot(
'./fixtures/page-trees/i18n-no-prefix.tree.json',
);
expect(result.getPages().length).toBe(4);
expect(result.getPage(['test'])?.url).toBe('/test');
expect(result.getPage(['test'], 'cn')?.url).toBe('/cn/test');
});
test('Internationalized Routing: dir', async () => {
const result = loader({
baseUrl: '/',
i18n: {
parser: 'dir',
languages: ['cn', 'en'],
defaultLanguage: 'en',
},
source: (await import('./fixtures/page-trees/i18n-dir')).source,
});
await expect(removeUndefined(result.pageTree, true)).toMatchFileSnapshot(
'./fixtures/page-trees/i18n-dir.tree.json',
);
});
test('Loader: Without meta.json', () => {
const result = loader({
baseUrl: '/',
pageTree: {
noRef: true,
},
source: {
files: [
{
type: 'page',
path: 'test.mdx',
data: {
title: 'Hello',
},
},
{
type: 'page',
path: 'hello/index.mdx',
data: {
title: 'Hello',
},
},
],
},
});
expect(removeUndefined(result.pageTree, true), 'Page Tree').toMatchInlineSnapshot(`
{
"$id": "root",
"children": [
{
"$id": "root:test.mdx",
"name": "Hello",
"type": "page",
"url": "/test",
},
{
"$id": "root:hello",
"children": [],
"index": {
"$id": "root:hello/index.mdx",
"name": "Hello",
"type": "page",
"url": "/hello",
},
"name": "Hello",
"type": "folder",
},
],
"name": "Docs",
}
`);
});
test('Loader: Rest operator', () => {
const result = loader({
baseUrl: '/',
pageTree: {
noRef: true,
},
source: {
files: [
{
type: 'meta',
path: 'meta.json',
data: {
pages: ['z...a'],
},
},
{
type: 'page',
path: '1-2.mdx',
data: {
title: '1.2',
},
},
{
type: 'page',
path: '2-2.mdx',
data: {
title: '2.2',
},
},
],
},
});
expect(removeUndefined(result.pageTree, true), 'Page Tree').toMatchInlineSnapshot(`
{
"$id": "root",
"children": [
{
"$id": "root:2-2.mdx",
"name": "2.2",
"type": "page",
"url": "/2-2",
},
{
"$id": "root:1-2.mdx",
"name": "1.2",
"type": "page",
"url": "/1-2",
},
],
"name": "Docs",
}
`);
});
test('Loader: Allow duplicate pages when explicitly referenced twice', () => {
const result = loader({
baseUrl: '/',