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

@@ -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')