diff --git a/packages/bgm/src/collect.test.ts b/packages/bgm/src/collect.test.ts index 1239461..8a3216b 100644 --- a/packages/bgm/src/collect.test.ts +++ b/packages/bgm/src/collect.test.ts @@ -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/); + }); }); \ No newline at end of file diff --git a/packages/bgm/src/collect.ts b/packages/bgm/src/collect.ts index 07f9721..8cf063b 100644 --- a/packages/bgm/src/collect.ts +++ b/packages/bgm/src/collect.ts @@ -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(['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) { diff --git a/packages/bgm/src/markdown.test.ts b/packages/bgm/src/markdown.test.ts index 3616404..4203935 100644 --- a/packages/bgm/src/markdown.test.ts +++ b/packages/bgm/src/markdown.test.ts @@ -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'); diff --git a/packages/bgm/src/markdown.ts b/packages/bgm/src/markdown.ts index 36e8682..d7ac312 100644 --- a/packages/bgm/src/markdown.ts +++ b/packages/bgm/src/markdown.ts @@ -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`. */ diff --git a/packages/bgm/src/parse.test.ts b/packages/bgm/src/parse.test.ts new file mode 100644 index 0000000..526ef45 --- /dev/null +++ b/packages/bgm/src/parse.test.ts @@ -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 { + 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' }); + }); +}); \ No newline at end of file diff --git a/packages/bgm/src/parse.ts b/packages/bgm/src/parse.ts index bf786bc..3dde891 100644 --- a/packages/bgm/src/parse.ts +++ b/packages/bgm/src/parse.ts @@ -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, source: file.source }); + const merged = mergeRole(file, value as Record); + 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): Record { + 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': diff --git a/packages/bgm/src/types.ts b/packages/bgm/src/types.ts index 8114ead..d37dacc 100644 --- a/packages/bgm/src/types.ts +++ b/packages/bgm/src/types.ts @@ -188,6 +188,20 @@ export interface Setup { export type Role = 'package' | 'part' | 'surface' | 'setup'; +/** The four definition roles. */ +export const ROLES: ReadonlySet = 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; /** 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; } /**