Files
vue-faq-layer/app/components/Faq/Block.vue
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

73 lines
3.0 KiB
Vue

<script setup lang="ts">
// FaqBlock: a FAQ of the FAQ plugin, fetched during SSR (useFaq): the
// 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<{
/** The FAQ slug (GET /faqs/{slug}). */
slug: string
/** ?locale= for the API; the API's default locale when unset. */
locale?: string
/** 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, schema: true },
)
defineSlots<{ fallback?(): unknown }>()
const { data: faq, error } = await useFaq(
() => props.slug,
{ locale: () => props.locale },
)
const items = computed(() => (error.value ? [] : (faq.value?.content ?? [])))
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>
<section
v-if="faq && items.length > 0"
class="faq-block"
:aria-labelledby="showHeading ? headingId : undefined"
:aria-label="showHeading ? undefined : faq.name"
>
<component :is="headingTag" v-if="showHeading" :id="headingId" class="faq-block__title">{{ faq.name }}</component>
<!-- eslint-disable-next-line vue/no-v-html -- sanitized by the FAQ API on output -->
<div v-if="description" class="faq-block__description faq-html" v-html="description" />
<FaqList :items="items" :exclusive="exclusive" />
</section>
<slot v-else name="fallback" />
</template>