Repository navigation
Step 3 — Routing, sidebar nav & TOC #5
Description
Summary
Reimplement the docs site's routing, sidebar navigation, on-this-page TOC, and breadcrumb against Astro content collections, replacing the @nuxt/content data layer (queryCollection, queryCollectionNavigation) the old site relied on. This delivers the catch-all doc route, the language-sliced collapsible sidebar nav tree (with the TypeDoc dedupe/relabel logic preserved), the right-rail TOC, and the path-derived breadcrumb — all statically generated and scaling to ~2,286 doc pages.
Why / context
The current Nuxt 4 site derives its nav tree, TOC, and breadcrumbs from @nuxt/content queries at request/build time. The Astro + Markdoc target (see /Users/nficano/code/arpc/site/docs/markdoc-migration.md) has no @nuxt/content; we must rebuild these data structures from the Astro docs collection and render them in .astro components. URLs must be preserved exactly — routes are /<lang>/<page> with no trailing slash, plus /spec/... — because the synced docs contain thousands of internal links already rewritten to these absolute paths; any change to the route shape 404s every synced link. Prefer static generation via getStaticPaths to handle the deep auto-generated api/ trees (kotlin ≈ 1,049, typescript ≈ 793 files).
Reference files (read these first)
/Users/nficano/code/arpc/www/app/pages/[...slug].vue— the catch-all doc page. Provides: per-page data fetch byroute.path, theLANG_LABELSmap, thesectionLabelderivation (· Guides/· API/Specification), the SEO call, andconst toc = page.value?.body?.toc?.links ?? []. Copy the route/section logic and<article class="prose prose-neutral dark:prose-invert">wrapper; dropdefinePageMeta,useAsyncData,<ContentRenderer>, anduseArcpSeo(SEO is Step 7). Gotcha: this file'sLANG_LABELSis missingrust(a bug — the other two files include it); use the complete 12-entry list./Users/nficano/code/arpc/www/app/layouts/docs.vue— the three-column shell + sidebar nav. Provides the load-bearingdedupe()andnormalizeTitle()functions,SDKS/LANGSarrays,LANG_LABELS,API_ROOT_LABELS, the language-chip row, the<USelect>mobile switcher, collapsible-group state (isOpen/toggle/manualToggles), active-trail logic (isActive), mobile drawer (navOpen), and active-rowscrollIntoView. Copy the nav-tree transform + markup; dropqueryCollectionNavigation, replace<USelect>with native<select>, replacebg-(--ui-*)/text-(--ui-*)reads with Inkwell tokens./Users/nficano/code/arpc/www/app/components/DocsToc.vue— the right-rail "On this page". Provides exact markup/classes for a 2-level{ id, text, depth?, children? }[]tree. Copy verbatim as.astro./Users/nficano/code/arpc/www/app/components/DocsBreadcrumb.vue— path-derived breadcrumb. ProvidesformatSegment()(PascalCase,@-passthrough, 40-hex-char SHA → 7-char truncation),LANG_LABELS, and the crumb builder. Copy as-is (purely path-driven; no collection access)./Users/nficano/code/arpc/www/server/api/__sitemap__/urls.ts— enumerates all doc paths from the collection. Out of scope here (Step 7), but confirms the canonical path set = every doc'spath.- Other files discovered:
/Users/nficano/code/arpc/www/app/components/AppHeader.vue(the "Docs" nav link points to/python— relevant to where the sidebar's default language resolves; full header is Step 6) and/Users/nficano/code/arpc/www/app/assets/css/main.css(defines.lang-chip/.lang-chip.is-activeat ~L319-327,--text-mini/--text-small,.spec-prose; these come from Step 1 — consume the classes, don't redefine).
Tasks
- Catch-all route — create
src/pages/[...slug].astrowith agetStaticPaths()that enumerates every entry in thedocscollection (getCollection('docs')), mapping each entry's slug/id to its URL without a trailing slash (e.g./python/getting-started,/spec/...). Ensure the home (/) is NOT produced by this route (it's Step 6). - In the route, look up the entry,
render()it, and emit<article class="prose prose-neutral dark:prose-invert">{Content}</article>inside the docs layout. 404 for an unknown slug (build-time: just don't emit a path; if doing on-demand, throw a 404). - Port the section label logic from
[...slug].vue:lang = path.split('/').filter(Boolean)[0];sectionLabel=LANG_LABELS[lang] ?? lang, then suffix· Guidesifrest[0] === 'guides',· APIifrest[0] === 'api', andSpecificationwhenlang === 'spec'. (Used by Step 7 SEO; expose it but don't wire SEO here.) - Use the complete
LANG_LABELS(12 entries incl.rust):csharp:'C#', fsharp:'F#', go:'Go', java:'Java', kotlin:'Kotlin', php:'PHP', python:'Python', ruby:'Ruby', rust:'Rust', swift:'Swift', typescript:'TypeScript', spec:'Spec'. DefineSDKS = ['csharp','fsharp','go','java','kotlin','php','python','ruby','rust','swift','typescript']andLANGS = [...SDKS, 'spec']. - Build the nav tree — write a build-time helper that turns the flat
docscollection into a hierarchicalNavItem = { path: string; title?: string; children?: NavItem[] }tree keyed by URL path. Derive each node'stitlefrom the doc's first# H1(the docs carry no frontmatter; see Step 2/4 — atitlemay be injected on sync). This replacesqueryCollectionNavigation('docs'). - Port
normalizeTitle(title, lang)verbatim: iftitle === 'Documentation', returnAPI_ROOT_LABELS[lang] ?? \${LANG_LABELS[lang] ?? lang} API reference`. Include the fullAPI_ROOT_LABELSmap ('C# API reference', …,'Rust API reference',spec:'Specification'`). - Port
dedupe(nodes, parentPath?, lang?)verbatim, preserving all three behaviors: (a) skip a child whosepath === parentPath; (b) merge duplicate paths, attaching children from the richer duplicate; (c) collapse the redundant TypeDoc"Documentation"wrapper — whenchildren.length === 1 && children[0].children?.length && (title === 'Documentation' || /\/api$/.test(node.path)), hoist the grandchildren and adopt the child's title unless itstartsWith('@'). - Per-language slice:
langNav=dedupe(root?.children, root?.path, currentLang)whereroot = tree.find(n => n.path === \/${currentLang}`). ResolvecurrentLangfrom the first path segment, falling back to'spec'when the segment isn't inLANGS`. - Sidebar component (
.astro, in the docs layout): render the language-chip row using the.lang-chip/.lang-chip.is-activeclasses (one<a href="/<lang>">perLANG_ITEMS), and the collapsiblelangNavlist. Reproduce the parent-row + chevron-toggle + indented children markup fromdocs.vue(them9 18 6-6-6-6chevron SVG,border-lchild list,data-active/aria-currentattributes). - Replace the
<USelect v-model="selectedLang">mobile language switcher with a native<select>whose options areLANGS(labels fromLANG_LABELS) and whosechangenavigates to/<value>. - Active-trail + collapse behavior: port
isActive(path, exact?)(exact→path === current; elsecurrent === path || current.startsWith(path + '/')) and the open-on-active-trail default (isOpen(path)returnsmanualToggles[path] ?? isActive(path)). Since Astro is static, implement the manual toggle + active-rowscrollIntoView+ mobile-drawer open/close as a small client-side<script>island (orclient:Vue island if Step 0 enabled@astrojs/vue); the active trail itself can be computed at build time from the current path. - Replace all
bg-(--ui-bg),text-(--ui-text),bg-(--ui-bg-elevated),text-(--ui-primary),text-(--ui-text-muted),border-(--ui-border),text-(--ui-text-highlighted)reads indocs.vuewith the Inkwell equivalents from Step 1 (bg-bg/bg-paper,text-ink,text-accent,text-muted,border-line,text-ink-soft, etc.). Keep all geometry/spacing classes (w-72,top-14,h-[calc(100vh-3.5rem)],space-y-0.5) identical. - TOC component (
src/components/DocsToc.astro): portDocsToc.vueverbatim —<nav aria-label="On this page" class="sticky top-[4.75rem] …">, thetext-mini uppercaseheading, and the 2-levelborder-llist rendering{ id, text, depth?, children? }. Render it only whenlinks.length(class="hidden w-52 shrink-0 xl:block"). - Surface headings for the TOC: use Astro/Markdoc's
render()headingsoutput (id/text/depth) and shape it into the 2-level{ id, text, depth, children }[]the TOC expects (top level = depth 2, nested = depth 3), matching the oldpage.body.toc.linksstructure. Headingids must be the same slugs as today (Step 2 owns slugging — depend on it). - Breadcrumb component (
src/components/DocsBreadcrumb.astro): portDocsBreadcrumb.vueverbatim, includingformatSegment()(@-prefixed passthrough,/^[a-f0-9]{40}$/i→.slice(0,7),[-_]+→space + capitalize-each-word), theLANG_LABELSfirst crumb, thecrumbs.length > 1render guard, and thetext-line"/" separators. Render at the top of the article, before<article>, matching[...slug].vue's<DocsBreadcrumb />placement. - Wire the three-column layout (this is the Step 6 shell, but the route consumes it): article (
max-w-3xl/xl:max-w-[64rem] xl:gap-12) + TOC rail (hidden w-52 shrink-0 xl:block), sidebarw-72sticky left. If the shell isn't built yet, stub a minimal layout so the route renders; flag the dependency. - Verify a build emits all ~2,286 doc routes and that a deep
api/page (e.g. a kotlin class page) resolves, shows the right sidebar slice, breadcrumb, and TOC.
Details & gotchas
rustis missing fromLANG_LABELSin[...slug].vue(lines 6-18) but present indocs.vue(L8-21) andDocsBreadcrumb.vue(L4-17). Use the complete 12-entry list everywhere — the[...slug].vueomission is a latent bug, not a spec.- No trailing slash. The old routes are bare
/<lang>/<page>. Astro defaults totrailingSlash: 'ignore'but@astrojs/sitemap/build output can append slashes — settrailingSlash: 'never'(or equivalent) and confirm generated paths have none. Mismatch 404s thousands of synced links (Hard Constraint Step 0 — Scaffold the Astro + Markdoc project #2). dedupe()is load-bearing: it collapses TypeDoc's duplicate"Documentation"wrapper (the/\/api$/.test(node.path)branch andtitle === 'Documentation'branch), dedupes repeated index children (if (parentPath && node.path === parentPath) continue;and theseenSet merge), and relabels generator API roots vianormalizeTitle→API_ROOT_LABELS. The child-title adoption guard!child.title.startsWith('@')exists so scoped npm package names (@arcp/...) don't become group titles. Port exactly; do not "simplify."- Titles come from the first
# H1, not frontmatter (docs carry none). The nav-treetitleand the breadcrumb's final crumb both depend on this. Step 4 may inject a frontmattertitlefrom the H1 on sync — coordinate so the nav helper reads whichever source exists. formatSegmentSHA truncation:/^[a-f0-9]{40}$/imatches full git SHAs in paths and truncates to 7 chars (.slice(0, 7)) — this is how Kotlin's commit-hash path segments render. Keep the regex exact.- Static interactivity:
docs.vueuses Vue reactivity (reactivemanualToggles,watchonroute.pathfor drawer-close +scrollIntoView). Astro is static HTML — the build-time active trail handles default-open; the manual chevron toggle, mobile drawer, and active-row scroll must be a small vanilla<script>(or a hydrated island). Don't lose thescrollIntoView({ block: 'nearest' })on[data-active="true"]— deepapi/pages need it. USelect→ native<select>: Nuxt UI's<USelect>usesvalue-key="value"two-way binding; the native replacement just needs<option value="<lang>">+ achange→navigate handler. No Nuxt UI provider exists in Astro.--ui-*tokens don't exist in the Inkwell CSS — they were a Nuxt-UI-Pro remap layer deleted in Step 1. Every(--ui-*)read indocs.vuemust be swapped to a real Inkwell token/Tailwind bridge class (bg-paper,text-ink,text-accent,text-muted,border-line,text-ink-soft). Leaving a--ui-*read yields an unstyled/transparent element.- TOC is 2 levels only (
DocsToc.vuerenderslinks[].children[], no deeper). Don't surface H4+; cap at depth 2/3 to matchpage.body.toc.links. .lang-chip,.spec-prose,--text-mini/--text-smallare defined inmain.css(Step 1) — consume them; do not redefine in these components.
Acceptance criteria
-
pnpm buildstatically generates a route for every entry in thedocscollection (~2,286), each at its exact/<lang>/<page>path with no trailing slash, and the home/is not produced by[...slug].astro. - The sidebar shows the correct per-language slice, with TypeDoc
"Documentation"wrappers collapsed and API roots relabeled (e.g.Kotlin API reference), identical in structure to the current site for a spot-checkedpython,kotlin, andspectree. - Collapsible groups default-open on the active trail; manual chevron toggle, mobile drawer open/close, and active-row scroll-into-view all work.
- The "On this page" TOC renders 2 levels with anchor
ids that match the in-page heading slugs (deep links resolve), and is hidden belowxl. - Breadcrumbs render for any page ≥2 segments deep, with PascalCase segments,
@-passthrough, 40-hex-char SHA → 7-char truncation, andLANG_LABELSfirst crumb — matchingDocsBreadcrumb.vueoutput exactly. - The mobile language
<select>and desktop.lang-chiprow both navigate to/<lang>and mark the current language active. - No
(--ui-*)token references remain in the ported routing/nav/TOC/breadcrumb components.
Dependencies & out of scope
- Depends on Step 0 (scaffold: Astro +
@astrojs/markdoc, pnpm, route config) and Step 1 (Inkwell design tokens /main.css— provides.lang-chip,--text-mini,text-ink/text-accent/border-line, etc.). - Depends on Step 2 (the
docscontent collection + Markdoc/Shiki config, and specifically the heading slugging that the TOC anchors and deep links rely on) and Step 4 (the synced content directory the collection globs, and the optional H1→titlefrontmatter injection that the nav tree/breadcrumb titles consume). - Soft dependency on Step 6 (the app shell / three-column docs layout, header, theme toggle): this route renders inside that layout. If Step 6 isn't done, stub a minimal layout and flag it.
- Out of scope: SEO/
useArcpSeo, OG images, and the sitemap endpoint (Step 7); the home page (Step 6); the docs-sync pipeline itself (Step 4); diagram<img>passthrough / raw-HTML whitelisting (Step 5); search (explicitly not built). ExposesectionLabelfor Step 7 to consume, but do not wire<head>/meta here.
Part of #1 · Full spec: docs/markdoc-migration.md
Activity
Step 3 — Routing, sidebar nav & TOC