feat: FaqBlock layer for the SummerCMS FAQ API
- typed client and useFaq/useFaqApi (SSR, faq:<slug>:<locale> key, server and public bases, x-forwarded-for forwarded also on cached renders) - FaqBlock, FaqList and FaqItem: native details, exclusive and openFirst, server-sanitized description and answers, nothing or #fallback on 404, error or no items - FAQ tokens and base CSS with the .faq-html rules, README - node client tests and a playground SSR smoke against a fixture API
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.nuxt/
|
||||
.output/
|
||||
.data/
|
||||
.playground/.nuxt/
|
||||
.playground/.output/
|
||||
.env
|
||||
12
.playground/app/app.vue
Normal file
12
.playground/app/app.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<main>
|
||||
<FaqBlock slug="acme" exclusive />
|
||||
<FaqBlock slug="acme" locale="en" :heading-level="3" :show-description="false" />
|
||||
<FaqBlock slug="missing">
|
||||
<template #fallback>
|
||||
<p class="playground-fallback">No FAQ</p>
|
||||
</template>
|
||||
</FaqBlock>
|
||||
<FaqBlock slug="empty" />
|
||||
</main>
|
||||
</template>
|
||||
6
.playground/nuxt.config.ts
Normal file
6
.playground/nuxt.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Local playground: an app that only extends the layer and renders FAQs
|
||||
// (app.vue). testing/smoke-ssr.mjs builds it against the fixture API.
|
||||
export default defineNuxtConfig({
|
||||
extends: ['..'],
|
||||
compatibilityDate: '2025-07-15',
|
||||
})
|
||||
113
README.md
Normal file
113
README.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# vue-faq-layer
|
||||
|
||||
A Nuxt 4 layer that renders a FAQ of the SummerCMS FAQ plugin as native `<details>` items.
|
||||
|
||||
```bash
|
||||
pnpm add git+https://git.golem15.com/golem15/vue-faq-layer.git#v0.1.0
|
||||
```
|
||||
|
||||
## Overview
|
||||
|
||||
The layer is consumed with `extends: ['vue-faq-layer']`. It reads one route of the FAQ plugin's anonymous API, `GET /_faq/api/v1/faqs/{slug}`, during SSR and renders it with `<FaqBlock slug="…">`. Editors manage FAQs in the SummerCMS admin; the site needs no FAQ data of its own.
|
||||
|
||||
The layer is neutral: it ships no site colours, no site copy and no Nuxt modules. Its look comes from `--faq-*` CSS custom properties, which an app overrides, and every component can be replaced by a file of the same name in the app. There is deliberately no component named `FaqSection`, so an app's own `FaqSection.vue` can wrap `FaqBlock`.
|
||||
|
||||
## Features
|
||||
|
||||
- **`FaqBlock`**: a `<section>` labelled by its heading (the FAQ `name`), the description and a `FaqList`. Fetched during SSR through `useAsyncData` (key `faq:<slug>:<locale>`).
|
||||
- **`FaqList`** and **`FaqItem`**: one native `<details>` per question in API order, the question in `<summary>` 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.
|
||||
- **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.
|
||||
- **Rate limits**: server-side fetches forward the visitor's `x-forwarded-for` (also on route-cached renders), so the API's per-client throttle sees visitors, not the Nuxt server.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts of the app
|
||||
export default defineNuxtConfig({
|
||||
extends: ['vue-faq-layer'],
|
||||
runtimeConfig: {
|
||||
faq: { apiBase: 'http://127.0.0.1:8096/_faq/api/v1' }, // NUXT_FAQ_API_BASE
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
```vue
|
||||
<FaqBlock slug="shipping" :heading-level="2" exclusive />
|
||||
```
|
||||
|
||||
`FaqBlock` props:
|
||||
|
||||
| Prop | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `slug` | required | The FAQ slug. |
|
||||
| `locale` | the API's default locale | Sent as `?locale=`. |
|
||||
| `headingLevel` | `2` | Heading element, `2` to `4`. |
|
||||
| `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. |
|
||||
|
||||
`FaqList` takes `items` (`{ question, answer }[]`), `exclusive` and `openFirst`; `FaqItem` takes `question`, `answer`, `name` and `open`.
|
||||
|
||||
`useFaq(slug, { locale })` returns the `useAsyncData` result (`data` is `null` for a missing FAQ); `useFaqApi()` returns the typed client.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Default | Environment |
|
||||
|---|---|---|
|
||||
| `runtimeConfig.faq.apiBase` | `http://127.0.0.1:8096/_faq/api/v1` | `NUXT_FAQ_API_BASE` (server only, used for SSR) |
|
||||
| `runtimeConfig.public.faq.apiBase` | `/_faq/api/v1` | `NUXT_PUBLIC_FAQ_API_BASE` (browser, same origin) |
|
||||
|
||||
The base URLs always come from runtime config, never from request input. In production the web server routes `/_faq/` to the SummerCMS binary.
|
||||
|
||||
## API
|
||||
|
||||
The layer expects the FAQ plugin's public API:
|
||||
|
||||
```json
|
||||
GET /_faq/api/v1/faqs/shipping?locale=en
|
||||
{"data":{"name":"Shipping","slug":"shipping","locale":"en","description":"<p>About shipping.</p>",
|
||||
"content":[{"question":"How long?","answer":"<p>Two days.</p>"},{"question":"Where?","answer":"<p>Everywhere.</p>"}]}}
|
||||
```
|
||||
|
||||
An unknown or inactive FAQ, or a locale that is not enabled, answers `404 {"error":"FAQ not found"}`. `description` may be `null`. The types are in `app/utils/types.ts` (`Faq`, `FaqItem`).
|
||||
|
||||
## Theming
|
||||
|
||||
Override tokens on `:root:root` (higher specificity than the layer's `:root`, independent of CSS order):
|
||||
|
||||
```css
|
||||
:root:root {
|
||||
--faq-color-accent: #2f6b3a;
|
||||
--faq-font: 'Open Sans', sans-serif;
|
||||
}
|
||||
```
|
||||
|
||||
| Group | Tokens |
|
||||
|---|---|
|
||||
| Colour | `--faq-color-text`, `--faq-color-muted`, `--faq-color-bg`, `--faq-color-surface`, `--faq-color-border`, `--faq-color-accent` |
|
||||
| Type | `--faq-font`, `--faq-font-size-base`, `--faq-line-height`, `--faq-question-size`, `--faq-title-size` |
|
||||
| Spacing | `--faq-space-1` … `--faq-space-4` |
|
||||
| Radius and shadow | `--faq-radius`, `--faq-shadow` |
|
||||
|
||||
Answer and description HTML is styled by the `.faq-html` rules in `faq-base.css` (paragraphs, lists, links, `h3`, `blockquote`).
|
||||
|
||||
To replace a component, add a file with the same path in the app, for example `app/components/Faq/Item.vue`.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # the playground in .playground/
|
||||
```
|
||||
|
||||
## Releasing
|
||||
|
||||
Commit, tag `vX.Y.Z` (annotated) and push the tag; apps pin `#vX.Y.Z` in their dependency.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
pnpm test # node --test: the client
|
||||
pnpm build:playground && pnpm smoke:ssr # SSR of the playground against testing/fixture-api.mjs
|
||||
```
|
||||
133
app/assets/css/faq-base.css
Normal file
133
app/assets/css/faq-base.css
Normal file
@@ -0,0 +1,133 @@
|
||||
/* FaqBlock styles. Global, unscoped and token-driven, so an app restyles the
|
||||
FAQ through the tokens or its own rules. */
|
||||
|
||||
.faq-block {
|
||||
font-family: var(--faq-font);
|
||||
font-size: var(--faq-font-size-base);
|
||||
line-height: var(--faq-line-height);
|
||||
color: var(--faq-color-text);
|
||||
}
|
||||
|
||||
.faq-block__title {
|
||||
margin: 0 0 var(--faq-space-2);
|
||||
font-size: var(--faq-title-size);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.faq-block__description {
|
||||
margin-bottom: var(--faq-space-4);
|
||||
}
|
||||
|
||||
.faq-list {
|
||||
display: grid;
|
||||
gap: var(--faq-space-3);
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
border: 1px solid var(--faq-color-border);
|
||||
border-radius: var(--faq-radius);
|
||||
background: var(--faq-color-bg);
|
||||
}
|
||||
|
||||
.faq-item[open] {
|
||||
box-shadow: var(--faq-shadow);
|
||||
}
|
||||
|
||||
.faq-item__question {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--faq-space-3);
|
||||
padding: var(--faq-space-3) var(--faq-space-4);
|
||||
font-size: var(--faq-question-size);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--faq-color-text);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.faq-item__question::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.faq-item__question::after {
|
||||
flex-shrink: 0;
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-right: 2px solid var(--faq-color-text);
|
||||
border-bottom: 2px solid var(--faq-color-text);
|
||||
transform: rotate(45deg);
|
||||
transition: transform 150ms ease;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.faq-item[open] > .faq-item__question::after {
|
||||
transform: rotate(-135deg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.faq-item__question::after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.faq-item__question:focus-visible {
|
||||
outline: 2px solid var(--faq-color-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--faq-radius);
|
||||
}
|
||||
|
||||
.faq-item__answer {
|
||||
padding: 0 var(--faq-space-4) var(--faq-space-4);
|
||||
}
|
||||
|
||||
/* Answer and description HTML (the PHP partial's rules, ported). */
|
||||
.faq-html p,
|
||||
.faq-html ul,
|
||||
.faq-html ol {
|
||||
margin: 0 0 var(--faq-space-3);
|
||||
}
|
||||
|
||||
.faq-html > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.faq-html ul {
|
||||
list-style: disc;
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
|
||||
.faq-html ol {
|
||||
list-style: decimal;
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
|
||||
.faq-html li + li {
|
||||
margin-top: var(--faq-space-2);
|
||||
}
|
||||
|
||||
.faq-html a {
|
||||
color: var(--faq-color-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.faq-html a:focus-visible {
|
||||
outline: 2px solid var(--faq-color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.faq-html h3 {
|
||||
margin: 0 0 var(--faq-space-2);
|
||||
font-size: var(--faq-question-size);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.faq-html blockquote {
|
||||
margin: 0 0 var(--faq-space-3);
|
||||
padding-inline-start: var(--faq-space-3);
|
||||
border-inline-start: 3px solid var(--faq-color-border);
|
||||
color: var(--faq-color-muted);
|
||||
}
|
||||
28
app/assets/css/faq-tokens.css
Normal file
28
app/assets/css/faq-tokens.css
Normal file
@@ -0,0 +1,28 @@
|
||||
/* FAQ design tokens. Every visual value of FaqBlock comes from these. An app
|
||||
overrides any of them in its own CSS, e.g. on :root:root. */
|
||||
:root {
|
||||
/* Colour */
|
||||
--faq-color-text: #1f2328; /* questions, answers, chevron */
|
||||
--faq-color-muted: #59636e; /* quotes */
|
||||
--faq-color-bg: #ffffff; /* item background */
|
||||
--faq-color-surface: #f6f8fa; /* summary hover */
|
||||
--faq-color-border: #d1d9e0; /* item borders */
|
||||
--faq-color-accent: #0969da; /* links, focus ring */
|
||||
|
||||
/* Type */
|
||||
--faq-font: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--faq-font-size-base: 1rem;
|
||||
--faq-line-height: 1.7;
|
||||
--faq-question-size: 1.125rem;
|
||||
--faq-title-size: 1.5rem;
|
||||
|
||||
/* Spacing scale */
|
||||
--faq-space-1: 0.25rem;
|
||||
--faq-space-2: 0.5rem;
|
||||
--faq-space-3: 1rem;
|
||||
--faq-space-4: 1.5rem;
|
||||
|
||||
/* Radius and shadow */
|
||||
--faq-radius: 8px;
|
||||
--faq-shadow: 0 8px 24px rgba(31, 35, 40, 0.08);
|
||||
}
|
||||
50
app/components/Faq/Block.vue
Normal file
50
app/components/Faq/Block.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<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.
|
||||
import { computed, useId } from 'vue'
|
||||
|
||||
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. */
|
||||
headingLevel?: 2 | 3 | 4
|
||||
showHeading?: boolean
|
||||
showDescription?: boolean
|
||||
/** Only one item open at a time (native exclusive details). */
|
||||
exclusive?: boolean
|
||||
}>(),
|
||||
{ locale: undefined, headingLevel: 2, showHeading: true, showDescription: true, exclusive: false },
|
||||
)
|
||||
|
||||
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(() => `h${Math.min(4, Math.max(2, Math.trunc(Number(props.headingLevel)) || 2))}`)
|
||||
const description = computed(() => (props.showDescription ? (faq.value?.description ?? '').trim() : ''))
|
||||
const headingId = `${useId()}-title`
|
||||
</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>
|
||||
13
app/components/Faq/Item.vue
Normal file
13
app/components/Faq/Item.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
// FaqItem: one question as a native <details>. The answer is HTML the FAQ
|
||||
// API sanitized on output, so there is no client sanitizer.
|
||||
defineProps<{ question: string; answer: string; name?: string; open?: boolean }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<details class="faq-item" :name="name" :open="open || undefined">
|
||||
<summary class="faq-item__question">{{ question }}</summary>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- sanitized by the FAQ API on output -->
|
||||
<div class="faq-item__answer faq-html" v-html="answer" />
|
||||
</details>
|
||||
</template>
|
||||
26
app/components/Faq/List.vue
Normal file
26
app/components/Faq/List.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
// FaqList: the items in API order. With `exclusive`, every item shares one
|
||||
// details name, so the browser keeps one open at a time.
|
||||
import { useId } from 'vue'
|
||||
import type { FaqItem as FaqItemData } from '../../utils/types.ts'
|
||||
|
||||
withDefaults(defineProps<{ items: FaqItemData[]; exclusive?: boolean; openFirst?: boolean }>(), {
|
||||
exclusive: false,
|
||||
openFirst: false,
|
||||
})
|
||||
|
||||
const group = `${useId()}-faq`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="faq-list">
|
||||
<FaqItem
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
:question="item.question"
|
||||
:answer="item.answer"
|
||||
:name="exclusive ? group : undefined"
|
||||
:open="openFirst && i === 0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
19
app/composables/useFaq.ts
Normal file
19
app/composables/useFaq.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// A FAQ by slug, fetched during SSR through useAsyncData with the key
|
||||
// faq:<slug>:<locale>. data is null for a missing (404) FAQ; error is set
|
||||
// when the API failed.
|
||||
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import type { Faq } from '../utils/types.ts'
|
||||
|
||||
export interface UseFaqOptions {
|
||||
/** ?locale= for the API; the API's default locale when empty. */
|
||||
locale?: MaybeRefOrGetter<string | undefined>
|
||||
}
|
||||
|
||||
export function useFaq(slug: MaybeRefOrGetter<string>, opts: UseFaqOptions = {}) {
|
||||
const api = useFaqApi()
|
||||
const key = computed(() => `faq:${toValue(slug)}:${toValue(opts.locale) ?? ''}`)
|
||||
return useAsyncData<Faq | null>(key, () => api.getFaq(toValue(slug), toValue(opts.locale) || undefined), {
|
||||
default: () => null,
|
||||
})
|
||||
}
|
||||
28
app/composables/useFaqApi.ts
Normal file
28
app/composables/useFaqApi.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// The FAQ API client. On the server it calls the internal base
|
||||
// (runtimeConfig.faq.apiBase) and forwards the visitor's x-forwarded-for,
|
||||
// plus the address of the peer that reached Nuxt, so the Go rate limiter
|
||||
// keeps one bucket per visitor, also on route-cached renders. In the browser
|
||||
// it calls the same-origin public base. The base is always runtime config,
|
||||
// never request input.
|
||||
|
||||
import { createFaqClient, type FaqClient, type FaqFetcher } from '../utils/client.ts'
|
||||
|
||||
export function useFaqApi(): FaqClient {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
if (import.meta.server) {
|
||||
const base = (config.faq as { apiBase: string }).apiBase
|
||||
const event = useRequestEvent()
|
||||
const recorded = event?.context.faqForwardedFor as string | undefined
|
||||
const incoming = useRequestHeaders(['x-forwarded-for'])['x-forwarded-for']
|
||||
const peer = event?.node?.req?.socket?.remoteAddress
|
||||
const forwarded = recorded || [incoming, peer].filter((v): v is string => typeof v === 'string' && v !== '').join(', ')
|
||||
const headers: Record<string, string> = forwarded ? { 'x-forwarded-for': forwarded } : {}
|
||||
const fetcher: FaqFetcher = (path, query) => $fetch(path, { baseURL: base, query, headers })
|
||||
return createFaqClient(fetcher)
|
||||
}
|
||||
|
||||
const base = (config.public.faq as { apiBase: string }).apiBase
|
||||
const fetcher: FaqFetcher = (path, query) => $fetch(path, { baseURL: base, query })
|
||||
return createFaqClient(fetcher)
|
||||
}
|
||||
52
app/utils/client.ts
Normal file
52
app/utils/client.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Typed client for the anonymous FAQ API. It wraps an injected fetcher, so
|
||||
// the Nuxt composable decides the base URL and headers and `node --test` can
|
||||
// drive it with a stub.
|
||||
|
||||
import type { Faq, FaqEnvelope, FaqItem } from './types.ts'
|
||||
|
||||
/** Issues one GET for `path` (e.g. "/faqs/shipping") with `query`, resolving the parsed JSON. */
|
||||
export type FaqFetcher = (path: string, query?: Record<string, string>) => Promise<unknown>
|
||||
|
||||
/** True for a fetch error that carries HTTP status 404 (ofetch, h3 or plain). */
|
||||
export function isNotFound(err: unknown): boolean {
|
||||
if (!err || typeof err !== 'object') return false
|
||||
const e = err as { status?: unknown; statusCode?: unknown; response?: { status?: unknown } }
|
||||
return e.status === 404 || e.statusCode === 404 || e.response?.status === 404
|
||||
}
|
||||
|
||||
/** The API path of a FAQ: /faqs/<slug>, the slug URL-encoded. */
|
||||
export function faqPath(slug: string): string {
|
||||
return `/faqs/${encodeURIComponent(slug)}`
|
||||
}
|
||||
|
||||
/** Items with a string question and answer; anything else is dropped. */
|
||||
export function faqItems(content: unknown): FaqItem[] {
|
||||
if (!Array.isArray(content)) return []
|
||||
return content.filter(
|
||||
(item): item is FaqItem =>
|
||||
!!item && typeof item === 'object' && typeof (item as FaqItem).question === 'string' && typeof (item as FaqItem).answer === 'string',
|
||||
)
|
||||
}
|
||||
|
||||
export interface FaqClient {
|
||||
/** The FAQ with `slug`, or null when the API answers 404 (unknown, inactive or locale not enabled). */
|
||||
getFaq(slug: string, locale?: string): Promise<Faq | null>
|
||||
}
|
||||
|
||||
export function createFaqClient(fetcher: FaqFetcher): FaqClient {
|
||||
return {
|
||||
async getFaq(slug, locale) {
|
||||
const query = locale ? { locale } : undefined
|
||||
let res: FaqEnvelope
|
||||
try {
|
||||
res = (await (query ? fetcher(faqPath(slug), query) : fetcher(faqPath(slug)))) as FaqEnvelope
|
||||
} catch (err) {
|
||||
if (isNotFound(err)) return null
|
||||
throw err
|
||||
}
|
||||
const faq = res?.data
|
||||
if (!faq || typeof faq !== 'object') return null
|
||||
return { ...faq, description: typeof faq.description === 'string' ? faq.description : null, content: faqItems(faq.content) }
|
||||
},
|
||||
}
|
||||
}
|
||||
20
app/utils/types.ts
Normal file
20
app/utils/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// The response shape of GET /_faq/api/v1/faqs/{slug}?locale=xx (the FAQ
|
||||
// plugin's public API). description and every answer are HTML the server
|
||||
// sanitized on output; questions are plain text.
|
||||
|
||||
export interface FaqItem {
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
export interface Faq {
|
||||
name: string
|
||||
slug: string
|
||||
locale: string
|
||||
description: string | null
|
||||
content: FaqItem[]
|
||||
}
|
||||
|
||||
export interface FaqEnvelope {
|
||||
data: Faq
|
||||
}
|
||||
33
nuxt.config.ts
Normal file
33
nuxt.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// vue-faq-layer: renders a FAQ of the SummerCMS FAQ plugin
|
||||
// (GET /_faq/api/v1/faqs/{slug}) as native <details> items. An app consumes
|
||||
// it with `extends: ['vue-faq-layer']` and renders <FaqBlock>.
|
||||
//
|
||||
// Every path below is absolute and built from this file's directory: a
|
||||
// relative or ~ path in a layer config resolves against the consuming
|
||||
// project, not the layer. The layer declares no Nuxt modules.
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: false },
|
||||
|
||||
// Tokens first, then the neutral styles that use them. The app's own css
|
||||
// array loads after these, so its token overrides win.
|
||||
css: [`${currentDir}app/assets/css/faq-tokens.css`, `${currentDir}app/assets/css/faq-base.css`],
|
||||
|
||||
runtimeConfig: {
|
||||
faq: {
|
||||
// Server-only base for SSR fetches (NUXT_FAQ_API_BASE). It never comes
|
||||
// from request input.
|
||||
apiBase: 'http://127.0.0.1:8096/_faq/api/v1',
|
||||
},
|
||||
public: {
|
||||
faq: {
|
||||
// Same-origin base for browser fetches (NUXT_PUBLIC_FAQ_API_BASE).
|
||||
apiBase: '/_faq/api/v1',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "vue-faq-layer",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./nuxt.config.ts",
|
||||
"scripts": {
|
||||
"dev": "nuxi dev .playground",
|
||||
"build:playground": "nuxt build .playground",
|
||||
"test": "node --test --test-reporter=tap tests/*.test.ts",
|
||||
"dev:prepare": "nuxt prepare .playground",
|
||||
"smoke:ssr": "node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect.txt"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"nuxt": "4.4.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nuxt": "4.4.8",
|
||||
"typescript": "5.9.3",
|
||||
"vue": "3.5.35"
|
||||
}
|
||||
}
|
||||
6854
pnpm-lock.yaml
generated
Normal file
6854
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
esbuild: true
|
||||
vue-demi: true
|
||||
17
server/plugins/faq-client-address.ts
Normal file
17
server/plugins/faq-client-address.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Keeps the visitor's address available to route-cached SSR renders.
|
||||
//
|
||||
// An app rule such as `'/**': { swr }` renders cached pages from a copy of
|
||||
// the request without headers. The FAQ API throttles per client address,
|
||||
// so forwarding only the Nuxt peer would put every cache miss in one bucket.
|
||||
// The event context is shared with that copy, so the incoming
|
||||
// x-forwarded-for plus the peer address is recorded here, before the cached
|
||||
// handler runs, and useFaqApi forwards it.
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('request', (event) => {
|
||||
const forwarded = [getRequestHeader(event, 'x-forwarded-for'), event.node.req.socket?.remoteAddress]
|
||||
.filter((v): v is string => typeof v === 'string' && v !== '')
|
||||
.join(', ')
|
||||
event.context.faqForwardedFor = forwarded
|
||||
})
|
||||
})
|
||||
14
testing/expect.txt
Normal file
14
testing/expect.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
# SSR smoke of the playground against testing/fixtures: FaqBlock acme
|
||||
# (exclusive), acme again (h3, no description), missing (fallback slot) and
|
||||
# empty (no items). Run: pnpm build:playground && pnpm smoke:ssr
|
||||
200 / contains=class="faq-block__title">Acme questions</h2>
|
||||
200 / contains=<h3 id=
|
||||
200 / contains=<div class="faq-block__description faq-html"><p>Answers about <a href="https://acme.example.test/">Acme</a>.</p></div>
|
||||
200 / contains=<summary class="faq-item__question">How long does shipping take?</summary>
|
||||
200 / contains=<div class="faq-item__answer faq-html"><p>Two days.</p><ul><li>Poland</li><li>Europe</li></ul></div>
|
||||
200 / contains=<details class="faq-item" name=
|
||||
200 / contains=<details class="faq-item"><summary
|
||||
# A missing FAQ renders its fallback; a FAQ without items renders nothing.
|
||||
200 / contains=<p class="playground-fallback">No FAQ</p>
|
||||
200 / notcontains=>Empty</h
|
||||
200 / notcontains=FaqSection
|
||||
67
testing/fixture-api.mjs
Normal file
67
testing/fixture-api.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
// A small node:http stand-in for the anonymous FAQ API (/_faq/api/v1),
|
||||
// answering GET /faqs/<slug> from testing/fixtures/faqs/<slug>.json and
|
||||
// 404 {"error":"FAQ not found"} otherwise. It records every request so a
|
||||
// smoke run can assert header forwarding.
|
||||
//
|
||||
// Plain .mjs with JSDoc types: Node does not strip TypeScript under
|
||||
// node_modules, and consuming apps may import this file from there.
|
||||
|
||||
import { createServer } from 'node:http'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const PREFIX = '/_faq/api/v1'
|
||||
|
||||
/**
|
||||
* @typedef {{ method: string, url: string, xff: string | null }} FixtureRequest
|
||||
* @typedef {{ url: string, requests: FixtureRequest[], close: () => Promise<void> }} FixtureApi
|
||||
*/
|
||||
|
||||
/**
|
||||
* Starts the fixture API on 127.0.0.1.
|
||||
* @param {{ dir: string, port?: number }} opts
|
||||
* @returns {Promise<FixtureApi>}
|
||||
*/
|
||||
export function startFixtureApi({ dir, port = 0 }) {
|
||||
/** @type {FixtureRequest[]} */
|
||||
const requests = []
|
||||
|
||||
/** @param {import('node:http').IncomingMessage} req */
|
||||
const route = (req) => {
|
||||
const url = new URL(req.url ?? '/', 'http://fixture.local')
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return { status: 405, body: { error: 'Method not allowed' } }
|
||||
const m = /^\/faqs\/([a-z0-9][a-z0-9-]*)$/.exec(url.pathname.slice(PREFIX.length))
|
||||
if (!url.pathname.startsWith(PREFIX + '/') || !m) return { status: 404, body: { error: 'FAQ not found' } }
|
||||
try {
|
||||
return { status: 200, body: JSON.parse(readFileSync(join(dir, 'faqs', `${m[1]}.json`), 'utf8')) }
|
||||
} catch (err) {
|
||||
if (/** @type {any} */ (err).code === 'ENOENT') return { status: 404, body: { error: 'FAQ not found' } }
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const xff = req.headers['x-forwarded-for']
|
||||
requests.push({ method: req.method ?? 'GET', url: req.url ?? '/', xff: Array.isArray(xff) ? xff.join(', ') : (xff ?? null) })
|
||||
let out
|
||||
try {
|
||||
out = route(req)
|
||||
} catch (err) {
|
||||
out = { status: 500, body: { error: String(err) } }
|
||||
}
|
||||
res.writeHead(out.status, { 'content-type': 'application/json' })
|
||||
res.end(req.method === 'HEAD' ? undefined : JSON.stringify(out.body))
|
||||
})
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
const addr = /** @type {import('node:net').AddressInfo} */ (server.address())
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${addr.port}`,
|
||||
requests,
|
||||
close: () => new Promise((done) => server.close(() => done())),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
12
testing/fixtures/faqs/acme.json
Normal file
12
testing/fixtures/faqs/acme.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"data": {
|
||||
"name": "Acme questions",
|
||||
"slug": "acme",
|
||||
"locale": "en",
|
||||
"description": "<p>Answers about <a href=\"https://acme.example.test/\">Acme</a>.</p>",
|
||||
"content": [
|
||||
{ "question": "How long does shipping take?", "answer": "<p>Two days.</p><ul><li>Poland</li><li>Europe</li></ul>" },
|
||||
{ "question": "Can I return an order?", "answer": "<p>Within 14 days.</p>" }
|
||||
]
|
||||
}
|
||||
}
|
||||
1
testing/fixtures/faqs/empty.json
Normal file
1
testing/fixtures/faqs/empty.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "data": { "name": "Empty", "slug": "empty", "locale": "en", "description": null, "content": [] } }
|
||||
192
testing/smoke-ssr.mjs
Normal file
192
testing/smoke-ssr.mjs
Normal file
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
// SSR smoke check: boots the fixture FAQ API and the built playground
|
||||
// server, then checks each line of an expectations file.
|
||||
//
|
||||
// node testing/smoke-ssr.mjs --output <.output dir> --fixtures <dir> --expect <file> [--env KEY=VALUE]...
|
||||
//
|
||||
// Expectation grammar, one check per line ('#' starts a comment line):
|
||||
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]
|
||||
// A value runs until the next ` contains=`, ` notcontains=`, ` type=` or the
|
||||
// end of the line, so it may contain spaces. Requests are plain GETs that do
|
||||
// not follow redirects and carry `X-Forwarded-For: 203.0.113.7`; afterwards
|
||||
// at least one fixture API request must have carried that address (the SSR
|
||||
// fetches forward the visitor's x-forwarded-for).
|
||||
//
|
||||
// The same approach as vue-journal-layer's testing/smoke-ssr.mjs; plain .mjs
|
||||
// with JSDoc types so it runs from node_modules unchanged.
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { createServer } from 'node:net'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { startFixtureApi } from './fixture-api.mjs'
|
||||
|
||||
const VISITOR_IP = '203.0.113.7'
|
||||
const API_PREFIX = '/_faq/api/v1'
|
||||
const API_ENV = 'NUXT_FAQ_API_BASE'
|
||||
|
||||
/** @param {string[]} argv */
|
||||
function parseArgs(argv) {
|
||||
/** @type {{ output?: string, fixtures?: string, expect?: string, env: Record<string, string> }} */
|
||||
const out = { env: {} }
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]
|
||||
const next = () => {
|
||||
const v = argv[++i]
|
||||
if (v === undefined) throw new Error(`${arg} needs a value`)
|
||||
return v
|
||||
}
|
||||
if (arg === '--output') out.output = next()
|
||||
else if (arg === '--fixtures') out.fixtures = next()
|
||||
else if (arg === '--expect') out.expect = next()
|
||||
else if (arg === '--env') {
|
||||
const kv = next()
|
||||
const eq = kv.indexOf('=')
|
||||
if (eq < 1) throw new Error(`--env expects KEY=VALUE, got ${kv}`)
|
||||
out.env[kv.slice(0, eq)] = kv.slice(eq + 1)
|
||||
} else throw new Error(`unknown argument ${arg}`)
|
||||
}
|
||||
if (!out.output || !out.fixtures || !out.expect) throw new Error('--output, --fixtures and --expect are required')
|
||||
return /** @type {{ output: string, fixtures: string, expect: string, env: Record<string, string> }} */ (out)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} line
|
||||
* @returns {{ status: number, path: string, contains?: string, notcontains?: string, type?: string }}
|
||||
*/
|
||||
export function parseExpectLine(line) {
|
||||
const m = /^(\d{3})\s+(\S+)(.*)$/.exec(line.trim())
|
||||
if (!m) throw new Error('expected "STATUS PATH [options]"')
|
||||
/** @type {{ status: number, path: string, contains?: string, notcontains?: string, type?: string }} */
|
||||
const check = { status: Number(m[1]), path: m[2] }
|
||||
const rest = m[3]
|
||||
const re = /\s(contains|notcontains|type)=/g
|
||||
/** @type {Array<{ key: string, start: number, valueStart: number }>} */
|
||||
const marks = []
|
||||
let hit
|
||||
while ((hit = re.exec(rest)) !== null) marks.push({ key: hit[1], start: hit.index, valueStart: re.lastIndex })
|
||||
if (marks.length === 0 && rest.trim() !== '') throw new Error(`unknown option text "${rest.trim()}"`)
|
||||
if (marks.length > 0 && rest.slice(0, marks[0].start).trim() !== '') {
|
||||
throw new Error(`unknown option text "${rest.slice(0, marks[0].start).trim()}"`)
|
||||
}
|
||||
/** @type {Record<string, string>} */
|
||||
const options = {}
|
||||
marks.forEach((mark, i) => {
|
||||
const end = i + 1 < marks.length ? marks[i + 1].start : rest.length
|
||||
options[mark.key] = rest.slice(mark.valueStart, end).trim()
|
||||
})
|
||||
return { ...check, ...options }
|
||||
}
|
||||
|
||||
/** @returns {Promise<number>} */
|
||||
function freePort() {
|
||||
return new Promise((done, fail) => {
|
||||
const srv = createServer()
|
||||
srv.once('error', fail)
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const { port } = /** @type {import('node:net').AddressInfo} */ (srv.address())
|
||||
srv.close(() => done(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots `<outputDir>/server/index.mjs` on 127.0.0.1 and waits until it answers.
|
||||
* @param {{ outputDir: string, env: Record<string, string>, timeoutMs?: number }} opts
|
||||
* @returns {Promise<{ url: string, close: () => Promise<void> }>}
|
||||
*/
|
||||
async function startNuxtServer({ outputDir, env, timeoutMs = 30000 }) {
|
||||
const entry = join(resolve(outputDir), 'server', 'index.mjs')
|
||||
if (!existsSync(entry)) throw new Error(`${entry} does not exist; run pnpm build:playground first`)
|
||||
const port = await freePort()
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
const child = spawn(process.execPath, [entry], {
|
||||
env: { ...process.env, NODE_ENV: 'production', NITRO_HOST: '127.0.0.1', NITRO_PORT: String(port), ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let log = ''
|
||||
child.stdout.on('data', (b) => (log += b))
|
||||
child.stderr.on('data', (b) => (log += b))
|
||||
let exited = false
|
||||
child.once('exit', () => (exited = true))
|
||||
const close = () =>
|
||||
new Promise((/** @type {(v?: void) => void} */ done) => {
|
||||
if (exited) return done()
|
||||
child.once('exit', () => done())
|
||||
child.kill('SIGTERM')
|
||||
setTimeout(() => {
|
||||
if (!exited) child.kill('SIGKILL')
|
||||
}, 5000).unref()
|
||||
})
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (exited) throw new Error(`server exited before answering\n${log}`)
|
||||
try {
|
||||
await fetch(url + '/', { redirect: 'manual', signal: AbortSignal.timeout(2000) })
|
||||
return { url, close }
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
}
|
||||
await close()
|
||||
throw new Error(`no answer on ${url} within ${timeoutMs} ms\n${log}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const lines = readFileSync(resolve(args.expect), 'utf8')
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l !== '' && !l.startsWith('#'))
|
||||
|
||||
const api = await startFixtureApi({ dir: resolve(args.fixtures) })
|
||||
/** @type {{ url: string, close: () => Promise<void> } | null} */
|
||||
let nuxt = null
|
||||
const failures = /** @type {string[]} */ ([])
|
||||
try {
|
||||
nuxt = await startNuxtServer({ outputDir: args.output, env: { [API_ENV]: `${api.url}${API_PREFIX}`, ...args.env } })
|
||||
for (const line of lines) {
|
||||
let check
|
||||
try {
|
||||
check = parseExpectLine(line)
|
||||
} catch (err) {
|
||||
failures.push(`${line}: ${/** @type {Error} */ (err).message}`)
|
||||
continue
|
||||
}
|
||||
let res
|
||||
try {
|
||||
res = await fetch(nuxt.url + check.path, {
|
||||
redirect: 'manual',
|
||||
headers: { 'X-Forwarded-For': VISITOR_IP },
|
||||
signal: AbortSignal.timeout(20000),
|
||||
})
|
||||
} catch (err) {
|
||||
failures.push(`${line}: request failed: ${/** @type {Error} */ (err).message}`)
|
||||
continue
|
||||
}
|
||||
const body = await res.text()
|
||||
const type = res.headers.get('content-type') ?? ''
|
||||
if (res.status !== check.status) failures.push(`${line}: status ${res.status}, want ${check.status}`)
|
||||
else if (check.contains !== undefined && !body.includes(check.contains)) failures.push(`${line}: body lacks "${check.contains}"`)
|
||||
else if (check.notcontains !== undefined && body.includes(check.notcontains)) failures.push(`${line}: body contains "${check.notcontains}"`)
|
||||
else if (check.type !== undefined && !type.startsWith(check.type)) failures.push(`${line}: content-type "${type}", want prefix "${check.type}"`)
|
||||
}
|
||||
if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
|
||||
failures.push('x-forwarded-for: the SSR fetches did not forward the visitor address')
|
||||
}
|
||||
} finally {
|
||||
if (nuxt) await nuxt.close()
|
||||
await api.close()
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const f of failures) console.error(`smoke-ssr: FAIL ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`smoke-ssr: ok (${lines.length} checks)`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`smoke-ssr: FAIL setup: ${err instanceof Error ? err.message : String(err)}`)
|
||||
process.exit(1)
|
||||
})
|
||||
91
tests/client.test.ts
Normal file
91
tests/client.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
// The FAQ client against a fake fetcher: the URL built from base, slug and
|
||||
// locale, the envelope unwrapped, malformed items dropped, 404 as null. No
|
||||
// network.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createFaqClient, faqItems, faqPath, isNotFound } from '../app/utils/client.ts'
|
||||
|
||||
type Call = { path: string; query?: Record<string, string> }
|
||||
|
||||
const FAQ = {
|
||||
data: {
|
||||
name: 'Shipping',
|
||||
slug: 'shipping',
|
||||
locale: 'en',
|
||||
description: '<p>About shipping.</p>',
|
||||
content: [
|
||||
{ question: 'How long?', answer: '<p>Two days.</p>' },
|
||||
{ question: 'Where?', answer: '<p>Everywhere.</p>' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
function fakeFetcher(answer: () => unknown) {
|
||||
const calls: Call[] = []
|
||||
const fetcher = async (path: string, query?: Record<string, string>) => {
|
||||
calls.push(query === undefined ? { path } : { path, query })
|
||||
return answer()
|
||||
}
|
||||
return { fetcher, calls }
|
||||
}
|
||||
|
||||
test('faqPath encodes the slug', () => {
|
||||
assert.equal(faqPath('shipping'), '/faqs/shipping')
|
||||
assert.equal(faqPath('a b/c'), '/faqs/a%20b%2Fc')
|
||||
})
|
||||
|
||||
test('getFaq calls /faqs/<slug> and passes locale only when set', async () => {
|
||||
const { fetcher, calls } = fakeFetcher(() => FAQ)
|
||||
const client = createFaqClient(fetcher)
|
||||
const faq = await client.getFaq('shipping')
|
||||
assert.equal(faq?.name, 'Shipping')
|
||||
assert.equal(faq?.content.length, 2)
|
||||
await client.getFaq('shipping', 'pl')
|
||||
assert.deepEqual(calls, [{ path: '/faqs/shipping' }, { path: '/faqs/shipping', query: { locale: 'pl' } }])
|
||||
})
|
||||
|
||||
test('the fetcher base plus the path is the API URL', async () => {
|
||||
const urls: string[] = []
|
||||
const base = 'http://127.0.0.1:8096/_faq/api/v1'
|
||||
const client = createFaqClient(async (path, query) => {
|
||||
const u = new URL(base + path)
|
||||
for (const [k, v] of Object.entries(query ?? {})) u.searchParams.set(k, v)
|
||||
urls.push(u.toString())
|
||||
return FAQ
|
||||
})
|
||||
await client.getFaq('what-mushrooms-can-give-you', 'pl')
|
||||
assert.deepEqual(urls, ['http://127.0.0.1:8096/_faq/api/v1/faqs/what-mushrooms-can-give-you?locale=pl'])
|
||||
})
|
||||
|
||||
test('a 404 resolves to null; other errors are thrown', async () => {
|
||||
const notFound = createFaqClient(async () => {
|
||||
throw Object.assign(new Error('Not Found'), { response: { status: 404 } })
|
||||
})
|
||||
assert.equal(await notFound.getFaq('nope'), null)
|
||||
const failing = createFaqClient(async () => {
|
||||
throw Object.assign(new Error('boom'), { statusCode: 502 })
|
||||
})
|
||||
await assert.rejects(failing.getFaq('x'), /boom/)
|
||||
})
|
||||
|
||||
test('a body without data is null; a null description stays null', async () => {
|
||||
assert.equal(await createFaqClient(async () => ({})).getFaq('x'), null)
|
||||
const faq = await createFaqClient(async () => ({ data: { ...FAQ.data, description: null } })).getFaq('x')
|
||||
assert.equal(faq?.description, null)
|
||||
})
|
||||
|
||||
test('faqItems keeps only items with a string question and answer', () => {
|
||||
assert.deepEqual(faqItems(null), [])
|
||||
assert.deepEqual(faqItems({}), [])
|
||||
assert.deepEqual(faqItems([{ question: 'Q', answer: 'A' }, { question: 'Q' }, null, 'x', { question: 1, answer: 'A' }]), [
|
||||
{ question: 'Q', answer: 'A' },
|
||||
])
|
||||
})
|
||||
|
||||
test('isNotFound reads status, statusCode and response.status', () => {
|
||||
assert.equal(isNotFound({ status: 404 }), true)
|
||||
assert.equal(isNotFound({ statusCode: 404 }), true)
|
||||
assert.equal(isNotFound({ response: { status: 404 } }), true)
|
||||
assert.equal(isNotFound({ status: 500 }), false)
|
||||
assert.equal(isNotFound(undefined), false)
|
||||
})
|
||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
// Self-contained on purpose: Vite reads this file when it compiles the
|
||||
// layer's .vue and .ts files, also from a consumer's node_modules, where no
|
||||
// generated .nuxt/tsconfig.json exists. For editor types of Nuxt
|
||||
// auto-imports, run `pnpm dev:prepare` and open .playground.
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user