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
2026-11-04 22:54:59 +01:00

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.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, 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.
  • 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)

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:

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
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.

Description
No description provided
Readme 453 KiB
Languages
TypeScript 52.1%
JavaScript 18.9%
Vue 18.1%
CSS 10.9%