/** * Vite plugin: resolve `virtual:bgm/packages` and `virtual:bgm/package/` * imports to JSON. * * The loader reads a games root (markdown code blocks + real * yaml/json/toml/csv files), collects packages, and this plugin serves them * as modules: * * - `virtual:bgm/packages` — the default export is an array of every * discovered package. * - `virtual:bgm/package/` — the default export is that single package's * assembled JSON. * * The `virtual:` prefix marks these as plugin-provided modules, so they can't * be mistaken for real installed packages. Editing a game definition * hot-reloads the app via `addWatchFile`. */ import * as path from 'node:path'; import { normalizePath, type ModuleNode, type Plugin } from 'vite'; import { collectPackages, loadDefs } from './collect.js'; import { readDefFiles } from './parse.js'; import type { Package, SerializedPackage } from './types.js'; const VIRTUAL_PREFIX = '\0bgm:'; /** Public specifier for the module that lists every package. */ const PACKAGES = 'virtual:bgm/packages'; /** Public specifier prefix for a single package module. */ const PACKAGE = 'virtual:bgm/package/'; export interface BgmOptions { /** Absolute path to the games root (e.g. `/games`). */ root: string; } export function bgm(options: BgmOptions): Plugin { // Vite normalizes `ctx.file` to POSIX separators before HMR hooks run, but // `fileURLToPath` retains backslashes on Windows. Normalize `root` to the // same form so `ctx.file.startsWith(root)` matches regardless of platform. const root = normalizePath(options.root); const collect = (): Package[] => { const defMap = loadDefs('', root); return collectPackages(defMap, root); }; return { name: 'bgm', buildStart() { // Watch every real definition source under the games root so edits // trigger a re-collect. `defMap.files` only holds the virtual code-block // files plus real non-markdown files; the markdown files themselves are // consumed for their code blocks and never appear there, so watch the // real files on disk too (markdown and anything else the loader reads). const defMap = loadDefs('', root); for (const name of defMap.files.keys()) { this.addWatchFile(path.join(root, name)); } for (const file of readDefFiles(root, '')) { this.addWatchFile(file.source); } }, resolveId(id) { if (id === PACKAGES) return VIRTUAL_PREFIX + PACKAGES; if (id.startsWith(PACKAGE)) return VIRTUAL_PREFIX + id; }, handleHotUpdate(ctx) { // Re-collect and invalidate the virtual modules when a game definition // changes, so edits hot-reload instead of requiring a manual refresh. if (!ctx.file.startsWith(root)) return; const invalidated: ModuleNode[] = []; const mod = ctx.server.moduleGraph.getModuleById(VIRTUAL_PREFIX + PACKAGES); if (mod) { ctx.server.moduleGraph.invalidateModule(mod); invalidated.push(mod); } return invalidated; }, load(id) { if (!id.startsWith(VIRTUAL_PREFIX)) return; const virtual = id.slice(VIRTUAL_PREFIX.length); if (virtual === PACKAGES) { const packages = collect().map(toJson); return `export default ${JSON.stringify(packages)}`; } const name = virtual.slice(PACKAGE.length); const pkg = collect().find((p) => p.meta.id === name); if (!pkg) { throw new Error(`bgm package "${name}" not found`); } return `export default ${JSON.stringify(toJson(pkg))}`; }, }; } /** * Serialize a `Package` for JSON emission. The parts/surfaces/setups are * `Map`s, which `JSON.stringify` would otherwise turn into `{}`. */ function toJson(pkg: Package): SerializedPackage { return { meta: pkg.meta, parts: Object.fromEntries(pkg.parts), surfaces: Object.fromEntries(pkg.surfaces), setups: Object.fromEntries(pkg.setups), }; }