feat: implement hierarchical documentation structure and nested navigation

This commit is contained in:
Haitao Pan 2026-01-26 23:34:02 +08:00
parent ba16745daa
commit 31cae2b83d
7 changed files with 284 additions and 75 deletions

View File

@ -2,25 +2,68 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useState } from 'react'
import { ChevronRight, ChevronDown, FileText } from 'lucide-react'
import type { DocCollection } from './types'
import { useState, useEffect } from 'react'
import {
ChevronRight,
ChevronDown,
Book,
Settings,
Zap,
Shield,
HelpCircle,
Code,
Terminal,
Activity,
GraduationCap,
Layout,
Layers,
Puzzle
} from 'lucide-react'
import type { DocCollection, DocVersionOption } from './types'
interface DocsSidebarProps {
collections: DocCollection[]
}
// Helper to humanize category names
const humanize = (s: string) => {
if (!s) return ''
return s.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
// Icon mapping for categories
const ICON_MAP: Record<string, any> = {
'getting-started': Book,
'architecture': Zap,
'usage': Settings,
'advanced': GraduationCap,
'api': Code,
'development': Terminal,
'operations': Activity,
'governance': Shield,
'appendix': HelpCircle,
'integrations': Puzzle,
'overview': Layout,
'core-concepts': Layers,
}
const ADVANCED_GROUP = ['api', 'development', 'operations', 'governance', 'advanced']
export default function DocsSidebar({ collections }: DocsSidebarProps) {
const pathname = usePathname()
// Sort collections by title or defined order if any
const sortedCollections = [...collections].sort((a, b) => a.title.localeCompare(b.title))
// Sort collections: Console first, then others alphabetically
const sortedCollections = [...collections].sort((a, b) => {
if (a.slug.includes('console')) return -1
if (b.slug.includes('console')) return 1
return a.title.localeCompare(b.title)
})
return (
<aside className="sticky top-[64px] hidden h-[calc(100vh-64px)] w-64 shrink-0 overflow-y-auto border-r border-surface-border bg-background py-6 pl-8 pr-4 lg:block">
<nav className="space-y-6">
<aside className="sticky top-[64px] hidden h-[calc(100vh-64px)] w-72 shrink-0 overflow-y-auto border-r border-surface-border bg-background/50 backdrop-blur-sm py-8 pl-8 pr-4 lg:block">
<nav className="space-y-10">
{sortedCollections.map((collection) => (
<SidebarGroup
<CollectionGroup
key={collection.slug}
collection={collection}
activePath={pathname}
@ -31,44 +74,162 @@ export default function DocsSidebar({ collections }: DocsSidebarProps) {
)
}
function SidebarGroup({ collection, activePath }: { collection: DocCollection; activePath: string }) {
function CollectionGroup({ collection, activePath }: { collection: DocCollection; activePath: string }) {
const [isOpen, setIsOpen] = useState(true)
// Check if any child is active to auto-expand (optional, defaulted to true for now)
const isActive = collection.versions.some(v => activePath === `/docs/${collection.slug}/${v.slug}`)
// Group versions by category
const grouped: Record<string, DocVersionOption[]> = {}
const topLevel: DocVersionOption[] = []
collection.versions.forEach(v => {
const category = v.category
if (!category || category === 'overview' || category === 'index') {
topLevel.push(v)
} else {
if (!grouped[category]) grouped[category] = []
grouped[category].push(v)
}
})
const hasAdvanced = ADVANCED_GROUP.some(k => grouped[k])
return (
<div className="space-y-2">
<div className="space-y-4">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex w-full items-center justify-between text-sm font-semibold text-heading transistion hover:text-primary"
className="group flex w-full items-center justify-between text-xs font-bold uppercase tracking-widest text-text-subtle transition-colors hover:text-primary"
>
<span>{collection.title}</span>
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<span className="flex items-center gap-2">
<span className="h-1 w-1 rounded-full bg-primary/40 group-hover:bg-primary transition-colors"></span>
{collection.title}
</span>
{isOpen ? <ChevronDown className="h-3.5 w-3.5 opacity-50" /> : <ChevronRight className="h-3.5 w-3.5 opacity-50" />}
</button>
{isOpen && (
<ul className="space-y-1 border-l border-surface-border pl-4">
{collection.versions.map((version) => {
const href = `/docs/${collection.slug}/${version.slug}`
const isPageActive = activePath === href
<div className="space-y-6">
{/* Uncategorized / Overview / README */}
{topLevel.length > 0 && (
<ul className="space-y-1">
{topLevel.map(v => (
<SidebarLink key={v.slug} version={v} collectionSlug={collection.slug} activePath={activePath} />
))}
</ul>
)}
return (
<li key={version.slug}>
<Link
href={href}
className={`block rounded-md px-2 py-1.5 text-sm transition-colors ${isPageActive
? 'bg-primary/10 text-primary font-medium'
: 'text-text-muted hover:text-heading hover:bg-surface-muted'
}`}
>
{version.title}
</Link>
</li>
)
})}
</ul>
{/* Main Categories (Getting Started, Architecture, etc.) */}
<div className="space-y-4">
{Object.entries(grouped)
.filter(([k]) => !ADVANCED_GROUP.includes(k))
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([category, versions]) => (
<CategorySection
key={category}
title={category}
versions={versions}
collectionSlug={collection.slug}
activePath={activePath}
/>
))}
</div>
{/* Advanced Section Dropdown */}
{hasAdvanced && (
<AdvancedSection
grouped={grouped}
collectionSlug={collection.slug}
activePath={activePath}
/>
)}
</div>
)}
</div>
)
}
function CategorySection({ title, versions, collectionSlug, activePath }: { title: string; versions: DocVersionOption[]; collectionSlug: string; activePath: string }) {
const Icon = ICON_MAP[title] || Book
// Auto-expand if active
const isActive = versions.some(v => activePath === `/docs/${collectionSlug}/${v.slug}`)
const [isExpanded, setIsExpanded] = useState(true)
return (
<div className="space-y-1">
<div
className={`flex w-full items-center gap-2.5 px-2 py-1 text-[11px] font-bold uppercase tracking-tight ${isActive ? 'text-primary' : 'text-text-muted/80'}`}
>
<Icon className={`h-3.5 w-3.5 ${isActive ? 'text-primary' : 'text-text-subtle'}`} />
<span>{humanize(title)}</span>
</div>
<ul className="ml-3.5 space-y-1 border-l border-surface-border pl-4">
{versions.map(v => (
<SidebarLink key={v.slug} version={v} collectionSlug={collectionSlug} activePath={activePath} />
))}
</ul>
</div>
)
}
function AdvancedSection({ grouped, collectionSlug, activePath }: { grouped: Record<string, DocVersionOption[]>; collectionSlug: string; activePath: string }) {
// Check if anything inside is active to auto-expand
const isInsideActive = ADVANCED_GROUP.some(k => grouped[k]?.some(v => activePath === `/docs/${collectionSlug}/${v.slug}`))
const [isExpanded, setIsExpanded] = useState(isInsideActive)
useEffect(() => {
if (isInsideActive) setIsExpanded(true)
}, [isInsideActive])
return (
<div className="space-y-2 rounded-lg bg-surface-muted/30 p-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className={`flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-xs font-bold transition-all ${isExpanded ? 'text-primary bg-primary/5' : 'text-text-muted hover:text-primary hover:bg-primary/5'}`}
>
<GraduationCap className={`h-4 w-4 ${isExpanded ? 'text-primary' : 'text-text-subtle'}`} />
<span className="uppercase tracking-wide">Advanced</span>
<ChevronDown className={`ml-auto h-3.5 w-3.5 transition-transform duration-300 ${isExpanded ? '' : '-rotate-90'}`} />
</button>
{isExpanded && (
<div className="space-y-5 animate-in fade-in slide-in-from-top-1 duration-300">
{ADVANCED_GROUP.map(k => grouped[k] && (
<div key={k} className="space-y-2 py-1">
<div className="flex items-center gap-2 px-3 text-[10px] font-bold uppercase tracking-widest text-text-subtle/50">
<span className="h-[1px] w-2 bg-surface-border"></span>
{humanize(k)}
</div>
<ul className="ml-4 space-y-1 border-l border-surface-border/50 pl-4">
{grouped[k].map(v => (
<SidebarLink key={v.slug} version={v} collectionSlug={collectionSlug} activePath={activePath} />
))}
</ul>
</div>
))}
</div>
)}
</div>
)
}
function SidebarLink({ version, collectionSlug, activePath }: { version: DocVersionOption; collectionSlug: string; activePath: string }) {
const href = `/docs/${collectionSlug}/${version.slug}`
const isPageActive = activePath === href
return (
<li>
<Link
href={href}
className={`group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-all duration-200 ${isPageActive
? 'bg-primary/10 text-primary font-medium shadow-sm'
: 'text-text-muted hover:text-heading hover:bg-surface-muted'
}`}
>
{isPageActive && <span className="h-1.5 w-1.5 rounded-full bg-primary" />}
<span className="truncate">{version.title}</span>
{!isPageActive && <ChevronRight className="ml-auto h-3 w-3 opacity-0 transition-all -translate-x-2 group-hover:opacity-30 group-hover:translate-x-0" />}
</Link>
</li>
)
}

View File

@ -41,8 +41,9 @@ export const generateStaticParams = async () => {
export const dynamicParams = false
export async function generateMetadata({ params }: { params: { collection: string; version: string } }): Promise<Metadata> {
const doc = await getDocVersion(params.collection, params.version)
export async function generateMetadata({ params }: { params: Promise<{ collection: string; slug: string[] }> }): Promise<Metadata> {
const resolvedParams = await params
const doc = await getDocVersion(resolvedParams.collection, resolvedParams.slug)
if (!doc) return {}
return {
title: `${doc.version.title} - ${doc.collection.title} | Documentation`,
@ -53,13 +54,14 @@ export async function generateMetadata({ params }: { params: { collection: strin
export default async function DocVersionPage({
params,
}: {
params: { collection: string; version: string }
params: Promise<{ collection: string; slug: string[] }>
}) {
if (!isFeatureEnabled('appModules', '/docs')) {
notFound()
}
const doc = await getDocVersion(params.collection, params.version)
const resolvedParams = await params
const doc = await getDocVersion(resolvedParams.collection, resolvedParams.slug)
if (!doc) {
notFound()
}
@ -101,14 +103,6 @@ export default async function DocVersionPage({
tags={version.tags}
/>
</div>
{/* We could add TOC here later */}
{/*
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-text-subtle">On This Page</h3>
<TOC content={version.content} />
</div>
*/}
</div>
</aside>
</div>

View File

@ -20,13 +20,14 @@ export const generateStaticParams = async () => {
export default async function CollectionPage({
params,
}: {
params: { collection: string }
params: Promise<{ collection: string }>
}) {
if (!isFeatureEnabled('appModules', '/docs')) {
notFound()
}
const doc = await getDocResource(params.collection)
const resolvedParams = await params
const doc = await getDocResource(resolvedParams.collection)
if (!doc) {
notFound()
}

View File

@ -37,13 +37,16 @@ export async function getDocVersionParams() {
return getDocParams()
}
export async function getDocVersion(slug: string, version: string) {
export async function getDocVersion(collectionSlug: string, slugSegments: string | string[]) {
if (!isDocsModuleEnabled()) {
return undefined
}
const collection = await getDocCollection(slug)
const collection = await getDocCollection(collectionSlug)
if (!collection) return undefined
const versionMatch = collection.versions.find((item) => item.slug === version)
const targetSlug = Array.isArray(slugSegments) ? slugSegments.join('/') : slugSegments
const versionMatch = collection.versions.find((item) => item.slug === targetSlug)
if (!versionMatch) return undefined
return { collection, version: versionMatch }
}

View File

@ -7,6 +7,8 @@ export interface DocVersionOption {
tags?: string[]
content: string
isMdx: boolean
category?: string
subcategory?: boolean
}
export interface DocCollection {
@ -17,4 +19,5 @@ export interface DocCollection {
tags: string[]
versions: DocVersionOption[]
defaultVersionSlug: string
category?: string
}

View File

@ -12,6 +12,8 @@ export interface DocVersion {
tags: string[]
content: string
isMdx: boolean
category?: string
subcategory?: boolean
}
export interface DocCollection {
@ -68,17 +70,43 @@ function buildExcerpt(markdown: string): string {
function normalizeDoc(file: Awaited<ReturnType<typeof readDocFiles>>[number]): DocVersion & {
collection: string
collectionLabel: string
category?: string
subcategory?: boolean
} {
const segments = file.slug.split('/')
// collection is usually the first segment (e.g., '01-console')
const collection = typeof file.metadata.collection === 'string' ? file.metadata.collection : segments[0] || 'docs'
const collectionLabel =
typeof file.metadata.collectionLabel === 'string'
? file.metadata.collectionLabel
: humanize(collection)
const versionSlug = typeof file.metadata.versionSlug === 'string' ? file.metadata.versionSlug : segments.at(-1) ?? 'latest'
const label = typeof file.metadata.version === 'string' ? file.metadata.version : versionSlug
const title = typeof file.metadata.title === 'string' ? file.metadata.title : label
// category is usually the second segment (e.g., 'getting-started')
const category = typeof file.metadata.category === 'string'
? file.metadata.category
: (segments.length > 2 ? segments[1] : undefined)
const subcategory = !!file.metadata.subcategory || (segments.length > 2 && segments.at(-1) !== 'index')
// Determine the slug within the collection
// segments[0] is the collection (e.g., '01-console')
// We want the rest of the path as the nested slug
const relativeSegments = segments.slice(1)
let innerSlug = relativeSegments.join('/')
// Handle 'index' or 'README' at any level
if (innerSlug.endsWith('/index') || innerSlug.endsWith('/README')) {
innerSlug = innerSlug.replace(/\/(index|README)$/, '')
} else if (innerSlug === 'index' || innerSlug === 'README') {
innerSlug = ''
}
// If empty (it was just the collection-level README or index), use 'overview'
const finalSlug = innerSlug || 'overview'
const label = typeof file.metadata.version === 'string' ? file.metadata.version : (segments.at(-1) ?? 'latest')
const title = typeof file.metadata.title === 'string' ? file.metadata.title : (finalSlug === 'overview' ? 'Overview' : humanize(segments.at(-1) || label))
const description =
typeof file.metadata.description === 'string' ? file.metadata.description : buildExcerpt(file.content)
const updatedAt = typeof file.metadata.updatedAt === 'string' ? file.metadata.updatedAt : undefined
@ -88,7 +116,7 @@ function normalizeDoc(file: Awaited<ReturnType<typeof readDocFiles>>[number]): D
const isMdx = String(file.metadata.format || '').toLowerCase() === 'mdx'
return {
slug: versionSlug,
slug: finalSlug,
label,
title,
description,
@ -98,6 +126,8 @@ function normalizeDoc(file: Awaited<ReturnType<typeof readDocFiles>>[number]): D
isMdx,
collection,
collectionLabel,
category,
subcategory,
}
}
@ -125,6 +155,8 @@ export const getDocCollections = cache(async (): Promise<DocCollection[]> => {
tags: doc.tags,
content: doc.content,
isMdx: doc.isMdx,
category: doc.category,
subcategory: doc.subcategory,
}
if (existing) {
@ -197,6 +229,9 @@ export async function getDocVersion(
export async function getDocParams() {
const collections = await getDocCollections()
return collections.flatMap((collection) =>
collection.versions.map((version) => ({ collection: collection.slug, version: version.slug })),
collection.versions.map((version) => ({
collection: collection.slug,
slug: version.slug.split('/')
})),
)
}

View File

@ -19,38 +19,38 @@ const tailwindConfig = {
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
border: 'var(--color-surface-border)',
input: 'var(--color-surface-border)',
ring: 'var(--color-ring)',
background: 'var(--color-background)',
foreground: 'var(--color-text)',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
DEFAULT: 'var(--color-primary)',
foreground: 'var(--color-primary-foreground)',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
DEFAULT: 'var(--color-surface-muted)',
foreground: 'var(--color-text-muted)',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
DEFAULT: 'var(--color-danger)',
foreground: 'var(--color-danger-foreground)',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
DEFAULT: 'var(--color-surface-muted)',
foreground: 'var(--color-text-muted)',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
DEFAULT: 'var(--color-accent)',
foreground: 'var(--color-accent-foreground)',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
DEFAULT: 'var(--color-surface-elevated)',
foreground: 'var(--color-text)',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
DEFAULT: 'var(--color-surface)',
foreground: 'var(--color-text)',
},
brand: {
DEFAULT: '#3366FF', // 主色
@ -61,6 +61,18 @@ const tailwindConfig = {
navy: '#1E2E55', // 海军蓝
heading: '#2E3A59', // 标题色
},
surface: {
DEFAULT: 'var(--color-surface)',
muted: 'var(--color-surface-muted)',
border: 'var(--color-surface-border)',
hover: 'var(--color-surface-hover)',
},
text: {
DEFAULT: 'var(--color-text)',
muted: 'var(--color-text-muted)',
subtle: 'var(--color-text-subtle)',
},
heading: 'var(--color-heading)',
},
// 字体配置