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:
@@ -1,4 +1,7 @@
|
||||
<template>
|
||||
<header class="playground-header">
|
||||
<JournalSearchBox />
|
||||
</header>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
|
||||
@@ -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',
|
||||
|
||||
39
README.md
39
README.md
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vue-journal-layer",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./nuxt.config.ts",
|
||||
|
||||
@@ -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: <script>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"
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
203
tests/search.test.ts
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user