- server/lib/siteCachePurge.ts: authorizeSitePurge, purgeSiteRouteCache,
parseSitePurgeEvent (kinds: lower-case letters and underscores)
- the route reads runtimeConfig.siteCache.purgeToken
(NUXT_SITE_CACHE_PURGE_TOKEN), 404 when off or proxied, 401 unauthorized
- route rule '/api/__site__/**': { cache: false }
- sitePurge specs with sha256 pins of the two shared files
- version 0.2.0
vue-faq-layer
A Nuxt 4 layer that renders a FAQ of the SummerCMS FAQ plugin as native <details> items.
pnpm add git+https://git.golem15.com/golem15/vue-faq-layer.git#v0.2.0
Overview
The layer is consumed with extends: ['vue-faq-layer']. It reads one route of the FAQ plugin's anonymous API, GET /_faq/api/v1/faqs/{slug}, during SSR and renders it with <FaqBlock slug="…">. Editors manage FAQs in the SummerCMS admin; the site needs no FAQ data of its own.
The layer is neutral: it ships no site colours, no site copy and no Nuxt modules. Its look comes from --faq-* CSS custom properties, which an app overrides, and every component can be replaced by a file of the same name in the app. There is deliberately no component named FaqSection, so an app's own FaqSection.vue can wrap FaqBlock.
Features
FaqBlock: a<section>labelled by its heading (the FAQname), the description and aFaqList. Fetched during SSR throughuseAsyncData(keyfaq:<slug>:<locale>).FaqListandFaqItem: one native<details>per question in API order, the question in<summary>with a CSS chevron (no animation underprefers-reduced-motion), the answer below. All items start closed (openFirstopens the first). Withexclusive, the items share aname, so the browser keeps one open at a time.- HTML: the description and answers are HTML the FAQ API sanitizes on output; the layer renders them with
v-htmland adds no client sanitizer. Questions are plain text. - Empty states: a missing FAQ (404), a failed request or a FAQ without items renders nothing, or the
#fallbackslot. There is never an empty heading. - Site cache purge: the shared route
POST /api/__site__/cache/purgedrops the site's route cache when the backend saves a FAQ (or a menu 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-faq-layer'],
runtimeConfig: {
faq: { apiBase: 'http://127.0.0.1:8096/_faq/api/v1' }, // NUXT_FAQ_API_BASE
},
})
<FaqBlock slug="shipping" :heading-level="2" exclusive />
FaqBlock props:
| Prop | Default | Meaning |
|---|---|---|
slug |
required | The FAQ slug. |
locale |
the API's default locale | Sent as ?locale=. |
headingLevel |
2 |
Heading element, 2 to 4. |
showHeading |
true |
false hides the heading (the section is then labelled with aria-label), for an app that shows its own title. |
showDescription |
true |
false hides the description. |
exclusive |
false |
One item open at a time. |
FaqList takes items ({ question, answer }[]), exclusive and openFirst; FaqItem takes question, answer, name and open.
useFaq(slug, { locale }) returns the useAsyncData result (data is null for a missing FAQ); useFaqApi() returns the typed client.
Configuration
| Key | Default | Environment |
|---|---|---|
runtimeConfig.faq.apiBase |
http://127.0.0.1:8096/_faq/api/v1 |
NUXT_FAQ_API_BASE (server only, used for SSR) |
runtimeConfig.public.faq.apiBase |
/_faq/api/v1 |
NUXT_PUBLIC_FAQ_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 /_faq/ 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":"faq","slug":"shipping"}
→ {"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_SITE_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). - 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.tsandserver/api/__site__/cache/purge.post.tsbyte 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 FAQ plugin's public API:
GET /_faq/api/v1/faqs/shipping?locale=en
{"data":{"name":"Shipping","slug":"shipping","locale":"en","description":"<p>About shipping.</p>",
"content":[{"question":"How long?","answer":"<p>Two days.</p>"},{"question":"Where?","answer":"<p>Everywhere.</p>"}]}}
An unknown or inactive FAQ, or a locale that is not enabled, answers 404 {"error":"FAQ not found"}. description may be null. The types are in app/utils/types.ts (Faq, FaqItem).
Theming
Override tokens on :root:root (higher specificity than the layer's :root, independent of CSS order):
:root:root {
--faq-color-accent: #2f6b3a;
--faq-font: 'Open Sans', sans-serif;
}
| Group | Tokens |
|---|---|
| Colour | --faq-color-text, --faq-color-muted, --faq-color-bg, --faq-color-surface, --faq-color-border, --faq-color-accent |
| Type | --faq-font, --faq-font-size-base, --faq-line-height, --faq-question-size, --faq-title-size |
| Spacing | --faq-space-1 … --faq-space-4 |
| Radius and shadow | --faq-radius, --faq-shadow |
Answer and description HTML is styled by the .faq-html rules in faq-base.css (paragraphs, lists, links, h3, blockquote).
To replace a component, add a file with the same path in the app, for example app/components/Faq/Item.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: the client and the site cache purge
pnpm build:playground && pnpm smoke:ssr # SSR of the playground against testing/fixture-api.mjs