Files
tts-workshop/packages/bgm/src/collect.ts
T
hypercross aab0b66ee8 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.
2026-08-10 16:09:12 +08:00

292 lines
9.7 KiB
TypeScript

/**
* Collect packages from a games root directory.
*
* A games root contains yaml/json/toml files and markdown files with
* definition code blocks. The loader:
*
* 1. Reads real files and extracts virtual files from markdown code blocks
* (virtual wins over real files with the same name).
* 2. Parses each def file into JSON objects.
* 3. Recognizes `role: package` objects, expands their `$variants`, follows
* their `include` patterns, and assembles the package's parts, surfaces,
* and setups.
*
* See docs/bgm-format.md for the format's concrete behavior.
*/
import * as path from 'node:path';
import picomatch from 'picomatch';
import { collectVirtualFiles } from './markdown.js';
import { parseDefText, readDefFiles } from './parse.js';
import { validatePackage, validatePart, validateSetup, validateSurface } from './schemas.js';
import { expandVariants } from './variants.js';
import {
BgmError,
ROLES,
type DefFile,
type ParsedDef,
type Package,
type PackageDef,
type Part,
type Role,
type Setup,
type Surface,
} from './types.js';
/** Every definition parsed from a def file, keyed by its path-style name. */
export interface DefMap {
/** All def files (real + virtual), keyed by name. */
files: Map<string, DefFile[]>;
/** All parsed definitions, keyed by file name. */
defs: Map<string, ParsedDef[]>;
}
/**
* Load a games root into a def map.
*
* @param root the path-style name of the root, e.g. `harbor`
* @param rootDir the absolute path of the games root
*/
export function loadDefs(root: string, rootDir: string): DefMap {
const realFiles = readDefFiles(rootDir, root);
const markdownFiles = new Map<string, string>();
const others: DefFile[] = [];
for (const file of realFiles) {
if (file.kind === 'markdown') markdownFiles.set(file.name, file.text);
else others.push(file);
}
const virtualFiles = collectVirtualFiles(markdownFiles);
const files = new Map<string, DefFile[]>();
// Real files first, virtual files override (virtual wins per the format).
for (const file of others) files.set(file.name, [file]);
for (const [name, list] of virtualFiles) files.set(name, list);
const defs = new Map<string, ParsedDef[]>();
for (const [name, list] of files) {
const parsed: ParsedDef[] = [];
for (const file of list) parsed.push(...parseDefText(file));
defs.set(name, parsed);
}
return { files, defs };
}
/**
* Collect all packages from the given def map.
*
* @param rootDir the absolute path of the games root; `$variants` file paths
* resolve relative to their def file's directory within the root
*/
export function collectPackages(defMap: DefMap, rootDir: string): Package[] {
const packages = new Map<string, PackageAcc>();
const byRole = new Map<string, ParsedDef[]>();
// Group parsed defs by role.
for (const [file, defs] of defMap.defs) {
const list: ParsedDef[] = [];
for (const def of defs) {
const role = def.value['role'];
if (role !== undefined && typeof role === 'string' && ROLES.has(role as Role)) {
list.push(def);
byRole.set(file, list);
}
}
}
const accs: PackageAcc[] = [];
for (const [file, defs] of byRole) {
for (const def of defs) {
const role = def.value['role'] as Role;
if (role === 'package') {
const pkg = asPackage(def, file);
const baseDir = path.posix.dirname(file).replace(/^\/+/, '');
accs.push(new PackageAcc(pkg, defMap, rootDir, baseDir));
}
}
}
const result: Package[] = [];
for (const acc of accs) {
acc.collect();
result.push(acc.toPackage());
}
return result;
}
/** Identity validation: `type#id` must be unique within a package. */
class PackageAcc {
readonly parts = new Map<string, Part>();
readonly surfaces = new Map<string, Surface>();
readonly setups = new Map<string, Setup>();
readonly byRole = new Map<string, string[]>();
constructor(
readonly pkg: PackageDef,
private readonly defs: DefMap,
private readonly rootDir: string,
/** Path-style directory of the package declaration, e.g. `carcassonne`. */
private readonly baseDir: string,
) {}
collect() {
const include = this.pkg.include ?? ['./**/*.yaml'];
const names = this.expandIncludes(include);
for (const name of names) {
const fileDefs = this.defs.defs.get(name);
if (!fileDefs) continue;
for (const def of fileDefs) {
const role = def.value['role'];
if (typeof role !== 'string' || !ROLES.has(role as Role) || role === 'package') continue;
this.add(role as Role, def, name);
}
}
}
/** Expand `$variants` on a def object into a list of concrete objects. */
private expand(obj: Record<string, unknown>, baseName: string, source: string): Record<string, unknown>[] {
if (!('$variants' in obj)) return [obj];
const rows = expandVariants(obj['$variants'], baseName, this.defs.files, source);
const { $variants: _v, ...base } = obj;
return rows.map((row) => ({ ...base, ...row }));
}
private expandIncludes(patterns: string[]): string[] {
// Match include patterns against the parsed definitions' names, which
// cover both real files and markdown code blocks. Patterns are relative
// to the package declaration's own directory (e.g. `./**/*.yaml` means
// this package's folder and below), so a package never absorbs defs from
// a sibling game. A leading `/` marks a pattern as root-relative.
// Def names carry a leading `/` (from the empty games root), so resolved
// patterns are prefixed to match.
const names = new Set<string>();
for (const pattern of patterns) {
const resolved = pattern.startsWith('/')
? pattern
: `/${path.posix.join(this.baseDir, pattern)}`;
const matcher = picomatch(resolved, { dot: true });
for (const name of this.defs.defs.keys()) {
if (matcher(name)) names.add(name);
}
}
return [...names];
}
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) {
case 'part': {
const part = asPart(obj, fileName);
const key = `${part.type}#${part.id}`;
if (this.parts.has(key)) {
throw new BgmError(`Duplicate part "${key}"`, fileName);
}
this.parts.set(key, part);
break;
}
case 'surface': {
const surface = asSurface(obj, fileName, this.defs.files);
const key = `${surface.type}#${surface.id}`;
if (this.surfaces.has(key)) {
throw new BgmError(`Duplicate surface "${key}"`, fileName);
}
this.surfaces.set(key, surface);
break;
}
case 'setup': {
const setup = asSetup(obj, fileName);
const key = `${setup.type}#${setup.id}`;
if (this.setups.has(key)) {
throw new BgmError(`Duplicate setup "${key}"`, fileName);
}
this.setups.set(key, setup);
break;
}
}
}
}
toPackage(): Package {
return { meta: metaOf(this.pkg), parts: this.parts, surfaces: this.surfaces, setups: this.setups };
}
}
function metaOf(pkg: PackageDef) {
const { role: _role, include: _include, ...meta } = pkg;
return meta;
}
function asPackage(def: ParsedDef, source: string): PackageDef {
const obj = def.value;
try {
return validatePackage(obj) as unknown as PackageDef;
} catch (err) {
throw wrapZod(err, source);
}
}
function asPart(obj: Record<string, unknown>, source: string): Part {
try {
const part = validatePart(obj) as unknown as Part;
// Resolve relative asset paths against the directory of the source file
// (path-style name relative to the games root, e.g. `harbor/parts/`).
// Real files may carry a leading slash from an empty root; strip it.
const dir = path.posix.dirname(source).replace(/^\/+/, '');
part.baseUrl = dir ? `${dir}/` : '';
return part;
} catch (err) {
throw wrapZod(err, source);
}
}
function asSurface(
obj: Record<string, unknown>,
source: string,
defs: Map<string, DefFile[]>,
): Surface {
const value: Record<string, unknown> = { ...obj };
delete value['role'];
// Expand `candidates.$variants` on each route into a concrete array.
if (Array.isArray(value['layout'])) {
value['layout'] = value['layout'].map((route) => {
if (typeof route !== 'object' || route === null) return route;
const r = route as Record<string, unknown>;
const cand = r['candidates'];
if (cand && typeof cand === 'object' && !Array.isArray(cand) && '$variants' in cand) {
const rows = expandVariants(cand['$variants'], source, defs, source);
const { $variants: _v, ...base } = cand as Record<string, unknown>;
return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) };
}
return route;
});
}
try {
return validateSurface(value) as unknown as Surface;
} catch (err) {
throw wrapZod(err, source);
}
}
function asSetup(obj: Record<string, unknown>, source: string): Setup {
const value: Record<string, unknown> = { ...obj };
delete value['role'];
try {
return validateSetup(value) as unknown as Setup;
} catch (err) {
throw wrapZod(err, source);
}
}
/** Wrap a zod error with the source location. */
function wrapZod(err: unknown, source: string): BgmError {
const message = err instanceof Error ? err.message : String(err);
return new BgmError(`Invalid definition: ${message}`, source);
}