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:
24
testing/expect.txt
Normal file
24
testing/expect.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
# SSR smoke of the playground (one MenuNav code="main", one code="missing"
|
||||
# with a fallback slot) against testing/fixtures. Run: pnpm build:playground
|
||||
# && pnpm smoke:ssr. Vue's SSR markers (<!--[-->) sit around slot content.
|
||||
200 / contains=<nav class="menu-nav nav-main" aria-label="Main menu">
|
||||
200 /about contains=<a href="/" class="menu-nav__link"><!--[-->Home<!--]--></a>
|
||||
200 / contains=<a href="/about" class="menu-nav__link"><!--[-->About<!--]--></a>
|
||||
200 / contains=<span class="menu-nav__sr">Show submenu: About</span>
|
||||
200 / contains=<a href="/first-topic" class="menu-nav__link"><!--[-->First topic<!--]--></a>
|
||||
200 / contains=<li class="menu-nav__divider" aria-hidden="true"></li>
|
||||
# The parent of a list type with the placeholder URL "#" is one disclosure button.
|
||||
200 / contains=class="menu-nav__link menu-nav__subtoggle" aria-expanded="false"
|
||||
# javascript: never reaches an href; it renders as text.
|
||||
200 / notcontains=href="javascript:
|
||||
200 / contains=<span class="menu-nav__text"><!--[-->Unsafe topic<!--]--></span>
|
||||
200 / contains=target="_blank" rel="noopener noreferrer"
|
||||
200 / contains=<span class="menu-nav__sr">Open menu</span>
|
||||
# The active link and the trail of its ancestors.
|
||||
200 /about contains=<a href="/about" class="menu-nav__link menu-nav__link--active" aria-current="page">
|
||||
200 /about/team contains=menu-nav__item--parent menu-nav__item--trail
|
||||
200 /about/team/ contains=<a href="/about/team" class="menu-nav__link menu-nav__link--active" aria-current="page">
|
||||
200 /?x=1 contains=<a href="/" class="menu-nav__link menu-nav__link--active" aria-current="page">
|
||||
# A missing menu renders its fallback, never an empty nav.
|
||||
200 / contains=<p class="playground-fallback">No menu</p>
|
||||
200 / notcontains=aria-label="missing"
|
||||
67
testing/fixture-api.mjs
Normal file
67
testing/fixture-api.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
// A small node:http stand-in for the anonymous menu API (/_menu/api/v1),
|
||||
// answering GET /menus/<code> from testing/fixtures/menus/<code>.json and
|
||||
// 404 {"error":"Menu not found"} otherwise. It records every request so a
|
||||
// smoke run can assert header forwarding.
|
||||
//
|
||||
// Plain .mjs with JSDoc types: Node does not strip TypeScript under
|
||||
// node_modules, and consuming apps may import this file from there.
|
||||
|
||||
import { createServer } from 'node:http'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const PREFIX = '/_menu/api/v1'
|
||||
|
||||
/**
|
||||
* @typedef {{ method: string, url: string, xff: string | null }} FixtureRequest
|
||||
* @typedef {{ url: string, requests: FixtureRequest[], close: () => Promise<void> }} FixtureApi
|
||||
*/
|
||||
|
||||
/**
|
||||
* Starts the fixture API on 127.0.0.1.
|
||||
* @param {{ dir: string, port?: number }} opts
|
||||
* @returns {Promise<FixtureApi>}
|
||||
*/
|
||||
export function startFixtureApi({ dir, port = 0 }) {
|
||||
/** @type {FixtureRequest[]} */
|
||||
const requests = []
|
||||
|
||||
/** @param {import('node:http').IncomingMessage} req */
|
||||
const route = (req) => {
|
||||
const url = new URL(req.url ?? '/', 'http://fixture.local')
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return { status: 405, body: { error: 'Method not allowed' } }
|
||||
const m = /^\/menus\/([A-Za-z0-9_-]+)$/.exec(url.pathname.slice(PREFIX.length))
|
||||
if (!url.pathname.startsWith(PREFIX + '/') || !m) return { status: 404, body: { error: 'Menu not found' } }
|
||||
try {
|
||||
return { status: 200, body: JSON.parse(readFileSync(join(dir, 'menus', `${m[1]}.json`), 'utf8')) }
|
||||
} catch (err) {
|
||||
if (/** @type {any} */ (err).code === 'ENOENT') return { status: 404, body: { error: 'Menu not found' } }
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const xff = req.headers['x-forwarded-for']
|
||||
requests.push({ method: req.method ?? 'GET', url: req.url ?? '/', xff: Array.isArray(xff) ? xff.join(', ') : (xff ?? null) })
|
||||
let out
|
||||
try {
|
||||
out = route(req)
|
||||
} catch (err) {
|
||||
out = { status: 500, body: { error: String(err) } }
|
||||
}
|
||||
res.writeHead(out.status, { 'content-type': 'application/json' })
|
||||
res.end(req.method === 'HEAD' ? undefined : JSON.stringify(out.body))
|
||||
})
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
const addr = /** @type {import('node:net').AddressInfo} */ (server.address())
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${addr.port}`,
|
||||
requests,
|
||||
close: () => new Promise((done) => server.close(() => done())),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
39
testing/fixtures/menus/main.json
Normal file
39
testing/fixtures/menus/main.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"data": {
|
||||
"code": "main",
|
||||
"name": "Main",
|
||||
"css_class": "nav-main",
|
||||
"items": [
|
||||
{ "id": 1, "title": "Home", "type": "url", "url": "/", "target": "_self", "css_class": null, "icon": null, "code": "home", "items": [] },
|
||||
{
|
||||
"id": 2,
|
||||
"title": "About",
|
||||
"type": "url",
|
||||
"url": "/about",
|
||||
"target": null,
|
||||
"css_class": null,
|
||||
"icon": null,
|
||||
"code": null,
|
||||
"items": [
|
||||
{ "id": 3, "title": "Team", "type": "url", "url": "/about/team", "target": null, "css_class": null, "icon": null, "code": null, "items": [] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Topics",
|
||||
"type": "acme-list",
|
||||
"url": "#",
|
||||
"target": null,
|
||||
"css_class": null,
|
||||
"icon": null,
|
||||
"code": null,
|
||||
"items": [
|
||||
{ "id": null, "title": "First topic", "type": "acme-list", "url": "/first-topic", "target": null, "css_class": null, "icon": null, "code": null, "items": [] },
|
||||
{ "id": null, "title": "Unsafe topic", "type": "acme-list", "url": "javascript:alert(1)", "target": null, "css_class": null, "icon": null, "code": null, "items": [] }
|
||||
]
|
||||
},
|
||||
{ "id": 5, "title": "", "type": "divider", "url": null, "target": null, "css_class": null, "icon": null, "code": null, "items": [] },
|
||||
{ "id": 6, "title": "Partner", "type": "url", "url": "https://partner.example.test/", "target": "_blank", "css_class": "nav-ext", "icon": null, "code": null, "items": [] }
|
||||
]
|
||||
}
|
||||
}
|
||||
192
testing/smoke-ssr.mjs
Normal file
192
testing/smoke-ssr.mjs
Normal file
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
// SSR smoke check: boots the fixture menu API and the built playground
|
||||
// server, then checks each line of an expectations file.
|
||||
//
|
||||
// node testing/smoke-ssr.mjs --output <.output dir> --fixtures <dir> --expect <file> [--env KEY=VALUE]...
|
||||
//
|
||||
// Expectation grammar, one check per line ('#' starts a comment line):
|
||||
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]
|
||||
// A value runs until the next ` contains=`, ` notcontains=`, ` type=` or the
|
||||
// end of the line, so it may contain spaces. Requests are plain GETs that do
|
||||
// not follow redirects and carry `X-Forwarded-For: 203.0.113.7`; afterwards
|
||||
// at least one fixture API request must have carried that address (the SSR
|
||||
// fetches forward the visitor's x-forwarded-for).
|
||||
//
|
||||
// The same approach as vue-journal-layer's testing/smoke-ssr.mjs; plain .mjs
|
||||
// with JSDoc types so it runs from node_modules unchanged.
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { createServer } from 'node:net'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { startFixtureApi } from './fixture-api.mjs'
|
||||
|
||||
const VISITOR_IP = '203.0.113.7'
|
||||
const API_PREFIX = '/_menu/api/v1'
|
||||
const API_ENV = 'NUXT_MENU_API_BASE'
|
||||
|
||||
/** @param {string[]} argv */
|
||||
function parseArgs(argv) {
|
||||
/** @type {{ output?: string, fixtures?: string, expect?: string, env: Record<string, string> }} */
|
||||
const out = { env: {} }
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]
|
||||
const next = () => {
|
||||
const v = argv[++i]
|
||||
if (v === undefined) throw new Error(`${arg} needs a value`)
|
||||
return v
|
||||
}
|
||||
if (arg === '--output') out.output = next()
|
||||
else if (arg === '--fixtures') out.fixtures = next()
|
||||
else if (arg === '--expect') out.expect = next()
|
||||
else if (arg === '--env') {
|
||||
const kv = next()
|
||||
const eq = kv.indexOf('=')
|
||||
if (eq < 1) throw new Error(`--env expects KEY=VALUE, got ${kv}`)
|
||||
out.env[kv.slice(0, eq)] = kv.slice(eq + 1)
|
||||
} else throw new Error(`unknown argument ${arg}`)
|
||||
}
|
||||
if (!out.output || !out.fixtures || !out.expect) throw new Error('--output, --fixtures and --expect are required')
|
||||
return /** @type {{ output: string, fixtures: string, expect: string, env: Record<string, string> }} */ (out)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} line
|
||||
* @returns {{ status: number, path: string, contains?: string, notcontains?: string, type?: string }}
|
||||
*/
|
||||
export function parseExpectLine(line) {
|
||||
const m = /^(\d{3})\s+(\S+)(.*)$/.exec(line.trim())
|
||||
if (!m) throw new Error('expected "STATUS PATH [options]"')
|
||||
/** @type {{ status: number, path: string, contains?: string, notcontains?: string, type?: string }} */
|
||||
const check = { status: Number(m[1]), path: m[2] }
|
||||
const rest = m[3]
|
||||
const re = /\s(contains|notcontains|type)=/g
|
||||
/** @type {Array<{ key: string, start: number, valueStart: number }>} */
|
||||
const marks = []
|
||||
let hit
|
||||
while ((hit = re.exec(rest)) !== null) marks.push({ key: hit[1], start: hit.index, valueStart: re.lastIndex })
|
||||
if (marks.length === 0 && rest.trim() !== '') throw new Error(`unknown option text "${rest.trim()}"`)
|
||||
if (marks.length > 0 && rest.slice(0, marks[0].start).trim() !== '') {
|
||||
throw new Error(`unknown option text "${rest.slice(0, marks[0].start).trim()}"`)
|
||||
}
|
||||
/** @type {Record<string, string>} */
|
||||
const options = {}
|
||||
marks.forEach((mark, i) => {
|
||||
const end = i + 1 < marks.length ? marks[i + 1].start : rest.length
|
||||
options[mark.key] = rest.slice(mark.valueStart, end).trim()
|
||||
})
|
||||
return { ...check, ...options }
|
||||
}
|
||||
|
||||
/** @returns {Promise<number>} */
|
||||
function freePort() {
|
||||
return new Promise((done, fail) => {
|
||||
const srv = createServer()
|
||||
srv.once('error', fail)
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const { port } = /** @type {import('node:net').AddressInfo} */ (srv.address())
|
||||
srv.close(() => done(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots `<outputDir>/server/index.mjs` on 127.0.0.1 and waits until it answers.
|
||||
* @param {{ outputDir: string, env: Record<string, string>, timeoutMs?: number }} opts
|
||||
* @returns {Promise<{ url: string, close: () => Promise<void> }>}
|
||||
*/
|
||||
async function startNuxtServer({ outputDir, env, timeoutMs = 30000 }) {
|
||||
const entry = join(resolve(outputDir), 'server', 'index.mjs')
|
||||
if (!existsSync(entry)) throw new Error(`${entry} does not exist; run pnpm build:playground first`)
|
||||
const port = await freePort()
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
const child = spawn(process.execPath, [entry], {
|
||||
env: { ...process.env, NODE_ENV: 'production', NITRO_HOST: '127.0.0.1', NITRO_PORT: String(port), ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let log = ''
|
||||
child.stdout.on('data', (b) => (log += b))
|
||||
child.stderr.on('data', (b) => (log += b))
|
||||
let exited = false
|
||||
child.once('exit', () => (exited = true))
|
||||
const close = () =>
|
||||
new Promise((/** @type {(v?: void) => void} */ done) => {
|
||||
if (exited) return done()
|
||||
child.once('exit', () => done())
|
||||
child.kill('SIGTERM')
|
||||
setTimeout(() => {
|
||||
if (!exited) child.kill('SIGKILL')
|
||||
}, 5000).unref()
|
||||
})
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (exited) throw new Error(`server exited before answering\n${log}`)
|
||||
try {
|
||||
await fetch(url + '/', { redirect: 'manual', signal: AbortSignal.timeout(2000) })
|
||||
return { url, close }
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
}
|
||||
await close()
|
||||
throw new Error(`no answer on ${url} within ${timeoutMs} ms\n${log}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const lines = readFileSync(resolve(args.expect), 'utf8')
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l !== '' && !l.startsWith('#'))
|
||||
|
||||
const api = await startFixtureApi({ dir: resolve(args.fixtures) })
|
||||
/** @type {{ url: string, close: () => Promise<void> } | null} */
|
||||
let nuxt = null
|
||||
const failures = /** @type {string[]} */ ([])
|
||||
try {
|
||||
nuxt = await startNuxtServer({ outputDir: args.output, env: { [API_ENV]: `${api.url}${API_PREFIX}`, ...args.env } })
|
||||
for (const line of lines) {
|
||||
let check
|
||||
try {
|
||||
check = parseExpectLine(line)
|
||||
} catch (err) {
|
||||
failures.push(`${line}: ${/** @type {Error} */ (err).message}`)
|
||||
continue
|
||||
}
|
||||
let res
|
||||
try {
|
||||
res = await fetch(nuxt.url + check.path, {
|
||||
redirect: 'manual',
|
||||
headers: { 'X-Forwarded-For': VISITOR_IP },
|
||||
signal: AbortSignal.timeout(20000),
|
||||
})
|
||||
} catch (err) {
|
||||
failures.push(`${line}: request failed: ${/** @type {Error} */ (err).message}`)
|
||||
continue
|
||||
}
|
||||
const body = await res.text()
|
||||
const type = res.headers.get('content-type') ?? ''
|
||||
if (res.status !== check.status) failures.push(`${line}: status ${res.status}, want ${check.status}`)
|
||||
else if (check.contains !== undefined && !body.includes(check.contains)) failures.push(`${line}: body lacks "${check.contains}"`)
|
||||
else if (check.notcontains !== undefined && body.includes(check.notcontains)) failures.push(`${line}: body contains "${check.notcontains}"`)
|
||||
else if (check.type !== undefined && !type.startsWith(check.type)) failures.push(`${line}: content-type "${type}", want prefix "${check.type}"`)
|
||||
}
|
||||
if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
|
||||
failures.push('x-forwarded-for: the SSR fetches did not forward the visitor address')
|
||||
}
|
||||
} finally {
|
||||
if (nuxt) await nuxt.close()
|
||||
await api.close()
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const f of failures) console.error(`smoke-ssr: FAIL ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`smoke-ssr: ok (${lines.length} checks)`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`smoke-ssr: FAIL setup: ${err instanceof Error ? err.message : String(err)}`)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user