Defer article content fetches to page-scoped hydration (AI)
Splits discovery into a cheap indexing phase (buildStub — directory listing only: .tag/.date markers and a new `draft` marker file, no markdown fetch) and a separate hydrateArticle() step that only runs for articles actually being rendered: the current page's cards, a visited article, or a search (which needs real text, so it hydrates everything it's searching). Sorting, filtering, pagination, and the sidebar counts all work from stubs alone. `draft` (an empty marker file, same pattern as .tag/.date) replaces the frontmatter draft:/status: check entirely, since that also needs to be knowable without a content fetch. Bumps CACHE_SCHEMA_VERSION since the cached article shape changed (added root/hydrated fields). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
This commit is contained in:
1 parent
60cf355352
commit
81fb2a2414
4 files changed
+192
-90
No files matched your search
@@ -5,10 +5,16 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil
|
||||
## How it works
|
||||
|
||||
1. The browser traverses `cmjs/articles/` via a server directory index — discovering authors and their post directories (`author/slug/slug.md`); nginx JSON autoindex is preferred, with HTML autoindexes supported as a fallback.
|
||||
2. Each `.md` file's YAML frontmatter is parsed for title/description/image; publish date and tags/category come from empty `.date`/`.tag` marker files beside it (see "Adding an article" below).
|
||||
2. Sorting, filtering, and publish status all come from empty marker files beside each article
|
||||
(`.date`, `.tag`, `draft`) — knowable from the directory listing alone. A `.md` file's actual
|
||||
content (title, description, image) is only fetched once that article is about to be shown —
|
||||
the current page's cards, a single article, or a search — not for the whole site up front.
|
||||
See "Adding an article" and "Article discovery & caching" below.
|
||||
3. Clicking an article fetches the Markdown file and renders it with [marked.js](https://marked.js.org/).
|
||||
4. Hash-based routing handles navigation — back/forward works without page reloads.
|
||||
5. Discovered articles are cached in IndexedDB, keyed by post directory and invalidated by directory `mtime`, so repeat visits skip re-fetching unchanged content — see "Article discovery & caching" below.
|
||||
5. Once an article's content has been fetched, it's cached in IndexedDB, keyed by post directory
|
||||
and invalidated by directory `mtime`, so repeat visits skip re-fetching unchanged content —
|
||||
see "Article discovery & caching" below.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -74,7 +80,8 @@ cmjs/
|
||||
├── slug.md # Article content + frontmatter
|
||||
├── cover.jpg # Optional — referenced by slug.md's `image:` field
|
||||
├── tag-id.tag # Zero or more empty tag/category markers
|
||||
└── DD-MM-YYYY.date # Publish date marker (exactly one)
|
||||
├── DD-MM-YYYY.date # Publish date marker (exactly one)
|
||||
└── draft # Optional — presence hides the article from the list
|
||||
```
|
||||
|
||||
## Adding an article
|
||||
@@ -92,11 +99,18 @@ cmjs/
|
||||
5. Optionally add a cover image beside it too — `cmjs/articles/author/my-article/photo.jpg` —
|
||||
and reference it from frontmatter with `image: photo.jpg`. Images live next to the article
|
||||
that uses them, not in a shared folder.
|
||||
6. Add a frontmatter block at the top of the Markdown file
|
||||
7. Write your content — it will appear in the list automatically
|
||||
6. To keep it out of the published list for now, add an empty file named exactly
|
||||
`cmjs/articles/author/my-article/draft` (no extension) — remove it (or rename it) when the
|
||||
article is ready to publish. This is the sole source of draft/publish status; a `draft:`
|
||||
frontmatter field is no longer read.
|
||||
7. Add a frontmatter block at the top of the Markdown file
|
||||
8. Write your content — it will appear in the list automatically
|
||||
|
||||
`author` is derived from the directory path — no frontmatter field needed for it. Publish date,
|
||||
tags, and category are derived from the `.date`/`.tag` marker files, not frontmatter.
|
||||
tags, category, and draft status are all derived from marker files, not frontmatter — this
|
||||
keeps every one of them knowable from a directory listing alone, without fetching an article's
|
||||
content, which is what makes the page-scoped loading described in "Article discovery & caching"
|
||||
below possible.
|
||||
|
||||
### Tag labels
|
||||
|
||||
@@ -122,7 +136,6 @@ they're recognised automatically by pattern and don't use this label registry.
|
||||
---
|
||||
title: My Article Title
|
||||
description: A short summary shown in the article list.
|
||||
draft: false
|
||||
---
|
||||
|
||||
Your markdown content here...
|
||||
@@ -133,11 +146,10 @@ Your markdown content here...
|
||||
| `title` | yes | Shown in the list and article header |
|
||||
| `description` | no | Short summary shown under the title in the list |
|
||||
| `image` | no | Filename of an image file placed in this same post directory (e.g. `photo.jpg`); shown as card thumbnail and article hero |
|
||||
| `draft` | no | Set to `true`, `yes`, or `1` to hide from the list |
|
||||
|
||||
Reading time is computed automatically from the article word count — no frontmatter field
|
||||
needed. `date` and `tags` frontmatter fields, if present, are ignored — superseded by the
|
||||
`.date`/`.tag` marker files above.
|
||||
needed. `date`, `tags`, and `draft` frontmatter fields, if present, are ignored — superseded by
|
||||
the `.date`/`.tag`/`draft` marker files described above.
|
||||
|
||||
## Author profiles
|
||||
|
||||
@@ -336,31 +348,40 @@ server {
|
||||
|
||||
## Article discovery & caching
|
||||
|
||||
Articles are discovered by crawling `cmjs/articles/` client-side: fetch the author
|
||||
directories, then each author's post directories along with their `mtime`, then — only for
|
||||
directories whose `mtime` has changed since the last visit — that directory's own listing (to
|
||||
read its `.tag`/`.date` markers) and its Markdown content.
|
||||
Discovery happens in two phases. **Indexing** crawls `cmjs/articles/` client-side to build a
|
||||
lightweight "stub" for every article — author directories, then each author's post directories
|
||||
along with their `mtime`, then — only for directories whose `mtime` has changed since the last
|
||||
visit — that directory's own listing, read for its `.tag`/`.date`/`draft` markers. This alone is
|
||||
enough to sort, filter, paginate, and populate the sidebar's tag/year counts, without ever
|
||||
fetching an article's actual Markdown. **Hydration** is the separate, deferred step that fetches
|
||||
a specific article's content (title, description, cover image, reading time) — it only ever runs
|
||||
for articles about to be rendered: the current page's cards (`hydrateArticles()` in
|
||||
`cmjs/js/discovery.js`), a single visited article, or (since full-text search needs real content)
|
||||
every result of a search. A large site's list is therefore fast to show regardless of how many
|
||||
articles it has — only ~20 articles' worth of content is ever fetched per page view.
|
||||
|
||||
- **Cache** — results are cached per post directory in IndexedDB (`cmjs/js/cache.js`), keyed
|
||||
by directory path and invalidated by comparing the live `mtime` against the cached one. If
|
||||
IndexedDB is unavailable (private browsing, disabled storage), the crawler transparently
|
||||
falls back to fetching everything live on every visit.
|
||||
- **Cache** — once an article is hydrated, the result is cached per post directory in IndexedDB
|
||||
(`cmjs/js/cache.js`), keyed by directory path and invalidated by comparing the live `mtime`
|
||||
against the cached one — a stub whose directory `mtime` hasn't changed reuses the cached,
|
||||
already-hydrated result directly, skipping both the listing and content fetch. If IndexedDB is
|
||||
unavailable (private browsing, disabled storage), discovery transparently falls back to
|
||||
fetching everything live on every visit.
|
||||
- **Cache schema version** — the cached records store the full derived article shape, not raw
|
||||
content, so a code change to how articles are derived (in `cmjs/js/discovery.js`) can make old
|
||||
cached entries stale in *structure*, not just content — and a directory's `mtime` won't catch
|
||||
that. Bump `CACHE_SCHEMA_VERSION` in `cmjs/js/cache.js` whenever that shape changes; it's baked
|
||||
into the IndexedDB database name, so a bump invalidates every cached entry on the next deploy.
|
||||
- **Concurrency** — `MAX_CONCURRENT_REQUESTS` in `cmjs/js/discovery.js` (default `10`) caps how
|
||||
many post directories are crawled at once; raise it for a fast/local nginx, lower it for a
|
||||
slow or shared/production instance.
|
||||
- **Progressive rendering** — on a large site, the article list doesn't wait for the entire
|
||||
tree to finish crawling before showing anything: `discoverArticles()` streams incremental
|
||||
many requests (indexing listings or hydration content fetches) run at once; raise it for a
|
||||
fast/local nginx, lower it for a slow or shared/production instance.
|
||||
- **Progressive indexing** — on a large site, the article list doesn't wait for the entire tree
|
||||
to finish indexing before showing anything: `discoverArticles()` streams incremental stub
|
||||
results via `onArticlesProgress()`, so the list (and pagination, and the sidebar tag/year
|
||||
counts) render as soon as the first articles are indexed and keep filling in while the rest
|
||||
crawl in the background — with a small "still indexing…" note shown until it's done. The
|
||||
crawl also visits directories with the most recent `mtime` first (a best-effort heuristic, not
|
||||
the final display order — that's always the authoritative `.date` marker), so the first
|
||||
results tend to already be close to correct.
|
||||
index in the background — with a small "still indexing…" note shown until it's done. Indexing
|
||||
also visits directories with the most recent `mtime` first (a best-effort heuristic, not the
|
||||
final display order — that's always the authoritative `.date` marker), so the first results
|
||||
tend to already be close to correct.
|
||||
- **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
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
// cached entry immediately, instead of stale-shaped records lingering until
|
||||
// each directory's mtime happens to change — which, on a "deploy and
|
||||
// forget" site, could be years.
|
||||
const CACHE_SCHEMA_VERSION = 1;
|
||||
const CACHE_SCHEMA_VERSION = 2;
|
||||
const DB_NAME = `cmjs-cache-v${CACHE_SCHEMA_VERSION}`;
|
||||
const STORE_NAME = 'slugDirs';
|
||||
|
||||
|
||||
+94
-43
@@ -4,9 +4,12 @@ import { mapWithConcurrency } from './concurrency.js';
|
||||
import { getCacheEntry, setCacheEntry, pruneCache } from './cache.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
// Caps simultaneous per-article fetches (see the "Wave 3+4" step below) so a
|
||||
// large site doesn't burst hundreds of requests at once. Raise it for a
|
||||
// fast/local nginx, lower it for a slow or shared/production instance — see
|
||||
// Caps simultaneous per-article requests — both the cheap per-directory
|
||||
// listing fetch every article needs at index time (see "Wave 3" below) and
|
||||
// the markdown content fetch, which only ever runs for articles actually
|
||||
// being rendered (see hydrateArticle()) — so a large site doesn't burst
|
||||
// hundreds of requests at once. Raise it for a fast/local nginx, lower it
|
||||
// for a slow or shared/production instance — see
|
||||
// .claude/nginx-sitemap-crawler-plan.md §2a for the full rationale.
|
||||
const MAX_CONCURRENT_REQUESTS = 10;
|
||||
|
||||
@@ -137,11 +140,6 @@ export function getCrawlStats() {
|
||||
return { ...crawlStats };
|
||||
}
|
||||
|
||||
function isPublished(meta) {
|
||||
if (meta.status != null) return ['published', 'publish'].includes(String(meta.status).toLowerCase());
|
||||
return !['true', 'yes', '1'].includes(String(meta.draft ?? '').toLowerCase().trim());
|
||||
}
|
||||
|
||||
function excerpt(content) {
|
||||
const text = String(content || '')
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
||||
@@ -155,9 +153,8 @@ function excerpt(content) {
|
||||
}
|
||||
|
||||
// A single `<DD-MM-YYYY>.date` marker file is the sole authoritative source
|
||||
// of an article's publish date; a missing/ambiguous one falls back to the
|
||||
// (deprecated) frontmatter `date:` field so older, unmigrated content still
|
||||
// works, resolved by the caller.
|
||||
// of an article's publish date — no frontmatter fallback, so this is always
|
||||
// resolvable from a directory listing alone, without fetching content.
|
||||
function parseDateFromFiles(files) {
|
||||
const matches = files.map(file => file.name.match(DATE_FILE_PATTERN)).filter(Boolean);
|
||||
if (matches.length !== 1) return null;
|
||||
@@ -165,11 +162,14 @@ function parseDateFromFiles(files) {
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Fetches a slug directory's own listing, resolves its markdown file and
|
||||
// `.tag` marker files, and builds the full article record. Returns null on
|
||||
// any failure (missing/unreadable markdown, network error) — the caller
|
||||
// treats that as a silent skip, matching the rest of this module's approach.
|
||||
async function buildArticle(dir) {
|
||||
// Everything an article card/filter/sort needs that's derivable from its
|
||||
// slug directory's own *listing* alone — no markdown fetch. This is what
|
||||
// runs for every article at index time; the (expensive) content fetch is
|
||||
// deferred to hydrateArticle(), which only ever runs for articles actually
|
||||
// being rendered. Returns null on any failure (missing markdown file,
|
||||
// network error) — the caller treats that as a silent skip, matching the
|
||||
// rest of this module's approach.
|
||||
async function buildStub(dir) {
|
||||
try {
|
||||
const entries = await fetchJson(dir.root);
|
||||
const files = entries.filter(entry => entry.type === 'file');
|
||||
@@ -179,37 +179,89 @@ async function buildArticle(dir) {
|
||||
|| mdFiles.find(entry => entry.name === 'index.md')
|
||||
|| (mdFiles.length === 1 ? mdFiles[0] : null);
|
||||
if (!mdFile) return null;
|
||||
|
||||
const path = `${dir.root}${encodeURIComponent(mdFile.name)}`;
|
||||
const response = await fetch(path);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const raw = await response.text();
|
||||
const { meta, content } = parseFrontmatter(raw);
|
||||
const words = content.trim().split(/\s+/).filter(Boolean).length;
|
||||
// "Category" isn't a separate concept anymore — it's just whichever
|
||||
// tag id (if any) looks like an expedition year.
|
||||
const category = tagIds.find(id => /^expa-20\d{2}$/i.test(id)) || null;
|
||||
|
||||
return {
|
||||
...meta,
|
||||
author: dir.author,
|
||||
slug: dir.slug,
|
||||
hash: `${dir.author}/${dir.slug}`,
|
||||
root: dir.root,
|
||||
mtime: dir.mtime,
|
||||
path: `${dir.root}${encodeURIComponent(mdFile.name)}`,
|
||||
category,
|
||||
expa: category ? Number(category.replace(/^expa-/i, '')) : null,
|
||||
slug: dir.slug,
|
||||
path,
|
||||
hash: `${dir.author}/${dir.slug}`,
|
||||
date: parseDateFromFiles(files) || meta.date || null,
|
||||
readingTime: Math.max(1, Math.round(words / 200)),
|
||||
description: meta.description || excerpt(content),
|
||||
date: parseDateFromFiles(files),
|
||||
tags: tagIds,
|
||||
coverUrl: resolveMediaUrl(meta.cover || meta.image, path),
|
||||
published: isPublished(meta),
|
||||
// A `draft` marker file (no extension, exact name) is the sole
|
||||
// source of publish status — like `.tag`/`.date`, this needs to
|
||||
// be knowable from the listing alone, not from a content fetch.
|
||||
published: !files.some(entry => entry.name === 'draft'),
|
||||
hydrated: false,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache-aware: a directory whose mtime matches its cached (already-hydrated)
|
||||
// entry is reused directly, skipping the listing fetch entirely; otherwise
|
||||
// builds a fresh, unhydrated stub from the listing alone.
|
||||
async function resolveStub(dir) {
|
||||
const cached = await getCacheEntry(dir.root);
|
||||
if (cached && dir.mtime != null && cached.mtime === dir.mtime) return cached.article;
|
||||
return buildStub(dir);
|
||||
}
|
||||
|
||||
// Fields only derivable from an article's actual markdown content (not its
|
||||
// directory listing) — shared by hydrateArticle() (page-scoped list cards)
|
||||
// and loadArticle() in views.js (which always fetches its own content
|
||||
// directly, regardless of what's been hydrated for list rendering).
|
||||
export function deriveContentFields(meta, content, path) {
|
||||
const words = content.trim().split(/\s+/).filter(Boolean).length;
|
||||
return {
|
||||
readingTime: Math.max(1, Math.round(words / 200)),
|
||||
description: meta.description || excerpt(content),
|
||||
coverUrl: resolveMediaUrl(meta.cover || meta.image, path),
|
||||
};
|
||||
}
|
||||
|
||||
// Fetches and parses a stub's markdown content (title, description, cover
|
||||
// image, reading time) — the one fetch this module defers until an article
|
||||
// is actually about to be rendered (a visible list card, or the article
|
||||
// itself), rather than for every article at index time. Mutates `stub` in
|
||||
// place (rather than returning a new object) so every other reference to
|
||||
// this same article — the shared discoverArticles() list, other pending
|
||||
// hydrateArticle() calls for it — sees the update too, and a later
|
||||
// re-render of the same page doesn't re-fetch it. No-ops if already
|
||||
// hydrated. Leaves the stub as-is (title-less but still linkable/sortable)
|
||||
// on any failure, matching this module's silent-skip approach elsewhere.
|
||||
export async function hydrateArticle(stub) {
|
||||
if (stub.hydrated) return stub;
|
||||
try {
|
||||
const response = await fetch(stub.path);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const raw = await response.text();
|
||||
const { meta, content } = parseFrontmatter(raw);
|
||||
// Snapshot stub's own fields *before* mutating, then reapply them
|
||||
// after merging in meta — so a stray same-named frontmatter field
|
||||
// (a leftover `tags:`/`date:`) can never override the authoritative
|
||||
// marker-derived values. (stub can't be used as its own "source of
|
||||
// truth" mid-Object.assign — by the time that argument is read it'd
|
||||
// already reflect this same mutation.)
|
||||
const authoritative = { ...stub };
|
||||
Object.assign(stub, meta, authoritative, deriveContentFields(meta, content, stub.path), { hydrated: true });
|
||||
await setCacheEntry(stub.root, { mtime: stub.mtime, article: stub });
|
||||
} catch { /* leave stub unhydrated — retried next time it's rendered */ }
|
||||
return stub;
|
||||
}
|
||||
|
||||
// Concurrency-limited batch hydration for a page's worth of stubs.
|
||||
export function hydrateArticles(stubs) {
|
||||
return mapWithConcurrency(stubs, MAX_CONCURRENT_REQUESTS, hydrateArticle);
|
||||
}
|
||||
|
||||
// Best-effort recency signal used only to decide crawl *order* (see below) —
|
||||
// never for display order, which always uses the authoritative `date`
|
||||
// marker once the crawl finishes. `Date.parse` handles both the RFC-1123
|
||||
@@ -234,7 +286,7 @@ async function runCrawl() {
|
||||
// One author's listing failing (network blip, deleted mid-crawl) is
|
||||
// silently skipped rather than failing every other author's list too —
|
||||
// consistent with this module's usual per-item failure handling (see
|
||||
// buildArticle) — but is still counted, so a *total* breakage (wrong
|
||||
// buildStub) — but is still counted, so a *total* breakage (wrong
|
||||
// origin, CORS) remains visible via getCrawlStats() instead of just
|
||||
// silently producing an empty article list.
|
||||
let authorsFailed = 0;
|
||||
@@ -263,25 +315,24 @@ async function runCrawl() {
|
||||
// onArticlesProgress). Purely a priority order, not the display order.
|
||||
const crawlOrder = [...slugDirs].sort((a, b) => mtimeSortValue(b) - mtimeSortValue(a));
|
||||
|
||||
// Wave 3+4 — per slug directory, cache-aware and concurrency-limited: a
|
||||
// directory whose mtime matches its cached entry skips both the listing
|
||||
// fetch (for .tag/.date files) and the markdown content fetch entirely.
|
||||
// Wave 3 — per slug directory, cache-aware and concurrency-limited: a
|
||||
// directory whose mtime matches its cached (already-hydrated) entry
|
||||
// skips the listing fetch entirely; otherwise just its listing (for
|
||||
// .tag/.date/draft markers) — not its markdown content, which is
|
||||
// deferred to hydrateArticle() until something actually renders it.
|
||||
let loaded = 0;
|
||||
let resolvedCount = 0;
|
||||
await mapWithConcurrency(crawlOrder, MAX_CONCURRENT_REQUESTS, async dir => {
|
||||
const cached = await getCacheEntry(dir.root);
|
||||
const cacheHit = cached && dir.mtime != null && cached.mtime === dir.mtime;
|
||||
const article = cacheHit ? cached.article : await buildArticle(dir);
|
||||
if (!cacheHit && article) await setCacheEntry(dir.root, { mtime: dir.mtime, article });
|
||||
if (article) {
|
||||
const stub = await resolveStub(dir);
|
||||
if (stub) {
|
||||
loaded += 1;
|
||||
partialResolved.push(article);
|
||||
partialResolved.push(stub);
|
||||
}
|
||||
// Throttled rather than on every single item — with a large tree
|
||||
// that would mean hundreds of list re-renders for little benefit.
|
||||
resolvedCount += 1;
|
||||
if (resolvedCount === 1 || resolvedCount % 5 === 0 || resolvedCount === crawlOrder.length) notifyProgress(false);
|
||||
return article;
|
||||
return stub;
|
||||
});
|
||||
|
||||
await pruneCache(slugDirs.map(dir => dir.root));
|
||||
|
||||
+50
-20
@@ -1,5 +1,5 @@
|
||||
import { templates } from './templates.js';
|
||||
import { discoverArticles, fetchAuthorProfile, fetchTagRegistry, findArticle, getCrawlStats, onArticlesProgress, resolveMediaUrl } from './discovery.js';
|
||||
import { deriveContentFields, discoverArticles, fetchAuthorProfile, fetchTagRegistry, findArticle, getCrawlStats, hydrateArticles, onArticlesProgress, resolveMediaUrl } from './discovery.js';
|
||||
import { parseFrontmatter } from './frontmatter.js';
|
||||
import { t, getLocale } from './i18n.js';
|
||||
|
||||
@@ -208,11 +208,13 @@ function renderCrawlHealthWarning(view, done) {
|
||||
view.appendChild(warning);
|
||||
}
|
||||
|
||||
// Renders one snapshot of the (possibly still-growing) article list 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).
|
||||
function renderArticleListBody(view, articles, filter, page, done) {
|
||||
// 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 }) {
|
||||
view.innerHTML = '';
|
||||
renderCrawlHealthWarning(view, done);
|
||||
if (filter) {
|
||||
@@ -221,17 +223,15 @@ function renderArticleListBody(view, articles, filter, page, done) {
|
||||
heading.textContent = filterLabel(filter);
|
||||
view.appendChild(heading);
|
||||
}
|
||||
if (!articles.length) {
|
||||
if (!total) {
|
||||
const message = document.createElement('p');
|
||||
message.className = done ? 'empty' : 'loading';
|
||||
message.textContent = done ? t('emptyArticleList') : t('loadingArticles');
|
||||
view.appendChild(message);
|
||||
return;
|
||||
}
|
||||
const pages = Math.max(1, Math.ceil(articles.length / PAGE_SIZE));
|
||||
const clampedPage = Math.max(1, Math.min(page, pages));
|
||||
articles.slice((clampedPage - 1) * PAGE_SIZE, clampedPage * PAGE_SIZE).forEach(article => view.appendChild(renderCard(article)));
|
||||
if (pages > 1) view.appendChild(buildPaginationNav(clampedPage, pages, filter));
|
||||
pageArticles.forEach(article => view.appendChild(renderCard(article)));
|
||||
if (pages > 1) view.appendChild(buildPaginationNav(page, pages, filter));
|
||||
if (!done) {
|
||||
const indexing = document.createElement('p');
|
||||
indexing.className = 'loading';
|
||||
@@ -257,15 +257,32 @@ export async function loadArticleList(filter = null, page = 1) {
|
||||
});
|
||||
};
|
||||
|
||||
const unsubscribe = onArticlesProgress((partialArticles, done) => {
|
||||
// Sorting/filtering/pagination all work on stubs alone (tags, date,
|
||||
// category — none of that needs a content fetch). Only the current
|
||||
// page's cards need real content, so only that slice gets hydrated —
|
||||
// this is what keeps a large site's list fast: the rest of the tree's
|
||||
// markdown is never fetched until the user actually pages to it.
|
||||
// `renderToken` guards against two overlapping hydration+render cycles
|
||||
// (e.g. two progress ticks arriving close together) applying out of
|
||||
// order — only the most recently *started* one is allowed to render.
|
||||
let renderToken = 0;
|
||||
const render = async (rawArticles, done) => {
|
||||
if (generation !== currentGeneration) return;
|
||||
renderArticleListBody(view, applyFilter(partialArticles), filter, page, done);
|
||||
});
|
||||
const myToken = ++renderToken;
|
||||
const articles = applyFilter(rawArticles);
|
||||
const pages = Math.max(1, Math.ceil(articles.length / PAGE_SIZE));
|
||||
const clampedPage = Math.max(1, Math.min(page, pages));
|
||||
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 });
|
||||
};
|
||||
|
||||
const unsubscribe = onArticlesProgress((partialArticles, done) => { render(partialArticles, done); });
|
||||
try {
|
||||
await ensureTags();
|
||||
const allArticles = await discoverArticles();
|
||||
if (generation !== currentGeneration) return;
|
||||
renderArticleListBody(view, applyFilter(allArticles), filter, page, true);
|
||||
await render(allArticles, true);
|
||||
} catch (error) {
|
||||
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`;
|
||||
} finally {
|
||||
@@ -324,8 +341,13 @@ export async function loadSearch(query) {
|
||||
try {
|
||||
await ensureTags();
|
||||
const needle = query.toLocaleLowerCase(getLocale());
|
||||
const allArticles = await discoverArticles();
|
||||
renderFilterNavigation(allArticles);
|
||||
const stubs = await discoverArticles();
|
||||
renderFilterNavigation(stubs);
|
||||
// Unlike the list view, search needs every article's real title and
|
||||
// description — text that only exists in its markdown content — so
|
||||
// it hydrates the whole set rather than just a page's worth. Most
|
||||
// of that is typically already cached from earlier browsing/search.
|
||||
const allArticles = await hydrateArticles(stubs);
|
||||
const articles = allArticles.filter(article => [article.title, article.description, article.author, article.authorName, article.category, ...article.tags.map(tag => tagLabels.get(tag) || tag)]
|
||||
.some(value => String(value || '').toLocaleLowerCase(getLocale()).includes(needle)));
|
||||
view.innerHTML = `<h2 class="category-heading">${t('searchHeading', { query })}</h2>`;
|
||||
@@ -366,6 +388,14 @@ export async function loadArticle(hash) {
|
||||
fetchAuthorProfile(article.author),
|
||||
]);
|
||||
const { meta, content } = parseFrontmatter(raw);
|
||||
// This view always fetches its own content directly (above) rather
|
||||
// than relying on discoverArticles()'s stub — the list view defers
|
||||
// that fetch until a page is actually rendered (see
|
||||
// loadArticleList), and a single visited article is exactly such a
|
||||
// case, so readingTime/coverUrl are derived fresh here. Unlike
|
||||
// cards, the description field here stays frontmatter-only (no
|
||||
// auto-excerpt) — the full content is right below it either way.
|
||||
const { readingTime, coverUrl } = deriveContentFields(meta, content, article.path);
|
||||
document.title = `${meta.title || article.slug} | ${t('siteName')}`;
|
||||
const element = document.importNode(templates.article, true);
|
||||
const field = name => element.querySelector(`[data-field="${name}"]`);
|
||||
@@ -374,9 +404,9 @@ export async function loadArticle(hash) {
|
||||
// (already resolved onto `article.date`), not the raw frontmatter.
|
||||
setField(field('date'), article.date, (time, value) => { time.dateTime = String(value); time.textContent = formatDate(value); });
|
||||
setField(field('description'), meta.description, (description, value) => { description.textContent = value; });
|
||||
setField(field('reading-time'), article.readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); });
|
||||
setField(field('reading-time'), readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); });
|
||||
const image = field('image');
|
||||
if (article.coverUrl) { image.src = article.coverUrl; image.alt = meta.title || article.slug; } else image.closest('figure')?.remove();
|
||||
if (coverUrl) { image.src = coverUrl; image.alt = meta.title || article.slug; } else image.closest('figure')?.remove();
|
||||
const author = field('author'); author.textContent = meta.authorName || article.author; author.href = `#author/${encodeURIComponent(article.author)}`;
|
||||
setField(field('category'), article.category, (category, value) => {
|
||||
category.textContent = t('yearLabel', { year: article.expa });
|
||||
|
||||
Reference in new issue
Block a user