/** * 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, type DefFile, type ParsedDef, type Package, type PackageDef, type Part, type Role, type Setup, 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. */ files: Map; /** All parsed definitions, keyed by file name. */ defs: Map; } /** * 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(); 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(); // 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(); 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(); const byRole = new Map(); // 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(); readonly surfaces = new Map(); readonly setups = new Map(); readonly byRole = new Map(); 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, baseName: string, source: string): Record[] { 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(); 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) { 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, 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, source: string, defs: Map, ): Surface { const value: Record = { ...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; 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; 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, source: string): Setup { const value: Record = { ...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); }