diff --git a/docs/app/blog/[[...slug]]/page.tsx b/docs/app/blog/[[...slug]]/page.tsx index bed1764f..3a0f6731 100644 --- a/docs/app/blog/[[...slug]]/page.tsx +++ b/docs/app/blog/[[...slug]]/page.tsx @@ -15,7 +15,6 @@ import defaultMdxComponents from "fumadocs-ui/mdx"; import { File, Folder, Files } from "fumadocs-ui/components/files"; import { Accordion, Accordions } from "fumadocs-ui/components/accordion"; import { Pre } from "fumadocs-ui/components/codeblock"; -import { DocsBody } from "fumadocs-ui/page"; import { Glow } from "../_components/default-changelog"; import { IconLink } from "../_components/changelog-layout"; import { BookIcon, GitHubIcon, XIcon } from "../_components/icons"; @@ -23,6 +22,7 @@ import { DiscordLogoIcon } from "@radix-ui/react-icons"; import { StarField } from "../_components/stat-field"; import Image from "next/image"; import { BlogPage } from "../_components/blog-list"; +import { Callout } from "@/components/ui/callout"; const metaTitle = "Blogs"; const metaDescription = "Latest changes , fixes and updates."; @@ -119,7 +119,7 @@ export default async function Page({
- +
( + + {children} + + ), }} /> - +
); diff --git a/docs/app/changelogs/[[...slug]]/page.tsx b/docs/app/changelogs/[[...slug]]/page.tsx index 294c3854..b1643eea 100644 --- a/docs/app/changelogs/[[...slug]]/page.tsx +++ b/docs/app/changelogs/[[...slug]]/page.tsx @@ -15,12 +15,12 @@ import defaultMdxComponents from "fumadocs-ui/mdx"; import { File, Folder, Files } from "fumadocs-ui/components/files"; import { Accordion, Accordions } from "fumadocs-ui/components/accordion"; import { Pre } from "fumadocs-ui/components/codeblock"; -import { DocsBody } from "fumadocs-ui/page"; import ChangelogPage, { Glow } from "../_components/default-changelog"; import { IconLink } from "../_components/changelog-layout"; import { XIcon } from "../_components/icons"; import { StarField } from "../_components/stat-field"; import { GridPatterns } from "../_components/grid-pattern"; +import { Callout } from "@/components/ui/callout"; const metaTitle = "Changelogs"; const metaDescription = "Latest changes , fixes and updates."; @@ -71,7 +71,7 @@ export default async function Page({
- +
( + + {children} + + ), }} /> - +
); diff --git a/docs/app/docs/[[...slug]]/page.tsx b/docs/app/docs/[[...slug]]/page.tsx index c09a3eff..fa6e771d 100644 --- a/docs/app/docs/[[...slug]]/page.tsx +++ b/docs/app/docs/[[...slug]]/page.tsx @@ -1,5 +1,5 @@ import { source } from "@/lib/source"; -import { DocsPage, DocsBody, DocsTitle } from "fumadocs-ui/page"; +import { DocsPage, DocsBody, DocsTitle } from "@/components/docs/page"; import { notFound } from "next/navigation"; import { absoluteUrl } from "@/lib/utils"; import DatabaseTable from "@/components/mdx/database-tables"; @@ -13,18 +13,22 @@ import { Features } from "@/components/blocks/features"; import { ForkButton } from "@/components/fork-button"; import Link from "next/link"; import defaultMdxComponents from "fumadocs-ui/mdx"; +import { + CodeBlock, + Pre, + CodeBlockTab, + CodeBlockTabsList, + CodeBlockTabs, +} from "@/components/ui/code-block"; import { File, Folder, Files } from "fumadocs-ui/components/files"; import { AutoTypeTable } from "fumadocs-typescript/ui"; import { Accordion, Accordions } from "fumadocs-ui/components/accordion"; -import { Card, Cards } from "fumadocs-ui/components/card"; -import { ChevronLeft, ChevronRight } from "lucide-react"; -import { contents } from "@/components/sidebar-content"; import { Endpoint } from "@/components/endpoint"; import { DividerText } from "@/components/divider-text"; import { APIMethod } from "@/components/api-method"; import { LLMCopyButton, ViewOptions } from "./page.client"; import { GenerateAppleJwt } from "@/components/generate-apple-jwt"; - +import { Callout } from "@/components/ui/callout"; export default async function Page({ params, }: { @@ -37,8 +41,6 @@ export default async function Page({ notFound(); } - const { nextPage, prevPage } = getPageLinks(page.url); - const MDX = page.data.body; const avoidLLMHeader = ["Introduction", "Comparison"]; return ( @@ -49,16 +51,11 @@ export default async function Page({ owner: "better-auth", repo: "better-auth", sha: "main", - path: `/docs/content/docs/${page.file.path}`, + path: `/docs/content/docs/${page.path}`, }} tableOfContent={{ - style: "clerk", header:
, }} - footer={{ - enabled: true, - component:
, - }} > {page.data.title} {!avoidLLMHeader.includes(page.data.title) && ( @@ -74,6 +71,38 @@ export default async function Page({ { + return ( + +
{props.children}
+
+ ); + }, + CodeBlockTabsList: (props) => { + return ( + + ); + }, + CodeBlockTab: (props) => { + return ; + }, + pre: (props) => { + return ( + +
+
+											{props.children}
+										
+
+
+ ); + }, Link: ({ className, ...props @@ -105,19 +134,18 @@ export default async function Page({ Accordions, Endpoint, APIMethod, - Callout: ({ children, ...props }) => ( - + Callout: ({ + children, + type, + ...props + }: { + children: React.ReactNode; + type?: "info" | "warn" | "error" | "success" | "warning"; + [key: string]: any; + }) => ( + {children} - + ), DividerText, iframe: (props) => ( @@ -125,48 +153,12 @@ export default async function Page({ ), }} /> - - - {prevPage ? ( - {prevPage.data.description}} - title={ -
- - {prevPage.data.title} -
- } - /> - ) : ( -
- )} - {nextPage ? ( - {nextPage.data.description}} - title={ -
- {nextPage.data.title} - -
- } - className="flex flex-col items-end text-right [&>p]:ml-1 [&>p]:truncate [&>p]:w-full" - /> - ) : ( -
- )} -
); } export async function generateStaticParams() { - const res = source.getPages().map((page) => ({ - slug: page.slugs, - })); return source.generateParams(); } @@ -211,56 +203,3 @@ export async function generateMetadata({ }, }; } - -function getPageLinks(path: string) { - const current_category_index = contents.findIndex( - (x) => x.list.find((x) => x.href === path)!, - )!; - const current_category = contents[current_category_index]; - if (!current_category) return { nextPage: undefined, prevPage: undefined }; - - // user's current page. - const current_page = current_category.list.find((x) => x.href === path)!; - - // the next page in the array. - let next_page = current_category.list.filter((x) => !x.group)[ - current_category.list - .filter((x) => !x.group) - .findIndex((x) => x.href === current_page.href) + 1 - ]; - //if there isn't a next page, then go to next cat's page. - if (!next_page) { - // get next cat - let next_category = contents[current_category_index + 1]; - // if doesn't exist, return to first cat. - if (!next_category) next_category = contents[0]; - - next_page = next_category.list[0]; - if (next_page.group) { - next_page = next_category.list[1]; - } - } - // the prev page in the array. - let prev_page = current_category.list.filter((x) => !x.group)[ - current_category.list - .filter((x) => !x.group) - .findIndex((x) => x.href === current_page.href) - 1 - ]; - // if there isn't a prev page, then go to prev cat's page. - if (!prev_page) { - // get prev cat - let prev_category = contents[current_category_index - 1]; - // if doesn't exist, return to last cat. - if (!prev_category) prev_category = contents[contents.length - 1]; - prev_page = prev_category.list[prev_category.list.length - 1]; - if (prev_page.group) { - prev_page = prev_category.list[prev_category.list.length - 2]; - } - } - - const pages = source.getPages(); - let next_page2 = pages.find((x) => x.url === next_page.href); - let prev_page2 = pages.find((x) => x.url === prev_page.href); - if (path === "/docs/introduction") prev_page2 = undefined; - return { nextPage: next_page2, prevPage: prev_page2 }; -} diff --git a/docs/app/docs/layout.tsx b/docs/app/docs/layout.tsx index b82a318b..8b08177d 100644 --- a/docs/app/docs/layout.tsx +++ b/docs/app/docs/layout.tsx @@ -1,26 +1,7 @@ -import { DocsLayout } from "fumadocs-ui/layouts/docs"; +import { DocsLayout } from "@/components/docs/docs"; import type { ReactNode } from "react"; import { docsOptions } from "../layout.config"; -import ArticleLayout from "@/components/side-bar"; -import { cn } from "@/lib/utils"; export default function Layout({ children }: { children: ReactNode }) { - return ( - - -
- ), - }} - > - {children} - - ); + return {children}; } diff --git a/docs/app/docs/lib/get-llm-text.ts b/docs/app/docs/lib/get-llm-text.ts index 36d96a86..de12b9dc 100644 --- a/docs/app/docs/lib/get-llm-text.ts +++ b/docs/app/docs/lib/get-llm-text.ts @@ -1,6 +1,7 @@ import { remark } from "remark"; import remarkGfm from "remark-gfm"; -import { fileGenerator, remarkDocGen, remarkInstall } from "fumadocs-docgen"; +import { fileGenerator, remarkDocGen } from "fumadocs-docgen"; +import { remarkNpm } from "fumadocs-core/mdx-plugins"; import remarkStringify from "remark-stringify"; import remarkMdx from "remark-mdx"; import { remarkAutoTypeTable } from "fumadocs-typescript"; @@ -13,7 +14,7 @@ const processor = remark() .use(remarkGfm) .use(remarkAutoTypeTable) .use(remarkDocGen, { generators: [fileGenerator()] }) - .use(remarkInstall) + .use(remarkNpm) .use(remarkStringify); export async function getLLMText(docPage: any) { diff --git a/docs/app/global.css b/docs/app/global.css index c9e499ab..edf13af9 100644 --- a/docs/app/global.css +++ b/docs/app/global.css @@ -4,8 +4,7 @@ @config "../tailwind.config.js"; @plugin 'tailwindcss-animate'; @custom-variant dark (&:is(.dark *)); -@source '../../node_modules/fumadocs-ui/dist/**/*.js'; -@source '../node_modules/fumadocs-ui/dist/**/*.js'; + :root { --fd-nav-height: 56px; diff --git a/docs/app/layout.config.tsx b/docs/app/layout.config.tsx index df239311..cfb97b70 100644 --- a/docs/app/layout.config.tsx +++ b/docs/app/layout.config.tsx @@ -1,24 +1,5 @@ -import { changelogs, source } from "@/lib/source"; -import { BaseLayoutProps } from "fumadocs-ui/layouts/shared"; - -export const baseOptions: BaseLayoutProps = { - nav: { - enabled: false, - }, - links: [ - { - text: "Documentation", - url: "/docs", - active: "nested-url", - }, - ], -}; +import { source } from "@/lib/source"; export const docsOptions = { - ...baseOptions, tree: source.pageTree, }; -export const changelogOptions = { - ...baseOptions, - tree: changelogs.pageTree, -}; diff --git a/docs/app/reference/route.ts b/docs/app/reference/route.ts index 781f4e11..1eac8380 100644 --- a/docs/app/reference/route.ts +++ b/docs/app/reference/route.ts @@ -1,9 +1,7 @@ import { ApiReference } from "@scalar/nextjs-api-reference"; -const config = { +export const GET = ApiReference({ spec: { url: "/openapi.yml", }, -}; - -export const GET = ApiReference(config); +}); diff --git a/docs/components/docs/docs.client.tsx b/docs/components/docs/docs.client.tsx new file mode 100644 index 00000000..e982a57d --- /dev/null +++ b/docs/components/docs/docs.client.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Menu, X } from "lucide-react"; +import { type ButtonHTMLAttributes, type HTMLAttributes } from "react"; +import { cn } from "../../lib/utils"; +import { buttonVariants } from "./ui/button"; +import { useSidebar } from "fumadocs-ui/provider"; +import { useNav } from "./layout/nav"; +import { SidebarTrigger } from "fumadocs-core/sidebar"; + +export function Navbar(props: HTMLAttributes) { + const { open } = useSidebar(); + const { isTransparent } = useNav(); + + return ( +
+ {props.children} +
+ ); +} + +export function NavbarSidebarTrigger( + props: ButtonHTMLAttributes, +) { + const { open } = useSidebar(); + + return ( + + {open ? : } + + ); +} diff --git a/docs/components/docs/docs.tsx b/docs/components/docs/docs.tsx new file mode 100644 index 00000000..66546991 --- /dev/null +++ b/docs/components/docs/docs.tsx @@ -0,0 +1,57 @@ +import type { PageTree } from "fumadocs-core/server"; +import { type ReactNode, type HTMLAttributes } from "react"; +import { cn } from "../../lib/utils"; +import { type BaseLayoutProps } from "./shared"; +import { TreeContextProvider } from "fumadocs-ui/provider"; +import { NavProvider } from "./layout/nav"; +import { type PageStyles, StylesProvider } from "fumadocs-ui/provider"; +import ArticleLayout from "../side-bar"; + +export interface DocsLayoutProps extends BaseLayoutProps { + tree: PageTree.Root; + + containerProps?: HTMLAttributes; +} + +export function DocsLayout({ children, ...props }: DocsLayoutProps): ReactNode { + const variables = cn( + "[--fd-tocnav-height:36px] md:[--fd-sidebar-width:268px] lg:[--fd-sidebar-width:286px] xl:[--fd-toc-width:286px] xl:[--fd-tocnav-height:0px]", + ); + + const pageStyles: PageStyles = { + tocNav: cn("xl:hidden"), + toc: cn("max-xl:hidden"), + }; + + return ( + + +
+
+ +
+ {children} +
+
+
+ ); +} diff --git a/docs/components/docs/layout/nav.tsx b/docs/components/docs/layout/nav.tsx new file mode 100644 index 00000000..23ad1880 --- /dev/null +++ b/docs/components/docs/layout/nav.tsx @@ -0,0 +1,93 @@ +"use client"; +import Link, { type LinkProps } from "fumadocs-core/link"; +import { + createContext, + type ReactNode, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { cn } from "../../../lib/utils"; +import { useI18n } from "fumadocs-ui/provider"; + +export interface NavProviderProps { + /** + * Use transparent background + * + * @defaultValue none + */ + transparentMode?: "always" | "top" | "none"; +} + +export interface TitleProps { + title?: ReactNode; + + /** + * Redirect url of title + * @defaultValue '/' + */ + url?: string; +} + +interface NavContextType { + isTransparent: boolean; +} + +const NavContext = createContext({ + isTransparent: false, +}); + +export function NavProvider({ + transparentMode = "none", + children, +}: NavProviderProps & { children: ReactNode }) { + const [transparent, setTransparent] = useState(transparentMode !== "none"); + + useEffect(() => { + if (transparentMode !== "top") return; + + const listener = () => { + setTransparent(window.scrollY < 10); + }; + + listener(); + window.addEventListener("scroll", listener); + return () => { + window.removeEventListener("scroll", listener); + }; + }, [transparentMode]); + + return ( + ({ isTransparent: transparent }), [transparent])} + > + {children} + + ); +} + +export function useNav(): NavContextType { + return useContext(NavContext); +} + +export function Title({ + title, + url, + ...props +}: TitleProps & Omit) { + const { locale } = useI18n(); + + return ( + + {title} + + ); +} diff --git a/docs/components/docs/layout/theme-toggle.tsx b/docs/components/docs/layout/theme-toggle.tsx new file mode 100644 index 00000000..0743b7e9 --- /dev/null +++ b/docs/components/docs/layout/theme-toggle.tsx @@ -0,0 +1,87 @@ +"use client"; +import { cva } from "class-variance-authority"; +import { Moon, Sun, Airplay } from "lucide-react"; +import { useTheme } from "next-themes"; +import { type HTMLAttributes, useLayoutEffect, useState } from "react"; +import { cn } from "../../../lib/utils"; + +const itemVariants = cva( + "size-6.5 rounded-full p-1.5 text-fd-muted-foreground", + { + variants: { + active: { + true: "bg-fd-accent text-fd-accent-foreground", + false: "text-fd-muted-foreground", + }, + }, + }, +); + +const full = [ + ["light", Sun] as const, + ["dark", Moon] as const, + ["system", Airplay] as const, +]; + +export function ThemeToggle({ + className, + mode = "light-dark", + ...props +}: HTMLAttributes & { + mode?: "light-dark" | "light-dark-system"; +}) { + const { setTheme, theme, resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useLayoutEffect(() => { + setMounted(true); + }, []); + + const container = cn( + "inline-flex items-center rounded-full border p-1", + className, + ); + + if (mode === "light-dark") { + const value = mounted ? resolvedTheme : null; + + return ( + + ); + } + + const value = mounted ? theme : null; + + return ( +
+ {full.map(([key, Icon]) => ( + + ))} +
+ ); +} diff --git a/docs/components/docs/layout/toc-thumb.tsx b/docs/components/docs/layout/toc-thumb.tsx new file mode 100644 index 00000000..a13edc6b --- /dev/null +++ b/docs/components/docs/layout/toc-thumb.tsx @@ -0,0 +1,73 @@ +import { type HTMLAttributes, type RefObject, useEffect, useRef } from "react"; +import * as Primitive from "fumadocs-core/toc"; +import { useOnChange } from "fumadocs-core/utils/use-on-change"; +import { useEffectEvent } from "fumadocs-core/utils/use-effect-event"; + +export type TOCThumb = [top: number, height: number]; + +function calc(container: HTMLElement, active: string[]): TOCThumb { + if (active.length === 0 || container.clientHeight === 0) { + return [0, 0]; + } + + let upper = Number.MAX_VALUE, + lower = 0; + + for (const item of active) { + const element = container.querySelector(`a[href="#${item}"]`); + if (!element) continue; + + const styles = getComputedStyle(element); + upper = Math.min(upper, element.offsetTop + parseFloat(styles.paddingTop)); + lower = Math.max( + lower, + element.offsetTop + + element.clientHeight - + parseFloat(styles.paddingBottom), + ); + } + + return [upper, lower - upper]; +} + +function update(element: HTMLElement, info: TOCThumb): void { + element.style.setProperty("--fd-top", `${info[0]}px`); + element.style.setProperty("--fd-height", `${info[1]}px`); +} + +export function TocThumb({ + containerRef, + ...props +}: HTMLAttributes & { + containerRef: RefObject; +}) { + const active = Primitive.useActiveAnchors(); + const thumbRef = useRef(null); + + const onResize = useEffectEvent(() => { + if (!containerRef.current || !thumbRef.current) return; + + update(thumbRef.current, calc(containerRef.current, active)); + }); + + useEffect(() => { + if (!containerRef.current) return; + const container = containerRef.current; + + onResize(); + const observer = new ResizeObserver(onResize); + observer.observe(container); + + return () => { + observer.disconnect(); + }; + }, [containerRef, onResize]); + + useOnChange(active, () => { + if (!containerRef.current || !thumbRef.current) return; + + update(thumbRef.current, calc(containerRef.current, active)); + }); + + return
; +} diff --git a/docs/components/docs/layout/toc.tsx b/docs/components/docs/layout/toc.tsx new file mode 100644 index 00000000..96a78bbe --- /dev/null +++ b/docs/components/docs/layout/toc.tsx @@ -0,0 +1,345 @@ +"use client"; +import type { TOCItemType } from "fumadocs-core/server"; +import * as Primitive from "fumadocs-core/toc"; +import { + type ComponentProps, + createContext, + type HTMLAttributes, + type ReactNode, + use, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { cn } from "@/lib/utils"; +import { useI18n } from "fumadocs-ui/provider"; +import { TocThumb } from "./toc-thumb"; +import { ScrollArea, ScrollViewport } from "../ui/scroll-area"; +import type { + PopoverContentProps, + PopoverTriggerProps, +} from "@radix-ui/react-popover"; +import { ChevronRight, Text } from "lucide-react"; +import { usePageStyles } from "fumadocs-ui/provider"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "../ui/collapsible"; + +export interface TOCProps { + /** + * Custom content in TOC container, before the main TOC + */ + header?: ReactNode; + + /** + * Custom content in TOC container, after the main TOC + */ + footer?: ReactNode; + + children: ReactNode; +} + +export function Toc(props: HTMLAttributes) { + const { toc } = usePageStyles(); + + return ( +
+
+ {props.children} +
+
+ ); +} + +export function TocItemsEmpty() { + const { text } = useI18n(); + + return ( +
+ {text.tocNoHeadings} +
+ ); +} + +export function TOCScrollArea({ + isMenu, + ...props +}: ComponentProps & { isMenu?: boolean }) { + const viewRef = useRef(null); + + return ( + + + + {props.children} + + + + ); +} + +export function TOCItems({ items }: { items: TOCItemType[] }) { + const containerRef = useRef(null); + + const [svg, setSvg] = useState<{ + path: string; + width: number; + height: number; + }>(); + + useEffect(() => { + if (!containerRef.current) return; + const container = containerRef.current; + + function onResize(): void { + if (container.clientHeight === 0) return; + let w = 0, + h = 0; + const d: string[] = []; + for (let i = 0; i < items.length; i++) { + const element: HTMLElement | null = container.querySelector( + `a[href="#${items[i].url.slice(1)}"]`, + ); + if (!element) continue; + + const styles = getComputedStyle(element); + const offset = getLineOffset(items[i].depth) + 1, + top = element.offsetTop + parseFloat(styles.paddingTop), + bottom = + element.offsetTop + + element.clientHeight - + parseFloat(styles.paddingBottom); + + w = Math.max(offset, w); + h = Math.max(h, bottom); + + d.push(`${i === 0 ? "M" : "L"}${offset} ${top}`); + d.push(`L${offset} ${bottom}`); + } + + setSvg({ + path: d.join(" "), + width: w + 1, + height: h, + }); + } + + const observer = new ResizeObserver(onResize); + onResize(); + + observer.observe(container); + return () => { + observer.disconnect(); + }; + }, [items]); + + if (items.length === 0) return ; + + return ( + <> + {svg ? ( +
`, + ) + }")`, + }} + > + +
+ ) : null} +
+ {items.map((item, i) => ( + + ))} +
+ + ); +} + +function getItemOffset(depth: number): number { + if (depth <= 2) return 14; + if (depth === 3) return 26; + return 36; +} + +function getLineOffset(depth: number): number { + return depth >= 3 ? 10 : 0; +} + +function TOCItem({ + item, + upper = item.depth, + lower = item.depth, +}: { + item: TOCItemType; + upper?: number; + lower?: number; +}) { + const offset = getLineOffset(item.depth), + upperOffset = getLineOffset(upper), + lowerOffset = getLineOffset(lower); + + return ( + + {offset !== upperOffset ? ( + + + + ) : null} +
+ {item.title} + + ); +} + +type MakeRequired = T & { [P in K]-?: T[P] }; + +const Context = createContext<{ + open: boolean; + setOpen: (open: boolean) => void; +} | null>(null); + +const TocProvider = Context.Provider || Context; + +export function TocPopover({ + open, + onOpenChange, + ref: _ref, + ...props +}: MakeRequired, "open" | "onOpenChange">) { + return ( + + ({ + open, + setOpen: onOpenChange, + }), + [onOpenChange, open], + )} + > + {props.children} + + + ); +} + +export function TocPopoverTrigger({ + items, + ...props +}: PopoverTriggerProps & { items: TOCItemType[] }) { + const { text } = useI18n(); + const { open } = use(Context)!; + const active = Primitive.useActiveAnchor(); + const current = useMemo(() => { + return items.find((item) => active === item.url.slice(1))?.title; + }, [items, active]); + + return ( + + + {text.toc} + + + {current} + + + ); +} + +export function TocPopoverContent(props: PopoverContentProps) { + return ( + + {props.children} + + ); +} diff --git a/docs/components/docs/page.client.tsx b/docs/components/docs/page.client.tsx new file mode 100644 index 00000000..4cd07540 --- /dev/null +++ b/docs/components/docs/page.client.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { + Fragment, + type HTMLAttributes, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import Link from "next/link"; +import { cva } from "class-variance-authority"; +import { cn } from "../../lib/utils"; +import { useI18n } from "fumadocs-ui/provider"; +import { useTreeContext, useTreePath } from "fumadocs-ui/provider"; +import { useSidebar } from "fumadocs-ui/provider"; +import type { PageTree } from "fumadocs-core/server"; +import { usePathname } from "next/navigation"; +import { useNav } from "./layout/nav"; +import { + type BreadcrumbOptions, + getBreadcrumbItemsFromPath, +} from "fumadocs-core/breadcrumb"; +import { usePageStyles } from "fumadocs-ui/provider"; +import { isActive } from "../../lib/is-active"; +import { TocPopover } from "./layout/toc"; +import { useEffectEvent } from "fumadocs-core/utils/use-effect-event"; + +export function TocPopoverHeader(props: HTMLAttributes) { + const ref = useRef(null); + const [open, setOpen] = useState(false); + const sidebar = useSidebar(); + const { tocNav } = usePageStyles(); + const { isTransparent } = useNav(); + + const onClick = useEffectEvent((e: Event) => { + if (!open) return; + + if (ref.current && !ref.current.contains(e.target as HTMLElement)) + setOpen(false); + }); + + useEffect(() => { + window.addEventListener("click", onClick); + + return () => { + window.removeEventListener("click", onClick); + }; + }, [onClick]); + + return ( +
+ +
+ {props.children} +
+
+
+ ); +} + +export function PageBody(props: HTMLAttributes) { + const { page } = usePageStyles(); + + return ( +
+ {props.children} +
+ ); +} + +export function PageArticle(props: HTMLAttributes) { + const { article } = usePageStyles(); + + return ( +
+ {props.children} +
+ ); +} + +export function LastUpdate(props: { date: Date }) { + const { text } = useI18n(); + const [date, setDate] = useState(""); + + useEffect(() => { + // to the timezone of client + setDate(props.date.toLocaleDateString()); + }, [props.date]); + + return ( +

+ {text.lastUpdate} {date} +

+ ); +} + +export interface FooterProps { + /** + * Items including information for the next and previous page + */ + items?: { + previous?: { name: string; url: string }; + next?: { name: string; url: string }; + }; +} + +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[] = []; + + tree.forEach((node) => { + if (node.type === "folder") { + if (node.index) { + list.push(node.index); + } + + list.push(...scanNavigationList(node.children)); + return; + } + + if (node.type === "page" && !node.external) { + list.push(node); + } + }); + + return list; +} + +const listCache = new WeakMap(); + +export function Footer({ items }: FooterProps) { + const { root } = useTreeContext(); + const { text } = useI18n(); + const pathname = usePathname(); + + const { previous, next } = useMemo(() => { + if (items) return items; + + const cached = listCache.get(root); + const list = cached ?? scanNavigationList(root.children); + listCache.set(root, list); + + const idx = list.findIndex((item) => isActive(item.url, pathname, false)); + + if (idx === -1) return {}; + return { + previous: list[idx - 1], + next: list[idx + 1], + }; + }, [items, pathname, root]); + + return ( +
+ {previous ? ( + +
+ +

{text.previousPage}

+
+

{previous.name}

+ + ) : null} + {next ? ( + +
+ +

{text.nextPage}

+
+

{next.name}

+ + ) : null} +
+ ); +} + +export type BreadcrumbProps = BreadcrumbOptions; + +export function Breadcrumb(options: BreadcrumbProps) { + const path = useTreePath(); + const { root } = useTreeContext(); + const items = useMemo(() => { + return getBreadcrumbItemsFromPath(root, path, { + includePage: options.includePage ?? false, + ...options, + }); + }, [options, path, root]); + + if (items.length === 0) return null; + + return ( +
+ {items.map((item, i) => { + const className = cn( + "truncate", + i === items.length - 1 && "text-fd-primary font-medium", + ); + + return ( + + {i !== 0 && /} + {item.url ? ( + + {item.name} + + ) : ( + {item.name} + )} + + ); + })} +
+ ); +} diff --git a/docs/components/docs/page.tsx b/docs/components/docs/page.tsx new file mode 100644 index 00000000..afdab28e --- /dev/null +++ b/docs/components/docs/page.tsx @@ -0,0 +1,298 @@ +import type { TableOfContents } from "fumadocs-core/server"; +import { + type AnchorHTMLAttributes, + forwardRef, + type HTMLAttributes, + type ReactNode, +} from "react"; +import { type AnchorProviderProps, AnchorProvider } from "fumadocs-core/toc"; +import { replaceOrDefault } from "./shared"; +import { cn } from "../../lib/utils"; +import { + Footer, + type FooterProps, + LastUpdate, + TocPopoverHeader, + type BreadcrumbProps, + PageBody, + PageArticle, +} from "./page.client"; +import { + Toc, + TOCItems, + TocPopoverTrigger, + TocPopoverContent, + type TOCProps, + TOCScrollArea, +} from "./layout/toc"; +import { buttonVariants } from "./ui/button"; +import { Edit, Text } from "lucide-react"; +import { I18nLabel } from "fumadocs-ui/provider"; + +type TableOfContentOptions = Omit & + Pick & { + enabled: boolean; + component: ReactNode; + }; + +type TableOfContentPopoverOptions = Omit; + +interface EditOnGitHubOptions + extends Omit, "href" | "children"> { + owner: string; + repo: string; + + /** + * SHA or ref (branch or tag) name. + * + * @defaultValue main + */ + sha?: string; + + /** + * File path in the repo + */ + path: string; +} + +interface BreadcrumbOptions extends BreadcrumbProps { + enabled: boolean; + component: ReactNode; + + /** + * Show the full path to the current page + * + * @defaultValue false + * @deprecated use `includePage` instead + */ + full?: boolean; +} + +interface FooterOptions extends FooterProps { + enabled: boolean; + component: ReactNode; +} + +export interface DocsPageProps { + toc?: TableOfContents; + + /** + * Extend the page to fill all available space + * + * @defaultValue false + */ + full?: boolean; + + tableOfContent?: Partial; + tableOfContentPopover?: Partial; + + /** + * Replace or disable breadcrumb + */ + breadcrumb?: Partial; + + /** + * Footer navigation, you can disable it by passing `false` + */ + footer?: Partial; + + editOnGithub?: EditOnGitHubOptions; + lastUpdate?: Date | string | number; + + container?: HTMLAttributes; + article?: HTMLAttributes; + children: ReactNode; +} + +export function DocsPage({ + toc = [], + full = false, + tableOfContentPopover: { + enabled: tocPopoverEnabled, + component: tocPopoverReplace, + ...tocPopoverOptions + } = {}, + tableOfContent: { + enabled: tocEnabled, + component: tocReplace, + ...tocOptions + } = {}, + ...props +}: DocsPageProps) { + const isTocRequired = + toc.length > 0 || + tocOptions.footer !== undefined || + tocOptions.header !== undefined; + + // disable TOC on full mode, you can still enable it with `enabled` option. + tocEnabled ??= !full && isTocRequired; + + tocPopoverEnabled ??= + toc.length > 0 || + tocPopoverOptions.header !== undefined || + tocPopoverOptions.footer !== undefined; + + return ( + + + {replaceOrDefault( + { enabled: tocPopoverEnabled, component: tocPopoverReplace }, + + + + {tocPopoverOptions.header} + + + + {tocPopoverOptions.footer} + + , + { + items: toc, + ...tocPopoverOptions, + }, + )} + + {props.children} +
+
+ {props.editOnGithub ? ( + + ) : null} + {props.lastUpdate ? ( + + ) : null} +
+ {replaceOrDefault( + props.footer, +