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:
43
app/utils/client.ts
Normal file
43
app/utils/client.ts
Normal 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
103
app/utils/menuLinks.ts
Normal 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
28
app/utils/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user