/** * Minimal YAML frontmatter parser tailored to the prompt workflow files * in this directory. Mirrors the subset of YAML that the existing `c-*.md` * and `p-*.md` files actually use: * * key: value -> string * key: "quoted value" -> string (quotes stripped) * key: -> block scalar (everything more-indented is * collected, de-indented, and returned as one * string with newlines) * * Comments (`# ...`) and blank lines are skipped. Quoted strings have * their quotes stripped, matching the convention used in the rest of the * repo (no nested quoting or escapes are needed by the fixtures). */ export interface ParsedDoc { frontmatter: Record; body: string; } function stripComment(line: string): string { const idx = line.indexOf("#"); // Only treat `#` as a comment when it appears at start-of-line or // after whitespace; otherwise the `#` is part of a value (it never // is in this repo, but it keeps the parser robust). if (idx === -1) return line; const before = line.slice(0, idx); if (/^\s*$/.test(before)) return before; return line; } function deIndentBlock(block: string): string { const lines = block.split("\n"); const indents = lines .filter((l) => l.trim() !== "") .map((l) => l.match(/^[ \t]*/)?.[0].length ?? 0); const min = indents.length === 0 ? 0 : Math.min(...indents); if (min === 0) return block.replace(/^\n+/, ""); return lines .map((l) => (l.length >= min ? l.slice(min) : l)) .join("\n") .replace(/^\n+/, ""); } export function parseFrontmatter(content: string): ParsedDoc { const normalized = content.replace(/\r\n/g, "\n"); if (!normalized.startsWith("---")) { return { frontmatter: {}, body: normalized }; } // The closing fence is `\n---` (optionally followed by whitespace/eol). const endIndex = normalized.indexOf("\n---", 3); if (endIndex === -1) { return { frontmatter: {}, body: normalized }; } const block = normalized.slice(3, endIndex); const body = normalized.slice(endIndex + 4).replace(/^\s+/, ""); const frontmatter: Record = {}; let currentKey: string | null = null; let currentBlock: string[] | null = null; let currentIndent = -1; const flush = () => { if (currentKey === null || currentBlock === null) return; frontmatter[currentKey] = deIndentBlock(currentBlock.join("\n")); currentKey = null; currentBlock = null; currentIndent = -1; }; for (const rawLine of block.split("\n")) { const line = stripComment(rawLine); if (line.trim() === "") { // Preserve blank lines inside an open block scalar, otherwise // ignore them between keys. if (currentBlock !== null) currentBlock.push(""); continue; } const indent = line.match(/^[ \t]*/)?.[0].length ?? 0; if (currentKey !== null && currentBlock !== null && indent > currentIndent) { currentBlock.push(line); continue; } flush(); const match = line.match(/^([A-Za-z][\w-]*):\s*(.*)$/); if (!match) continue; const key = match[1]; const raw = match[2].trim(); if (raw === "" || raw === ">" || raw === "|-" || raw === ">-" || raw === "|") { currentKey = key; currentBlock = []; currentIndent = indent; } else { const quoted = (raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'")); frontmatter[key] = quoted ? raw.slice(1, -1) : raw; } } flush(); return { frontmatter, body }; } /** * Split a `chain:` frontmatter value into individual step names. * Mirrors the upstream convention of ` -> ` as the delimiter and trims * whitespace from each step. */ export function parseChain(value: string | undefined): string[] { if (!value) return []; return value .split(" -> ") .map((s) => s.trim()) .filter((s) => s.length > 0); }