Core & UI: Generate a $id attribute to page tree nodes

This commit is contained in:
Fuma Nama
2025-02-23 17:39:41 +08:00
parent ecacb53d08
commit 93952dbd4e
10 changed files with 302 additions and 241 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Generate a `$id` attribute to page tree nodes
+10 -4
View File
@@ -1,6 +1,8 @@
import type { ReactElement, ReactNode } from 'react';
export interface Root {
$id?: string;
name: ReactNode;
children: Node[];
}
@@ -8,24 +10,28 @@ export interface Root {
export type Node = Item | Separator | Folder;
export interface Item {
$id?: string;
$ref?: {
file: string;
};
type: 'page';
name: ReactNode;
url: string;
external?: boolean;
icon?: ReactElement;
$ref?: {
file: string;
};
}
export interface Separator {
$id?: string;
type: 'separator';
name: ReactNode;
icon?: ReactElement;
}
export interface Folder {
$id?: string;
$ref?: {
metaFile?: string;
};
+10 -11
View File
@@ -131,20 +131,19 @@ function indexPages(
// (locale.slugs -> page[])
pages: Map<string, Page>;
pathToPage: Map<string, Page>;
pathToMeta: Map<string, Meta>;
pathToFile: Map<string, Page | Meta>;
} {
const defaultLanguage = i18n?.defaultLanguage ?? '';
const map = new Map<string, Page>();
const pages = new Map<string, Page>();
const metas = new Map<string, Meta>();
const pathToFile = new Map<string, Page | Meta>();
for (const item of storage.list()) {
if (item.format === 'meta') metas.set(item.file.path, fileToMeta(item));
if (item.format === 'meta')
pathToFile.set(item.file.path, fileToMeta(item));
if (item.format === 'page') {
const page = fileToPage(item, getUrl, item.file.locale);
pages.set(item.file.path, page);
pathToFile.set(item.file.path, page);
if (item.file.locale) continue;
map.set(`${defaultLanguage}.${page.slugs.join('/')}`, page);
@@ -164,8 +163,7 @@ function indexPages(
return {
pages: map,
pathToPage: pages,
pathToMeta: metas,
pathToFile,
};
}
@@ -279,7 +277,7 @@ function createOutput(options: LoaderOptions): LoaderOutput<LoaderConfig> {
getNodeMeta(node) {
if (!node.$ref?.metaFile) return;
return walker.pathToMeta.get(node.$ref.metaFile);
return walker.pathToFile.get(node.$ref.metaFile) as Meta;
},
getPageTree(locale) {
if (options.i18n) {
@@ -291,9 +289,10 @@ function createOutput(options: LoaderOptions): LoaderOutput<LoaderConfig> {
return pageTree as PageTree.Root;
},
getNodePage(node) {
if (!node.$ref?.file) return;
const ref = node.$ref?.file ?? node.$id;
if (!ref) return;
return walker.pathToPage.get(node.$ref.file);
return walker.pathToFile.get(ref) as Page;
},
// @ts-expect-error -- ignore this
generateParams(slug, lang) {
+13 -12
View File
@@ -1,6 +1,5 @@
import type { ReactElement } from 'react';
import type { I18nConfig } from '@/i18n';
import { removeUndefined } from '@/utils/remove-undefined';
import type * as PageTree from '../server/page-tree';
import type { File, Folder, MetaFile, PageFile, Storage } from './file-system';
import { resolvePath } from '@/utils/path';
@@ -113,6 +112,7 @@ function resolveFolderItem(
folder: Folder,
item: string,
ctx: PageTreeBuilderContext,
idx: number,
addedNodePaths: Set<string>,
): PageTree.Node[] | typeof rest | typeof restReversed {
if (item === rest || item === restReversed) return item;
@@ -120,12 +120,13 @@ function resolveFolderItem(
let match = separator.exec(item);
if (match?.groups) {
const node: PageTree.Separator = {
$id: `${folder.file.path}#${idx}`,
type: 'separator',
icon: ctx.options.resolveIcon?.(match.groups.icon),
name: match.groups.name,
};
return [removeUndefined(ctx.options.attachSeparator?.(node) ?? node)];
return [ctx.options.attachSeparator?.(node) ?? node];
}
match = link.exec(item);
@@ -142,7 +143,7 @@ function resolveFolderItem(
external: !isRelative,
};
return [removeUndefined(ctx.options.attachFile?.(node) ?? node)];
return [ctx.options.attachFile?.(node) ?? node];
}
const isExcept = item.startsWith(excludePrefix),
@@ -187,20 +188,20 @@ function buildFolderNode(
);
const metadata = meta?.data;
const isRoot = metadata?.root ?? isGlobalRoot;
const index = indexFile ? buildFileNode(indexFile, ctx) : undefined;
let children: PageTree.Node[];
if (!meta) {
children = buildAll(folder.children, ctx, !isGlobalRoot);
if (!metadata?.pages) {
children = buildAll(folder.children, ctx, !isRoot);
} else {
const isRoot = metadata?.root ?? isGlobalRoot;
const addedNodePaths = new Set<string>();
const resolved = metadata?.pages?.flatMap<
PageTree.Node | typeof rest | typeof restReversed
>((item) => {
return resolveFolderItem(folder, item, ctx, addedNodePaths);
>((item, i) => {
return resolveFolderItem(folder, item, ctx, i, addedNodePaths);
});
const restNodes = buildAll(
@@ -235,6 +236,7 @@ function buildFolderNode(
description: metadata?.description,
index,
children,
$id: folder.file.path,
$ref: !ctx.options.noRef
? {
metaFile: meta?.file.path,
@@ -242,9 +244,7 @@ function buildFolderNode(
: undefined,
};
return removeUndefined(
ctx.options.attachFolder?.(node, folder, meta) ?? node,
);
return ctx.options.attachFolder?.(node, folder, meta) ?? node;
}
function buildFileNode(
@@ -255,6 +255,7 @@ function buildFileNode(
findLocalizedFile(file.file.flattenedPath, 'page', ctx) ?? file;
const item: PageTree.Item = {
$id: localized.file.path,
type: 'page',
name: localized.data.data.title ?? pathToName(localized.file.name),
icon: ctx.options.resolveIcon?.(localized.data.data.icon),
@@ -266,7 +267,7 @@ function buildFileNode(
: undefined,
};
return removeUndefined(ctx.options.attachFile?.(item, file) ?? item);
return ctx.options.attachFile?.(item, file) ?? item;
}
function build(ctx: PageTreeBuilderContext): PageTree.Root {
+7 -1
View File
@@ -1,8 +1,14 @@
export function removeUndefined<T extends object>(value: T): T {
export function removeUndefined<T extends object>(value: T, deep = false): T {
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (obj[key] === undefined) delete obj[key];
if (deep && typeof obj[key] === 'object' && obj[key] !== null) {
removeUndefined(obj[key], deep);
} else if (deep && Array.isArray(obj[key])) {
obj[key].forEach((v) => removeUndefined(v, deep));
}
}
return value;
+230 -201
View File
@@ -9,6 +9,7 @@ import {
import { parseFilePath } from '@/source';
import { PageTree } from '../dist/server';
import type { ReactElement } from 'react';
import { removeUndefined } from '@/utils/remove-undefined';
test('get slugs', () => {
expect(getSlugs(parseFilePath('index.mdx'))).toStrictEqual([]);
@@ -73,18 +74,20 @@ test('Loader: Simple', () => {
},
});
expect(result.pageTree, 'Page Tree').toMatchInlineSnapshot(`
{
"children": [
{
"name": "Hello",
"type": "page",
"url": "/test",
},
],
"name": "",
}
`);
expect(removeUndefined(result.pageTree, true), 'Page Tree')
.toMatchInlineSnapshot(`
{
"children": [
{
"$id": "test.mdx",
"name": "Hello",
"type": "page",
"url": "/test",
},
],
"name": "",
}
`);
expect(result.getPages().length).toBe(1);
expect(result.getPage(['test'])).toBeDefined();
@@ -144,53 +147,59 @@ test('Nested Directories', async () => {
},
});
expect(result.pageTree, 'Page Tree').toMatchInlineSnapshot(`
{
"children": [
{
"name": "Hello",
"type": "page",
"url": "/test",
},
{
"children": [
{
"name": "Route Group Page",
"type": "page",
"url": "/hello",
},
],
"name": "Nested",
"type": "folder",
},
{
"children": [
{
"name": "Nested Page",
"type": "page",
"url": "/nested/test",
},
],
"name": "Nested",
"type": "folder",
},
{
"external": true,
"name": "Text",
"type": "page",
"url": "https://google.com",
},
{
"external": true,
"icon": "Icon",
"name": "Text",
"type": "page",
"url": "https://google.com",
},
],
"name": "",
}
`);
expect(removeUndefined(result.pageTree, true), 'Page Tree')
.toMatchInlineSnapshot(`
{
"children": [
{
"$id": "test.mdx",
"name": "Hello",
"type": "page",
"url": "/test",
},
{
"$id": "(nested)",
"children": [
{
"$id": "(nested)/hello.mdx",
"name": "Route Group Page",
"type": "page",
"url": "/hello",
},
],
"name": "Nested",
"type": "folder",
},
{
"$id": "nested",
"children": [
{
"$id": "nested/test.mdx",
"name": "Nested Page",
"type": "page",
"url": "/nested/test",
},
],
"name": "Nested",
"type": "folder",
},
{
"external": true,
"name": "Text",
"type": "page",
"url": "https://google.com",
},
{
"external": true,
"icon": "Icon",
"name": "Text",
"type": "page",
"url": "https://google.com",
},
],
"name": "",
}
`);
expect(result.getPages().length).toBe(4);
// page in folder
expect(result.getPage(['nested', 'test'])).toBeDefined();
@@ -274,71 +283,77 @@ test('Internationalized Routing', () => {
}),
);
expect(result.pageTree['en'], 'Page Tree').toMatchInlineSnapshot(`
{
"children": [
{
"$ref": {
"file": "test.mdx",
},
"name": "Hello",
"type": "page",
"url": "/en/test",
},
{
"$ref": {
"metaFile": undefined,
},
"children": [
{
"$ref": {
"file": "nested/test.mdx",
},
"name": "Nested Page",
"type": "page",
"url": "/en/nested/test",
expect(removeUndefined(result.pageTree['en'], true), 'Page Tree')
.toMatchInlineSnapshot(`
{
"children": [
{
"$id": "test.mdx",
"$ref": {
"file": "test.mdx",
},
],
"name": "Nested",
"type": "folder",
},
],
"name": "",
}
`);
"name": "Hello",
"type": "page",
"url": "/en/test",
},
{
"$id": "nested",
"$ref": {},
"children": [
{
"$id": "nested/test.mdx",
"$ref": {
"file": "nested/test.mdx",
},
"name": "Nested Page",
"type": "page",
"url": "/en/nested/test",
},
],
"name": "Nested",
"type": "folder",
},
],
"name": "",
}
`);
expect(result.pageTree['cn'], 'Page Tree').toMatchInlineSnapshot(`
{
"children": [
{
"$ref": {
"file": "test.cn.mdx",
},
"name": "Hello Chinese",
"type": "page",
"url": "/cn/test",
},
{
"$ref": {
"metaFile": "nested/meta.cn.json",
},
"children": [
{
"$ref": {
"file": "nested/test.cn.mdx",
},
"name": "Nested Page Chinese",
"type": "page",
"url": "/cn/nested/test",
expect(removeUndefined(result.pageTree['cn'], true), 'Page Tree')
.toMatchInlineSnapshot(`
{
"children": [
{
"$id": "test.cn.mdx",
"$ref": {
"file": "test.cn.mdx",
},
],
"name": "Nested Chinese",
"type": "folder",
},
],
"name": "Docs Chinese",
}
`);
"name": "Hello Chinese",
"type": "page",
"url": "/cn/test",
},
{
"$id": "nested",
"$ref": {
"metaFile": "nested/meta.cn.json",
},
"children": [
{
"$id": "nested/test.cn.mdx",
"$ref": {
"file": "nested/test.cn.mdx",
},
"name": "Nested Page Chinese",
"type": "page",
"url": "/cn/nested/test",
},
],
"name": "Nested Chinese",
"type": "folder",
},
],
"name": "Docs Chinese",
}
`);
expect(result.getPages()).toMatchInlineSnapshot(`
[
@@ -481,53 +496,61 @@ test('Internationalized Routing: Hide Prefix', () => {
source: i18nSource,
});
expect(result.pageTree['en'], 'Page Tree').toMatchInlineSnapshot(`
{
"children": [
{
"name": "Hello",
"type": "page",
"url": "/test",
},
{
"children": [
{
"name": "Nested Page",
"type": "page",
"url": "/nested/test",
},
],
"name": "Nested",
"type": "folder",
},
],
"name": "",
}
`);
expect(removeUndefined(result.pageTree['en'], true), 'Page Tree')
.toMatchInlineSnapshot(`
{
"children": [
{
"$id": "test.mdx",
"name": "Hello",
"type": "page",
"url": "/test",
},
{
"$id": "nested",
"children": [
{
"$id": "nested/test.mdx",
"name": "Nested Page",
"type": "page",
"url": "/nested/test",
},
],
"name": "Nested",
"type": "folder",
},
],
"name": "",
}
`);
expect(result.pageTree['cn'], 'Page Tree').toMatchInlineSnapshot(`
{
"children": [
{
"name": "Hello Chinese",
"type": "page",
"url": "/cn/test",
},
{
"children": [
{
"name": "Nested Page Chinese",
"type": "page",
"url": "/cn/nested/test",
},
],
"name": "Nested Chinese",
"type": "folder",
},
],
"name": "Docs Chinese",
}
`);
expect(removeUndefined(result.pageTree['cn'], true), 'Page Tree')
.toMatchInlineSnapshot(`
{
"children": [
{
"$id": "test.cn.mdx",
"name": "Hello Chinese",
"type": "page",
"url": "/cn/test",
},
{
"$id": "nested",
"children": [
{
"$id": "nested/test.cn.mdx",
"name": "Nested Page Chinese",
"type": "page",
"url": "/cn/nested/test",
},
],
"name": "Nested Chinese",
"type": "folder",
},
],
"name": "Docs Chinese",
}
`);
expect(result.getPages().length).toBe(2);
expect(result.getPage(['test'])?.url).toBe('/test');
@@ -560,23 +583,26 @@ test('Loader: Without meta.json', () => {
},
});
expect(result.pageTree, 'Page Tree').toMatchInlineSnapshot(`
{
"children": [
{
"name": "Hello",
"type": "page",
"url": "/hello",
},
{
"name": "Hello",
"type": "page",
"url": "/test",
},
],
"name": "",
}
`);
expect(removeUndefined(result.pageTree, true), 'Page Tree')
.toMatchInlineSnapshot(`
{
"children": [
{
"$id": "hello/index.mdx",
"name": "Hello",
"type": "page",
"url": "/hello",
},
{
"$id": "test.mdx",
"name": "Hello",
"type": "page",
"url": "/test",
},
],
"name": "",
}
`);
});
test('Loader: Rest operator', () => {
@@ -612,21 +638,24 @@ test('Loader: Rest operator', () => {
},
});
expect(result.pageTree, 'Page Tree').toMatchInlineSnapshot(`
{
"children": [
{
"name": "2.2",
"type": "page",
"url": "/2-2",
},
{
"name": "1.2",
"type": "page",
"url": "/1-2",
},
],
"name": "",
}
`);
expect(removeUndefined(result.pageTree, true), 'Page Tree')
.toMatchInlineSnapshot(`
{
"children": [
{
"$id": "2-2.mdx",
"name": "2.2",
"type": "page",
"url": "/2-2",
},
{
"$id": "1-2.mdx",
"name": "1.2",
"type": "page",
"url": "/1-2",
},
],
"name": "",
}
`);
});
+18 -6
View File
@@ -1,11 +1,19 @@
'use client';
import type { PageTree } from 'fumadocs-core/server';
import { usePathname } from 'next/navigation';
import { createContext, useContext, type ReactNode, useMemo } from 'react';
import {
createContext,
useContext,
type ReactNode,
useMemo,
useRef,
} from 'react';
import { searchPath } from 'fumadocs-core/breadcrumb';
type MakeRequired<O, K extends keyof O> = Omit<O, K> & Pick<Required<O>, K>;
interface TreeContextType {
root: PageTree.Root | PageTree.Folder;
root: MakeRequired<PageTree.Root | PageTree.Folder, '$id'>;
}
const TreeContext = createContext<TreeContextType | null>(null);
@@ -17,18 +25,22 @@ export function TreeContextProvider({
}: {
tree: PageTree.Root;
children: ReactNode;
}): ReactNode {
}) {
const nextIdRef = useRef(0);
const pathname = usePathname();
const path = useMemo(
() => searchPath(tree.children, pathname) ?? [],
[pathname, tree],
);
const root = (path.findLast((item) => item.type === 'folder' && item.root) ??
tree) as PageTree.Root;
const root =
path.findLast((item) => item.type === 'folder' && item.root) ?? tree;
root.$id ??= String(nextIdRef.current++);
return (
<TreeContext.Provider value={useMemo(() => ({ root }), [root])}>
<TreeContext.Provider
value={useMemo(() => ({ root: root as TreeContextType['root'] }), [root])}
>
<PathContext.Provider value={path}>{children}</PathContext.Provider>
</TreeContext.Provider>
);
+5 -2
View File
@@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation';
import {
type ButtonHTMLAttributes,
createContext,
Fragment,
type HTMLAttributes,
type PointerEventHandler,
type ReactNode,
@@ -462,8 +463,10 @@ export function SidebarPageTree(props: {
});
}
return renderSidebarList(root.children, 1);
}, [props.components, root.children]);
return (
<Fragment key={root.$id}>{renderSidebarList(root.children, 1)}</Fragment>
);
}, [props.components, root.$id]);
}
function PageTreeFolder({
+2 -2
View File
@@ -52,7 +52,7 @@ const DefaultSearchDialog = dynamic(
export function RootProvider({
children,
dir,
dir = 'ltr',
theme: { enabled = true, ...theme } = {},
search,
}: RootProviderProps): React.ReactElement {
@@ -79,7 +79,7 @@ export function RootProvider({
);
return (
<DirectionProvider dir={dir ?? 'ltr'}>
<DirectionProvider dir={dir}>
<SidebarProvider>{body}</SidebarProvider>
</DirectionProvider>
);
+2 -2
View File
@@ -424,7 +424,7 @@ export const DEFAULT: Config = {
'a:not([data-card])': {
color: 'var(--tw-prose-links)',
transition: 'text-decoration .2s',
transition: 'opacity .2s',
fontWeight: '500',
textDecoration: 'underline',
textUnderlineOffset: '3.5px',
@@ -432,7 +432,7 @@ export const DEFAULT: Config = {
textDecorationThickness: '1.5px',
},
'a:not([data-card]):hover': {
textDecorationThickness: '3px',
opacity: '80%',
},
},
colors,