stack/docs/src/components/layouts/docs.tsx
2025-07-02 16:05:09 -05:00

686 lines
20 KiB
TypeScript

/**
* DOCS BASE LAYOUT
*
* This file contains the core documentation layout structure and styling.
* It provides the foundational DocsLayout component that handles the actual
* rendering of the documentation interface.
*
* ARCHITECTURE:
* 1. app/docs/layout.tsx
* ↓ imports DynamicDocsLayout
* 2. docs-layout-router.tsx (ROUTER)
* ↓ routes to appropriate config → imports DocsLayout
* 3. docs.tsx (THIS FILE - BASE LAYOUT)
* ↓ renders the actual layout structure
*
* RESPONSIBILITIES:
* - Core layout structure (sidebar, main content, navigation)
* - Sidebar rendering with custom content injection
* - Platform tab integration
* - Page tree navigation for general docs
* - Theme and responsive behavior
*
* EXPORTED COMPONENTS:
* - DocsLayout: Main layout component used by router
* - SdkSidebarContent: SDK-specific navigation content
* - DocsLayoutSidebar: Fixed sidebar with custom content support
* - DocsLayoutSidebarFooter: Sidebar footer with theme toggle
*
* CUSTOM UI COMPONENTS:
* - DocsSidebarLink: Styled navigation links
* - DocsSeparator: Section dividers
* - CollapsibleSection: Expandable navigation groups
* - PageTreeItem: Recursive page tree rendering
*/
import Link from 'fumadocs-core/link';
import type { PageTree } from 'fumadocs-core/server';
import {
NavProvider,
type PageStyles,
StylesProvider,
} from 'fumadocs-ui/contexts/layout';
import { TreeContextProvider } from 'fumadocs-ui/contexts/tree';
import { ArrowLeft, ChevronDown, ChevronRight, Languages } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { createContext, type HTMLAttributes, type ReactNode, useContext, useMemo, useRef, useState } from 'react';
import { cn } from '../../lib/cn';
import { getCurrentPlatform } from '../../lib/platform-utils';
import { CustomSearchDialog } from '../layout/custom-search-dialog';
import {
SearchInputToggle
} from '../layout/custom-search-toggle';
import {
LanguageToggle,
LanguageToggleText,
} from '../layout/language-toggle';
import { RootToggle } from '../layout/root-toggle';
import { ThemeToggle } from '../layout/theme-toggle';
import { buttonVariants } from '../ui/button';
import { HideIfEmpty } from '../ui/hide-if-empty';
import { ScrollArea, ScrollViewport } from '../ui/scroll-area';
import {
CollapsibleControl,
Navbar,
NavbarSidebarTrigger,
} from './docs-client';
import { ComponentsSidebarContent } from './docs-layout-router';
import {
getSidebarTabsFromOptions,
layoutVariables,
type SidebarOptions
} from './docs/shared';
import {
BaseLinkItem,
type IconItemType,
type LinkItemType,
} from './links';
import { type BaseLayoutProps, getLinks, omit, slot, slots } from './shared';
import {
isInApiSection,
isInComponentsSection,
isInSdkSection
} from './shared/section-utils';
import { useTOC } from './toc-context';
// Import chat context
import { useChatContext } from '../chat/ai-chat';
// Context for persisting accordion state
type AccordionContextType = {
accordionState: Record<string, boolean>,
setAccordionState: (key: string, isOpen: boolean) => void,
};
const AccordionContext = createContext<AccordionContextType | null>(null);
function AccordionProvider({ children }: { children: ReactNode }) {
const [accordionState, setAccordionStateInternal] = useState<Record<string, boolean>>({});
const setAccordionState = (key: string, isOpen: boolean) => {
setAccordionStateInternal(prev => ({ ...prev, [key]: isOpen }));
};
return (
<AccordionContext.Provider value={{ accordionState, setAccordionState }}>
{children}
</AccordionContext.Provider>
);
}
function useAccordionState(key: string, defaultValue: boolean) {
const context = useContext(AccordionContext);
if (!context) {
throw new Error('useAccordionState must be used within AccordionProvider');
}
const { accordionState, setAccordionState } = context;
const isOpen = accordionState[key] ?? defaultValue;
const setIsOpen = (value: boolean) => {
setAccordionState(key, value);
};
return [isOpen, setIsOpen] as const;
}
// Custom Link Component for docs sidebar - matches API sidebar
function DocsSidebarLink({
href,
children,
external = false
}: {
href: string,
children: ReactNode,
external?: boolean,
}) {
const pathname = usePathname();
const isActive = pathname === href;
return (
<Link
href={href}
className={`flex items-center gap-2 px-2 py-1.5 rounded-md text-xs transition-colors ${
isActive
? 'bg-fd-primary/10 text-fd-primary font-medium'
: 'text-fd-muted-foreground hover:text-fd-foreground hover:bg-fd-muted/50'
}`}
{...(external && { target: '_blank', rel: 'noopener noreferrer' })}
>
<span className="flex-1">{children}</span>
</Link>
);
}
// Custom separator component - matches API sidebar
function DocsSeparator({ children }: { children: ReactNode }) {
return (
<div className="mt-6 mb-3 first:mt-2">
<span className="text-xs font-bold text-fd-foreground uppercase tracking-wider">
{children}
</span>
</div>
);
}
// Custom collapsible section component - matches API sidebar
function CollapsibleSection({
title,
children,
defaultOpen = false
}: {
title: string,
children: ReactNode,
defaultOpen?: boolean,
}) {
// Use context for persistent state
const accordionKey = `section-${title.toLowerCase().replace(/\s+/g, '-')}`;
const [isOpen, setIsOpen] = useAccordionState(accordionKey, defaultOpen);
return (
<div className="space-y-1">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 w-full text-left px-2 py-1.5 text-xs font-medium text-fd-muted-foreground hover:text-fd-foreground transition-colors"
>
{isOpen ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
{title}
</button>
{isOpen && (
<div className="ml-4 space-y-1">
{children}
</div>
)}
</div>
);
}
// Clickable collapsible section component - for folders with index pages
function ClickableCollapsibleSection({
title,
href,
children,
defaultOpen = false
}: {
title: string,
href: string,
children: ReactNode,
defaultOpen?: boolean,
}) {
const pathname = usePathname();
const isActive = pathname === href || pathname.startsWith(href + '/');
// Use context for persistent state
const accordionKey = `collapsible-${href}`;
const [isOpen, setIsOpen] = useAccordionState(accordionKey, defaultOpen || isActive);
const containerRef = useRef<HTMLDivElement>(null);
// Note: Removed outside click detection as it was interfering with navigation
// The accordion should stay open when user navigates within the section
return (
<div className="space-y-1" ref={containerRef}>
<div className="group">
<Link
href={href}
className={`flex items-center justify-between w-full px-2 py-1.5 rounded-md text-xs transition-colors ${
isActive
? 'bg-fd-primary/10 text-fd-primary font-medium'
: 'text-fd-muted-foreground hover:text-fd-foreground hover:bg-fd-muted/50'
}`}
onClick={() => {
if (!isOpen) {
setIsOpen(true);
}
}}
>
<span className="flex-1">{title}</span>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsOpen(!isOpen);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-fd-muted/30"
>
{isOpen ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</button>
</Link>
</div>
{isOpen && (
<div className="ml-4 space-y-1">
{children}
</div>
)}
</div>
);
}
// SDK-specific sidebar content
export function SdkSidebarContent() {
const pathname = usePathname();
const currentPlatform = getCurrentPlatform(pathname) || undefined;
if (!currentPlatform) return null;
const baseUrl = `/docs/${currentPlatform}/sdk`;
const isReactLike = ['next', 'react'].includes(currentPlatform);
return (
<>
<DocsSidebarLink href={`${baseUrl}/overview`}>
Overview
</DocsSidebarLink>
<DocsSeparator>
Objects
</DocsSeparator>
<DocsSidebarLink href={`${baseUrl}/objects/stack-app`}>
StackApp
</DocsSidebarLink>
<DocsSeparator>
Types
</DocsSeparator>
<DocsSidebarLink href={`${baseUrl}/types/user`}>
User
</DocsSidebarLink>
<DocsSidebarLink href={`${baseUrl}/types/team`}>
Team
</DocsSidebarLink>
<DocsSidebarLink href={`${baseUrl}/types/team-user`}>
TeamUser
</DocsSidebarLink>
<DocsSidebarLink href={`${baseUrl}/types/team-permission`}>
TeamPermission
</DocsSidebarLink>
<DocsSidebarLink href={`${baseUrl}/types/team-profile`}>
TeamProfile
</DocsSidebarLink>
<DocsSidebarLink href={`${baseUrl}/types/contact-channel`}>
ContactChannel
</DocsSidebarLink>
<DocsSidebarLink href={`${baseUrl}/types/api-key`}>
API Key
</DocsSidebarLink>
<DocsSidebarLink href={`${baseUrl}/types/project`}>
Project
</DocsSidebarLink>
{isReactLike && (
<>
<DocsSeparator>
Hooks
</DocsSeparator>
<DocsSidebarLink href={`${baseUrl}/hooks/use-stack-app`}>
useStackApp()
</DocsSidebarLink>
<DocsSidebarLink href={`${baseUrl}/hooks/use-user`}>
useUser()
</DocsSidebarLink>
</>
)}
</>
);
}
// Function to find platform-specific content in the page tree
function findPlatformContent(tree: PageTree.Root, platform: string): PageTree.Node[] {
const platformMappings: Record<string, string[]> = {
'next': ['next', 'next.js', 'nextjs'],
'react': ['react', 'react.js', 'reactjs'],
'js': ['js', 'javascript'],
'python': ['python', 'py']
};
const possibleNames = platformMappings[platform.toLowerCase()] ?? [platform.toLowerCase()];
for (const item of tree.children) {
if (item.type === 'folder') {
const itemName = typeof item.name === 'string' ? item.name.toLowerCase() : '';
if (possibleNames.some(name => itemName === name || itemName.includes(name))) {
return item.children;
}
}
}
return [];
}
// Recursive component to render page tree items with API styling
function PageTreeItem({ item, currentPlatform }: { item: PageTree.Node, currentPlatform?: string }) {
const pathname = usePathname();
if (item.type === 'separator') {
return <DocsSeparator>{item.name}</DocsSeparator>;
}
if (item.type === 'folder') {
const hasIndexPage = 'index' in item && item.index;
const folderUrl = hasIndexPage ? item.index!.url : '';
const isCurrentPath = folderUrl && pathname.startsWith(folderUrl);
const itemName = typeof item.name === 'string' ? item.name : '';
// If folder has an index page, make the title clickable
if (hasIndexPage) {
return (
<ClickableCollapsibleSection
title={itemName || 'Folder'}
href={item.index!.url}
defaultOpen={!!isCurrentPath}
>
{item.children.map((child, index) => (
<PageTreeItem key={child.type === 'page' ? child.url : index} item={child} currentPlatform={currentPlatform} />
))}
</ClickableCollapsibleSection>
);
}
// If no index page, use regular accordion trigger
return (
<CollapsibleSection
title={itemName || 'Folder'}
defaultOpen={!!isCurrentPath}
>
{item.children.map((child, index) => (
<PageTreeItem key={child.type === 'page' ? child.url : index} item={child} currentPlatform={currentPlatform} />
))}
</CollapsibleSection>
);
}
return (
<DocsSidebarLink href={item.url} external={item.external}>
{item.name}
</DocsSidebarLink>
);
}
// Function to render sidebar content based on context
function renderSidebarContent(tree: PageTree.Root, pathname: string) {
const currentPlatform = getCurrentPlatform(pathname) || undefined;
// For SDK section, show SDK-specific content
if (isInSdkSection(pathname)) {
return <SdkSidebarContent />;
}
// For Components section, show Components-specific content
if (isInComponentsSection(pathname)) {
return <ComponentsSidebarContent />;
}
// For API section, don't show anything (API has its own sidebar)
if (isInApiSection(pathname)) {
return null;
}
// For platform-specific docs, show the platform folder's content
if (currentPlatform) {
const platformContent = findPlatformContent(tree, currentPlatform);
if (platformContent.length > 0) {
return (
<>
{platformContent.map((item, index) => (
<PageTreeItem key={item.type === 'page' ? item.url : index} item={item} currentPlatform={currentPlatform} />
))}
</>
);
}
}
// For general docs, show root level content
return (
<>
{tree.children.map((item, index) => (
<PageTreeItem key={item.type === 'page' ? item.url : index} item={item} currentPlatform={currentPlatform} />
))}
</>
);
}
export type DocsLayoutProps = {
tree: PageTree.Root,
sidebar?: Partial<SidebarOptions> & {
enabled?: boolean,
component?: ReactNode,
},
/**
* Props for the `div` container
*/
containerProps?: HTMLAttributes<HTMLDivElement>,
} & BaseLayoutProps
export function DocsLayout({
nav: { transparentMode, ...nav } = {},
sidebar = {},
searchToggle,
disableThemeSwitch = false,
themeSwitch = { enabled: !disableThemeSwitch },
i18n = false,
children,
...props
}: DocsLayoutProps): ReactNode {
const { isTocOpen } = useTOC();
const [searchOpen, setSearchOpen] = useState(false);
const { isOpen: isChatOpen } = useChatContext();
const tabs = useMemo(
() => getSidebarTabsFromOptions(sidebar.tabs, props.tree) ?? [],
[sidebar.tabs, props.tree],
);
const links = getLinks(props.links ?? [], props.githubUrl);
const variables = cn(
'[--fd-tocnav-height:36px] [--fd-sidebar-width:268px] [--fd-toc-width:200px]',
!nav.component && nav.enabled !== false
? '[--fd-nav-height:56px] md:[--fd-nav-height:56px]'
: undefined,
);
const pageStyles: PageStyles = {
tocNav: cn('xl:hidden'),
toc: cn('max-xl:hidden'),
};
return (
<AccordionProvider>
<TreeContextProvider tree={props.tree}>
<NavProvider transparentMode={transparentMode}>
{slot(
nav,
<Navbar className="h-14 md:hidden">
<Link
href={nav.url ?? '/'}
className="inline-flex items-center gap-2.5 font-semibold"
>
{nav.title}
</Link>
<div className="flex-1">{nav.children}</div>
{slots('sm', searchToggle, <SearchInputToggle onOpen={() => setSearchOpen(true)} />)}
<NavbarSidebarTrigger className="-me-2 md:hidden" />
</Navbar>,
)}
<CustomSearchDialog
open={searchOpen}
onOpenChange={setSearchOpen}
/>
<main
id="nd-docs-layout"
{...props.containerProps}
className={cn(
'flex flex-1 flex-row md:ml-64 pt-14 min-w-0',
variables,
props.containerProps?.className,
)}
style={{
...layoutVariables,
...props.containerProps?.style,
}}
>
{slot(
sidebar,
<DocsLayoutSidebar
{...omit(sidebar, 'enabled', 'component', 'tabs')}
links={links}
tree={props.tree}
onSearchOpen={() => setSearchOpen(true)}
nav={
<>
<Link
href={nav.url ?? '/'}
className="inline-flex text-[15px] items-center gap-2.5 font-medium"
>
{nav.title}
</Link>
{nav.children}
</>
}
banner={
<>
{tabs.length > 0 ? <RootToggle options={tabs} /> : null}
{sidebar.banner}
</>
}
footer={
<>
<DocsLayoutSidebarFooter
links={links.filter((item) => item.type === 'icon')}
i18n={i18n}
themeSwitch={themeSwitch}
/>
{sidebar.footer}
</>
}
/>,
)}
<div className={cn(
'flex-1 transition-all duration-300 min-w-0',
isTocOpen && !isChatOpen && 'xl:mr-72'
)}>
<StylesProvider {...pageStyles}>{children}</StylesProvider>
</div>
</main>
</NavProvider>
</TreeContextProvider>
</AccordionProvider>
);
}
export function DocsLayoutSidebar({
collapsible = true,
banner,
onSearchOpen,
...props
}: Omit<SidebarOptions, 'tabs'> & {
links?: LinkItemType[],
nav?: ReactNode,
tree?: PageTree.Root,
onSearchOpen?: () => void,
}) {
const pathname = usePathname();
return (
<>
{collapsible ? <CollapsibleControl onSearchOpen={onSearchOpen} /> : null}
{/* Sidebar positioned under the header */}
<div className="hidden md:block fixed left-0 top-14 w-64 border-r border-fd-border bg-fd-background z-30">
<div className="h-[calc(100vh-3.5rem)] flex flex-col">
{/* Scrollable content area - no header needed since branding is in main header */}
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea className="h-full">
<ScrollViewport className="p-4">
<Link
href="/"
className="flex items-center gap-2 px-2 py-1.5 mb-2 text-sm text-fd-muted-foreground hover:text-fd-foreground transition-colors"
>
<ArrowLeft className="h-3 w-3" />
Back to home
</Link>
{/* Platform tabs/banner */}
{banner && (
<div className="mb-4">
{banner}
</div>
)}
{/* Page tree content */}
<div className="space-y-1">
{renderSidebarContent(props.tree || { name: 'root', children: [] } as PageTree.Root, pathname)}
</div>
</ScrollViewport>
</ScrollArea>
</div>
{/* Footer - matches API layout */}
<div className="border-t border-fd-border p-4 flex-shrink-0">
<div className="flex items-center justify-between">
<span className="text-xs text-fd-muted-foreground">Stack Auth Docs</span>
<ThemeToggle mode="light-dark" />
</div>
</div>
</div>
</div>
</>
);
}
export function DocsLayoutSidebarFooter({
i18n,
themeSwitch,
links = [],
}: {
i18n?: DocsLayoutProps['i18n'],
links?: IconItemType[],
themeSwitch?: DocsLayoutProps['themeSwitch'],
}) {
return (
<HideIfEmpty>
<div className="flex items-center justify-end">
<div className="flex items-center flex-1 empty:hidden">
{links.map((item, i) => (
<BaseLinkItem
key={i}
item={item}
className={cn(
buttonVariants({ size: 'icon', color: 'ghost' }),
'text-fd-muted-foreground md:[&_svg]:size-4.5',
)}
aria-label={item.label}
>
{item.icon}
</BaseLinkItem>
))}
</div>
{i18n ? (
<LanguageToggle className="me-1.5">
<Languages className="size-4.5" />
<LanguageToggleText className="md:hidden" />
</LanguageToggle>
) : null}
{slot(
themeSwitch,
<ThemeToggle className="p-0" mode={themeSwitch?.mode} />,
)}
</div>
</HideIfEmpty>
);
}
export { getSidebarTabsFromOptions } from './docs/shared';
export { CollapsibleControl, Navbar, NavbarSidebarTrigger, type LinkItemType };