- 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
171 lines
7.3 KiB
TypeScript
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;
|
|
}
|