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
110 lines
4.5 KiB
JavaScript
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.
|
|
}
|
|
}
|