Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 17 additions & 96 deletions components/docs/DocsSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
'use client'

import Link from 'next/link'
import { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { ChevronDown } from 'lucide-react'
import type { NavSection, ProductNav } from '@/lib/content'
import type { ProductNav } from '@/lib/content'
import { NavSection } from './NavSection'

/**
* Frontmatter-derived nav with the product switch on top: auths-mcp and
* Identity & signing are separate worlds that never intermix. The switch
* navigates to each product's landing page; the shown product follows the URL.
* Frontmatter-derived nav with the area switch on top. Areas are separate
* worlds that never intermix; the switch navigates to each area's landing page
* and the shown area follows the URL.
*
* Sections come in two shapes: flat (always open) and collapsible — one row
* with a chevron for large topics, auto-expanded while the reader is inside.
* Every section renders through the one shared {@link NavSection} disclosure —
* same caret, same behaviour, in every area.
*/

function productForPath(nav: ProductNav[], pathname: string): ProductNav['product'] {
Expand All @@ -22,7 +21,7 @@ function productForPath(nav: ProductNav[], pathname: string): ProductNav['produc
return 'mcp'
}

/** A product's first navigable page — where its switch tab points. */
/** An area's first navigable page — where its switch tab points. */
function landingHref(p: ProductNav): string {
for (const section of p.sections) {
const first = section.items.find((i) => i.badge !== 'soon')
Expand All @@ -31,94 +30,23 @@ function landingHref(p: ProductNav): string {
return '#'
}

function SectionItems({ section, pathname }: { section: NavSection; pathname: string }) {
return (
<ul className="space-y-0.5">
{section.items.map((item) => {
const isActive = pathname === item.href
if (item.badge === 'soon') {
return (
<li key={item.href}>
<div className="flex cursor-default items-center justify-between px-3 py-1.5 text-sm text-ink-faint/70">
<span>{item.label}</span>
<span className="rounded-sm border border-rule px-1.5 py-0.5 font-mono text-[10px] text-ink-faint">
soon
</span>
</div>
</li>
)
}
return (
<li key={item.href}>
<Link
href={item.href}
className={`flex items-center justify-between border-l-2 px-3 py-1.5 text-sm transition-colors ${
isActive
? 'border-seal font-semibold text-ink'
: 'border-transparent text-ink-soft hover:border-rule hover:text-ink'
}`}
>
<span>{item.label}</span>
{item.badge === 'new' ? (
<span className="rounded-sm bg-seal/10 px-1.5 py-0.5 font-mono text-[10px] font-semibold text-seal">
new
</span>
) : null}
</Link>
</li>
)
})}
</ul>
)
}

/** A large topic folded to one row; opens on click and while a child is active. */
function CollapsibleSection({ section, pathname }: { section: NavSection; pathname: string }) {
const containsActive = section.items.some((i) => i.href === pathname)
const [open, setOpen] = useState(containsActive)
useEffect(() => {
if (containsActive) setOpen(true)
}, [containsActive])

return (
<div>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="mb-3 flex w-full items-center justify-between px-2 font-mono text-[11px] font-semibold uppercase tracking-[0.15em] text-ink-faint transition-colors hover:text-ink"
>
<span>{section.title}</span>
<ChevronDown
size={14}
aria-hidden="true"
className={`transition-transform ${open ? '' : '-rotate-90'}`}
/>
</button>
{open ? <SectionItems section={section} pathname={pathname} /> : null}
</div>
)
}

export function DocsSidebar({ nav }: { nav: ProductNav[] }) {
const pathname = usePathname()
const currentProduct = productForPath(nav, pathname)
const active = nav.find((p) => p.product === currentProduct) ?? nav[0]

return (
<nav className="space-y-8">
<div aria-label="Product" className="flex rounded-lg border border-rule bg-paper p-1">
<nav className="space-y-6">
<div aria-label="Area" className="flex flex-col gap-1 rounded-lg border border-rule bg-paper p-1">
{nav.map((p) => {
const selected = p.product === currentProduct
return (
<Link
key={p.product}
href={landingHref(p)}
aria-current={selected ? 'page' : undefined}
className={`flex-1 rounded-md px-2 py-1.5 text-center font-mono text-[12px] transition-colors ${
selected
? 'bg-paper-deep font-semibold text-ink'
: 'text-ink-faint hover:text-ink'
className={`rounded-md px-2 py-1.5 text-center font-mono text-[12px] transition-colors ${
selected ? 'bg-paper-deep font-semibold text-ink' : 'text-ink-faint hover:text-ink'
}`}
>
{p.label}
Expand All @@ -127,18 +55,11 @@ export function DocsSidebar({ nav }: { nav: ProductNav[] }) {
})}
</div>

{active.sections.map((section) =>
section.collapsible ? (
<CollapsibleSection key={section.title} section={section} pathname={pathname} />
) : (
<div key={section.title}>
<h3 className="mb-3 px-2 font-mono text-[11px] font-semibold uppercase tracking-[0.15em] text-ink-faint">
{section.title}
</h3>
<SectionItems section={section} pathname={pathname} />
</div>
)
)}
<div className="space-y-2">
{active.sections.map((section) => (
<NavSection key={section.title} section={section} pathname={pathname} />
))}
</div>
</nav>
)
}
153 changes: 153 additions & 0 deletions components/docs/NavSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
'use client'

import Link from 'next/link'
import { useEffect, useId, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import type { NavItem, NavSection as NavSectionData } from '@/lib/content'

/**
* One sidebar sub-category.
*
* Two shapes, chosen by content rather than configuration:
*
* - **Many pages** → a disclosure row with the caret on the LEFT, open by
* default so the nav shows its full structure on arrival. Collapsing is the
* reader's opt-in, and navigating into a collapsed section re-opens it.
* - **Exactly one page** (Overview, Glossary) → a plain row. A caret that
* reveals a single child is noise, so there is none; the row is aligned to
* the disclosure labels by a spacer the width of the caret.
*
* Every section in every area goes through this component, so the sidebar
* cannot drift into looking different between areas.
*/

/** Reserves the caret's width so plain rows align with disclosure labels. */
function CaretSpacer() {
return <span aria-hidden="true" className="w-[13px] shrink-0" />
}

const ROW_CLASS =
'flex w-full items-center gap-1.5 px-2 py-1 font-mono text-[11px] font-semibold uppercase tracking-[0.15em] transition-colors'

function NavItemRow({ item, pathname }: { item: NavItem; pathname: string }) {
if (item.badge === 'soon') {
return (
<li>
<div className="flex cursor-default items-center justify-between py-1.5 pl-8 pr-3 text-sm text-ink-faint/70">
<span>{item.label}</span>
<span className="rounded-sm border border-rule px-1.5 py-0.5 font-mono text-[10px] text-ink-faint">
soon
</span>
</div>
</li>
)
}

const isActive = pathname === item.href
return (
<li>
<Link
href={item.href}
aria-current={isActive ? 'page' : undefined}
className={`flex items-center justify-between border-l-2 py-1.5 pl-8 pr-3 text-sm transition-colors ${
isActive
? 'border-seal font-semibold text-ink'
: 'border-transparent text-ink-soft hover:border-rule hover:text-ink'
}`}
>
<span>{item.label}</span>
{item.badge === 'new' ? (
<span className="rounded-sm bg-seal/10 px-1.5 py-0.5 font-mono text-[10px] font-semibold text-seal">
new
</span>
) : null}
</Link>
</li>
)
}

/**
* A one-page section: the section title *is* the nav entry, linking straight to
* its single page. No caret, no nesting.
*/
function PlainSection({
section,
pathname,
}: {
section: NavSectionData
pathname: string
}) {
const item = section.items[0]
const isActive = pathname === item.href

if (item.badge === 'soon') {
return (
<div className={`${ROW_CLASS} cursor-default text-ink-faint/70`}>
<CaretSpacer />
<span>{section.title}</span>
</div>
)
}

return (
<Link
href={item.href}
aria-current={isActive ? 'page' : undefined}
className={`${ROW_CLASS} ${isActive ? 'text-ink' : 'text-ink-faint hover:text-ink'}`}
>
<CaretSpacer />
<span>{section.title}</span>
</Link>
)
}

/**
* Renders one nav section.
*
* Args:
* * `section`: the frontmatter-derived section (title + its pages).
* * `pathname`: the active route, used to auto-open the containing section.
*/
export function NavSection({
section,
pathname,
}: {
section: NavSectionData
pathname: string
}) {
const containsActive = section.items.some((i) => i.href === pathname)
const [open, setOpen] = useState(true)
const panelId = useId()

useEffect(() => {
if (containsActive) setOpen(true)
}, [containsActive])

if (section.items.length === 0) return null
if (section.items.length === 1) return <PlainSection section={section} pathname={pathname} />

return (
<div>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={panelId}
className={`${ROW_CLASS} text-ink-faint hover:text-ink`}
>
<ChevronDown
size={13}
aria-hidden="true"
className={`shrink-0 transition-transform ${open ? '' : '-rotate-90'}`}
/>
<span>{section.title}</span>
</button>
{/* Kept mounted (hidden) so `aria-controls` always resolves. */}
<ul id={panelId} hidden={!open} className="mt-1 space-y-0.5">
{section.items.map((item) => (
<NavItemRow key={item.href} item={item} pathname={pathname} />
))}
</ul>
</div>
)
}
28 changes: 25 additions & 3 deletions components/markdoc/CodeTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,31 @@ export function CodeTabs({ children }: { children: ReactNode }) {
const tabs = Children.toArray(children).filter(
(c): c is ReactElement<CodeTabProps> => isValidElement(c) && 'lang' in ((c.props as object) ?? {})
)
const langs = tabs.map((t) => t.props.lang)

// Tabs are identified by language and sync site-wide, so a duplicated `lang`
// makes every tab after the first unreachable — `indexOf` always resolves to
// the first match, and clicking the others is a no-op. Surface the authoring
// mistake rather than shipping dead tabs.
const langSignature = langs.join('|')
useEffect(() => {
if (process.env.NODE_ENV === 'production') return
const seen = new Set<string>()
const duplicated = new Set<string>()
for (const l of langSignature.split('|')) {
if (seen.has(l)) duplicated.add(l)
seen.add(l)
}
if (duplicated.size > 0) {
console.warn(
`CodeTabs: duplicate lang(s) [${[...duplicated].join(', ')}] — use one tab per language. ` +
'For non-language variants (deploy tools, platforms), use separate headed sections instead.'
)
}
}, [langSignature])

if (tabs.length === 0) return null

const langs = tabs.map((t) => t.props.lang)
// Until hydration, and whenever the chosen language is absent, show the
// block's own first tab.
const chosen = hydrated && lang && langs.includes(lang) ? lang : langs[0]
Expand Down Expand Up @@ -71,7 +93,7 @@ export function CodeTabs({ children }: { children: ReactNode }) {
const active = i === activeIndex
return (
<button
key={tab.props.lang}
key={`${tab.props.lang}-${i}`}
ref={(el) => {
tabRefs.current[i] = el
}}
Expand Down Expand Up @@ -100,7 +122,7 @@ export function CodeTabs({ children }: { children: ReactNode }) {
</div>
{tabs.map((tab, i) => (
<div
key={tab.props.lang}
key={`${tab.props.lang}-${i}`}
role="tabpanel"
id={`${baseId}-panel-${i}`}
aria-labelledby={`${baseId}-tab-${i}`}
Expand Down
Loading
Loading