Files
vue-faq-layer/app/utils/faqSchema.ts
Jakub Zych f7ec4fe56f 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
2026-11-04 14:02:05 +01:00

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