/** * YAML frontmatter → Meta callout (opt-in via --frontmatter-as-callout). * * Parses a leading `---\n…\n---` block as a minimal YAML subset and renders * a blockquote "Meta" callout that lark-hirono's normalize pass later * converts to a Feishu callout. * * Supported value forms: bare string, quoted string, ISO date, flow-style * array (`[a, b, c]`). Block-style arrays and nested maps are rejected * with a clear error — consumers with richer frontmatter should * preprocess it themselves. */ // ─── Types ────────────────────────────────────────────────────────────── export interface ParsedFrontmatter { /** Document body with the `---…---` block stripped. */ body: string; /** Rendered Meta callout (blockquote) — null when no frontmatter was present. */ callout: string | null; /** Raw key/value pairs in parse order. */ data: Record; } // Built-in key order; anything else falls through alphabetically. const KEY_ORDER = [ "type", "created", "updated", "raw_source", "tags", "refs", "tier", "source_count", "highlights", ]; // ─── Parser ───────────────────────────────────────────────────────────── const FENCE_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/; function parseValue(raw: string): string | string[] { const v = raw.trim(); if (v === "") return ""; // Flow-style array: [a, b, c] if (v.startsWith("[") && v.endsWith("]")) { const inner = v.slice(1, -1).trim(); if (inner === "") return []; return inner.split(",").map((item) => stripQuotes(item.trim())); } // Quoted scalar return stripQuotes(v); } function stripQuotes(s: string): string { if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { return s.slice(1, -1); } return s; } function formatLabel(key: string): string { const spaced = key.replace(/_/g, " "); return spaced.charAt(0).toUpperCase() + spaced.slice(1); } function formatValue(v: string | string[]): string { if (Array.isArray(v)) return v.join(", "); return v; } function renderCallout(data: Record): string { const keys = Object.keys(data); if (keys.length === 0) return ""; const rows: string[] = ["> **Meta**"]; const seen = new Set(); for (const key of KEY_ORDER) { if (key in data) { rows.push(`> - **${formatLabel(key)}**: ${formatValue(data[key])}`); seen.add(key); } } for (const key of keys.sort()) { if (!seen.has(key)) { rows.push(`> - **${formatLabel(key)}**: ${formatValue(data[key])}`); } } return rows.join("\n"); } /** * Parse a leading YAML frontmatter block. * * If no frontmatter is present: returns `{ body: md, callout: null, data: {} }`. * If malformed (block-style list, nested map, non-`key: value` line): throws. */ export function parseFrontmatter(md: string): ParsedFrontmatter { const match = md.match(FENCE_RE); if (!match) return { body: md, callout: null, data: {} }; const raw = match[1]; const data: Record = {}; for (const rawLine of raw.split(/\r?\n/)) { const line = rawLine.trimEnd(); if (line.trim() === "") continue; // Block-list continuation ("- item") without a preceding array context is unsupported. if (/^\s*-\s/.test(line)) { throw new Error( `frontmatter: block-style list not supported (found "${line.trim()}"). ` + `Use flow syntax: tags: [a, b, c]` ); } const kv = line.match(/^(\w[\w-]*)\s*:\s*(.*)$/); if (!kv) { throw new Error(`frontmatter: cannot parse line "${line}"`); } const [, key, valueStr] = kv; data[key] = parseValue(valueStr); } const body = md.slice(match[0].length).replace(/^\n+/, ""); const callout = renderCallout(data); return { body, callout: callout || null, data }; }