Files
vue-journal-layer/app/utils/search.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

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,
}
}