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]!]);
|
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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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`. */
|
||||||
|
|||||||
@@ -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.
|
* 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':
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user