Core: Fix node IDs are not unique across different locales

This commit is contained in:
Fuma Nama
2025-11-12 20:19:59 +08:00
parent 50d12c88fb
commit ff68f69f80
9 changed files with 333 additions and 333 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
[Page Tree Builder] Fix node IDs are not unique across different locales
+9 -8
View File
@@ -1,8 +1,13 @@
import type { ReactNode } from 'react';
export interface Root {
interface INode {
/**
* ID for the node, unique in all page trees (even across different locales)
*/
$id?: string;
}
export interface Root extends INode {
name: ReactNode;
children: Node[];
/**
@@ -13,8 +18,7 @@ export interface Root {
export type Node = Item | Separator | Folder;
export interface Item {
$id?: string;
export interface Item extends INode {
/**
* @internal
*/
@@ -36,16 +40,13 @@ export interface Item {
icon?: ReactNode;
}
export interface Separator {
$id?: string;
export interface Separator extends INode {
type: 'separator';
name?: ReactNode;
icon?: ReactNode;
}
export interface Folder {
$id?: string;
export interface Folder extends INode {
/**
* @internal
*/
+275 -284
View File
@@ -9,10 +9,8 @@ export interface PageTreeBuilderContext<
Page extends PageData = PageData,
Meta extends MetaData = MetaData,
> {
/**
* @internal resolve paths without extensions
*/
resolveName: (name: string, format: 'meta' | 'page') => string;
rootId: string;
generateNodeId: () => string;
options: PageTreeOptions;
transformers: PageTreeTransformer<Page, Meta>[];
@@ -22,7 +20,6 @@ export interface PageTreeBuilderContext<
storages?: Record<string, ContentStorage<Page, Meta>>;
locale?: string;
visitedPaths: Set<string>;
}
export interface PageTreeTransformer<
@@ -92,295 +89,39 @@ const restReversed = 'z...a' as const;
const extractPrefix = '...';
const excludePrefix = '!';
function buildAll(
paths: string[],
ctx: PageTreeBuilderContext,
reversed = false,
): PageTree.Node[] {
const items: PageTree.Item[] = [];
const folders: PageTree.Folder[] = [];
const sortedPaths = paths.sort(
(a, b) => a.localeCompare(b) * (reversed ? -1 : 1),
);
for (const path of sortedPaths) {
ctx.visitedPaths.add(path);
const fileNode = buildFileNode(path, ctx);
if (fileNode) {
if (basename(path, extname(path)) === 'index') items.unshift(fileNode);
else items.push(fileNode);
continue;
}
const dirNode = buildFolderNode(path, false, ctx);
if (dirNode) folders.push(dirNode);
}
return [...items, ...folders];
}
function resolveFolderItem(
folderPath: string,
item: string,
ctx: PageTreeBuilderContext,
idx: number,
): PageTree.Node[] | '...' | 'z...a' {
if (item === rest || item === restReversed) return item;
const { resolveName } = ctx;
let match = separator.exec(item);
if (match?.groups) {
let node: PageTree.Separator = {
$id: `${folderPath}#${idx}`,
type: 'separator',
icon: match.groups.icon,
name: match.groups.name,
};
for (const transformer of ctx.transformers) {
if (!transformer.separator) continue;
node = transformer.separator.call(ctx, node);
}
return [node];
}
match = link.exec(item);
if (match?.groups) {
const { icon, url, name, external } = match.groups;
let node: PageTree.Item = {
type: 'page',
icon,
name,
url,
external: external ? true : undefined,
};
for (const transformer of ctx.transformers) {
if (!transformer.file) continue;
node = transformer.file.call(ctx, node);
}
return [node];
}
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);
}
const path = resolveName(joinPath(folderPath, filename), 'page');
ctx.visitedPaths.add(path);
if (isExcept) return [];
const dirNode = buildFolderNode(path, false, ctx);
if (dirNode) {
return isExtract ? dirNode.children : [dirNode];
}
const fileNode = buildFileNode(path, ctx);
return fileNode ? [fileNode] : [];
}
function buildFolderNode(
folderPath: string,
isGlobalRoot: boolean,
ctx: PageTreeBuilderContext,
): PageTree.Folder | undefined {
const { storage, options, resolveName, transformers } = ctx;
const files = storage.readDir(folderPath);
if (!files) return;
const metaPath = resolveName(joinPath(folderPath, 'meta'), 'meta');
const indexPath = resolveName(joinPath(folderPath, 'index'), 'page');
let meta = storage.read(metaPath);
if (meta?.format !== 'meta') {
meta = undefined;
}
const isRoot = meta?.data.root ?? isGlobalRoot;
let index: PageTree.Item | undefined;
let children: PageTree.Node[];
function setIndexIfUnused() {
if (isRoot || ctx.visitedPaths.has(indexPath)) return;
ctx.visitedPaths.add(indexPath);
index = buildFileNode(indexPath, ctx);
}
if (meta && meta.data.pages) {
const resolved = meta.data.pages.flatMap<
PageTree.Node | typeof rest | typeof restReversed
>((item, i) => resolveFolderItem(folderPath, item, ctx, i));
setIndexIfUnused();
for (let i = 0; i < resolved.length; i++) {
const item = resolved[i];
if (item !== rest && item !== restReversed) continue;
const items = buildAll(
files.filter((file) => !ctx.visitedPaths.has(file)),
ctx,
item === restReversed,
);
resolved.splice(i, 1, ...items);
break;
}
children = resolved as PageTree.Node[];
} else {
setIndexIfUnused();
children = buildAll(
files.filter((file) => !ctx.visitedPaths.has(file)),
ctx,
);
}
let name = meta?.data.title ?? index?.name;
if (!name) {
const folderName = basename(folderPath);
name = pathToName(group.exec(folderName)?.[1] ?? folderName);
}
let node: PageTree.Folder = {
type: 'folder',
name,
icon: meta?.data.icon ?? index?.icon,
root: meta?.data.root,
defaultOpen: meta?.data.defaultOpen,
description: meta?.data.description,
index,
children,
$id: folderPath,
$ref:
!options.noRef && meta
? {
metaFile: metaPath,
}
: undefined,
};
for (const transformer of transformers) {
if (!transformer.folder) continue;
node = transformer.folder.call(ctx, node, folderPath, metaPath);
}
return node;
}
function buildFileNode(
path: string,
ctx: PageTreeBuilderContext,
): PageTree.Item | undefined {
const { options, getUrl, storage, locale, transformers } = ctx;
const page = storage.read(path);
if (page?.format !== 'page') return;
const { title, description, icon } = page.data;
let item: PageTree.Item = {
$id: path,
type: 'page',
name: title ?? pathToName(basename(path, extname(path))),
description,
icon,
url: getUrl(page.slugs, locale),
$ref: !options.noRef
? {
file: path,
}
: undefined,
};
for (const transformer of transformers) {
if (!transformer.file) continue;
item = transformer.file.call(ctx, item, path);
}
return item;
}
function build(id: string, ctx: PageTreeBuilderContext): PageTree.Root {
const folder = buildFolderNode('', true, ctx)!;
let root: PageTree.Root = {
$id: id,
name: folder.name || 'Docs',
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,
plugins?: LoaderPlugin[],
): PageTreeBuilder {
function getTransformers({
generateFallback = true,
...options
}: PageTreeOptions) {
const transformers: PageTreeTransformer[] = [];
if (options.transformers) {
transformers.push(...options.transformers);
}
for (const plugin of plugins ?? []) {
if (plugin.transformPageTree) transformers.push(plugin.transformPageTree);
}
if (generateFallback) {
transformers.push(transformerFallback());
}
return transformers;
}
function createFlattenPathResolver(storage: ContentStorage) {
const map = new Map<string, string>();
const files = storage.getFiles();
for (const file of files) {
const content = storage.read(file)!;
const flattenPath = file.substring(0, file.length - extname(file).length);
map.set(flattenPath + '.' + content.format, file);
}
return (name: string, format: string) => {
return map.get(name + '.' + format);
};
}
return {
build(storage, options) {
const key = '';
return this.buildI18n({ [key]: storage }, options)[key];
},
buildI18n(storages, options = {}) {
const transformers = getTransformers(options);
let nextId = 0;
const out: Record<string, PageTree.Root> = {};
const transformers: PageTreeTransformer[] = [];
if (options.transformers) {
transformers.push(...options.transformers);
}
for (const plugin of plugins ?? []) {
if (plugin.transformPageTree)
transformers.push(plugin.transformPageTree);
}
if (options.generateFallback ?? true) {
transformers.push(transformerFallback());
}
for (const [locale, storage] of Object.entries(storages)) {
const resolve = createFlattenPathResolver(storage);
const branch = locale.length === 0 ? 'root' : locale;
let rootId = locale.length === 0 ? 'root' : locale;
if (options.id) rootId = `${options.id}-${rootId}`;
out[locale] = build(options.id ? `${options.id}-${branch}` : branch, {
out[locale] = createPageTreeBuilderUtils({
rootId,
transformers,
builder: this,
options,
@@ -388,11 +129,10 @@ export function createPageTreeBuilder(
locale,
storage,
storages,
visitedPaths: new Set<string>(),
resolveName(name, format) {
return resolve(name, format) ?? name;
generateNodeId() {
return '_' + nextId++;
},
});
}).root();
}
return out;
@@ -400,6 +140,257 @@ export function createPageTreeBuilder(
};
}
function createFlattenPathResolver(storage: ContentStorage) {
const map = new Map<string, string>();
const files = storage.getFiles();
for (const file of files) {
const content = storage.read(file)!;
const flattenPath = file.substring(0, file.length - extname(file).length);
map.set(flattenPath + '.' + content.format, file);
}
return (name: string, format: string) => {
return map.get(name + '.' + format) ?? name;
};
}
function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
const resolveFlattenPath = createFlattenPathResolver(ctx.storage);
const visitedPaths = new Set<string>();
function nextNodeId(localId = ctx.generateNodeId()) {
return `${ctx.rootId}:${localId}`;
}
return {
buildPaths(paths: string[], reversed = false): PageTree.Node[] {
const items: PageTree.Item[] = [];
const folders: PageTree.Folder[] = [];
const sortedPaths = paths.sort(
(a, b) => a.localeCompare(b) * (reversed ? -1 : 1),
);
for (const path of sortedPaths) {
const fileNode = this.file(path);
if (fileNode) {
if (basename(path, extname(path)) === 'index')
items.unshift(fileNode);
else items.push(fileNode);
continue;
}
const dirNode = this.folder(path, false);
if (dirNode) folders.push(dirNode);
}
return [...items, ...folders];
},
resolveFolderItem(
folderPath: string,
item: string,
): PageTree.Node[] | '...' | 'z...a' {
if (item === rest || item === restReversed) return item;
let match = separator.exec(item);
if (match?.groups) {
let node: PageTree.Separator = {
$id: nextNodeId(),
type: 'separator',
icon: match.groups.icon,
name: match.groups.name,
};
for (const transformer of ctx.transformers) {
if (!transformer.separator) continue;
node = transformer.separator.call(ctx, node);
}
return [node];
}
match = link.exec(item);
if (match?.groups) {
const { icon, url, name, external } = match.groups;
let node: PageTree.Item = {
$id: nextNodeId(),
type: 'page',
icon,
name,
url,
external: external ? true : undefined,
};
for (const transformer of ctx.transformers) {
if (!transformer.file) continue;
node = transformer.file.call(ctx, node);
}
return [node];
}
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);
}
const path = resolveFlattenPath(joinPath(folderPath, filename), 'page');
if (isExcept) {
visitedPaths.add(path);
return [];
}
const dirNode = this.folder(path, false);
if (dirNode) {
return isExtract ? dirNode.children : [dirNode];
}
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);
if (!files) return;
const metaPath = resolveFlattenPath(joinPath(folderPath, 'meta'), 'meta');
const indexPath = resolveFlattenPath(
joinPath(folderPath, 'index'),
'page',
);
let meta = storage.read(metaPath);
if (meta?.format !== 'meta') {
meta = undefined;
}
const isRoot = meta?.data.root ?? isGlobalRoot;
let index: PageTree.Item | undefined;
let children: PageTree.Node[];
if (meta && meta.data.pages) {
const resolved = meta.data.pages.flatMap<
PageTree.Node | typeof rest | typeof restReversed
>((item) => this.resolveFolderItem(folderPath, item));
if (!isRoot && !visitedPaths.has(indexPath)) {
index = this.file(indexPath);
}
for (let i = 0; i < resolved.length; i++) {
const item = resolved[i];
if (item !== rest && item !== restReversed) continue;
const items = this.buildPaths(
files.filter((file) => !visitedPaths.has(file)),
item === restReversed,
);
resolved.splice(i, 1, ...items);
break;
}
children = resolved as PageTree.Node[];
} else {
if (!isRoot && !visitedPaths.has(indexPath)) {
index = this.file(indexPath);
}
children = this.buildPaths(
files.filter((file) => !visitedPaths.has(file)),
);
}
let name = meta?.data.title ?? index?.name;
if (!name) {
const folderName = basename(folderPath);
name = pathToName(group.exec(folderName)?.[1] ?? folderName);
}
let node: PageTree.Folder = {
type: 'folder',
name,
icon: meta?.data.icon ?? index?.icon,
root: meta?.data.root,
defaultOpen: meta?.data.defaultOpen,
description: meta?.data.description,
index,
children,
$id: nextNodeId(folderPath),
$ref:
!options.noRef && meta
? {
metaFile: metaPath,
}
: undefined,
};
visitedPaths.add(folderPath);
for (const transformer of transformers) {
if (!transformer.folder) continue;
node = transformer.folder.call(ctx, node, folderPath, metaPath);
}
return node;
},
file(path: string): PageTree.Item | undefined {
const { options, getUrl, storage, locale, transformers } = ctx;
const page = storage.read(path);
if (page?.format !== 'page') return;
const { title, description, icon } = page.data;
let item: PageTree.Item = {
$id: nextNodeId(path),
type: 'page',
name: title ?? pathToName(basename(path, extname(path))),
description,
icon,
url: getUrl(page.slugs, locale),
$ref: !options.noRef
? {
file: path,
}
: undefined,
};
visitedPaths.add(path);
for (const transformer of transformers) {
if (!transformer.file) continue;
item = transformer.file.call(ctx, item, path);
}
return item;
},
root(): PageTree.Root {
const folder = this.folder('', true)!;
let root: PageTree.Root = {
$id: ctx.rootId,
name: folder.name || 'Docs',
children: folder.children,
};
for (const transformer of ctx.transformers) {
if (!transformer.root) continue;
root = transformer.root.call(ctx, root);
}
return root;
},
};
}
/**
* Get item name from file name
*
+1 -1
View File
@@ -2,7 +2,7 @@
"$id": "root",
"children": [
{
"$id": "test.mdx",
"$id": "root:test.mdx",
"name": "Hello",
"type": "page",
"url": "/test",
+6 -6
View File
@@ -3,7 +3,7 @@
"$id": "cn",
"children": [
{
"$id": "test.mdx",
"$id": "cn:test.mdx",
"$ref": {
"file": "test.mdx",
},
@@ -12,13 +12,13 @@
"url": "/cn/test",
},
{
"$id": "nested",
"$id": "cn:nested",
"$ref": {
"metaFile": "nested/meta.json",
},
"children": [
{
"$id": "nested/test.mdx",
"$id": "cn:nested/test.mdx",
"$ref": {
"file": "nested/test.mdx",
},
@@ -37,7 +37,7 @@
"$id": "en",
"children": [
{
"$id": "test.mdx",
"$id": "en:test.mdx",
"$ref": {
"file": "test.mdx",
},
@@ -46,10 +46,10 @@
"url": "/en/test",
},
{
"$id": "nested",
"$id": "en:nested",
"children": [
{
"$id": "nested/test.mdx",
"$id": "en:nested/test.mdx",
"$ref": {
"file": "nested/test.mdx",
},
@@ -3,16 +3,16 @@
"$id": "cn",
"children": [
{
"$id": "test.mdx",
"$id": "cn:test.mdx",
"name": "Hello Chinese",
"type": "page",
"url": "/cn/test",
},
{
"$id": "nested",
"$id": "cn:nested",
"children": [
{
"$id": "nested/test.mdx",
"$id": "cn:nested/test.mdx",
"name": "Nested Page Chinese",
"type": "page",
"url": "/cn/nested/test",
@@ -28,16 +28,16 @@
"$id": "en",
"children": [
{
"$id": "test.mdx",
"$id": "en:test.mdx",
"name": "Hello",
"type": "page",
"url": "/test",
},
{
"$id": "nested",
"$id": "en:nested",
"children": [
{
"$id": "nested/test.mdx",
"$id": "en:nested/test.mdx",
"name": "Nested Page",
"type": "page",
"url": "/nested/test",
+6 -6
View File
@@ -3,7 +3,7 @@
"$id": "cn",
"children": [
{
"$id": "test.mdx",
"$id": "cn:test.mdx",
"$ref": {
"file": "test.mdx",
},
@@ -12,13 +12,13 @@
"url": "/cn/test",
},
{
"$id": "nested",
"$id": "cn:nested",
"$ref": {
"metaFile": "nested/meta.json",
},
"children": [
{
"$id": "nested/test.mdx",
"$id": "cn:nested/test.mdx",
"$ref": {
"file": "nested/test.mdx",
},
@@ -37,7 +37,7 @@
"$id": "en",
"children": [
{
"$id": "test.mdx",
"$id": "en:test.mdx",
"$ref": {
"file": "test.mdx",
},
@@ -46,10 +46,10 @@
"url": "/en/test",
},
{
"$id": "nested",
"$id": "en:nested",
"children": [
{
"$id": "nested/test.mdx",
"$id": "en:nested/test.mdx",
"$ref": {
"file": "nested/test.mdx",
},
+11 -8
View File
@@ -2,16 +2,16 @@
"$id": "root",
"children": [
{
"$id": "test.mdx",
"$id": "root:test.mdx",
"name": "Hello",
"type": "page",
"url": "/test",
},
{
"$id": "(nested)",
"$id": "root:(nested)",
"children": [
{
"$id": "(nested)/hello.mdx",
"$id": "root:(nested)/hello.mdx",
"name": "Route Group Page",
"type": "page",
"url": "/hello",
@@ -21,10 +21,10 @@
"type": "folder",
},
{
"$id": "hello",
"$id": "root:hello",
"children": [],
"index": {
"$id": "hello/index.mdx",
"$id": "root:hello/index.mdx",
"name": "Another hello page",
"type": "page",
"url": "/hello/index",
@@ -33,10 +33,10 @@
"type": "folder",
},
{
"$id": "nested",
"$id": "root:nested",
"children": [
{
"$id": "nested/test.mdx",
"$id": "root:nested/test.mdx",
"name": "Nested Page",
"type": "page",
"url": "/nested/test",
@@ -46,17 +46,20 @@
"type": "folder",
},
{
"$id": "root:_0",
"name": "Text",
"type": "page",
"url": "https://google.com",
},
{
"$id": "root:_1",
"icon": "Icon",
"name": "Text",
"type": "page",
"url": "https://google.com",
},
{
"$id": "root:_2",
"external": true,
"name": "AI",
"type": "page",
@@ -67,7 +70,7 @@
"$id": "fallback-root-root",
"children": [
{
"$id": "hidden.mdx",
"$id": "fallback-root-root:hidden.mdx",
"name": "Hidden",
"type": "page",
"url": "/hidden",
+14 -14
View File
@@ -170,16 +170,16 @@ test('Loader: Without meta.json', () => {
"$id": "root",
"children": [
{
"$id": "test.mdx",
"$id": "root:test.mdx",
"name": "Hello",
"type": "page",
"url": "/test",
},
{
"$id": "hello",
"$id": "root:hello",
"children": [],
"index": {
"$id": "hello/index.mdx",
"$id": "root:hello/index.mdx",
"name": "Hello",
"type": "page",
"url": "/hello",
@@ -232,13 +232,13 @@ test('Loader: Rest operator', () => {
"$id": "root",
"children": [
{
"$id": "2-2.mdx",
"$id": "root:2-2.mdx",
"name": "2.2",
"type": "page",
"url": "/2-2",
},
{
"$id": "1-2.mdx",
"$id": "root:1-2.mdx",
"name": "1.2",
"type": "page",
"url": "/1-2",
@@ -284,9 +284,9 @@ test('Loader: Allow duplicate pages when explicitly referenced twice', () => {
const treeChildren = result.pageTree.children;
expect(treeChildren.length).toBe(3);
expect(treeChildren[0].$id).toBe('page1.mdx');
expect(treeChildren[1].$id).toBe('page1.mdx');
expect(treeChildren[2].$id).toBe('page2.mdx');
expect(treeChildren[0].$id).toBe('root:page1.mdx');
expect(treeChildren[1].$id).toBe('root:page1.mdx');
expect(treeChildren[2].$id).toBe('root:page2.mdx');
});
test('Loader: No duplicate pages when referencing subfolder items and folder', () => {
@@ -376,34 +376,34 @@ test('Loader: No duplicate pages when referencing subfolder items and folder', (
"$id": "root",
"children": [
{
"$id": "index.mdx",
"$id": "root:index.mdx",
"name": "Home",
"type": "page",
"url": "/",
},
{
"$id": "subfolder/page1.mdx",
"$id": "root:subfolder/page1.mdx",
"name": "Subfolder Page 1",
"type": "page",
"url": "/subfolder/page1",
},
{
"$id": "subfolder/page2.mdx",
"$id": "root:subfolder/page2.mdx",
"name": "Subfolder Page 2",
"type": "page",
"url": "/subfolder/page2",
},
{
"$id": "other-page.mdx",
"$id": "root:other-page.mdx",
"name": "Other Page",
"type": "page",
"url": "/other-page",
},
{
"$id": "subfolder",
"$id": "root:subfolder",
"children": [
{
"$id": "subfolder/page3.mdx",
"$id": "root:subfolder/page3.mdx",
"name": "Subfolder Page 3",
"type": "page",
"url": "/subfolder/page3",