diff --git a/.playground/app/app.vue b/.playground/app/app.vue index 3c03b26..4ff4d9e 100644 --- a/.playground/app/app.vue +++ b/.playground/app/app.vue @@ -8,5 +8,8 @@ + diff --git a/README.md b/README.md index 3def302..5b61b3f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ The layer is neutral: it ships no site colours, no site copy and no Nuxt modules - **`FaqBlock`**: a `
` labelled by its heading (the FAQ `name`), the description and a `FaqList`. Fetched during SSR through `useAsyncData` (key `faq::`). - **`FaqList`** and **`FaqItem`**: one native `
` per question in API order, the question in `` with a CSS chevron (no animation under `prefers-reduced-motion`), the answer below. All items start closed (`openFirst` opens the first). With `exclusive`, the items share a `name`, so the browser keeps one open at a time. +- **Structured data**: each `FaqBlock` adds a `FAQPage` node (`@id` `#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`, so the page keeps a single JSON-LD script. Blocks on the same page share the `@id`, so the first block's questions are the node; set `:schema="false"` on the others. `faqSchema(faq, pageUrl)` and `faqAnswerText(html)` in `app/utils/faqSchema.ts` build it. +- **Widget titles**: `headingLevel: 0` renders the title as `

` (same id, still labelling the section) for sidebar and footer widgets, which are styled text rather than content headings; content blocks keep `h2` to `h4`. - **HTML**: the description and answers are HTML the FAQ API sanitizes on output; the layer renders them with `v-html` and adds no client sanitizer. Questions are plain text. - **Empty states**: a missing FAQ (404), a failed request or a FAQ without items renders nothing, or the `#fallback` slot. There is never an empty heading. - **Site cache purge**: vue-seo-layer's shared route `POST /api/__site__/cache/purge` drops the site's route cache when the backend saves a FAQ (or a menu or post, through the other core layers). See [Site cache purge](#site-cache-purge). @@ -45,10 +47,11 @@ export default defineNuxtConfig({ |---|---|---| | `slug` | required | The FAQ slug. | | `locale` | the API's default locale | Sent as `?locale=`. | -| `headingLevel` | `2` | Heading element, `2` to `4`. | +| `headingLevel` | `2` | Heading element, `2` to `4`; `0` renders a styled `

` for widgets. | | `showHeading` | `true` | `false` hides the heading (the section is then labelled with `aria-label`), for an app that shows its own title. | | `showDescription` | `true` | `false` hides the description. | | `exclusive` | `false` | One item open at a time. | +| `schema` | `true` | Add the `FAQPage` node to the page's JSON-LD graph; `false` on every block but one when a page shows several FAQs. | `FaqList` takes `items` (`{ question, answer }[]`), `exclusive` and `openFirst`; `FaqItem` takes `question`, `answer`, `name` and `open`. @@ -130,6 +133,6 @@ Commit, tag `vX.Y.Z` (annotated) and push the tag; apps pin `#vX.Y.Z` in their d ## Testing ```bash -pnpm test # node --test: the client +pnpm test # node --test: the client and the FAQPage node pnpm build:playground && pnpm smoke:ssr # SSR of the playground against testing/fixture-api.mjs ``` diff --git a/app/components/Faq/Block.vue b/app/components/Faq/Block.vue index 9adc7c6..a753d1e 100644 --- a/app/components/Faq/Block.vue +++ b/app/components/Faq/Block.vue @@ -3,7 +3,16 @@ // 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

(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 +//

, so crawlers read them without opening an item. import { computed, useId } from 'vue' +import { faqSchema } from '../../utils/faqSchema.ts' const props = withDefaults( defineProps<{ @@ -11,14 +20,16 @@ const props = withDefaults( slug: string /** ?locale= for the API; the API's default locale when unset. */ locale?: string - /** Level of the heading element, 2 to 4. */ - headingLevel?: 2 | 3 | 4 + /** Level of the heading element, 2 to 4; 0 renders a styled

(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 }, + { locale: undefined, headingLevel: 2, showHeading: true, showDescription: true, exclusive: false, schema: true }, ) defineSlots<{ fallback?(): unknown }>() @@ -29,9 +40,20 @@ const { data: faq, error } = await useFaq( ) const items = computed(() => (error.value ? [] : (faq.value?.content ?? []))) -const headingTag = computed(() => `h${Math.min(4, Math.max(2, Math.trunc(Number(props.headingLevel)) || 2))}`) +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) +})