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
This commit is contained in:
2026-10-12 18:19:53 +02:00
commit d980f4cf2f
27 changed files with 8529 additions and 0 deletions

43
app/utils/client.ts Normal file
View File

@@ -0,0 +1,43 @@
// Typed client for the anonymous menu API. It wraps an injected fetcher, so
// the Nuxt composable decides the base URL and headers and `node --test` can
// drive it with a stub.
import type { Menu, MenuEnvelope } from './types.ts'
/** Issues one GET for `path` (e.g. "/menus/main") with `query`, resolving the parsed JSON. */
export type MenuFetcher = (path: string, query?: Record<string, string>) => Promise<unknown>
/** True for a fetch error that carries HTTP status 404 (ofetch, h3 or plain). */
export function isNotFound(err: unknown): boolean {
if (!err || typeof err !== 'object') return false
const e = err as { status?: unknown; statusCode?: unknown; response?: { status?: unknown } }
return e.status === 404 || e.statusCode === 404 || e.response?.status === 404
}
/** The API path of a menu: /menus/<code>, the code URL-encoded. */
export function menuPath(code: string): string {
return `/menus/${encodeURIComponent(code)}`
}
export interface MenuClient {
/** The menu with `code`, or null when the API answers 404 (unknown or inactive). */
getMenu(code: string, locale?: string): Promise<Menu | null>
}
export function createMenuClient(fetcher: MenuFetcher): MenuClient {
return {
async getMenu(code, locale) {
const query = locale ? { locale } : undefined
let res: MenuEnvelope
try {
res = (await (query ? fetcher(menuPath(code), query) : fetcher(menuPath(code)))) as MenuEnvelope
} catch (err) {
if (isNotFound(err)) return null
throw err
}
const menu = res?.data
if (!menu || typeof menu !== 'object') return null
return { ...menu, items: Array.isArray(menu.items) ? menu.items : [] }
},
}
}

103
app/utils/menuLinks.ts Normal file
View File

@@ -0,0 +1,103 @@
// 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)
}

28
app/utils/types.ts Normal file
View File

@@ -0,0 +1,28 @@
// The response shape of GET /_menu/api/v1/menus/{code}?locale=xx (the menu
// plugin's public API). Generated children of a list type carry id null and
// the parent's registry type.
export interface MenuEntry {
id: number | null
title: string
/** "url", "divider" or a registry type key such as "all-journal-categories". */
type: string
/** Null for dividers and for a URL the server refused (scheme allowlist). */
url: string | null
target: '_self' | '_blank' | null
css_class: string | null
icon: string | null
code: string | null
items: MenuEntry[]
}
export interface Menu {
code: string
name: string
css_class: string | null
items: MenuEntry[]
}
export interface MenuEnvelope {
data: Menu
}