// Minimal YAML-subset frontmatter, enough for memory files. // // A full YAML parser is deliberately avoided: memory files only need scalars // and string arrays, and every added dependency is one more thing that can fail // to load on someone else's machine. This reads what we write and tolerates // what humans type. export interface Frontmatter { [key: string]: string | number | boolean | string[] | undefined } /** Parse a leading `---` block. Returns empty data when there is none. */ export function parseFrontmatter(raw: string): { data: Frontmatter; body: string } { // Tolerate a UTF-8 BOM and leading blank lines before the fence. const text = raw.replace(/^\uFEFF/, "") const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text) if (!match) return { data: {}, body: text.trim() } const data: Frontmatter = {} let currentKey: string | undefined for (const line of match[1].split(/\r?\n/)) { if (!line.trim() || line.trim().startsWith("#")) continue // `- item` continues the array opened by the previous `key:`. const item = /^\s*-\s+(.*)$/.exec(line) if (item && currentKey) { const prev = data[currentKey] const list = Array.isArray(prev) ? prev : [] list.push(String(scalar(item[1]))) data[currentKey] = list continue } const kv = /^([A-Za-z_][\w.-]*)\s*:\s*(.*)$/.exec(line) if (!kv) continue currentKey = kv[1] const value = kv[2].trim() if (value === "") { // Either an empty scalar or the head of a block list; the `- ` branch // above fills it in. Mark it as an empty array so round-tripping works. data[currentKey] = [] continue } data[currentKey] = value.startsWith("[") ? parseInlineList(value) : scalar(value) } return { data, body: text.slice(match[0].length).trim() } } /** Serialize data + body back to a memory file. Key order is preserved. */ export function stringifyFrontmatter(data: Frontmatter, body: string): string { const lines: string[] = [] for (const [key, value] of Object.entries(data)) { if (value === undefined) continue if (Array.isArray(value)) { if (value.length === 0) { lines.push(`${key}: []`) continue } // Arrays are written one item per line: readable in a diff, and immune // to quoting problems that inline JSON lists hit with Chinese text. lines.push(`${key}:`) for (const entry of value) lines.push(` - ${quoteScalar(entry)}`) continue } lines.push(`${key}: ${quoteScalar(value)}`) } return `---\n${lines.join("\n")}\n---\n\n${body.trim()}\n` } function scalar(raw: string): string | number | boolean { const value = unquote(raw.trim()) if (value === "true") return true if (value === "false") return false if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value) return value } function parseInlineList(raw: string): string[] { const inner = raw.trim().replace(/^\[/, "").replace(/\]$/, "") if (!inner.trim()) return [] return splitTopLevel(inner).map((part) => String(unquote(part.trim()))) } /** Split on commas that are not inside quotes. */ function splitTopLevel(input: string): string[] { const parts: string[] = [] let buf = "" let quote: string | undefined for (const ch of input) { if (quote) { if (ch === quote) quote = undefined else buf += ch continue } if (ch === '"' || ch === "'") { quote = ch continue } if (ch === ",") { parts.push(buf) buf = "" continue } buf += ch } if (buf.trim()) parts.push(buf) return parts } function unquote(value: string): string { if (value.length >= 2 && ((value[0] === '"' && value.endsWith('"')) || (value[0] === "'" && value.endsWith("'")))) { return value.slice(1, -1) } return value } /** Quote only when the value would otherwise not round-trip. */ function quoteScalar(value: string | number | boolean): string { if (typeof value === "number" || typeof value === "boolean") return String(value) // A bare `:` or a leading marker would be read back as YAML structure. if (/^[\s>|*&!%@`#-]|[:#]\s|^$/.test(value)) { return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` } return value }