Files
cmkiss/js/router.js
T
biosiasandClaude Sonnet 5 24f9740d97 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
2026-09-12 16:23:26 +02:00

122 lines
6.7 KiB
JavaScript

// Entry point — loads templates then dispatches routes on hash changes.
//
// Hash routing scheme:
// 0 segments → all articles, page 1
// first = "search" → search results (#search/query)
// first = "search", ends "/page/N" → search results, page N (#search/query/page/2)
// 2 segments, first = "page" → all articles, page N (#page/2)
// 2 segments, first = "author" → author filter, page 1 (#author/alice)
// 2 segments, first = "category" or "tag" → filtered list, page 1
// 3 segments, first = "post" → single article (#post/alice/my-article)
// 4 segments, first = "author", third = "page" → author filter, page N (#author/alice/page/2)
// 4 segments, first = "category", third = "page" → category filter, page N (#category/linux/page/2)
// 4 segments, first = "tag", third = "page" → tag filter, page N (#tag/guides/page/2)
import { MARKED_URL } from './config.js';
import { loadTemplates } from './templates.js';
import { loadStrings, t } from './i18n.js';
import { loadArticleList, loadAuthorView, loadCategoryView, loadTagView, loadArticle, loadSearch } from './views.js';
// Dynamically inject marked.js as a plain global script before the app starts.
function loadScript(url) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = url;
s.onload = resolve;
s.onerror = () => reject(new Error(`Failed to load ${url}`));
document.head.appendChild(s);
});
}
// Parse a page-number segment — returns 1 for any non-positive-integer value.
const parsePage = s => { const n = +s; return Number.isInteger(n) && n >= 1 ? n : 1; };
function router() {
const hash = location.hash.slice(1); // strip leading #
const parts = hash.split('/').filter(Boolean);
// Search: decode query and guard against empty string (#search or #search/).
// A trailing "/page/N" is peeled off for pagination first — the query
// itself is always a single encodeURIComponent()'d segment when built
// by this app (see the search-form handler below), so this only
// matters for hand-typed/legacy multi-segment links, where joining the
// rest back with "/" keeps a literal slash in the query working too.
if (parts[0] === 'search') {
const rest = parts.slice(1);
const hasPage = rest.length >= 3 && rest[rest.length - 2] === 'page' && /^\d+$/.test(rest[rest.length - 1]);
const queryParts = hasPage ? rest.slice(0, -2) : rest;
const q = decodeURIComponent(queryParts.join('/'));
if (q) loadSearch(q, hasPage ? parsePage(rest[rest.length - 1]) : 1); else loadArticleList();
}
else if (parts.length === 0) loadArticleList();
else if (parts.length === 2 && parts[0] === 'page') loadArticleList(null, parsePage(parts[1]));
else if (parts.length === 2 && parts[0] === 'author') loadAuthorView(decodeURIComponent(parts[1]));
else if (parts.length === 2 && parts[0] === 'category') loadCategoryView(decodeURIComponent(parts[1]));
else if (parts.length === 2 && parts[0] === 'tag') loadTagView(decodeURIComponent(parts[1]));
else if (parts.length === 3 && parts[0] === 'post') loadArticle(parts.slice(1).map(decodeURIComponent).join('/'));
else if (parts.length === 4 && parts[0] === 'author' && parts[2] === 'page') loadAuthorView(decodeURIComponent(parts[1]), parsePage(parts[3]));
else if (parts.length === 4 && parts[0] === 'category' && parts[2] === 'page') loadCategoryView(decodeURIComponent(parts[1]), parsePage(parts[3]));
else if (parts.length === 4 && parts[0] === 'tag' && parts[2] === 'page') loadTagView(decodeURIComponent(parts[1]), parsePage(parts[3]));
else loadArticleList();
}
// All routing — initial and hashchange — waits for this before running.
let ready;
function init() {
// Wire up search form submission → navigate to #search/query
document.getElementById('search-form')?.addEventListener('submit', e => {
e.preventDefault();
const q = document.getElementById('search-input')?.value?.trim();
if (q) location.hash = `#search/${encodeURIComponent(q)}`;
});
// 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()])).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 (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 => {
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(() => {});
}
// readyState guard in case DOMContentLoaded already fired
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.addEventListener('hashchange', () => ready.then(router).catch(() => {}));