From e9e10e4ffa61a2ddc1b56b326171ad402d8eb9b5 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sun, 31 May 2026 14:44:52 +0200 Subject: [PATCH] Fix doc nav + sidebar active-page detection after trailingSlash:true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #212 added `trailingSlash: true` to next.config.mjs so GitHub Pages would serve the locale roots correctly. That changed what usePathname() returns at runtime — `/docs/.../page/` with a trailing slash — but the sidebar config (sidebarItems in DocSidebar.tsx) still declares hrefs without the trailing slash. Every equality check `pathname === item.href` therefore returned false on every page, and two things broke: 1. components/ui/doc-navigation.tsx — the Previous/Next bar at the bottom of every doc page. With `findIndex` returning -1, `prevPage` was null and `nextPage = allPages[0]` (Introduction). So every doc page showed "Next: Introduction" regardless of where the user was. 2. components/DocSidebar.tsx — four comparisons that drove (a) the highlighted active item in the sidebar, (b) the active-section auto-open when navigating directly to a nested page, (c) the leaf-item highlight when the item has no submenu. All silently broken on every page. Fix: a `stripTrailingSlash` helper plus a derived `currentPath` that is compared instead of the raw `pathname`. `collectHrefs(...)` results are also normalized at the point of comparison so the `.includes(currentPath)` checks behave correctly. Verified locally with `npm run build` — 232 pages indexed, no errors. Co-Authored-By: Claude Opus 4.7 --- web/components/DocSidebar.tsx | 27 ++++++++++++++++++++------- web/components/ui/doc-navigation.tsx | 14 +++++++++++++- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/web/components/DocSidebar.tsx b/web/components/DocSidebar.tsx index 401ef309..7cb37ef0 100644 --- a/web/components/DocSidebar.tsx +++ b/web/components/DocSidebar.tsx @@ -32,6 +32,17 @@ function collectHrefs(items: SubMenuItem[]): string[] { return out } +// Next.js is configured with `trailingSlash: true` (required so GitHub +// Pages serves /foo/ as foo/index.html). That makes usePathname() return +// `/docs/.../page/` while sidebar hrefs are declared without the slash, +// so a naive `pathname === item.href` always fails and the active-item +// highlight + active-section auto-open both break. Strip the trailing +// slash on both sides before comparing. +function stripTrailingSlash(s: string): string { + if (!s || s === "/") return s + return s.replace(/\/+$/, "") +} + export const sidebarItems: MenuItem[] = [ { title: "Introduction", i18nKey: "introduction", href: "/docs/introduction" }, { title: "Installation", i18nKey: "installation", href: "/docs/installation" }, @@ -273,6 +284,7 @@ export const sidebarItems: MenuItem[] = [ export default function DocSidebar() { const pathname = usePathname() + const currentPath = stripTrailingSlash(pathname) const [openSections, setOpenSections] = useState<{ [key: string]: boolean }>({}) const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) const t = useTranslations("docSidebar") @@ -313,9 +325,10 @@ export default function DocSidebar() { const renderSubItem = (subItem: SubMenuItem, depth: number) => { const hasChildren = !!subItem.submenu && subItem.submenu.length > 0 if (hasChildren) { - const descendantHrefs = collectHrefs(subItem.submenu!) + const descendantHrefs = collectHrefs(subItem.submenu!).map(stripTrailingSlash) + const subItemPath = stripTrailingSlash(subItem.href) const containsActivePage = - subItem.href === pathname || descendantHrefs.includes(pathname) + subItemPath === currentPath || descendantHrefs.includes(currentPath) const sectionKey = `${subItem.href}__${subItem.title}` const isOpen = (openSections[sectionKey] ?? containsActivePage) || false return ( @@ -325,7 +338,7 @@ export default function DocSidebar() { href={subItem.href} onClick={() => setIsMobileMenuOpen(false)} className={`flex-1 block p-2 rounded-l ${ - pathname === subItem.href + currentPath === subItemPath ? "bg-blue-500 text-white" : containsActivePage ? "bg-blue-50 text-blue-900 font-medium" @@ -339,7 +352,7 @@ export default function DocSidebar() { aria-label={isOpen ? "Collapse" : "Expand"} onClick={() => toggleSection(sectionKey)} className={`px-2 flex items-center rounded-r ${ - containsActivePage && pathname !== subItem.href + containsActivePage && currentPath !== subItemPath ? "bg-blue-50 text-blue-900 hover:bg-blue-100" : "text-gray-500 hover:bg-gray-200" }`} @@ -361,7 +374,7 @@ export default function DocSidebar() { { if (item.submenu) { - const containsActivePage = collectHrefs(item.submenu).includes(pathname) + const containsActivePage = collectHrefs(item.submenu).map(stripTrailingSlash).includes(currentPath) const isOpen = (openSections[item.title] ?? containsActivePage) || false return (
  • @@ -403,7 +416,7 @@ export default function DocSidebar() { setIsMobileMenuOpen(false)} > diff --git a/web/components/ui/doc-navigation.tsx b/web/components/ui/doc-navigation.tsx index 49a0f233..3da1c08d 100644 --- a/web/components/ui/doc-navigation.tsx +++ b/web/components/ui/doc-navigation.tsx @@ -92,7 +92,19 @@ export function DocNavigation({ className }: DocNavigationProps) { allPages.push(p) } - const currentPageIndex = allPages.findIndex((page) => page.href === pathname) + // Normalize trailing slashes before comparing. Next.js is configured + // with `trailingSlash: true` (so GitHub Pages serves `/foo/` as + // `foo/index.html`), which means usePathname() returns + // `/docs/.../page/` while sidebarItems declares hrefs as + // `/docs/.../page` (no trailing slash). Without this normalization + // findIndex always returned -1 → prevPage was null and nextPage was + // allPages[0] (Introduction) on every page, so the bottom Previous/Next + // bar showed "Next: Introduction" everywhere regardless of the route. + const stripTrailingSlash = (s: string) => (s !== "/" ? s.replace(/\/+$/, "") : s) + const normalizedPathname = stripTrailingSlash(pathname) + const currentPageIndex = allPages.findIndex( + (page) => stripTrailingSlash(page.href) === normalizedPathname, + ) const prevPage = currentPageIndex > 0 ? allPages[currentPageIndex - 1] : null const nextPage = currentPageIndex < allPages.length - 1 ? allPages[currentPageIndex + 1] : null