- 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
53 lines
2.1 KiB
TypeScript
53 lines
2.1 KiB
TypeScript
// 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) }
|
|
},
|
|
}
|
|
}
|