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:
@@ -86,4 +86,18 @@ describe('collectPackages', () => {
|
||||
defMap.defs.set(tokensKey.replace('tokens.yaml', '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 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/);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { validatePackage, validatePart, validateSetup, validateSurface } from '.
|
||||
import { expandVariants } from './variants.js';
|
||||
import {
|
||||
BgmError,
|
||||
ROLES,
|
||||
type DefFile,
|
||||
type ParsedDef,
|
||||
type Package,
|
||||
@@ -31,8 +32,6 @@ import {
|
||||
type Surface,
|
||||
} from './types.js';
|
||||
|
||||
const ROLES = new Set<Role>(['package', 'part', 'surface', 'setup']);
|
||||
|
||||
/** Every definition parsed from a def file, keyed by its path-style name. */
|
||||
export interface DefMap {
|
||||
/** All def files (real + virtual), keyed by name. */
|
||||
@@ -174,6 +173,11 @@ class PackageAcc {
|
||||
}
|
||||
|
||||
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);
|
||||
for (const obj of expanded) {
|
||||
switch (role) {
|
||||
|
||||
@@ -39,6 +39,44 @@ describe('scanMarkdown', () => {
|
||||
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', () => {
|
||||
const md = '```js\nconst x = 1;\n```';
|
||||
const { files, fences } = scanMarkdown(md, 'harbor/harbor.md');
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import { posix } from 'node:path';
|
||||
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. */
|
||||
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 });
|
||||
|
||||
const name = parseInfo(info);
|
||||
const name = parseInfo(info, token.text);
|
||||
if (name) {
|
||||
files.push({
|
||||
name: posix.join(posix.dirname(sourcePath), name),
|
||||
text: token.text,
|
||||
source: `${sourcePath}:${startLine}-${endLine}`,
|
||||
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
|
||||
* 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);
|
||||
if (fileMatch) return fileMatch[1]!;
|
||||
|
||||
const lang = info.split(/\s+/)[0];
|
||||
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. */
|
||||
@@ -96,9 +119,9 @@ function kindOf(name: string): DefFile['kind'] {
|
||||
return 'yaml';
|
||||
}
|
||||
|
||||
/** A stable content hash for auto-named blocks. */
|
||||
function hash(text: string): string {
|
||||
return crypto.createHash('sha1').update(text).digest('hex').slice(0, 8);
|
||||
/** 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 1-based line number where `raw` starts within `text`. */
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
*
|
||||
* 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
|
||||
* items (index `0..n`)
|
||||
*/
|
||||
@@ -33,7 +36,8 @@ export function parseDefText(file: DefFile): ParsedDef[] {
|
||||
|
||||
const push = (value: unknown, index: number) => {
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||
out.push({ file: file.name, index, value: value as Record<string, unknown>, source: file.source });
|
||||
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) {
|
||||
throw new BgmError(`Expected a JSON object, got ${typeof value}`, file.source);
|
||||
}
|
||||
@@ -47,6 +51,27 @@ export function parseDefText(file: DefFile): ParsedDef[] {
|
||||
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 {
|
||||
switch (kind) {
|
||||
case 'json':
|
||||
|
||||
@@ -188,6 +188,20 @@ export interface 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
|
||||
* root of a file/block or an item in the file's list.
|
||||
@@ -228,6 +242,8 @@ 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?: RoleMeta;
|
||||
}
|
||||
|
||||
/** A single parsed definition (one JSON object from a def file). */
|
||||
@@ -239,6 +255,8 @@ export interface ParsedDef {
|
||||
value: Record<string, unknown>;
|
||||
/** 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?: RoleMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user