feat(bgm): support role= on code block info strings

Parse role.type#id from the fence info string and merge it into each
parsed object, erroring on conflicts with the content. id on the info
string can't combine with $variants. Auto-named blocks now hash their
content instead of the info string, so identical blocks dedupe.
This commit is contained in:
2026-08-10 16:09:12 +08:00
parent c41c266ac1
commit aab0b66ee8
7 changed files with 207 additions and 10 deletions
+14
View File
@@ -86,4 +86,18 @@ describe('collectPackages', () => {
defMap.defs.set(tokensKey.replace('tokens.yaml', 'dup.yaml'), [tokens[0]!]); defMap.defs.set(tokensKey.replace('tokens.yaml', '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', () => {
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 tokens = defMap.defs.get(tokensKey)!;
const variant = {
...tokens[0]!,
value: { ...tokens[0]!.value, id: undefined, $variants: './parts/seats.csv' },
role: { role: 'part', type: 'token', id: 'wood' },
};
defMap.defs.set(tokensKey.replace('tokens.yaml', 'dup.yaml'), [variant]);
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(/can't combine with \$variants/);
});
}); });
+6 -2
View File
@@ -21,6 +21,7 @@ import { validatePackage, validatePart, validateSetup, validateSurface } from '.
import { expandVariants } from './variants.js'; import { expandVariants } from './variants.js';
import { import {
BgmError, BgmError,
ROLES,
type DefFile, type DefFile,
type ParsedDef, type ParsedDef,
type Package, type Package,
@@ -31,8 +32,6 @@ import {
type Surface, type Surface,
} from './types.js'; } 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. */ /** Every definition parsed from a def file, keyed by its path-style name. */
export interface DefMap { export interface DefMap {
/** All def files (real + virtual), keyed by name. */ /** All def files (real + virtual), keyed by name. */
@@ -174,6 +173,11 @@ class PackageAcc {
} }
private add(role: Role, def: ParsedDef, fileName: string) { private add(role: Role, def: ParsedDef, fileName: string) {
// `id` on the info string can't combine with `$variants`, since every
// row supplies its own `id` and would override it.
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.file, def.source);
for (const obj of expanded) { for (const obj of expanded) {
switch (role) { switch (role) {
+38
View File
@@ -39,6 +39,44 @@ describe('scanMarkdown', () => {
expect(files[0]!.kind).toBe('yaml'); expect(files[0]!.kind).toBe('yaml');
}); });
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```';
const { files } = scanMarkdown(md, 'poker/poker.md');
expect(files[0]).toMatchObject({
name: 'poker/cards.yaml',
role: { role: 'part', type: 'poker-card' },
});
});
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('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('throws on an unknown role=', () => {
const md = '```yaml file=a.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```';
expect(() => scanMarkdown(md, 'poker/poker.md')).toThrow(/Package role takes no 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');
+30 -7
View File
@@ -14,7 +14,7 @@
import * as crypto from 'node:crypto'; 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, type DefFile } from './types.js'; import { BgmError, ROLES, 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']);
@@ -60,13 +60,14 @@ 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); const name = parseInfo(info, token.text);
if (name) { if (name) {
files.push({ files.push({
name: posix.join(posix.dirname(sourcePath), name), name: posix.join(posix.dirname(sourcePath), name),
text: token.text, text: token.text,
source: `${sourcePath}:${startLine}-${endLine}`, source: `${sourcePath}:${startLine}-${endLine}`,
kind: kindOf(name), kind: kindOf(name),
role: parseRole(info, `${sourcePath}:${startLine}`),
}); });
} }
} }
@@ -78,13 +79,35 @@ export function scanMarkdown(text: string, sourcePath: string): MarkdownResult {
* Parse a fence's info string for a `file=` segment and derive the virtual * 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. * file name. Blocks without `file=` are auto-named from their content hash.
*/ */
function parseInfo(info: string): string | null { function parseInfo(info: string, content: string): string | null {
const fileMatch = /file=(\S+)/.exec(info); const fileMatch = /file=(\S+)/.exec(info);
if (fileMatch) return fileMatch[1]!; if (fileMatch) return fileMatch[1]!;
const lang = info.split(/\s+/)[0]; const lang = info.split(/\s+/)[0];
if (!lang || !DEF_LANGS.has(lang)) return null; if (!lang || !DEF_LANGS.has(lang)) return null;
return `./${hash(info)}.yaml`; return `./${contentHash(content)}.yaml`;
}
/**
* Parse a fence's info string for a `role=` segment into role metadata.
* `role=part.cargo` -> `{ role: 'part', type: 'cargo' }`;
* `role=surface.game#main` -> `{ role: 'surface', type: 'game', id: 'main' }`;
* `role=package` -> `{ role: 'package' }`. Returns `undefined` when absent.
*/
function parseRole(info: string, source: string): RoleMeta | undefined {
const match = /role=(\S+)/.exec(info);
if (!match) return undefined;
const spec = match[1]!;
const [role, rest] = spec.split('.');
if (!role || !ROLES.has(role as Role)) {
throw new BgmError(`Invalid role "${spec}"`, source);
}
if (role === 'package') {
if (rest) throw new BgmError(`Package role takes no type or id`, source);
return { role: 'package' };
}
const [type, id] = (rest ?? '').split('#');
return { role: role as Role, type: type || undefined, id: id || undefined };
} }
/** Derive the def file type from its name's extension. */ /** Derive the def file type from its name's extension. */
@@ -96,9 +119,9 @@ function kindOf(name: string): DefFile['kind'] {
return 'yaml'; return 'yaml';
} }
/** A stable content hash for auto-named blocks. */ /** Hash the block's content, so identical blocks dedupe to the same name. */
function hash(text: string): string { function contentHash(content: string): string {
return crypto.createHash('sha1').update(text).digest('hex').slice(0, 8); return crypto.createHash('sha1').update(content).digest('hex').slice(0, 8);
} }
/** The 1-based line number where `raw` starts within `text`. */ /** The 1-based line number where `raw` starts within `text`. */
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { parseDefText } from './parse.js';
import type { DefFile } from './types.js';
function file(overrides: Partial<DefFile> = {}): DefFile {
return {
name: 'poker/cards.yaml',
text: 'id: 2s',
source: 'poker/poker.md:3-5',
kind: 'yaml',
...overrides,
};
}
describe('parseDefText', () => {
it('merges role= metadata into the parsed object', () => {
const defs = parseDefText(file({ role: { role: 'part', type: 'poker-card' } }));
expect(defs[0]!.value).toMatchObject({ role: 'part', type: 'poker-card', id: '2s' });
});
it('merges role= with type and id', () => {
const defs = parseDefText(
file({ text: 'id: main', role: { role: 'surface', type: 'game', id: 'main' } }),
);
expect(defs[0]!.value).toMatchObject({ role: 'surface', type: 'game', id: 'main' });
});
it('merges role= into every item of a list block', () => {
const defs = parseDefText(
file({
text: '- id: a\n- id: b',
role: { role: 'part', type: 'tile' },
}),
);
expect(defs.map((d) => d.value)).toEqual([
{ role: 'part', type: 'tile', id: 'a' },
{ role: 'part', type: 'tile', id: 'b' },
]);
});
it('carries the role metadata on the parsed def', () => {
const defs = parseDefText(file({ role: { role: 'part', type: 'poker-card' } }));
expect(defs[0]!.role).toEqual({ role: 'part', type: 'poker-card' });
});
it('errors on a role conflict with the content', () => {
expect(() =>
parseDefText(file({ text: 'role: surface', role: { role: 'part' } })),
).toThrow(/Conflicting role/);
});
it('errors on a type conflict with the content', () => {
expect(() =>
parseDefText(file({ text: 'type: tile', role: { role: 'part', type: 'card' } })),
).toThrow(/Conflicting type/);
});
it('errors on an id conflict with the content', () => {
expect(() =>
parseDefText(file({ text: 'id: 2s', role: { role: 'part', type: 'card', id: '3s' } })),
).toThrow(/Conflicting id/);
});
it('accepts matching role metadata and content', () => {
const defs = parseDefText(
file({ text: 'role: part\ntype: card', role: { role: 'part', type: 'card' } }),
);
expect(defs[0]!.value).toMatchObject({ role: 'part', type: 'card' });
});
it('leaves the object unchanged without role metadata', () => {
const defs = parseDefText(file({ text: 'role: part\ntype: card\nid: 2s' }));
expect(defs[0]!.value).toEqual({ role: 'part', type: 'card', id: '2s' });
});
});
+26 -1
View File
@@ -14,6 +14,9 @@ import { BgmError, 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
* 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`)
*/ */
@@ -33,7 +36,8 @@ 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)) {
out.push({ file: file.name, index, value: value as Record<string, unknown>, source: file.source }); const merged = mergeRole(file, value as Record<string, unknown>);
out.push({ file: file.name, index, value: merged, source: file.source, role: file.role });
} 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);
} }
@@ -47,6 +51,27 @@ export function parseDefText(file: DefFile): ParsedDef[] {
return out; return out;
} }
/** Merge a code block's `role=` metadata into a parsed object, erroring on conflict. */
function mergeRole(file: DefFile, value: Record<string, unknown>): Record<string, unknown> {
const meta = file.role;
if (!meta) return value;
const out = { ...value };
const keys: Array<[key: string, fromMeta: string | undefined]> = [
['role', meta.role],
['type', meta.type],
['id', meta.id],
];
for (const [key, fromMeta] of keys) {
if (fromMeta === undefined) continue;
const fromContent = out[key];
if (fromContent !== undefined && fromContent !== fromMeta) {
throw new BgmError(`Conflicting ${key}: "${fromMeta}" on the info string vs "${fromContent}" in content`, file.source);
}
out[key] = fromMeta;
}
return out;
}
function parseText(kind: DefFile['kind'], text: string): unknown { function parseText(kind: DefFile['kind'], text: string): unknown {
switch (kind) { switch (kind) {
case 'json': case 'json':
+18
View File
@@ -188,6 +188,20 @@ export interface Setup {
export type Role = 'package' | 'part' | 'surface' | 'setup'; export type Role = 'package' | 'part' | 'surface' | 'setup';
/** The four definition roles. */
export const ROLES: ReadonlySet<Role> = 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.
*/
export interface RoleMeta {
role: Role;
type?: string;
id?: string;
}
/** /**
* 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.
@@ -228,6 +242,8 @@ 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?: RoleMeta;
} }
/** A single parsed definition (one JSON object from a def file). */ /** A single parsed definition (one JSON object from a def file). */
@@ -239,6 +255,8 @@ 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?: RoleMeta;
} }
/** /**