Paginate search results (AI)

router.js: recognises a trailing /page/N on a #search/... hash,
falling back to treating the whole remainder as the query for
hand-typed/legacy links (the app itself always encodes the query as
one segment, so this is unambiguous for real usage).

views.js: renderArticleListBody() is generalized to take an explicit
heading/emptyKey/loadingKey instead of assuming the article-list's
own copy, so loadSearch() can reuse it directly with a
{type:'search', value:query} pseudo-filter driving the same
pageHash()-based pagination links as author/category/tag views.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
This commit is contained in:
biosiasandClaude Sonnet 5 committed 2026-09-11 13:51:52 +02:00
1 parent 7c6e13a2fe
commit 36297616a5
3 files changed
+59 -29

No files matched your search

+6 -3
View File
@@ -19,7 +19,7 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil
## Features
- **Dark mode** — automatically follows the OS `prefers-color-scheme` setting; no toggle needed.
- **Client-side search** — the search box in the header filters all articles by title, description, author, category, and tags in-memory. Navigates to `#search/query` so results are bookmarkable.
- **Client-side search** — the search box in the header filters all articles by title, description, author, category, and tags in-memory. Navigates to `#search/query` so results are bookmarkable; results paginate the same way the article list does.
- **Reading time** — estimated from the article word count (200 wpm) and shown on list cards and in article headers. Computed automatically; no frontmatter field needed.
- **Table of contents** — auto-generated from `h2`/`h3` headings and inserted at the top of the article body. Only appears when the article has three or more headings.
- **Back link** — a "← Back to articles" link above every article for one-click navigation back to the list.
@@ -191,7 +191,8 @@ The body text below the frontmatter is rendered as markdown and shown as the bio
|----------|------|
| *(empty)* | All articles, page 1 |
| `#page/2` | All articles, page 2 |
| `#search/linux` | Search results for "linux" |
| `#search/linux` | Search results for "linux", page 1 |
| `#search/linux/page/2` | Same, page 2 |
| `#author/alice` | All articles by alice, page 1 |
| `#author/alice/page/2` | All articles by alice, page 2 |
| `#category/expa-2026` | All articles carrying the "expa-2026" year tag, page 1 |
@@ -393,7 +394,9 @@ articles it has — only ~20 articles' worth of content is ever fetched per page
- **Progressive search** — search hydrates every indexed article (see above — it needs real
text, unlike the list view), but does so incrementally rather than in one blocking batch:
matches appear and the result list grows as hydration resolves, with a "still indexing…" note
until every article has been checked.
until every article has been checked. Results paginate at the same `PAGE_SIZE` as the article
list, and the page count can shift while hydration is still filling in matches, settling once
it's done.
- **Crawl health** — if every author's directory listing fails outright, or very few of the
known post directories loaded, a small warning banner is shown above the article list instead
of silently rendering an empty page (see `getCrawlStats()` / `renderCrawlHealthWarning()` in
+12 -3
View File
@@ -3,6 +3,7 @@
// Hash routing scheme:
// 0 segments → all articles, page 1
// first = "search" → search results (#search/query)
// first = "search", ends "/page/N" → search results, page N (#search/query/page/2)
// 2 segments, first = "page" → all articles, page N (#page/2)
// 2 segments, first = "author" → author filter, page 1 (#author/alice)
// 2 segments, first = "category" or "tag" → filtered list, page 1
@@ -34,10 +35,18 @@ function router() {
const hash = location.hash.slice(1); // strip leading #
const parts = hash.split('/').filter(Boolean);
// Search: decode query and guard against empty string (#search or #search/)
// Search: decode query and guard against empty string (#search or #search/).
// A trailing "/page/N" is peeled off for pagination first — the query
// itself is always a single encodeURIComponent()'d segment when built
// by this app (see the search-form handler below), so this only
// matters for hand-typed/legacy multi-segment links, where joining the
// rest back with "/" keeps a literal slash in the query working too.
if (parts[0] === 'search') {
const q = decodeURIComponent(parts.slice(1).join('/'));
if (q) loadSearch(q); else loadArticleList();
const rest = parts.slice(1);
const hasPage = rest.length >= 3 && rest[rest.length - 2] === 'page' && /^\d+$/.test(rest[rest.length - 1]);
const queryParts = hasPage ? rest.slice(0, -2) : rest;
const q = decodeURIComponent(queryParts.join('/'));
if (q) loadSearch(q, hasPage ? parsePage(rest[rest.length - 1]) : 1); else loadArticleList();
}
else if (parts.length === 0) loadArticleList();
else if (parts.length === 2 && parts[0] === 'page') loadArticleList(null, parsePage(parts[1]));
+41 -23
View File
@@ -208,25 +208,29 @@ function renderCrawlHealthWarning(view, done) {
view.appendChild(warning);
}
// Renders one snapshot of the current page into `view` — shared by the live
// progress updates and the final render, so a large site's first page shows
// up as soon as it's indexed instead of blocking on the whole tree (see
// onArticlesProgress() in discovery.js). `pageArticles` must already be
// hydrated (see loadArticleList) — this function is purely synchronous
// DOM-building, no fetching of its own.
function renderArticleListBody(view, { total, pageArticles, page, pages, filter, done }) {
// Renders one snapshot of a paginated article page into `view` — shared by
// the article list (live progress updates and the final render, so a large
// site's first page shows up as soon as it's indexed instead of blocking on
// the whole tree — see onArticlesProgress() in discovery.js) and search
// (see loadSearch). `pageArticles` must already be hydrated — this function
// is purely synchronous DOM-building, no fetching of its own. `heading`,
// `emptyKey`/`loadingKey` let each caller supply its own copy; `filter`
// still drives pagination links (buildPaginationNav) even when it's not
// one of the sidebar's own filter types (search uses `{type: 'search',
// value: query}`, matched by pageHash() the same way as any other filter).
function renderArticleListBody(view, { total, pageArticles, page, pages, filter, done, heading, emptyKey = 'emptyArticleList', loadingKey = 'loadingArticles' }) {
view.innerHTML = '';
renderCrawlHealthWarning(view, done);
if (filter) {
const heading = document.createElement('h2');
heading.className = 'category-heading';
heading.textContent = filterLabel(filter);
view.appendChild(heading);
if (heading) {
const headingElement = document.createElement('h2');
headingElement.className = 'category-heading';
headingElement.textContent = heading;
view.appendChild(headingElement);
}
if (!total) {
const message = document.createElement('p');
message.className = done ? 'empty' : 'loading';
message.textContent = done ? t('emptyArticleList') : t('loadingArticles');
message.textContent = done ? t(emptyKey) : t(loadingKey);
view.appendChild(message);
return;
}
@@ -275,7 +279,7 @@ export async function loadArticleList(filter = null, page = 1) {
const pageStubs = articles.slice((clampedPage - 1) * PAGE_SIZE, clampedPage * PAGE_SIZE);
const pageArticles = pageStubs.length ? await hydrateArticles(pageStubs) : [];
if (generation !== currentGeneration || myToken !== renderToken) return;
renderArticleListBody(view, { total: articles.length, pageArticles, page: clampedPage, pages, filter, done });
renderArticleListBody(view, { total: articles.length, pageArticles, page: clampedPage, pages, filter, done, heading: filter ? filterLabel(filter) : undefined });
// Once the list is fully indexed (not mid-background-crawl, where
// page boundaries could still shift), warm the cache for the next
@@ -346,7 +350,7 @@ export async function loadAuthorView(author, page = 1) {
export const loadCategoryView = (category, page = 1) => loadArticleList({ type: 'category', value: category }, page);
export const loadTagView = (tag, page = 1) => loadArticleList({ type: 'tag', value: tag }, page);
export async function loadSearch(query) {
export async function loadSearch(query, page = 1) {
showListView();
window.scrollTo(0, 0);
const generation = ++currentGeneration;
@@ -354,6 +358,10 @@ export async function loadSearch(query) {
view.innerHTML = `<p class="loading">${t('searching')}</p>`;
const input = document.getElementById('search-input');
if (input) input.value = query;
// Drives pagination links the same way any other filter does (see
// pageHash()) — `#search/<query>` for page 1, `#search/<query>/page/N`
// after, which router.js recognises and parses back into (query, page).
const filter = { type: 'search', value: query };
try {
await ensureTags();
const needle = query.toLocaleLowerCase(getLocale());
@@ -372,19 +380,29 @@ export async function loadSearch(query) {
// they're found instead of one long wait for the whole site's
// content — hydrateArticle() mutates stubs in place, so checking
// each stub's own `hydrated` flag is enough to know what's ready,
// without tracking a separately-growing list.
// without tracking a separately-growing list. Pagination applies to
// whatever's matched so far, the same page math as the list view —
// the page count can shift while hydration is still in progress,
// settling once it's done.
const renderResults = () => {
if (generation !== currentGeneration) return;
const hydratedSoFar = stubs.filter(stub => stub.hydrated);
const done = hydratedSoFar.length === stubs.length;
const matches = hydratedSoFar.filter(matchesQuery);
view.innerHTML = `<h2 class="category-heading">${t('searchHeading', { query })}</h2>`;
if (matches.length) {
matches.forEach(article => view.appendChild(renderCard(article)));
if (!done) view.insertAdjacentHTML('beforeend', `<p class="loading">${t('indexingBackground')}</p>`);
} else {
view.insertAdjacentHTML('beforeend', done ? `<p class="empty">${t('searchNoResults')}</p>` : `<p class="loading">${t('searching')}</p>`);
}
const pages = Math.max(1, Math.ceil(matches.length / PAGE_SIZE));
const clampedPage = Math.max(1, Math.min(page, pages));
const pageMatches = matches.slice((clampedPage - 1) * PAGE_SIZE, clampedPage * PAGE_SIZE);
renderArticleListBody(view, {
total: matches.length,
pageArticles: pageMatches,
page: clampedPage,
pages,
filter,
done,
heading: t('searchHeading', { query }),
emptyKey: 'searchNoResults',
loadingKey: 'searching',
});
};
await hydrateArticles(stubs, resolvedCount => {