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.

Description
No description provided
Readme 170 KiB
Languages
TypeScript 64%
JavaScript 13.9%
Vue 13.8%
CSS 8.3%