Jakub Zych a09236360b fix: vue-seo-layer as a peer dependency
pnpm refuses a git dependency inside another package by default
(blockExoticSubdeps), so an app could not install v0.5.0. vue-seo-layer
is now a peer dependency (^0.2.0) and a development dependency pinned
to v0.2.0; apps list vue-seo-layer#v0.2.0 next to the layer.
2026-11-05 01:34:28 +01:00

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-seo-layer.git#v0.2.0 git+https://git.golem15.com/golem15/vue-faq-layer.git#v0.5.1

vue-seo-layer is a peer dependency (^0.2.0): the app lists it next to the layer, pinned to a tag as above, and needs no extends entry for it (the layer extends it). The layer cannot bring it itself: pnpm refuses a git dependency inside another package (blockExoticSubdeps, on by default).

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 extends 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 <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.
  • Structured data: each FaqBlock adds a FAQPage node (@id <page URL>#faq, one Question with its acceptedAnswer as plain text per item) to the page's one JSON-LD @graph through vue-seo-layer's useSchemaNode, so the page keeps a single JSON-LD script. Blocks on the same page share the @id, so the first block's questions are the node; set :schema="false" on the others. faqSchema(faq, pageUrl) and faqAnswerText(html) in app/utils/faqSchema.ts build it.
  • Widget titles: headingLevel: 0 renders the title as <p class="faq-block__title"> (same id, still labelling the section) for sidebar and footer widgets, which are styled text rather than content headings; content blocks keep h2 to h4.
  • 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.
  • 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; 0 renders a styled <p class="faq-block__title"> for widgets.
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.
schema true Add the FAQPage node to the page's JSON-LD graph; false on every block but one when a page shows several FAQs.

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 <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.

Upgrading from 0.4

  • List vue-seo-layer in the app (from 0.5.1): "vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0" next to vue-faq-layer#v0.5.1. It is a peer dependency of the layer (^0.2.0). 0.5.0 listed it as its own git dependency, which pnpm refuses by default (ERR_PNPM_EXOTIC_SUBDEP), so use 0.5.1.
  • 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.
  • HTML sitemap pages. The layer brings vue-seo-layer's HTML sitemap pages (pl /mapa-strony, en /sitemap by default, set in seo.htmlSitemap.paths). An app that renders its pages through a root catch-all (pages/[...slug].vue, as with the pages plugin) gets none, so a CMS page at such a path is not shadowed, unless it sets seo.htmlSitemap.overCatchAll: true; an app with its own page at such a path keeps its page; seo: { htmlSitemap: false } turns them off. An app without pages of its own (only app.vue) gets none and needs no setting.

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:

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. vue-seo-layer is a peer dependency (^0.2.0) and a development dependency pinned to its tag, never a regular dependency (pnpm refuses a git dependency inside another package); vue-journal-layer's README, "Releasing", has the steps and the --check-links guard.

Testing

pnpm test                                   # node --test: the client and the FAQPage node
pnpm build:playground && pnpm smoke:ssr     # SSR of the playground against testing/fixture-api.mjs
Description
No description provided
Readme 456 KiB
Languages
TypeScript 54.8%
JavaScript 26.3%
Vue 10.8%
CSS 8.1%