- typed client and useMenu/useMenuApi (SSR, menu:<code>:<locale> key, server and public bases, x-forwarded-for forwarded also on cached renders) - MenuNav and recursive MenuNavItem: WAI disclosure navigation, URL allowlist with a text fallback, rel on _blank, active link and trail, mobile toggle, Esc / focus-out / outside pointer / route change closing - menu tokens and base CSS, README - node tests (link rules, client, server-rendered NavItem) and a playground SSR smoke against a fixture API
104 lines
3.8 KiB
TypeScript
104 lines
3.8 KiB
TypeScript
// 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<MenuEntry, 'url'>): 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<MenuEntry, 'url'>): 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<MenuEntry, 'url'>, 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<MenuEntry, 'url'>, 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<MenuEntry, 'items'>, 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)
|
|
}
|