Files
vue-faq-layer/app/utils/client.ts
Jakub Zych 2d62924b78 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
2026-10-12 18:22:03 +02:00

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) }
},
}
}