Files
vue-journal-layer/app/utils/client.ts
Jakub Zych 6f6792f51c feat: scaffold Journal layer with configurable post route and git-install check
- journal module registers the Journal pages at journal.routes paths
- typed anonymous-GET client, html gate, SSR x-forwarded-for forwarding
- fixture API, built-server SSR smoke and git-tag install check
2026-10-09 17:31:45 +02:00

151 lines
5.3 KiB
TypeScript

// 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<string, string | number>) => Promise<unknown>
export interface PostListOptions {
page?: number
perPage?: number
/** "<column> <asc|desc>", 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<string, string | number> {
const q: Record<string, string | number> = { 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<JournalPostDetail | null>
listPosts(opts?: PostListOptions): Promise<JournalPage<JournalPost>>
listCategories(): Promise<JournalCategory[]>
findCategory(slug: string): Promise<JournalCategory | null>
listTags(): Promise<JournalTag[]>
getAuthor(slug: string): Promise<JournalAuthor | null>
listAuthorPosts(slug: string, opts?: AuthorPostListOptions): Promise<JournalPage<JournalPost> | null>
}
export function createJournalClient(fetcher: JournalFetcher): JournalClient {
const orNull = async <T>(p: Promise<T>): Promise<T | null> => {
try {
return await p
} catch (err) {
if (isNotFound(err)) return null
throw err
}
}
const listCategories = async (): Promise<JournalCategory[]> => {
// include_empty=1 always (D-24): an empty category still has a page.
const res = (await fetcher('/categories', { include_empty: 1 })) as JournalItem<JournalCategory[]>
return Array.isArray(res?.data) ? res.data : []
}
return {
async getPost(slug) {
const res = (await orNull(fetcher(`/posts/${encodeSlugPath(slug)}`))) as JournalItem<JournalPostDetail> | null
return res?.data ?? null
},
async listPosts(opts = {}) {
return (await fetcher('/posts', buildPostListQuery(opts))) as JournalPage<JournalPost>
},
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<JournalTag[]>
return Array.isArray(res?.data) ? res.data : []
},
async getAuthor(slug) {
const res = (await orNull(fetcher(`/authors/${encodeURIComponent(slug)}`))) as JournalItem<JournalAuthor> | null
return res?.data ?? null
},
async listAuthorPosts(slug, opts = {}) {
const q: Record<string, string | number> = { 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<JournalPost> | null
},
}
}