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:
1 parent
a0f1ed67d9
commit
24f9740d97
7 files changed
+210
-56
No files matched your search
@@ -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"
|
||||
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
|
||||
|
||||
```yaml
|
||||
|
||||
+2
-2
@@ -50,8 +50,8 @@ body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* The expedition blog follows the same restrained night-sky palette as the
|
||||
main site, independently of the visitor's operating-system theme. */
|
||||
/* The demo blog follows its own restrained night-sky palette,
|
||||
independently of the visitor's operating-system theme. */
|
||||
body.blog-page {
|
||||
--color-bg: #050506;
|
||||
--color-surface: #101114;
|
||||
|
||||
+18
-4
@@ -39,8 +39,13 @@ function openDb() {
|
||||
// lifecycle details.
|
||||
async function runRequest(mode, makeRequest) {
|
||||
if (!cacheAvailable) return undefined;
|
||||
let db;
|
||||
try {
|
||||
db = await openDb();
|
||||
} catch {
|
||||
return undefined; // openDb() itself already flipped cacheAvailable
|
||||
}
|
||||
try {
|
||||
const db = await openDb();
|
||||
const store = db.transaction(STORE_NAME, mode).objectStore(STORE_NAME);
|
||||
return await new Promise((resolve, reject) => {
|
||||
const request = makeRequest(store);
|
||||
@@ -48,7 +53,10 @@ async function runRequest(mode, makeRequest) {
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -76,8 +84,13 @@ export async function setCacheEntry(path, entry) {
|
||||
export async function pruneCache(validPaths) {
|
||||
if (!cacheAvailable) return;
|
||||
const valid = new Set(validPaths);
|
||||
let db;
|
||||
try {
|
||||
db = await openDb();
|
||||
} catch {
|
||||
return; // openDb() itself already flipped cacheAvailable
|
||||
}
|
||||
try {
|
||||
const db = await openDb();
|
||||
const store = db.transaction(STORE_NAME, 'readwrite').objectStore(STORE_NAME);
|
||||
await new Promise((resolve, reject) => {
|
||||
const cursorRequest = store.openCursor();
|
||||
@@ -90,6 +103,7 @@ export async function pruneCache(validPaths) {
|
||||
cursorRequest.onerror = () => reject(cursorRequest.error);
|
||||
});
|
||||
} catch {
|
||||
cacheAvailable = false;
|
||||
// Same reasoning as runRequest() above — a failed prune pass doesn't
|
||||
// mean IndexedDB itself is unavailable.
|
||||
}
|
||||
}
|
||||
+59
-11
@@ -73,10 +73,13 @@ export function resolveMediaUrl(value, articlePath = '') {
|
||||
if (/^https?:\/\//i.test(path)) {
|
||||
try {
|
||||
const url = new URL(path);
|
||||
// Legacy WordPress-migrated content sometimes still carries an
|
||||
// absolute upload URL — reduce it to a bare filename so it
|
||||
// resolves against the article's own directory like anything
|
||||
// else, assuming the file was migrated alongside it.
|
||||
// Legacy compatibility, not a general-purpose feature: content
|
||||
// migrated from a WordPress export sometimes still carries an
|
||||
// absolute upload URL. Reduce it to a bare filename so 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];
|
||||
else return path;
|
||||
} catch { return path; }
|
||||
@@ -86,7 +89,7 @@ export function resolveMediaUrl(value, articlePath = '') {
|
||||
|
||||
let articleCache = 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
|
||||
// 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
|
||||
// that entry directly — worthwhile only when it's already hydrated
|
||||
// (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
|
||||
// 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;
|
||||
//
|
||||
// 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 {
|
||||
const response = await fetch(stub.path);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
@@ -279,8 +313,22 @@ async function runCrawl() {
|
||||
// to the total.
|
||||
const cacheSnapshotPromise = getAllCacheEntries();
|
||||
|
||||
// Wave 1 (root) — always fetched live: the author directories.
|
||||
const authorEntries = await fetchJson(ARTICLES_URL);
|
||||
// Wave 1 (root) — always fetched live: the author directories. Unlike
|
||||
// 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('.'));
|
||||
|
||||
// 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);
|
||||
return entries
|
||||
.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 {
|
||||
authorsFailed += 1;
|
||||
return [];
|
||||
@@ -329,7 +377,7 @@ async function runCrawl() {
|
||||
}
|
||||
|
||||
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);
|
||||
notifyProgress(true);
|
||||
|
||||
+5
-2
@@ -31,7 +31,10 @@ function parseScalar(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 };
|
||||
const meta = {};
|
||||
let arrayKey = null;
|
||||
@@ -44,5 +47,5 @@ export function parseFrontmatter(raw) {
|
||||
if (!rawValue.trim()) { meta[key] = []; arrayKey = key; }
|
||||
else { meta[key] = parseScalar(rawValue); arrayKey = null; }
|
||||
}
|
||||
return { meta, content: match[2] };
|
||||
return { meta, content: match[2] ?? '' };
|
||||
}
|
||||
+31
-3
@@ -73,11 +73,39 @@ function init() {
|
||||
|
||||
// Strings load first — everything else (including the error handler
|
||||
// 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 => {
|
||||
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(() => {});
|
||||
|
||||
+91
-34
@@ -11,6 +11,12 @@ const PAGE_SIZE = 20;
|
||||
// navigated away from (e.g. clicked into a different filter/page).
|
||||
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) {
|
||||
if (!value) return '';
|
||||
const date = new Date(`${String(value).slice(0, 10)}T12:00:00`);
|
||||
@@ -42,7 +48,8 @@ function filterLabel(filter) {
|
||||
if (!filter) return '';
|
||||
if (filter.type === 'author') return t('filterAuthor', { 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) {
|
||||
@@ -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) {
|
||||
const fragment = document.importNode(templates.card, true);
|
||||
const element = fragment.firstElementChild;
|
||||
@@ -156,7 +187,7 @@ function renderCard(article) {
|
||||
image.src = value;
|
||||
image.alt = article.title || article.slug;
|
||||
if (imageLink) {
|
||||
imageLink.href = `#post/${article.hash}`;
|
||||
imageLink.href = postHref(article);
|
||||
imageLink.removeAttribute('aria-hidden');
|
||||
imageLink.removeAttribute('tabindex');
|
||||
}
|
||||
@@ -165,20 +196,13 @@ function renderCard(article) {
|
||||
imageLink?.remove();
|
||||
element.classList.add('card--no-image');
|
||||
}
|
||||
field('link').href = `#post/${article.hash}`;
|
||||
field('link').href = postHref(article);
|
||||
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('reading-time'), article.readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); });
|
||||
const author = field('author');
|
||||
author.textContent = article.authorName || 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);
|
||||
return element;
|
||||
}
|
||||
@@ -186,16 +210,21 @@ function renderCard(article) {
|
||||
// Near-total crawl failure (wrong origin after a migration, CORS broken,
|
||||
// autoindex disabled) should be visible rather than silently rendering an
|
||||
// empty/near-empty list forever — see .claude/nginx-sitemap-crawler-plan.md L3.
|
||||
// Two independent signals feed this: every author's own listing failing
|
||||
// (checked regardless of site size, since discovery.js now silently skips
|
||||
// individual author failures — see the wave-2 comment in discovery.js) and,
|
||||
// for larger sites, a near-zero loaded/total ratio at 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.
|
||||
// Three independent signals feed this: the root author-listing fetch itself
|
||||
// failing outright (rootFailed), every individual author's own listing
|
||||
// failing (checked regardless of site size, since discovery.js now silently
|
||||
// skips individual author failures — see the wave-2 comment in
|
||||
// discovery.js), and, for larger sites, a near-zero loaded/total ratio at
|
||||
// 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) {
|
||||
if (!done) return;
|
||||
const { total, loaded, authorsTotal, authorsFailed } = getCrawlStats();
|
||||
const authorsBroken = authorsTotal > 0 && authorsFailed === authorsTotal;
|
||||
const { total, loaded, authorsTotal, authorsFailed, rootFailed } = getCrawlStats();
|
||||
// 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;
|
||||
if (!authorsBroken && !articlesBroken) return;
|
||||
const warning = document.createElement('p');
|
||||
@@ -246,6 +275,7 @@ export async function loadArticleList(filter = null, page = 1) {
|
||||
showListView();
|
||||
window.scrollTo(0, 0);
|
||||
clearSearchInput();
|
||||
lastListHash = location.hash || '#';
|
||||
const view = document.getElementById('list-view');
|
||||
view.innerHTML = `<p class="loading">${t('loadingArticles')}</p>`;
|
||||
const generation = ++currentGeneration;
|
||||
@@ -255,6 +285,11 @@ export async function loadArticleList(filter = null, page = 1) {
|
||||
if (!filter) return allArticles;
|
||||
return allArticles.filter(article => {
|
||||
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();
|
||||
});
|
||||
};
|
||||
@@ -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; });
|
||||
field('name').textContent = profile.title || profile.author;
|
||||
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();
|
||||
const social = field('social');
|
||||
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');
|
||||
view.innerHTML = `<p class="loading">${t('loadingGeneric')}</p>`;
|
||||
clearSearchInput();
|
||||
let generation;
|
||||
try {
|
||||
// Independent of each other — fetching in parallel instead of
|
||||
// sequentially shaves a round-trip. Safe to run together:
|
||||
// fetchAuthorProfile() never touches the DOM, and the profile card
|
||||
// below is only inserted once both have settled, so there's
|
||||
// nothing for loadArticleList()'s own view updates to race with.
|
||||
const [profile] = await Promise.all([
|
||||
fetchAuthorProfile(author),
|
||||
loadArticleList({ type: 'author', value: author }, page),
|
||||
]);
|
||||
if (page === 1 && profile) view.insertBefore(renderAuthorProfile(profile), view.firstChild);
|
||||
} catch (error) { view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`; }
|
||||
const profilePromise = fetchAuthorProfile(author);
|
||||
const listPromise = loadArticleList({ type: 'author', value: author }, page);
|
||||
// loadArticleList() bumps currentGeneration synchronously as one of
|
||||
// its first steps (before any await), so reading it right after
|
||||
// starting — not awaiting — that call captures the exact generation
|
||||
// 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);
|
||||
@@ -365,6 +414,7 @@ export async function loadSearch(query, page = 1) {
|
||||
showListView();
|
||||
window.scrollTo(0, 0);
|
||||
const generation = ++currentGeneration;
|
||||
lastListHash = location.hash || '#';
|
||||
const view = document.getElementById('list-view');
|
||||
view.innerHTML = `<p class="loading">${t('searching')}</p>`;
|
||||
const input = document.getElementById('search-input');
|
||||
@@ -466,7 +516,7 @@ function buildTOC(content) {
|
||||
export async function loadArticle(hash) {
|
||||
showArticleView();
|
||||
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();
|
||||
const view = document.getElementById('article-view');
|
||||
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(); }),
|
||||
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);
|
||||
// This view always fetches its own content directly (above) rather
|
||||
// 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;
|
||||
// Publish date comes from the article file's own name (already
|
||||
// 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('reading-time'), readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); });
|
||||
const image = field('image');
|
||||
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.categoryYear });
|
||||
category.href = `#category/${encodeURIComponent(value)}`;
|
||||
});
|
||||
renderTags(field('tags'), article.tags);
|
||||
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.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 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);
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user