- 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
54 lines
2.2 KiB
TypeScript
54 lines
2.2 KiB
TypeScript
// 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) },
|
|
})),
|
|
}
|
|
}
|