Files
tts-workshop/packages/bgm/src/vite.ts
T
hypercross 90baa35c7c fix(bgm): hot-reload markdown definition edits
Normalize the games root to POSIX separators so the HMR path guard
matches on Windows, and watch the real on-disk markdown files (which
defMap.files omits in favor of their virtual code blocks) so edits
trigger a re-collect instead of a manual restart.
2026-08-10 00:06:43 +08:00

107 lines
3.9 KiB
TypeScript

/**
* Vite plugin: resolve `virtual:bgm/packages` and `virtual:bgm/package/<id>`
* 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/<id>` — 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. `<repo>/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),
};
}