Files
vue-menu-layer/README.md
Jakub Zych 3bc7729646 feat(19.3-04): shared site cache purge route POST /api/__site__/cache/purge
- server/lib/siteCachePurge.ts and the route, byte for byte from
  vue-faq-layer (sha256 pinned in tests/sitePurge.test.ts)
- runtimeConfig.siteCache.purgeToken (NUXT_SITE_CACHE_PURGE_TOKEN) and the
  '/api/__site__/**': { cache: false } route rule
- version 0.2.0
2026-10-15 18:31:13 +02:00

148 lines
8.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.2.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>`.
- **Site cache purge**: the shared route `POST /api/__site__/cache/purge` drops the site's route cache when the backend saves, deletes or reorders a menu or menu entry (or a FAQ or post, through the other core layers). See [Site cache purge](#site-cache-purge).
- **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) |
| `runtimeConfig.siteCache.purgeToken` | `''` (route off) | `NUXT_SITE_CACHE_PURGE_TOKEN` (server only; at least 32 characters) |
The base URLs always come from runtime config, never from request input. In production the web server routes `/_menu/` to the SummerCMS binary.
## Site cache purge
A site that route-caches its pages (for example `routeRules: { '/**': { swr: 300 } }`) shows an admin edit only when the cache window ends. The layer ships the shared purge route that the SummerCMS backend calls after an admin save commits:
```
POST /api/__site__/cache/purge
Authorization: Bearer <NUXT_SITE_CACHE_PURGE_TOKEN>
{"kind":"menu_entry","slug":"partition:3"}
→ {"purged": <n>}
```
- It removes every entry of Nitro's route cache (pages, their `_payload.json` and feeds) and logs `[site-cache] purge: …` with the kind and slug.
- Set `NUXT_SITE_CACHE_PURGE_TOKEN` to the backend's `site_cache.purge_token` (at least 32 characters) and point the backend's `site_cache.purge_url` at `http://127.0.0.1:<port>/api/__site__/cache/purge`. Without a token of that length the route answers 404.
- It answers 404 to any request that came through a reverse proxy (`X-Forwarded-For`, `X-Real-IP` or `Forwarded`), so only the backend on the same machine reaches it, and 401 to a missing or wrong bearer token (compared in constant time).
- The layer adds the route rule `'/api/__site__/**': { cache: false }`; without it, a site-wide `swr` rule would answer the purge from the cache.
- vue-journal-layer, vue-menu-layer and vue-faq-layer ship the same `server/lib/siteCachePurge.ts` and `server/api/__site__/cache/purge.post.ts` byte for byte (a test pins their sha256), so a site that extends several of them gets one route, whichever copy Nitro keeps.
## 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, site cache purge
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.