// lib/util/frontmatter.mjs // Hand-rolled YAML-frontmatter parser. // // Supported subset: // - String scalars (quoted or unquoted) // - Numbers (integer + float) // - Booleans (true / false) // - null // - Single-level dicts // - Lists of scalars (- value) // // Deliberately rejects: nested dicts in lists, multi-line strings, // anchors/aliases, tags, flow style ({...} / [...]). // // Why no js-yaml: zero-deps invariant. Templates emit only this subset. import { issue, ok, fail } from './result.mjs'; const FRONTMATTER_RE = /^?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/; /** * Split raw markdown into { frontmatter, body }. * Returns { hasFrontmatter: false } when no leading --- block exists. */ export function splitFrontmatter(text) { if (typeof text !== 'string') return { hasFrontmatter: false, body: '' }; const stripped = text.replace(/^/, ''); const m = stripped.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/); if (!m) return { hasFrontmatter: false, body: stripped }; return { hasFrontmatter: true, frontmatter: m[1], body: m[2] || '', }; } /** * Parse a YAML-frontmatter string into a JS object. * @returns {import('./result.mjs').Result} */ export function parseFrontmatter(yamlText) { if (typeof yamlText !== 'string') { return fail(issue('FM_INPUT', 'Frontmatter input is not a string')); } const lines = yamlText.split(/\r?\n/); const out = {}; const errors = []; let i = 0; while (i < lines.length) { const line = lines[i]; if (line.trim() === '' || line.trimStart().startsWith('#')) { i++; continue; } const indentMatch = line.match(/^(\s*)/); const indent = indentMatch ? indentMatch[0].length : 0; if (indent > 0) { errors.push(issue('FM_INDENT', `Unexpected indentation at line ${i + 1}`, 'Top-level keys only; nested dicts unsupported.')); i++; continue; } const kv = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/); if (!kv) { errors.push(issue('FM_SYNTAX', `Cannot parse line ${i + 1}: ${line}`)); i++; continue; } const key = kv[1]; const rest = kv[2]; if (rest === '' || rest === undefined) { const list = []; let j = i + 1; while (j < lines.length) { const next = lines[j]; if (next.trim() === '') { j++; continue; } const m2 = next.match(/^\s+-\s+(.*)$/); if (!m2) break; list.push(parseScalar(m2[1])); j++; } if (list.length > 0) { out[key] = list; i = j; } else { out[key] = null; i++; } continue; } out[key] = parseScalar(rest); i++; } if (errors.length > 0) return { valid: false, errors, warnings: [], parsed: out }; return ok(out); } function parseScalar(raw) { const s = raw.trim(); if (s === '') return ''; if (s === 'null' || s === '~') return null; if (s === 'true') return true; if (s === 'false') return false; if (s === '[]') return []; if (s === '{}') return {}; if (/^-?\d+$/.test(s)) return Number.parseInt(s, 10); if (/^-?\d+\.\d+$/.test(s)) return Number.parseFloat(s); if (s.startsWith('"') && s.endsWith('"')) { return s.slice(1, -1).replace(/\\(.)/g, (_, ch) => { if (ch === 'n') return '\n'; if (ch === 't') return '\t'; if (ch === 'r') return '\r'; return ch; }); } if (s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1); return s; } /** * Parse a markdown file's frontmatter directly from its full text. * @returns {import('./result.mjs').Result} */ export function parseDocument(text) { const split = splitFrontmatter(text); if (!split.hasFrontmatter) { return fail(issue('FM_MISSING', 'No frontmatter block found')); } const result = parseFrontmatter(split.frontmatter); return { ...result, parsed: { frontmatter: result.parsed, body: split.body } }; }