Merge pull request #221 from MacRimi/hotfix/doc-nav-storage-share-anchors

Hotfix: doc-navigation skips sidebar anchor-only section headers
This commit is contained in:
MacRimi
2026-06-02 19:50:41 +02:00
committed by GitHub

View File

@@ -9,7 +9,6 @@
import { Link, usePathname } from "@/i18n/navigation" import { Link, usePathname } from "@/i18n/navigation"
import { ChevronLeft, ChevronRight } from "lucide-react" import { ChevronLeft, ChevronRight } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { useEffect, useState } from "react"
import { sidebarItems } from "@/components/DocSidebar" import { sidebarItems } from "@/components/DocSidebar"
interface DocNavigationProps { interface DocNavigationProps {
@@ -31,6 +30,21 @@ interface FlatPage {
sectionI18nKey?: string sectionI18nKey?: string
} }
// Sidebar entries whose href contains a fragment (`#host`, `#lxc-net`,
// …) are visual section headers that group submenu items inside an
// existing physical page (currently only Storage Share Manager uses
// this pattern — `Host storage integration` and `LXC network sharing`
// are headers for groups of subpages, but their href is the parent
// Overview page with an anchor). They aren't standalone docs the
// reader can advance to, so including them in the Previous/Next walk
// produces two regressions: on the page they anchor (`#host`) Next
// circles back to the same URL, and on a regular page that happens to
// sit next to one of them in the flat list (`lxc-mount-points`) Next
// jumps to the section header instead of skipping to the next real
// subpage. Skip them at walk time so both cases collapse to "the next
// real page in reading order".
const isAnchorOnlyHref = (href: string) => href.includes("#")
function walkSubmenu( function walkSubmenu(
items: SubMenuItem[], items: SubMenuItem[],
section: string, section: string,
@@ -38,6 +52,7 @@ function walkSubmenu(
out: FlatPage[], out: FlatPage[],
) { ) {
items.forEach((sub) => { items.forEach((sub) => {
if (!isAnchorOnlyHref(sub.href)) {
out.push({ out.push({
title: sub.title, title: sub.title,
i18nKey: sub.i18nKey, i18nKey: sub.i18nKey,
@@ -45,6 +60,7 @@ function walkSubmenu(
section, section,
sectionI18nKey, sectionI18nKey,
}) })
}
if (sub.submenu && sub.submenu.length > 0) { if (sub.submenu && sub.submenu.length > 0) {
walkSubmenu(sub.submenu, section, sectionI18nKey, out) walkSubmenu(sub.submenu, section, sectionI18nKey, out)
} }
@@ -56,26 +72,6 @@ export function DocNavigation({ className }: DocNavigationProps) {
const tNav = useTranslations("docNav") const tNav = useTranslations("docNav")
const tSidebar = useTranslations("docSidebar") const tSidebar = useTranslations("docSidebar")
// Capture the URL hash (`#host`, `#lxc-net`, …) on the client so we
// can disambiguate Previous/Next when a single doc page hosts several
// sidebar entries via in-page anchors (Storage Share Manager is the
// canonical case: /docs/storage-share + /docs/storage-share#host +
// /docs/storage-share#lxc-net are three distinct sidebar items but a
// single physical page; usePathname() returns the same string for
// all three because the fragment is not part of the path).
//
// SSR can't see the hash, so we hydrate with an empty string and
// refresh on mount + on hashchange. The brief render before
// hydration just shows the navigation as if the user were at the
// parent page — same behaviour as before this fix, so no regression.
const [hash, setHash] = useState("")
useEffect(() => {
const sync = () => setHash(window.location.hash || "")
sync()
window.addEventListener("hashchange", sync)
return () => window.removeEventListener("hashchange", sync)
}, [])
const tItem = (i18nKey: string | undefined, fallback: string) => { const tItem = (i18nKey: string | undefined, fallback: string) => {
if (!i18nKey) return fallback if (!i18nKey) return fallback
try { try {
@@ -89,7 +85,7 @@ export function DocNavigation({ className }: DocNavigationProps) {
const flatItems: FlatPage[] = [] const flatItems: FlatPage[] = []
sidebarItems.forEach((item) => { sidebarItems.forEach((item) => {
if (item.href) { if (item.href && !isAnchorOnlyHref(item.href)) {
flatItems.push({ title: item.title, i18nKey: item.i18nKey, href: item.href }) flatItems.push({ title: item.title, i18nKey: item.i18nKey, href: item.href })
} }
@@ -124,29 +120,14 @@ export function DocNavigation({ className }: DocNavigationProps) {
const stripTrailingSlash = (s: string) => (s !== "/" ? s.replace(/\/+$/, "") : s) const stripTrailingSlash = (s: string) => (s !== "/" ? s.replace(/\/+$/, "") : s)
const normalizedPathname = stripTrailingSlash(pathname) const normalizedPathname = stripTrailingSlash(pathname)
// Match attempt order: // Anchored URLs (`/docs/storage-share/#host`) share their pathname
// 1. pathname + hash (e.g. /docs/storage-share#host) — exact match // with the Overview page, so they collapse to that page's flat-list
// against sidebar items that intentionally point to an in-page // index — Previous walks back into the previous section as usual and
// anchor as the "current location" for navigation purposes. // Next advances to the first real subpage (`host-nfs`) instead of
// 2. pathname alone — the regular case, no anchor in the URL. // looping back to the same anchor.
// const currentPageIndex = allPages.findIndex(
// Without step 1, every anchor visit collapsed to the parent page
// and Next/Previous walked from there — so on /docs/storage-share#host
// the bottom bar offered the same #host as Next (no movement) and on
// /docs/storage-share/lxc-mount-points/ Next pointed back at #host
// because the entire flat list got indexed from position 0.
const effectivePath = normalizedPathname + hash
let currentPageIndex = -1
if (hash) {
currentPageIndex = allPages.findIndex(
(page) => stripTrailingSlash(page.href) === effectivePath,
)
}
if (currentPageIndex === -1) {
currentPageIndex = allPages.findIndex(
(page) => stripTrailingSlash(page.href) === normalizedPathname, (page) => stripTrailingSlash(page.href) === normalizedPathname,
) )
}
const prevPage = currentPageIndex > 0 ? allPages[currentPageIndex - 1] : null const prevPage = currentPageIndex > 0 ? allPages[currentPageIndex - 1] : null
const nextPage = currentPageIndex < allPages.length - 1 ? allPages[currentPageIndex + 1] : null const nextPage = currentPageIndex < allPages.length - 1 ? allPages[currentPageIndex + 1] : null