#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 `` (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 `
` 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
```
diff --git a/app/components/Faq/Block.vue b/app/components/Faq/Block.vue
index 9adc7c6..a753d1e 100644
--- a/app/components/Faq/Block.vue
+++ b/app/components/Faq/Block.vue
@@ -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
(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
+// , 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 (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)
+})
diff --git a/app/utils/faqSchema.ts b/app/utils/faqSchema.ts
new file mode 100644
index 0000000..d46931a
--- /dev/null
+++ b/app/utils/faqSchema.ts
@@ -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
+// #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 = { 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) },
+ })),
+ }
+}
diff --git a/testing/expect.txt b/testing/expect.txt
index bc6cc25..1e8c27b 100644
--- a/testing/expect.txt
+++ b/testing/expect.txt
@@ -12,3 +12,11 @@
200 / contains=No FAQ
200 / notcontains=>EmptyAcme questions
+200 / count=Two days.
diff --git a/testing/smoke-ssr.mjs b/testing/smoke-ssr.mjs
index f722f22..246aba3 100644
--- a/testing/smoke-ssr.mjs
+++ b/testing/smoke-ssr.mjs
@@ -5,9 +5,12 @@
// node testing/smoke-ssr.mjs --output <.output dir> --fixtures --expect [--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 }} */ (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} */
- 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} */
@@ -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')
diff --git a/tests/faqSchema.test.ts b/tests/faqSchema.test.ts
new file mode 100644
index 0000000..c6d5234
--- /dev/null
+++ b/tests/faqSchema.test.ts
@@ -0,0 +1,43 @@
+// The FAQPage node of a FAQ block (D-43): one Question per item with the
+// answer as plain text, @id #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: 'About Acme.
',
+ content: [
+ { question: 'How long does shipping take?', answer: 'Two days.
' },
+ { question: 'Can I return it?', answer: 'Yes & free of charge <30 days>.
' },
+ ],
+}
+
+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: 'x
' }, { 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('a
\nb
c
'), 'a b c')
+ assert.equal(faqAnswerText('ok
'), 'ok')
+ assert.equal(faqAnswerText('"x" 'y' A B'), '"x" \'y\' A B')
+ assert.equal(faqAnswerText(null), '')
+})