- 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
138 lines
5.8 KiB
TypeScript
138 lines
5.8 KiB
TypeScript
// Link rules of the menu: the URL allowlist (javascript: and data: never get
|
|
// an href), rel on new-window links, and active / trail matching. The last
|
|
// tests compile Menu/NavItem.vue and render it on the server, so the rules
|
|
// are proven on the markup too. No network, no Nuxt.
|
|
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { renderNavItem } from './render.ts'
|
|
import {
|
|
entryHref,
|
|
entryPath,
|
|
fillTitle,
|
|
inTrail,
|
|
isActive,
|
|
isAllowedUrl,
|
|
linkKind,
|
|
linkRel,
|
|
normalizePath,
|
|
} from '../app/utils/menuLinks.ts'
|
|
import type { MenuEntry } from '../app/utils/types.ts'
|
|
|
|
function entry(url: string | null, extra: Partial<MenuEntry> = {}): MenuEntry {
|
|
return { id: 1, title: 'T', type: 'url', url, target: null, css_class: null, icon: null, code: null, items: [], ...extra }
|
|
}
|
|
|
|
test('the allowlist takes http, https, mailto, tel, / and #', () => {
|
|
for (const url of ['http://a.test/', 'HTTPS://a.test', 'mailto:a@b.test', 'tel:+48123', '/blog', '#faq', '//cdn.test/x']) {
|
|
assert.equal(isAllowedUrl(url), true, url)
|
|
}
|
|
for (const url of [
|
|
'javascript:alert(1)',
|
|
'JavaScript:alert(1)',
|
|
'data:text/html,<b>x</b>',
|
|
'vbscript:x',
|
|
'blog',
|
|
'rodzaje/reishi',
|
|
' /blog',
|
|
'/blog\n',
|
|
'\tjavascript:alert(1)',
|
|
'',
|
|
null,
|
|
]) {
|
|
assert.equal(isAllowedUrl(url), false, String(url))
|
|
}
|
|
})
|
|
|
|
test('javascript: and data: URLs render as text with no href', () => {
|
|
for (const url of ['javascript:alert(1)', 'data:text/html;base64,PHNjcmlwdD4=']) {
|
|
assert.equal(entryHref(entry(url)), null)
|
|
assert.equal(linkKind(entry(url)), 'text')
|
|
}
|
|
})
|
|
|
|
test('link kinds: router for site paths, anchor for absolute, mailto, tel and fragments', () => {
|
|
assert.equal(linkKind(entry('/blog')), 'router')
|
|
assert.equal(linkKind(entry('https://a.test/x')), 'anchor')
|
|
assert.equal(linkKind(entry('mailto:a@b.test')), 'anchor')
|
|
assert.equal(linkKind(entry('tel:+48123')), 'anchor')
|
|
assert.equal(linkKind(entry('#faq')), 'anchor')
|
|
assert.equal(linkKind(entry('//cdn.test/x')), 'anchor')
|
|
assert.equal(linkKind(entry('/\\evil.test')), 'anchor')
|
|
// A bare "#" (a dropdown placeholder) and null are no link.
|
|
assert.equal(linkKind(entry('#')), 'text')
|
|
assert.equal(linkKind(entry(null)), 'text')
|
|
})
|
|
|
|
test('_blank links get rel noopener noreferrer', () => {
|
|
assert.equal(linkRel('_blank'), 'noopener noreferrer')
|
|
assert.equal(linkRel('_self'), undefined)
|
|
assert.equal(linkRel(null), undefined)
|
|
})
|
|
|
|
test('paths normalize: query, hash and trailing slash ignored, decoded', () => {
|
|
assert.equal(normalizePath('/blog/'), '/blog')
|
|
assert.equal(normalizePath('/blog?page=2#x'), '/blog')
|
|
assert.equal(normalizePath('/'), '/')
|
|
assert.equal(normalizePath(''), '/')
|
|
assert.equal(normalizePath('/grzyby%20le%C5%9Bne'), '/grzyby leśne')
|
|
assert.equal(normalizePath('/bad%E0%A4%A'), '/bad%E0%A4%A')
|
|
})
|
|
|
|
test('active matching compares paths; absolute URLs only on the request host', () => {
|
|
assert.equal(isActive(entry('/blog/'), '/blog', 'site.test'), true)
|
|
assert.equal(isActive(entry('/blog?x=1'), '/blog/', 'site.test'), true)
|
|
assert.equal(isActive(entry('/blog'), '/blog/post', 'site.test'), false)
|
|
assert.equal(isActive(entry('https://site.test/blog'), '/blog', 'site.test'), true)
|
|
assert.equal(isActive(entry('https://other.test/blog'), '/blog', 'site.test'), false)
|
|
assert.equal(isActive(entry('#'), '/', 'site.test'), false)
|
|
assert.equal(entryPath(entry('mailto:a@b.test'), 'site.test'), null)
|
|
})
|
|
|
|
test('the trail marks ancestors of the active entry', () => {
|
|
const tree = entry('#', { items: [entry('/a'), entry(null, { items: [entry('/deep')] })] })
|
|
assert.equal(inTrail(tree, '/deep', 'site.test'), true)
|
|
assert.equal(inTrail(tree, '/a/', 'site.test'), true)
|
|
assert.equal(inTrail(tree, '/other', 'site.test'), false)
|
|
})
|
|
|
|
test('fillTitle fills :title', () => {
|
|
assert.equal(fillTitle('Show submenu: :title', 'Grzyby'), 'Show submenu: Grzyby')
|
|
})
|
|
|
|
test('rendered: unsafe URLs are a span without href, _blank links carry rel', async () => {
|
|
const html = await renderNavItem({
|
|
entry: entry('#', {
|
|
title: 'Parent',
|
|
items: [
|
|
entry('javascript:alert(1)', { id: 2, title: 'Js' }),
|
|
entry('data:text/html,x', { id: 3, title: 'Data' }),
|
|
entry('https://ext.test/', { id: 4, title: 'Ext', target: '_blank' }),
|
|
entry('/here', { id: 5, title: 'Here' }),
|
|
entry(null, { id: 6, title: '', type: 'divider' }),
|
|
],
|
|
}),
|
|
routePath: '/here/',
|
|
})
|
|
assert.ok(!html.includes('javascript:'), html)
|
|
assert.ok(!html.includes('data:text'), html)
|
|
assert.match(html, /<span class="menu-nav__text"[^>]*>Js<\/span>/)
|
|
assert.match(html, /<span class="menu-nav__text"[^>]*>Data<\/span>/)
|
|
assert.match(html, /<a href="https:\/\/ext\.test\/" class="menu-nav__link" target="_blank" rel="noopener noreferrer"[^>]*>Ext<\/a>/)
|
|
// The parent has no URL: one disclosure button; the active child is marked.
|
|
assert.match(html, /<button type="button" class="menu-nav__link menu-nav__subtoggle" aria-expanded="false" aria-controls="m-0-sub">/)
|
|
assert.match(html, /aria-current="page"[^>]*>Here</)
|
|
assert.match(html, /class="menu-nav__item menu-nav__item--parent menu-nav__item--trail"/)
|
|
assert.match(html, /<li class="menu-nav__divider" aria-hidden="true"><\/li>/)
|
|
})
|
|
|
|
test('rendered: an entry with children and a URL keeps its link and gets a labelled toggle', async () => {
|
|
const html = await renderNavItem({
|
|
entry: entry('/blog', { title: 'Blog', items: [entry('/blog/a', { id: 9, title: 'A' })] }),
|
|
routePath: '/',
|
|
open: true,
|
|
})
|
|
assert.match(html, /<a href="\/blog" class="menu-nav__link"[^>]*>Blog<\/a>/)
|
|
assert.match(html, /<button type="button" class="menu-nav__subtoggle" aria-expanded="true" aria-controls="m-0-sub">.*Show submenu: Blog/)
|
|
assert.match(html, /menu-nav__item--open/)
|
|
})
|