// A small YAML subset reader for repo-local files (store/workflows/*.yml), so storebound stays at zero runtime // dependencies. Pure. Supported: block maps (`key: value`), block lists (`- item`, `- key: value` with the rest of the // map indented below), nested by indentation (spaces only), plain / 'single' / "double" quoted scalars, inline lists // `[a, b]`, `#` comments, `|` and `>` block scalars, true / false / null / numbers. Not supported (an error names the // line): anchors, aliases, tags, multi-document files, inline maps `{...}`, tabs for indentation. export type YamlValue = string | number | boolean | null | YamlValue[] | { [k: string]: YamlValue }; interface Line { n: number; indent: number; text: string } function stripComment(s: string): string { let q: string | null = null; for (let i = 0; i < s.length; i++) { const c = s[i]!; if (q) { if (c === q && !(q === '"' && s[i - 1] === '\\')) q = null; continue; } if (c === '"' || c === '\'') { if (i === 0 || /[\s:[,-]/.test(s[i - 1]!)) q = c; continue; } if (c === '#' && (i === 0 || /\s/.test(s[i - 1]!))) return s.slice(0, i).trimEnd(); } return s.trimEnd(); } function scalar(raw: string, n: number): YamlValue { const s = raw.trim(); if (s === '' || s === '~' || s === 'null') return s === '' ? '' : null; if (s.startsWith('"')) { if (!s.endsWith('"') || s.length < 2) throw new Error(`line ${n}: unterminated "string"`); try { return JSON.parse(s) as string; } catch { throw new Error(`line ${n}: bad "string"`); } } if (s.startsWith('\'')) { if (!s.endsWith('\'') || s.length < 2) throw new Error(`line ${n}: unterminated 'string'`); return s.slice(1, -1).replace(/''/g, '\''); } if (s.startsWith('[')) { if (!s.endsWith(']')) throw new Error(`line ${n}: unterminated [list]`); const inner = s.slice(1, -1).trim(); if (!inner) return []; const parts: string[] = []; let q: string | null = null, cur = ''; for (const c of inner) { if (q) { cur += c; if (c === q) q = null; continue; } if (c === '"' || c === '\'') { q = c; cur += c; continue; } if (c === ',') { parts.push(cur); cur = ''; continue; } cur += c; } parts.push(cur); return parts.map(p => scalar(p, n)); } if (s.startsWith('{')) throw new Error(`line ${n}: inline maps {...} are not supported; use one key per line`); if (/^[&*!]/.test(s)) throw new Error(`line ${n}: anchors, aliases and tags are not supported`); if (s === 'true') return true; if (s === 'false') return false; if (/^-?\d+(\.\d+)?$/.test(s) && !/^0\d/.test(s)) return Number(s); return s; } /** Index of the `:` that separates a map key from its value (outside quotes), or -1. */ function keyColon(s: string): number { let q: string | null = null; for (let i = 0; i < s.length; i++) { const c = s[i]!; if (q) { if (c === q) q = null; continue; } if ((c === '"' || c === '\'') && i === 0) { q = c; continue; } if (c === ':' && (i === s.length - 1 || s[i + 1] === ' ')) return i; } return -1; } export function parseYaml(text: string): YamlValue { const lines: Line[] = []; const raw = text.replace(/\r\n?/g, '\n').split('\n'); for (let i = 0; i < raw.length; i++) { const l = raw[i]!; if (/^\t/.test(l) || /^ *\t/.test(l)) throw new Error(`line ${i + 1}: tabs are not allowed for indentation`); if (/^(---|\.\.\.)\s*$/.test(l)) { if (lines.length) throw new Error(`line ${i + 1}: one document per file`); continue; } lines.push({ n: i + 1, indent: l.length - l.trimStart().length, text: l.trim() }); } let pos = 0; const content = (l: Line) => l.text !== '' && !l.text.startsWith('#'); const skip = () => { while (pos < lines.length && !content(lines[pos]!)) pos++; }; /** Block scalar (| or >) below a key at `parentIndent`. */ function block(style: string, parentIndent: number): string { const out: string[] = []; let ind = -1; while (pos < lines.length) { const l = lines[pos]!; const rawLine = raw[l.n - 1]!; if (l.text === '') { out.push(''); pos++; continue; } if (l.indent <= parentIndent) break; if (ind < 0) ind = l.indent; out.push(rawLine.slice(Math.min(ind, l.indent))); pos++; } while (out.length && out[out.length - 1] === '') out.pop(); const keep = style.endsWith('-') ? '' : '\n'; return (style.startsWith('>') ? out.join('\n').replace(/([^\n])\n(?=[^\n])/g, '$1 ') : out.join('\n')) + (out.length ? keep : ''); } function value(indent: number): YamlValue { skip(); if (pos >= lines.length) return null; const first = lines[pos]!; if (first.indent < indent) return null; return first.text.startsWith('- ') || first.text === '-' ? list(first.indent) : map(first.indent); } function entry(rest: string, n: number, ownIndent: number): [string, YamlValue] { const c = keyColon(rest); if (c < 0) throw new Error(`line ${n}: expected "key: value"`); const key = String(scalar(rest.slice(0, c), n)); const v = stripComment(rest.slice(c + 1)).trim(); if (v === '') { skip(); const next = lines[pos]; // a list may sit at the key's own indentation ("steps:\n- id: a") if (next && next.indent === ownIndent && (next.text.startsWith('- ') || next.text === '-')) return [key, list(ownIndent)]; return [key, value(ownIndent + 1) ?? null]; } if (/^[|>][+-]?$/.test(v)) return [key, block(v, ownIndent)]; return [key, scalar(v, n)]; } function map(indent: number): { [k: string]: YamlValue } { const out: { [k: string]: YamlValue } = {}; for (;;) { skip(); if (pos >= lines.length) break; const l = lines[pos]!; if (l.indent < indent) break; if (l.indent > indent) throw new Error(`line ${l.n}: unexpected indentation`); if (l.text.startsWith('- ')) throw new Error(`line ${l.n}: a list item where a "key: value" was expected`); pos++; const [k, v] = entry(stripComment(l.text), l.n, indent); if (k in out) throw new Error(`line ${l.n}: "${k}" twice`); out[k] = v; } return out; } function list(indent: number): YamlValue[] { const out: YamlValue[] = []; for (;;) { skip(); if (pos >= lines.length) break; const l = lines[pos]!; if (l.indent < indent) break; if (l.indent > indent) throw new Error(`line ${l.n}: expected a list item "- ..."`); if (!(l.text.startsWith('- ') || l.text === '-')) break; pos++; const rest = stripComment(l.text.slice(1)).trim(); if (rest === '') { out.push(value(indent + 1)); continue; } const itemIndent = indent + (l.text.length - l.text.slice(1).trimStart().length); if (keyColon(rest) >= 0 && !/^["'[]/.test(rest)) { // "- key: value" starts a map whose other keys are indented to the item's text column const [k, v] = entry(rest, l.n, itemIndent); const m: { [k: string]: YamlValue } = { [k]: v }; skip(); if (pos < lines.length && lines[pos]!.indent === itemIndent && !lines[pos]!.text.startsWith('- ')) { for (const [k2, v2] of Object.entries(map(itemIndent))) { if (k2 in m) throw new Error(`line ${l.n}: "${k2}" twice`); m[k2] = v2; } } out.push(m); } else out.push(scalar(rest, l.n)); } return out; } const v = value(0); skip(); if (pos < lines.length) throw new Error(`line ${lines[pos]!.n}: unexpected content`); return v; }