feat: FAQPage node and widget headings

- FaqBlock prop schema (default true) 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 (faqSchema, faqAnswerText)
- headingLevel 0 renders the title as <p class="faq-block__title"> with
  the same id, still labelling the section, for sidebar and footer
  widgets
- smoke: count= and repeatable options; expectations for the widget
  title, one FAQPage node and the answers in the HTML inside details
This commit is contained in:
Jakub Zych
2026-11-04 14:02:05 +01:00
parent cb321e9437
commit f7ec4fe56f
7 changed files with 185 additions and 22 deletions

View File

@@ -8,5 +8,8 @@
</template>
</FaqBlock>
<FaqBlock slug="empty" />
<aside class="playground-widget">
<FaqBlock slug="acme" :heading-level="0" :show-description="false" :schema="false" />
</aside>
</main>
</template>

View File

@@ -18,6 +18,8 @@ The layer is neutral: it ships no site colours, no site copy and no Nuxt modules
- **`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](#site-cache-purge).
@@ -45,10 +47,11 @@ export default defineNuxtConfig({
|---|---|---|
| `slug` | required | The FAQ slug. |
| `locale` | the API's default locale | Sent as `?locale=`. |
| `headingLevel` | `2` | Heading element, `2` to `4`. |
| `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`.
@@ -130,6 +133,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
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
```

View File

@@ -3,7 +3,16 @@
// heading (the FAQ name), the server-sanitized description and the items.
// A missing FAQ (404), a failed request or a FAQ without items renders
// nothing, or the #fallback slot; never an empty heading.
//
// headingLevel 0 renders the title as <p class="faq-block__title"> (same id,
// still the section's aria-labelledby) for sidebar and footer widgets, which
// are styled text, not content headings (D-52, UI-SPEC L6). With schema (the
// default) the block adds a FAQPage node to the page's one JSON-LD graph
// (vue-seo-layer's useSchemaNode, D-43); set schema false on every block but
// one when a page shows several FAQs. The answers are in the HTML inside
// <details>, so crawlers read them without opening an item.
import { computed, useId } from 'vue'
import { faqSchema } from '../../utils/faqSchema.ts'
const props = withDefaults(
defineProps<{
@@ -11,14 +20,16 @@ const props = withDefaults(
slug: string
/** ?locale= for the API; the API's default locale when unset. */
locale?: string
/** Level of the heading element, 2 to 4. */
headingLevel?: 2 | 3 | 4
/** Level of the heading element, 2 to 4; 0 renders a styled <p> (widgets). */
headingLevel?: 0 | 2 | 3 | 4
showHeading?: boolean
showDescription?: boolean
/** Only one item open at a time (native exclusive details). */
exclusive?: boolean
/** Add the FAQPage node to the page's JSON-LD graph. */
schema?: boolean
}>(),
{ locale: undefined, headingLevel: 2, showHeading: true, showDescription: true, exclusive: false },
{ locale: undefined, headingLevel: 2, showHeading: true, showDescription: true, exclusive: false, schema: true },
)
defineSlots<{ fallback?(): unknown }>()
@@ -29,9 +40,20 @@ const { data: faq, error } = await useFaq(
)
const items = computed(() => (error.value ? [] : (faq.value?.content ?? [])))
const headingTag = computed(() => `h${Math.min(4, Math.max(2, Math.trunc(Number(props.headingLevel)) || 2))}`)
const headingTag = computed(() => {
const level = Math.trunc(Number(props.headingLevel))
return level === 0 ? 'p' : `h${Math.min(4, Math.max(2, level || 2))}`
})
const description = computed(() => (props.showDescription ? (faq.value?.description ?? '').trim() : ''))
const headingId = `${useId()}-title`
const answer = useSeoAnswer()
const request = useRequestURL()
useSchemaNode(() => {
if (!props.schema || error.value || items.value.length === 0) return null
const url = answer.value?.action === 'ok' && /^https?:\/\//i.test(answer.value.url) ? answer.value.url : `${request.origin}${request.pathname}`
return faqSchema(faq.value, url)
})
</script>
<template>

53
app/utils/faqSchema.ts Normal file
View File

@@ -0,0 +1,53 @@
// The schema.org FAQPage node of a FAQ (D-43): one Question per item with its
// acceptedAnswer as plain text (the answer's HTML without markup), @id
// <page URL>#faq. FaqBlock adds it to the page's one JSON-LD @graph through
// vue-seo-layer's useSchemaNode (prop schema, default true), so a page keeps
// one JSON-LD script; a second block on the page with the same @id merges
// into the first (the first one's questions stay). Pure, so `node --test`
// drives it.
import type { Faq } from './types.ts'
export interface FaqPageNode {
'@type': 'FAQPage'
'@id': string
url: string
mainEntity: Array<{ '@type': 'Question'; name: string; acceptedAnswer: { '@type': 'Answer'; text: string } }>
[key: string]: unknown
}
const NAMED: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
/** Plain text of an answer: tags dropped (script and style with their content), entities decoded, whitespace collapsed. */
export function faqAnswerText(html: string | null | undefined): string {
if (!html) return ''
return html
.replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, ' ')
.replace(/<[^>]*>/g, ' ')
.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (m, code: string) => {
if (code[0] === '#') {
const n = code[1] === 'x' || code[1] === 'X' ? Number.parseInt(code.slice(2), 16) : Number.parseInt(code.slice(1), 10)
return Number.isFinite(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : m
}
return NAMED[code.toLowerCase()] ?? m
})
.replace(/\s+/g, ' ')
.trim()
}
/** The FAQPage node of `faq` on the page at `pageUrl`, or null without questions. */
export function faqSchema(faq: Faq | null | undefined, pageUrl: string): FaqPageNode | null {
const items = (faq?.content ?? []).filter((item) => item && typeof item.question === 'string' && item.question.trim() !== '')
if (items.length === 0) return null
const url = pageUrl.split('#')[0] ?? pageUrl
return {
'@type': 'FAQPage',
'@id': `${url}#faq`,
url,
mainEntity: items.map((item) => ({
'@type': 'Question',
name: item.question.trim(),
acceptedAnswer: { '@type': 'Answer', text: faqAnswerText(item.answer) },
})),
}
}

