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

@@ -1,7 +1,7 @@
// A small node:http stand-in for the anonymous Journal API
// (/_journal/api/v1), answering from JSON fixtures. It follows the Go
// handlers' query rules (page, per_page clamp, sort allow-list, category with
// nested children, tag, search, include_empty) and the author routes, and it
// nested children, tag, search, fields=suggest, include_empty) and the author routes, and it
// records every request so a smoke run can assert header forwarding.
//
// Plain .mjs with JSDoc types: Node does not strip TypeScript under
@@ -106,6 +106,19 @@ function paginate(rows, page, perPage) {
}
}
/** The ?fields=suggest row of the Go serializer. @param {any} post */
function suggestion(post) {
const text = String(post.excerpt || post.content_html || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
return {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: text.length > 160 ? `${text.slice(0, 160).replace(/\s+\S*$/, '')}…` : text,
featured_image_url: post.featured_image_url ?? null,
published_at: post.published_at,
}
}
/** @param {any} post */
function sibling(post) {
return post ? { id: post.id, title: post.title, slug: post.slug } : null
@@ -172,7 +185,9 @@ export function startFixtureApi({ dir, port = 0 }) {
rows = rows.filter((p) => p.title.toLowerCase().includes(s))
}
rows = sortRows(rows, parseSort(q.get('sort')))
return paginate(rows, parsePage(q.get('page')), parsePerPage(q.get('per_page')))
const out = paginate(rows, parsePage(q.get('page')), parsePerPage(q.get('per_page')))
if (q.get('fields') !== 'suggest') return out
return { ...out, data: out.data.map(suggestion) }
}
/**