Files
vue-menu-layer/app/utils/menuLinks.ts
Jakub Zych d980f4cf2f feat: MenuNav layer for the SummerCMS menu API
- 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
2026-10-12 18:19:53 +02:00

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)
}