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:
@@ -287,3 +287,187 @@
|
||||
margin-top: var(--journal-space-4);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Search box (header). Outside .journal-layout, so it sets its own type. The
|
||||
panel is inline; an app turns it into a drop-down panel by positioning
|
||||
.journal-search__panel. Without JS the panel is always shown. */
|
||||
.journal-search {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--journal-space-2);
|
||||
font-family: var(--journal-font-body);
|
||||
color: var(--journal-color-text);
|
||||
}
|
||||
|
||||
.journal-search__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--journal-space-2);
|
||||
border: 0;
|
||||
border-radius: var(--journal-radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.journal-search__toggle:focus-visible,
|
||||
.journal-search__input:focus-visible,
|
||||
.journal-search-page__input:focus-visible,
|
||||
.journal-search-page__submit:focus-visible {
|
||||
outline: 2px solid var(--journal-color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.journal-search--enhanced:not(.journal-search--open) .journal-search__panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.journal-search__panel {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.journal-search__input,
|
||||
.journal-search-page__input {
|
||||
box-sizing: border-box;
|
||||
width: 16rem;
|
||||
max-width: 100%;
|
||||
padding: var(--journal-space-2) var(--journal-space-3);
|
||||
border: 1px solid var(--journal-color-border);
|
||||
border-radius: var(--journal-radius-sm);
|
||||
background: var(--journal-color-bg);
|
||||
color: var(--journal-color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.journal-search__dropdown {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% + var(--journal-space-1));
|
||||
left: 0; /* a right-aligned box sets left: auto; right: 0 */
|
||||
box-sizing: border-box;
|
||||
width: min(26rem, calc(100vw - 2 * var(--journal-space-3)));
|
||||
padding: var(--journal-space-2) 0;
|
||||
border: 1px solid var(--journal-color-border);
|
||||
border-radius: var(--journal-radius-md);
|
||||
background: var(--journal-color-bg);
|
||||
box-shadow: var(--journal-shadow-dropdown);
|
||||
}
|
||||
|
||||
.journal-search__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.journal-search__link {
|
||||
display: flex;
|
||||
gap: var(--journal-space-3);
|
||||
padding: var(--journal-space-2) var(--journal-space-3);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.journal-search__option--active .journal-search__link {
|
||||
background: var(--journal-color-surface);
|
||||
}
|
||||
|
||||
.journal-search__thumb {
|
||||
flex: none;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: var(--journal-radius-sm);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.journal-search__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.journal-search__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.journal-search__excerpt {
|
||||
color: var(--journal-color-muted);
|
||||
font-size: 0.875em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.journal-search mark,
|
||||
.journal-search-page mark {
|
||||
background: var(--journal-color-mark);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.journal-search__status {
|
||||
margin: 0;
|
||||
padding: var(--journal-space-2) var(--journal-space-3);
|
||||
color: var(--journal-color-muted);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.journal-search__status--loading::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 0.8em;
|
||||
height: 0.8em;
|
||||
margin-right: var(--journal-space-2);
|
||||
border: 2px solid var(--journal-color-border);
|
||||
border-top-color: var(--journal-color-accent);
|
||||
border-radius: 50%;
|
||||
vertical-align: -0.1em;
|
||||
animation: journal-search-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes journal-search-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.journal-search__status--loading::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.journal-search__all {
|
||||
display: block;
|
||||
padding: var(--journal-space-2) var(--journal-space-3);
|
||||
border-top: 1px solid var(--journal-color-border);
|
||||
color: var(--journal-color-link);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
/* Search page */
|
||||
.journal-search-page__form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--journal-space-2);
|
||||
margin-bottom: var(--journal-space-4);
|
||||
}
|
||||
|
||||
.journal-search-page__input {
|
||||
flex: 1 1 16rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.journal-search-page__submit {
|
||||
padding: var(--journal-space-2) var(--journal-space-4);
|
||||
border: 0;
|
||||
border-radius: var(--journal-radius-sm);
|
||||
background: var(--journal-color-accent);
|
||||
color: var(--journal-color-on-accent);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.journal-search-page__count,
|
||||
.journal-search-page__message {
|
||||
margin: 0 0 var(--journal-space-4);
|
||||
color: var(--journal-color-muted);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
--journal-color-link-hover: #0550ae; /* hovered links */
|
||||
--journal-color-accent: #0969da; /* current page, focus ring */
|
||||
--journal-color-on-accent: #ffffff; /* text on the accent colour */
|
||||
--journal-color-mark: #fff8c5; /* search term highlight */
|
||||
--journal-shadow-dropdown: 0 8px 24px rgb(0 0 0 / 0.12); /* search suggestions */
|
||||
|
||||
/* Type */
|
||||
--journal-font-body: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; /* body copy */
|
||||
|
||||
228
app/components/Journal/SearchBox.vue
Normal file
228
app/components/Journal/SearchBox.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
// The header search: an icon button opens a search input, up to `limit`
|
||||
// suggestions show as the visitor types, and "see all" opens the search page.
|
||||
// The input is a WAI-ARIA combobox: arrows move through the suggestions,
|
||||
// Enter opens the active one or the search page, Escape closes and returns
|
||||
// focus to the button, a click outside closes. Without JS it is a plain GET
|
||||
// form to the search page, always open. It renders nothing when
|
||||
// journal.routes.search is false.
|
||||
//
|
||||
// Layout hooks: .journal-search--open on the root while open, and the panel
|
||||
// (.journal-search__panel) for an inline input or a drop-down; the icon is
|
||||
// the `icon` slot.
|
||||
import { JOURNAL_SEARCH_MIN_LENGTH } from '../../utils/client.ts'
|
||||
import { createSuggestController, markTerms, moveActive, searchTermState, type SuggestController, type SuggestState } from '../../utils/search.ts'
|
||||
import type { JournalPostSuggestion } from '../../utils/types.ts'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
limit?: number
|
||||
minLength?: number
|
||||
debounce?: number
|
||||
/** Overrides journal.search.placeholder. */
|
||||
placeholder?: string
|
||||
/** Overrides journal.search.button, the icon button's accessible label. */
|
||||
buttonLabel?: string
|
||||
/** Overrides journal.search.seeAll. */
|
||||
seeAllLabel?: string
|
||||
/** Overrides journal.search.empty. */
|
||||
emptyLabel?: string
|
||||
}>(),
|
||||
{ limit: 5, minLength: JOURNAL_SEARCH_MIN_LENGTH, debounce: 250 },
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const links = useJournalLinks()
|
||||
const api = useJournalApi()
|
||||
|
||||
const action = computed(() => links.searchLink())
|
||||
const id = useId()
|
||||
const panelId = `${id}-panel`
|
||||
const listId = `${id}-list`
|
||||
const optionId = (i: number) => `${id}-option-${i}`
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const toggleEl = ref<HTMLButtonElement | null>(null)
|
||||
const inputEl = ref<HTMLInputElement | null>(null)
|
||||
const enhanced = ref(false)
|
||||
const open = ref(false)
|
||||
const term = ref('')
|
||||
const active = ref(-1)
|
||||
const state = shallowRef<SuggestState<JournalPostSuggestion>>({ term: '', status: 'idle', loading: false, rows: [], total: 0 })
|
||||
|
||||
const rows = computed(() => state.value.rows)
|
||||
const listOpen = computed(
|
||||
() => enhanced.value && open.value && searchTermState(term.value, props.minLength) === 'ready' && state.value.status !== 'idle',
|
||||
)
|
||||
const seeAll = computed(() => links.searchLink(term.value.trim()))
|
||||
|
||||
let suggest: SuggestController | null = null
|
||||
|
||||
const onOutside = (e: PointerEvent) => {
|
||||
if (open.value && root.value && !root.value.contains(e.target as Node)) close(false)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
enhanced.value = true
|
||||
suggest = createSuggestController<JournalPostSuggestion>({
|
||||
fetch: (q, signal) => api.suggestPosts(q, { perPage: props.limit, signal }),
|
||||
onChange: (s) => {
|
||||
state.value = s
|
||||
active.value = -1
|
||||
},
|
||||
debounce: props.debounce,
|
||||
minLength: props.minLength,
|
||||
})
|
||||
document.addEventListener('pointerdown', onOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
suggest?.dispose()
|
||||
document.removeEventListener('pointerdown', onOutside)
|
||||
})
|
||||
|
||||
watch(term, (value) => suggest?.input(value))
|
||||
|
||||
// Opening selects the previous term, so typing replaces it.
|
||||
function openPanel() {
|
||||
open.value = true
|
||||
nextTick(() => {
|
||||
inputEl.value?.focus()
|
||||
inputEl.value?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function close(focusToggle: boolean) {
|
||||
open.value = false
|
||||
active.value = -1
|
||||
if (focusToggle) toggleEl.value?.focus()
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (open.value) close(false)
|
||||
else openPanel()
|
||||
}
|
||||
|
||||
function go(to: string | null) {
|
||||
if (!to) return
|
||||
close(false)
|
||||
return navigateTo(to)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close(true)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' && listOpen.value && active.value >= 0) {
|
||||
const row = rows.value[active.value]
|
||||
if (row) {
|
||||
e.preventDefault()
|
||||
go(links.postLink(row.slug))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!listOpen.value) return
|
||||
const next = moveActive(active.value, rows.value.length, e.key)
|
||||
if (next !== null) {
|
||||
e.preventDefault()
|
||||
active.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit(e: Event) {
|
||||
if (!enhanced.value) return
|
||||
e.preventDefault()
|
||||
const q = term.value.trim()
|
||||
if (q) go(links.searchLink(q))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="action"
|
||||
ref="root"
|
||||
class="journal-search"
|
||||
:class="{ 'journal-search--enhanced': enhanced, 'journal-search--open': open }"
|
||||
>
|
||||
<button
|
||||
v-if="enhanced"
|
||||
ref="toggleEl"
|
||||
type="button"
|
||||
class="journal-search__toggle"
|
||||
:aria-label="buttonLabel ?? t('journal.search.button')"
|
||||
:aria-expanded="open"
|
||||
:aria-controls="panelId"
|
||||
@click="toggle"
|
||||
>
|
||||
<slot name="icon">
|
||||
<svg class="journal-search__icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
|
||||
<circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M15.5 15.5 21 21" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</slot>
|
||||
</button>
|
||||
<form :id="panelId" class="journal-search__panel" role="search" method="get" :action="action" @submit="onSubmit">
|
||||
<input
|
||||
ref="inputEl"
|
||||
v-model="term"
|
||||
class="journal-search__input"
|
||||
type="search"
|
||||
name="q"
|
||||
autocomplete="off"
|
||||
:placeholder="placeholder ?? t('journal.search.placeholder')"
|
||||
:aria-label="t('journal.search.input')"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
:aria-expanded="listOpen"
|
||||
:aria-controls="listId"
|
||||
:aria-activedescendant="listOpen && active >= 0 ? optionId(active) : undefined"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<div v-show="listOpen" class="journal-search__dropdown">
|
||||
<ul :id="listId" role="listbox" class="journal-search__list" :aria-label="t('journal.search.suggestions')">
|
||||
<li
|
||||
v-for="(row, i) in rows"
|
||||
:id="optionId(i)"
|
||||
:key="row.id"
|
||||
role="option"
|
||||
class="journal-search__option"
|
||||
:class="{ 'journal-search__option--active': i === active }"
|
||||
:aria-selected="i === active"
|
||||
@mousedown.prevent
|
||||
@mouseenter="active = i"
|
||||
>
|
||||
<NuxtLink :to="links.postLink(row.slug) ?? undefined" tabindex="-1" class="journal-search__link" @click="close(false)">
|
||||
<img v-if="row.featured_image_url" :src="row.featured_image_url" alt="" class="journal-search__thumb" width="48" height="48" loading="lazy" />
|
||||
<span class="journal-search__text">
|
||||
<span class="journal-search__title">
|
||||
<template v-for="(seg, k) in markTerms(row.title, state.term)" :key="k">
|
||||
<mark v-if="seg.mark">{{ seg.text }}</mark>
|
||||
<template v-else>{{ seg.text }}</template>
|
||||
</template>
|
||||
</span>
|
||||
<span v-if="row.excerpt" class="journal-search__excerpt">
|
||||
<template v-for="(seg, k) in markTerms(row.excerpt, state.term)" :key="k">
|
||||
<mark v-if="seg.mark">{{ seg.text }}</mark>
|
||||
<template v-else>{{ seg.text }}</template>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="state.loading" class="journal-search__status journal-search__status--loading" role="status">
|
||||
{{ t('journal.search.loading') }}
|
||||
</p>
|
||||
<p v-else-if="state.status === 'error'" class="journal-search__status" role="status">{{ t('journal.search.error') }}</p>
|
||||
<p v-else-if="state.status === 'done' && rows.length === 0" class="journal-search__status" role="status">
|
||||
{{ emptyLabel ?? t('journal.search.empty') }}
|
||||
</p>
|
||||
<NuxtLink v-if="seeAll && state.status !== 'error'" :to="seeAll" class="journal-search__all" @mousedown.prevent @click="close(false)">
|
||||
{{ seeAllLabel ?? t('journal.search.seeAll') }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -20,11 +20,11 @@ export function useJournalApi(): JournalClient {
|
||||
const peer = event?.node?.req?.socket?.remoteAddress
|
||||
const forwarded = recorded || [incoming, peer].filter((v): v is string => typeof v === 'string' && v !== '').join(', ')
|
||||
const headers: Record<string, string> = forwarded ? { 'x-forwarded-for': forwarded } : {}
|
||||
const fetcher: JournalFetcher = (path, query) => $fetch(path, { baseURL: base, query, headers })
|
||||
const fetcher: JournalFetcher = (path, query, init) => $fetch(path, { baseURL: base, query, headers, signal: init?.signal })
|
||||
return createJournalClient(fetcher)
|
||||
}
|
||||
|
||||
const base = (config.public.journal as { apiBase: string }).apiBase
|
||||
const fetcher: JournalFetcher = (path, query) => $fetch(path, { baseURL: base, query })
|
||||
const fetcher: JournalFetcher = (path, query, init) => $fetch(path, { baseURL: base, query, signal: init?.signal })
|
||||
return createJournalClient(fetcher)
|
||||
}
|
||||
|
||||
@@ -19,5 +19,7 @@ export function useJournalLinks() {
|
||||
tagLink: (slug: string) => link('tag', { tag: slug }),
|
||||
authorLink: (slug: string) => link('author', { author: slug }),
|
||||
indexLink: (page: number = 1) => link('index', undefined, page > 1 ? { page: String(page) } : undefined),
|
||||
/** The search page for `q`, or null when journal.routes.search is false. */
|
||||
searchLink: (q?: string) => link('search', undefined, q ? { q } : undefined),
|
||||
}
|
||||
}
|
||||
|
||||
77
app/journal-pages/SearchPage.vue
Normal file
77
app/journal-pages/SearchPage.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
// journal-search: the posts matching ?q, paginated with ?page=N, in the API's
|
||||
// relevance order (no sort is sent). Rendered on the server, so the form and
|
||||
// the results work without JS. The module keeps the page out of the route
|
||||
// cache and the sitemap and marks it noindex, follow. An invalid or
|
||||
// out-of-range page is a real 404.
|
||||
import { JOURNAL_SEARCH_MIN_LENGTH } from '../utils/client.ts'
|
||||
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
|
||||
import { pluralForm, searchTermFromQuery, searchTermState } from '../utils/search.ts'
|
||||
import { listTitle } from '../utils/seo.ts'
|
||||
import { toListPost } from '../utils/view.ts'
|
||||
import type { JournalModuleOptions } from '../utils/types.ts'
|
||||
|
||||
definePageMeta({ key: (route) => route.fullPath })
|
||||
|
||||
const route = useRoute()
|
||||
const api = useJournalApi()
|
||||
const { t, localeProperties } = useI18n()
|
||||
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
|
||||
|
||||
const q = searchTermFromQuery(route.query.q)
|
||||
const termState = searchTermState(q)
|
||||
const page = parsePageParam(route.query.page)
|
||||
if (page === null) throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
|
||||
const { data, error } = await useAsyncData(`journal-search-${q}-${page}`, async () => {
|
||||
if (termState !== 'ready') return null
|
||||
const posts = await api.listPosts({ search: q, page, perPage: cfg.perPage.search ?? cfg.perPage.index })
|
||||
return { ...posts, data: posts.data.map(toListPost) }
|
||||
})
|
||||
if (error.value) {
|
||||
throw createError({ statusCode: (error.value as { statusCode?: number }).statusCode ?? 500, fatal: true })
|
||||
}
|
||||
if (data.value && pageOutOfRange(page, data.value.meta.last_page)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
}
|
||||
|
||||
const total = data.value?.meta.total ?? 0
|
||||
const count = t(`journal.search.count.${pluralForm(localeProperties.value.language ?? 'en', total)}`, { count: total })
|
||||
const heading = q ? t('journal.search.titleQuery', { query: q }) : t('journal.search.title')
|
||||
const title = listTitle(heading, page, t('journal.pagination.pageTitle', { page }))
|
||||
useSeoMeta({ title, ogTitle: title })
|
||||
|
||||
const term = ref(q)
|
||||
const onSubmit = () => {
|
||||
const value = term.value.trim()
|
||||
return navigateTo({ path: route.path, query: value ? { q: value } : {} })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="journal-search-page">
|
||||
<header class="journal-index-header">
|
||||
<h1 class="journal-index-header__title">{{ title }}</h1>
|
||||
</header>
|
||||
<form class="journal-search-page__form" role="search" method="get" :action="route.path" @submit.prevent="onSubmit">
|
||||
<input
|
||||
v-model="term"
|
||||
class="journal-search-page__input"
|
||||
type="search"
|
||||
name="q"
|
||||
:aria-label="$t('journal.search.input')"
|
||||
:placeholder="$t('journal.search.placeholder')"
|
||||
/>
|
||||
<button type="submit" class="journal-search-page__submit">{{ $t('journal.search.submit') }}</button>
|
||||
</form>
|
||||
<p v-if="termState === 'empty'" class="journal-search-page__message">{{ $t('journal.search.prompt') }}</p>
|
||||
<p v-else-if="termState === 'short'" class="journal-search-page__message">
|
||||
{{ $t('journal.search.short', { min: JOURNAL_SEARCH_MIN_LENGTH }) }}
|
||||
</p>
|
||||
<template v-else-if="data">
|
||||
<p v-if="total > 0" class="journal-search-page__count">{{ count }}</p>
|
||||
<JournalPostList :posts="data.data" :empty="$t('journal.search.none', { query: q })" />
|
||||
<JournalPagination :current="data.meta.current_page" :last="data.meta.last_page" />
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -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