Fix bugs and robustness gaps found in a full code review (AI)

Real bugs:
- loadArticle()/loadAuthorView() had no stale-render guard (unlike
  loadArticleList()/loadSearch()) -- a slower, superseded request could
  overwrite a newer one's render. Both now capture and check a
  generation snapshot.
- #post/... links interpolated article.hash (raw, unencoded
  "author/slug") directly, but router.js unconditionally
  decodeURIComponent()s each segment when parsing -- would throw
  (silently swallowed) for any author/slug containing a literal "%" or
  other reserved character. Links now go through a new postHref()
  helper that encodes each segment individually.
- A startup failure replaced the entire document.body, which would
  wipe out a host site's own page content when cmjs is embedded
  alongside it. Now scoped to cmjs's own mount points when at least
  one exists.
- hydrateArticle() had no in-flight de-duplication -- two concurrent
  callers for the same not-yet-hydrated stub (e.g. a page's hydration
  racing the next page's prefetch) could each fire a duplicate fetch.
  Now tracked via a WeakMap keyed by the stub.
- Leftover "expedition blog"/"expedition year" wording missed by the
  earlier astronomy-club genericization pass (that pass's grep didn't
  cover the English word "expedition").

Robustness gaps:
- cache.js no longer permanently disables the whole IndexedDB cache
  after a single failed transaction -- only openDb() itself failing
  does that now.
- Listing mtimes are normalized to minute precision at the one point
  they enter the system, so cache comparisons stay consistent even if
  a server flips between JSON and HTML-fallback autoindex.
- The root author-listing fetch failing outright is now treated like
  "every author's listing failed" (a new rootFailed crawl stat) instead
  of surfacing a generic, less-specific error.
- router.js now checks both required mount points exist right after
  startup and fails with a clear, catchable message instead of a bare
  TypeError the first time showListView()/showArticleView() runs.
- The article view's "back" link now returns to whatever list/search/
  filter page the user actually came from (tracked via lastListHash)
  instead of always resetting to page 1 of everything.
- filterLabel() now checks filter.type === 'category' explicitly
  instead of falling through to it as the default case.
- Author-name filtering is now case-sensitive, matching how author
  paths/profile lookups are looked up everywhere else.
- Documented (in code comments and a new README note) that article/bio
  Markdown renders via marked.js with no HTML-sanitization pass --
  only trusted authors should be adding content.

Minor:
- Extracted applyArticleMetaFields() to share the date/reading-time/
  category-badge logic between renderCard() and loadArticle() instead
  of duplicating it.
- Clarified resolveMediaUrl()'s WordPress-migration special case as
  legacy compatibility, not a general-purpose feature.
- frontmatter.js no longer requires a trailing newline after the
  closing "---" -- a file that's nothing but frontmatter now parses
  instead of falling through to "no frontmatter at all".

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-12 16:23:26 +02:00
1 parent a0f1ed67d9
commit 24f9740d97
7 files changed
+210 -56

No files matched your search

+4
View File
@@ -135,6 +135,10 @@ metadata never requires fetching anything beyond the directory listing that alre
no further per-article request at all, which is what makes the "Article discovery & caching" no further per-article request at all, which is what makes the "Article discovery & caching"
section below possible. section below possible.
> **A note on trust:** article and author-bio Markdown is rendered directly via marked.js with no
> HTML-sanitization pass — anyone who can write a `.md` file here can include raw HTML, including
> `<script>` tags. Only let people you trust add or edit content.
### Frontmatter format ### Frontmatter format
```yaml ```yaml
+2 -2
View File
@@ -50,8 +50,8 @@ body {
padding: 0; padding: 0;
} }
/* The expedition blog follows the same restrained night-sky palette as the /* The demo blog follows its own restrained night-sky palette,
main site, independently of the visitor's operating-system theme. */ independently of the visitor's operating-system theme. */
body.blog-page { body.blog-page {
--color-bg: #050506; --color-bg: #050506;
--color-surface: #101114; --color-surface: #101114;
+18 -4
View File
@@ -39,8 +39,13 @@ function openDb() {
// lifecycle details. // lifecycle details.
async function runRequest(mode, makeRequest) { async function runRequest(mode, makeRequest) {
if (!cacheAvailable) return undefined; if (!cacheAvailable) return undefined;
let db;
try {
db = await openDb();
} catch {
return undefined; // openDb() itself already flipped cacheAvailable
}
try { try {
const db = await openDb();
const store = db.transaction(STORE_NAME, mode).objectStore(STORE_NAME); const store = db.transaction(STORE_NAME, mode).objectStore(STORE_NAME);
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const request = makeRequest(store); const request = makeRequest(store);
@@ -48,7 +53,10 @@ async function runRequest(mode, makeRequest) {
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
}); });
} catch { } catch {
cacheAvailable = false; // A single request/transaction failing (e.g. one write hitting a
// storage quota) doesn't mean IndexedDB itself is gone — only
// openDb() failing disables the cache wholesale. Leave
// cacheAvailable as-is so later calls can still try.
return undefined; return undefined;
} }
} }
@@ -76,8 +84,13 @@ export async function setCacheEntry(path, entry) {
export async function pruneCache(validPaths) { export async function pruneCache(validPaths) {
if (!cacheAvailable) return; if (!cacheAvailable) return;
const valid = new Set(validPaths); const valid = new Set(validPaths);
let db;
try {
db = await openDb();
} catch {
return; // openDb() itself already flipped cacheAvailable
}
try { try {
const db = await openDb();
const store = db.transaction(STORE_NAME, 'readwrite').objectStore(STORE_NAME); const store = db.transaction(STORE_NAME, 'readwrite').objectStore(STORE_NAME);
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const cursorRequest = store.openCursor(); const cursorRequest = store.openCursor();
@@ -90,6 +103,7 @@ export async function pruneCache(validPaths) {
cursorRequest.onerror = () => reject(cursorRequest.error); cursorRequest.onerror = () => reject(cursorRequest.error);
}); });
} catch { } catch {
cacheAvailable = false; // Same reasoning as runRequest() above — a failed prune pass doesn't
// mean IndexedDB itself is unavailable.
} }
} }
+59 -11
View File
@@ -73,10 +73,13 @@ export function resolveMediaUrl(value, articlePath = '') {
if (/^https?:\/\//i.test(path)) { if (/^https?:\/\//i.test(path)) {
try { try {
const url = new URL(path); const url = new URL(path);
// Legacy WordPress-migrated content sometimes still carries an // Legacy compatibility, not a general-purpose feature: content
// absolute upload URL — reduce it to a bare filename so it // migrated from a WordPress export sometimes still carries an
// resolves against the article's own directory like anything // absolute upload URL. Reduce it to a bare filename so it
// else, assuming the file was migrated alongside it. // resolves against the article's own directory like any other
// relative image path, assuming the file itself was migrated
// alongside the article. A no-op for anything that isn't
// WordPress-derived content — most deployments never hit this.
if (url.pathname.includes('/wp-content/uploads/')) path = url.pathname.split('/wp-content/uploads/')[1]; if (url.pathname.includes('/wp-content/uploads/')) path = url.pathname.split('/wp-content/uploads/')[1];
else return path; else return path;
} catch { return path; } } catch { return path; }
@@ -86,7 +89,7 @@ export function resolveMediaUrl(value, articlePath = '') {
let articleCache = null; let articleCache = null;
let crawlPromise = null; let crawlPromise = null;
let crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0 }; let crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0, rootFailed: false };
// Raw (unfiltered/unsorted) article records resolved so far by the crawl // Raw (unfiltered/unsorted) article records resolved so far by the crawl
// currently or most recently in flight — lets a late subscriber (a view // currently or most recently in flight — lets a late subscriber (a view
@@ -203,6 +206,17 @@ function buildStub(author, fileName, mtime, authorRoot) {
}; };
} }
// nginx's JSON autoindex reports mtimes with second (or finer) precision,
// but the HTML-autoindex fallback (see parseHtmlDirectoryListing) only ever
// has minute precision. Truncating both to the same minute-level grain here
// — the one place a raw listing mtime enters the system — means the cache
// comparison in resolveStub() below still matches consistently even if a
// server ever flips between the two formats, instead of every file looking
// "changed" the moment JSON autoindex is lost.
function normalizeMtime(mtime) {
return mtime ? mtime.slice(0, 16) : null; // "YYYY-MM-DDTHH:MM" prefix
}
// Cache-aware: a file whose mtime matches its cached entry's mtime reuses // Cache-aware: a file whose mtime matches its cached entry's mtime reuses
// that entry directly — worthwhile only when it's already hydrated // that entry directly — worthwhile only when it's already hydrated
// (skipping a real content re-fetch); a merely-parsed stub is free to // (skipping a real content re-fetch); a merely-parsed stub is free to
@@ -241,8 +255,28 @@ export function deriveContentFields(meta, content, path) {
// re-render of the same page doesn't re-fetch it. No-ops if already // 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) // hydrated. Leaves the stub as-is (title-less but still linkable/sortable)
// on any failure, matching this module's silent-skip approach elsewhere. // on any failure, matching this module's silent-skip approach elsewhere.
export async function hydrateArticle(stub) { //
if (stub.hydrated) return stub; // De-duplicates concurrent calls for the *same* stub: without this, two
// callers racing on a not-yet-hydrated stub (e.g. a page's own hydration
// racing the next page's background prefetch) would each fire their own
// fetch for the same article file. `inFlightHydrations` tracks the shared
// promise for as long as one is in flight, keyed by the stub object itself
// (each article has exactly one stub instance for its whole lifetime — see
// buildStub()/resolveStub()) — removed again once settled, success or
// failure, so a failed hydration is still retried on the next call rather
// than permanently returning the same rejected attempt.
const inFlightHydrations = new WeakMap();
export function hydrateArticle(stub) {
if (stub.hydrated) return Promise.resolve(stub);
const existing = inFlightHydrations.get(stub);
if (existing) return existing;
const promise = hydrateArticleNow(stub).finally(() => inFlightHydrations.delete(stub));
inFlightHydrations.set(stub, promise);
return promise;
}
async function hydrateArticleNow(stub) {
try { try {
const response = await fetch(stub.path); const response = await fetch(stub.path);
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -279,8 +313,22 @@ async function runCrawl() {
// to the total. // to the total.
const cacheSnapshotPromise = getAllCacheEntries(); const cacheSnapshotPromise = getAllCacheEntries();
// Wave 1 (root) — always fetched live: the author directories. // Wave 1 (root) — always fetched live: the author directories. Unlike
const authorEntries = await fetchJson(ARTICLES_URL); // wave 2 below, a failure here means discovery can't proceed at all
// (wrong origin, CORS, autoindex disabled) — rather than letting that
// reject all the way out to a generic "failed to load" message, treat it
// the same as "every author's listing failed": resolve to an empty,
// fully-indexed (done) result so the list view renders normally and
// renderCrawlHealthWarning() (via rootFailed below) shows the specific
// crawl-health message instead.
let authorEntries;
try {
authorEntries = await fetchJson(ARTICLES_URL);
} catch {
crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0, rootFailed: true };
notifyProgress(true);
return sortAndFilterPublished(partialResolved);
}
const authors = authorEntries.filter(entry => entry.type === 'directory' && !entry.name.startsWith('.')); const authors = authorEntries.filter(entry => entry.type === 'directory' && !entry.name.startsWith('.'));
// Wave 2 — always fetched live, in full, for every author (not just the // Wave 2 — always fetched live, in full, for every author (not just the
@@ -307,7 +355,7 @@ async function runCrawl() {
const entries = await fetchJson(authorRoot); const entries = await fetchJson(authorRoot);
return entries return entries
.filter(entry => entry.type === 'file' && entry.name.endsWith('.md') && entry.name !== bioFileName) .filter(entry => entry.type === 'file' && entry.name.endsWith('.md') && entry.name !== bioFileName)
.map(entry => ({ author: author.name, fileName: entry.name, mtime: entry.mtime ?? null, authorRoot })); .map(entry => ({ author: author.name, fileName: entry.name, mtime: normalizeMtime(entry.mtime), authorRoot }));
} catch { } catch {
authorsFailed += 1; authorsFailed += 1;
return []; return [];
@@ -329,7 +377,7 @@ async function runCrawl() {
} }
await pruneCache(allFiles.map(file => `${file.authorRoot}${encodeURIComponent(file.fileName)}`)); await pruneCache(allFiles.map(file => `${file.authorRoot}${encodeURIComponent(file.fileName)}`));
crawlStats = { total: allFiles.length, loaded, authorsTotal: authors.length, authorsFailed }; crawlStats = { total: allFiles.length, loaded, authorsTotal: authors.length, authorsFailed, rootFailed: false };
const finalArticles = sortAndFilterPublished(partialResolved); const finalArticles = sortAndFilterPublished(partialResolved);
notifyProgress(true); notifyProgress(true);
+5 -2
View File
@@ -31,7 +31,10 @@ function parseScalar(raw) {
} }
export function parseFrontmatter(raw) { export function parseFrontmatter(raw) {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); // The closing "---" doesn't require a trailing newline of its own — a
// file that's nothing but frontmatter (no body, no trailing newline)
// still parses instead of falling through to "no frontmatter at all".
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/);
if (!match) return { meta: {}, content: raw }; if (!match) return { meta: {}, content: raw };
const meta = {}; const meta = {};
let arrayKey = null; let arrayKey = null;
@@ -44,5 +47,5 @@ export function parseFrontmatter(raw) {
if (!rawValue.trim()) { meta[key] = []; arrayKey = key; } if (!rawValue.trim()) { meta[key] = []; arrayKey = key; }
else { meta[key] = parseScalar(rawValue); arrayKey = null; } else { meta[key] = parseScalar(rawValue); arrayKey = null; }
} }
return { meta, content: match[2] }; return { meta, content: match[2] ?? '' };
} }
+31 -3
View File
@@ -73,11 +73,39 @@ function init() {
// Strings load first — everything else (including the error handler // Strings load first — everything else (including the error handler
// right below) may call t(), so it needs to already have resolved. // right below) may call t(), so it needs to already have resolved.
ready = loadStrings().then(() => Promise.all([loadScript(MARKED_URL), loadTemplates()])); ready = loadStrings().then(() => Promise.all([loadScript(MARKED_URL), loadTemplates()])).then(() => {
// Fail with a clear, catchable message here rather than a cryptic
// uncaught TypeError the first time showListView()/showArticleView()
// run — both mount points are a hard requirement, unlike
// search/tag/year, which are optional.
if (!document.getElementById('list-view') || !document.getElementById('article-view')) {
throw new Error('Missing required #list-view and/or #article-view element — see the "Embedding in an existing page" section of the README.');
}
});
// Show a top-level error if dependencies fail to load // Show a top-level error if dependencies fail to load (or a mount point
// is missing). Scoped to cmjs's own mount points when at least one
// exists, rather than replacing the whole page — this can run on a page
// that embeds cmjs alongside plenty of its own content, which wiping
// document.body would destroy too. Falls back to the full-page replacement
// only when neither mount point is present to show the message in.
ready.catch(err => { ready.catch(err => {
document.body.textContent = t('appStartError', { message: err.message }); const message = t('appStartError', { message: err.message });
const listView = document.getElementById('list-view');
const articleView = document.getElementById('article-view');
if (listView || articleView) {
if (listView) {
listView.hidden = false;
listView.innerHTML = '';
const p = document.createElement('p');
p.className = 'error';
p.textContent = message;
listView.appendChild(p);
}
if (articleView) articleView.hidden = true;
} else {
document.body.textContent = message;
}
}); });
ready.then(router).catch(() => {}); ready.then(router).catch(() => {});
+91 -34
View File
@@ -11,6 +11,12 @@ const PAGE_SIZE = 20;
// navigated away from (e.g. clicked into a different filter/page). // navigated away from (e.g. clicked into a different filter/page).
let currentGeneration = 0; let currentGeneration = 0;
// The hash of the last list view shown (all articles, a filter, a search,
// any page) — used by loadArticle()'s "back" link so it returns to wherever
// the user actually came from instead of always resetting to page 1 of
// everything.
let lastListHash = '#';
function formatDate(value) { function formatDate(value) {
if (!value) return ''; if (!value) return '';
const date = new Date(`${String(value).slice(0, 10)}T12:00:00`); const date = new Date(`${String(value).slice(0, 10)}T12:00:00`);
@@ -42,7 +48,8 @@ function filterLabel(filter) {
if (!filter) return ''; if (!filter) return '';
if (filter.type === 'author') return t('filterAuthor', { value: filter.value }); if (filter.type === 'author') return t('filterAuthor', { value: filter.value });
if (filter.type === 'tag') return t('filterTag', { value: filter.value }); if (filter.type === 'tag') return t('filterTag', { value: filter.value });
return t('filterYear', { value: filter.value.replace(/^year-/i, '') }); if (filter.type === 'category') return t('filterYear', { value: filter.value.replace(/^year-/i, '') });
return filter.value;
} }
function pageHash(page, filter) { function pageHash(page, filter) {
@@ -146,6 +153,30 @@ function renderFilterNavigation(articles, activeFilter = null) {
} }
} }
// Builds the #post/author/slug hash for an article, encoding each segment
// individually. article.hash itself (a raw, unencoded "author/slug") is only
// ever used for internal matching in findArticle() — router.js decodes the
// two segments of a #post/... hash separately when parsing, so the link
// needs to encode them separately too, rather than interpolating the
// combined hash as-is (which breaks — decodeURIComponent throws — for any
// author/slug containing a literal "%" or other reserved character).
function postHref(article) {
return `#post/${encodeURIComponent(article.author)}/${encodeURIComponent(article.slug)}`;
}
// Shared between renderCard() and loadArticle() — date, reading-time, and
// the category/year badge are populated identically in both places.
function applyArticleMetaFields(field, { date, readingTime, category, categoryYear }) {
setField(field('date'), date, (time, value) => { time.dateTime = String(value); time.textContent = formatDate(value); });
setField(field('reading-time'), readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); });
// A post only counts as a "category" when one of its directory-name
// tags happens to name a year — not every article has one.
setField(field('category'), category, (categoryEl, value) => {
categoryEl.textContent = t('yearLabel', { year: categoryYear });
categoryEl.href = `#category/${encodeURIComponent(value)}`;
});
}
function renderCard(article) { function renderCard(article) {
const fragment = document.importNode(templates.card, true); const fragment = document.importNode(templates.card, true);
const element = fragment.firstElementChild; const element = fragment.firstElementChild;
@@ -156,7 +187,7 @@ function renderCard(article) {
image.src = value; image.src = value;
image.alt = article.title || article.slug; image.alt = article.title || article.slug;
if (imageLink) { if (imageLink) {
imageLink.href = `#post/${article.hash}`; imageLink.href = postHref(article);
imageLink.removeAttribute('aria-hidden'); imageLink.removeAttribute('aria-hidden');
imageLink.removeAttribute('tabindex'); imageLink.removeAttribute('tabindex');
} }
@@ -165,20 +196,13 @@ function renderCard(article) {
imageLink?.remove(); imageLink?.remove();
element.classList.add('card--no-image'); element.classList.add('card--no-image');
} }
field('link').href = `#post/${article.hash}`; field('link').href = postHref(article);
field('title').textContent = article.title || article.slug; field('title').textContent = article.title || article.slug;
setField(field('date'), article.date, (time, value) => { time.dateTime = String(value); time.textContent = formatDate(value); }); applyArticleMetaFields(field, article);
setField(field('description'), article.description, (description, value) => { description.textContent = value; }); setField(field('description'), article.description, (description, value) => { description.textContent = value; });
setField(field('reading-time'), article.readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); });
const author = field('author'); const author = field('author');
author.textContent = article.authorName || article.author; author.textContent = article.authorName || article.author;
author.href = `#author/${encodeURIComponent(article.author)}`; author.href = `#author/${encodeURIComponent(article.author)}`;
// A post only counts as a "category" when one of its directory-name
// tags happens to name an expedition year — not every article has one.
setField(field('category'), article.category, (category, value) => {
category.textContent = t('yearLabel', { year: article.categoryYear });
category.href = `#category/${encodeURIComponent(value)}`;
});
renderTags(field('tags'), article.tags); renderTags(field('tags'), article.tags);
return element; return element;
} }
@@ -186,16 +210,21 @@ function renderCard(article) {
// Near-total crawl failure (wrong origin after a migration, CORS broken, // Near-total crawl failure (wrong origin after a migration, CORS broken,
// autoindex disabled) should be visible rather than silently rendering an // autoindex disabled) should be visible rather than silently rendering an
// empty/near-empty list forever — see .claude/nginx-sitemap-crawler-plan.md L3. // empty/near-empty list forever — see .claude/nginx-sitemap-crawler-plan.md L3.
// Two independent signals feed this: every author's own listing failing // Three independent signals feed this: the root author-listing fetch itself
// (checked regardless of site size, since discovery.js now silently skips // failing outright (rootFailed), every individual author's own listing
// individual author failures — see the wave-2 comment in discovery.js) and, // failing (checked regardless of site size, since discovery.js now silently
// for larger sites, a near-zero loaded/total ratio at the article level. // skips individual author failures — see the wave-2 comment in
// Only meaningful once the crawl has actually finished (`done`) — mid-crawl // discovery.js), and, for larger sites, a near-zero loaded/total ratio at
// stats are incomplete and would misreport a healthy crawl as broken. // the article level. Only meaningful once the crawl has actually finished
// (`done`) — mid-crawl stats are incomplete and would misreport a healthy
// crawl as broken.
function renderCrawlHealthWarning(view, done) { function renderCrawlHealthWarning(view, done) {
if (!done) return; if (!done) return;
const { total, loaded, authorsTotal, authorsFailed } = getCrawlStats(); const { total, loaded, authorsTotal, authorsFailed, rootFailed } = getCrawlStats();
const authorsBroken = authorsTotal > 0 && authorsFailed === authorsTotal; // rootFailed means the very first listing fetch (the author directories
// themselves) couldn't be read at all — a total breakage same in spirit
// as every author's own listing failing, just one level higher up.
const authorsBroken = rootFailed || (authorsTotal > 0 && authorsFailed === authorsTotal);
const articlesBroken = total >= 5 && loaded / total < 0.1; const articlesBroken = total >= 5 && loaded / total < 0.1;
if (!authorsBroken && !articlesBroken) return; if (!authorsBroken && !articlesBroken) return;
const warning = document.createElement('p'); const warning = document.createElement('p');
@@ -246,6 +275,7 @@ export async function loadArticleList(filter = null, page = 1) {
showListView(); showListView();
window.scrollTo(0, 0); window.scrollTo(0, 0);
clearSearchInput(); clearSearchInput();
lastListHash = location.hash || '#';
const view = document.getElementById('list-view'); const view = document.getElementById('list-view');
view.innerHTML = `<p class="loading">${t('loadingArticles')}</p>`; view.innerHTML = `<p class="loading">${t('loadingArticles')}</p>`;
const generation = ++currentGeneration; const generation = ++currentGeneration;
@@ -255,6 +285,11 @@ export async function loadArticleList(filter = null, page = 1) {
if (!filter) return allArticles; if (!filter) return allArticles;
return allArticles.filter(article => { return allArticles.filter(article => {
if (filter.type === 'tag') return article.tags.includes(filter.value); if (filter.type === 'tag') return article.tags.includes(filter.value);
// Author names are exact, case-sensitive directory names (used
// as-is for paths and profile lookups elsewhere) — comparing
// them case-insensitively here could merge two distinctly-named
// author directories that differ only in case.
if (filter.type === 'author') return article.author === filter.value;
return String(article[filter.type] || '').toLowerCase() === filter.value.toLowerCase(); return String(article[filter.type] || '').toLowerCase() === filter.value.toLowerCase();
}); });
}; };
@@ -317,6 +352,8 @@ function renderAuthorProfile(profile) {
setField(field('avatar'), profile.avatar, (image, value) => { image.src = resolveMediaUrl(value, profile.path); image.alt = profile.title || profile.author; }); setField(field('avatar'), profile.avatar, (image, value) => { image.src = resolveMediaUrl(value, profile.path); image.alt = profile.title || profile.author; });
field('name').textContent = profile.title || profile.author; field('name').textContent = profile.title || profile.author;
const bio = field('bio'); const bio = field('bio');
// See the matching note in loadArticle() — same no-sanitization caveat
// applies here (an author bio's raw HTML, if any, renders as-is).
if (profile.content?.trim()) bio.innerHTML = marked.parse(profile.content); else bio.remove(); if (profile.content?.trim()) bio.innerHTML = marked.parse(profile.content); else bio.remove();
const social = field('social'); const social = field('social');
const links = Object.entries(SOCIAL_LABELS).filter(([key]) => profile[key]).map(([key, label]) => makeSocialLink(profile[key], label)); const links = Object.entries(SOCIAL_LABELS).filter(([key]) => profile[key]).map(([key, label]) => makeSocialLink(profile[key], label));
@@ -330,18 +367,30 @@ export async function loadAuthorView(author, page = 1) {
const view = document.getElementById('list-view'); const view = document.getElementById('list-view');
view.innerHTML = `<p class="loading">${t('loadingGeneric')}</p>`; view.innerHTML = `<p class="loading">${t('loadingGeneric')}</p>`;
clearSearchInput(); clearSearchInput();
let generation;
try { try {
// Independent of each other — fetching in parallel instead of // Independent of each other — fetching in parallel instead of
// sequentially shaves a round-trip. Safe to run together: // sequentially shaves a round-trip. Safe to run together:
// fetchAuthorProfile() never touches the DOM, and the profile card // fetchAuthorProfile() never touches the DOM, and the profile card
// below is only inserted once both have settled, so there's // below is only inserted once both have settled, so there's
// nothing for loadArticleList()'s own view updates to race with. // nothing for loadArticleList()'s own view updates to race with.
const [profile] = await Promise.all([ const profilePromise = fetchAuthorProfile(author);
fetchAuthorProfile(author), const listPromise = loadArticleList({ type: 'author', value: author }, page);
loadArticleList({ type: 'author', value: author }, page), // loadArticleList() bumps currentGeneration synchronously as one of
]); // its first steps (before any await), so reading it right after
if (page === 1 && profile) view.insertBefore(renderAuthorProfile(profile), view.firstChild); // starting — not awaiting — that call captures the exact generation
} catch (error) { view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`; } // this navigation owns. Without this, a slower-resolving call here
// could insert a stale profile card into a list-view the user has
// since navigated away from (it stays in the DOM invisibly, since
// list-view would be hidden, only to reappear stale later).
generation = currentGeneration;
const [profile] = await Promise.all([profilePromise, listPromise]);
if (generation === currentGeneration && page === 1 && profile) {
view.insertBefore(renderAuthorProfile(profile), view.firstChild);
}
} catch (error) {
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`;
}
} }
export const loadCategoryView = (category, page = 1) => loadArticleList({ type: 'category', value: category }, page); export const loadCategoryView = (category, page = 1) => loadArticleList({ type: 'category', value: category }, page);
@@ -365,6 +414,7 @@ export async function loadSearch(query, page = 1) {
showListView(); showListView();
window.scrollTo(0, 0); window.scrollTo(0, 0);
const generation = ++currentGeneration; const generation = ++currentGeneration;
lastListHash = location.hash || '#';
const view = document.getElementById('list-view'); const view = document.getElementById('list-view');
view.innerHTML = `<p class="loading">${t('searching')}</p>`; view.innerHTML = `<p class="loading">${t('searching')}</p>`;
const input = document.getElementById('search-input'); const input = document.getElementById('search-input');
@@ -466,7 +516,7 @@ function buildTOC(content) {
export async function loadArticle(hash) { export async function loadArticle(hash) {
showArticleView(); showArticleView();
window.scrollTo(0, 0); window.scrollTo(0, 0);
currentGeneration += 1; // invalidates any still-running loadArticleList progress listener const generation = ++currentGeneration; // also invalidates any still-running loadArticleList progress listener
clearSearchInput(); clearSearchInput();
const view = document.getElementById('article-view'); const view = document.getElementById('article-view');
view.innerHTML = `<p class="loading">${t('loadingGeneric')}</p>`; view.innerHTML = `<p class="loading">${t('loadingGeneric')}</p>`;
@@ -476,6 +526,11 @@ export async function loadArticle(hash) {
fetch(article.path).then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.text(); }), fetch(article.path).then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.text(); }),
fetchAuthorProfile(article.author), fetchAuthorProfile(article.author),
]); ]);
// Nothing below has any visible effect until this point, so this is
// the one place worth checking: without it, a slower-resolving
// (superseded) loadArticle() call could still render over whatever
// the user has since navigated to.
if (generation !== currentGeneration) return;
const { meta, content } = parseFrontmatter(raw); const { meta, content } = parseFrontmatter(raw);
// This view always fetches its own content directly (above) rather // This view always fetches its own content directly (above) rather
// than relying on discoverArticles()'s stub — the list view defers // than relying on discoverArticles()'s stub — the list view defers
@@ -491,23 +546,25 @@ export async function loadArticle(hash) {
field('title').textContent = meta.title || article.slug; field('title').textContent = meta.title || article.slug;
// Publish date comes from the article file's own name (already // Publish date comes from the article file's own name (already
// resolved onto `article.date`), not the raw frontmatter. // resolved onto `article.date`), not the raw frontmatter.
setField(field('date'), article.date, (time, value) => { time.dateTime = String(value); time.textContent = formatDate(value); }); applyArticleMetaFields(field, { date: article.date, readingTime, category: article.category, categoryYear: article.categoryYear });
setField(field('description'), meta.description, (description, value) => { description.textContent = value; }); setField(field('description'), meta.description, (description, value) => { description.textContent = value; });
setField(field('reading-time'), readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); });
const image = field('image'); const image = field('image');
if (coverUrl) { image.src = 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)}`; 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.categoryYear });
category.href = `#category/${encodeURIComponent(value)}`;
});
renderTags(field('tags'), article.tags); renderTags(field('tags'), article.tags);
const contentElement = field('content'); const contentElement = field('content');
// Rendered directly via innerHTML with no HTML-sanitization pass —
// anyone who can write a .md file here can include raw HTML,
// including <script> tags (same for renderAuthorProfile()'s bio; the
// description field above skips this — it's plain textContent).
// Only let people you trust author content.
contentElement.innerHTML = marked.parse(content); contentElement.innerHTML = marked.parse(content);
contentElement.querySelectorAll('img[src]').forEach(imageElement => { imageElement.src = resolveMediaUrl(imageElement.getAttribute('src'), article.path); }); contentElement.querySelectorAll('img[src]').forEach(imageElement => { imageElement.src = resolveMediaUrl(imageElement.getAttribute('src'), article.path); });
const toc = buildTOC(contentElement); if (toc) contentElement.insertBefore(toc, contentElement.firstChild); const toc = buildTOC(contentElement); if (toc) contentElement.insertBefore(toc, contentElement.firstChild);
const back = document.createElement('a'); back.href = '#'; back.className = 'back-link'; back.textContent = t('backToArticles'); const back = document.createElement('a'); back.href = lastListHash; back.className = 'back-link'; back.textContent = t('backToArticles');
view.innerHTML = ''; view.append(back, element); view.innerHTML = ''; view.append(back, element);
if (profile) { const divider = document.createElement('hr'); divider.className = 'article-divider'; view.append(divider, renderAuthorProfile(profile)); } if (profile) { const divider = document.createElement('hr'); divider.className = 'article-divider'; view.append(divider, renderAuthorProfile(profile)); }
} catch (error) { view.innerHTML = `<p class="error">${t('articleLoadError', { message: error.message })}</p>`; } } catch (error) {
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articleLoadError', { message: error.message })}</p>`;
}
} }