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,34 @@
|
||||
{
|
||||
"name": "@tts/bgm",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"lint": "echo \"no lint configured\""
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^16.0.0",
|
||||
"picomatch": "^4.0.5",
|
||||
"smol-toml": "^1.4.0",
|
||||
"typed-csv": "^2.0.0",
|
||||
"yaml": "^2.4.2",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/picomatch": "^4.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# Harbor
|
||||
|
||||
A tiny example game used to exercise the bgm loader.
|
||||
|
||||
```yaml file=harbor.yaml
|
||||
role: package
|
||||
id: harbor
|
||||
title: Harbor
|
||||
designer: Jane Doe
|
||||
players: 2
|
||||
language: en
|
||||
```
|
||||
|
||||
## Tokens
|
||||
|
||||
```yaml file=parts/tokens.yaml
|
||||
role: part
|
||||
type: token
|
||||
id: wood
|
||||
face: ./assets/tokens.png
|
||||
faceCrop: [1, 0, 5, 2]
|
||||
back: ./assets/tokens.png
|
||||
backCrop: [3, 0, 5, 2]
|
||||
shape: ./assets/token-shape.png
|
||||
size: [20, 20, 3]
|
||||
fillet: 2
|
||||
```
|
||||
|
||||
```yaml file=parts/tokens.yaml
|
||||
role: part
|
||||
type: token
|
||||
id: grain
|
||||
face: ./assets/tokens.png
|
||||
faceCrop: [0, 0, 5, 2]
|
||||
back: ./assets/tokens.png
|
||||
backCrop: [2, 0, 5, 2]
|
||||
shape: ./assets/token-shape.png
|
||||
size: [20, 20, 3]
|
||||
fillet: 2
|
||||
```
|
||||
|
||||
## Board
|
||||
|
||||
```yaml file=parts/board.yaml
|
||||
type: board
|
||||
id: harbor
|
||||
role: surface
|
||||
size: [300, 200]
|
||||
layout:
|
||||
- route: /dock/:seat
|
||||
candidates:
|
||||
$variants: ./seats.csv
|
||||
- route: /deck
|
||||
x: -100
|
||||
y: 0
|
||||
rotation: 0
|
||||
```
|
||||
|
||||
```csv file=parts/seats.csv
|
||||
seat,x,y,rotation
|
||||
string,number,number,number
|
||||
0,40,0,0
|
||||
1,40,20,0
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```yaml file=setup/main.yaml
|
||||
role: setup
|
||||
type: game
|
||||
id: main
|
||||
setup:
|
||||
/dock/0: harbor:token#wood
|
||||
/deck: harbor:token#grain
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadDefs, collectPackages } from './collect.js';
|
||||
|
||||
const fixtureRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__');
|
||||
|
||||
describe('collectPackages', () => {
|
||||
it('collects the harbor package from markdown code blocks', () => {
|
||||
const defMap = loadDefs('', fixtureRoot);
|
||||
const packages = collectPackages(defMap, fixtureRoot);
|
||||
|
||||
expect(packages).toHaveLength(1);
|
||||
const harbor = packages[0]!;
|
||||
expect(harbor.meta).toMatchObject({ id: 'harbor', title: 'Harbor', designer: 'Jane Doe' });
|
||||
|
||||
// Two tokens from two yaml blocks sharing a `file=` name.
|
||||
expect([...harbor.parts.keys()].sort()).toEqual(['token#grain', 'token#wood']);
|
||||
const wood = harbor.parts.get('token#wood')!;
|
||||
expect(wood).toMatchObject({
|
||||
type: 'token',
|
||||
id: 'wood',
|
||||
size: [20, 20, 3],
|
||||
fillet: 2,
|
||||
});
|
||||
expect(wood.face).toBe('./assets/tokens.png');
|
||||
expect(wood.faceCrop).toEqual([1, 0, 5, 2]);
|
||||
|
||||
// One surface with a $variants-expanded candidates list.
|
||||
expect([...harbor.surfaces.keys()]).toEqual(['board#harbor']);
|
||||
const board = harbor.surfaces.get('board#harbor')!;
|
||||
expect(board.size).toEqual([300, 200]);
|
||||
expect(board.layout).toHaveLength(2);
|
||||
const dock = board.layout[0]!;
|
||||
expect(dock.route).toBe('/dock/:seat');
|
||||
expect(dock.candidates).toEqual([
|
||||
{ seat: '0', x: 40, y: 0, rotation: 0 },
|
||||
{ seat: '1', x: 40, y: 20, rotation: 0 },
|
||||
]);
|
||||
const deck = board.layout[1]!;
|
||||
expect(deck.route).toBe('/deck');
|
||||
expect(deck).toMatchObject({ x: -100, y: 0, rotation: 0 });
|
||||
|
||||
// One setup.
|
||||
expect([...harbor.setups.keys()]).toEqual(['game#main']);
|
||||
const setup = harbor.setups.get('game#main')!;
|
||||
expect(setup.setup).toEqual({
|
||||
'/dock/0': 'harbor:token#wood',
|
||||
'/deck': 'harbor:token#grain',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on a duplicate type#id', () => {
|
||||
const defMap = loadDefs('', fixtureRoot);
|
||||
// Inject a duplicate part into the map under a new file name.
|
||||
const tokensKey = [...defMap.defs.keys()].find((k) => k.endsWith('parts/tokens.yaml'))!;
|
||||
const tokens = defMap.defs.get(tokensKey)!;
|
||||
defMap.defs.set(tokensKey.replace('tokens.yaml', 'dup.yaml'), [tokens[0]!]);
|
||||
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(/Duplicate part/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Collect packages from a games root directory.
|
||||
*
|
||||
* A games root contains yaml/json/toml files and markdown files with
|
||||
* definition code blocks. The loader:
|
||||
*
|
||||
* 1. Reads real files and extracts virtual files from markdown code blocks
|
||||
* (virtual wins over real files with the same name).
|
||||
* 2. Parses each def file into JSON objects.
|
||||
* 3. Recognizes `role: package` objects, expands their `$variants`, follows
|
||||
* their `include` patterns, and assembles the package's parts, surfaces,
|
||||
* and setups.
|
||||
*
|
||||
* See docs/bgm-format.md for the format's concrete behavior.
|
||||
*/
|
||||
import picomatch from 'picomatch';
|
||||
import { collectVirtualFiles } from './markdown.js';
|
||||
import { parseDefText, readDefFiles } from './parse.js';
|
||||
import { validatePackage, validatePart, validateSetup, validateSurface } from './schemas.js';
|
||||
import { expandVariants } from './variants.js';
|
||||
import {
|
||||
BgmError,
|
||||
type DefFile,
|
||||
type ParsedDef,
|
||||
type Package,
|
||||
type PackageDef,
|
||||
type Part,
|
||||
type Role,
|
||||
type Setup,
|
||||
type Surface,
|
||||
} from './types.js';
|
||||
|
||||
const ROLES = new Set<Role>(['package', 'part', 'surface', 'setup']);
|
||||
|
||||
/** Every definition parsed from a def file, keyed by its path-style name. */
|
||||
export interface DefMap {
|
||||
/** All def files (real + virtual), keyed by name. */
|
||||
files: Map<string, DefFile[]>;
|
||||
/** All parsed definitions, keyed by file name. */
|
||||
defs: Map<string, ParsedDef[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a games root into a def map.
|
||||
*
|
||||
* @param root the path-style name of the root, e.g. `harbor`
|
||||
* @param rootDir the absolute path of the games root
|
||||
*/
|
||||
export function loadDefs(root: string, rootDir: string): DefMap {
|
||||
const realFiles = readDefFiles(rootDir, root);
|
||||
const markdownFiles = new Map<string, string>();
|
||||
const others: DefFile[] = [];
|
||||
|
||||
for (const file of realFiles) {
|
||||
if (file.kind === 'markdown') markdownFiles.set(file.name, file.text);
|
||||
else others.push(file);
|
||||
}
|
||||
|
||||
const virtualFiles = collectVirtualFiles(markdownFiles);
|
||||
const files = new Map<string, DefFile[]>();
|
||||
// Real files first, virtual files override (virtual wins per the format).
|
||||
for (const file of others) files.set(file.name, [file]);
|
||||
for (const [name, list] of virtualFiles) files.set(name, list);
|
||||
|
||||
const defs = new Map<string, ParsedDef[]>();
|
||||
for (const [name, list] of files) {
|
||||
const parsed: ParsedDef[] = [];
|
||||
for (const file of list) parsed.push(...parseDefText(file));
|
||||
defs.set(name, parsed);
|
||||
}
|
||||
|
||||
return { files, defs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all packages from the given def map.
|
||||
*
|
||||
* @param rootDir the absolute path of the games root; `$variants` file paths
|
||||
* resolve relative to their def file's directory within the root
|
||||
*/
|
||||
export function collectPackages(defMap: DefMap, rootDir: string): Package[] {
|
||||
const packages = new Map<string, PackageAcc>();
|
||||
const byRole = new Map<string, ParsedDef[]>();
|
||||
|
||||
// Group parsed defs by role.
|
||||
for (const [file, defs] of defMap.defs) {
|
||||
const list: ParsedDef[] = [];
|
||||
for (const def of defs) {
|
||||
const role = def.value['role'];
|
||||
if (role !== undefined && typeof role === 'string' && ROLES.has(role as Role)) {
|
||||
list.push(def);
|
||||
byRole.set(file, list);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const accs: PackageAcc[] = [];
|
||||
for (const [file, defs] of byRole) {
|
||||
for (const def of defs) {
|
||||
const role = def.value['role'] as Role;
|
||||
if (role === 'package') {
|
||||
const pkg = asPackage(def, file);
|
||||
accs.push(new PackageAcc(pkg, defMap, rootDir));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: Package[] = [];
|
||||
for (const acc of accs) {
|
||||
acc.collect();
|
||||
result.push(acc.toPackage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Identity validation: `type#id` must be unique within a package. */
|
||||
class PackageAcc {
|
||||
readonly parts = new Map<string, Part>();
|
||||
readonly surfaces = new Map<string, Surface>();
|
||||
readonly setups = new Map<string, Setup>();
|
||||
readonly byRole = new Map<string, string[]>();
|
||||
|
||||
constructor(
|
||||
readonly pkg: PackageDef,
|
||||
private readonly defs: DefMap,
|
||||
private readonly rootDir: string,
|
||||
) {}
|
||||
|
||||
collect() {
|
||||
const include = this.pkg.include ?? ['./**/*.yaml'];
|
||||
const names = this.expandIncludes(include);
|
||||
for (const name of names) {
|
||||
const fileDefs = this.defs.defs.get(name);
|
||||
if (!fileDefs) continue;
|
||||
for (const def of fileDefs) {
|
||||
const role = def.value['role'];
|
||||
if (typeof role !== 'string' || !ROLES.has(role as Role) || role === 'package') continue;
|
||||
this.add(role as Role, def, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Expand `$variants` on a def object into a list of concrete objects. */
|
||||
private expand(obj: Record<string, unknown>, baseName: string, source: string): Record<string, unknown>[] {
|
||||
if (!('$variants' in obj)) return [obj];
|
||||
const rows = expandVariants(obj['$variants'], baseName, this.defs.files, source);
|
||||
const { $variants: _v, ...base } = obj;
|
||||
return rows.map((row) => ({ ...base, ...row }));
|
||||
}
|
||||
|
||||
private expandIncludes(patterns: string[]): string[] {
|
||||
// Match include patterns against the parsed definitions' names, which
|
||||
// cover both real files and markdown code blocks. Patterns are relative
|
||||
// to the games root (e.g. `./**/*.yaml`).
|
||||
const names = new Set<string>();
|
||||
for (const pattern of patterns) {
|
||||
const matcher = picomatch(pattern, { dot: true });
|
||||
for (const name of this.defs.defs.keys()) {
|
||||
if (matcher(name)) names.add(name);
|
||||
}
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
|
||||
private add(role: Role, def: ParsedDef, fileName: string) {
|
||||
const expanded = this.expand(def.value, def.file, def.source);
|
||||
for (const obj of expanded) {
|
||||
switch (role) {
|
||||
case 'part': {
|
||||
const part = asPart(obj, fileName);
|
||||
const key = `${part.type}#${part.id}`;
|
||||
if (this.parts.has(key)) {
|
||||
throw new BgmError(`Duplicate part "${key}"`, fileName);
|
||||
}
|
||||
this.parts.set(key, part);
|
||||
break;
|
||||
}
|
||||
case 'surface': {
|
||||
const surface = asSurface(obj, fileName, this.defs.files);
|
||||
const key = `${surface.type}#${surface.id}`;
|
||||
if (this.surfaces.has(key)) {
|
||||
throw new BgmError(`Duplicate surface "${key}"`, fileName);
|
||||
}
|
||||
this.surfaces.set(key, surface);
|
||||
break;
|
||||
}
|
||||
case 'setup': {
|
||||
const setup = asSetup(obj, fileName);
|
||||
const key = `${setup.type}#${setup.id}`;
|
||||
if (this.setups.has(key)) {
|
||||
throw new BgmError(`Duplicate setup "${key}"`, fileName);
|
||||
}
|
||||
this.setups.set(key, setup);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toPackage(): Package {
|
||||
return { meta: metaOf(this.pkg), parts: this.parts, surfaces: this.surfaces, setups: this.setups };
|
||||
}
|
||||
}
|
||||
|
||||
function metaOf(pkg: PackageDef) {
|
||||
const { role: _role, include: _include, ...meta } = pkg;
|
||||
return meta;
|
||||
}
|
||||
|
||||
function asPackage(def: ParsedDef, source: string): PackageDef {
|
||||
const obj = def.value;
|
||||
try {
|
||||
return validatePackage(obj) as unknown as PackageDef;
|
||||
} catch (err) {
|
||||
throw wrapZod(err, source);
|
||||
}
|
||||
}
|
||||
|
||||
function asPart(obj: Record<string, unknown>, source: string): Part {
|
||||
try {
|
||||
return validatePart(obj) as unknown as Part;
|
||||
} catch (err) {
|
||||
throw wrapZod(err, source);
|
||||
}
|
||||
}
|
||||
|
||||
function asSurface(
|
||||
obj: Record<string, unknown>,
|
||||
source: string,
|
||||
defs: Map<string, DefFile[]>,
|
||||
): Surface {
|
||||
const value: Record<string, unknown> = { ...obj };
|
||||
delete value['role'];
|
||||
|
||||
// Expand `candidates.$variants` on each route into a concrete array.
|
||||
if (Array.isArray(value['layout'])) {
|
||||
value['layout'] = value['layout'].map((route) => {
|
||||
if (typeof route !== 'object' || route === null) return route;
|
||||
const r = route as Record<string, unknown>;
|
||||
const cand = r['candidates'];
|
||||
if (cand && typeof cand === 'object' && !Array.isArray(cand) && '$variants' in cand) {
|
||||
const rows = expandVariants(cand['$variants'], source, defs, source);
|
||||
const { $variants: _v, ...base } = cand as Record<string, unknown>;
|
||||
return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) };
|
||||
}
|
||||
return route;
|
||||
});
|
||||
}
|
||||
try {
|
||||
return validateSurface(value) as unknown as Surface;
|
||||
} catch (err) {
|
||||
throw wrapZod(err, source);
|
||||
}
|
||||
}
|
||||
|
||||
function asSetup(obj: Record<string, unknown>, source: string): Setup {
|
||||
const value: Record<string, unknown> = { ...obj };
|
||||
delete value['role'];
|
||||
try {
|
||||
return validateSetup(value) as unknown as Setup;
|
||||
} catch (err) {
|
||||
throw wrapZod(err, source);
|
||||
}
|
||||
}
|
||||
|
||||
/** Wrap a zod error with the source location. */
|
||||
function wrapZod(err: unknown, source: string): BgmError {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new BgmError(`Invalid definition: ${message}`, source);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './types.js';
|
||||
export * from './schemas.js';
|
||||
export * from './markdown.js';
|
||||
export * from './parse.js';
|
||||
export * from './variants.js';
|
||||
export * from './collect.js';
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { scanMarkdown } from './markdown.js';
|
||||
|
||||
describe('scanMarkdown', () => {
|
||||
it('extracts a fenced code block with a file= name', () => {
|
||||
const md = [
|
||||
'# Title',
|
||||
'',
|
||||
'```yaml file=parts/cargo.yaml',
|
||||
'role: part',
|
||||
'```',
|
||||
'',
|
||||
'text after',
|
||||
].join('\n');
|
||||
|
||||
const { files, fences } = scanMarkdown(md, 'harbor/harbor.md');
|
||||
|
||||
expect(fences).toHaveLength(1);
|
||||
expect(fences[0]).toMatchObject({
|
||||
info: 'yaml file=parts/cargo.yaml',
|
||||
content: 'role: part',
|
||||
startLine: 3,
|
||||
endLine: 5,
|
||||
});
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]).toMatchObject({
|
||||
name: 'harbor/parts/cargo.yaml',
|
||||
kind: 'yaml',
|
||||
text: 'role: part',
|
||||
source: 'harbor/harbor.md:3-5',
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-names a block without file= from its content hash', () => {
|
||||
const md = '```yaml\nrole: part\n```';
|
||||
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]!.name).toMatch(/^harbor\/[0-9a-f]{8}\.yaml$/);
|
||||
expect(files[0]!.kind).toBe('yaml');
|
||||
});
|
||||
|
||||
it('ignores non-definition languages', () => {
|
||||
const md = '```js\nconst x = 1;\n```';
|
||||
const { files, fences } = scanMarkdown(md, 'harbor/harbor.md');
|
||||
expect(files).toHaveLength(0);
|
||||
expect(fences).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores indented code blocks', () => {
|
||||
const md = ' role: part\n';
|
||||
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||
expect(files).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('names a csv block with file= as csv', () => {
|
||||
const md = '```csv file=parts/seats.csv\nseat,x\nstring,number\n0,40\n```';
|
||||
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||
expect(files[0]).toMatchObject({ name: 'harbor/parts/seats.csv', kind: 'csv' });
|
||||
});
|
||||
|
||||
it('tracks line numbers across multiple blocks', () => {
|
||||
const md = [
|
||||
'```yaml file=a.yaml',
|
||||
'role: part',
|
||||
'```',
|
||||
'',
|
||||
'```yaml file=b.yaml',
|
||||
'role: part',
|
||||
'```',
|
||||
].join('\n');
|
||||
const { fences } = scanMarkdown(md, 'harbor/harbor.md');
|
||||
expect(fences.map((f) => f.startLine)).toEqual([1, 5]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Extract virtual definition files from markdown code blocks.
|
||||
*
|
||||
* Each fenced code block is a virtual definition file:
|
||||
* - With a `file=` segment in its info string, named relative to the
|
||||
* current markdown file: a yaml block with `file=parts/cargo.yaml`.
|
||||
* - Without one, auto-named `./<hash>.yaml` from its content, so every yaml
|
||||
* block is discoverable by the default include pattern (all yaml in the
|
||||
* same and sub folders). Identical blocks dedupe to the same hash.
|
||||
*
|
||||
* Markdown is tokenized with `marked`; each `code` token is a candidate
|
||||
* virtual file.
|
||||
*/
|
||||
import * as crypto from 'node:crypto';
|
||||
import { posix } from 'node:path';
|
||||
import { marked } from 'marked';
|
||||
import { BgmError, type DefFile } from './types.js';
|
||||
|
||||
/** The languages that count as definition files; others are ignored. */
|
||||
const DEF_LANGS = new Set(['yaml', 'yml', 'json', 'toml']);
|
||||
|
||||
/** A single fenced code block. */
|
||||
export interface Fence {
|
||||
/** Line number (1-based) of the opening fence. */
|
||||
startLine: number;
|
||||
/** Line number (1-based) of the closing fence. */
|
||||
endLine: number;
|
||||
/** The info string content (e.g. `yaml file=parts/cargo.yaml`). */
|
||||
info: string;
|
||||
/** The code block's content (without the fences). */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Result of scanning a markdown file. */
|
||||
export interface MarkdownResult {
|
||||
/** All fenced code blocks found, in order. */
|
||||
fences: Fence[];
|
||||
/** Virtual def files extracted from the definition-language blocks. */
|
||||
files: DefFile[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan `text` for fenced code blocks.
|
||||
*
|
||||
* @param text the markdown source
|
||||
* @param sourcePath the markdown file's path-style name, for error messages
|
||||
* and for resolving `file=` names relative to the markdown file
|
||||
* @returns the fences and the virtual def files derived from them
|
||||
*/
|
||||
export function scanMarkdown(text: string, sourcePath: string): MarkdownResult {
|
||||
const fences: Fence[] = [];
|
||||
const files: DefFile[] = [];
|
||||
|
||||
const tokens = marked.lexer(text);
|
||||
for (const token of tokens) {
|
||||
if (token.type !== 'code' || token.codeBlockStyle === 'indented') continue;
|
||||
const info = token.lang ?? '';
|
||||
const startLine = lineOf(text, token.raw);
|
||||
const endLine = startLine + token.raw.split(/\r?\n/).length - 1;
|
||||
|
||||
fences.push({ startLine, endLine, info, content: token.text });
|
||||
|
||||
const name = parseInfo(info);
|
||||
if (name) {
|
||||
files.push({
|
||||
name: posix.join(posix.dirname(sourcePath), name),
|
||||
text: token.text,
|
||||
source: `${sourcePath}:${startLine}-${endLine}`,
|
||||
kind: kindOf(name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { fences, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a fence's info string for a `file=` segment and derive the virtual
|
||||
* file name. Blocks without `file=` are auto-named from their content hash.
|
||||
*/
|
||||
function parseInfo(info: string): string | null {
|
||||
const fileMatch = /file=(\S+)/.exec(info);
|
||||
if (fileMatch) return fileMatch[1]!;
|
||||
|
||||
const lang = info.split(/\s+/)[0];
|
||||
if (!lang || !DEF_LANGS.has(lang)) return null;
|
||||
return `./${hash(info)}.yaml`;
|
||||
}
|
||||
|
||||
/** Derive the def file type from its name's extension. */
|
||||
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';
|
||||
}
|
||||
|
||||
/** A stable content hash for auto-named blocks. */
|
||||
function hash(text: string): string {
|
||||
return crypto.createHash('sha1').update(text).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
/** The 1-based line number where `raw` starts within `text`. */
|
||||
function lineOf(text: string, raw: string): number {
|
||||
const idx = text.indexOf(raw);
|
||||
if (idx < 0) return 1;
|
||||
return text.slice(0, idx).split(/\r?\n/).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual files gathered from markdown code blocks, keyed by path-style name.
|
||||
* Multiple blocks may share a name (e.g. several `file=parts/tokens.yaml`
|
||||
* blocks); each is kept as a separate entry. Identical blocks dedupe to the
|
||||
* same hash name.
|
||||
*/
|
||||
export type VirtualFiles = Map<string, DefFile[]>;
|
||||
|
||||
/**
|
||||
* Collect virtual def files from a set of markdown sources.
|
||||
*
|
||||
* @param markdownFiles real markdown files, keyed by their path-style name
|
||||
* relative to the games root, e.g. `harbor/harbor.md`
|
||||
* @returns the virtual files, keyed by name
|
||||
*/
|
||||
export function collectVirtualFiles(markdownFiles: Map<string, string>): VirtualFiles {
|
||||
const files = new Map<string, DefFile[]>();
|
||||
for (const [name, text] of markdownFiles) {
|
||||
const result = scanMarkdown(text, name);
|
||||
for (const file of result.files) {
|
||||
const list = files.get(file.name) ?? [];
|
||||
list.push(file);
|
||||
files.set(file.name, list);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Zod schemas for the bgm definition roles.
|
||||
*
|
||||
* These validate the raw definition objects (after `$variants` expansion)
|
||||
* and produce the typed `Part` / `Surface` / `Setup` / `PackageDef` values.
|
||||
* See docs/bgm-format.md for the format's concrete behavior.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { PackageDef, Part, Setup, Surface } from './types.js';
|
||||
|
||||
const crop = z.tuple([z.number(), z.number(), z.number(), z.number()]);
|
||||
const size = z.tuple([z.number(), z.number(), z.number()]);
|
||||
const surfaceSize = z.tuple([z.number(), z.number()]);
|
||||
|
||||
const stacking = z.object({
|
||||
curve: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
align: z.enum(['start', 'end', 'center']).optional(),
|
||||
steps: z.number().optional(),
|
||||
});
|
||||
|
||||
const route = z.object({
|
||||
route: z.string(),
|
||||
x: z.number().optional(),
|
||||
y: z.number().optional(),
|
||||
rotation: z.number().optional(),
|
||||
candidates: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
stacking: stacking.optional(),
|
||||
});
|
||||
|
||||
const partSchema = z.object({
|
||||
type: z.string().min(1),
|
||||
id: z.string().min(1),
|
||||
face: z.string().optional(),
|
||||
faceCrop: crop.optional(),
|
||||
back: z.string().optional(),
|
||||
backCrop: crop.optional(),
|
||||
shape: z.string().optional(),
|
||||
size: size.optional(),
|
||||
fillet: z.number().optional(),
|
||||
});
|
||||
|
||||
const surfaceSchema = z.object({
|
||||
type: z.string().min(1),
|
||||
id: z.string().min(1),
|
||||
size: surfaceSize.optional(),
|
||||
layout: z.array(route),
|
||||
});
|
||||
|
||||
const setupSchema = z.object({
|
||||
type: z.string().min(1),
|
||||
id: z.string().min(1),
|
||||
setup: z.record(z.string(), z.union([z.string(), z.array(z.string())])),
|
||||
});
|
||||
|
||||
const packageSchema = z.object({
|
||||
role: z.literal('package'),
|
||||
id: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
designer: z.string().optional(),
|
||||
development: z.string().optional(),
|
||||
publisher: z.string().optional(),
|
||||
players: z.number().optional(),
|
||||
language: z.string().optional(),
|
||||
include: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
/** Validate a raw part definition. */
|
||||
export function validatePart(value: Record<string, unknown>): Part {
|
||||
return partSchema.parse(value) as unknown as Part;
|
||||
}
|
||||
|
||||
/** Validate a raw surface definition. */
|
||||
export function validateSurface(value: Record<string, unknown>): Surface {
|
||||
return surfaceSchema.parse(value) as unknown as Surface;
|
||||
}
|
||||
|
||||
/** Validate a raw setup definition. */
|
||||
export function validateSetup(value: Record<string, unknown>): Setup {
|
||||
return setupSchema.parse(value) as unknown as Setup;
|
||||
}
|
||||
|
||||
/** Validate a raw package definition. */
|
||||
export function validatePackage(value: Record<string, unknown>): PackageDef {
|
||||
return packageSchema.parse(value) as unknown as PackageDef;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Core types for the board game manifest (bgm) format.
|
||||
*
|
||||
* A package is the container for a game's definitions. Raw definitions are
|
||||
* discovered as JSON objects from yaml/json/toml files and from markdown
|
||||
* code blocks, then assembled into a `Package` (see `collect.ts` / `emit.ts`).
|
||||
*
|
||||
* The concrete behavior of the format is described in `docs/bgm-format.md`.
|
||||
*/
|
||||
|
||||
/** Part value types. */
|
||||
export type PartValueType = 'image' | 'crop' | 'size' | 'sprite';
|
||||
|
||||
/**
|
||||
* A crop tuple `[col, row, cols, rows]`. Divides the image into a
|
||||
* `cols` x `rows` grid and picks the cell at `[col, row]`.
|
||||
*/
|
||||
export type Crop = [col: number, row: number, cols: number, rows: number];
|
||||
|
||||
/** A size tuple `[width, height, depth]` in mm units. */
|
||||
export type Size = [width: number, height: number, depth: number];
|
||||
|
||||
/** A surface size `[width, height]` in mm units. */
|
||||
export type SurfaceSize = [width: number, height: number];
|
||||
|
||||
/** `type#id` identification used across roles (e.g. `harbor:token#wood`). */
|
||||
export type PartRef = string;
|
||||
|
||||
/**
|
||||
* The identification of a part. `package:type#id` is the full, package-qualified
|
||||
* string placed on the board via setup and referenced by routes.
|
||||
*/
|
||||
export interface PartId {
|
||||
package: string;
|
||||
type: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface PackageMeta {
|
||||
/** Package id (also used as the module name, e.g. `bgm/harbor`). */
|
||||
id: string;
|
||||
/** Game name. */
|
||||
title?: string;
|
||||
designer?: string;
|
||||
/** Artist / developer credit. */
|
||||
development?: string;
|
||||
publisher?: string;
|
||||
/** Player count. */
|
||||
players?: number;
|
||||
/** Language code, e.g. `en`. */
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/** A game component: identified by `package:type#id`, placed via setup. */
|
||||
export interface Part {
|
||||
type: string;
|
||||
id: string;
|
||||
/** Face sprite url (texture). */
|
||||
face?: string;
|
||||
/** Crop for `face`. */
|
||||
faceCrop?: Crop;
|
||||
/** Back sprite url; defaults to the face sprite. */
|
||||
back?: string;
|
||||
/** Crop for `back`. */
|
||||
backCrop?: Crop;
|
||||
/** Shape sprite url; traced for its profile to create the mesh. */
|
||||
shape?: string;
|
||||
/** `[width, height, depth]` in mm; the token is scaled to fit the box. */
|
||||
size?: Size;
|
||||
/** Fillet radius in mm; defaults to `0`. */
|
||||
fillet?: number;
|
||||
/** Extra fields from the source definition, kept for forwards compatibility. */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** A candidate for a route's `:param`, carrying its own anchor. */
|
||||
export interface Candidate {
|
||||
[param: string]: unknown;
|
||||
x?: number;
|
||||
y?: number;
|
||||
rotation?: number;
|
||||
}
|
||||
|
||||
export interface Route {
|
||||
/** Express-style url path with named params, e.g. `/dock/:seat`. */
|
||||
route: string;
|
||||
x: number;
|
||||
y: number;
|
||||
rotation: number;
|
||||
/** Candidates to match `:param` against; each carries its own anchor. */
|
||||
candidates?: Candidate[];
|
||||
/** Stacking strategy for multiple parts on the path. */
|
||||
stacking?: Stacking;
|
||||
}
|
||||
|
||||
export interface Stacking {
|
||||
/** SVG path string to spread stacked parts along, relative to the anchor. */
|
||||
curve?: string;
|
||||
/** How many parts to display. `0` shows all, `3` the first 3, `-3` the last 3. */
|
||||
limit?: number;
|
||||
/** `start`, `end`, or `center` of the curve. */
|
||||
align?: 'start' | 'end' | 'center';
|
||||
/** Maximum parts per curve length unit; defaults to `1`. */
|
||||
steps?: number;
|
||||
}
|
||||
|
||||
/** A view over the state store, purely for visual rendering. */
|
||||
export interface Surface {
|
||||
type: string;
|
||||
id: string;
|
||||
/** Reference `[width, height]` in mm; may be scaled to fit the table. */
|
||||
size?: SurfaceSize;
|
||||
layout: Route[];
|
||||
}
|
||||
|
||||
export type SetupValue = string | string[];
|
||||
|
||||
/** Seeds the state store: a map from path to a stack of parts. */
|
||||
export interface Setup {
|
||||
type: string;
|
||||
id: string;
|
||||
setup: Record<string, SetupValue>;
|
||||
}
|
||||
|
||||
export type Role = 'package' | 'part' | 'surface' | 'setup';
|
||||
|
||||
/**
|
||||
* A raw definition object as written by the author. Definitions can be the
|
||||
* root of a file/block or an item in the file's list.
|
||||
*/
|
||||
export interface RawDef {
|
||||
role?: Role;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** The four definition roles. */
|
||||
export type RoleDef = PackageDef | PartDef | SurfaceDef | SetupDef;
|
||||
|
||||
export interface PackageDef extends PackageMeta {
|
||||
role: 'package';
|
||||
/** Git-style path patterns of the defs that make up the package. */
|
||||
include?: string[];
|
||||
}
|
||||
|
||||
export interface PartDef extends Part {
|
||||
role: 'part';
|
||||
}
|
||||
|
||||
export interface SurfaceDef extends Surface {
|
||||
role: 'surface';
|
||||
}
|
||||
|
||||
export interface SetupDef extends Setup {
|
||||
role: 'setup';
|
||||
}
|
||||
|
||||
/** A virtual definition file: a real file or a markdown code block. */
|
||||
export interface DefFile {
|
||||
/** Path-style name; for code blocks, relative to their markdown file. */
|
||||
name: string;
|
||||
/** Raw text content. */
|
||||
text: string;
|
||||
/** Source location for error messages (real path or `file.md:12-19`). */
|
||||
source: string;
|
||||
/** File type derived from the name's extension. */
|
||||
kind: 'yaml' | 'json' | 'toml' | 'markdown' | 'csv';
|
||||
}
|
||||
|
||||
/** A single parsed definition (one JSON object from a def file). */
|
||||
export interface ParsedDef {
|
||||
file: string;
|
||||
/** Index into the file's parsed object list; `-1` for the root object. */
|
||||
index: number;
|
||||
/** The raw definition object. */
|
||||
value: Record<string, unknown>;
|
||||
/** Source location for error messages (real path or `file.md:12-19`). */
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fully collected package: the parts, surfaces, and setups reachable from
|
||||
* the package declaration's `include` patterns.
|
||||
*/
|
||||
export interface Package {
|
||||
meta: PackageMeta;
|
||||
/** All parts by `type#id`. */
|
||||
parts: Map<string, Part>;
|
||||
/** All surfaces by `type#id`. */
|
||||
surfaces: Map<string, Surface>;
|
||||
/** All setups by `type#id`. */
|
||||
setups: Map<string, Setup>;
|
||||
}
|
||||
|
||||
/** Errors during loading, carrying the source location when available. */
|
||||
export class BgmError extends Error {
|
||||
constructor(message: string, readonly location?: string) {
|
||||
super(location ? `${location}: ${message}` : message);
|
||||
this.name = 'BgmError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseCsvData, expandVariants } from './variants.js';
|
||||
import type { DefFile } from './types.js';
|
||||
|
||||
function defFile(name: string, text: string): DefFile {
|
||||
return { name, text, source: name, kind: 'csv' };
|
||||
}
|
||||
|
||||
describe('parseCsvData', () => {
|
||||
it('parses the spec example with an empty array', () => {
|
||||
const csv = [
|
||||
'name,parents',
|
||||
'string,string[]',
|
||||
'clark,[jonathan;martha]',
|
||||
'bruce,[]',
|
||||
].join('\n');
|
||||
const { rows, header } = parseCsvData(csv, 'test');
|
||||
expect(header).toEqual(['name', 'parents']);
|
||||
expect(rows).toEqual([
|
||||
{ name: 'clark', parents: ['jonathan', 'martha'] },
|
||||
{ name: 'bruce', parents: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses a crop tuple', () => {
|
||||
const csv = [
|
||||
'id,faceCrop',
|
||||
'string,[number;number;number;number]',
|
||||
'fish,[0;0;5;2]',
|
||||
'grain,[1;0;5;2]',
|
||||
].join('\n');
|
||||
const { rows } = parseCsvData(csv, 'test');
|
||||
expect(rows).toEqual([
|
||||
{ id: 'fish', faceCrop: [0, 0, 5, 2] },
|
||||
{ id: 'grain', faceCrop: [1, 0, 5, 2] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws a BgmError on a type mismatch', () => {
|
||||
const csv = ['n', 'number', 'not-a-number'].join('\n');
|
||||
expect(() => parseCsvData(csv, 'test')).toThrow(/Invalid CSV/);
|
||||
});
|
||||
|
||||
it('allows a header and schema with no data rows', () => {
|
||||
const { rows } = parseCsvData('a\nstring', 'test');
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandVariants', () => {
|
||||
it('parses inline CSV when the value contains a newline', () => {
|
||||
const rows = expandVariants('a,b\nstring,number\nx,1', 'pkg/def.yaml', new Map(), 'src');
|
||||
expect(rows).toEqual([{ a: 'x', b: 1 }]);
|
||||
});
|
||||
|
||||
it('resolves a path against the def file directory', () => {
|
||||
const defs = new Map<string, DefFile[]>([
|
||||
['pkg/parts/seats.csv', [defFile('pkg/parts/seats.csv', 'seat\nnumber\n0\n1')]],
|
||||
]);
|
||||
const rows = expandVariants('./seats.csv', 'pkg/parts/board.yaml', defs, 'src');
|
||||
expect(rows).toEqual([{ seat: 0 }, { seat: 1 }]);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
expect(() => expandVariants(42, 'pkg/def.yaml', new Map(), 'src')).toThrow(
|
||||
/must be a path or inline CSV/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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 file/URL path *or* an inline CSV string: a value
|
||||
* containing a newline is inline CSV, otherwise it is a path.
|
||||
*
|
||||
* 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
|
||||
* @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>[] {
|
||||
if (typeof value !== 'string') {
|
||||
throw new BgmError('`$variants` must be a path or inline CSV string', source);
|
||||
}
|
||||
|
||||
if (value.includes('\n')) {
|
||||
return parseCsvData(value, source).rows;
|
||||
}
|
||||
|
||||
const name = path.posix.join(path.posix.dirname(baseName), value);
|
||||
return parseCsvByName(name, defs, source).rows;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user