Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
102da43f76 |
@@ -1,4 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<header class="playground-header">
|
||||||
|
<JournalSearchBox />
|
||||||
|
</header>
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export default defineNuxtConfig({
|
|||||||
// Route-cached like a real host, so testing/purge-check.mjs can prove a
|
// Route-cached like a real host, so testing/purge-check.mjs can prove a
|
||||||
// purge clears cached pages.
|
// purge clears cached pages.
|
||||||
routeRules: { '/**': { swr: 300 } },
|
routeRules: { '/**': { swr: 300 } },
|
||||||
|
// The search page is opt-in; the playground turns it on for the smoke.
|
||||||
|
journal: { routes: { search: '/search' } },
|
||||||
i18n: {
|
i18n: {
|
||||||
strategy: 'prefix_except_default',
|
strategy: 'prefix_except_default',
|
||||||
defaultLocale: 'en',
|
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.
|
- **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`.
|
- **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.
|
- **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.
|
- **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`.
|
- **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`.
|
- **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.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.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.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.index` | `9` | Posts per list page (the API caps at 30) |
|
||||||
| `journal.perPage.category` | `9` | Posts per category page |
|
| `journal.perPage.category` | `9` | Posts per category page |
|
||||||
| `journal.perPage.tag` | `9` | Posts per tag page |
|
| `journal.perPage.tag` | `9` | Posts per tag page |
|
||||||
| `journal.perPage.author` | `9` | Posts per author 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.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.relatedCount` | `3` | Related posts under a post |
|
||||||
| `journal.rss` | `true` | Serve and link `/rss.xml` |
|
| `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.
|
- 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.
|
- 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
|
## Theming
|
||||||
|
|
||||||
Every visual value is a token in `app/assets/css/journal-tokens.css`:
|
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`
|
- 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`
|
- Spacing: `--journal-space-1` to `--journal-space-6`
|
||||||
- Radii: `--journal-radius-sm`, `--journal-radius-md`, `--journal-radius-lg`
|
- 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
|
## i18n
|
||||||
|
|
||||||
@@ -205,7 +234,7 @@ A release is an annotated git tag (`v0.1.0`) on `main`, pushed to git.golem15.co
|
|||||||
|
|
||||||
## Testing
|
## 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).
|
- `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/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)`.
|
- `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
|
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);
|
margin-top: var(--journal-space-4);
|
||||||
font-size: 0.9em;
|
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-link-hover: #0550ae; /* hovered links */
|
||||||
--journal-color-accent: #0969da; /* current page, focus ring */
|
--journal-color-accent: #0969da; /* current page, focus ring */
|
||||||
--journal-color-on-accent: #ffffff; /* text on the accent colour */
|
--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 */
|
/* Type */
|
||||||
--journal-font-body: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; /* body copy */
|
--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 peer = event?.node?.req?.socket?.remoteAddress
|
||||||
const forwarded = recorded || [incoming, peer].filter((v): v is string => typeof v === 'string' && v !== '').join(', ')
|
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 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)
|
return createJournalClient(fetcher)
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = (config.public.journal as { apiBase: string }).apiBase
|
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)
|
return createJournalClient(fetcher)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,7 @@ export function useJournalLinks() {
|
|||||||
tagLink: (slug: string) => link('tag', { tag: slug }),
|
tagLink: (slug: string) => link('tag', { tag: slug }),
|
||||||
authorLink: (slug: string) => link('author', { author: slug }),
|
authorLink: (slug: string) => link('author', { author: slug }),
|
||||||
indexLink: (page: number = 1) => link('index', undefined, page > 1 ? { page: String(page) } : undefined),
|
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,
|
JournalPage,
|
||||||
JournalPost,
|
JournalPost,
|
||||||
JournalPostDetail,
|
JournalPostDetail,
|
||||||
|
JournalPostSuggestion,
|
||||||
JournalTag,
|
JournalTag,
|
||||||
} from './types.ts'
|
} from './types.ts'
|
||||||
|
|
||||||
@@ -18,8 +19,18 @@ export const JOURNAL_MAX_PER_PAGE = 30
|
|||||||
/** The Journal API's per_page default. */
|
/** The Journal API's per_page default. */
|
||||||
export const JOURNAL_DEFAULT_PER_PAGE = 9
|
export const JOURNAL_DEFAULT_PER_PAGE = 9
|
||||||
|
|
||||||
/** Issues one GET for `path` (e.g. "/posts") with `query`, resolving the parsed JSON. */
|
/** Search terms shorter than this are ignored by the API, so the layer never sends them. */
|
||||||
export type JournalFetcher = (path: string, query?: Record<string, string | number>) => Promise<unknown>
|
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 {
|
export interface PostListOptions {
|
||||||
page?: number
|
page?: number
|
||||||
@@ -30,6 +41,13 @@ export interface PostListOptions {
|
|||||||
category?: string
|
category?: string
|
||||||
tag?: string
|
tag?: string
|
||||||
search?: string
|
search?: string
|
||||||
|
/** "suggest" asks for JournalPostSuggestion rows instead of full posts. */
|
||||||
|
fields?: 'suggest'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuggestOptions {
|
||||||
|
perPage?: number
|
||||||
|
signal?: AbortSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthorPostListOptions {
|
export interface AuthorPostListOptions {
|
||||||
@@ -56,6 +74,7 @@ export function buildPostListQuery(opts: PostListOptions = {}): Record<string, s
|
|||||||
if (opts.category) q.category = opts.category
|
if (opts.category) q.category = opts.category
|
||||||
if (opts.tag) q.tag = opts.tag
|
if (opts.tag) q.tag = opts.tag
|
||||||
if (opts.search) q.search = opts.search
|
if (opts.search) q.search = opts.search
|
||||||
|
if (opts.fields) q.fields = opts.fields
|
||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +109,11 @@ export function isNotFound(err: unknown): boolean {
|
|||||||
export interface JournalClient {
|
export interface JournalClient {
|
||||||
getPost(slug: string): Promise<JournalPostDetail | null>
|
getPost(slug: string): Promise<JournalPostDetail | null>
|
||||||
listPosts(opts?: PostListOptions): Promise<JournalPage<JournalPost>>
|
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[]>
|
listCategories(): Promise<JournalCategory[]>
|
||||||
findCategory(slug: string): Promise<JournalCategory | null>
|
findCategory(slug: string): Promise<JournalCategory | null>
|
||||||
listTags(): Promise<JournalTag[]>
|
listTags(): Promise<JournalTag[]>
|
||||||
@@ -123,6 +147,17 @@ export function createJournalClient(fetcher: JournalFetcher): JournalClient {
|
|||||||
return (await fetcher('/posts', buildPostListQuery(opts))) as JournalPage<JournalPost>
|
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,
|
listCategories,
|
||||||
|
|
||||||
async findCategory(slug) {
|
async findCategory(slug) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const DEFAULT_JOURNAL_ROUTES: Readonly<JournalRoutes> = Object.freeze({
|
|||||||
category: '/blog/category/:category',
|
category: '/blog/category/:category',
|
||||||
tag: '/blog/tag/:tag',
|
tag: '/blog/tag/:tag',
|
||||||
author: '/blog/author/:author',
|
author: '/blog/author/:author',
|
||||||
|
search: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Route names stay stable whatever the paths are, so links never hard-code a path. */
|
/** 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',
|
category: 'journal-category',
|
||||||
tag: 'journal-tag',
|
tag: 'journal-tag',
|
||||||
author: 'journal-author',
|
author: 'journal-author',
|
||||||
|
search: 'journal-search',
|
||||||
})
|
})
|
||||||
|
|
||||||
/** The route param each path must carry. */
|
/** The route param each path must carry. */
|
||||||
@@ -29,6 +31,7 @@ const REQUIRED_PARAM: Readonly<Record<keyof JournalRoutes, string | null>> = {
|
|||||||
category: 'category',
|
category: 'category',
|
||||||
tag: 'tag',
|
tag: 'tag',
|
||||||
author: 'author',
|
author: 'author',
|
||||||
|
search: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROUTE_KEYS = Object.keys(DEFAULT_JOURNAL_ROUTES) as Array<keyof JournalRoutes>
|
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
|
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}. */
|
/** previous_post / next_post on GET /posts/{slug}. */
|
||||||
export interface JournalPostSibling {
|
export interface JournalPostSibling {
|
||||||
id: number
|
id: number
|
||||||
@@ -121,6 +134,8 @@ export interface JournalRoutes {
|
|||||||
category: string | false
|
category: string | false
|
||||||
tag: string | false
|
tag: string | false
|
||||||
author: string | false
|
author: string | false
|
||||||
|
/** Search results page; the term is the `q` query parameter. Off by default. */
|
||||||
|
search: string | false
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JournalPerPage {
|
export interface JournalPerPage {
|
||||||
@@ -128,6 +143,7 @@ export interface JournalPerPage {
|
|||||||
category: number
|
category: number
|
||||||
tag: number
|
tag: number
|
||||||
author: number
|
author: number
|
||||||
|
search: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Options of the `journal` config key in nuxt.config. */
|
/** Options of the `journal` config key in nuxt.config. */
|
||||||
|
|||||||
@@ -33,6 +33,28 @@
|
|||||||
"readMore": "Read more",
|
"readMore": "Read more",
|
||||||
"rss": {
|
"rss": {
|
||||||
"title": "RSS feed"
|
"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",
|
"readMore": "Czytaj dalej",
|
||||||
"rss": {
|
"rss": {
|
||||||
"title": "Kanał 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',
|
category: 'CategoryPage',
|
||||||
tag: 'TagPage',
|
tag: 'TagPage',
|
||||||
author: 'AuthorPage',
|
author: 'AuthorPage',
|
||||||
|
search: 'SearchPage',
|
||||||
}
|
}
|
||||||
|
|
||||||
const pagesDir = fileURLToPath(new URL('../app/journal-pages/', import.meta.url))
|
const pagesDir = fileURLToPath(new URL('../app/journal-pages/', import.meta.url))
|
||||||
@@ -39,7 +40,7 @@ export default defineNuxtModule<JournalModuleOptions>({
|
|||||||
},
|
},
|
||||||
defaults: {
|
defaults: {
|
||||||
routes: { ...DEFAULT_JOURNAL_ROUTES },
|
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',
|
sort: 'published_at desc',
|
||||||
relatedCount: 3,
|
relatedCount: 3,
|
||||||
rss: true,
|
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
|
// Register the layer's strings only for locales the app enables. The
|
||||||
// layer config declares no locales: @nuxtjs/i18n merges every locale a
|
// 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
|
// layer declares into the app, which would add /en routes to an app that
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vue-journal-layer",
|
"name": "vue-journal-layer",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./nuxt.config.ts",
|
"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?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=og:title
|
||||||
200 /blog/hello-acme contains=Acme says hello and introduces its blog.
|
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
|
// A small node:http stand-in for the anonymous Journal API
|
||||||
// (/_journal/api/v1), answering from JSON fixtures. It follows the Go
|
// (/_journal/api/v1), answering from JSON fixtures. It follows the Go
|
||||||
// handlers' query rules (page, per_page clamp, sort allow-list, category with
|
// 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.
|
// records every request so a smoke run can assert header forwarding.
|
||||||
//
|
//
|
||||||
// Plain .mjs with JSDoc types: Node does not strip TypeScript under
|
// 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 */
|
/** @param {any} post */
|
||||||
function sibling(post) {
|
function sibling(post) {
|
||||||
return post ? { id: post.id, title: post.title, slug: post.slug } : null
|
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 = rows.filter((p) => p.title.toLowerCase().includes(s))
|
||||||
}
|
}
|
||||||
rows = sortRows(rows, parseSort(q.get('sort')))
|
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.getAuthor('nobody'), null)
|
||||||
assert.equal(await client.listAuthorPosts('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)', () => {
|
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)
|
assert.deepEqual(normalizeJournalRoutes(input), input)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -85,9 +85,17 @@ test('route names are stable', () => {
|
|||||||
category: 'journal-category',
|
category: 'journal-category',
|
||||||
tag: 'journal-tag',
|
tag: 'journal-tag',
|
||||||
author: 'journal-author',
|
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', () => {
|
test('fillJournalPath encodes a value', () => {
|
||||||
assert.equal(fillJournalPath('/blog/:slug', { slug: 'a b' }), '/blog/a%20b')
|
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')
|
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