Core & UI: support collapsible in meta data

This commit is contained in:
Fuma Nama
2025-12-04 17:37:58 +08:00
parent ef8eb6c361
commit 4e2bca7227
13 changed files with 129 additions and 93 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'fumadocs-core': patch
'fumadocs-ui': patch
---
support `collapsible` in meta data
+6 -5
View File
@@ -1,13 +1,13 @@
import type { ReactNode } from 'react';
interface INode {
interface ID {
/**
* ID for the node, unique in all page trees (even across different locales)
*/
$id?: string;
}
export interface Root extends INode {
export interface Root extends ID {
name: ReactNode;
children: Node[];
/**
@@ -18,7 +18,7 @@ export interface Root extends INode {
export type Node = Item | Separator | Folder;
export interface Item extends INode {
export interface Item extends ID {
/**
* @internal
*/
@@ -40,13 +40,13 @@ export interface Item extends INode {
icon?: ReactNode;
}
export interface Separator extends INode {
export interface Separator extends ID {
type: 'separator';
name?: ReactNode;
icon?: ReactNode;
}
export interface Folder extends INode {
export interface Folder extends ID {
/**
* @internal
*/
@@ -59,6 +59,7 @@ export interface Folder extends INode {
description?: ReactNode;
root?: boolean;
defaultOpen?: boolean;
collapsible?: boolean;
index?: Item;
icon?: ReactNode;
children: Node[];
+19 -19
View File
@@ -268,20 +268,19 @@ function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
);
let meta = storage.read(metaPath);
if (meta?.format !== 'meta') {
meta = undefined;
}
if (meta && meta.format !== 'meta') meta = undefined;
const isRoot = meta?.data.root ?? isGlobalRoot;
const metadata = meta?.data ?? {};
const { root = isGlobalRoot, pages } = metadata;
let index: PageTree.Item | undefined;
let children: PageTree.Node[];
if (meta && meta.data.pages) {
const resolved = meta.data.pages.flatMap<
if (pages) {
const resolved = pages.flatMap<
PageTree.Node | typeof rest | typeof restReversed
>((item) => this.resolveFolderItem(folderPath, item));
if (!isRoot && !visitedPaths.has(indexPath)) {
if (!root && !visitedPaths.has(indexPath)) {
index = this.file(indexPath);
}
@@ -300,7 +299,7 @@ function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
children = resolved as PageTree.Node[];
} else {
if (!isRoot && !visitedPaths.has(indexPath)) {
if (!root && !visitedPaths.has(indexPath)) {
index = this.file(indexPath);
}
@@ -309,19 +308,20 @@ function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
);
}
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,
name:
metadata.title ??
index?.name ??
(() => {
const folderName = basename(folderPath);
return pathToName(group.exec(folderName)?.[1] ?? folderName);
})(),
icon: metadata.icon,
root: metadata.root,
defaultOpen: metadata.defaultOpen,
description: metadata.description,
collapsible: metadata.collapsible,
index,
children,
$id: nextNodeId(folderPath),
+1
View File
@@ -13,6 +13,7 @@ export interface MetaData {
root?: boolean | undefined;
pages?: string[] | undefined;
defaultOpen?: boolean | undefined;
collapsible?: boolean | undefined;
description?: string | undefined;
}
+1
View File
@@ -491,6 +491,7 @@ test('Loader: Serialize data', async () => {
"url": "/hello",
},
],
"collapsible": undefined,
"defaultOpen": undefined,
"description": undefined,
"icon": undefined,
+3 -3
View File
@@ -1,7 +1,7 @@
import type { StandardSchemaV1 } from '@standard-schema/spec';
import type { MDXPresetOptions } from '@/config/preset';
import type { ProcessorOptions } from '@mdx-js/mdx';
import { frontmatterSchema, metaSchema } from '@/config/zod-4';
import { metaSchema, pageSchema } from 'fumadocs-core/source/schema';
import type { PostprocessOptions } from '@/loaders/mdx/remark-postprocess';
import type { PluginOption } from '@/core';
import type { BuildEnvironment } from './build';
@@ -106,7 +106,7 @@ export function defineCollections(
}
export function defineDocs<
DocSchema extends StandardSchemaV1 = typeof frontmatterSchema,
DocSchema extends StandardSchemaV1 = typeof pageSchema,
MetaSchema extends StandardSchemaV1 = typeof metaSchema,
>(options: {
/**
@@ -127,7 +127,7 @@ export function defineDocs<
docs: defineCollections({
type: 'doc',
dir,
schema: frontmatterSchema as any,
schema: pageSchema as any,
...options?.docs,
}),
meta: defineCollections({
+4 -1
View File
@@ -1,6 +1,9 @@
export * from './define';
export * from '@/config/preset';
export * from './zod-4';
export { remarkInclude } from '@/loaders/mdx/remark-include';
export type { PostprocessOptions } from '@/loaders/mdx/remark-postprocess';
export {
metaSchema,
pageSchema as frontmatterSchema,
} from 'fumadocs-core/source/schema';
-26
View File
@@ -1,26 +0,0 @@
import { z } from 'zod';
/**
* Zod 4 schema
*/
export const metaSchema = z.object({
title: z.string().optional(),
pages: z.array(z.string()).optional(),
description: z.string().optional(),
root: z.boolean().optional(),
defaultOpen: z.boolean().optional(),
icon: z.string().optional(),
});
/**
* Zod 4 schema
*/
export const frontmatterSchema = z.object({
title: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
full: z.boolean().optional(),
// Fumadocs OpenAPI generated
_openapi: z.looseObject({}).optional(),
});
+11 -2
View File
@@ -1,5 +1,12 @@
import { defineConfig } from 'tsup';
const external = ['next', 'typescript', 'bun'];
const noExternal = [
// TODO: remove this when the min `fumadocs-core` version is above 16.2.3
'fumadocs-core/source/schema',
];
export default defineConfig([
{
entry: [
@@ -11,7 +18,8 @@ export default defineConfig([
'./src/plugins/*.ts',
],
format: 'esm',
external: ['next', 'typescript', 'bun'],
noExternal,
external,
dts: true,
target: 'node22',
},
@@ -22,7 +30,8 @@ export default defineConfig([
'./next/index': './src/next/index.ts',
},
format: 'cjs',
external: ['next', 'typescript', 'bun'],
noExternal,
external,
dts: false,
target: 'node22',
},
+55 -26
View File
@@ -74,6 +74,7 @@ const FolderContext = createContext<{
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
depth: number;
collapsible: boolean;
} | null>(null);
export function SidebarProvider({
@@ -125,6 +126,10 @@ export function useSidebar(): SidebarContext {
return ctx;
}
export function useFolder() {
return use(FolderContext);
}
export function useFolderDepth() {
return use(FolderContext)?.depth ?? 0;
}
@@ -284,17 +289,22 @@ export function SidebarItem({
}
export function SidebarFolder({
defaultOpen: defaultOpenOption = false,
defaultOpen: defaultOpenProp,
collapsible = true,
active = false,
children,
...props
}: ComponentProps<'div'> & {
defaultOpen?: boolean | ((currentDefault: boolean) => boolean);
active?: boolean;
defaultOpen?: boolean;
collapsible?: boolean;
}) {
const { defaultOpenLevel } = useSidebar();
const depth = useFolderDepth() + 1;
const defaultOpen =
typeof defaultOpenOption === 'function'
? defaultOpenOption(defaultOpenLevel >= depth)
: defaultOpenLevel >= depth || defaultOpenOption;
collapsible === false ||
active ||
(defaultOpenProp ?? defaultOpenLevel >= depth);
const [open, setOpen] = useState(defaultOpen);
useOnChange(defaultOpen, (v) => {
@@ -302,33 +312,48 @@ export function SidebarFolder({
});
return (
<Collapsible open={open} onOpenChange={setOpen} {...props}>
<Collapsible
open={open}
onOpenChange={setOpen}
disabled={!collapsible}
{...props}
>
<FolderContext
value={useMemo(() => ({ open, setOpen, depth }), [depth, open])}
value={useMemo(
() => ({ open, setOpen, depth, collapsible }),
[collapsible, depth, open],
)}
>
{props.children}
{children}
</FolderContext>
</Collapsible>
);
}
export function SidebarFolderTrigger(props: CollapsibleTriggerProps) {
const { open } = use(FolderContext)!;
export function SidebarFolderTrigger({
children,
...props
}: CollapsibleTriggerProps) {
const { open, collapsible } = use(FolderContext)!;
return (
<CollapsibleTrigger {...props}>
{props.children}
<ChevronDown
data-icon
className={cn('ms-auto transition-transform', !open && '-rotate-90')}
/>
</CollapsibleTrigger>
);
if (collapsible) {
return (
<CollapsibleTrigger {...props}>
{children}
<ChevronDown
data-icon
className={cn('ms-auto transition-transform', !open && '-rotate-90')}
/>
</CollapsibleTrigger>
);
}
return <div {...(props as ComponentProps<'div'>)}>{children}</div>;
}
export function SidebarFolderLink(props: LinkProps) {
export function SidebarFolderLink({ children, ...props }: LinkProps) {
const ref = useRef<HTMLAnchorElement>(null);
const { open, setOpen } = use(FolderContext)!;
const { open, setOpen, collapsible } = use(FolderContext)!;
const { prefetch } = useSidebar();
const pathname = usePathname();
const active =
@@ -341,6 +366,8 @@ export function SidebarFolderLink(props: LinkProps) {
ref={ref}
data-active={active}
onClick={(e) => {
if (!collapsible) return;
if (
e.target instanceof Element &&
e.target.matches('[data-icon], [data-icon] *')
@@ -354,11 +381,13 @@ export function SidebarFolderLink(props: LinkProps) {
prefetch={prefetch}
{...props}
>
{props.children}
<ChevronDown
data-icon
className={cn('ms-auto transition-transform', !open && '-rotate-90')}
/>
{children}
{collapsible && (
<ChevronDown
data-icon
className={cn('ms-auto transition-transform', !open && '-rotate-90')}
/>
)}
</Link>
);
}
@@ -38,9 +38,9 @@ export function createPageTreeRenderer({
return (
<SidebarFolder
defaultOpen={(value) =>
(item.defaultOpen ?? value) || path.includes(item)
}
collapsible={item.collapsible}
active={path.includes(item)}
defaultOpen={item.defaultOpen}
>
{item.index ? (
<SidebarFolderLink
+10 -4
View File
@@ -11,11 +11,13 @@ import { Sidebar as SidebarIcon } from 'lucide-react';
import { mergeRefs } from '@/utils/merge-refs';
const itemVariants = cva(
'relative flex flex-row items-center gap-2 rounded-lg p-2 text-start text-fd-muted-foreground wrap-anywhere transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none [&_svg]:size-4 [&_svg]:shrink-0',
'relative flex flex-row items-center gap-2 rounded-lg p-2 text-start text-fd-muted-foreground wrap-anywhere [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
link: 'data-[active=true]:bg-fd-primary/10 data-[active=true]:text-fd-primary data-[active=true]:hover:transition-colors',
link: 'transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none data-[active=true]:bg-fd-primary/10 data-[active=true]:text-fd-primary data-[active=true]:hover:transition-colors',
button:
'transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none',
},
highlight: {
true: "data-[active=true]:before:content-[''] data-[active=true]:before:bg-fd-primary data-[active=true]:before:absolute data-[active=true]:before:w-px data-[active=true]:before:inset-y-2.5 data-[active=true]:before:start-2.5",
@@ -179,11 +181,15 @@ export function SidebarFolderTrigger({
style,
...props
}: ComponentProps<typeof Base.SidebarFolderTrigger>) {
const depth = Base.useFolderDepth();
const { depth, collapsible } = Base.useFolder()!;
return (
<Base.SidebarFolderTrigger
className={cn(itemVariants(), 'w-full', className)}
className={cn(
itemVariants({ variant: collapsible ? 'button' : null }),
'w-full',
className,
)}
style={{
paddingInlineStart: getItemOffset(depth - 1),
...style,
+10 -4
View File
@@ -9,11 +9,13 @@ import { createLinkItemRenderer } from '@/components/sidebar/link-item';
import { mergeRefs } from '@/utils/merge-refs';
const itemVariants = cva(
'relative flex flex-row items-center gap-2 rounded-lg p-2 text-start text-fd-muted-foreground wrap-anywhere transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none [&_svg]:size-4 [&_svg]:shrink-0',
'relative flex flex-row items-center gap-2 rounded-lg p-2 text-start text-fd-muted-foreground wrap-anywhere [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
link: 'data-[active=true]:bg-fd-primary/10 data-[active=true]:text-fd-primary data-[active=true]:hover:transition-colors',
link: 'transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none data-[active=true]:bg-fd-primary/10 data-[active=true]:text-fd-primary data-[active=true]:hover:transition-colors',
button:
'transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none',
},
highlight: {
true: "data-[active=true]:before:content-[''] data-[active=true]:before:bg-fd-primary data-[active=true]:before:absolute data-[active=true]:before:w-px data-[active=true]:before:inset-y-2.5 data-[active=true]:before:start-2.5",
@@ -162,11 +164,15 @@ export function SidebarFolderTrigger({
style,
...props
}: ComponentProps<typeof Base.SidebarFolderTrigger>) {
const depth = Base.useFolderDepth();
const { depth, collapsible } = Base.useFolder()!;
return (
<Base.SidebarFolderTrigger
className={cn(itemVariants(), 'w-full', className)}
className={cn(
itemVariants({ variant: collapsible ? 'button' : null }),
'w-full',
className,
)}
style={{
paddingInlineStart: getItemOffset(depth - 1),
...style,