View File

@@ -12,3 +12,11 @@
200 / contains=<p class="playground-fallback">No FAQ</p>
200 / notcontains=>Empty</h
200 / notcontains=FaqSection
# A widget block (headingLevel 0) renders its title as a styled p that still
# labels the section (UI-SPEC L6); the page keeps one heading per content block.
200 / contains=<p id= contains=class="faq-block__title">Acme questions</p>
200 / count=<h2:1 count=<h3:1
# One JSON-LD script with one FAQPage node (two blocks with schema share its
# @id; the widget has schema off); the answers are in the HTML inside details.
200 / count=application/ld+json:1 count="@type":"FAQPage":1 contains="acceptedAnswer":{"@type":"Answer","text":"Two days. Poland Europe"}
200 / contains=<details class="faq-item" name= contains=<p>Two days.</p><ul><li>Poland</li><li>Europe</li></ul>

View File

@@ -5,9 +5,12 @@
// node testing/smoke-ssr.mjs --output <.output dir> --fixtures <dir> --expect <file> [--env KEY=VALUE]...
//
// Expectation grammar, one check per line ('#' starts a comment line):
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]
// A value runs until the next ` contains=`, ` notcontains=`, ` type=` or the
// end of the line, so it may contain spaces. Requests are plain GETs that do
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [count=TEXT:N] [type=PREFIX]
// A value runs until the next ` contains=`, ` notcontains=`, ` count=`,
// ` type=` or the end of the line, so it may contain spaces. Every option
// may repeat on a line and every occurrence applies; count= wants exactly N
// occurrences of TEXT in the body (N after the last colon), as in
// vue-seo-layer's testing/smoke-ssr.mjs. Requests are plain GETs that do
// not follow redirects and carry `X-Forwarded-For: 203.0.113.7`; afterwards
// at least one fixture API request must have carried that address (the SSR
// fetches forward the visitor's x-forwarded-for).
@@ -50,17 +53,19 @@ function parseArgs(argv) {
return /** @type {{ output: string, fixtures: string, expect: string, env: Record<string, string> }} */ (out)
}
/**
* @typedef {{ status: number, path: string, options: Array<{ key: string, value: string }> }} ExpectCheck
*/
/**
* @param {string} line
* @returns {{ status: number, path: string, contains?: string, notcontains?: string, type?: string }}
* @returns {ExpectCheck}
*/
export function parseExpectLine(line) {
const m = /^(\d{3})\s+(\S+)(.*)$/.exec(line.trim())
if (!m) throw new Error('expected "STATUS PATH [options]"')
/** @type {{ status: number, path: string, contains?: string, notcontains?: string, type?: string }} */
const check = { status: Number(m[1]), path: m[2] }
const rest = m[3]
const re = /\s(contains|notcontains|type)=/g
const re = /\s(contains|notcontains|count|type)=/g
/** @type {Array<{ key: string, start: number, valueStart: number }>} */
const marks = []
let hit
@@ -69,13 +74,42 @@ export function parseExpectLine(line) {
if (marks.length > 0 && rest.slice(0, marks[0].start).trim() !== '') {
throw new Error(`unknown option text "${rest.slice(0, marks[0].start).trim()}"`)
}
/** @type {Record<string, string>} */
const options = {}
marks.forEach((mark, i) => {
const options = marks.map((mark, i) => {
const end = i + 1 < marks.length ? marks[i + 1].start : rest.length
options[mark.key] = rest.slice(mark.valueStart, end).trim()
return { key: mark.key, value: rest.slice(mark.valueStart, end).trim() }
})
return { ...check, ...options }
return { status: Number(m[1]), path: m[2], options }
}
/** @param {string} body @param {string} text */
function occurrences(body, text) {
if (text === '') return 0
let n = 0
for (let at = body.indexOf(text); at !== -1; at = body.indexOf(text, at + text.length)) n++
return n
}
/**
* The failures of one response against its check.
* @param {ExpectCheck} check @param {{ status: number, type: string, body: string }} res
* @returns {string[]}
*/
export function checkResponse(check, res) {
if (res.status !== check.status) return [`status ${res.status}, want ${check.status}`]
/** @type {string[]} */
const out = []
for (const { key, value } of check.options) {
if (key === 'contains' && !res.body.includes(value)) out.push(`body lacks "${value}"`)
else if (key === 'notcontains' && res.body.includes(value)) out.push(`body contains "${value}"`)
else if (key === 'type' && !res.type.startsWith(value)) out.push(`content-type "${res.type}", want prefix "${value}"`)
else if (key === 'count') {
const colon = value.lastIndexOf(':')
const want = colon > 0 ? Number(value.slice(colon + 1)) : NaN
if (!Number.isInteger(want)) out.push(`count=${value}: want TEXT:N`)
else if (occurrences(res.body, value.slice(0, colon)) !== want) out.push(`body has ${occurrences(res.body, value.slice(0, colon))} of "${value.slice(0, colon)}", want ${want}`)
}
}
return out
}
/** @returns {Promise<number>} */
@@ -172,10 +206,7 @@ async function main() {
}
const body = await res.text()
const type = res.headers.get('content-type') ?? ''
if (res.status !== check.status) failures.push(`${line}: status ${res.status}, want ${check.status}`)
else if (check.contains !== undefined && !body.includes(check.contains)) failures.push(`${line}: body lacks "${check.contains}"`)
else if (check.notcontains !== undefined && body.includes(check.notcontains)) failures.push(`${line}: body contains "${check.notcontains}"`)
else if (check.type !== undefined && !type.startsWith(check.type)) failures.push(`${line}: content-type "${type}", want prefix "${check.type}"`)
for (const f of checkResponse(check, { status: res.status, type, body })) failures.push(`${line}: ${f}`)
}
if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
failures.push('x-forwarded-for: the SSR fetches did not forward the visitor address')

43
tests/faqSchema.test.ts Normal file
View File

@@ -0,0 +1,43 @@
// The FAQPage node of a FAQ block (D-43): one Question per item with the
// answer as plain text, @id <page URL>#faq.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { faqAnswerText, faqSchema } from '../app/utils/faqSchema.ts'
const FAQ = {
name: 'Acme questions',
slug: 'acme',
locale: 'en',
description: '<p>About Acme.</p>',
content: [
{ question: 'How long does shipping take?', answer: '<p>Two days.</p><ul><li>Poland</li><li>Europe</li></ul>' },
{ question: 'Can I return it?', answer: '<p>Yes &amp; <a href="/returns">free</a>&nbsp;of charge &lt;30 days&gt;.</p>' },
],
}
test('faqSchema: a FAQPage with a Question and acceptedAnswer per item', () => {
assert.deepEqual(faqSchema(FAQ, 'https://acme.test/help'), {
'@type': 'FAQPage',
'@id': 'https://acme.test/help#faq',
url: 'https://acme.test/help',
mainEntity: [
{ '@type': 'Question', name: 'How long does shipping take?', acceptedAnswer: { '@type': 'Answer', text: 'Two days. Poland Europe' } },
{ '@type': 'Question', name: 'Can I return it?', acceptedAnswer: { '@type': 'Answer', text: 'Yes & free of charge <30 days>.' } },
],
})
})
test('faqSchema: no FAQ, no items or blank questions give null or skip the item', () => {
assert.equal(faqSchema(null, 'https://acme.test/'), null)
assert.equal(faqSchema({ ...FAQ, content: [] }, 'https://acme.test/'), null)
const node = faqSchema({ ...FAQ, content: [{ question: ' ', answer: '<p>x</p>' }, { question: 'Q', answer: '' }] }, 'https://acme.test/x#top')
assert.equal(node?.['@id'], 'https://acme.test/x#faq')
assert.deepEqual(node?.mainEntity, [{ '@type': 'Question', name: 'Q', acceptedAnswer: { '@type': 'Answer', text: '' } }])
})
test('faqAnswerText: markup stripped, entities decoded, scripts and styles dropped, whitespace collapsed', () => {
assert.equal(faqAnswerText('<p>a</p>\n<p>b<br>c</p>'), 'a b c')
assert.equal(faqAnswerText('<script>alert(1)</script><style>p{}</style><p>ok</p>'), 'ok')
assert.equal(faqAnswerText('&quot;x&quot; &#39;y&#39; &#x41; &#66;'), '"x" \'y\' A B')
assert.equal(faqAnswerText(null), '')
})