Files
vue-menu-layer/README.md
Jakub Zych a7602c9659 chore: version 0.5.0 and install pin
- package.json version 0.5.0, README install line #v0.5.0 (L4 CR-01)
- Upgrading from 0.4: the HTML sitemap pages, the catch-all rule and
  seo.htmlSitemap.overCatchAll (L4 WR-04); an app without pages needs no
  htmlSitemap: false any more
- the link:../vue-seo-layer dependency stays until the release swap
2026-11-04 22:54:59 +01:00

150 lines
10 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.5.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 extends [vue-seo-layer](https://git.golem15.com/golem15/vue-seo-layer), which sets every page's head from the SummerCMS SEO plugin and serves the shared site cache purge route; an app that extends vue-menu-layer gets it with no extra entry in `extends`.
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**: vue-seo-layer's 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) |
The purge settings (`runtimeConfig.cachePurge.token`, `NUXT_CACHE_PURGE_TOKEN`; `cachePurge.allowProxied`, `NUXT_CACHE_PURGE_ALLOW_PROXIED`; the deprecated `siteCache.*` names) and the SEO options (`seo.*`, `NUXT_SEO_API_BASE` and the rest) are vue-seo-layer's; see its README.
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
The purge route `POST /api/__site__/cache/purge` that the SummerCMS backend calls after an admin save commits lives in vue-seo-layer: one copy for every layer, which also empties the SEO layer's resolve cache. Its contract is unchanged (`Authorization: Bearer <NUXT_CACHE_PURGE_TOKEN>`, body `{"kind":"<kind>","slug":"<slug>"}`, answer `{"purged": <n>}`, 404 without a token of at least 32 characters, 401 for a wrong token, proxied requests refused unless `NUXT_CACHE_PURGE_ALLOW_PROXIED=true`). Set the token and the backend's `site_cache.purge_url` as vue-seo-layer's README describes.
### Upgrading from 0.4
- The layer extends vue-seo-layer. Nothing is needed in the app's `extends`, but every page now resolves through the SEO plugin (`GET /_seo/api/v1/resolve`) and takes its head from the answer; set `NUXT_SEO_API_BASE` when the backend is not on `http://127.0.0.1:8096`, or `seo: { cms: false }` for a site without the SEO plugin.
- `server/api/__site__/cache/purge.post.ts`, `server/lib/siteCachePurge.ts` and `server/plugins/site-cache-purge.ts` are removed from this layer: the same route, library and plugin come from vue-seo-layer, with the same environment variables. An app that imported `server/lib/siteCachePurge.ts` from this layer imports it from vue-seo-layer.
- **HTML sitemap pages.** The layer brings vue-seo-layer's HTML sitemap pages (pl `/mapa-strony`, en `/sitemap` by default, set in `seo.htmlSitemap.paths`). An app that renders its pages through a root catch-all (`pages/[...slug].vue`, as with the pages plugin) gets none, so a CMS page at such a path is not shadowed, unless it sets `seo.htmlSitemap.overCatchAll: true`; an app with its own page at such a path keeps its page; `seo: { htmlSitemap: false }` turns them off. An app without pages of its own (only `app.vue`) gets none and needs no setting.
### Upgrading from 0.3
- Rename `NUXT_SITE_CACHE_PURGE_TOKEN` to `NUXT_CACHE_PURGE_TOKEN` and `NUXT_SITE_CACHE_ALLOW_PROXIED` to `NUXT_CACHE_PURGE_ALLOW_PROXIED` in the site's environment. The old names keep working through 0.4.x (a non-empty new token wins) and are removed in the next minor release.
- Why: nuxt-site-config publishes every `NUXT_SITE_*` variable in the page payload, so in a site with nuxt-site-config a token set under `NUXT_SITE_CACHE_PURGE_TOKEN` appeared in every page as `cachePurgeToken`. From 0.4 the layer strips both keys from the site config even under the old names.
- Rotate a token that was ever set under `NUXT_SITE_CACHE_PURGE_TOKEN`: earlier versions published it. Set the new value in the site's `NUXT_CACHE_PURGE_TOKEN` and in the backend's `site_cache.purge_token`, then restart both.
## 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.