storebound/cli/lib/yaml.ts
CRT 236977940e flows, listing translate, scan 2.2 + --originality, prices export with subscriptions, site sections
- storebound flow list|validate|run: repo-local workflows (store/workflows/*.yml, storebound.workflows.*), writes dry unless --apply, owner steps never run, --from / --resume; init templates for app and game; MCP flow_list + dry flow_run; release-flows skill
- storebound listing translate|check|reviewed: per-language listing drafts with store limits and keyword rules, agent or --translate hook, marked until reviewed; MCP listing_translate / listing_check; listing-localization skill
- scan: Apple 2.2 beta/demo/test wording rule; scan --originality [--against]
- prices export includes subscriptions; prices import skips their rows
- site: rejection scan section, Desktop app section with fixture-mode screenshots, Everything else grid, FAQ 4.3 answer
- README, CHANGELOG, adapters, API tables
2026-10-12 01:24:21 +08:00

171 lines
7.3 KiB
TypeScript

// 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;
}