// 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) }, })), } }