- 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
151 lines
5.3 KiB
TypeScript
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
|
|
},
|
|
}
|
|
}
|