pnpm refuses a git dependency inside another package by default (blockExoticSubdeps), so an app could not install v0.5.0. vue-seo-layer is now a peer dependency (^0.2.0) and a development dependency pinned to v0.2.0; apps list vue-seo-layer#v0.2.0 next to the layer.
153 lines
11 KiB
Markdown
153 lines
11 KiB
Markdown
# 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-seo-layer.git#v0.2.0 git+https://git.golem15.com/golem15/vue-menu-layer.git#v0.5.1
|
||
```
|
||
|
||
vue-seo-layer is a peer dependency (`^0.2.0`): the app lists it next to the layer, pinned to a tag as above, and needs no `extends` entry for it (the layer extends it). The layer cannot bring it itself: pnpm refuses a git dependency inside another package (`blockExoticSubdeps`, on by default).
|
||
|
||
## 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
|
||
|
||
- **List vue-seo-layer in the app** (from 0.5.1): `"vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0"` next to `vue-menu-layer#v0.5.1`. It is a peer dependency of the layer (`^0.2.0`). 0.5.0 listed it as its own git dependency, which pnpm refuses by default (`ERR_PNPM_EXOTIC_SUBDEP`), so use 0.5.1.
|
||
- 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. vue-seo-layer is a peer dependency (`^0.2.0`) and a development dependency pinned to its tag, never a regular dependency (pnpm refuses a git dependency inside another package); vue-journal-layer's README, "Releasing", has the steps and the `--check-links` guard.
|
||
|
||
## 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.
|