// Typed client for the anonymous GET routes of the Journal 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. It never calls a write route. import type { JournalAuthor, JournalCategory, JournalItem, JournalPage, JournalPost, JournalPostDetail, JournalTag, } from './types.ts' /** The Journal API's per_page ceiling. */ export const JOURNAL_MAX_PER_PAGE = 30 /** The Journal API's per_page default. */ export const JOURNAL_DEFAULT_PER_PAGE = 9 /** Issues one GET for `path` (e.g. "/posts") with `query`, resolving the parsed JSON. */ export type JournalFetcher = (path: string, query?: Record) => Promise export interface PostListOptions { page?: number perPage?: number /** " ", passed through verbatim. */ sort?: string /** Category slug or id; the API includes nested children. */ category?: string tag?: string search?: string } export interface AuthorPostListOptions { page?: number perPage?: number sort?: string } /** Clamps a per_page value to 1..30 like the API does; non-numeric gives the default 9. */ export function clampPerPage(value: unknown): number { const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN if (!Number.isFinite(n)) return JOURNAL_DEFAULT_PER_PAGE const i = Math.trunc(n) if (i < 1) return 1 if (i > JOURNAL_MAX_PER_PAGE) return JOURNAL_MAX_PER_PAGE return i } /** Query for GET /posts: per_page always clamped, other keys only when set. */ export function buildPostListQuery(opts: PostListOptions = {}): Record { const q: Record = { per_page: clampPerPage(opts.perPage) } if (opts.page !== undefined && Number.isInteger(opts.page) && opts.page > 0) q.page = opts.page if (opts.sort) q.sort = opts.sort if (opts.category) q.category = opts.category if (opts.tag) q.tag = opts.tag if (opts.search) q.search = opts.search return q } /** Encodes each "/"-separated segment of a slug (post slugs may contain "/"). */ export function encodeSlugPath(slug: string): string { return String(slug) .split('/') .map((part) => encodeURIComponent(part)) .join('/') } /** Depth-first flattening of the categories tree, parents before children. */ export function flattenCategories(tree: readonly JournalCategory[] | null | undefined): JournalCategory[] { const out: JournalCategory[] = [] const walk = (nodes: readonly JournalCategory[] | null | undefined): void => { for (const node of nodes ?? []) { out.push(node) walk(node.children) } } walk(tree) return out } /** 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 } export interface JournalClient { getPost(slug: string): Promise listPosts(opts?: PostListOptions): Promise> listCategories(): Promise findCategory(slug: string): Promise listTags(): Promise getAuthor(slug: string): Promise listAuthorPosts(slug: string, opts?: AuthorPostListOptions): Promise | null> } export function createJournalClient(fetcher: JournalFetcher): JournalClient { const orNull = async (p: Promise): Promise => { try { return await p } catch (err) { if (isNotFound(err)) return null throw err } } const listCategories = async (): Promise => { // include_empty=1 always (D-24): an empty category still has a page. const res = (await fetcher('/categories', { include_empty: 1 })) as JournalItem return Array.isArray(res?.data) ? res.data : [] } return { async getPost(slug) { const res = (await orNull(fetcher(`/posts/${encodeSlugPath(slug)}`))) as JournalItem | null return res?.data ?? null }, async listPosts(opts = {}) { return (await fetcher('/posts', buildPostListQuery(opts))) as JournalPage }, listCategories, async findCategory(slug) { const rows = flattenCategories(await listCategories()) return rows.find((c) => c.slug === slug) ?? null }, async listTags() { const res = (await fetcher('/tags')) as JournalItem return Array.isArray(res?.data) ? res.data : [] }, async getAuthor(slug) { const res = (await orNull(fetcher(`/authors/${encodeURIComponent(slug)}`))) as JournalItem | null return res?.data ?? null }, async listAuthorPosts(slug, opts = {}) { const q: Record = { per_page: clampPerPage(opts.perPage) } if (opts.page !== undefined && Number.isInteger(opts.page) && opts.page > 0) q.page = opts.page if (opts.sort) q.sort = opts.sort return (await orNull(fetcher(`/authors/${encodeURIComponent(slug)}/posts`, q))) as JournalPage | null }, } }