The SFC is compiled with vue/compiler-sfc and rendered with vue/server-renderer, the Nuxt composables stubbed: headingLevel 0 renders a <p> that still labels the section, the FAQPage node has plain-text answers, and a missing or empty FAQ renders the fallback slot.
121 lines
5.3 KiB
TypeScript
121 lines
5.3 KiB
TypeScript
// FaqBlock (D-52, UI-SPEC L6) rendered on the server: the SFC is compiled
|
|
// with vue/compiler-sfc and rendered with vue/server-renderer, with the
|
|
// Nuxt composables it calls (useFaq, useSeoAnswer, useRequestURL,
|
|
// useSchemaNode) stubbed on globalThis and FaqList stubbed as a component.
|
|
// headingLevel picks the title's element (0 a <p>), the section stays
|
|
// labelled by the title, and the FAQPage node it hands to the page graph
|
|
// carries the answers as plain text.
|
|
import assert from 'node:assert/strict'
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { after, test } from 'node:test'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { compileScript, parse } from 'vue/compiler-sfc'
|
|
import { createSSRApp, h, ref, type Component } from 'vue'
|
|
import { renderToString } from 'vue/server-renderer'
|
|
|
|
const root = new URL('..', import.meta.url)
|
|
const dir = mkdtempSync(join(tmpdir(), 'faq-block-'))
|
|
after(() => rmSync(dir, { recursive: true, force: true }))
|
|
|
|
async function loadBlock(): Promise<Component> {
|
|
const source = readFileSync(new URL('app/components/Faq/Block.vue', root), 'utf8')
|
|
const { descriptor } = parse(source, { filename: 'Block.vue' })
|
|
const script = compileScript(descriptor, { id: 'faq-block', inlineTemplate: true, templateOptions: { ssr: true } })
|
|
const code = script.content
|
|
.replaceAll(`from 'vue'`, `from '${import.meta.resolve('vue')}'`)
|
|
.replaceAll(`from "vue"`, `from '${import.meta.resolve('vue')}'`)
|
|
.replaceAll(`from 'vue/server-renderer'`, `from '${import.meta.resolve('vue/server-renderer')}'`)
|
|
.replaceAll(`from "vue/server-renderer"`, `from '${import.meta.resolve('vue/server-renderer')}'`)
|
|
.replaceAll(`'../../utils/faqSchema.ts'`, `'${new URL('app/utils/faqSchema.ts', root).href}'`)
|
|
const file = join(dir, 'Block.ts')
|
|
writeFileSync(file, code)
|
|
return (await import(pathToFileURL(file).href)).default as Component
|
|
}
|
|
|
|
const faq = {
|
|
id: 1,
|
|
slug: 'grzyby',
|
|
name: 'Pytania o grzyby',
|
|
description: '<p>Najczęstsze pytania</p>',
|
|
content: [
|
|
{ question: ' Czy chaga jest bezpieczna? ', answer: '<p>Tak, <strong>w umiarkowanych</strong> dawkach.</p><script>x()</script>' },
|
|
{ question: 'Jak parzyć?', answer: '<ul><li>Woda 80°C & 5 min</li></ul>' },
|
|
],
|
|
}
|
|
|
|
const g = globalThis as Record<string, unknown>
|
|
let nodes: unknown[] = []
|
|
|
|
function stub(data: unknown, error: unknown = null) {
|
|
nodes = []
|
|
Object.assign(g, {
|
|
useFaq: async () => ({ data: ref(data), error: ref(error) }),
|
|
useSeoAnswer: () => ref({ action: 'ok', url: 'https://example.test/faq#top' }),
|
|
useRequestURL: () => new URL('https://fallback.test/page'),
|
|
useSchemaNode: (fn: () => unknown) => nodes.push(fn()),
|
|
})
|
|
}
|
|
|
|
const FaqList: Component = { props: ['items', 'exclusive'], setup: (p: { items: unknown[] }) => () => h('div', { class: 'faq-list' }, String(p.items.length)) }
|
|
const Block = await loadBlock()
|
|
|
|
async function render(props: Record<string, unknown>): Promise<string> {
|
|
const app = createSSRApp({ render: () => h(Block, { slug: 'grzyby', ...props }, { fallback: () => h('em', 'fallback') }) })
|
|
app.component('FaqList', FaqList)
|
|
return renderToString(app)
|
|
}
|
|
|
|
test('headingLevel 2 to 4 renders that heading, out-of-range levels are clamped', async () => {
|
|
stub(faq)
|
|
for (const [level, tag] of [[2, 'h2'], [3, 'h3'], [4, 'h4'], [6, 'h4'], [1, 'h2']] as const) {
|
|
const html = await render({ headingLevel: level })
|
|
assert.match(html, new RegExp(`<${tag} id="([^"]+)" class="faq-block__title">Pytania o grzyby</${tag}>`), `level ${level}`)
|
|
}
|
|
})
|
|
|
|
test('headingLevel 0 renders the title as a <p> that still labels the section', async () => {
|
|
stub(faq)
|
|
const html = await render({ headingLevel: 0 })
|
|
const title = html.match(/<p id="([^"]+)" class="faq-block__title">Pytania o grzyby<\/p>/)
|
|
assert.ok(title, html)
|
|
assert.match(html, new RegExp(`<section class="faq-block" aria-labelledby="${title![1]}"`))
|
|
assert.doesNotMatch(html, /<h[1-6]/)
|
|
})
|
|
|
|
test('without a heading the section is labelled by the FAQ name', async () => {
|
|
stub(faq)
|
|
const html = await render({ showHeading: false })
|
|
assert.match(html, /<section class="faq-block" aria-label="Pytania o grzyby"/)
|
|
assert.doesNotMatch(html, /faq-block__title/)
|
|
})
|
|
|
|
test('the FAQPage node: the page URL without fragment, questions trimmed, answers as plain text', async () => {
|
|
stub(faq)
|
|
await render({})
|
|
assert.equal(nodes.length, 1)
|
|
assert.deepEqual(nodes[0], {
|
|
'@type': 'FAQPage',
|
|
'@id': 'https://example.test/faq#faq',
|
|
url: 'https://example.test/faq',
|
|
mainEntity: [
|
|
{ '@type': 'Question', name: 'Czy chaga jest bezpieczna?', acceptedAnswer: { '@type': 'Answer', text: 'Tak, w umiarkowanych dawkach.' } },
|
|
{ '@type': 'Question', name: 'Jak parzyć?', acceptedAnswer: { '@type': 'Answer', text: 'Woda 80°C & 5 min' } },
|
|
],
|
|
})
|
|
})
|
|
|
|
test('schema false, a missing FAQ or one without items: no node; the fallback slot instead of an empty block', async () => {
|
|
stub(faq)
|
|
await render({ schema: false })
|
|
assert.deepEqual(nodes, [null])
|
|
stub(null, new Error('404'))
|
|
const missing = await render({})
|
|
assert.match(missing, /<em>fallback<\/em>/)
|
|
assert.doesNotMatch(missing, /faq-block/)
|
|
assert.deepEqual(nodes, [null])
|
|
stub({ ...faq, content: [] })
|
|
assert.match(await render({}), /<em>fallback<\/em>/)
|
|
})
|