diff --git a/games/carcassonne/carcassonne.md b/games/carcassonne/carcassonne.md index c7f0dcc..3aa8d76 100644 --- a/games/carcassonne/carcassonne.md +++ b/games/carcassonne/carcassonne.md @@ -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 standard `45×45` mm square. -```yaml file=carcassonne.yaml -role: package +```yaml role=package id: carcassonne title: Carcassonne 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 tile parts. -```yaml file=tiles.yaml -role: part -type: tile +```yaml role=part.tile face: ./20AE_Base_Game_C2_Tile_A.png size: [45, 45, 3] 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 so tiles sit edge to edge. -```yaml file=board.yaml -type: board +```yaml role=surface.board id: board -role: surface size: [600, 600] layout: - 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 every tile of that type), then seed the grid with a few opening tiles. -```yaml file=main.yaml -role: setup -type: game +```yaml role=setup.game id: main surfaces: - board#board diff --git a/games/poker/poker.md b/games/poker/poker.md index 76f6f6e..8eba2dd 100644 --- a/games/poker/poker.md +++ b/games/poker/poker.md @@ -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 generate a full deck from a single part definition. -```yaml file=poker.yaml -role: package +```yaml role=package id: poker title: Poker 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 (`back-4x1.png`). -```yaml file=cards.yaml -role: part -type: card +```yaml role=part.card face: ./cards-13x4.jpg back: ./back-4x1.png 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 middle. The deck pile fans its stacked cards along a curve. -```yaml file=board.yaml -type: board +```yaml role=surface.board id: poker -role: surface size: [600, 400] layout: - 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 and stand a couple of drawn cards on their bottom edge. -```yaml file=main.yaml -role: setup -type: game +```yaml role=setup.game id: main setup: - path: /deck diff --git a/packages/bgm/src/__fixtures__/harbor/harbor.md b/packages/bgm/src/__fixtures__/harbor/harbor.md index 44a97bf..ce5c05d 100644 --- a/packages/bgm/src/__fixtures__/harbor/harbor.md +++ b/packages/bgm/src/__fixtures__/harbor/harbor.md @@ -2,20 +2,18 @@ A tiny example game used to exercise the bgm loader. -```yaml file=harbor.yaml -role: package +```yaml role=package id: harbor title: Harbor designer: Jane Doe players: 2 language: en +include: ['./**/*.yaml'] ``` ## Tokens -```yaml file=parts/tokens.yaml -role: part -type: token +```yaml role=part.token id: wood face: ./assets/tokens.png faceCrop: [1, 0, 5, 2] @@ -26,9 +24,7 @@ size: [20, 20, 3] fillet: 2 ``` -```yaml file=parts/tokens.yaml -role: part -type: token +```yaml role=part.token id: grain face: ./assets/tokens.png faceCrop: [0, 0, 5, 2] @@ -41,10 +37,8 @@ fillet: 2 ## Board -```yaml file=parts/board.yaml -type: board +```yaml role=surface.board id: harbor -role: surface size: [300, 200] mount: kind: table @@ -63,10 +57,8 @@ layout: rotation: 0 ``` -```yaml file=parts/player.yaml -type: board +```yaml role=surface.board id: player -role: surface size: [200, 200] mount: kind: child @@ -79,14 +71,14 @@ layout: $variants: ./hand.csv ``` -```csv file=parts/hand.csv +```csv file=hand.csv slot,x,y,rotation string,number,number,number 0,0,0,0 1,0,20,0 ``` -```csv file=parts/seats.csv +```csv file=seats.csv seat,x,y,rotation string,number,number,number 0,40,0,0 @@ -95,9 +87,7 @@ string,number,number,number ## Setup -```yaml file=setup/main.yaml -role: setup -type: game +```yaml role=setup.game id: main surfaces: - board#harbor diff --git a/packages/bgm/src/__fixtures__/vite-build/games/azul/azul.md b/packages/bgm/src/__fixtures__/vite-build/games/azul/azul.md index 3cf658c..9eafba8 100644 --- a/packages/bgm/src/__fixtures__/vite-build/games/azul/azul.md +++ b/packages/bgm/src/__fixtures__/vite-build/games/azul/azul.md @@ -3,8 +3,7 @@ A second tiny example game, used to exercise the loader's collection of multiple packages. -```yaml file=azul.yaml -role: package +```yaml role=package id: azul title: Azul designer: Michael Kiesling @@ -13,9 +12,7 @@ language: en include: ['./**/*.yaml'] ``` -```yaml file=parts/tiles.yaml -role: part -type: tile +```yaml role=part.tile id: blue face: ./assets/tiles.png faceCrop: [0, 0, 5, 5] @@ -23,10 +20,8 @@ size: [20, 20, 3] fillet: 1 ``` -```yaml file=parts/board.yaml -type: board +```yaml role=surface.board id: azul -role: surface size: [400, 300] layout: - route: /factory/:n @@ -34,7 +29,7 @@ layout: $variants: ./factories.csv ``` -```csv file=parts/factories.csv +```csv file=factories.csv n,x,y,rotation string,number,number,number 0,-150,0,0 @@ -43,9 +38,7 @@ string,number,number,number 3,150,0,0 ``` -```yaml file=setup/main.yaml -role: setup -type: game +```yaml role=setup.game id: main setup: - path: /factory/0 diff --git a/packages/bgm/src/__fixtures__/vite-build/games/harbor/harbor.md b/packages/bgm/src/__fixtures__/vite-build/games/harbor/harbor.md index 1385895..2ff8f41 100644 --- a/packages/bgm/src/__fixtures__/vite-build/games/harbor/harbor.md +++ b/packages/bgm/src/__fixtures__/vite-build/games/harbor/harbor.md @@ -3,8 +3,7 @@ A tiny example game used to exercise the bgm loader end-to-end through a real vite build. -```yaml file=harbor.yaml -role: package +```yaml role=package id: harbor title: Harbor designer: Jane Doe @@ -13,9 +12,7 @@ language: en include: ['./**/*.yaml'] ``` -```yaml file=parts/tokens.yaml -role: part -type: token +```yaml role=part.token id: wood face: ./assets/tokens.png faceCrop: [1, 0, 5, 2] @@ -26,10 +23,8 @@ size: [20, 20, 3] fillet: 2 ``` -```yaml file=parts/board.yaml -type: board +```yaml role=surface.board id: harbor -role: surface size: [300, 200] layout: - route: /dock/:seat @@ -41,16 +36,14 @@ layout: rotation: 0 ``` -```csv file=parts/seats.csv +```csv file=seats.csv seat,x,y,rotation string,number,number,number 0,40,0,0 1,40,20,0 ``` -```yaml file=setup/main.yaml -role: setup -type: game +```yaml role=setup.game id: main setup: - path: /dock/0 diff --git a/packages/bgm/src/collect.test.ts b/packages/bgm/src/collect.test.ts index 8a3216b..01a1f8c 100644 --- a/packages/bgm/src/collect.test.ts +++ b/packages/bgm/src/collect.test.ts @@ -15,7 +15,7 @@ describe('collectPackages', () => { 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. + // Two tokens from two yaml blocks sharing a `role=part.token` name. expect([...harbor.parts.keys()].sort()).toEqual(['token#grain', 'token#wood']); const wood = harbor.parts.get('token#wood')!; expect(wood).toMatchObject({ @@ -26,9 +26,9 @@ describe('collectPackages', () => { }); expect(wood.face).toBe('./assets/tokens.png'); expect(wood.faceCrop).toEqual([1, 0, 5, 2]); - // Relative assets resolve against the source file's directory. The fixture - // markdown sits at the games root, so the virtual file is `parts/tokens.yaml`. - expect(wood.baseUrl).toBe('parts/'); + // Relative assets resolve against the markdown file's directory. The + // fixture markdown sits at the games root, so baseUrl is empty. + expect(wood.baseUrl).toBe(''); // Two surfaces: the table board and its child player board. expect([...harbor.surfaces.keys()].sort()).toEqual(['board#harbor', 'board#player']); @@ -81,23 +81,23 @@ describe('collectPackages', () => { 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 tokensKey = [...defMap.defs.keys()].find((k) => k.endsWith('part.token.yaml'))!; 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/); }); it('throws when an info-string id combines with $variants', () => { const defMap = loadDefs('', fixtureRoot); // 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 variant = { ...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' }, }; - 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/); }); }); \ No newline at end of file diff --git a/packages/bgm/src/collect.ts b/packages/bgm/src/collect.ts index 8cf063b..cd2eeac 100644 --- a/packages/bgm/src/collect.ts +++ b/packages/bgm/src/collect.ts @@ -144,9 +144,9 @@ class PackageAcc { } /** Expand `$variants` on a def object into a list of concrete objects. */ - private expand(obj: Record, baseName: string, source: string): Record[] { + private expand(obj: Record, baseDir: string, source: string): Record[] { 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; return rows.map((row) => ({ ...base, ...row })); } @@ -161,9 +161,15 @@ class PackageAcc { // patterns are prefixed to match. const names = new Set(); 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('/') ? 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 }); for (const name of this.defs.defs.keys()) { if (matcher(name)) names.add(name); @@ -178,11 +184,11 @@ class PackageAcc { if (def.role?.id && '$variants' in def.value) { 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) { switch (role) { case 'part': { - const part = asPart(obj, fileName); + const part = asPart(obj, def.baseDir ?? ''); const key = `${part.type}#${part.id}`; if (this.parts.has(key)) { throw new BgmError(`Duplicate part "${key}"`, fileName); @@ -191,7 +197,7 @@ class PackageAcc { break; } 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}`; if (this.surfaces.has(key)) { throw new BgmError(`Duplicate surface "${key}"`, fileName); @@ -231,23 +237,29 @@ function asPackage(def: ParsedDef, source: string): PackageDef { } } -function asPart(obj: Record, 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, baseDir: string): Part { try { const part = validatePart(obj) as unknown as Part; // Resolve relative asset paths against the directory of the source file - // (path-style name relative to the games root, e.g. `harbor/parts/`). - // Real files may carry a leading slash from an empty root; strip it. - const dir = path.posix.dirname(source).replace(/^\/+/, ''); + // (path-style name relative to the games root). For a code block this is + // the markdown file's directory; for a real file, its own directory. + const dir = baseDir.replace(/^\/+/, ''); part.baseUrl = dir ? `${dir}/` : ''; return part; } catch (err) { - throw wrapZod(err, source); + throw wrapZod(err, ''); } } function asSurface( obj: Record, - source: string, + baseDir: string, defs: Map, ): Surface { const value: Record = { ...obj }; @@ -260,7 +272,7 @@ function asSurface( const r = route as Record; const cand = r['candidates']; 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; return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) }; } @@ -270,7 +282,7 @@ function asSurface( try { return validateSurface(value) as unknown as Surface; } catch (err) { - throw wrapZod(err, source); + throw wrapZod(err, baseDir); } } diff --git a/packages/bgm/src/markdown.test.ts b/packages/bgm/src/markdown.test.ts index 4203935..4d4a985 100644 --- a/packages/bgm/src/markdown.test.ts +++ b/packages/bgm/src/markdown.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest'; import { scanMarkdown } from './markdown.js'; 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 = [ '# Title', '', - '```yaml file=parts/cargo.yaml', - 'role: part', + '```yaml role=part.cargo', + 'id: wood', '```', '', 'text after', @@ -17,66 +17,72 @@ describe('scanMarkdown', () => { expect(fences).toHaveLength(1); expect(fences[0]).toMatchObject({ - info: 'yaml file=parts/cargo.yaml', - content: 'role: part', + info: 'yaml role=part.cargo', + content: 'id: wood', startLine: 3, endLine: 5, }); expect(files).toHaveLength(1); expect(files[0]).toMatchObject({ - name: 'harbor/parts/cargo.yaml', + name: 'harbor/part.cargo.yaml', kind: 'yaml', - text: 'role: part', + text: 'id: wood', source: 'harbor/harbor.md:3-5', + role: { role: 'part', type: 'cargo' }, }); }); - it('auto-names a block without file= from its content hash', () => { - const md = '```yaml\nrole: part\n```'; + it('names a package block package.yaml', () => { + const md = '```yaml role=package\nid: harbor\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'); + expect(files[0]).toMatchObject({ name: 'harbor/package.yaml', role: { role: 'package' } }); }); - it('hashes identical blocks to the same auto-name', () => { - const md = ['```yaml\nrole: part\n```', '', '```yaml\nrole: part\n```'].join('\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```'; + it('names a surface block with type and id', () => { + const md = '```yaml role=surface.game#main\n```'; const { files } = scanMarkdown(md, 'poker/poker.md'); expect(files[0]).toMatchObject({ - name: 'poker/cards.yaml', - role: { role: 'part', type: 'poker-card' }, + name: 'poker/surface.game.yaml', + role: { role: 'surface', type: 'game', id: 'main' }, }); }); - it('parses role= with type and id', () => { - const md = '```yaml file=board.yaml role=surface.game#main\n```'; - const { files } = scanMarkdown(md, 'poker/poker.md'); - expect(files[0]!.role).toEqual({ role: 'surface', type: 'game', id: 'main' }); + it('uses file= to override the role.type name', () => { + const md = '```yaml file=parts/cargo.yaml role=part.cargo\n```'; + const { files } = scanMarkdown(md, 'harbor/harbor.md'); + expect(files[0]).toMatchObject({ + name: 'harbor/parts/cargo.yaml', + role: { role: 'part', type: 'cargo' }, + }); }); - it('parses role=package without type or id', () => { - const md = '```yaml file=game.yaml role=package\n```'; - const { files } = scanMarkdown(md, 'poker/poker.md'); - expect(files[0]!.role).toEqual({ role: 'package' }); + it('ignores a block without role= or file=', () => { + const md = '```yaml\nrole: 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('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/); }); 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/); }); + 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', () => { const md = '```js\nconst x = 1;\n```'; const { files, fences } = scanMarkdown(md, 'harbor/harbor.md'); @@ -90,23 +96,15 @@ describe('scanMarkdown', () => { 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 role=part.a', '```', '', - '```yaml file=b.yaml', - 'role: part', + '```yaml role=part.b', '```', ].join('\n'); 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]); }); }); \ No newline at end of file diff --git a/packages/bgm/src/markdown.ts b/packages/bgm/src/markdown.ts index d7ac312..6378395 100644 --- a/packages/bgm/src/markdown.ts +++ b/packages/bgm/src/markdown.ts @@ -1,20 +1,20 @@ /** * 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 `./.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. + * Each fenced code block is a virtual definition file. A block is a + * definition only when its info string declares a `role=` (or a `file=`): + * - With `role=part.cargo`, named `part.cargo.yaml` (the `role.type.lang` + * form), discoverable by the default include pattern (all yaml in the + * same and sub folders). + * - 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 * virtual file. */ -import * as crypto from 'node:crypto'; import { posix } from 'node:path'; 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. */ const DEF_LANGS = new Set(['yaml', 'yml', 'json', 'toml']); @@ -25,7 +25,7 @@ export interface Fence { startLine: number; /** Line number (1-based) of the closing fence. */ 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; /** The code block's content (without the fences). */ content: string; @@ -60,14 +60,17 @@ export function scanMarkdown(text: string, sourcePath: string): MarkdownResult { fences.push({ startLine, endLine, info, content: token.text }); - const name = parseInfo(info, token.text); - if (name) { + const parsed = parseInfo(info, `${sourcePath}:${startLine}`); + if (parsed) { files.push({ - name: posix.join(posix.dirname(sourcePath), name), + name: posix.join(posix.dirname(sourcePath), parsed.name), text: token.text, source: `${sourcePath}:${startLine}-${endLine}`, - kind: kindOf(name), - role: parseRole(info, `${sourcePath}:${startLine}`), + kind: parsed.kind, + 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 }; } -/** - * 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, content: string): string | null { - const fileMatch = /file=(\S+)/.exec(info); - if (fileMatch) return fileMatch[1]!; +interface ParsedInfo { + name: string; + kind: DefFile['kind']; + role?: RoleMeta; +} +/** + * 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]; - if (!lang || !DEF_LANGS.has(lang)) return null; - return `./${contentHash(content)}.yaml`; + const fileMatch = /file=(\S+)/.exec(info); + 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' }; } 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. */ @@ -119,9 +137,21 @@ function kindOf(name: string): DefFile['kind'] { return 'yaml'; } -/** Hash the block's content, so identical blocks dedupe to the same name. */ -function contentHash(content: string): string { - return crypto.createHash('sha1').update(content).digest('hex').slice(0, 8); +/** The file extension for a definition language tag. */ +function extOf(lang: string): string { + 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`. */ @@ -133,9 +163,8 @@ function lineOf(text: string, raw: string): number { /** * 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. + * Multiple blocks may share a name (e.g. several `role=part.token` blocks); + * each is kept as a separate entry. */ export type VirtualFiles = Map; diff --git a/packages/bgm/src/parse.test.ts b/packages/bgm/src/parse.test.ts index 526ef45..13f51f7 100644 --- a/packages/bgm/src/parse.test.ts +++ b/packages/bgm/src/parse.test.ts @@ -8,6 +8,7 @@ function file(overrides: Partial = {}): DefFile { text: 'id: 2s', source: 'poker/poker.md:3-5', kind: 'yaml', + baseDir: 'poker', ...overrides, }; } @@ -43,6 +44,11 @@ describe('parseDefText', () => { 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', () => { expect(() => parseDefText(file({ text: 'role: surface', role: { role: 'part' } })), diff --git a/packages/bgm/src/parse.ts b/packages/bgm/src/parse.ts index 3dde891..29982ac 100644 --- a/packages/bgm/src/parse.ts +++ b/packages/bgm/src/parse.ts @@ -9,13 +9,14 @@ 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'; +import { BgmError, roleFromName, type DefFile, type ParsedDef } from './types.js'; /** * 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 - * object, erroring on a conflict with the same key in the content. + * A code block's `role=` (or a real file's `role.type` name) metadata is + * 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 * items (index `0..n`) @@ -37,7 +38,7 @@ export function parseDefText(file: DefFile): ParsedDef[] { const push = (value: unknown, index: number) => { if (value !== null && typeof value === 'object' && !Array.isArray(value)) { const merged = mergeRole(file, value as Record); - 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) { throw new BgmError(`Expected a JSON object, got ${typeof value}`, file.source); } @@ -51,7 +52,7 @@ export function parseDefText(file: DefFile): ParsedDef[] { 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): Record { const meta = file.role; if (!meta) return value; @@ -65,7 +66,7 @@ function mergeRole(file: DefFile, value: Record): Record = new Set(['package', 'part', 'surface', 'setup']); /** - * Role metadata declared on a code block's info string (`role=part.cargo`). - * `type` and `id` are optional; anything not given comes from the content or - * from `$variants` rows. + * Role metadata declared on a code block's info string (`role=part.cargo`) or + * a real file's name (`part.cargo.yaml`). `type` is required for all roles + * except `package`; `id` is optional — anything not given comes from the + * content or from `$variants` rows. */ export interface RoleMeta { role: Role; @@ -202,6 +203,25 @@ export interface RoleMeta { 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 * root of a file/block or an item in the file's list. @@ -242,8 +262,14 @@ export interface DefFile { source: string; /** File type derived from the name's extension. */ 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; + /** + * 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). */ @@ -255,8 +281,10 @@ export interface ParsedDef { value: Record; /** Source location for error messages (real path or `file.md:12-19`). */ 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; + /** Directory relative asset paths resolve against (see `DefFile.baseDir`). */ + baseDir?: string; } /**