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:
2026-08-10 16:36:57 +08:00
parent 23332ab786
commit 13b3eaed78
12 changed files with 211 additions and 173 deletions
+42 -44
View File
@@ -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]);
});
});