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.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
Reference in New Issue
Block a user