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:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
.nuxt/
|
||||
.output/
|
||||
.data/
|
||||
.playground/.nuxt/
|
||||
.playground/.output/
|
||||
.env
|
||||
app/components/Menu/.navitem-*.compiled.ts
|
||||
24
.playground/app/app.vue
Normal file
24
.playground/app/app.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div>
|
||||
<header class="playground-header">
|
||||
<MenuNav code="main" label="Main menu" />
|
||||
</header>
|
||||
<main>
|
||||
<h1>Menu playground</h1>
|
||||
<MenuNav code="missing">
|
||||
<template #fallback>
|
||||
<p class="playground-fallback">No menu</p>
|
||||
</template>
|
||||
</MenuNav>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.playground-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
</style>
|
||||
6
.playground/nuxt.config.ts
Normal file
6
.playground/nuxt.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Local playground: an app that only extends the layer and renders one menu
|
||||
// (app.vue). testing/smoke-ssr.mjs builds it against the fixture API.
|
||||
export default defineNuxtConfig({
|
||||
extends: ['..'],
|
||||
compatibilityDate: '2025-07-15',
|
||||
})
|
||||
128
README.md
Normal file
128
README.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# vue-menu-layer
|
||||
|
||||
A Nuxt 4 layer that renders a menu of the SummerCMS menu plugin as an accessible site navigation.
|
||||
|
||||
```bash
|
||||
pnpm add git+https://git.golem15.com/golem15/vue-menu-layer.git#v0.1.0
|
||||
```
|
||||
|
||||
## Overview
|
||||
|
||||
The layer is consumed with `extends: ['vue-menu-layer']`. It reads one route of the menu plugin's anonymous API, `GET /_menu/api/v1/menus/{code}`, during SSR, so the first paint already has the menu, and renders it with `<MenuNav code="…">`. Editors manage menus in the SummerCMS admin; the site needs no menu data of its own.
|
||||
|
||||
The layer is neutral: it ships no site colours, no site copy and no Nuxt modules. Its look comes from `--menu-*` CSS custom properties, which an app overrides, and every component can be replaced by a file of the same name in the app.
|
||||
|
||||
## Features
|
||||
|
||||
- **`MenuNav`**: a `<nav>` with the menu's `css_class`, a list of entries, and a 44×44 mobile toggle below 768px. Fetched during SSR through `useAsyncData` (key `menu:<code>:<locale>`).
|
||||
- **Disclosure navigation** (WAI pattern): an entry with children and a URL keeps its link and gets a sibling toggle button with `aria-expanded` and `aria-controls`; an entry with children and no URL (or the placeholder `#`) is one toggle button. Submenus open on click, and on hover for hover-capable pointers on the desktop layout; Esc (focus returns to the toggle), focus leaving the entry, a pointer-down outside the nav and every route change close them. Opening one submenu closes its open siblings.
|
||||
- **Links**: a site path renders as `NuxtLink`; an absolute, `mailto:`, `tel:` or `#fragment` URL as a plain anchor. A URL outside the allowlist (`http`, `https`, `mailto`, `tel`, a leading `/` or `#`) renders as `<span class="menu-nav__text">` without an `href`, even though the API already refuses such URLs. A `_blank` link carries `rel="noopener noreferrer"`.
|
||||
- **Active state**: the entry whose URL path equals the current route path (decoded, query, hash and trailing slash ignored; an absolute URL only on the request host) gets `aria-current="page"` and `menu-nav__link--active`; its ancestors get `menu-nav__item--trail`.
|
||||
- **Dividers**: `aria-hidden` list items, a horizontal line in panels and a short vertical line at the top level on the desktop.
|
||||
- **Empty states**: a missing menu (404), a failed request or a menu without entries renders nothing, or the `#fallback` slot. There is never an empty `<nav>`.
|
||||
- **Rate limits**: server-side fetches forward the visitor's `x-forwarded-for` (also on route-cached renders), so the API's per-client throttle sees visitors, not the Nuxt server.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts of the app
|
||||
export default defineNuxtConfig({
|
||||
extends: ['vue-menu-layer'],
|
||||
runtimeConfig: {
|
||||
menu: { apiBase: 'http://127.0.0.1:8096/_menu/api/v1' }, // NUXT_MENU_API_BASE
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
```vue
|
||||
<MenuNav
|
||||
code="main"
|
||||
label="Main menu"
|
||||
open-label="Open menu"
|
||||
close-label="Close menu"
|
||||
submenu-label="Show submenu: :title"
|
||||
/>
|
||||
```
|
||||
|
||||
| Prop | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `code` | required | The menu code. |
|
||||
| `locale` | the API's default locale | Sent as `?locale=`; picks the locale alternates of resolved entries. |
|
||||
| `label` | the menu code | `aria-label` of the `<nav>`. |
|
||||
| `openLabel` / `closeLabel` | `Open menu` / `Close menu` | Visually hidden text of the mobile toggle. |
|
||||
| `submenuLabel` | `Show submenu: :title` | Visually hidden text of a submenu toggle next to a link; `:title` is the entry title. |
|
||||
|
||||
Slots:
|
||||
|
||||
- `#item="{ entry, depth, active }"`: the content of an entry's link, button or text, for example to render the API's `icon`, which the layer does not render.
|
||||
- `#fallback`: rendered instead of the nav when there is no menu to show.
|
||||
|
||||
`useMenu(code, { locale })` returns the `useAsyncData` result (`data` is `null` for a missing menu) for an app that renders the menu its own way; `useMenuApi()` returns the typed client.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Default | Environment |
|
||||
|---|---|---|
|
||||
| `runtimeConfig.menu.apiBase` | `http://127.0.0.1:8096/_menu/api/v1` | `NUXT_MENU_API_BASE` (server only, used for SSR) |
|
||||
| `runtimeConfig.public.menu.apiBase` | `/_menu/api/v1` | `NUXT_PUBLIC_MENU_API_BASE` (browser, same origin) |
|
||||
|
||||
The base URLs always come from runtime config, never from request input. In production the web server routes `/_menu/` to the SummerCMS binary.
|
||||
|
||||
## API
|
||||
|
||||
The layer expects the menu plugin's public API:
|
||||
|
||||
```json
|
||||
GET /_menu/api/v1/menus/main?locale=en
|
||||
{"data":{"code":"main","name":"Main","css_class":null,"items":[
|
||||
{"id":1,"title":"Home","type":"url","url":"/","target":"_self","css_class":null,"icon":null,"code":"home","items":[]},
|
||||
{"id":3,"title":"Pages","type":"acme-list","url":"/pages","target":null,"css_class":null,"icon":null,"code":null,"items":[
|
||||
{"id":null,"title":"About","type":"acme-list","url":"/about","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":[]}]}}
|
||||
```
|
||||
|
||||
An unknown or inactive menu answers `404 {"error":"Menu not found"}`. The types are in `app/utils/types.ts` (`Menu`, `MenuEntry`).
|
||||
|
||||
## Theming
|
||||
|
||||
Override tokens on `:root:root` (higher specificity than the layer's `:root`, independent of CSS order):
|
||||
|
||||
```css
|
||||
:root:root {
|
||||
--menu-color-accent: #2f6b3a;
|
||||
--menu-font: 'Jost', sans-serif;
|
||||
}
|
||||
```
|
||||
|
||||
| Group | Tokens |
|
||||
|---|---|
|
||||
| Colour | `--menu-color-text`, `--menu-color-muted`, `--menu-color-bg`, `--menu-color-surface`, `--menu-color-border`, `--menu-color-accent` |
|
||||
| Type | `--menu-font`, `--menu-font-size`, `--menu-line-height` |
|
||||
| Spacing | `--menu-space-1` … `--menu-space-4` |
|
||||
| Radii | `--menu-radius`, `--menu-radius-panel` |
|
||||
| Layout | `--menu-submenu-min-width`, `--menu-toggle-size` |
|
||||
| Shadow | `--menu-shadow` |
|
||||
|
||||
The mobile breakpoint (767px) is fixed in `menu-base.css`, because custom properties cannot drive media queries. The mobile panel is positioned against the nearest positioned ancestor (a sticky header, for example).
|
||||
|
||||
To replace a component, add a file with the same path in the app, for example `app/components/Menu/NavItem.vue`.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # the playground in .playground/
|
||||
```
|
||||
|
||||
## Releasing
|
||||
|
||||
Commit, tag `vX.Y.Z` (annotated) and push the tag; apps pin `#vX.Y.Z` in their dependency.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
pnpm test # node --test: link rules, client, rendered NavItem markup
|
||||
pnpm build:playground && pnpm smoke:ssr # SSR of the playground against testing/fixture-api.mjs
|
||||
```
|
||||
|
||||
`tests/render.ts` compiles `Menu/NavItem.vue` with Vue's SFC compiler and renders it on the server, so the URL allowlist and `rel` rules are asserted on the markup.
|
||||
247
app/assets/css/menu-base.css
Normal file
247
app/assets/css/menu-base.css
Normal file
@@ -0,0 +1,247 @@
|
||||
/* MenuNav styles. Global, unscoped and token-driven, so an app restyles the
|
||||
menu through the tokens or its own rules. The 767px breakpoint is fixed
|
||||
here because custom properties cannot drive media queries. */
|
||||
|
||||
.menu-nav {
|
||||
font-family: var(--menu-font);
|
||||
font-size: var(--menu-font-size);
|
||||
line-height: var(--menu-line-height);
|
||||
color: var(--menu-color-text);
|
||||
}
|
||||
|
||||
.menu-nav__list,
|
||||
.menu-nav__submenu {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.menu-nav__list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--menu-space-2);
|
||||
}
|
||||
|
||||
.menu-nav__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.menu-nav__link,
|
||||
.menu-nav__text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--menu-space-2);
|
||||
padding: var(--menu-space-2) var(--menu-space-3);
|
||||
border-radius: var(--menu-radius);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
button.menu-nav__link,
|
||||
.menu-nav__subtoggle,
|
||||
.menu-nav__toggle {
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-nav__link:hover,
|
||||
.menu-nav__subtoggle:hover {
|
||||
background: var(--menu-color-surface);
|
||||
}
|
||||
|
||||
.menu-nav__link--active {
|
||||
color: var(--menu-color-accent);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 0.35em;
|
||||
}
|
||||
|
||||
.menu-nav__item--trail > .menu-nav__link,
|
||||
.menu-nav__item--trail > .menu-nav__subtoggle {
|
||||
color: var(--menu-color-accent);
|
||||
}
|
||||
|
||||
.menu-nav__link:focus-visible,
|
||||
.menu-nav__subtoggle:focus-visible,
|
||||
.menu-nav__toggle:focus-visible {
|
||||
outline: 2px solid var(--menu-color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.menu-nav__subtoggle:not(.menu-nav__link) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--menu-space-2);
|
||||
border-radius: var(--menu-radius);
|
||||
}
|
||||
|
||||
.menu-nav__chevron {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-right: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
transform: translateY(-2px) rotate(45deg);
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.menu-nav__item--open > .menu-nav__subtoggle .menu-nav__chevron {
|
||||
transform: translateY(1px) rotate(-135deg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.menu-nav__chevron {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-nav__submenu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-nav__item--open > .menu-nav__submenu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-nav__submenu .menu-nav__link,
|
||||
.menu-nav__submenu .menu-nav__text {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.menu-nav__divider {
|
||||
align-self: stretch;
|
||||
margin: var(--menu-space-1) 0;
|
||||
border-top: 1px solid var(--menu-color-border);
|
||||
}
|
||||
|
||||
.menu-nav__sr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.menu-nav__toggle {
|
||||
display: none;
|
||||
position: relative;
|
||||
width: var(--menu-toggle-size);
|
||||
height: var(--menu-toggle-size);
|
||||
border: 1px solid var(--menu-color-border);
|
||||
border-radius: var(--menu-radius);
|
||||
background: var(--menu-color-bg);
|
||||
}
|
||||
|
||||
.menu-nav__toggle-bars,
|
||||
.menu-nav__toggle-bars::before,
|
||||
.menu-nav__toggle-bars::after {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: 1.25rem;
|
||||
height: 2px;
|
||||
margin-left: -0.625rem;
|
||||
background: var(--menu-color-text);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.menu-nav__toggle-bars {
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.menu-nav__toggle-bars::before,
|
||||
.menu-nav__toggle-bars::after {
|
||||
left: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.menu-nav__toggle-bars::before {
|
||||
top: -6px;
|
||||
}
|
||||
|
||||
.menu-nav__toggle-bars::after {
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
/* Desktop */
|
||||
@media (min-width: 768px) {
|
||||
.menu-nav__list > .menu-nav__divider {
|
||||
align-self: center;
|
||||
width: 1px;
|
||||
height: 1.25rem;
|
||||
margin: 0;
|
||||
border-top: 0;
|
||||
border-inline-start: 1px solid var(--menu-color-border);
|
||||
}
|
||||
|
||||
.menu-nav__item > .menu-nav__submenu {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
inset-inline-start: 0;
|
||||
top: calc(100% + var(--menu-space-2));
|
||||
min-width: var(--menu-submenu-min-width);
|
||||
padding: var(--menu-space-2);
|
||||
background: var(--menu-color-bg);
|
||||
border: 1px solid var(--menu-color-border);
|
||||
border-radius: var(--menu-radius-panel);
|
||||
box-shadow: var(--menu-shadow);
|
||||
}
|
||||
|
||||
.menu-nav__submenu .menu-nav__item > .menu-nav__submenu {
|
||||
inset-inline-start: 100%;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 767px) {
|
||||
.menu-nav__toggle {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.menu-nav__list {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
inset-inline: 0;
|
||||
top: 100%;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--menu-space-1);
|
||||
padding: var(--menu-space-2) var(--menu-space-3) var(--menu-space-3);
|
||||
background: var(--menu-color-bg);
|
||||
border-bottom: 1px solid var(--menu-color-border);
|
||||
box-shadow: var(--menu-shadow);
|
||||
}
|
||||
|
||||
.menu-nav--open .menu-nav__list {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.menu-nav__item > .menu-nav__link,
|
||||
.menu-nav__item > .menu-nav__text {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
button.menu-nav__link {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.menu-nav__submenu {
|
||||
flex-basis: 100%;
|
||||
padding-inline-start: var(--menu-space-3);
|
||||
}
|
||||
}
|
||||
33
app/assets/css/menu-tokens.css
Normal file
33
app/assets/css/menu-tokens.css
Normal file
@@ -0,0 +1,33 @@
|
||||
/* Menu design tokens. Every visual value of MenuNav comes from these. An app
|
||||
overrides any of them in its own CSS, e.g. on :root:root. */
|
||||
:root {
|
||||
/* Colour */
|
||||
--menu-color-text: #1f2328; /* links and text */
|
||||
--menu-color-muted: #59636e; /* secondary text */
|
||||
--menu-color-bg: #ffffff; /* submenu and mobile panels */
|
||||
--menu-color-surface: #f6f8fa; /* link hover background */
|
||||
--menu-color-border: #d1d9e0; /* panel borders, dividers */
|
||||
--menu-color-accent: #0969da; /* active link, focus ring */
|
||||
|
||||
/* Type */
|
||||
--menu-font: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--menu-font-size: 1rem;
|
||||
--menu-line-height: 1.5;
|
||||
|
||||
/* Spacing scale */
|
||||
--menu-space-1: 0.25rem;
|
||||
--menu-space-2: 0.5rem;
|
||||
--menu-space-3: 1rem;
|
||||
--menu-space-4: 1.5rem;
|
||||
|
||||
/* Radii */
|
||||
--menu-radius: 4px; /* links */
|
||||
--menu-radius-panel: 8px; /* submenu panels */
|
||||
|
||||
/* Layout */
|
||||
--menu-submenu-min-width: 14rem;
|
||||
--menu-toggle-size: 2.75rem; /* 44px touch target */
|
||||
|
||||
/* Shadow */
|
||||
--menu-shadow: 0 8px 24px rgba(31, 35, 40, 0.12);
|
||||
}
|
||||
125
app/components/Menu/Nav.vue
Normal file
125
app/components/Menu/Nav.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
// MenuNav: a menu of the menu plugin as a site navigation. Fetched during
|
||||
// SSR (useMenu), so the first paint has it. A missing menu (404), a failed
|
||||
// request or a menu without entries renders nothing, or the #fallback slot;
|
||||
// never an empty <nav>. Below 768px a 44x44 toggle opens the list as a
|
||||
// panel; Esc and every route change close it.
|
||||
import { computed, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
|
||||
import type { MenuEntry } from '../../utils/types.ts'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** The menu code (GET /menus/{code}). */
|
||||
code: string
|
||||
/** ?locale= for the API; the API's default locale when unset. */
|
||||
locale?: string
|
||||
/** aria-label of the <nav>; the menu code when unset. */
|
||||
label?: string
|
||||
openLabel?: string
|
||||
closeLabel?: string
|
||||
/** Label of a submenu toggle next to a link; ":title" is the entry title. */
|
||||
submenuLabel?: string
|
||||
}>(),
|
||||
{
|
||||
locale: undefined,
|
||||
label: undefined,
|
||||
openLabel: 'Open menu',
|
||||
closeLabel: 'Close menu',
|
||||
submenuLabel: 'Show submenu: :title',
|
||||
},
|
||||
)
|
||||
|
||||
const slots = defineSlots<{
|
||||
item?(props: { entry: MenuEntry; depth: number; active: boolean }): unknown
|
||||
fallback?(): unknown
|
||||
}>()
|
||||
|
||||
const { data: menu, error } = await useMenu(
|
||||
() => props.code,
|
||||
{ locale: () => props.locale },
|
||||
)
|
||||
|
||||
const items = computed<MenuEntry[]>(() => (error.value ? [] : (menu.value?.items ?? [])))
|
||||
// Dividers alone are no menu.
|
||||
const visible = computed(() => items.value.some((entry) => entry.type !== 'divider'))
|
||||
|
||||
const route = useRoute()
|
||||
const host = useRequestURL().host
|
||||
const uid = useId()
|
||||
const listId = `${uid}-list`
|
||||
|
||||
const panelOpen = ref(false)
|
||||
const openIndex = ref<number | null>(null)
|
||||
const navEl = ref<HTMLElement | null>(null)
|
||||
const toggleEl = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
panelOpen.value = false
|
||||
openIndex.value = null
|
||||
},
|
||||
)
|
||||
|
||||
function setOpen(index: number, value: boolean): void {
|
||||
if (value) openIndex.value = index
|
||||
else if (openIndex.value === index) openIndex.value = null
|
||||
}
|
||||
|
||||
// Esc that no open submenu took closes the mobile panel.
|
||||
function onEscape(): void {
|
||||
if (!panelOpen.value) return
|
||||
panelOpen.value = false
|
||||
toggleEl.value?.focus()
|
||||
}
|
||||
|
||||
// A pointer-down outside the nav closes its open submenus.
|
||||
function onPointerDown(event: PointerEvent): void {
|
||||
if (navEl.value && !navEl.value.contains(event.target as Node)) openIndex.value = null
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('pointerdown', onPointerDown))
|
||||
onBeforeUnmount(() => document.removeEventListener('pointerdown', onPointerDown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
v-if="visible"
|
||||
ref="navEl"
|
||||
class="menu-nav"
|
||||
:class="[menu?.css_class, { 'menu-nav--open': panelOpen }]"
|
||||
:aria-label="label || code"
|
||||
@keydown.esc="onEscape"
|
||||
>
|
||||
<button
|
||||
ref="toggleEl"
|
||||
type="button"
|
||||
class="menu-nav__toggle"
|
||||
:aria-controls="listId"
|
||||
:aria-expanded="panelOpen ? 'true' : 'false'"
|
||||
@click="panelOpen = !panelOpen"
|
||||
>
|
||||
<span class="menu-nav__toggle-bars" aria-hidden="true" />
|
||||
<span class="menu-nav__sr">{{ panelOpen ? closeLabel : openLabel }}</span>
|
||||
</button>
|
||||
<ul :id="listId" class="menu-nav__list">
|
||||
<MenuNavItem
|
||||
v-for="(entry, i) in items"
|
||||
:key="entry.id ?? `generated-${i}`"
|
||||
:entry="entry"
|
||||
:depth="0"
|
||||
:id-base="`${uid}-${i}`"
|
||||
:open="openIndex === i"
|
||||
:route-path="route.path"
|
||||
:host="host"
|
||||
:submenu-label="submenuLabel"
|
||||
@update:open="(value: boolean) => setOpen(i, value)"
|
||||
>
|
||||
<template v-if="slots.item" #item="slotProps">
|
||||
<slot name="item" v-bind="slotProps" />
|
||||
</template>
|
||||
</MenuNavItem>
|
||||
</ul>
|
||||
</nav>
|
||||
<slot v-else name="fallback" />
|
||||
</template>
|
||||
189
app/components/Menu/NavItem.vue
Normal file
189
app/components/Menu/NavItem.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
// One menu entry (MenuNavItem, recursive): a link, a disclosure toggle with
|
||||
// its submenu, plain text, or a divider. The WAI disclosure navigation
|
||||
// pattern: an entry with children and a URL keeps its link and gets a
|
||||
// sibling toggle button; an entry with children and no URL is one button.
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { MenuEntry } from '../../utils/types.ts'
|
||||
import { entryHref, fillTitle, inTrail, isActive, linkKind, linkRel } from '../../utils/menuLinks.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: MenuEntry
|
||||
depth: number
|
||||
/** Unique, SSR-stable id prefix of this entry (submenu ids). */
|
||||
idBase: string
|
||||
open: boolean
|
||||
routePath: string
|
||||
host: string
|
||||
submenuLabel: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
|
||||
|
||||
defineSlots<{
|
||||
item?(props: { entry: MenuEntry; depth: number; active: boolean }): unknown
|
||||
}>()
|
||||
|
||||
const isDivider = computed(() => props.entry.type === 'divider')
|
||||
const children = computed(() => (isDivider.value ? [] : (props.entry.items ?? [])))
|
||||
const hasChildren = computed(() => children.value.length > 0)
|
||||
const href = computed(() => entryHref(props.entry))
|
||||
const kind = computed(() => linkKind(props.entry))
|
||||
const active = computed(() => isActive(props.entry, props.routePath, props.host))
|
||||
const trail = computed(() => !active.value && inTrail(props.entry, props.routePath, props.host))
|
||||
const target = computed(() => (props.entry.target === '_blank' ? '_blank' : undefined))
|
||||
const rel = computed(() => linkRel(props.entry.target))
|
||||
const submenuId = computed(() => `${props.idBase}-sub`)
|
||||
const linkClass = computed(() => ['menu-nav__link', props.entry.css_class, { 'menu-nav__link--active': active.value }])
|
||||
|
||||
const openChild = ref<number | null>(null)
|
||||
const itemEl = ref<HTMLElement | null>(null)
|
||||
const toggleEl = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
// A closed submenu closes its own open submenus too.
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) openChild.value = null
|
||||
},
|
||||
)
|
||||
|
||||
// Hover opens a submenu only for hover-capable pointers on the desktop
|
||||
// layout; a click on the toggle then pins it open.
|
||||
const HOVER_QUERY = '(hover: hover) and (min-width: 768px)'
|
||||
let hoverOpened = false
|
||||
|
||||
function canHover(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia(HOVER_QUERY).matches
|
||||
}
|
||||
|
||||
function setOpen(value: boolean): void {
|
||||
if (!value) hoverOpened = false
|
||||
emit('update:open', value)
|
||||
}
|
||||
|
||||
function toggle(): void {
|
||||
if (hoverOpened) {
|
||||
hoverOpened = false
|
||||
return
|
||||
}
|
||||
setOpen(!props.open)
|
||||
}
|
||||
|
||||
function onEnter(): void {
|
||||
if (hasChildren.value && !props.open && canHover()) {
|
||||
hoverOpened = true
|
||||
emit('update:open', true)
|
||||
}
|
||||
}
|
||||
|
||||
function onLeave(): void {
|
||||
if (hoverOpened) setOpen(false)
|
||||
}
|
||||
|
||||
function onEscape(event: KeyboardEvent): void {
|
||||
if (!props.open || !hasChildren.value) return
|
||||
event.stopPropagation()
|
||||
setOpen(false)
|
||||
toggleEl.value?.focus()
|
||||
}
|
||||
|
||||
function onFocusOut(event: FocusEvent): void {
|
||||
if (!props.open) return
|
||||
const next = event.relatedTarget as Node | null
|
||||
if (!next || !itemEl.value?.contains(next)) setOpen(false)
|
||||
}
|
||||
|
||||
function setChild(index: number, value: boolean): void {
|
||||
if (value) openChild.value = index
|
||||
else if (openChild.value === index) openChild.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li v-if="isDivider" class="menu-nav__divider" aria-hidden="true" />
|
||||
<li
|
||||
v-else
|
||||
ref="itemEl"
|
||||
class="menu-nav__item"
|
||||
:class="{
|
||||
'menu-nav__item--parent': hasChildren,
|
||||
'menu-nav__item--open': hasChildren && open,
|
||||
'menu-nav__item--trail': trail,
|
||||
}"
|
||||
:data-depth="depth"
|
||||
@mouseenter="onEnter"
|
||||
@mouseleave="onLeave"
|
||||
@focusout="onFocusOut"
|
||||
@keydown.esc="onEscape"
|
||||
>
|
||||
<button
|
||||
v-if="hasChildren && href === null"
|
||||
ref="toggleEl"
|
||||
type="button"
|
||||
class="menu-nav__link menu-nav__subtoggle"
|
||||
:class="entry.css_class"
|
||||
:aria-expanded="open ? 'true' : 'false'"
|
||||
:aria-controls="submenuId"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="menu-nav__label"><slot name="item" :entry="entry" :depth="depth" :active="active">{{ entry.title }}</slot></span>
|
||||
<span class="menu-nav__chevron" aria-hidden="true" />
|
||||
</button>
|
||||
<NuxtLink
|
||||
v-else-if="kind === 'router'"
|
||||
:to="href!"
|
||||
:class="linkClass"
|
||||
:target="target"
|
||||
:rel="rel"
|
||||
:aria-current="active ? 'page' : undefined"
|
||||
>
|
||||
<slot name="item" :entry="entry" :depth="depth" :active="active">{{ entry.title }}</slot>
|
||||
</NuxtLink>
|
||||
<a
|
||||
v-else-if="kind === 'anchor'"
|
||||
:href="href!"
|
||||
:class="linkClass"
|
||||
:target="target"
|
||||
:rel="rel"
|
||||
:aria-current="active ? 'page' : undefined"
|
||||
>
|
||||
<slot name="item" :entry="entry" :depth="depth" :active="active">{{ entry.title }}</slot>
|
||||
</a>
|
||||
<span v-else class="menu-nav__text" :class="entry.css_class">
|
||||
<slot name="item" :entry="entry" :depth="depth" :active="active">{{ entry.title }}</slot>
|
||||
</span>
|
||||
|
||||
<button
|
||||
v-if="hasChildren && href !== null"
|
||||
ref="toggleEl"
|
||||
type="button"
|
||||
class="menu-nav__subtoggle"
|
||||
:aria-expanded="open ? 'true' : 'false'"
|
||||
:aria-controls="submenuId"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="menu-nav__chevron" aria-hidden="true" />
|
||||
<span class="menu-nav__sr">{{ fillTitle(submenuLabel, entry.title) }}</span>
|
||||
</button>
|
||||
|
||||
<ul v-if="hasChildren" :id="submenuId" class="menu-nav__submenu">
|
||||
<MenuNavItem
|
||||
v-for="(child, i) in children"
|
||||
:key="child.id ?? `generated-${i}`"
|
||||
:entry="child"
|
||||
:depth="depth + 1"
|
||||
:id-base="`${idBase}-${i}`"
|
||||
:open="openChild === i"
|
||||
:route-path="routePath"
|
||||
:host="host"
|
||||
:submenu-label="submenuLabel"
|
||||
@update:open="(value: boolean) => setChild(i, value)"
|
||||
>
|
||||
<template v-if="$slots.item" #item="slotProps">
|
||||
<slot name="item" v-bind="slotProps" />
|
||||
</template>
|
||||
</MenuNavItem>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
20
app/composables/useMenu.ts
Normal file
20
app/composables/useMenu.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// A menu by code, fetched during SSR through useAsyncData with the key
|
||||
// menu:<code>:<locale>, so the first paint has the menu and client
|
||||
// navigation reuses the result. data is null for a missing (404) menu;
|
||||
// error is set when the API failed.
|
||||
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import type { Menu } from '../utils/types.ts'
|
||||
|
||||
export interface UseMenuOptions {
|
||||
/** ?locale= for the API; the API's default locale when empty. */
|
||||
locale?: MaybeRefOrGetter<string | undefined>
|
||||
}
|
||||
|
||||
export function useMenu(code: MaybeRefOrGetter<string>, opts: UseMenuOptions = {}) {
|
||||
const api = useMenuApi()
|
||||
const key = computed(() => `menu:${toValue(code)}:${toValue(opts.locale) ?? ''}`)
|
||||
return useAsyncData<Menu | null>(key, () => api.getMenu(toValue(code), toValue(opts.locale) || undefined), {
|
||||
default: () => null,
|
||||
})
|
||||
}
|
||||
28
app/composables/useMenuApi.ts
Normal file
28
app/composables/useMenuApi.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// The menu API client. On the server it calls the internal base
|
||||
// (runtimeConfig.menu.apiBase) and forwards the visitor's x-forwarded-for,
|
||||
// plus the address of the peer that reached Nuxt, so the Go rate limiter
|
||||
// keeps one bucket per visitor, also on route-cached renders. In the browser
|
||||
// it calls the same-origin public base. The base is always runtime config,
|
||||
// never request input.
|
||||
|
||||
import { createMenuClient, type MenuClient, type MenuFetcher } from '../utils/client.ts'
|
||||
|
||||
export function useMenuApi(): MenuClient {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
if (import.meta.server) {
|
||||
const base = (config.menu as { apiBase: string }).apiBase
|
||||
const event = useRequestEvent()
|
||||
const recorded = event?.context.menuForwardedFor as string | undefined
|
||||
const incoming = useRequestHeaders(['x-forwarded-for'])['x-forwarded-for']
|
||||
const peer = event?.node?.req?.socket?.remoteAddress
|
||||
const forwarded = recorded || [incoming, peer].filter((v): v is string => typeof v === 'string' && v !== '').join(', ')
|
||||
const headers: Record<string, string> = forwarded ? { 'x-forwarded-for': forwarded } : {}
|
||||
const fetcher: MenuFetcher = (path, query) => $fetch(path, { baseURL: base, query, headers })
|
||||
return createMenuClient(fetcher)
|
||||
}
|
||||
|
||||
const base = (config.public.menu as { apiBase: string }).apiBase
|
||||
const fetcher: MenuFetcher = (path, query) => $fetch(path, { baseURL: base, query })
|
||||
return createMenuClient(fetcher)
|
||||
}
|
||||
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
|
||||
}
|
||||
33
nuxt.config.ts
Normal file
33
nuxt.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// vue-menu-layer: renders a menu of the SummerCMS menu plugin
|
||||
// (GET /_menu/api/v1/menus/{code}) as an accessible site navigation. An app
|
||||
// consumes it with `extends: ['vue-menu-layer']` and renders <MenuNav>.
|
||||
//
|
||||
// Every path below is absolute and built from this file's directory: a
|
||||
// relative or ~ path in a layer config resolves against the consuming
|
||||
// project, not the layer. The layer declares no Nuxt modules.
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: false },
|
||||
|
||||
// Tokens first, then the neutral styles that use them. The app's own css
|
||||
// array loads after these, so its token overrides win.
|
||||
css: [`${currentDir}app/assets/css/menu-tokens.css`, `${currentDir}app/assets/css/menu-base.css`],
|
||||
|
||||
runtimeConfig: {
|
||||
menu: {
|
||||
// Server-only base for SSR fetches (NUXT_MENU_API_BASE). It never
|
||||
// comes from request input.
|
||||
apiBase: 'http://127.0.0.1:8096/_menu/api/v1',
|
||||
},
|
||||
public: {
|
||||
menu: {
|
||||
// Same-origin base for browser fetches (NUXT_PUBLIC_MENU_API_BASE).
|
||||
apiBase: '/_menu/api/v1',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "vue-menu-layer",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./nuxt.config.ts",
|
||||
"scripts": {
|
||||
"dev": "nuxi dev .playground",
|
||||
"build:playground": "nuxt build .playground",
|
||||
"test": "node --test --test-reporter=tap tests/*.test.ts",
|
||||
"dev:prepare": "nuxt prepare .playground",
|
||||
"smoke:ssr": "node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect.txt"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"nuxt": "4.4.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nuxt": "4.4.8",
|
||||
"typescript": "5.9.3",
|
||||
"vue": "3.5.35"
|
||||
}
|
||||
}
|
||||
6854
pnpm-lock.yaml
generated
Normal file
6854
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
esbuild: true
|
||||
vue-demi: true
|
||||
17
server/plugins/menu-client-address.ts
Normal file
17
server/plugins/menu-client-address.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Keeps the visitor's address available to route-cached SSR renders.
|
||||
//
|
||||
// An app rule such as `'/**': { swr }` renders cached pages from a copy of
|
||||
// the request without headers. The menu API throttles per client address,
|
||||
// so forwarding only the Nuxt peer would put every cache miss in one bucket.
|
||||
// The event context is shared with that copy, so the incoming
|
||||
// x-forwarded-for plus the peer address is recorded here, before the cached
|
||||
// handler runs, and useMenuApi forwards it.
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('request', (event) => {
|
||||
const forwarded = [getRequestHeader(event, 'x-forwarded-for'), event.node.req.socket?.remoteAddress]
|
||||
.filter((v): v is string => typeof v === 'string' && v !== '')
|
||||
.join(', ')
|
||||
event.context.menuForwardedFor = forwarded
|
||||
})
|
||||
})
|
||||
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)
|
||||
})
|
||||
78
tests/client.test.ts
Normal file
78
tests/client.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// The menu client against a fake fetcher: the URL built from base, code and
|
||||
// locale, the envelope unwrapped, 404 as null. No network.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createMenuClient, isNotFound, menuPath } from '../app/utils/client.ts'
|
||||
|
||||
type Call = { path: string; query?: Record<string, string> }
|
||||
|
||||
function fakeFetcher(answer: (path: string) => unknown) {
|
||||
const calls: Call[] = []
|
||||
const fetcher = async (path: string, query?: Record<string, string>) => {
|
||||
calls.push(query === undefined ? { path } : { path, query })
|
||||
return answer(path)
|
||||
}
|
||||
return { fetcher, calls }
|
||||
}
|
||||
|
||||
const MENU = {
|
||||
data: {
|
||||
code: 'main',
|
||||
name: 'Main',
|
||||
css_class: null,
|
||||
items: [{ id: 1, title: 'Home', type: 'url', url: '/', target: null, css_class: null, icon: null, code: null, items: [] }],
|
||||
},
|
||||
}
|
||||
|
||||
test('menuPath encodes the code', () => {
|
||||
assert.equal(menuPath('main'), '/menus/main')
|
||||
assert.equal(menuPath('a b/c'), '/menus/a%20b%2Fc')
|
||||
})
|
||||
|
||||
test('getMenu calls /menus/<code> and passes locale only when set', async () => {
|
||||
const { fetcher, calls } = fakeFetcher(() => MENU)
|
||||
const client = createMenuClient(fetcher)
|
||||
const menu = await client.getMenu('main')
|
||||
assert.equal(menu?.code, 'main')
|
||||
assert.equal(menu?.items.length, 1)
|
||||
await client.getMenu('main', 'en')
|
||||
assert.deepEqual(calls, [{ path: '/menus/main' }, { path: '/menus/main', query: { locale: 'en' } }])
|
||||
})
|
||||
|
||||
test('the fetcher base plus the path is the API URL', async () => {
|
||||
const urls: string[] = []
|
||||
const base = 'http://127.0.0.1:8096/_menu/api/v1'
|
||||
const client = createMenuClient(async (path, query) => {
|
||||
const u = new URL(base + path)
|
||||
for (const [k, v] of Object.entries(query ?? {})) u.searchParams.set(k, v)
|
||||
urls.push(u.toString())
|
||||
return MENU
|
||||
})
|
||||
await client.getMenu('main-menu', 'pl')
|
||||
assert.deepEqual(urls, ['http://127.0.0.1:8096/_menu/api/v1/menus/main-menu?locale=pl'])
|
||||
})
|
||||
|
||||
test('a 404 resolves to null; other errors are thrown', async () => {
|
||||
const notFound = createMenuClient(async () => {
|
||||
throw Object.assign(new Error('Not Found'), { statusCode: 404 })
|
||||
})
|
||||
assert.equal(await notFound.getMenu('nope'), null)
|
||||
const failing = createMenuClient(async () => {
|
||||
throw Object.assign(new Error('boom'), { status: 500 })
|
||||
})
|
||||
await assert.rejects(failing.getMenu('main'), /boom/)
|
||||
})
|
||||
|
||||
test('a body without data is null; missing items become an empty list', async () => {
|
||||
assert.equal(await createMenuClient(async () => ({})).getMenu('x'), null)
|
||||
const menu = await createMenuClient(async () => ({ data: { code: 'x', name: 'X', css_class: null } })).getMenu('x')
|
||||
assert.deepEqual(menu?.items, [])
|
||||
})
|
||||
|
||||
test('isNotFound reads status, statusCode and response.status', () => {
|
||||
assert.equal(isNotFound({ status: 404 }), true)
|
||||
assert.equal(isNotFound({ statusCode: 404 }), true)
|
||||
assert.equal(isNotFound({ response: { status: 404 } }), true)
|
||||
assert.equal(isNotFound({ status: 500 }), false)
|
||||
assert.equal(isNotFound(null), false)
|
||||
})
|
||||
137
tests/menuLinks.test.ts
Normal file
137
tests/menuLinks.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// 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/)
|
||||
})
|
||||
60
tests/render.ts
Normal file
60
tests/render.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Test helper: compiles app/components/Menu/NavItem.vue with Vue's SFC
|
||||
// compiler and renders it with the server renderer. NuxtLink is stubbed as
|
||||
// a plain anchor (it renders one for a path in the app too), the component
|
||||
// resolves itself for the recursion, and the returned HTML has no hydration
|
||||
// comments.
|
||||
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { compileScript, parse } from 'vue/compiler-sfc'
|
||||
import { createSSRApp, defineComponent, h, type Component } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import type { MenuEntry } from '../app/utils/types.ts'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const source = join(here, '..', 'app', 'components', 'Menu', 'NavItem.vue')
|
||||
|
||||
let compiled: Promise<Component> | null = null
|
||||
|
||||
async function compileNavItem(): Promise<Component> {
|
||||
const { descriptor } = parse(readFileSync(source, 'utf8'), { filename: source })
|
||||
const script = compileScript(descriptor, { id: 'navitem', inlineTemplate: true, genDefaultAs: '__sfc__' })
|
||||
// The compiled module keeps the relative imports, so it is written next to
|
||||
// the source, imported (Node strips the types) and removed.
|
||||
const out = join(dirname(source), `.navitem-${process.pid}.compiled.ts`)
|
||||
writeFileSync(out, `${script.content}\nexport default __sfc__\n`)
|
||||
try {
|
||||
const mod = (await import(pathToFileURL(out).href)) as { default: Component }
|
||||
return mod.default
|
||||
} finally {
|
||||
rmSync(out, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
const NuxtLink = defineComponent({
|
||||
props: { to: { type: String, required: true } },
|
||||
setup(props, { slots, attrs }) {
|
||||
return () => h('a', { href: props.to, ...attrs }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
export async function renderNavItem(opts: { entry: MenuEntry; routePath: string; open?: boolean }): Promise<string> {
|
||||
compiled ??= compileNavItem()
|
||||
const NavItem = await compiled
|
||||
const app = createSSRApp({
|
||||
render: () =>
|
||||
h(NavItem, {
|
||||
entry: opts.entry,
|
||||
depth: 0,
|
||||
idBase: 'm-0',
|
||||
open: opts.open ?? false,
|
||||
routePath: opts.routePath,
|
||||
host: 'site.test',
|
||||
submenuLabel: 'Show submenu: :title',
|
||||
}),
|
||||
})
|
||||
app.component('NuxtLink', NuxtLink)
|
||||
app.component('MenuNavItem', NavItem)
|
||||
// Hydration markers (<!--[-->, <!--v-if-->) are dropped for readable assertions.
|
||||
return (await renderToString(app)).replace(/<!--[^>]*-->/g, '')
|
||||
}
|
||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
// Self-contained on purpose: Vite reads this file when it compiles the
|
||||
// layer's .vue and .ts files, also from a consumer's node_modules, where no
|
||||
// generated .nuxt/tsconfig.json exists. For editor types of Nuxt
|
||||
// auto-imports, run `pnpm dev:prepare` and open .playground.
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user