diff --git a/docs/bgm-format.md b/docs/bgm-format.md index 8970580..d0d2519 100644 --- a/docs/bgm-format.md +++ b/docs/bgm-format.md @@ -37,9 +37,11 @@ bruce,[] ### Inline vs file -`$variants` can be a file/URL path *or* an inline CSV string. If the value -contains a newline it is inline CSV; otherwise it is a path. In YAML a block -scalar (`|`) is the natural way to write inline CSV; in JSON you'd use `\n`. +`$variants` can be a single source or an array of sources. Each source is a +file/URL path if 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. In YAML a block scalar (`|`) is the natural +way to write inline CSV; in JSON you'd use `\n`. ```yaml $variants: | @@ -47,9 +49,21 @@ $variants: | string,string,[number;number;number;number] fish,Fish,[0;0;5;2] grain,Grain,[1;0;5;2] - wood,Wood,[2;0;5;2] ``` +An array of sources concatenates their rows. This lets one part definition +pull from several CSVs with different schemas — e.g. a deck where the regular +cards share a face sheet but the jokers have their own: + +```yaml +$variants: + - ./cards.csv + - ./jokers.csv +``` + +Each source is parsed with its own schema, and its rows extend the original +object independently. + ### CSV conventions CSV is parsed with `typed-csv`: diff --git a/packages/bgm/src/variants.test.ts b/packages/bgm/src/variants.test.ts index 8954f47..acfe213 100644 --- a/packages/bgm/src/variants.test.ts +++ b/packages/bgm/src/variants.test.ts @@ -48,7 +48,7 @@ describe('parseCsvData', () => { }); describe('expandVariants', () => { - it('parses inline CSV when the value contains a newline', () => { + it('parses inline CSV when the first line does not end in .csv', () => { const rows = expandVariants('a,b\nstring,number\nx,1', 'pkg/def.yaml', new Map(), 'src'); expect(rows).toEqual([{ a: 'x', b: 1 }]); }); @@ -61,15 +61,30 @@ describe('expandVariants', () => { expect(rows).toEqual([{ seat: 0 }, { seat: 1 }]); }); + it('concatenates rows from an array of csv paths', () => { + const defs = new Map([ + ['pkg/parts/a.csv', [defFile('pkg/parts/a.csv', 'id\nstring\nred')]], + ['pkg/parts/b.csv', [defFile('pkg/parts/b.csv', 'id\nstring\nblack')]], + ]); + const rows = expandVariants(['./a.csv', './b.csv'], 'pkg/parts/board.yaml', defs, 'src'); + expect(rows).toEqual([{ id: 'red' }, { id: 'black' }]); + }); + it('throws when the referenced csv is missing', () => { expect(() => expandVariants('./nope.csv', 'pkg/def.yaml', new Map(), 'src')).toThrow( /CSV not found/, ); }); - it('throws when $variants is not a string', () => { + it('throws when $variants is not a string or string array', () => { expect(() => expandVariants(42, 'pkg/def.yaml', new Map(), 'src')).toThrow( - /must be a path or inline CSV/, + /must be a path or inline CSV string, or an array of them/, + ); + }); + + it('throws on an empty $variants array', () => { + expect(() => expandVariants([], 'pkg/def.yaml', new Map(), 'src')).toThrow( + /must not be empty/, ); }); }); \ No newline at end of file diff --git a/packages/bgm/src/variants.ts b/packages/bgm/src/variants.ts index 466e471..ded4495 100644 --- a/packages/bgm/src/variants.ts +++ b/packages/bgm/src/variants.ts @@ -9,8 +9,8 @@ * - 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 file/URL path *or* an inline CSV string: a value - * containing a newline is inline CSV, otherwise it is a path. + * - `$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 @@ -74,7 +74,8 @@ export function parseCsvByName( /** * Expand a `$variants` value into rows. * - * @param value the `$variants` value: a path or inline CSV + * @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 @@ -86,14 +87,41 @@ export function expandVariants( defs: Map, source: string, ): Record[] { - if (typeof value !== 'string') { - throw new BgmError('`$variants` must be a path or inline CSV string', source); + const sources = Array.isArray(value) ? value : [value]; + if (sources.length === 0) { + throw new BgmError('`$variants` array must not be empty', source); } - if (value.includes('\n')) { - return parseCsvData(value, source).rows; + const rows: Record[] = []; + 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; +} - const name = path.posix.join(path.posix.dirname(baseName), value); - return parseCsvByName(name, defs, source).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, + source: string, +): Record[] { + 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; } \ No newline at end of file