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,4 +1,7 @@
<template>
<header class="playground-header">
<JournalSearchBox />
</header>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>

View File

@@ -7,6 +7,8 @@ export default defineNuxtConfig({
// Route-cached like a real host, so testing/purge-check.mjs can prove a
// purge clears cached pages.
routeRules: { '/**': { swr: 300 } },
// The search page is opt-in; the playground turns it on for the smoke.
journal: { routes: { search: '/search' } },
i18n: {
strategy: 'prefix_except_default',
defaultLocale: 'en',

View File

@@ -20,6 +20,7 @@ Route paths are configuration, not files. The layer's page components live in `a
- **Post** (`journal-post`): title, date, reading time, categories, author, featured image, body, previous and next links, and related posts. When the API returns no related posts (a post without a category), the latest posts are shown instead.
- **Category** (`journal-category`): name, description and the category's rich HTML content above its posts. Categories are always requested with `include_empty=1`, so a category without posts still has a page. An unknown category answers 404, which makes the route safe as a root-level catch-all such as `/:category`.
- **Tag** (`journal-tag`) and **author** (`journal-author`) pages. An author resolves by author slug, or by login once the author has a published post; a login URL sets the canonical to the slug URL.
- **Search** (`journal-search`, opt-in): a results page for `?q=` and the `JournalSearchBox` header component with suggestions as the visitor types. Off until the app sets `journal.routes.search`. See [Search](#search).
- **RSS**: `/rss.xml` proxies the API feed, and list and post pages link it in the head.
- **SEO**: per-page title and description, Open Graph tags (`og:image` from the featured image, article times), canonical URLs that keep `?page=N`, robots and `sitemap.xml` through `@nuxtjs/seo`.
- **HTML sanitizer**: post bodies and category content go through `sanitizeJournalHtml` before `v-html` and before they reach the SSR payload. It is [sanitize-html](https://github.com/apostrophecms/sanitize-html) with the Journal API's own allow-list: the usual content elements, `footer`, `class`, schema.org microdata, the `color`, `font-size`, `font-weight` and `text-align` inline styles, and `http`, `https`, `mailto` or relative URLs. Four rules keep the Journal Blog Editor's own markup, mirroring the API's policy: `data-embed` (`youtube`) and `data-id` (an 11-character video id) on `figure`, `alt` with any text on `img`, and an integer `start` on `ol`. Anything else (`script`, `style`, `iframe`, `svg`, forms, event-handler attributes, `javascript:` URLs in any spelling) is removed and the rest of the value is kept, so a post never renders blank because of one stray tag. Server and browser produce the same string, so hydration matches. See `docs/decisions/0001-sanitize-html.md`.
@@ -92,10 +93,12 @@ Options under the `journal` key of `nuxt.config`:
| `journal.routes.category` | `/blog/category/:category` | Category path; must contain `:category`, or `false` |
| `journal.routes.tag` | `/blog/tag/:tag` | Tag path; must contain `:tag`, or `false` |
| `journal.routes.author` | `/blog/author/:author` | Author path; must contain `:author`, or `false` |
| `journal.routes.search` | `false` | Search results path, such as `/search`, or `false`; takes no param (the term is `?q=`) |
| `journal.perPage.index` | `9` | Posts per list page (the API caps at 30) |
| `journal.perPage.category` | `9` | Posts per category page |
| `journal.perPage.tag` | `9` | Posts per tag page |
| `journal.perPage.author` | `9` | Posts per author page |
| `journal.perPage.search` | `9` | Results per search page |
| `journal.sort` | `published_at desc` | `<column> <asc\|desc>`; columns `published_at`, `created_at`, `updated_at`, `title`, `id` |
| `journal.relatedCount` | `3` | Related posts under a post |
| `journal.rss` | `true` | Serve and link `/rss.xml` |
@@ -154,11 +157,37 @@ An app that caches its pages with Nitro route rules (`swr` or `cache`) would oth
- Why: nuxt-site-config publishes every `NUXT_SITE_*` variable in the page payload, so up to 0.5 a token set under `NUXT_SITE_CACHE_PURGE_TOKEN` appeared in every page as `cachePurgeToken`. From 0.6 the layer strips both keys from the site config even under the old names.
- Rotate a token that was ever set under `NUXT_SITE_CACHE_PURGE_TOKEN`: earlier versions published it. Set the new value in the site's `NUXT_CACHE_PURGE_TOKEN` and in the backend's `site_cache.purge_token`, then restart both.
## Search
Search is off by default: an app that does not set `journal.routes.search` gets no search page, and `JournalSearchBox` renders nothing. To turn it on:
```ts
journal: { routes: { search: '/szukaj' } },
```
The Journal API answers `GET /posts?search=` from Typesense in relevance order when the backend has it on, and from SQL otherwise; the layer sends no `sort` with a search, so the API's order is kept. Terms under 3 characters are never sent: the API would ignore them.
**Results page** (`journal-search`). Reads `?q=` and `?page=N`, renders the matches with `JournalPostList` and `JournalPagination` under a search form and a result count (plural forms through `Intl.PluralRules`: Polish one, few and many), and shows its own message for an empty term, a term under 3 characters and no results. It is fetched with `useAsyncData` on the server, so it works without JS. The title includes the term; the term is rendered as text everywhere.
**Header box** (`<JournalSearchBox />`). An icon button opens a search input; once the term has 3 or more characters, a suggestion request (`GET /posts?search=…&fields=suggest&per_page=5`) goes out 250 ms after the last keystroke, and a newer term aborts the running request and drops its answer. Up to `limit` suggestions show the title, the excerpt and a small featured image, with the query words in `<mark>` ignoring case and diacritics ("grzybow" marks "grzybów"); the marking splits text and never inserts API HTML. Under them, "see all results" links to the search page with `q`. Enter opens the active suggestion, or the search page when none is active. The input is a WAI-ARIA combobox: ArrowDown and ArrowUp move through the options (`aria-activedescendant`), Escape closes the box and returns focus to the button, and a click outside closes it. A spinner shows after 300 ms, "no results" when nothing matched, and a quiet message when the request failed. Without JS the box is a plain GET form to the search page, always shown. Reopening selects the previous term.
| Prop | Default | Meaning |
|------|---------|---------|
| `limit` | `5` | Suggestions shown |
| `minLength` | `3` | Shortest term that sends a request |
| `debounce` | `250` | Wait after the last keystroke, in ms |
| `placeholder`, `buttonLabel`, `seeAllLabel`, `emptyLabel` | the `journal.search.*` strings | Text overrides |
The `icon` slot replaces the magnifier. The root gets `journal-search--open` while open and `journal-search--enhanced` once mounted. The panel (`.journal-search__panel`) is inline next to the button, with the suggestions (`.journal-search__dropdown`) positioned below its left edge; an app makes it a full-width panel under its header, or anchors the suggestions on the right (`left: auto; right: 0`), in its own CSS.
**Caching, robots and sitemap.** Nitro's route cache keys by path, so under an app rule such as `'/**': { swr: 300 }` every `?q=` would get the first query's cached page. The journal module therefore adds `{ cache: false, robots: 'noindex, follow' }` route rules for the search path and its locale-prefixed copies (`/pl/szukaj`), and excludes them from `sitemap.xml`. An app's own rule for the same path wins key by key. The search page is not route-cached: every search renders on request.
## Theming
Every visual value is a token in `app/assets/css/journal-tokens.css`:
- Colour: `--journal-color-text`, `--journal-color-muted`, `--journal-color-bg`, `--journal-color-surface`, `--journal-color-border`, `--journal-color-link`, `--journal-color-link-hover`, `--journal-color-accent`, `--journal-color-on-accent`
- Colour: `--journal-color-text`, `--journal-color-muted`, `--journal-color-bg`, `--journal-color-surface`, `--journal-color-border`, `--journal-color-link`, `--journal-color-link-hover`, `--journal-color-accent`, `--journal-color-on-accent`, `--journal-color-mark` (search term highlight)
- Shadow: `--journal-shadow-dropdown` (search suggestions)
- Type: `--journal-font-body`, `--journal-font-heading`, `--journal-font-mono`, `--journal-font-size-base`, `--journal-line-height`
- Spacing: `--journal-space-1` to `--journal-space-6`
- Radii: `--journal-radius-sm`, `--journal-radius-md`, `--journal-radius-lg`
@@ -173,9 +202,9 @@ Override tokens in the app's CSS. The app's `css` entries load after the layer's
}
```
Override a component by creating the same file in the app, for example `app/components/Journal/PostCard.vue`. The overridable components are `JournalPostBody`, `JournalPostCard`, `JournalPostList`, `JournalPostMeta`, `JournalFeaturedImage`, `JournalPagination`, `JournalRelatedPosts`, `JournalIndexHeader`, `JournalCategoryHeader`, `JournalAuthorCard` and `JournalRssLink`. A replacement that renders HTML from the API must pass it through `sanitizeJournalHtml` (or `gateJournalHtml`, whose `html` is the same sanitized string) before `v-html`. Style classes that imported content uses, such as an author footer, belong in the app's CSS scoped under `.journal-post-body`: the sanitizer removes `<style>` elements from the content.
Override a component by creating the same file in the app, for example `app/components/Journal/PostCard.vue`. The overridable components are `JournalPostBody`, `JournalPostCard`, `JournalPostList`, `JournalPostMeta`, `JournalFeaturedImage`, `JournalPagination`, `JournalRelatedPosts`, `JournalIndexHeader`, `JournalCategoryHeader`, `JournalAuthorCard`, `JournalRssLink` and `JournalSearchBox`. A replacement that renders HTML from the API must pass it through `sanitizeJournalHtml` (or `gateJournalHtml`, whose `html` is the same sanitized string) before `v-html`. Style classes that imported content uses, such as an author footer, belong in the app's CSS scoped under `.journal-post-body`: the sanitizer removes `<style>` elements from the content.
Pages and components can use the `useJournalApi()` client and the `useJournalLinks()` link helpers (`postLink`, `categoryLink`, `tagLink`, `authorLink`, `indexLink`), which return `null` for a disabled view.
Pages and components can use the `useJournalApi()` client and the `useJournalLinks()` link helpers (`postLink`, `categoryLink`, `tagLink`, `authorLink`, `indexLink`, `searchLink`), which return `null` for a disabled view. The client's `suggestPosts(term, { perPage, signal })` returns the suggestion rows (`JournalPostSuggestion`).
## i18n
@@ -205,7 +234,7 @@ A release is an annotated git tag (`v0.1.0`) on `main`, pushed to git.golem15.co
## Testing
- `pnpm test` runs the unit tests in `tests/*.test.ts` with `node --test` (Node strips the TypeScript types; no test framework dependency). They cover the pure helpers in `app/utils/`: route normalization and path filling, the client against a fake fetcher, pagination, the HTML sanitizer (the Journal plugin's FormatHTML cases, the bypasses from its code review, and the Blog Editor corpus in `tests/fixtures/editor-corpus.json`, a byte copy of the plugin's), the embed expansion and its sanitize-first order, the post page view, sitemap entries, SEO values, the site cache purge rules (`tests/siteCache.test.ts` for the old route's helpers and browser revalidation, `tests/sitePurge.test.ts` for the shared route's helpers, the purge settings under their new and deprecated names, the site config stripping plugin and the sha256 pins of the shared files), and pl/en key and placeholder parity. They need no build, network or database.
- `pnpm test` runs the unit tests in `tests/*.test.ts` with `node --test` (Node strips the TypeScript types; no test framework dependency). They cover the pure helpers in `app/utils/`: route normalization and path filling, the client against a fake fetcher (including the suggest query), the search helpers (term rules, diacritic-insensitive marking, plural forms, combobox keys, and the suggestion controller's debounce, abort and stale-answer handling under mocked timers), pagination, the HTML sanitizer (the Journal plugin's FormatHTML cases, the bypasses from its code review, and the Blog Editor corpus in `tests/fixtures/editor-corpus.json`, a byte copy of the plugin's), the embed expansion and its sanitize-first order, the post page view, sitemap entries, SEO values, the site cache purge rules (`tests/siteCache.test.ts` for the old route's helpers and browser revalidation, `tests/sitePurge.test.ts` for the shared route's helpers, the purge settings under their new and deprecated names, the site config stripping plugin and the sha256 pins of the shared files), and pl/en key and placeholder parity. They need no build, network or database.
- `scripts/check-git-install.sh <tag>` installs the layer from the git tag into a scratch app, checks that `pnpm-lock.yaml` pins the tag's commit, runs `nuxt build`, and runs the SSR smoke against the build. Run it from a directory that is not inside a directory named `vue-journal-layer` (Nuxt treats such a path segment as the layer).
- `testing/smoke-ssr.mjs --output <.output> --fixtures <dir> --expect <file>` boots a fixture Journal API and the built server and checks each line of the expectations file (`STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]`). It also checks that SSR requests forwarded `x-forwarded-for` and that categories were requested with `include_empty=1`.
- `testing/purge-check.mjs --output <.output> --fixtures <dir> --kind post|category --slug <slug> --path <page path>` proves the [site cache purge](#site-cache-purge) on a built server whose route rules cache pages: it caches the page, changes the entity in a temporary copy of the fixtures, requires the cached page to stay unchanged, purges with a random token, and requires the change on the next request. It also checks `max-age=0` on the page and its 304, the purged `_payload.json`, a second purge after re-caching, `401` without or with a wrong token, `404` behind a proxy and `404` on a server without a token. The last line is `purge-check: ok (<n> checks)`.
@@ -219,4 +248,4 @@ node testing/purge-check.mjs --output .playground/.output --fixtures testing/fix
node testing/purge-token-check.mjs --output .playground/.output --fixtures testing/fixtures/acme --path /blog/hello-acme
```
The playground caches every page with `routeRules: { '/**': { swr: 300 } }`, like a real host.
The playground caches every page with `routeRules: { '/**': { swr: 300 } }`, like a real host, enables the search page at `/search` and places `JournalSearchBox` above the layout, so the smoke covers the search page, its cache bypass, robots and sitemap rules.

View File

@@ -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);
}

View File

@@ -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 */

View 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>

View File

@@ -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)
}

View File

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

View 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>

View File

@@ -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) {

View File

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

View File

@@ -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. */

View File

@@ -33,6 +33,28 @@
"readMore": "Read more",
"rss": {
"title": "RSS feed"
},
"search": {
"title": "Search",
"titleQuery": "Search results: {query}",
"placeholder": "Search posts…",
"button": "Search",
"input": "Search the posts",
"submit": "Search",
"suggestions": "Suggestions",
"seeAll": "See all results",
"empty": "No results",
"none": "Nothing found for “{query}”.",
"short": "Type at least {min} characters.",
"prompt": "Type what you are looking for.",
"loading": "Searching…",
"error": "Search is unavailable right now.",
"count": {
"one": "{count} result",
"few": "{count} results",
"many": "{count} results",
"other": "{count} results"
}
}
}
}

View File

@@ -33,6 +33,28 @@
"readMore": "Czytaj dalej",
"rss": {
"title": "Kanał RSS"
},
"search": {
"title": "Szukaj",
"titleQuery": "Wyniki wyszukiwania: {query}",
"placeholder": "Szukaj wpisów…",
"button": "Szukaj",
"input": "Szukaj we wpisach",
"submit": "Szukaj",
"suggestions": "Podpowiedzi",
"seeAll": "Zobacz więcej",
"empty": "Brak wyników",
"none": "Nic nie znaleźliśmy dla „{query}”.",
"short": "Wpisz co najmniej {min} znaki.",
"prompt": "Wpisz, czego szukasz.",
"loading": "Szukam…",
"error": "Wyszukiwanie jest chwilowo niedostępne.",
"count": {
"one": "{count} wynik",
"few": "{count} wyniki",
"many": "{count} wyników",
"other": "{count} wyniku"
}
}
}
}

