Files
tts-workshop/packages/bgm/src/parse.ts
T
hypercross c0967ab71f feat(bgm): add board game manifest loader
Parse yaml/json/toml and markdown code blocks into packages, expanding
$variants via typed-csv and collecting parts, surfaces, and setups by
include patterns. Ships zod validation, a vitest config, and 16 tests.
2026-08-09 18:59:14 +08:00

110 lines
3.4 KiB
TypeScript

/**
* Parse raw definition files (yaml/json/toml text) into JSON objects.
*
* A def file's document can be either a single JSON object (the root) or a
* list of objects; both are handled per docs/bgm-format.md §3. In list mode,
* each object is a separate definition.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { parse as parseYaml } from 'yaml';
import { parse as parseToml } from 'smol-toml';
import { BgmError, type DefFile, type ParsedDef } from './types.js';
/**
* Parse a def file's text into a list of definition objects.
*
* @returns the parsed objects; the root object (index `-1`) or the list
* items (index `0..n`)
*/
export function parseDefText(file: DefFile): ParsedDef[] {
if (file.kind === 'csv') return [];
const text = file.text.trim();
if (!text) return [];
const out: ParsedDef[] = [];
let doc: unknown;
try {
doc = parseText(file.kind, text);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new BgmError(`Failed to parse ${file.kind}: ${message}`, file.source);
}
const push = (value: unknown, index: number) => {
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
out.push({ file: file.name, index, value: value as Record<string, unknown>, source: file.source });
} else if (value !== null) {
throw new BgmError(`Expected a JSON object, got ${typeof value}`, file.source);
}
};
if (Array.isArray(doc)) {
doc.forEach((item, index) => push(item, index));
} else {
push(doc, -1);
}
return out;
}
function parseText(kind: DefFile['kind'], text: string): unknown {
switch (kind) {
case 'json':
return JSON.parse(text);
case 'yaml':
return parseYaml(text);
case 'toml':
return parseToml(text);
case 'markdown':
// Markdown-only blocks contain no definitions; handled by the caller.
return null;
}
}
/**
* Parse a directory of real files (yaml/json/toml/md) into def files.
* Markdown files are also returned here as-is; code-block extraction happens
* in `collect.ts` via `scanMarkdown`.
*
* @param dir absolute directory to scan
* @param root the path-style root the file names are relative to (for
* consistent naming with virtual files), e.g. `harbor`
*/
export function readDefFiles(dir: string, root: string): DefFile[] {
const out: DefFile[] = [];
const walk = (current: string, rel: string) => {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const abs = path.join(current, entry.name);
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walk(abs, relPath);
} else if (/csv$/i.test(entry.name)) {
out.push({
name: `${root}/${relPath}`,
text: fs.readFileSync(abs, 'utf8'),
source: abs,
kind: 'csv',
});
} else if (/\.(ya?ml|json|toml|md|markdown)$/i.test(entry.name)) {
const kind = kindOf(entry.name);
out.push({
name: `${root}/${relPath}`,
text: fs.readFileSync(abs, 'utf8'),
source: abs,
kind,
});
}
}
};
walk(dir, '');
return out;
}
function kindOf(name: string): DefFile['kind'] {
if (name.endsWith('.json')) return 'json';
if (name.endsWith('.toml')) return 'toml';
if (name.endsWith('.md') || name.endsWith('.markdown')) return 'markdown';
if (name.endsWith('.csv')) return 'csv';
return 'yaml';
}