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.
This commit is contained in:
Jakub Zych
2026-10-30 18:14:05 +01:00
parent e0cc87118c
commit 102da43f76
22 changed files with 1170 additions and 14 deletions

View File

@@ -9,6 +9,7 @@ import type {
JournalPage,
JournalPost,
JournalPostDetail,
JournalPostSuggestion,
JournalTag,
} from './types.ts'
@@ -18,8 +19,18 @@ 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>
/** 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
@@ -30,6 +41,13 @@ export interface PostListOptions {
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 {
@@ -56,6 +74,7 @@ export function buildPostListQuery(opts: PostListOptions = {}): Record<string, s
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
}
@@ -90,6 +109,11 @@ export function isNotFound(err: unknown): boolean {
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[]>
@@ -123,6 +147,17 @@ export function createJournalClient(fetcher: JournalFetcher): JournalClient {
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) {