feat!: extend the SEO layer and drop the copied purge route

- extend vue-seo-layer (link dependency during development); every page
  takes its head from the SEO plugin through it
- remove the copied shared purge route, library, plugin and their test:
  POST /api/__site__/cache/purge comes from vue-seo-layer with the same
  contract and environment variables
- the page-less playground sets seo.htmlSitemap false; the smoke points
  NUXT_SEO_API_BASE at the fixture API (fails open)

BREAKING CHANGE: server/api/__site__/cache/purge.post.ts,
server/lib/siteCachePurge.ts and server/plugins/site-cache-purge.ts are
no longer in this layer, and the runtime keys cachePurge and siteCache
are declared by vue-seo-layer. An app without pages sets
seo.htmlSitemap false.
This commit is contained in:
Jakub Zych
2026-11-04 14:00:04 +01:00
parent 1f23340a2f
commit cb321e9437
10 changed files with 132 additions and 891 deletions

View File

@@ -10,6 +10,8 @@ pnpm add git+https://git.golem15.com/golem15/vue-faq-layer.git#v0.2.0
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 extends [vue-seo-layer](https://git.golem15.com/golem15/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-faq-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 `--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
@@ -18,7 +20,7 @@ The layer is neutral: it ships no site colours, no site copy and no Nuxt modules
- **`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).
- **Site cache purge**: vue-seo-layer's 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
@@ -59,31 +61,20 @@ export default defineNuxtConfig({
| `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.cachePurge.token` | `''` (route off) | `NUXT_CACHE_PURGE_TOKEN` (server only; at least 32 characters) |
| `runtimeConfig.cachePurge.allowProxied` | `false` | `NUXT_CACHE_PURGE_ALLOW_PROXIED` (server only) |
| `runtimeConfig.siteCache.purgeToken` | `''` | Deprecated (one release): `NUXT_SITE_CACHE_PURGE_TOKEN`, the earlier name of `cachePurge.token`, read when that is empty |
| `runtimeConfig.siteCache.allowProxied` | `false` | Deprecated (one release): `NUXT_SITE_CACHE_ALLOW_PROXIED`, the earlier name of `cachePurge.allowProxied` |
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 `/_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:
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.
```
POST /api/__site__/cache/purge
Authorization: Bearer <NUXT_CACHE_PURGE_TOKEN>
{"kind":"faq","slug":"shipping"}
→ {"purged": <n>}
```
### Upgrading from 0.4
- 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_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_CACHE_PURGE_ALLOW_PROXIED=true` (`runtimeConfig.cachePurge.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`, `server/api/__site__/cache/purge.post.ts` and `server/plugins/site-cache-purge.ts` byte for byte (a test pins their sha256), so a site that extends several of them gets one route, whichever copy Nitro keeps.
- Never put a secret in a `NUXT_SITE_*` or `NUXT_PUBLIC_SITE_*` variable: nuxt-site-config (in a site that also uses `@nuxtjs/seo`, for example through vue-journal-layer) copies every such variable into the site config, which is published in every page's payload. The Nitro plugin `server/plugins/site-cache-purge.ts` removes `cachePurgeToken` and `cacheAllowProxied` from that site config on its `site-config:init` hook, whatever names the site uses, and logs one `[site-cache]` deprecation warning per process when a setting comes from an old name (variable names only, never a value). Without nuxt-site-config the plugin does nothing.
- 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 without pages (only `app.vue`) sets `seo: { htmlSitemap: false }`: otherwise the SEO layer's HTML sitemap pages are its only routes, which turns the router on and makes every other path answer 404.
### Upgrading from 0.3
@@ -139,6 +130,6 @@ Commit, tag `vX.Y.Z` (annotated) and push the tag; apps pin `#vX.Y.Z` in their d
## Testing
```bash
pnpm test # node --test: the client and the site cache purge
pnpm test # node --test: the client
pnpm build:playground && pnpm smoke:ssr # SSR of the playground against testing/fixture-api.mjs
```