Group the bgm spec cluster under docs/bgm and move dev logs and plans under docs/status, add an overview index, and update cross-references in the docs, README, and source comments.
127 lines
4.3 KiB
TypeScript
127 lines
4.3 KiB
TypeScript
/**
|
|
* The `$variants` directive: parse a CSV into a typed object array and
|
|
* extend the original object with each row.
|
|
*
|
|
* Per docs/bgm/format.md §1:
|
|
* - The CSV's first row is the header, the second row is the type declaration
|
|
* (`string`, `number`, `string[]`, `[number;number;number;number]`, ...),
|
|
* the remaining rows are data.
|
|
* - Rows are validated against a schema derived from the type row.
|
|
* - A cell for an array/tuple type uses `;` as the element separator
|
|
* (`[0;0;5;2]`), because `,` is the CSV delimiter.
|
|
* - `$variants` can be a single source or an array of them. A source is a
|
|
* file/URL path if its first line ends in `.csv`, otherwise inline CSV.
|
|
*
|
|
* Parsing is delegated to `typed-csv`'s `parseCsv`, which implements exactly
|
|
* this header/schema/data layout and validates each row against a schema
|
|
* derived from the type row.
|
|
*
|
|
* Paths resolve against the virtual def map — the same names `include` and
|
|
* `file=` resolve against — so a CSV can be a real file or a markdown code
|
|
* block (` ```csv file=parts/cargo.csv `).
|
|
*/
|
|
import * as path from 'node:path';
|
|
import { parseCsv } from 'typed-csv/csv-loader';
|
|
import { BgmError, type DefFile } from './types.js';
|
|
|
|
/** The parsed rows of a CSV, converted to typed values. */
|
|
export interface CsvData {
|
|
/** Column names from the header row. */
|
|
header: string[];
|
|
/** One object per data row. */
|
|
rows: Record<string, unknown>[];
|
|
}
|
|
|
|
/**
|
|
* Parse CSV text into typed row objects using `typed-csv`.
|
|
*
|
|
* @param text the CSV source (header + schema + data rows)
|
|
* @param source the source location, for error messages
|
|
*/
|
|
export function parseCsvData(text: string, source: string): CsvData {
|
|
try {
|
|
const result = parseCsv(text, { resolveReferences: false });
|
|
return { header: result.propertyConfigs.map((p) => p.name), rows: result.data };
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
throw new BgmError(`Invalid CSV: ${message}`, source);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Look up a CSV in the virtual def map and parse it.
|
|
*
|
|
* @param name the CSV's path-style name (relative to the games root)
|
|
* @param defs the virtual def map
|
|
* @param source the referencing def file's source, for error messages
|
|
*/
|
|
export function parseCsvByName(
|
|
name: string,
|
|
defs: Map<string, DefFile[]>,
|
|
source: string,
|
|
): CsvData {
|
|
const list = defs.get(name);
|
|
const file = list?.[0];
|
|
if (!file) {
|
|
throw new BgmError(`CSV not found: "${name}"`, source);
|
|
}
|
|
if (file.kind !== 'csv') {
|
|
throw new BgmError(`Expected a CSV file, got "${file.kind}" for "${name}"`, source);
|
|
}
|
|
return parseCsvData(file.text, file.source);
|
|
}
|
|
|
|
/**
|
|
* Expand a `$variants` value into rows.
|
|
*
|
|
* @param value the `$variants` value: a path or inline CSV string, or an
|
|
* array of them
|
|
* @param baseName the path-style name of the referencing def file; a path
|
|
* value resolves relative to its directory
|
|
* @param defs the virtual def map, for resolving the path
|
|
* @param source the def file's source location, for error messages
|
|
*/
|
|
export function expandVariants(
|
|
value: unknown,
|
|
baseName: string,
|
|
defs: Map<string, DefFile[]>,
|
|
source: string,
|
|
): Record<string, unknown>[] {
|
|
const sources = Array.isArray(value) ? value : [value];
|
|
if (sources.length === 0) {
|
|
throw new BgmError('`$variants` array must not be empty', source);
|
|
}
|
|
|
|
const rows: Record<string, unknown>[] = [];
|
|
for (const item of sources) {
|
|
if (typeof item !== 'string') {
|
|
throw new BgmError(
|
|
'`$variants` must be a path or inline CSV string, or an array of them',
|
|
source,
|
|
);
|
|
}
|
|
rows.push(...expandVariantsOne(item, baseName, defs, source));
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* Expand a single `$variants` source: a path or inline CSV.
|
|
*
|
|
* A source is a path when its first line ends in `.csv`; otherwise it is
|
|
* inline CSV. This keeps the two forms self-documenting and applies the same
|
|
* rule to single values and array elements alike.
|
|
*/
|
|
function expandVariantsOne(
|
|
value: string,
|
|
baseName: string,
|
|
defs: Map<string, DefFile[]>,
|
|
source: string,
|
|
): Record<string, unknown>[] {
|
|
const firstLine = value.split('\n', 1)[0] ?? value;
|
|
if (/[.]csv$/i.test(firstLine)) {
|
|
const name = path.posix.join(path.posix.dirname(baseName), value);
|
|
return parseCsvByName(name, defs, source).rows;
|
|
}
|
|
return parseCsvData(value, source).rows;
|
|
} |