mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 23:49:51 +00:00
Core & UI: support collapsible in meta data
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'fumadocs-core': patch
|
||||
'fumadocs-ui': patch
|
||||
---
|
||||
|
||||
support `collapsible` in meta data
|
||||
@@ -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[];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface MetaData {
|
||||
root?: boolean | undefined;
|
||||
pages?: string[] | undefined;
|
||||
defaultOpen?: boolean | undefined;
|
||||
collapsible?: boolean | undefined;
|
||||
|
||||
description?: string | undefined;
|
||||
}
|
||||
|
||||
@@ -491,6 +491,7 @@ test('Loader: Serialize data', async () => {
|
||||
"url": "/hello",
|
||||
},
|
||||
],
|
||||
"collapsible": undefined,
|
||||
"defaultOpen": undefined,
|
||||
"description": undefined,
|
||||
"icon": undefined,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user