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
52 lines
2.3 KiB
JavaScript
52 lines
2.3 KiB
JavaScript
// Small YAML-frontmatter reader for the canonical blog schema. It supports
|
|
// quoted/unquoted scalars, booleans, numbers and scalar arrays in inline or
|
|
// block form. The editor remains the authoritative validator and writer.
|
|
|
|
function parseScalar(raw) {
|
|
const value = raw.trim();
|
|
if (!value) return '';
|
|
if (value === 'true') return true;
|
|
if (value === 'false') return false;
|
|
if (value === 'null' || value === '~') return null;
|
|
if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
|
|
if (value.startsWith('"') && value.endsWith('"')) {
|
|
try { return JSON.parse(value); } catch { return value.slice(1, -1); }
|
|
}
|
|
if (value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1).replace(/''/g, "'");
|
|
if (value.startsWith('[') && value.endsWith(']')) {
|
|
const inside = value.slice(1, -1).trim();
|
|
if (!inside) return [];
|
|
const values = [];
|
|
let token = '';
|
|
let quote = '';
|
|
for (const character of inside) {
|
|
if ((character === '"' || character === "'") && (!quote || quote === character)) quote = quote ? '' : character;
|
|
if (character === ',' && !quote) { values.push(parseScalar(token)); token = ''; }
|
|
else token += character;
|
|
}
|
|
values.push(parseScalar(token));
|
|
return values;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function parseFrontmatter(raw) {
|
|
// 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;
|
|
for (const line of match[1].split(/\r?\n/)) {
|
|
const item = line.match(/^\s*-\s+(.*)$/);
|
|
if (item && arrayKey) { meta[arrayKey].push(parseScalar(item[1])); continue; }
|
|
const field = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(?:\s*(.*))?$/);
|
|
if (!field) { arrayKey = null; continue; }
|
|
const [, key, rawValue = ''] = field;
|
|
if (!rawValue.trim()) { meta[key] = []; arrayKey = key; }
|
|
else { meta[key] = parseScalar(rawValue); arrayKey = null; }
|
|
}
|
|
return { meta, content: match[2] ?? '' };
|
|
}
|