Files
cmkiss/js/cache.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

110 lines
4.5 KiB
JavaScript

// Persistent cache for discovered articles, keyed by each article file's own
// path. Raw IndexedDB (no wrapper library — see
// .claude/nginx-sitemap-crawler-plan.md L4/L5 on the zero-dependency,
// long-stable-API requirement). If IndexedDB is unavailable (private
// browsing, old browser, blocked), every operation below silently becomes a
// no-op so discovery.js gets live-fetch behaviour for free without needing
// its own availability branching (plan doc R6).
// Bump this whenever discovery.js's derived article record shape changes.
// The version is baked into the DB name so a code deploy invalidates every
// cached entry immediately, instead of stale-shaped records lingering until
// each file's mtime happens to change — which, on a "deploy and forget"
// site, could be years.
const CACHE_SCHEMA_VERSION = 3;
const DB_NAME = `cmjs-cache-v${CACHE_SCHEMA_VERSION}`;
const STORE_NAME = 'articleFiles';
let cacheAvailable = true;
let dbPromise = null;
function openDb() {
if (!dbPromise) {
dbPromise = new Promise((resolve, reject) => {
if (typeof indexedDB === 'undefined') { reject(new Error('IndexedDB unavailable')); return; }
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(STORE_NAME, { keyPath: 'path' });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onblocked = () => reject(new Error('IndexedDB open blocked'));
}).catch(error => { cacheAvailable = false; throw error; });
}
return dbPromise;
}
// Opens a store and resolves/rejects with the given IDBRequest's own outcome
// — the standard pattern, so result timing never depends on transaction
// 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 store = db.transaction(STORE_NAME, mode).objectStore(STORE_NAME);
return await new Promise((resolve, reject) => {
const request = makeRequest(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
} catch {
// 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;
}
}
export function isCacheAvailable() {
return cacheAvailable;
}
// Returns every cached { path, mtime, article } record as a Map keyed by
// path — one bulk read instead of a separate IndexedDB round-trip per
// article file, which matters once there are hundreds of them: discovery.js
// reads this once up front rather than doing a point lookup per file during
// indexing. Empty Map on a miss or if the cache is unavailable.
export async function getAllCacheEntries() {
const records = await runRequest('readonly', store => store.getAll());
return new Map((records || []).map(record => [record.path, record]));
}
export async function setCacheEntry(path, entry) {
await runRequest('readwrite', store => store.put({ ...entry, path }));
}
// Removes any cached entry whose path is no longer in the live directory
// listing (renamed/deleted slugs), so the cache doesn't grow unbounded.
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 store = db.transaction(STORE_NAME, 'readwrite').objectStore(STORE_NAME);
await new Promise((resolve, reject) => {
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = event => {
const cursor = event.target.result;
if (!cursor) { resolve(); return; }
if (!valid.has(cursor.value.path)) cursor.delete();
cursor.continue();
};
cursorRequest.onerror = () => reject(cursorRequest.error);
});
} catch {
// Same reasoning as runRequest() above — a failed prune pass doesn't
// mean IndexedDB itself is unavailable.
}
}