/** * Sidebar / secondary-nav active-state helpers. * * Only one nav target should be "selected" at a time. Prefix matching * (`/dashboard` active on `/dashboard/students`) is correct only when no * more-specific nav href also matches the current pathname. */ export function normalizePathname(pathname: string): string { if (pathname.length > 1 && pathname.endsWith("/")) return pathname.slice(0, -1) return pathname } /** Path segment before `#` (if any). */ export function navUrlPath(url: string): string { const hash = url.indexOf("#") return hash >= 0 ? url.slice(0, hash) : url } /** Hash fragment after `#`, or `null` when the href has no fragment. */ export function navUrlFragment(url: string): string | null { const i = url.indexOf("#") return i >= 0 ? url.slice(i + 1) : null } export function normalizedLocationHash(locationHash: string): string { if (!locationHash) return "" return locationHash.startsWith("#") ? locationHash.slice(1) : locationHash } /** Path → hash fragments claimed by *another* nav item at the same path. */ export function buildNavHashClaims( urls: readonly string[], ): ReadonlyMap> { const map = new Map>() for (const url of urls) { const p = navUrlPath(url) const f = navUrlFragment(url) if (!p || f === null) continue let set = map.get(p) if (!set) { set = new Set() map.set(p, set) } set.add(f) } return map } function navHasMoreSpecificHashMatch( pathname: string, locationHash: string, hashClaimsByPath: ReadonlyMap>, ): boolean { const claims = hashClaimsByPath.get(pathname) if (!claims) return false const h = normalizedLocationHash(locationHash) if (h === "") return false return claims.has(h) } function pathnameMatchesNavPath( pathname: string, pathOnly: string, locationHash: string, hashClaimsByPath: ReadonlyMap>, ): boolean { const norm = normalizePathname(pathname) const h = normalizedLocationHash(locationHash) if (pathOnly === "/") { if (norm !== "/" || h !== "") return false return !navHasMoreSpecificHashMatch("/", locationHash, hashClaimsByPath) } if (norm === pathOnly) { return !navHasMoreSpecificHashMatch(pathOnly, locationHash, hashClaimsByPath) } if (pathOnly === "/library") { return norm.startsWith("/library/") } if (pathOnly.startsWith("/library/")) { return norm === pathOnly } return norm.startsWith(`${pathOnly}/`) } /** * Longest matching nav path for `pathname` among `candidateUrls` (path + prefix rules). * Returns the winning href string, or `null`. */ export function resolveActiveNavHref( pathname: string, candidateUrls: readonly string[], options?: { locationHash?: string hashClaimsByPath?: ReadonlyMap> }, ): string | null { const norm = normalizePathname(pathname) const hashClaims = options?.hashClaimsByPath ?? buildNavHashClaims(candidateUrls) const locationHash = options?.locationHash ?? "" let bestHref: string | null = null let bestLen = -1 for (const url of candidateUrls) { const pathOnly = navUrlPath(url) const frag = navUrlFragment(url) if (!pathOnly || pathOnly === "#") continue let matches = false if (frag !== null) { const h = normalizedLocationHash(locationHash) if (pathOnly === "/") matches = norm === "/" && h === frag else if (pathOnly === "/library") matches = norm.startsWith("/library/") && h === frag else if (pathOnly.startsWith("/library/")) matches = norm === pathOnly && h === frag else matches = norm === pathOnly && h === frag } else { matches = pathnameMatchesNavPath(norm, pathOnly, locationHash, hashClaims) } if (matches && pathOnly.length > bestLen) { bestHref = url bestLen = pathOnly.length } } return bestHref } /** True when `url` is the single best match for `pathname` among all nav hrefs. */ export function isNavHrefActive( pathname: string, url: string, allNavUrls: readonly string[], options?: { locationHash?: string hashClaimsByPath?: ReadonlyMap> }, ): boolean { const active = resolveActiveNavHref(pathname, allNavUrls, options) if (!active) return false return navUrlPath(active) === navUrlPath(url) && navUrlFragment(active) === navUrlFragment(url) } /** Collect every `url` from a nav tree (primary rows + children). */ export function collectNavUrls( items: ReadonlyArray<{ url?: string; children?: ReadonlyArray<{ url?: string; children?: unknown }> }>, ): string[] { const out: string[] = [] const walk = (list: ReadonlyArray<{ url?: string; children?: ReadonlyArray<{ url?: string; children?: unknown }> }>) => { for (const it of list) { if (typeof it.url === "string" && it.url.length > 0 && it.url !== "#") out.push(it.url) if (Array.isArray(it.children)) walk(it.children) } } walk(items) return out }