View File

@@ -16,6 +16,7 @@ const PAGE_FILES: Record<keyof JournalRoutes, string> = {
category: 'CategoryPage',
tag: 'TagPage',
author: 'AuthorPage',
search: 'SearchPage',
}
const pagesDir = fileURLToPath(new URL('../app/journal-pages/', import.meta.url))
@@ -39,7 +40,7 @@ export default defineNuxtModule<JournalModuleOptions>({
},
defaults: {
routes: { ...DEFAULT_JOURNAL_ROUTES },
perPage: { index: 9, category: 9, tag: 9, author: 9 },
perPage: { index: 9, category: 9, tag: 9, author: 9, search: 9 },
sort: 'published_at desc',
relatedCount: 3,
rss: true,
@@ -55,6 +56,21 @@ export default defineNuxtModule<JournalModuleOptions>({
}
})
// The search page renders per ?q, so it is never route-cached: Nitro's
// cache keys by path, and an app rule such as '/**': { swr } would serve
// one query's results for every query. It is noindex, follow and left
// out of the sitemap. The locale-prefixed copies get the same rules. An
// app's own rule for the path wins key by key.
if (routes.search !== false) {
const i18n = (nuxt.options as { i18n?: { locales?: unknown } }).i18n
const paths = [routes.search, ...enabledLocaleCodes(i18n?.locales).map((code) => `/${code}${routes.search}`)]
const options = nuxt.options as { routeRules?: Record<string, Record<string, unknown>> }
const rules = (options.routeRules ??= {})
for (const path of paths) rules[path] = { cache: false, robots: 'noindex, follow', ...(rules[path] ?? {}) }
const opts = nuxt.options as { sitemap?: { exclude?: Array<string | RegExp> } }
opts.sitemap = { ...(opts.sitemap ?? {}), exclude: [...(opts.sitemap?.exclude ?? []), ...paths] }
}
// Register the layer's strings only for locales the app enables. The
// layer config declares no locales: @nuxtjs/i18n merges every locale a
// layer declares into the app, which would add /en routes to an app that

View File

@@ -1,6 +1,6 @@
{
"name": "vue-journal-layer",
"version": "0.6.0",
"version": "0.7.0",
"private": true,
"type": "module",
"main": "./nuxt.config.ts",

View File

@@ -43,3 +43,26 @@
200 /blog?page=2 contains=<link rel="canonical" href="https://acme.test/blog?page=2">
200 /blog/hello-acme contains=og:title
200 /blog/hello-acme contains=Acme says hello and introduces its blog.
# Search (the playground sets journal.routes.search: '/search'). 11 posts
# match "acme": 2 pages of 9. Not route-cached (the playground caches /**),
# noindex, follow, and left out of the sitemap.
200 /search?q=acme contains=Hello Acme
200 /search?q=acme contains=11 results
200 /search?q=acme contains=noindex, follow
200 /search?q=acme&page=2 contains=Acme Note 12
404 /search?q=acme&page=3
404 /search?q=acme&page=abc
200 /search?q=unsafe contains=Unsafe Sample notcontains=Hello Acme
200 /search?q=unsafe contains=1 result
200 /search?q=zzzzzz contains=Nothing found for “zzzzzz”.
200 /search?q=ac contains=Type at least 3 characters.
200 /search contains=Type what you are looking for.
# The term is text everywhere it is rendered (attributes may keep a bare <).
200 /search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E contains=Search results: &lt;script&gt;alert(1) notcontains=>Search results: <script>
200 /pl/search?q=acme contains=11 wyników
200 /pl/search?q=unsafe contains=1 wynik
200 /sitemap.xml notcontains=/search
# The header box renders its no-JS form to the search route on every page.
200 /blog contains=action="/search"
200 /pl/blog contains=action="/pl/search"

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) }
}
/**

View File

@@ -183,3 +183,38 @@ test('getAuthor and listAuthorPosts encode the author and map 404 to null', asyn
assert.equal(await client.getAuthor('nobody'), null)
assert.equal(await client.listAuthorPosts('nobody'), null)
})
test('buildPostListQuery passes fields through only when set', () => {
assert.deepEqual(buildPostListQuery({ search: 'kurki', fields: 'suggest', perPage: 5 }), {
per_page: 5,
search: 'kurki',
fields: 'suggest',
})
assert.equal('fields' in buildPostListQuery({ search: 'kurki' }), false)
})
test('suggestPosts sends search, fields=suggest and per_page, no sort, with the signal', async () => {
const page = { data: [{ id: 1, title: 'Kurki', slug: 'kurki', excerpt: 'x', featured_image_url: null, published_at: null }], meta: { current_page: 1, last_page: 1, per_page: 5, total: 1 } }
const inits: unknown[] = []
const calls: Call[] = []
const fetcher = async (path: string, query?: Record<string, string | number>, init?: { signal?: AbortSignal }) => {
calls.push({ path, query })
inits.push(init)
return page
}
const signal = new AbortController().signal
const client = createJournalClient(fetcher)
assert.deepEqual(await client.suggestPosts(' kurki ', { signal }), page)
assert.deepEqual(calls[0], { path: '/posts', query: { per_page: 5, search: 'kurki', fields: 'suggest' } })
assert.equal((inits[0] as { signal: AbortSignal }).signal, signal)
await client.suggestPosts('kurki', { perPage: 99 })
assert.equal(calls[1]!.query!.per_page, JOURNAL_MAX_PER_PAGE)
})
test('suggestPosts sends nothing for a term under 3 characters', async () => {
const { fetcher, calls } = fakeFetcher({})
const res = await createJournalClient(fetcher).suggestPosts('ąę')
assert.equal(calls.length, 0)
assert.deepEqual(res.data, [])
assert.equal(res.meta.total, 0)
})

View File

@@ -35,7 +35,7 @@ test('an override replaces one key and keeps the others', () => {
})
test('a site URL map normalizes as given (grzybyfunkcjonalne shape)', () => {
const input = { index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false }
const input = { index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false, search: '/szukaj' }
assert.deepEqual(normalizeJournalRoutes(input), input)
})
@@ -85,9 +85,17 @@ test('route names are stable', () => {
category: 'journal-category',
tag: 'journal-tag',
author: 'journal-author',
search: 'journal-search',
})
})
test('the search route is off by default and takes a plain path', () => {
assert.equal(DEFAULT_JOURNAL_ROUTES.search, false)
assert.equal(normalizeJournalRoutes().search, false)
assert.equal(normalizeJournalRoutes({ search: '/szukaj' }).search, '/szukaj')
assert.throws(() => normalizeJournalRoutes({ search: 'szukaj' }), /journal\.routes\.search/)
})
test('fillJournalPath encodes a value', () => {
assert.equal(fillJournalPath('/blog/:slug', { slug: 'a b' }), '/blog/a%20b')
assert.equal(fillJournalPath('/blog/:slug', { slug: 'zażółć' }), '/blog/za%C5%BC%C3%B3%C5%82%C4%87')

203
tests/search.test.ts Normal file
View File

@@ -0,0 +1,203 @@
// Search helpers: term rules, diacritic-insensitive marking, plural forms,
// listbox keys, and the suggestion controller's debounce, abort and
// stale-answer handling under mocked timers. No network.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
createSuggestController,
markTerms,
moveActive,
pluralForm,
searchTermFromQuery,
searchTermState,
type SuggestState,
} from '../app/utils/search.ts'
const marked = (text: string, q: string) =>
markTerms(text, q)
.map((s) => (s.mark ? `[${s.text}]` : s.text))
.join('')
test('searchTermFromQuery trims, takes the first repeated value and ignores non-strings', () => {
assert.equal(searchTermFromQuery(' borowik '), 'borowik')
assert.equal(searchTermFromQuery(['kurki', 'x']), 'kurki')
assert.equal(searchTermFromQuery(undefined), '')
assert.equal(searchTermFromQuery(null), '')
assert.equal(searchTermFromQuery([null]), '')
assert.equal([...searchTermFromQuery('ż'.repeat(500))].length, 200)
})
test('searchTermState counts characters, not UTF-16 units', () => {
assert.equal(searchTermState(''), 'empty')
assert.equal(searchTermState(' '), 'empty')
assert.equal(searchTermState('ab'), 'short')
assert.equal(searchTermState('żół'), 'ready')
assert.equal(searchTermState('ab', 2), 'ready')
})
test('markTerms ignores case and diacritics', () => {
assert.equal(marked('Sezon na grzybów zbieranie', 'grzybow'), 'Sezon na [grzybów] zbieranie')
assert.equal(marked('Łysiczki i ŁOSIE', 'lysiczki losie'), '[Łysiczki] i [ŁOSIE]')
assert.equal(marked('Kurki', 'KURKI'), '[Kurki]')
assert.equal(marked('Straße', 'strasse'), '[Straße]')
})
test('markTerms marks every occurrence and merges overlaps', () => {
assert.equal(marked('grzyb, grzyby, grzybów', 'grzyb'), '[grzyb], [grzyb]y, [grzyb]ów')
assert.equal(marked('borowik', 'bor borowik'), '[borowik]')
})
test('markTerms returns text segments only and leaves markup as text', () => {
const segs = markTerms('<b>kurki</b> & więcej', 'kurki')
assert.deepEqual(segs, [
{ text: '<b>', mark: false },
{ text: 'kurki', mark: true },
{ text: '</b> & więcej', mark: false },
])
})
test('markTerms skips one-letter words and handles empty input', () => {
assert.deepEqual(markTerms('a b c', 'a'), [{ text: 'a b c', mark: false }])
assert.deepEqual(markTerms('', 'kurki'), [])
assert.deepEqual(markTerms(null, 'kurki'), [])
assert.deepEqual(markTerms('kurki', ''), [{ text: 'kurki', mark: false }])
})
test('pluralForm gives the Polish one/few/many forms', () => {
assert.deepEqual(
[0, 1, 2, 4, 5, 12, 21, 22, 25, 102].map((n) => pluralForm('pl-PL', n)),
['many', 'one', 'few', 'few', 'many', 'many', 'many', 'few', 'many', 'few'],
)
assert.deepEqual([0, 1, 2].map((n) => pluralForm('en-US', n)), ['other', 'one', 'other'])
assert.equal(pluralForm('not a locale!!', 1), 'one')
})
test('moveActive wraps through the options', () => {
assert.equal(moveActive(-1, 3, 'ArrowDown'), 0)
assert.equal(moveActive(2, 3, 'ArrowDown'), 0)
assert.equal(moveActive(-1, 3, 'ArrowUp'), 2)
assert.equal(moveActive(0, 3, 'ArrowUp'), 2)
assert.equal(moveActive(1, 3, 'ArrowUp'), 0)
assert.equal(moveActive(0, 0, 'ArrowDown'), null)
assert.equal(moveActive(0, 3, 'Enter'), null)
})
type Row = { id: number; term: string }
function deferred<T>() {
let resolve!: (v: T) => void
let reject!: (e: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
const flush = () => new Promise<void>((r) => setImmediate(r))
function harness(t: import('node:test').TestContext) {
t.mock.timers.enable({ apis: ['setTimeout'] })
const calls: Array<{ term: string; signal: AbortSignal; answer: ReturnType<typeof deferred<{ data: Row[]; meta: { total: number } }>> }> = []
const states: Array<SuggestState<Row>> = []
const ctl = createSuggestController<Row>({
fetch: (term, signal) => {
const answer = deferred<{ data: Row[]; meta: { total: number } }>()
calls.push({ term, signal, answer })
return answer.promise
},
onChange: (s) => states.push(s),
})
const last = () => states[states.length - 1]!
return { ctl, calls, last, tick: (ms: number) => t.mock.timers.tick(ms) }
}
test('suggest: one request per pause, after the debounce', async (t) => {
const h = harness(t)
h.ctl.input('bor')
h.tick(100)
h.ctl.input('boro')
h.tick(100)
h.ctl.input('borow')
h.tick(249)
assert.equal(h.calls.length, 0)
h.tick(1)
assert.deepEqual(
h.calls.map((c) => c.term),
['borow'],
)
assert.equal(h.last().status, 'loading')
h.calls[0]!.answer.resolve({ data: [{ id: 1, term: 'borow' }], meta: { total: 7 } })
await flush()
assert.equal(h.last().status, 'done')
assert.deepEqual(h.last().rows, [{ id: 1, term: 'borow' }])
assert.equal(h.last().total, 7)
})
test('suggest: short terms send nothing and clear the rows', (t) => {
const h = harness(t)
h.ctl.input('bo')
h.tick(1000)
assert.equal(h.calls.length, 0)
assert.equal(h.last().status, 'idle')
})
test('suggest: a newer term aborts the running request and drops its answer', async (t) => {
const h = harness(t)
h.ctl.input('kurki')
h.tick(250)
h.ctl.input('kurkiw')
assert.equal(h.calls[0]!.signal.aborted, true)
h.tick(250)
h.calls[1]!.answer.resolve({ data: [{ id: 2, term: 'kurkiw' }], meta: { total: 1 } })
await flush()
h.calls[0]!.answer.resolve({ data: [{ id: 1, term: 'kurki' }], meta: { total: 1 } })
await flush()
assert.deepEqual(h.last().rows, [{ id: 2, term: 'kurkiw' }])
assert.equal(h.last().term, 'kurkiw')
})
test('suggest: the spinner shows only after the delay', async (t) => {
const h = harness(t)
h.ctl.input('rydze')
h.tick(250)
h.tick(299)
assert.equal(h.last().loading, false)
h.tick(1)
assert.equal(h.last().loading, true)
h.calls[0]!.answer.resolve({ data: [], meta: { total: 0 } })
await flush()
assert.equal(h.last().loading, false)
assert.equal(h.last().status, 'done')
})
test('suggest: a failed request ends in error without throwing', async (t) => {
const h = harness(t)
h.ctl.input('opieńki')
h.tick(250)
h.calls[0]!.answer.reject(new Error('429'))
await flush()
assert.equal(h.last().status, 'error')
assert.deepEqual(h.last().rows, [])
})
test('suggest: the same term again sends no second request', async (t) => {
const h = harness(t)
h.ctl.input('maślaki')
h.tick(250)
h.ctl.input('maślaki ')
h.tick(1000)
assert.equal(h.calls.length, 1)
})
test('suggest: dispose aborts and ignores the answer', async (t) => {
const h = harness(t)
h.ctl.input('gąski')
h.tick(250)
h.ctl.dispose()
assert.equal(h.calls[0]!.signal.aborted, true)
const before = h.last()
h.calls[0]!.answer.resolve({ data: [{ id: 9, term: 'gąski' }], meta: { total: 1 } })
await flush()
assert.equal(h.last(), before)
})