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

8.4 KiB
Raw Blame History

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'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.
  • 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'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:

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.