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:
1 parent
7c6e13a2fe
commit
36297616a5
3 files changed
+59
-29
No files matched your search
@@ -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
@@ -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
@@ -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 => {
|
||||
|
||||
Reference in new issue
Block a user