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'; import type { ReactNode } from 'react';
interface INode { interface ID {
/** /**
* ID for the node, unique in all page trees (even across different locales) * ID for the node, unique in all page trees (even across different locales)
*/ */
$id?: string; $id?: string;
} }
export interface Root extends INode { export interface Root extends ID {
name: ReactNode; name: ReactNode;
children: Node[]; children: Node[];
/** /**
@@ -18,7 +18,7 @@ export interface Root extends INode {
export type Node = Item | Separator | Folder; export type Node = Item | Separator | Folder;
export interface Item extends INode { export interface Item extends ID {
/** /**
* @internal * @internal
*/ */
@@ -40,13 +40,13 @@ export interface Item extends INode {
icon?: ReactNode; icon?: ReactNode;
} }
export interface Separator extends INode { export interface Separator extends ID {
type: 'separator'; type: 'separator';
name?: ReactNode; name?: ReactNode;
icon?: ReactNode; icon?: ReactNode;
} }
export interface Folder extends INode { export interface Folder extends ID {
/** /**
* @internal * @internal
*/ */
@@ -59,6 +59,7 @@ export interface Folder extends INode {
description?: ReactNode; description?: ReactNode;
root?: boolean; root?: boolean;
defaultOpen?: boolean; defaultOpen?: boolean;
collapsible?: boolean;
index?: Item; index?: Item;
icon?: ReactNode; icon?: ReactNode;
children: Node[]; children: Node[];
+19 -19
View File
@@ -268,20 +268,19 @@ function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
); );
let meta = storage.read(metaPath); let meta = storage.read(metaPath);
if (meta?.format !== 'meta') { if (meta && meta.format !== 'meta') meta = undefined;
meta = undefined;
}
const isRoot = meta?.data.root ?? isGlobalRoot; const metadata = meta?.data ?? {};
const { root = isGlobalRoot, pages } = metadata;
let index: PageTree.Item | undefined; let index: PageTree.Item | undefined;
let children: PageTree.Node[]; let children: PageTree.Node[];
if (meta && meta.data.pages) { if (pages) {
const resolved = meta.data.pages.flatMap< const resolved = pages.flatMap<
PageTree.Node | typeof rest | typeof restReversed PageTree.Node | typeof rest | typeof restReversed
>((item) => this.resolveFolderItem(folderPath, item)); >((item) => this.resolveFolderItem(folderPath, item));
if (!isRoot && !visitedPaths.has(indexPath)) { if (!root && !visitedPaths.has(indexPath)) {
index = this.file(indexPath); index = this.file(indexPath);
} }
@@ -300,7 +299,7 @@ function createPageTreeBuilderUtils(ctx: PageTreeBuilderContext) {
children = resolved as PageTree.Node[]; children = resolved as PageTree.Node[];
} else { } else {
if (!isRoot && !visitedPaths.has(indexPath)) { if (!root && !visitedPaths.has(indexPath)) {
index = this.file(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 = { let node: PageTree.Folder = {
type: 'folder', type: 'folder',
name, name:
icon: meta?.data.icon ?? index?.icon, metadata.title ??
root: meta?.data.root, index?.name ??
defaultOpen: meta?.data.defaultOpen, (() => {
description: meta?.data.description, 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, index,
children, children,
$id: nextNodeId(folderPath), $id: nextNodeId(folderPath),
+1
View File
@@ -13,6 +13,7 @@ export interface MetaData {
root?: boolean | undefined; root?: boolean | undefined;
pages?: string[] | undefined; pages?: string[] | undefined;
defaultOpen?: boolean | undefined; defaultOpen?: boolean | undefined;
collapsible?: boolean | undefined;
description?: string | undefined; description?: string | undefined;
} }
+1
View File
@@ -491,6 +491,7 @@ test('Loader: Serialize data', async () => {
"url": "/hello", "url": "/hello",
}, },
], ],
"collapsible": undefined,
"defaultOpen": undefined, "defaultOpen": undefined,
"description": undefined, "description": undefined,
"icon": undefined, "icon": undefined,
+3 -3
View File
@@ -1,7 +1,7 @@
import type { StandardSchemaV1 } from '@standard-schema/spec'; import type { StandardSchemaV1 } from '@standard-schema/spec';
import type { MDXPresetOptions } from '@/config/preset'; import type { MDXPresetOptions } from '@/config/preset';
import type { ProcessorOptions } from '@mdx-js/mdx'; 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 { PostprocessOptions } from '@/loaders/mdx/remark-postprocess';
import type { PluginOption } from '@/core'; import type { PluginOption } from '@/core';
import type { BuildEnvironment } from './build'; import type { BuildEnvironment } from './build';
@@ -106,7 +106,7 @@ export function defineCollections(
} }
export function defineDocs< export function defineDocs<
DocSchema extends StandardSchemaV1 = typeof frontmatterSchema, DocSchema extends StandardSchemaV1 = typeof pageSchema,
MetaSchema extends StandardSchemaV1 = typeof metaSchema, MetaSchema extends StandardSchemaV1 = typeof metaSchema,
>(options: { >(options: {
/** /**
@@ -127,7 +127,7 @@ export function defineDocs<
docs: defineCollections({ docs: defineCollections({
type: 'doc', type: 'doc',
dir, dir,
schema: frontmatterSchema as any, schema: pageSchema as any,
...options?.docs, ...options?.docs,
}), }),
meta: defineCollections({ meta: defineCollections({
+4 -1
View File
@@ -1,6 +1,9 @@
export * from './define'; export * from './define';
export * from '@/config/preset'; export * from '@/config/preset';
export * from './zod-4';
export { remarkInclude } from '@/loaders/mdx/remark-include'; export { remarkInclude } from '@/loaders/mdx/remark-include';
export type { PostprocessOptions } from '@/loaders/mdx/remark-postprocess'; 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'; 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([ export default defineConfig([
{ {
entry: [ entry: [
@@ -11,7 +18,8 @@ export default defineConfig([
'./src/plugins/*.ts', './src/plugins/*.ts',
], ],
format: 'esm', format: 'esm',
external: ['next', 'typescript', 'bun'], noExternal,
external,
dts: true, dts: true,
target: 'node22', target: 'node22',
}, },
@@ -22,7 +30,8 @@ export default defineConfig([
'./next/index': './src/next/index.ts', './next/index': './src/next/index.ts',
}, },
format: 'cjs', format: 'cjs',
external: ['next', 'typescript', 'bun'], noExternal,
external,
dts: false, dts: false,
target: 'node22', target: 'node22',
}, },
+55 -26
View File
@@ -74,6 +74,7 @@ const FolderContext = createContext<{
open: boolean; open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>; setOpen: React.Dispatch<React.SetStateAction<boolean>>;
depth: number; depth: number;
collapsible: boolean;
} | null>(null); } | null>(null);
export function SidebarProvider({ export function SidebarProvider({
@@ -125,6 +126,10 @@ export function useSidebar(): SidebarContext {
return ctx; return ctx;
} }
export function useFolder() {
return use(FolderContext);
}
export function useFolderDepth() { export function useFolderDepth() {
return use(FolderContext)?.depth ?? 0; return use(FolderContext)?.depth ?? 0;
} }
@@ -284,17 +289,22 @@ export function SidebarItem({
} }
export function SidebarFolder({ export function SidebarFolder({
defaultOpen: defaultOpenOption = false, defaultOpen: defaultOpenProp,
collapsible = true,
active = false,
children,
...props ...props
}: ComponentProps<'div'> & { }: ComponentProps<'div'> & {
defaultOpen?: boolean | ((currentDefault: boolean) => boolean); active?: boolean;
defaultOpen?: boolean;
collapsible?: boolean;
}) { }) {
const { defaultOpenLevel } = useSidebar(); const { defaultOpenLevel } = useSidebar();
const depth = useFolderDepth() + 1; const depth = useFolderDepth() + 1;
const defaultOpen = const defaultOpen =
typeof defaultOpenOption === 'function' collapsible === false ||
? defaultOpenOption(defaultOpenLevel >= depth) active ||
: defaultOpenLevel >= depth || defaultOpenOption; (defaultOpenProp ?? defaultOpenLevel >= depth);
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
useOnChange(defaultOpen, (v) => { useOnChange(defaultOpen, (v) => {
@@ -302,33 +312,48 @@ export function SidebarFolder({
}); });
return ( return (
<Collapsible open={open} onOpenChange={setOpen} {...props}> <Collapsible
open={open}
onOpenChange={setOpen}
disabled={!collapsible}
{...props}
>
<FolderContext <FolderContext
value={useMemo(() => ({ open, setOpen, depth }), [depth, open])} value={useMemo(
() => ({ open, setOpen, depth, collapsible }),
[collapsible, depth, open],
)}
> >
{props.children} {children}
</FolderContext> </FolderContext>
</Collapsible> </Collapsible>
); );
} }
export function SidebarFolderTrigger(props: CollapsibleTriggerProps) { export function SidebarFolderTrigger({
const { open } = use(FolderContext)!; children,
...props
}: CollapsibleTriggerProps) {
const { open, collapsible } = use(FolderContext)!;
return ( if (collapsible) {
<CollapsibleTrigger {...props}> return (
{props.children} <CollapsibleTrigger {...props}>
<ChevronDown {children}
data-icon <ChevronDown
className={cn('ms-auto transition-transform', !open && '-rotate-90')} data-icon
/> className={cn('ms-auto transition-transform', !open && '-rotate-90')}
</CollapsibleTrigger> />
); </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 ref = useRef<HTMLAnchorElement>(null);
const { open, setOpen } = use(FolderContext)!; const { open, setOpen, collapsible } = use(FolderContext)!;
const { prefetch } = useSidebar(); const { prefetch } = useSidebar();
const pathname = usePathname(); const pathname = usePathname();
const active = const active =
@@ -341,6 +366,8 @@ export function SidebarFolderLink(props: LinkProps) {
ref={ref} ref={ref}
data-active={active} data-active={active}
onClick={(e) => { onClick={(e) => {
if (!collapsible) return;
if ( if (
e.target instanceof Element && e.target instanceof Element &&
e.target.matches('[data-icon], [data-icon] *') e.target.matches('[data-icon], [data-icon] *')
@@ -354,11 +381,13 @@ export function SidebarFolderLink(props: LinkProps) {
prefetch={prefetch} prefetch={prefetch}
{...props} {...props}
> >
{props.children} {children}
<ChevronDown {collapsible && (
data-icon <ChevronDown
className={cn('ms-auto transition-transform', !open && '-rotate-90')} data-icon
/> className={cn('ms-auto transition-transform', !open && '-rotate-90')}
/>
)}
</Link> </Link>
); );
} }
@@ -38,9 +38,9 @@ export function createPageTreeRenderer({
return ( return (
<SidebarFolder <SidebarFolder
defaultOpen={(value) => collapsible={item.collapsible}
(item.defaultOpen ?? value) || path.includes(item) active={path.includes(item)}
} defaultOpen={item.defaultOpen}
> >
{item.index ? ( {item.index ? (
<SidebarFolderLink <SidebarFolderLink
+10 -4
View File
@@ -11,11 +11,13 @@ import { Sidebar as SidebarIcon } from 'lucide-react';
import { mergeRefs } from '@/utils/merge-refs'; import { mergeRefs } from '@/utils/merge-refs';
const itemVariants = cva( 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: { variants: {
variant: { 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: { 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", 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, style,
...props ...props
}: ComponentProps<typeof Base.SidebarFolderTrigger>) { }: ComponentProps<typeof Base.SidebarFolderTrigger>) {
const depth = Base.useFolderDepth(); const { depth, collapsible } = Base.useFolder()!;
return ( return (
<Base.SidebarFolderTrigger <Base.SidebarFolderTrigger
className={cn(itemVariants(), 'w-full', className)} className={cn(
itemVariants({ variant: collapsible ? 'button' : null }),
'w-full',
className,
)}
style={{ style={{
paddingInlineStart: getItemOffset(depth - 1), paddingInlineStart: getItemOffset(depth - 1),
...style, ...style,
+10 -4
View File
@@ -9,11 +9,13 @@ import { createLinkItemRenderer } from '@/components/sidebar/link-item';
import { mergeRefs } from '@/utils/merge-refs'; import { mergeRefs } from '@/utils/merge-refs';
const itemVariants = cva( 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: { variants: {
variant: { 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: { 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", 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, style,
...props ...props
}: ComponentProps<typeof Base.SidebarFolderTrigger>) { }: ComponentProps<typeof Base.SidebarFolderTrigger>) {
const depth = Base.useFolderDepth(); const { depth, collapsible } = Base.useFolder()!;
return ( return (
<Base.SidebarFolderTrigger <Base.SidebarFolderTrigger
className={cn(itemVariants(), 'w-full', className)} className={cn(
itemVariants({ variant: collapsible ? 'button' : null }),
'w-full',
className,
)}
style={{ style={{
paddingInlineStart: getItemOffset(depth - 1), paddingInlineStart: getItemOffset(depth - 1),
...style, ...style,