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

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] ?? '' };
}