# vue-faq-layer A Nuxt 4 layer that renders a FAQ of the SummerCMS FAQ plugin as native `
` 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 ``. 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 - **`FaqBlock`**: a `
` labelled by its heading (the FAQ `name`), the description and a `FaqList`. Fetched during SSR through `useAsyncData` (key `faq::`). - **`FaqList`** and **`FaqItem`**: one native `
` per question in API order, the question in `` 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**: 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 ```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` 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) | 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 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 `, body `{"kind":"","slug":""}`, answer `{"purged": }`, 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 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 - 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 FAQ plugin's public API: ```json GET /_faq/api/v1/faqs/shipping?locale=en {"data":{"name":"Shipping","slug":"shipping","locale":"en","description":"

About shipping.

", "content":[{"question":"How long?","answer":"

Two days.

"},{"question":"Where?","answer":"

Everywhere.

"}]}} ``` 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 pnpm build:playground && pnpm smoke:ssr # SSR of the playground against testing/fixture-api.mjs ```