- 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.
232 lines
7.7 KiB
TypeScript
232 lines
7.7 KiB
TypeScript
// 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,
|
|
}
|
|
}
|