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:
biosiasandClaude Sonnet 5 committed 2026-09-11 13:22:17 +02:00
1 parent 60cf355352
commit 81fb2a2414
4 files changed
+192 -90

No files matched your search

+47 -26
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 });