feat(bgm): allow $variants to take multiple csv sources
Expand $variants to accept an array of csv paths, concatenating their rows. Detect a path by a .csv suffix on the first line instead of a newline, so inline csv and paths are self-documenting and the rule applies uniformly to single values and array elements.
This commit is contained in:
+18
-4
@@ -37,9 +37,11 @@ bruce,[]
|
|||||||
|
|
||||||
### Inline vs file
|
### Inline vs file
|
||||||
|
|
||||||
`$variants` can be a file/URL path *or* an inline CSV string. If the value
|
`$variants` can be a single source or an array of sources. Each source is a
|
||||||
contains a newline it is inline CSV; otherwise it is a path. In YAML a block
|
file/URL path if its first line ends in `.csv`, otherwise it is inline CSV.
|
||||||
scalar (`|`) is the natural way to write inline CSV; in JSON you'd use `\n`.
|
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
|
```yaml
|
||||||
$variants: |
|
$variants: |
|
||||||
@@ -47,9 +49,21 @@ $variants: |
|
|||||||
string,string,[number;number;number;number]
|
string,string,[number;number;number;number]
|
||||||
fish,Fish,[0;0;5;2]
|
fish,Fish,[0;0;5;2]
|
||||||
grain,Grain,[1;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 conventions
|
||||||
|
|
||||||
CSV is parsed with `typed-csv`:
|
CSV is parsed with `typed-csv`:
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe('parseCsvData', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('expandVariants', () => {
|
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');
|
const rows = expandVariants('a,b\nstring,number\nx,1', 'pkg/def.yaml', new Map(), 'src');
|
||||||
expect(rows).toEqual([{ a: 'x', b: 1 }]);
|
expect(rows).toEqual([{ a: 'x', b: 1 }]);
|
||||||
});
|
});
|
||||||
@@ -61,15 +61,30 @@ describe('expandVariants', () => {
|
|||||||
expect(rows).toEqual([{ seat: 0 }, { seat: 1 }]);
|
expect(rows).toEqual([{ seat: 0 }, { seat: 1 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('concatenates rows from an array of csv paths', () => {
|
||||||
|
const defs = new Map<string, DefFile[]>([
|
||||||
|
['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', () => {
|
it('throws when the referenced csv is missing', () => {
|
||||||
expect(() => expandVariants('./nope.csv', 'pkg/def.yaml', new Map(), 'src')).toThrow(
|
expect(() => expandVariants('./nope.csv', 'pkg/def.yaml', new Map(), 'src')).toThrow(
|
||||||
/CSV not found/,
|
/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(
|
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/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
* - Rows are validated against a schema derived from the type row.
|
* - Rows are validated against a schema derived from the type row.
|
||||||
* - A cell for an array/tuple type uses `;` as the element separator
|
* - A cell for an array/tuple type uses `;` as the element separator
|
||||||
* (`[0;0;5;2]`), because `,` is the CSV delimiter.
|
* (`[0;0;5;2]`), because `,` is the CSV delimiter.
|
||||||
* - `$variants` can be a file/URL path *or* an inline CSV string: a value
|
* - `$variants` can be a single source or an array of them. A source is a
|
||||||
* containing a newline is inline CSV, otherwise it is a path.
|
* file/URL path if its first line ends in `.csv`, otherwise inline CSV.
|
||||||
*
|
*
|
||||||
* Parsing is delegated to `typed-csv`'s `parseCsv`, which implements exactly
|
* Parsing is delegated to `typed-csv`'s `parseCsv`, which implements exactly
|
||||||
* this header/schema/data layout and validates each row against a schema
|
* 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.
|
* 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
|
* @param baseName the path-style name of the referencing def file; a path
|
||||||
* value resolves relative to its directory
|
* value resolves relative to its directory
|
||||||
* @param defs the virtual def map, for resolving the path
|
* @param defs the virtual def map, for resolving the path
|
||||||
@@ -86,14 +87,41 @@ export function expandVariants(
|
|||||||
defs: Map<string, DefFile[]>,
|
defs: Map<string, DefFile[]>,
|
||||||
source: string,
|
source: string,
|
||||||
): Record<string, unknown>[] {
|
): Record<string, unknown>[] {
|
||||||
if (typeof value !== 'string') {
|
const sources = Array.isArray(value) ? value : [value];
|
||||||
throw new BgmError('`$variants` must be a path or inline CSV string', source);
|
if (sources.length === 0) {
|
||||||
|
throw new BgmError('`$variants` array must not be empty', source);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.includes('\n')) {
|
const rows: Record<string, unknown>[] = [];
|
||||||
return parseCsvData(value, source).rows;
|
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<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;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user