// How a menu entry renders as a link: the URL allowlist, the link kind, rel // for new-window links, and active / trail matching against the current // route. Pure functions, so `node --test` covers them without Nuxt. import type { MenuEntry } from './types.ts' /** A NuxtLink (same-site path), a plain anchor, or text with no href. */ export type LinkKind = 'router' | 'anchor' | 'text' const SCHEMES = ['http://', 'https://', 'mailto:', 'tel:'] /** * True for a URL the layer renders as a link: http, https, mailto, tel, a * leading slash or a leading #. Control characters and surrounding spaces * refuse it (the server applies the same allowlist; this is the second * line, Pitfall 15). */ export function isAllowedUrl(url: string | null | undefined): url is string { if (typeof url !== 'string' || url === '') return false if (url.trim() !== url) return false for (let i = 0; i < url.length; i++) { const c = url.charCodeAt(i) if (c < 0x20 || c === 0x7f) return false } if (url[0] === '/' || url[0] === '#') return true const lower = url.toLowerCase() return SCHEMES.some((s) => lower.startsWith(s)) } /** * The navigable URL of an entry, or null. A bare "#" is a placeholder (a * dropdown parent in many Winter menus), so it counts as no URL. */ export function entryHref(entry: Pick): string | null { const url = entry.url if (!isAllowedUrl(url) || url === '#') return null return url } /** A path on this site: one leading slash, not // or /\ (protocol-relative). */ function isSitePath(url: string): boolean { return url[0] === '/' && url[1] !== '/' && url[1] !== '\\' } /** router for a site path, anchor for absolute, mailto, tel and #fragment URLs, text otherwise. */ export function linkKind(entry: Pick): LinkKind { const href = entryHref(entry) if (href === null) return 'text' return isSitePath(href) ? 'router' : 'anchor' } /** rel for an entry's link: "noopener noreferrer" with target _blank. */ export function linkRel(target: string | null | undefined): string | undefined { return target === '_blank' ? 'noopener noreferrer' : undefined } /** A path for comparison: query and hash dropped, decoded, trailing slash removed except on "/". */ export function normalizePath(path: string): string { let p = path.split('#', 1)[0]!.split('?', 1)[0]! try { p = decodeURI(p) } catch { // keep the raw path when it is not valid percent-encoding } if (p === '') p = '/' while (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1) return p } /** * The site path an entry points at, for active matching: a site path as * is, an http(s) URL only when its host equals `host`; null otherwise. */ export function entryPath(entry: Pick, host: string): string | null { const href = entryHref(entry) if (href === null) return null if (isSitePath(href)) return normalizePath(href) if (!/^https?:\/\//i.test(href)) return null let parsed: URL try { parsed = new URL(href) } catch { return null } if (host === '' || parsed.host.toLowerCase() !== host.toLowerCase()) return null return normalizePath(parsed.pathname) } /** True when the entry links to the current route path. */ export function isActive(entry: Pick, routePath: string, host: string): boolean { const p = entryPath(entry, host) return p !== null && p === normalizePath(routePath) } /** True when a descendant of the entry (not the entry itself) is active. */ export function inTrail(entry: Pick, routePath: string, host: string): boolean { return (entry.items ?? []).some((child) => isActive(child, routePath, host) || inTrail(child, routePath, host)) } /** Fills ":title" in a label template. */ export function fillTitle(template: string, title: string): string { return template.split(':title').join(title) }