Files
vue-journal-layer/app/utils/client.ts
Jakub Zych 102da43f76 feat: opt-in Journal search page and JournalSearchBox
- journal.routes.search (off by default) registers journal-search, the
  results page for ?q= in the API's relevance order (no sort is sent),
  server-rendered with a Polish-aware result count.
- JournalSearchBox: an icon button and a WAI-ARIA combobox with debounced
  ?fields=suggest suggestions, stale answers dropped, query words marked
  ignoring diacritics; a plain GET form without JS.
- The module keeps the search path out of the route cache and the sitemap
  and marks it noindex, follow.
- Client: suggestPosts and the fields option; useJournalLinks.searchLink.
2026-10-30 18:14:05 +01:00

186 lines
6.5 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,
JournalPostSuggestion,
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
/** Search terms shorter than this are ignored by the API, so the layer never sends them. */
export const JOURNAL_SEARCH_MIN_LENGTH = 3
/**
* Issues one GET for `path` (e.g. "/posts") with `query`, resolving the parsed
* JSON. `init.signal` aborts the request.
*/
export type JournalFetcher = (
path: string,
query?: Record<string, string | number>,
init?: { signal?: AbortSignal },
) => 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
/** "suggest" asks for JournalPostSuggestion rows instead of full posts. */
fields?: 'suggest'
}
export interface SuggestOptions {
perPage?: number
signal?: AbortSignal
}
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
if (opts.fields) q.fields = opts.fields
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>>
/**
* Suggestion rows for `term` in relevance order (no sort is sent). A term
* shorter than JOURNAL_SEARCH_MIN_LENGTH sends nothing and gives an empty page.
*/
suggestPosts(term: string, opts?: SuggestOptions): Promise<JournalPage<JournalPostSuggestion>>
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>
},
async suggestPosts(term, opts = {}) {
const search = term.trim()
const perPage = clampPerPage(opts.perPage ?? 5)
if ([...search].length < JOURNAL_SEARCH_MIN_LENGTH) {
return { data: [], meta: { current_page: 1, last_page: 1, per_page: perPage, total: 0 } }
}
const query = buildPostListQuery({ search, fields: 'suggest', perPage })
const init = opts.signal ? { signal: opts.signal } : undefined
return (await fetcher('/posts', query, init)) as JournalPage<JournalPostSuggestion>
},
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
},
}
}