feat(bgm): derive definition names from role.type
A definition is identified by role.type and named role.type.lang (package.lang for packages). Code blocks declare it via role= on the info string; real files by their filename. Blocks and files without a matching role.type are ignored. file= still overrides the auto-name. Relative asset and $variants paths now resolve against the markdown file's directory (baseDir) rather than the virtual name. Same role.type with different ids groups under one name; duplicate type#id still errors. Migrate fixtures and example games to role=.
This commit is contained in:
@@ -4,8 +4,7 @@ The base game's 24 landscape tiles, laid out on a table with a draw pile and a
|
|||||||
grid of placed tiles. Each tile is a single `110×110` image (A–X), sized to a
|
grid of placed tiles. Each tile is a single `110×110` image (A–X), sized to a
|
||||||
standard `45×45` mm square.
|
standard `45×45` mm square.
|
||||||
|
|
||||||
```yaml file=carcassonne.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: carcassonne
|
id: carcassonne
|
||||||
title: Carcassonne
|
title: Carcassonne
|
||||||
designer: Klaus-Jürgen Wrede
|
designer: Klaus-Jürgen Wrede
|
||||||
@@ -21,9 +20,7 @@ One `tile` part per distinct tile type (A–X). All tiles share the same square
|
|||||||
face image and a uniform size; the `$variants` CSV expands them into the 24
|
face image and a uniform size; the `$variants` CSV expands them into the 24
|
||||||
tile parts.
|
tile parts.
|
||||||
|
|
||||||
```yaml file=tiles.yaml
|
```yaml role=part.tile
|
||||||
role: part
|
|
||||||
type: tile
|
|
||||||
face: ./20AE_Base_Game_C2_Tile_A.png
|
face: ./20AE_Base_Game_C2_Tile_A.png
|
||||||
size: [45, 45, 3]
|
size: [45, 45, 3]
|
||||||
fillet: 1
|
fillet: 1
|
||||||
@@ -65,10 +62,8 @@ A table with a draw pile on the left and an `11×11` grid of placed tiles in the
|
|||||||
middle. The grid routes each tile to its `col,row` cell, spaced `45` mm apart
|
middle. The grid routes each tile to its `col,row` cell, spaced `45` mm apart
|
||||||
so tiles sit edge to edge.
|
so tiles sit edge to edge.
|
||||||
|
|
||||||
```yaml file=board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: board
|
id: board
|
||||||
role: surface
|
|
||||||
size: [600, 600]
|
size: [600, 600]
|
||||||
layout:
|
layout:
|
||||||
- route: /draw
|
- route: /draw
|
||||||
@@ -215,9 +210,7 @@ string,string,number,number,number
|
|||||||
Start with the full tile supply on the draw pile (`carcassonne:tile` expands to
|
Start with the full tile supply on the draw pile (`carcassonne:tile` expands to
|
||||||
every tile of that type), then seed the grid with a few opening tiles.
|
every tile of that type), then seed the grid with a few opening tiles.
|
||||||
|
|
||||||
```yaml file=main.yaml
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
surfaces:
|
surfaces:
|
||||||
- board#board
|
- board#board
|
||||||
|
|||||||
+4
-11
@@ -4,8 +4,7 @@ A standard 52-card poker deck, laid out on a table with a draw pile and
|
|||||||
community-card slots. Exercises the bgm loader's `$variants` expansion to
|
community-card slots. Exercises the bgm loader's `$variants` expansion to
|
||||||
generate a full deck from a single part definition.
|
generate a full deck from a single part definition.
|
||||||
|
|
||||||
```yaml file=poker.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: poker
|
id: poker
|
||||||
title: Poker
|
title: Poker
|
||||||
designer: Public Domain
|
designer: Public Domain
|
||||||
@@ -21,9 +20,7 @@ picks a cell from the `13×4` face sheet (`cards-13x4.jpg`) — 13 ranks across,
|
|||||||
4 suits down. All cards share the same back from the `4×1` back sheet
|
4 suits down. All cards share the same back from the `4×1` back sheet
|
||||||
(`back-4x1.png`).
|
(`back-4x1.png`).
|
||||||
|
|
||||||
```yaml file=cards.yaml
|
```yaml role=part.card
|
||||||
role: part
|
|
||||||
type: card
|
|
||||||
face: ./cards-13x4.jpg
|
face: ./cards-13x4.jpg
|
||||||
back: ./back-4x1.png
|
back: ./back-4x1.png
|
||||||
size: [63, 88, 0.3]
|
size: [63, 88, 0.3]
|
||||||
@@ -93,10 +90,8 @@ ac,A,clubs,[12;3;13;4],[0;0;4;1]
|
|||||||
A table with a draw pile on the left and five community-card slots across the
|
A table with a draw pile on the left and five community-card slots across the
|
||||||
middle. The deck pile fans its stacked cards along a curve.
|
middle. The deck pile fans its stacked cards along a curve.
|
||||||
|
|
||||||
```yaml file=board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: poker
|
id: poker
|
||||||
role: surface
|
|
||||||
size: [600, 400]
|
size: [600, 400]
|
||||||
layout:
|
layout:
|
||||||
- route: /deck
|
- route: /deck
|
||||||
@@ -129,9 +124,7 @@ Deal the whole deck facedown onto the draw pile (`poker:card` expands to every
|
|||||||
card of that type, `facing: back`), then flip a flop onto the community slots
|
card of that type, `facing: back`), then flip a flop onto the community slots
|
||||||
and stand a couple of drawn cards on their bottom edge.
|
and stand a couple of drawn cards on their bottom edge.
|
||||||
|
|
||||||
```yaml file=main.yaml
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
setup:
|
setup:
|
||||||
- path: /deck
|
- path: /deck
|
||||||
|
|||||||
@@ -2,20 +2,18 @@
|
|||||||
|
|
||||||
A tiny example game used to exercise the bgm loader.
|
A tiny example game used to exercise the bgm loader.
|
||||||
|
|
||||||
```yaml file=harbor.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: harbor
|
id: harbor
|
||||||
title: Harbor
|
title: Harbor
|
||||||
designer: Jane Doe
|
designer: Jane Doe
|
||||||
players: 2
|
players: 2
|
||||||
language: en
|
language: en
|
||||||
|
include: ['./**/*.yaml']
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tokens
|
## Tokens
|
||||||
|
|
||||||
```yaml file=parts/tokens.yaml
|
```yaml role=part.token
|
||||||
role: part
|
|
||||||
type: token
|
|
||||||
id: wood
|
id: wood
|
||||||
face: ./assets/tokens.png
|
face: ./assets/tokens.png
|
||||||
faceCrop: [1, 0, 5, 2]
|
faceCrop: [1, 0, 5, 2]
|
||||||
@@ -26,9 +24,7 @@ size: [20, 20, 3]
|
|||||||
fillet: 2
|
fillet: 2
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/tokens.yaml
|
```yaml role=part.token
|
||||||
role: part
|
|
||||||
type: token
|
|
||||||
id: grain
|
id: grain
|
||||||
face: ./assets/tokens.png
|
face: ./assets/tokens.png
|
||||||
faceCrop: [0, 0, 5, 2]
|
faceCrop: [0, 0, 5, 2]
|
||||||
@@ -41,10 +37,8 @@ fillet: 2
|
|||||||
|
|
||||||
## Board
|
## Board
|
||||||
|
|
||||||
```yaml file=parts/board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: harbor
|
id: harbor
|
||||||
role: surface
|
|
||||||
size: [300, 200]
|
size: [300, 200]
|
||||||
mount:
|
mount:
|
||||||
kind: table
|
kind: table
|
||||||
@@ -63,10 +57,8 @@ layout:
|
|||||||
rotation: 0
|
rotation: 0
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/player.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: player
|
id: player
|
||||||
role: surface
|
|
||||||
size: [200, 200]
|
size: [200, 200]
|
||||||
mount:
|
mount:
|
||||||
kind: child
|
kind: child
|
||||||
@@ -79,14 +71,14 @@ layout:
|
|||||||
$variants: ./hand.csv
|
$variants: ./hand.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/hand.csv
|
```csv file=hand.csv
|
||||||
slot,x,y,rotation
|
slot,x,y,rotation
|
||||||
string,number,number,number
|
string,number,number,number
|
||||||
0,0,0,0
|
0,0,0,0
|
||||||
1,0,20,0
|
1,0,20,0
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/seats.csv
|
```csv file=seats.csv
|
||||||
seat,x,y,rotation
|
seat,x,y,rotation
|
||||||
string,number,number,number
|
string,number,number,number
|
||||||
0,40,0,0
|
0,40,0,0
|
||||||
@@ -95,9 +87,7 @@ string,number,number,number
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```yaml file=setup/main.yaml
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
surfaces:
|
surfaces:
|
||||||
- board#harbor
|
- board#harbor
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
A second tiny example game, used to exercise the loader's collection of
|
A second tiny example game, used to exercise the loader's collection of
|
||||||
multiple packages.
|
multiple packages.
|
||||||
|
|
||||||
```yaml file=azul.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: azul
|
id: azul
|
||||||
title: Azul
|
title: Azul
|
||||||
designer: Michael Kiesling
|
designer: Michael Kiesling
|
||||||
@@ -13,9 +12,7 @@ language: en
|
|||||||
include: ['./**/*.yaml']
|
include: ['./**/*.yaml']
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/tiles.yaml
|
```yaml role=part.tile
|
||||||
role: part
|
|
||||||
type: tile
|
|
||||||
id: blue
|
id: blue
|
||||||
face: ./assets/tiles.png
|
face: ./assets/tiles.png
|
||||||
faceCrop: [0, 0, 5, 5]
|
faceCrop: [0, 0, 5, 5]
|
||||||
@@ -23,10 +20,8 @@ size: [20, 20, 3]
|
|||||||
fillet: 1
|
fillet: 1
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: azul
|
id: azul
|
||||||
role: surface
|
|
||||||
size: [400, 300]
|
size: [400, 300]
|
||||||
layout:
|
layout:
|
||||||
- route: /factory/:n
|
- route: /factory/:n
|
||||||
@@ -34,7 +29,7 @@ layout:
|
|||||||
$variants: ./factories.csv
|
$variants: ./factories.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/factories.csv
|
```csv file=factories.csv
|
||||||
n,x,y,rotation
|
n,x,y,rotation
|
||||||
string,number,number,number
|
string,number,number,number
|
||||||
0,-150,0,0
|
0,-150,0,0
|
||||||
@@ -43,9 +38,7 @@ string,number,number,number
|
|||||||
3,150,0,0
|
3,150,0,0
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=setup/main.yaml
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
setup:
|
setup:
|
||||||
- path: /factory/0
|
- path: /factory/0
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
A tiny example game used to exercise the bgm loader end-to-end through a real
|
A tiny example game used to exercise the bgm loader end-to-end through a real
|
||||||
vite build.
|
vite build.
|
||||||
|
|
||||||
```yaml file=harbor.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: harbor
|
id: harbor
|
||||||
title: Harbor
|
title: Harbor
|
||||||
designer: Jane Doe
|
designer: Jane Doe
|
||||||
@@ -13,9 +12,7 @@ language: en
|
|||||||
include: ['./**/*.yaml']
|
include: ['./**/*.yaml']
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/tokens.yaml
|
```yaml role=part.token
|
||||||
role: part
|
|
||||||
type: token
|
|
||||||
id: wood
|
id: wood
|
||||||
face: ./assets/tokens.png
|
face: ./assets/tokens.png
|
||||||
faceCrop: [1, 0, 5, 2]
|
faceCrop: [1, 0, 5, 2]
|
||||||
@@ -26,10 +23,8 @@ size: [20, 20, 3]
|
|||||||
fillet: 2
|
fillet: 2
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: harbor
|
id: harbor
|
||||||
role: surface
|
|
||||||
size: [300, 200]
|
size: [300, 200]
|
||||||
layout:
|
layout:
|
||||||
- route: /dock/:seat
|
- route: /dock/:seat
|
||||||
@@ -41,16 +36,14 @@ layout:
|
|||||||
rotation: 0
|
rotation: 0
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/seats.csv
|
```csv file=seats.csv
|
||||||
seat,x,y,rotation
|
seat,x,y,rotation
|
||||||
string,number,number,number
|
string,number,number,number
|
||||||
0,40,0,0
|
0,40,0,0
|
||||||
1,40,20,0
|
1,40,20,0
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=setup/main.yaml
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
setup:
|
setup:
|
||||||
- path: /dock/0
|
- path: /dock/0
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe('collectPackages', () => {
|
|||||||
const harbor = packages[0]!;
|
const harbor = packages[0]!;
|
||||||
expect(harbor.meta).toMatchObject({ id: 'harbor', title: 'Harbor', designer: 'Jane Doe' });
|
expect(harbor.meta).toMatchObject({ id: 'harbor', title: 'Harbor', designer: 'Jane Doe' });
|
||||||
|
|
||||||
// Two tokens from two yaml blocks sharing a `file=` name.
|
// Two tokens from two yaml blocks sharing a `role=part.token` name.
|
||||||
expect([...harbor.parts.keys()].sort()).toEqual(['token#grain', 'token#wood']);
|
expect([...harbor.parts.keys()].sort()).toEqual(['token#grain', 'token#wood']);
|
||||||
const wood = harbor.parts.get('token#wood')!;
|
const wood = harbor.parts.get('token#wood')!;
|
||||||
expect(wood).toMatchObject({
|
expect(wood).toMatchObject({
|
||||||
@@ -26,9 +26,9 @@ describe('collectPackages', () => {
|
|||||||
});
|
});
|
||||||
expect(wood.face).toBe('./assets/tokens.png');
|
expect(wood.face).toBe('./assets/tokens.png');
|
||||||
expect(wood.faceCrop).toEqual([1, 0, 5, 2]);
|
expect(wood.faceCrop).toEqual([1, 0, 5, 2]);
|
||||||
// Relative assets resolve against the source file's directory. The fixture
|
// Relative assets resolve against the markdown file's directory. The
|
||||||
// markdown sits at the games root, so the virtual file is `parts/tokens.yaml`.
|
// fixture markdown sits at the games root, so baseUrl is empty.
|
||||||
expect(wood.baseUrl).toBe('parts/');
|
expect(wood.baseUrl).toBe('');
|
||||||
|
|
||||||
// Two surfaces: the table board and its child player board.
|
// Two surfaces: the table board and its child player board.
|
||||||
expect([...harbor.surfaces.keys()].sort()).toEqual(['board#harbor', 'board#player']);
|
expect([...harbor.surfaces.keys()].sort()).toEqual(['board#harbor', 'board#player']);
|
||||||
@@ -81,23 +81,23 @@ describe('collectPackages', () => {
|
|||||||
it('throws on a duplicate type#id', () => {
|
it('throws on a duplicate type#id', () => {
|
||||||
const defMap = loadDefs('', fixtureRoot);
|
const defMap = loadDefs('', fixtureRoot);
|
||||||
// Inject a duplicate part into the map under a new file name.
|
// 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 tokensKey = [...defMap.defs.keys()].find((k) => k.endsWith('part.token.yaml'))!;
|
||||||
const tokens = defMap.defs.get(tokensKey)!;
|
const tokens = defMap.defs.get(tokensKey)!;
|
||||||
defMap.defs.set(tokensKey.replace('tokens.yaml', 'dup.yaml'), [tokens[0]!]);
|
defMap.defs.set(tokensKey.replace('part.token.yaml', 'part.dup.yaml'), [tokens[0]!]);
|
||||||
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(/Duplicate part/);
|
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(/Duplicate part/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when an info-string id combines with $variants', () => {
|
it('throws when an info-string id combines with $variants', () => {
|
||||||
const defMap = loadDefs('', fixtureRoot);
|
const defMap = loadDefs('', fixtureRoot);
|
||||||
// A part whose `id` comes from $variants rows, but with an info-string id.
|
// A part whose `id` comes from $variants rows, but with an info-string id.
|
||||||
const tokensKey = [...defMap.defs.keys()].find((k) => k.endsWith('parts/tokens.yaml'))!;
|
const tokensKey = [...defMap.defs.keys()].find((k) => k.endsWith('part.token.yaml'))!;
|
||||||
const tokens = defMap.defs.get(tokensKey)!;
|
const tokens = defMap.defs.get(tokensKey)!;
|
||||||
const variant = {
|
const variant = {
|
||||||
...tokens[0]!,
|
...tokens[0]!,
|
||||||
value: { ...tokens[0]!.value, id: undefined, $variants: './parts/seats.csv' },
|
value: { ...tokens[0]!.value, id: undefined, $variants: './seats.csv' },
|
||||||
role: { role: 'part', type: 'token', id: 'wood' },
|
role: { role: 'part', type: 'token', id: 'wood' },
|
||||||
};
|
};
|
||||||
defMap.defs.set(tokensKey.replace('tokens.yaml', 'dup.yaml'), [variant]);
|
defMap.defs.set(tokensKey.replace('part.token.yaml', 'part.dup.yaml'), [variant]);
|
||||||
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(/can't combine with \$variants/);
|
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(/can't combine with \$variants/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+26
-14
@@ -144,9 +144,9 @@ class PackageAcc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Expand `$variants` on a def object into a list of concrete objects. */
|
/** 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>[] {
|
private expand(obj: Record<string, unknown>, baseDir: string, source: string): Record<string, unknown>[] {
|
||||||
if (!('$variants' in obj)) return [obj];
|
if (!('$variants' in obj)) return [obj];
|
||||||
const rows = expandVariants(obj['$variants'], baseName, this.defs.files, source);
|
const rows = expandVariants(obj['$variants'], baseNameFor(baseDir), this.defs.files, source);
|
||||||
const { $variants: _v, ...base } = obj;
|
const { $variants: _v, ...base } = obj;
|
||||||
return rows.map((row) => ({ ...base, ...row }));
|
return rows.map((row) => ({ ...base, ...row }));
|
||||||
}
|
}
|
||||||
@@ -161,9 +161,15 @@ class PackageAcc {
|
|||||||
// patterns are prefixed to match.
|
// patterns are prefixed to match.
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
|
// A leading `/` marks a pattern as root-relative. Otherwise it's
|
||||||
|
// relative to the package declaration's directory. When the package is
|
||||||
|
// at the games root (empty baseDir), the pattern has no leading slash:
|
||||||
|
// `**/*.yaml` matches root-level files, whereas `/**/*.yaml` does not.
|
||||||
const resolved = pattern.startsWith('/')
|
const resolved = pattern.startsWith('/')
|
||||||
? pattern
|
? pattern
|
||||||
: `/${path.posix.join(this.baseDir, pattern)}`;
|
: this.baseDir
|
||||||
|
? `/${path.posix.join(this.baseDir, pattern)}`
|
||||||
|
: path.posix.join(this.baseDir, pattern);
|
||||||
const matcher = picomatch(resolved, { dot: true });
|
const matcher = picomatch(resolved, { dot: true });
|
||||||
for (const name of this.defs.defs.keys()) {
|
for (const name of this.defs.defs.keys()) {
|
||||||
if (matcher(name)) names.add(name);
|
if (matcher(name)) names.add(name);
|
||||||
@@ -178,11 +184,11 @@ class PackageAcc {
|
|||||||
if (def.role?.id && '$variants' in def.value) {
|
if (def.role?.id && '$variants' in def.value) {
|
||||||
throw new BgmError(`id on the info string can't combine with $variants`, fileName);
|
throw new BgmError(`id on the info string can't combine with $variants`, fileName);
|
||||||
}
|
}
|
||||||
const expanded = this.expand(def.value, def.file, def.source);
|
const expanded = this.expand(def.value, def.baseDir ?? '', def.source);
|
||||||
for (const obj of expanded) {
|
for (const obj of expanded) {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'part': {
|
case 'part': {
|
||||||
const part = asPart(obj, fileName);
|
const part = asPart(obj, def.baseDir ?? '');
|
||||||
const key = `${part.type}#${part.id}`;
|
const key = `${part.type}#${part.id}`;
|
||||||
if (this.parts.has(key)) {
|
if (this.parts.has(key)) {
|
||||||
throw new BgmError(`Duplicate part "${key}"`, fileName);
|
throw new BgmError(`Duplicate part "${key}"`, fileName);
|
||||||
@@ -191,7 +197,7 @@ class PackageAcc {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'surface': {
|
case 'surface': {
|
||||||
const surface = asSurface(obj, fileName, this.defs.files);
|
const surface = asSurface(obj, def.baseDir ?? '', this.defs.files);
|
||||||
const key = `${surface.type}#${surface.id}`;
|
const key = `${surface.type}#${surface.id}`;
|
||||||
if (this.surfaces.has(key)) {
|
if (this.surfaces.has(key)) {
|
||||||
throw new BgmError(`Duplicate surface "${key}"`, fileName);
|
throw new BgmError(`Duplicate surface "${key}"`, fileName);
|
||||||
@@ -231,23 +237,29 @@ function asPackage(def: ParsedDef, source: string): PackageDef {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function asPart(obj: Record<string, unknown>, source: string): Part {
|
/** A base name whose directory is `baseDir`, for resolving `$variants` paths. */
|
||||||
|
function baseNameFor(baseDir: string): string {
|
||||||
|
const dir = baseDir.replace(/^\/+/, '');
|
||||||
|
return dir ? `/${dir}/def.yaml` : '/def.yaml';
|
||||||
|
}
|
||||||
|
|
||||||
|
function asPart(obj: Record<string, unknown>, baseDir: string): Part {
|
||||||
try {
|
try {
|
||||||
const part = validatePart(obj) as unknown as Part;
|
const part = validatePart(obj) as unknown as Part;
|
||||||
// Resolve relative asset paths against the directory of the source file
|
// Resolve relative asset paths against the directory of the source file
|
||||||
// (path-style name relative to the games root, e.g. `harbor/parts/`).
|
// (path-style name relative to the games root). For a code block this is
|
||||||
// Real files may carry a leading slash from an empty root; strip it.
|
// the markdown file's directory; for a real file, its own directory.
|
||||||
const dir = path.posix.dirname(source).replace(/^\/+/, '');
|
const dir = baseDir.replace(/^\/+/, '');
|
||||||
part.baseUrl = dir ? `${dir}/` : '';
|
part.baseUrl = dir ? `${dir}/` : '';
|
||||||
return part;
|
return part;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw wrapZod(err, source);
|
throw wrapZod(err, '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function asSurface(
|
function asSurface(
|
||||||
obj: Record<string, unknown>,
|
obj: Record<string, unknown>,
|
||||||
source: string,
|
baseDir: string,
|
||||||
defs: Map<string, DefFile[]>,
|
defs: Map<string, DefFile[]>,
|
||||||
): Surface {
|
): Surface {
|
||||||
const value: Record<string, unknown> = { ...obj };
|
const value: Record<string, unknown> = { ...obj };
|
||||||
@@ -260,7 +272,7 @@ function asSurface(
|
|||||||
const r = route as Record<string, unknown>;
|
const r = route as Record<string, unknown>;
|
||||||
const cand = r['candidates'];
|
const cand = r['candidates'];
|
||||||
if (cand && typeof cand === 'object' && !Array.isArray(cand) && '$variants' in cand) {
|
if (cand && typeof cand === 'object' && !Array.isArray(cand) && '$variants' in cand) {
|
||||||
const rows = expandVariants(cand['$variants'], source, defs, source);
|
const rows = expandVariants(cand['$variants'], baseNameFor(baseDir), defs, baseDir);
|
||||||
const { $variants: _v, ...base } = cand as Record<string, unknown>;
|
const { $variants: _v, ...base } = cand as Record<string, unknown>;
|
||||||
return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) };
|
return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) };
|
||||||
}
|
}
|
||||||
@@ -270,7 +282,7 @@ function asSurface(
|
|||||||
try {
|
try {
|
||||||
return validateSurface(value) as unknown as Surface;
|
return validateSurface(value) as unknown as Surface;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw wrapZod(err, source);
|
throw wrapZod(err, baseDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { scanMarkdown } from './markdown.js';
|
import { scanMarkdown } from './markdown.js';
|
||||||
|
|
||||||
describe('scanMarkdown', () => {
|
describe('scanMarkdown', () => {
|
||||||
it('extracts a fenced code block with a file= name', () => {
|
it('names a block from its role= as role.type.lang', () => {
|
||||||
const md = [
|
const md = [
|
||||||
'# Title',
|
'# Title',
|
||||||
'',
|
'',
|
||||||
'```yaml file=parts/cargo.yaml',
|
'```yaml role=part.cargo',
|
||||||
'role: part',
|
'id: wood',
|
||||||
'```',
|
'```',
|
||||||
'',
|
'',
|
||||||
'text after',
|
'text after',
|
||||||
@@ -17,66 +17,72 @@ describe('scanMarkdown', () => {
|
|||||||
|
|
||||||
expect(fences).toHaveLength(1);
|
expect(fences).toHaveLength(1);
|
||||||
expect(fences[0]).toMatchObject({
|
expect(fences[0]).toMatchObject({
|
||||||
info: 'yaml file=parts/cargo.yaml',
|
info: 'yaml role=part.cargo',
|
||||||
content: 'role: part',
|
content: 'id: wood',
|
||||||
startLine: 3,
|
startLine: 3,
|
||||||
endLine: 5,
|
endLine: 5,
|
||||||
});
|
});
|
||||||
expect(files).toHaveLength(1);
|
expect(files).toHaveLength(1);
|
||||||
expect(files[0]).toMatchObject({
|
expect(files[0]).toMatchObject({
|
||||||
name: 'harbor/parts/cargo.yaml',
|
name: 'harbor/part.cargo.yaml',
|
||||||
kind: 'yaml',
|
kind: 'yaml',
|
||||||
text: 'role: part',
|
text: 'id: wood',
|
||||||
source: 'harbor/harbor.md:3-5',
|
source: 'harbor/harbor.md:3-5',
|
||||||
|
role: { role: 'part', type: 'cargo' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('auto-names a block without file= from its content hash', () => {
|
it('names a package block package.yaml', () => {
|
||||||
const md = '```yaml\nrole: part\n```';
|
const md = '```yaml role=package\nid: harbor\n```';
|
||||||
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||||
expect(files).toHaveLength(1);
|
expect(files[0]).toMatchObject({ name: 'harbor/package.yaml', role: { role: 'package' } });
|
||||||
expect(files[0]!.name).toMatch(/^harbor\/[0-9a-f]{8}\.yaml$/);
|
|
||||||
expect(files[0]!.kind).toBe('yaml');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hashes identical blocks to the same auto-name', () => {
|
it('names a surface block with type and id', () => {
|
||||||
const md = ['```yaml\nrole: part\n```', '', '```yaml\nrole: part\n```'].join('\n');
|
const md = '```yaml role=surface.game#main\n```';
|
||||||
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
|
||||||
expect(files).toHaveLength(2);
|
|
||||||
expect(files[0]!.name).toBe(files[1]!.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses role= metadata from the info string', () => {
|
|
||||||
const md = '```yaml file=cards.yaml role=part.poker-card\nid: 2s\n```';
|
|
||||||
const { files } = scanMarkdown(md, 'poker/poker.md');
|
const { files } = scanMarkdown(md, 'poker/poker.md');
|
||||||
expect(files[0]).toMatchObject({
|
expect(files[0]).toMatchObject({
|
||||||
name: 'poker/cards.yaml',
|
name: 'poker/surface.game.yaml',
|
||||||
role: { role: 'part', type: 'poker-card' },
|
role: { role: 'surface', type: 'game', id: 'main' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses role= with type and id', () => {
|
it('uses file= to override the role.type name', () => {
|
||||||
const md = '```yaml file=board.yaml role=surface.game#main\n```';
|
const md = '```yaml file=parts/cargo.yaml role=part.cargo\n```';
|
||||||
const { files } = scanMarkdown(md, 'poker/poker.md');
|
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||||
expect(files[0]!.role).toEqual({ role: 'surface', type: 'game', id: 'main' });
|
expect(files[0]).toMatchObject({
|
||||||
|
name: 'harbor/parts/cargo.yaml',
|
||||||
|
role: { role: 'part', type: 'cargo' },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses role=package without type or id', () => {
|
it('ignores a block without role= or file=', () => {
|
||||||
const md = '```yaml file=game.yaml role=package\n```';
|
const md = '```yaml\nrole: part\n```';
|
||||||
const { files } = scanMarkdown(md, 'poker/poker.md');
|
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||||
expect(files[0]!.role).toEqual({ role: 'package' });
|
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('throws on an unknown role=', () => {
|
it('throws on an unknown role=', () => {
|
||||||
const md = '```yaml file=a.yaml role=widget\n```';
|
const md = '```yaml role=widget\n```';
|
||||||
expect(() => scanMarkdown(md, 'poker/poker.md')).toThrow(/Invalid role/);
|
expect(() => scanMarkdown(md, 'poker/poker.md')).toThrow(/Invalid role/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws on role=package with a type', () => {
|
it('throws on role=package with a type', () => {
|
||||||
const md = '```yaml file=a.yaml role=package.foo\n```';
|
const md = '```yaml role=package.foo\n```';
|
||||||
expect(() => scanMarkdown(md, 'poker/poker.md')).toThrow(/Package role takes no type/);
|
expect(() => scanMarkdown(md, 'poker/poker.md')).toThrow(/Package role takes no type/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('throws on a role without a type', () => {
|
||||||
|
const md = '```yaml role=part\n```';
|
||||||
|
expect(() => scanMarkdown(md, 'poker/poker.md')).toThrow(/requires a type/);
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores non-definition languages', () => {
|
it('ignores non-definition languages', () => {
|
||||||
const md = '```js\nconst x = 1;\n```';
|
const md = '```js\nconst x = 1;\n```';
|
||||||
const { files, fences } = scanMarkdown(md, 'harbor/harbor.md');
|
const { files, fences } = scanMarkdown(md, 'harbor/harbor.md');
|
||||||
@@ -90,23 +96,15 @@ describe('scanMarkdown', () => {
|
|||||||
expect(files).toHaveLength(0);
|
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', () => {
|
it('tracks line numbers across multiple blocks', () => {
|
||||||
const md = [
|
const md = [
|
||||||
'```yaml file=a.yaml',
|
'```yaml role=part.a',
|
||||||
'role: part',
|
|
||||||
'```',
|
'```',
|
||||||
'',
|
'',
|
||||||
'```yaml file=b.yaml',
|
'```yaml role=part.b',
|
||||||
'role: part',
|
|
||||||
'```',
|
'```',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
const { fences } = scanMarkdown(md, 'harbor/harbor.md');
|
const { fences } = scanMarkdown(md, 'harbor/harbor.md');
|
||||||
expect(fences.map((f) => f.startLine)).toEqual([1, 5]);
|
expect(fences.map((f) => f.startLine)).toEqual([1, 4]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Extract virtual definition files from markdown code blocks.
|
* Extract virtual definition files from markdown code blocks.
|
||||||
*
|
*
|
||||||
* Each fenced code block is a virtual definition file:
|
* Each fenced code block is a virtual definition file. A block is a
|
||||||
* - With a `file=` segment in its info string, named relative to the
|
* definition only when its info string declares a `role=` (or a `file=`):
|
||||||
* current markdown file: a yaml block with `file=parts/cargo.yaml`.
|
* - With `role=part.cargo`, named `part.cargo.yaml` (the `role.type.lang`
|
||||||
* - Without one, auto-named `./<hash>.yaml` from its content, so every yaml
|
* form), discoverable by the default include pattern (all yaml in the
|
||||||
* block is discoverable by the default include pattern (all yaml in the
|
* same and sub folders).
|
||||||
* same and sub folders). Identical blocks dedupe to the same hash.
|
* - With `file=parts/cargo.yaml`, named that path regardless of its role.
|
||||||
|
* - Without either, the block is not a definition and is ignored.
|
||||||
*
|
*
|
||||||
* Markdown is tokenized with `marked`; each `code` token is a candidate
|
* Markdown is tokenized with `marked`; each `code` token is a candidate
|
||||||
* virtual file.
|
* virtual file.
|
||||||
*/
|
*/
|
||||||
import * as crypto from 'node:crypto';
|
|
||||||
import { posix } from 'node:path';
|
import { posix } from 'node:path';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { BgmError, ROLES, type DefFile, type Role, type RoleMeta } from './types.js';
|
import { BgmError, ROLES, roleToName, type DefFile, type Role, type RoleMeta } from './types.js';
|
||||||
|
|
||||||
/** The languages that count as definition files; others are ignored. */
|
/** The languages that count as definition files; others are ignored. */
|
||||||
const DEF_LANGS = new Set(['yaml', 'yml', 'json', 'toml']);
|
const DEF_LANGS = new Set(['yaml', 'yml', 'json', 'toml']);
|
||||||
@@ -25,7 +25,7 @@ export interface Fence {
|
|||||||
startLine: number;
|
startLine: number;
|
||||||
/** Line number (1-based) of the closing fence. */
|
/** Line number (1-based) of the closing fence. */
|
||||||
endLine: number;
|
endLine: number;
|
||||||
/** The info string content (e.g. `yaml file=parts/cargo.yaml`). */
|
/** The info string content (e.g. `yaml role=part.cargo`). */
|
||||||
info: string;
|
info: string;
|
||||||
/** The code block's content (without the fences). */
|
/** The code block's content (without the fences). */
|
||||||
content: string;
|
content: string;
|
||||||
@@ -60,14 +60,17 @@ export function scanMarkdown(text: string, sourcePath: string): MarkdownResult {
|
|||||||
|
|
||||||
fences.push({ startLine, endLine, info, content: token.text });
|
fences.push({ startLine, endLine, info, content: token.text });
|
||||||
|
|
||||||
const name = parseInfo(info, token.text);
|
const parsed = parseInfo(info, `${sourcePath}:${startLine}`);
|
||||||
if (name) {
|
if (parsed) {
|
||||||
files.push({
|
files.push({
|
||||||
name: posix.join(posix.dirname(sourcePath), name),
|
name: posix.join(posix.dirname(sourcePath), parsed.name),
|
||||||
text: token.text,
|
text: token.text,
|
||||||
source: `${sourcePath}:${startLine}-${endLine}`,
|
source: `${sourcePath}:${startLine}-${endLine}`,
|
||||||
kind: kindOf(name),
|
kind: parsed.kind,
|
||||||
role: parseRole(info, `${sourcePath}:${startLine}`),
|
role: parsed.role,
|
||||||
|
// Relative asset paths resolve against the markdown file's directory,
|
||||||
|
// not the virtual `role.type` name (which has no directory).
|
||||||
|
baseDir: posix.dirname(sourcePath),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,17 +78,31 @@ export function scanMarkdown(text: string, sourcePath: string): MarkdownResult {
|
|||||||
return { fences, files };
|
return { fences, files };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
interface ParsedInfo {
|
||||||
* Parse a fence's info string for a `file=` segment and derive the virtual
|
name: string;
|
||||||
* file name. Blocks without `file=` are auto-named from their content hash.
|
kind: DefFile['kind'];
|
||||||
*/
|
role?: RoleMeta;
|
||||||
function parseInfo(info: string, content: string): string | null {
|
}
|
||||||
const fileMatch = /file=(\S+)/.exec(info);
|
|
||||||
if (fileMatch) return fileMatch[1]!;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a fence's info string into a virtual file name and role metadata.
|
||||||
|
* A block is a definition when it has a `role=` or a `file=`. `file=`
|
||||||
|
* overrides the auto `role.type.lang` name.
|
||||||
|
*/
|
||||||
|
function parseInfo(info: string, source: string): ParsedInfo | null {
|
||||||
const lang = info.split(/\s+/)[0];
|
const lang = info.split(/\s+/)[0];
|
||||||
if (!lang || !DEF_LANGS.has(lang)) return null;
|
const fileMatch = /file=(\S+)/.exec(info);
|
||||||
return `./${contentHash(content)}.yaml`;
|
const role = parseRole(info, source);
|
||||||
|
|
||||||
|
if (fileMatch) {
|
||||||
|
const name = fileMatch[1]!;
|
||||||
|
return { name, kind: kindOf(name), role };
|
||||||
|
}
|
||||||
|
if (!role) return null;
|
||||||
|
if (!lang || !DEF_LANGS.has(lang)) {
|
||||||
|
throw new BgmError(`Definition block needs a definition language tag`, source);
|
||||||
|
}
|
||||||
|
return { name: roleToName(role, extOf(lang)), kind: kindOfLang(lang), role };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,7 +124,8 @@ function parseRole(info: string, source: string): RoleMeta | undefined {
|
|||||||
return { role: 'package' };
|
return { role: 'package' };
|
||||||
}
|
}
|
||||||
const [type, id] = (rest ?? '').split('#');
|
const [type, id] = (rest ?? '').split('#');
|
||||||
return { role: role as Role, type: type || undefined, id: id || undefined };
|
if (!type) throw new BgmError(`Role "${role}" requires a type`, source);
|
||||||
|
return { role: role as Role, type, id: id || undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Derive the def file type from its name's extension. */
|
/** Derive the def file type from its name's extension. */
|
||||||
@@ -119,9 +137,21 @@ function kindOf(name: string): DefFile['kind'] {
|
|||||||
return 'yaml';
|
return 'yaml';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hash the block's content, so identical blocks dedupe to the same name. */
|
/** The file extension for a definition language tag. */
|
||||||
function contentHash(content: string): string {
|
function extOf(lang: string): string {
|
||||||
return crypto.createHash('sha1').update(content).digest('hex').slice(0, 8);
|
return lang === 'yml' ? 'yml' : lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The def file kind for a definition language tag. */
|
||||||
|
function kindOfLang(lang: string): DefFile['kind'] {
|
||||||
|
switch (lang) {
|
||||||
|
case 'json':
|
||||||
|
return 'json';
|
||||||
|
case 'toml':
|
||||||
|
return 'toml';
|
||||||
|
default:
|
||||||
|
return 'yaml';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The 1-based line number where `raw` starts within `text`. */
|
/** The 1-based line number where `raw` starts within `text`. */
|
||||||
@@ -133,9 +163,8 @@ function lineOf(text: string, raw: string): number {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Virtual files gathered from markdown code blocks, keyed by path-style name.
|
* 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`
|
* Multiple blocks may share a name (e.g. several `role=part.token` blocks);
|
||||||
* blocks); each is kept as a separate entry. Identical blocks dedupe to the
|
* each is kept as a separate entry.
|
||||||
* same hash name.
|
|
||||||
*/
|
*/
|
||||||
export type VirtualFiles = Map<string, DefFile[]>;
|
export type VirtualFiles = Map<string, DefFile[]>;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ function file(overrides: Partial<DefFile> = {}): DefFile {
|
|||||||
text: 'id: 2s',
|
text: 'id: 2s',
|
||||||
source: 'poker/poker.md:3-5',
|
source: 'poker/poker.md:3-5',
|
||||||
kind: 'yaml',
|
kind: 'yaml',
|
||||||
|
baseDir: 'poker',
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -43,6 +44,11 @@ describe('parseDefText', () => {
|
|||||||
expect(defs[0]!.role).toEqual({ role: 'part', type: 'poker-card' });
|
expect(defs[0]!.role).toEqual({ role: 'part', type: 'poker-card' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('carries the base directory on the parsed def', () => {
|
||||||
|
const defs = parseDefText(file({ baseDir: 'poker/parts' }));
|
||||||
|
expect(defs[0]!.baseDir).toBe('poker/parts');
|
||||||
|
});
|
||||||
|
|
||||||
it('errors on a role conflict with the content', () => {
|
it('errors on a role conflict with the content', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
parseDefText(file({ text: 'role: surface', role: { role: 'part' } })),
|
parseDefText(file({ text: 'role: surface', role: { role: 'part' } })),
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import * as fs from 'node:fs';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { parse as parseYaml } from 'yaml';
|
import { parse as parseYaml } from 'yaml';
|
||||||
import { parse as parseToml } from 'smol-toml';
|
import { parse as parseToml } from 'smol-toml';
|
||||||
import { BgmError, type DefFile, type ParsedDef } from './types.js';
|
import { BgmError, roleFromName, type DefFile, type ParsedDef } from './types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a def file's text into a list of definition objects.
|
* Parse a def file's text into a list of definition objects.
|
||||||
*
|
*
|
||||||
* A code block's `role=` info-string metadata is merged into every parsed
|
* A code block's `role=` (or a real file's `role.type` name) metadata is
|
||||||
* object, erroring on a conflict with the same key in the content.
|
* merged into every parsed object, erroring on a conflict with the same key
|
||||||
|
* in the content.
|
||||||
*
|
*
|
||||||
* @returns the parsed objects; the root object (index `-1`) or the list
|
* @returns the parsed objects; the root object (index `-1`) or the list
|
||||||
* items (index `0..n`)
|
* items (index `0..n`)
|
||||||
@@ -37,7 +38,7 @@ export function parseDefText(file: DefFile): ParsedDef[] {
|
|||||||
const push = (value: unknown, index: number) => {
|
const push = (value: unknown, index: number) => {
|
||||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
const merged = mergeRole(file, value as Record<string, unknown>);
|
const merged = mergeRole(file, value as Record<string, unknown>);
|
||||||
out.push({ file: file.name, index, value: merged, source: file.source, role: file.role });
|
out.push({ file: file.name, index, value: merged, source: file.source, role: file.role, baseDir: file.baseDir });
|
||||||
} else if (value !== null) {
|
} else if (value !== null) {
|
||||||
throw new BgmError(`Expected a JSON object, got ${typeof value}`, file.source);
|
throw new BgmError(`Expected a JSON object, got ${typeof value}`, file.source);
|
||||||
}
|
}
|
||||||
@@ -51,7 +52,7 @@ export function parseDefText(file: DefFile): ParsedDef[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merge a code block's `role=` metadata into a parsed object, erroring on conflict. */
|
/** Merge a definition's `role=`/filename metadata into a parsed object, erroring on conflict. */
|
||||||
function mergeRole(file: DefFile, value: Record<string, unknown>): Record<string, unknown> {
|
function mergeRole(file: DefFile, value: Record<string, unknown>): Record<string, unknown> {
|
||||||
const meta = file.role;
|
const meta = file.role;
|
||||||
if (!meta) return value;
|
if (!meta) return value;
|
||||||
@@ -65,7 +66,7 @@ function mergeRole(file: DefFile, value: Record<string, unknown>): Record<string
|
|||||||
if (fromMeta === undefined) continue;
|
if (fromMeta === undefined) continue;
|
||||||
const fromContent = out[key];
|
const fromContent = out[key];
|
||||||
if (fromContent !== undefined && fromContent !== fromMeta) {
|
if (fromContent !== undefined && fromContent !== fromMeta) {
|
||||||
throw new BgmError(`Conflicting ${key}: "${fromMeta}" on the info string vs "${fromContent}" in content`, file.source);
|
throw new BgmError(`Conflicting ${key}: "${fromMeta}" in the name vs "${fromContent}" in content`, file.source);
|
||||||
}
|
}
|
||||||
out[key] = fromMeta;
|
out[key] = fromMeta;
|
||||||
}
|
}
|
||||||
@@ -117,6 +118,8 @@ export function readDefFiles(dir: string, root: string): DefFile[] {
|
|||||||
text: fs.readFileSync(abs, 'utf8'),
|
text: fs.readFileSync(abs, 'utf8'),
|
||||||
source: abs,
|
source: abs,
|
||||||
kind,
|
kind,
|
||||||
|
role: roleFromName(entry.name),
|
||||||
|
baseDir: root && relPath.includes('/') ? relPath.slice(0, relPath.lastIndexOf('/')) : '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,9 +192,10 @@ export type Role = 'package' | 'part' | 'surface' | 'setup';
|
|||||||
export const ROLES: ReadonlySet<Role> = new Set(['package', 'part', 'surface', 'setup']);
|
export const ROLES: ReadonlySet<Role> = new Set(['package', 'part', 'surface', 'setup']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Role metadata declared on a code block's info string (`role=part.cargo`).
|
* Role metadata declared on a code block's info string (`role=part.cargo`) or
|
||||||
* `type` and `id` are optional; anything not given comes from the content or
|
* a real file's name (`part.cargo.yaml`). `type` is required for all roles
|
||||||
* from `$variants` rows.
|
* except `package`; `id` is optional — anything not given comes from the
|
||||||
|
* content or from `$variants` rows.
|
||||||
*/
|
*/
|
||||||
export interface RoleMeta {
|
export interface RoleMeta {
|
||||||
role: Role;
|
role: Role;
|
||||||
@@ -202,6 +203,25 @@ export interface RoleMeta {
|
|||||||
id?: string;
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The canonical file name for a role, e.g. `part.cargo.yaml`. */
|
||||||
|
export function roleToName(role: RoleMeta, ext = 'yaml'): string {
|
||||||
|
if (role.role === 'package') return `package.${ext}`;
|
||||||
|
return `${role.role}.${role.type}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `role.type.lang` file name into role metadata, or `undefined` when
|
||||||
|
* the name is not a definition (`package.yaml`, `part.cargo.yaml`, ...).
|
||||||
|
*/
|
||||||
|
export function roleFromName(name: string): RoleMeta | undefined {
|
||||||
|
if (/^package\.(ya?ml|json|toml)$/i.test(name)) return { role: 'package' };
|
||||||
|
const m = /^(\w+)\.(.+)\.(ya?ml|json|toml)$/i.exec(name);
|
||||||
|
if (m && ROLES.has(m[1] as Role) && m[1] !== 'package') {
|
||||||
|
return { role: m[1] as Role, type: m[2] };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A raw definition object as written by the author. Definitions can be the
|
* 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.
|
* root of a file/block or an item in the file's list.
|
||||||
@@ -242,8 +262,14 @@ export interface DefFile {
|
|||||||
source: string;
|
source: string;
|
||||||
/** File type derived from the name's extension. */
|
/** File type derived from the name's extension. */
|
||||||
kind: 'yaml' | 'json' | 'toml' | 'markdown' | 'csv';
|
kind: 'yaml' | 'json' | 'toml' | 'markdown' | 'csv';
|
||||||
/** Role declared on a code block's info string, if any. */
|
/** Role declared on a code block's info string or a real file's name. */
|
||||||
role?: RoleMeta;
|
role?: RoleMeta;
|
||||||
|
/**
|
||||||
|
* Directory (path-style, relative to the games root) that relative asset
|
||||||
|
* paths resolve against. For a code block, the markdown file's directory;
|
||||||
|
* for a real file, its own directory.
|
||||||
|
*/
|
||||||
|
baseDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single parsed definition (one JSON object from a def file). */
|
/** A single parsed definition (one JSON object from a def file). */
|
||||||
@@ -255,8 +281,10 @@ export interface ParsedDef {
|
|||||||
value: Record<string, unknown>;
|
value: Record<string, unknown>;
|
||||||
/** Source location for error messages (real path or `file.md:12-19`). */
|
/** Source location for error messages (real path or `file.md:12-19`). */
|
||||||
source: string;
|
source: string;
|
||||||
/** Role declared on the code block's info string, if any. */
|
/** Role declared on the code block's info string or a real file's name. */
|
||||||
role?: RoleMeta;
|
role?: RoleMeta;
|
||||||
|
/** Directory relative asset paths resolve against (see `DefFile.baseDir`). */
|
||||||
|
baseDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user