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:
@@ -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) {
|
||||
|
||||
@@ -11,6 +11,7 @@ export const DEFAULT_JOURNAL_ROUTES: Readonly<JournalRoutes> = Object.freeze({
|
||||
category: '/blog/category/:category',
|
||||
tag: '/blog/tag/:tag',
|
||||
author: '/blog/author/:author',
|
||||
search: false,
|
||||
})
|
||||
|
||||
/** Route names stay stable whatever the paths are, so links never hard-code a path. */
|
||||
@@ -20,6 +21,7 @@ export const JOURNAL_ROUTE_NAMES: Readonly<Record<keyof JournalRoutes, string>>
|
||||
category: 'journal-category',
|
||||
tag: 'journal-tag',
|
||||
author: 'journal-author',
|
||||
search: 'journal-search',
|
||||
})
|
||||
|
||||
/** The route param each path must carry. */
|
||||
@@ -29,6 +31,7 @@ const REQUIRED_PARAM: Readonly<Record<keyof JournalRoutes, string | null>> = {
|
||||
category: 'category',
|
||||
tag: 'tag',
|
||||
author: 'author',
|
||||
search: null,
|
||||
}
|
||||
|
||||
const ROUTE_KEYS = Object.keys(DEFAULT_JOURNAL_ROUTES) as Array<keyof JournalRoutes>
|
||||
|
||||
231
app/utils/search.ts
Normal file
231
app/utils/search.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
// Search helpers for JournalSearchBox and the search page: the term rules,
|
||||
// query-term marking that ignores case and diacritics, plural forms, listbox
|
||||
// keys and the suggestion request controller. Pure TypeScript, so
|
||||
// `node --test` imports it directly.
|
||||
|
||||
import { JOURNAL_SEARCH_MIN_LENGTH } from './client.ts'
|
||||
|
||||
/** Longer search terms are cut to this many characters. */
|
||||
export const JOURNAL_SEARCH_MAX_LENGTH = 200
|
||||
|
||||
/** The ?q value as a trimmed term: the first value of a repeated parameter, "" when absent. */
|
||||
export function searchTermFromQuery(raw: unknown): string {
|
||||
const value = Array.isArray(raw) ? raw[0] : raw
|
||||
if (typeof value !== 'string') return ''
|
||||
return [...value.trim()].slice(0, JOURNAL_SEARCH_MAX_LENGTH).join('').trim()
|
||||
}
|
||||
|
||||
export type SearchTermState = 'empty' | 'short' | 'ready'
|
||||
|
||||
/** Whether `term` is empty, too short for the API, or ready to search. */
|
||||
export function searchTermState(term: string, minLength: number = JOURNAL_SEARCH_MIN_LENGTH): SearchTermState {
|
||||
const n = [...term.trim()].length
|
||||
if (n === 0) return 'empty'
|
||||
return n < minLength ? 'short' : 'ready'
|
||||
}
|
||||
|
||||
// Letters NFD does not decompose into a base letter and a mark.
|
||||
const FOLD_EXTRA: Readonly<Record<string, string>> = {
|
||||
ł: 'l',
|
||||
Ł: 'l',
|
||||
ø: 'o',
|
||||
Ø: 'o',
|
||||
đ: 'd',
|
||||
Đ: 'd',
|
||||
ß: 'ss',
|
||||
æ: 'ae',
|
||||
Æ: 'ae',
|
||||
œ: 'oe',
|
||||
Œ: 'oe',
|
||||
}
|
||||
|
||||
function foldChar(ch: string): string {
|
||||
return FOLD_EXTRA[ch] ?? ch.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds `text` to lower case without diacritics. `start[i]` and `end[i]` are
|
||||
* the bounds, in `text`, of the character that produced folded[i].
|
||||
*/
|
||||
function fold(text: string): { folded: string; start: number[]; end: number[] } {
|
||||
let folded = ''
|
||||
const start: number[] = []
|
||||
const end: number[] = []
|
||||
let pos = 0
|
||||
for (const ch of text) {
|
||||
const f = foldChar(ch)
|
||||
for (let k = 0; k < f.length; k++) {
|
||||
start.push(pos)
|
||||
end.push(pos + ch.length)
|
||||
}
|
||||
folded += f
|
||||
pos += ch.length
|
||||
}
|
||||
return { folded, start, end }
|
||||
}
|
||||
|
||||
export interface TextSegment {
|
||||
text: string
|
||||
mark: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits `text` into plain and marked segments, marking every occurrence of
|
||||
* each word of `query` (2 or more characters) while ignoring case and
|
||||
* diacritics, so "grzybow" marks "grzybów". The segments are text: render
|
||||
* them as text nodes, never as HTML.
|
||||
*/
|
||||
export function markTerms(text: string | null | undefined, query: string | null | undefined): TextSegment[] {
|
||||
if (!text) return []
|
||||
const words = [...new Set((query ?? '').split(/\s+/).map((w) => fold(w).folded))].filter((w) => w.length >= 2)
|
||||
if (words.length === 0) return [{ text, mark: false }]
|
||||
const { folded, start, end } = fold(text)
|
||||
const ranges: Array<[number, number]> = []
|
||||
for (const word of words) {
|
||||
for (let i = folded.indexOf(word); i !== -1; i = folded.indexOf(word, i + 1)) {
|
||||
ranges.push([start[i]!, end[i + word.length - 1]!])
|
||||
}
|
||||
}
|
||||
if (ranges.length === 0) return [{ text, mark: false }]
|
||||
ranges.sort((a, b) => a[0] - b[0] || b[1] - a[1])
|
||||
const merged: Array<[number, number]> = []
|
||||
for (const r of ranges) {
|
||||
const last = merged[merged.length - 1]
|
||||
if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1])
|
||||
else merged.push([r[0], r[1]])
|
||||
}
|
||||
const out: TextSegment[] = []
|
||||
let pos = 0
|
||||
for (const [a, b] of merged) {
|
||||
if (a > pos) out.push({ text: text.slice(pos, a), mark: false })
|
||||
out.push({ text: text.slice(a, b), mark: true })
|
||||
pos = b
|
||||
}
|
||||
if (pos < text.length) out.push({ text: text.slice(pos), mark: false })
|
||||
return out
|
||||
}
|
||||
|
||||
export type PluralForm = 'one' | 'few' | 'many' | 'other'
|
||||
|
||||
/**
|
||||
* The plural form of `n` in `language` (a BCP 47 tag), from Intl.PluralRules:
|
||||
* Polish gives one (1), few (2-4, 22-24) and many (0, 5-21). Forms a message
|
||||
* file does not carry (zero, two) fall back to other.
|
||||
*/
|
||||
export function pluralForm(language: string, n: number): PluralForm {
|
||||
let form: string
|
||||
try {
|
||||
form = new Intl.PluralRules(language || 'en').select(n)
|
||||
} catch {
|
||||
form = new Intl.PluralRules('en').select(n)
|
||||
}
|
||||
return form === 'one' || form === 'few' || form === 'many' ? form : 'other'
|
||||
}
|
||||
|
||||
/**
|
||||
* The option a combobox key moves to among `count` options, from `current`
|
||||
* (-1 for none): ArrowDown and ArrowUp move one step and wrap. Any other key,
|
||||
* or no options, gives null.
|
||||
*/
|
||||
export function moveActive(current: number, count: number, key: string): number | null {
|
||||
if (count <= 0) return null
|
||||
if (key === 'ArrowDown') return current < 0 || current >= count - 1 ? 0 : current + 1
|
||||
if (key === 'ArrowUp') return current <= 0 ? count - 1 : current - 1
|
||||
return null
|
||||
}
|
||||
|
||||
export type SuggestStatus = 'idle' | 'loading' | 'done' | 'error'
|
||||
|
||||
export interface SuggestState<T> {
|
||||
/** The term the rows (or the pending request) belong to. */
|
||||
term: string
|
||||
status: SuggestStatus
|
||||
/** True once a request has run longer than the spinner delay. */
|
||||
loading: boolean
|
||||
rows: T[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface SuggestControllerOptions<T> {
|
||||
/** One suggestion request; it must stop when `signal` aborts. */
|
||||
fetch: (term: string, signal: AbortSignal) => Promise<{ data: T[]; meta?: { total?: number } }>
|
||||
onChange: (state: SuggestState<T>) => void
|
||||
/** Wait after the last keystroke before a request, in ms. Default 250. */
|
||||
debounce?: number
|
||||
/** Shorter terms send nothing. Default JOURNAL_SEARCH_MIN_LENGTH. */
|
||||
minLength?: number
|
||||
/** A request shows `loading` only after this many ms, to avoid flicker. Default 300. */
|
||||
spinnerDelay?: number
|
||||
}
|
||||
|
||||
export interface SuggestController {
|
||||
/** The input's current value. */
|
||||
input(value: string): void
|
||||
/** Cancels the pending and running request. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced suggestion requests. Each new term cancels the pending timer,
|
||||
* aborts the running request and ignores any answer that still arrives, so
|
||||
* the rows always belong to the latest term. A failed request ends in status
|
||||
* "error" and never throws.
|
||||
*/
|
||||
export function createSuggestController<T>(opts: SuggestControllerOptions<T>): SuggestController {
|
||||
const debounce = opts.debounce ?? 250
|
||||
const minLength = opts.minLength ?? JOURNAL_SEARCH_MIN_LENGTH
|
||||
const spinnerDelay = opts.spinnerDelay ?? 300
|
||||
let state: SuggestState<T> = { term: '', status: 'idle', loading: false, rows: [], total: 0 }
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let spinner: ReturnType<typeof setTimeout> | undefined
|
||||
let abort: AbortController | null = null
|
||||
let seq = 0
|
||||
|
||||
const emit = (patch: Partial<SuggestState<T>>) => {
|
||||
state = { ...state, ...patch }
|
||||
opts.onChange(state)
|
||||
}
|
||||
const cancel = () => {
|
||||
clearTimeout(timer)
|
||||
clearTimeout(spinner)
|
||||
abort?.abort()
|
||||
abort = null
|
||||
seq++
|
||||
}
|
||||
const run = async (term: string) => {
|
||||
const id = ++seq
|
||||
const ctl = new AbortController()
|
||||
abort = ctl
|
||||
emit({ status: 'loading', loading: false })
|
||||
spinner = setTimeout(() => {
|
||||
if (id === seq) emit({ loading: true })
|
||||
}, spinnerDelay)
|
||||
try {
|
||||
const page = await opts.fetch(term, ctl.signal)
|
||||
if (id !== seq) return
|
||||
clearTimeout(spinner)
|
||||
emit({ status: 'done', loading: false, rows: page.data ?? [], total: page.meta?.total ?? 0 })
|
||||
} catch {
|
||||
if (id !== seq) return
|
||||
clearTimeout(spinner)
|
||||
emit({ status: 'error', loading: false, rows: [], total: 0 })
|
||||
} finally {
|
||||
if (abort === ctl) abort = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
input(value) {
|
||||
const term = value.trim()
|
||||
if (term === state.term && (state.status === 'loading' || state.status === 'done')) return
|
||||
cancel()
|
||||
if (searchTermState(term, minLength) !== 'ready') {
|
||||
emit({ term, status: 'idle', loading: false, rows: [], total: 0 })
|
||||
return
|
||||
}
|
||||
emit({ term })
|
||||
timer = setTimeout(() => void run(term), debounce)
|
||||
},
|
||||
dispose: cancel,
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,19 @@ export interface JournalPost {
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of GET /posts?fields=suggest: a subset of JournalPost for a search
|
||||
* box. `excerpt` is plain text of about 160 characters.
|
||||
*/
|
||||
export interface JournalPostSuggestion {
|
||||
id: number
|
||||
title: string
|
||||
slug: string
|
||||
excerpt: string
|
||||
featured_image_url: string | null
|
||||
published_at: string | null
|
||||
}
|
||||
|
||||
/** previous_post / next_post on GET /posts/{slug}. */
|
||||
export interface JournalPostSibling {
|
||||
id: number
|
||||
@@ -121,6 +134,8 @@ export interface JournalRoutes {
|
||||
category: string | false
|
||||
tag: string | false
|
||||
author: string | false
|
||||
/** Search results page; the term is the `q` query parameter. Off by default. */
|
||||
search: string | false
|
||||
}
|
||||
|
||||
export interface JournalPerPage {
|
||||
@@ -128,6 +143,7 @@ export interface JournalPerPage {
|
||||
category: number
|
||||
tag: number
|
||||
author: number
|
||||
search: number
|
||||
}
|
||||
|
||||
/** Options of the `journal` config key in nuxt.config. */
|
||||
|
||||
Reference in New Issue
Block a user