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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
53
app/utils/faqSchema.ts
Normal 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) },
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
43
tests/faqSchema.test.ts
Normal 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 & <a href="/returns">free</a> of charge <30 days>.</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('"x" 'y' A B'), '"x" \'y\' A B')
|
||||
assert.equal(faqAnswerText(null), '')
|
||||
})
|
||||
Reference in New Issue
Block a user