Skip to content

Step 3 — Routing, sidebar nav & TOC #5

Open

Description

@nficano

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 by route.path, the LANG_LABELS map, the sectionLabel derivation (· Guides / · API / Specification), the SEO call, and const toc = page.value?.body?.toc?.links ?? []. Copy the route/section logic and <article class="prose prose-neutral dark:prose-invert"> wrapper; drop definePageMeta, useAsyncData, <ContentRenderer>, and useArcpSeo (SEO is Step 7). Gotcha: this file's LANG_LABELS is missing rust (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-bearing dedupe() and normalizeTitle() functions, SDKS/LANGS arrays, 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-row scrollIntoView. Copy the nav-tree transform + markup; drop queryCollectionNavigation, replace <USelect> with native <select>, replace bg-(--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. Provides formatSegment() (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's path.
  • 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-active at ~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].astro with a getStaticPaths() that enumerates every entry in the docs collection (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 · Guides if rest[0] === 'guides', · API if rest[0] === 'api', and Specification when lang === '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'. Define SDKS = ['csharp','fsharp','go','java','kotlin','php','python','ruby','rust','swift','typescript'] and LANGS = [...SDKS, 'spec'].
  • Build the nav tree — write a build-time helper that turns the flat docs collection into a hierarchical NavItem = { path: string; title?: string; children?: NavItem[] } tree keyed by URL path. Derive each node's title from the doc's first # H1 (the docs carry no frontmatter; see Step 2/4 — a title may be injected on sync). This replaces queryCollectionNavigation('docs').
  • Port normalizeTitle(title, lang) verbatim: if title === 'Documentation', return API_ROOT_LABELS[lang] ?? \${LANG_LABELS[lang] ?? lang} API reference`. Include the full API_ROOT_LABELS map ('C# API reference', …, 'Rust API reference', spec:'Specification'`).
  • Port dedupe(nodes, parentPath?, lang?) verbatim, preserving all three behaviors: (a) skip a child whose path === parentPath; (b) merge duplicate paths, attaching children from the richer duplicate; (c) collapse the redundant TypeDoc "Documentation" wrapper — when children.length === 1 && children[0].children?.length && (title === 'Documentation' || /\/api$/.test(node.path)), hoist the grandchildren and adopt the child's title unless it startsWith('@').
  • Per-language slice: langNav = dedupe(root?.children, root?.path, currentLang) where root = tree.find(n => n.path === \/${currentLang}`). Resolve currentLangfrom 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-active classes (one <a href="/<lang>"> per LANG_ITEMS), and the collapsible langNav list. Reproduce the parent-row + chevron-toggle + indented children markup from docs.vue (the m9 18 6-6-6-6 chevron SVG, border-l child list, data-active/aria-current attributes).
  • Replace the <USelect v-model="selectedLang"> mobile language switcher with a native <select> whose options are LANGS (labels from LANG_LABELS) and whose change navigates to /<value>.
  • Active-trail + collapse behavior: port isActive(path, exact?) (exact → path === current; else current === path || current.startsWith(path + '/')) and the open-on-active-trail default (isOpen(path) returns manualToggles[path] ?? isActive(path)). Since Astro is static, implement the manual toggle + active-row scrollIntoView + mobile-drawer open/close as a small client-side <script> island (or client: 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 in docs.vue with 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): port DocsToc.vue verbatim — <nav aria-label="On this page" class="sticky top-[4.75rem] …">, the text-mini uppercase heading, and the 2-level border-l list rendering { id, text, depth?, children? }. Render it only when links.length (class="hidden w-52 shrink-0 xl:block").
  • Surface headings for the TOC: use Astro/Markdoc's render() headings output (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 old page.body.toc.links structure. Heading ids must be the same slugs as today (Step 2 owns slugging — depend on it).
  • Breadcrumb component (src/components/DocsBreadcrumb.astro): port DocsBreadcrumb.vue verbatim, including formatSegment() (@-prefixed passthrough, /^[a-f0-9]{40}$/i → .slice(0,7), [-_]+→space + capitalize-each-word), the LANG_LABELS first crumb, the crumbs.length > 1 render guard, and the text-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), sidebar w-72 sticky 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

  • rust is missing from LANG_LABELS in [...slug].vue (lines 6-18) but present in docs.vue (L8-21) and DocsBreadcrumb.vue (L4-17). Use the complete 12-entry list everywhere — the [...slug].vue omission is a latent bug, not a spec.
  • No trailing slash. The old routes are bare /<lang>/<page>. Astro defaults to trailingSlash: 'ignore' but @astrojs/sitemap/build output can append slashes — set trailingSlash: '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 and title === 'Documentation' branch), dedupes repeated index children (if (parentPath && node.path === parentPath) continue; and the seen Set merge), and relabels generator API roots via normalizeTitle → 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-tree title and the breadcrumb's final crumb both depend on this. Step 4 may inject a frontmatter title from the H1 on sync — coordinate so the nav helper reads whichever source exists.
  • formatSegment SHA truncation: /^[a-f0-9]{40}$/i matches 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.vue uses Vue reactivity (reactive manualToggles, watch on route.path for 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 the scrollIntoView({ block: 'nearest' }) on [data-active="true"] — deep api/ pages need it.
  • USelect → native <select>: Nuxt UI's <USelect> uses value-key="value" two-way binding; the native replacement just needs <option value="<lang>"> + a change→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 in docs.vue must 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.vue renders links[].children[], no deeper). Don't surface H4+; cap at depth 2/3 to match page.body.toc.links.
  • .lang-chip, .spec-prose, --text-mini/--text-small are defined in main.css (Step 1) — consume them; do not redefine in these components.

Acceptance criteria

  • pnpm build statically generates a route for every entry in the docs collection (~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-checked python, kotlin, and spec tree.
  • 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 below xl.
  • Breadcrumbs render for any page ≥2 segments deep, with PascalCase segments, @-passthrough, 40-hex-char SHA → 7-char truncation, and LANG_LABELS first crumb — matching DocsBreadcrumb.vue output exactly.
  • The mobile language <select> and desktop .lang-chip row 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 docs content 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→title frontmatter 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). Expose sectionLabel for Step 7 to consume, but do not wire <head>/meta here.

Part of #1 · Full spec: docs/markdoc-migration.md

Activity

  1. added
    migrationNuxt → Astro+Markdoc migration
    contentContent pipeline: Markdoc, collections, routing
    uiApp shell / components / pages
    on Jun 14, 2026
  2. added a commit that references this issue on Jun 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    contentContent pipeline: Markdoc, collections, routingmigrationNuxt → Astro+Markdoc migrationuiApp shell / components / pages

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions