Core & UI: Improve navigation

This commit is contained in:
Fuma Nama
2025-03-17 21:36:16 +08:00
parent 9e16ce6ede
commit f491f6fb04
15 changed files with 278 additions and 76 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Fix `findNeighbour()` doesn't exclude other nodes of another root
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': minor
---
Lazy build page tree by default
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': minor
---
Support `getPageByHref()` on loader API
+34 -2
View File
@@ -29,7 +29,13 @@ import { Mermaid } from '@theguild/remark-mermaid/mermaid';
import { Rate } from '@/components/rate';
import { repo, owner, onRateAction } from '@/lib/github';
import type { MDXComponents } from 'mdx/types';
import defaultMdxComponents, { createRelativeLink } from 'fumadocs-ui/mdx';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card';
import Link from 'fumadocs-core/link';
function PreviewRenderer({ preview }: { preview: string }): ReactNode {
if (preview && preview in Preview) {
@@ -81,8 +87,34 @@ export default async function Page(props: {
components={{
...defaultMdxComponents,
...((await import('lucide-react')) as unknown as MDXComponents),
a: createRelativeLink(source, page),
a: ({ href, ...props }) => {
const found = source.getPageByHref(href ?? '', {
dir: page.file.dirname,
});
if (!found) return <Link href={href} {...props} />;
return (
<HoverCard openDelay={100}>
<HoverCardTrigger asChild>
<Link
href={
found.hash
? `${found.page.url}#${found.hash}`
: found.page.url
}
{...props}
/>
</HoverCardTrigger>
<HoverCardContent className="text-sm">
<p className="font-medium">{found.page.data.title}</p>
<p className="text-fd-muted-foreground">
{found.page.data.description}
</p>
</HoverCardContent>
</HoverCard>
);
},
Popup,
PopupContent,
PopupTrigger,
+31
View File
@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import { cn } from '@/lib/cn';
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ComponentRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.HoverCardPortal>
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-lg border bg-fd-popover p-4 text-popover-fd-foreground shadow-md outline-none data-[state=open]:animate-fd-popover-in data-[state=closed]:animate-fd-popover-out origin-[--radix-hover-card-content-transform-origin]',
className,
)}
{...props}
/>
</HoverCardPrimitive.HoverCardPortal>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };
+10 -2
View File
@@ -16,12 +16,20 @@ import defaultMdxComponents from 'fumadocs-ui/mdx';
To support links with relative file path in `href`, override the default `a` component with:
```ts
```tsx
import { createRelativeLink } from 'fumadocs-ui/mdx';
import { source } from '@/lib/source';
const page = source.getPage(['...']);
const a = createRelativeLink(source, page);
return (
<MdxContent
components={{
// override the `a` tag
a: createRelativeLink(source, page),
}}
/>
);
```
```mdx
+1
View File
@@ -16,6 +16,7 @@
"@inkeep/ai-api": "^0.8.0",
"@oramacloud/client": "^2.1.4",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-hover-card": "^1.1.6",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
+29 -7
View File
@@ -24,24 +24,46 @@ export function flattenTree(tree: PageTree.Node[]): PageTree.Item[] {
export function findNeighbour(
tree: PageTree.Root,
url: string,
options?: {
separateRoot?: boolean;
},
): {
previous?: PageTree.Item;
next?: PageTree.Item;
} {
const list = flattenTree(tree.children);
const { separateRoot = true } = options ?? {};
const roots = separateRoot ? getPageTreeRoots(tree) : [tree];
const lists = roots.map((node) => flattenTree(node.children));
for (let i = 0; i < list.length; i++) {
if (list[i].url === url) {
return {
next: list[i + 1],
previous: list[i - 1],
};
for (const list of lists) {
for (let i = 0; i < list.length; i++) {
if (list[i].url === url) {
return {
next: list[i + 1],
previous: list[i - 1],
};
}
}
}
return {};
}
export function getPageTreeRoots(
pageTree: PageTree.Root | PageTree.Folder,
): (PageTree.Root | PageTree.Folder)[] {
return pageTree.children.flatMap((child) => {
if (child.type !== 'folder') return [];
const roots = getPageTreeRoots(child);
if (child.root) {
return [child, ...roots];
}
return roots;
});
}
/**
* Separate the folder nodes of a root into multiple roots
*/
+2
View File
@@ -19,6 +19,8 @@ export interface Item {
name: ReactNode;
url: string;
external?: boolean;
description?: ReactNode;
icon?: ReactElement;
}
+69 -20
View File
@@ -11,6 +11,7 @@ import type { BuildPageTreeOptions } from './page-tree-builder';
import { createPageTreeBuilder } from './page-tree-builder';
import { type FileInfo } from './path';
import type { MetaFile, PageFile, Storage } from './file-system';
import * as path from 'node:path';
export interface LoaderConfig {
source: SourceConfig;
@@ -82,7 +83,21 @@ export interface LoaderOutput<Config extends LoaderConfig> {
? Record<string, PageTree.Root>
: PageTree.Root;
getPageTree(locale?: string): PageTree.Root;
getPageTree: (locale?: string) => PageTree.Root;
getPageByHref: (
href: string,
options?: {
/**
* resolve relative file paths in `href` from specified directory
*/
dir?: string;
},
) =>
| {
page: Page<Config['source']['pageData']>;
hash?: string;
}
| undefined;
_i18n?: I18nConfig;
@@ -128,7 +143,7 @@ function indexPages(
getUrl: UrlFn,
i18n?: I18nConfig,
): {
// (locale.slugs -> page[])
// (locale.slugs -> page)
pages: Map<string, Page>;
pathToFile: Map<string, Page | Meta>;
@@ -168,6 +183,8 @@ function indexPages(
}
export function createGetUrl(baseUrl: string, i18n?: I18nConfig): UrlFn {
const baseSlugs = baseUrl.split('/');
return (slugs, locale) => {
const hideLocale = i18n?.hideLocale ?? 'never';
let urlLocale: string | undefined;
@@ -181,9 +198,8 @@ export function createGetUrl(baseUrl: string, i18n?: I18nConfig): UrlFn {
urlLocale = locale;
}
const paths = urlLocale
? [urlLocale, ...baseUrl.split('/'), ...slugs]
: [...baseUrl.split('/'), ...slugs];
const paths = [...baseSlugs, ...slugs];
if (urlLocale) paths.unshift(urlLocale);
return `/${paths.filter((v) => v.length > 0).join('/')}`;
};
@@ -230,25 +246,58 @@ function createOutput(options: LoaderOptions): LoaderOutput<LoaderConfig> {
);
const walker = indexPages(storage, getUrl, options.i18n);
const builder = createPageTreeBuilder();
const pageTree =
options.i18n === undefined
? builder.build({
storage,
resolveIcon: options.icon,
getUrl,
...options.pageTree,
})
: builder.buildI18n({
let pageTree: LoaderOutput<LoaderConfig>['pageTree'] | undefined;
return {
_i18n: options.i18n,
get pageTree() {
if (options.i18n) {
pageTree ??= builder.buildI18n({
storage,
resolveIcon: options.icon,
getUrl,
i18n: options.i18n,
...options.pageTree,
}) as unknown as LoaderOutput<LoaderConfig>['pageTree'];
} else {
pageTree ??= builder.build({
storage,
resolveIcon: options.icon,
getUrl,
...options.pageTree,
});
}
return {
_i18n: options.i18n,
pageTree: pageTree as LoaderOutput<LoaderConfig>['pageTree'],
return pageTree;
},
set pageTree(v) {
pageTree = v;
},
getPageByHref(href, { dir = '' } = {}) {
const pages = Array.from(walker.pages.values());
const [value, hash] = href.split('#', 2);
if (
value.startsWith('.') &&
(value.endsWith('.md') || value.endsWith('.mdx'))
) {
const hrefPath = path.join(dir, value);
const target = pages.find((item) => item.file.path === hrefPath);
if (target)
return {
page: target,
hash,
};
}
const target = pages.find((item) => item.url === value);
if (target)
return {
page: target,
hash,
};
},
getPages(language = options.i18n?.defaultLanguage ?? '') {
const pages: Page[] = [];
@@ -281,12 +330,12 @@ function createOutput(options: LoaderOptions): LoaderOutput<LoaderConfig> {
},
getPageTree(locale) {
if (options.i18n) {
return pageTree[
return this.pageTree[
(locale ?? options.i18n.defaultLanguage) as keyof typeof pageTree
] as PageTree.Root;
];
}
return pageTree as PageTree.Root;
return this.pageTree;
},
getNodePage(node) {
const ref = node.$ref?.file ?? node.$id;
@@ -258,6 +258,7 @@ function buildFileNode(
$id: localized.file.path,
type: 'page',
name: localized.data.data.title ?? pathToName(localized.file.name),
description: localized.data.data.description,
icon: ctx.options.resolveIcon?.(localized.data.data.icon),
url: ctx.options.getUrl(localized.data.slugs, ctx.lang),
$ref: !ctx.options.noRef
+1
View File
@@ -13,6 +13,7 @@ export interface MetaData {
export interface PageData {
icon?: string | undefined;
title?: string;
description?: string | undefined;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- infer types
+4 -11
View File
@@ -1,7 +1,6 @@
import type { LoaderConfig, LoaderOutput, Page } from 'fumadocs-core/source';
import type { ComponentProps, FC } from 'react';
import defaultMdxComponents from '@/mdx';
import * as path from 'node:path';
/**
* Extend the default Link component to resolve relative file paths in `href`.
@@ -16,16 +15,10 @@ export function createRelativeLink(
OverrideLink: FC<ComponentProps<'a'>> = defaultMdxComponents.a,
): FC<ComponentProps<'a'>> {
return async function RelativeLink({ href, ...props }) {
if (
href &&
href.startsWith('.') &&
(href.endsWith('.md') || href.endsWith('.mdx'))
) {
const pages = source.getPages();
const hrefPath = path.join(page.file.dirname, href);
const targetPage = pages.find((item) => item.file.path === hrefPath);
if (targetPage) href = targetPage.url;
// resolve relative href
if (href && href.startsWith('.')) {
const target = source.getPageByHref(href, { dir: page.file.dirname });
if (target) href = target.page.url;
}
return <OverrideLink href={href} {...props} />;
+48 -34
View File
@@ -10,7 +10,6 @@ import {
} from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import Link from 'next/link';
import { cva } from 'class-variance-authority';
import { cn } from '@/utils/cn';
import { useI18n } from './contexts/i18n';
import { useTreeContext, useTreePath } from './contexts/tree';
@@ -122,24 +121,17 @@ export function LastUpdate(props: { date: Date }) {
);
}
type Item = Pick<PageTree.Item, 'name' | 'description' | 'url'>;
export interface FooterProps {
/**
* Items including information for the next and previous page
*/
items?: {
previous?: { name: string; url: string };
next?: { name: string; url: string };
previous?: Item;
next?: Item;
};
}
const itemVariants = cva(
'flex w-full flex-col gap-2 rounded-lg border p-4 text-sm transition-colors hover:bg-fd-accent/80 hover:text-fd-accent-foreground',
);
const itemLabel = cva(
'inline-flex items-center gap-0.5 text-fd-muted-foreground',
);
function scanNavigationList(tree: PageTree.Node[]) {
const list: PageTree.Item[] = [];
@@ -165,7 +157,6 @@ const listCache = new WeakMap<PageTree.Root, PageTree.Item[]>();
export function Footer({ items }: FooterProps) {
const { root } = useTreeContext();
const { text } = useI18n();
const pathname = usePathname();
const { previous, next } = useMemo(() => {
@@ -185,32 +176,55 @@ export function Footer({ items }: FooterProps) {
}, [items, pathname, root]);
return (
<div className="grid grid-cols-2 gap-4 pb-6">
{previous ? (
<Link href={previous.url} className={cn(itemVariants())}>
<div className={cn(itemLabel())}>
<ChevronLeft className="-ms-1 size-4 shrink-0 rtl:rotate-180" />
<p>{text.previousPage}</p>
</div>
<p className="font-medium md:text-[15px]">{previous.name}</p>
</Link>
) : null}
{next ? (
<Link
href={next.url}
className={cn(itemVariants({ className: 'col-start-2 text-end' }))}
>
<div className={cn(itemLabel({ className: 'flex-row-reverse' }))}>
<ChevronRight className="-me-1 size-4 shrink-0 rtl:rotate-180" />
<p>{text.nextPage}</p>
</div>
<p className="font-medium md:text-[15px]">{next.name}</p>
</Link>
) : null}
<div
className={cn(
'@container grid gap-4 pb-6',
previous && next ? 'grid-cols-2' : 'grid-cols-1',
)}
>
{previous ? <FooterItem item={previous} index={0} /> : null}
{next ? <FooterItem item={next} index={1} /> : null}
</div>
);
}
function FooterItem({ item, index }: { item: Item; index: 0 | 1 }) {
const { text } = useI18n();
const Icon = index === 0 ? ChevronLeft : ChevronRight;
const title = item.description ? item.name : null;
const description = item.description ?? item.name;
return (
<Link
href={item.url}
className={cn(
'flex flex-col gap-2 rounded-lg border p-4 text-sm transition-colors hover:bg-fd-accent/80 hover:text-fd-accent-foreground @max-lg:col-span-full',
index === 1 && 'text-end',
)}
>
<div
className={cn(
'inline-flex items-center gap-1.5',
index === 1 && 'flex-row-reverse',
title ? 'font-medium' : 'text-fd-muted-foreground',
)}
>
<Icon className="-mx-1 size-4 shrink-0 rtl:rotate-180" />
<p>{title ?? text.nextPage}</p>
</div>
<p
className={cn(
title
? 'text-fd-muted-foreground truncate'
: 'font-medium md:text-[15px]',
)}
>
{description}
</p>
</Link>
);
}
export type BreadcrumbProps = BreadcrumbOptions;
export function Breadcrumb(options: BreadcrumbProps) {
+33
View File
@@ -68,6 +68,9 @@ importers:
'@radix-ui/react-dialog':
specifier: ^1.1.6
version: 1.1.6(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-hover-card':
specifier: ^1.1.6
version: 1.1.6(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-popover':
specifier: ^1.1.6
version: 1.1.6(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
@@ -2410,6 +2413,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-E4ozl35jq0VRlrdc4dhHrNSV0JqBb4Jy73WAhBEK7JoYnQ83ED5r0Rb/XdVKw89ReAJN38N492BAPBZQ57VmqQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==}
peerDependencies:
@@ -8158,6 +8174,23 @@ snapshots:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-compose-refs': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-context': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-dismissable-layer': 1.1.5(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-popper': 1.2.2(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-portal': 1.1.4(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-presence': 1.1.2(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-primitive': 2.0.2(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-use-controllable-state': 1.1.0(@types/[email protected])([email protected])
react: 19.0.0
react-dom: 19.0.0([email protected])
optionalDependencies:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected])([email protected])':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.0(@types/[email protected])([email protected])