nuxt-site-config copies every NUXT_SITE_* variable into the public site config, so NUXT_SITE_CACHE_PURGE_TOKEN reached every page's payload as cachePurgeToken (and NUXT_SITE_CACHE_ALLOW_PROXIED as cacheAllowProxied). - The purge settings move to runtimeConfig cachePurge.token (NUXT_CACHE_PURGE_TOKEN) and cachePurge.allowProxied (NUXT_CACHE_PURGE_ALLOW_PROXIED), outside that prefix. - The old siteCache.* names keep working for one release: a non-empty new token wins, either allowProxied flag turns it on, and one deprecation warning per process names the variables (never a value). - The shared Nitro plugin server/plugins/site-cache-purge.ts strips cachePurgeToken and cacheAllowProxied from nuxt-site-config's stack on site-config:init, so a site on the old names leaks nothing either; it does nothing without nuxt-site-config. - The shared lib, route and plugin and tests/sitePurge.test.ts are byte-identical in vue-journal-layer, vue-menu-layer and vue-faq-layer, with three sha256 pins. - README: new names, the deprecation, the stripping and the advice to rotate a token ever set under the old name.
11 KiB
vue-menu-layer
A Nuxt 4 layer that renders a menu of the SummerCMS menu plugin as an accessible site navigation.
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'scss_class, a list of entries, and a 44×44 mobile toggle below 768px. Fetched during SSR throughuseAsyncData(keymenu:<code>:<locale>).- Disclosure navigation (WAI pattern): an entry with children and a URL keeps its link and gets a sibling toggle button with
aria-expandedandaria-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#fragmentURL as a plain anchor. A URL outside the allowlist (http,https,mailto,tel, a leading/or#) renders as<span class="menu-nav__text">without anhref, even though the API already refuses such URLs. A_blanklink carriesrel="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"andmenu-nav__link--active; its ancestors getmenu-nav__item--trail. - Dividers:
aria-hiddenlist 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
#fallbackslot. There is never an empty<nav>. - Site cache purge: the shared route
POST /api/__site__/cache/purgedrops 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. - 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
// 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
},
})
<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'sicon, 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.cachePurge.token |
'' (route off) |
NUXT_CACHE_PURGE_TOKEN (server only; at least 32 characters) |
runtimeConfig.cachePurge.allowProxied |
false |
NUXT_CACHE_PURGE_ALLOW_PROXIED (server only) |
runtimeConfig.siteCache.purgeToken |
'' |
Deprecated (one release): NUXT_SITE_CACHE_PURGE_TOKEN, the earlier name of cachePurge.token, read when that is empty |
runtimeConfig.siteCache.allowProxied |
false |
Deprecated (one release): NUXT_SITE_CACHE_ALLOW_PROXIED, the earlier name of cachePurge.allowProxied |
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_CACHE_PURGE_TOKEN>
{"kind":"menu_entry","slug":"partition:3"}
→ {"purged": <n>}
- It removes every entry of Nitro's route cache (pages, their
_payload.jsonand feeds) and logs[site-cache] purge: …with the kind and slug. - Set
NUXT_CACHE_PURGE_TOKENto the backend'ssite_cache.purge_token(at least 32 characters) and point the backend'ssite_cache.purge_urlathttp://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-IPorForwarded), so only the backend on the same machine reaches it, and 401 to a missing or wrong bearer token (compared in constant time). - Behind a TLS-terminating proxy, for a backend that reaches the site at an https
site_cache.purge_urlon another host, the proxy adds those headers. SetNUXT_CACHE_PURGE_ALLOW_PROXIED=true(runtimeConfig.cachePurge.allowProxied, defaultfalse) and the route accepts proxied requests; the bearer token is then the only gate, so keep it long and secret. Unset, proxied requests stay refused. - The layer adds the route rule
'/api/__site__/**': { cache: false }; without it, a site-wideswrrule would answer the purge from the cache. - vue-journal-layer, vue-menu-layer and vue-faq-layer ship the same
server/lib/siteCachePurge.ts,server/api/__site__/cache/purge.post.tsandserver/plugins/site-cache-purge.tsbyte for byte (a test pins their sha256), so a site that extends several of them gets one route, whichever copy Nitro keeps. - Never put a secret in a
NUXT_SITE_*orNUXT_PUBLIC_SITE_*variable: nuxt-site-config (in a site that also uses@nuxtjs/seo, for example through vue-journal-layer) copies every such variable into the site config, which is published in every page's payload. The Nitro pluginserver/plugins/site-cache-purge.tsremovescachePurgeTokenandcacheAllowProxiedfrom that site config on itssite-config:inithook, whatever names the site uses, and logs one[site-cache]deprecation warning per process when a setting comes from an old name (variable names only, never a value). Without nuxt-site-config the plugin does nothing.
Upgrading from 0.3
- Rename
NUXT_SITE_CACHE_PURGE_TOKENtoNUXT_CACHE_PURGE_TOKENandNUXT_SITE_CACHE_ALLOW_PROXIEDtoNUXT_CACHE_PURGE_ALLOW_PROXIEDin 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 underNUXT_SITE_CACHE_PURGE_TOKENappeared in every page ascachePurgeToken. 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'sNUXT_CACHE_PURGE_TOKENand in the backend'ssite_cache.purge_token, then restart both.
API
The layer expects the menu plugin's public API:
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):
: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
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
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.