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.
110 lines
3.4 KiB
TypeScript
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';
|
|
}
|