Files
vue-faq-layer/README.md
Jakub Zych a229f6a6c5 feat(19.3-06): purge route accepts proxied requests on siteCache.allowProxied (WR-03)
- runtimeConfig.siteCache.allowProxied (NUXT_SITE_CACHE_ALLOW_PROXIED, default
  false) lets the shared purge route accept a request that came through a
  TLS-terminating proxy; the bearer token is then the only gate
- shared lib, route and spec stay byte-identical across the three layers,
  with updated sha256 pins and two new sitePurge specs

- version 0.3.0
2026-10-15 22:42:09 +02:00

135 lines
7.2 KiB
Markdown

# vue-faq-layer
A Nuxt 4 layer that renders a FAQ of the SummerCMS FAQ plugin as native `<details>` items.
```bash
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 FAQ `name`), the description and a `FaqList`. Fetched during SSR through `useAsyncData` (key `faq:<slug>:<locale>`).
- **`FaqList`** and **`FaqItem`**: one native `<details>` per question in API order, the question in `<summary>` with a CSS chevron (no animation under `prefers-reduced-motion`), the answer below. All items start closed (`openFirst` opens the first). With `exclusive`, the items share a `name`, 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-html` and 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 `#fallback` slot. There is never an empty heading.
- **Site cache purge**: the shared route `POST /api/__site__/cache/purge` drops 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](#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
```ts
// 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
},
})
```
```vue
<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.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).
- Behind a TLS-terminating proxy, for a backend that reaches the site at an https `site_cache.purge_url` on another host, the proxy adds those headers. Set `NUXT_SITE_CACHE_ALLOW_PROXIED=true` (`runtimeConfig.siteCache.allowProxied`, default `false`) 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-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 FAQ plugin's public API:
```json
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):
```css
: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
```bash
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
```bash
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
```