feat(bgm): add vite plugin and build integration test
Move the bgm vite plugin into the package so it can be tested in isolation from the web app. Serve each package as a JSON module, serializing its maps, and verify resolution through a real vite build against a self-contained fixture.
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
"lint": "echo \"no lint configured\""
|
||||
},
|
||||
"dependencies": {
|
||||
"vite": "^8.2.1",
|
||||
"marked": "^16.0.0",
|
||||
"picomatch": "^4.0.5",
|
||||
"smol-toml": "^1.4.0",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Harbor
|
||||
|
||||
A tiny example game used to exercise the bgm loader end-to-end through a real
|
||||
vite build.
|
||||
|
||||
```yaml file=harbor.yaml
|
||||
role: package
|
||||
id: harbor
|
||||
title: Harbor
|
||||
designer: Jane Doe
|
||||
players: 2
|
||||
language: en
|
||||
```
|
||||
|
||||
```yaml file=parts/tokens.yaml
|
||||
role: part
|
||||
type: token
|
||||
id: wood
|
||||
face: ./assets/tokens.png
|
||||
faceCrop: [1, 0, 5, 2]
|
||||
back: ./assets/tokens.png
|
||||
backCrop: [3, 0, 5, 2]
|
||||
shape: ./assets/token-shape.png
|
||||
size: [20, 20, 3]
|
||||
fillet: 2
|
||||
```
|
||||
|
||||
```yaml file=parts/board.yaml
|
||||
type: board
|
||||
id: harbor
|
||||
role: surface
|
||||
size: [300, 200]
|
||||
layout:
|
||||
- route: /dock/:seat
|
||||
candidates:
|
||||
$variants: ./seats.csv
|
||||
- route: /deck
|
||||
x: -100
|
||||
y: 0
|
||||
rotation: 0
|
||||
```
|
||||
|
||||
```csv file=parts/seats.csv
|
||||
seat,x,y,rotation
|
||||
string,number,number,number
|
||||
0,40,0,0
|
||||
1,40,20,0
|
||||
```
|
||||
|
||||
```yaml file=setup/main.yaml
|
||||
role: setup
|
||||
type: game
|
||||
id: main
|
||||
setup:
|
||||
/dock/0: harbor:token#wood
|
||||
/deck: harbor:token#grain
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>bgm build fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
import harbor from 'bgm/harbor';
|
||||
|
||||
// Re-export the package data so the test can assert the bundled output.
|
||||
export const parts = [...harbor.parts.keys()];
|
||||
export const surfaces = [...harbor.surfaces.keys()];
|
||||
export const setups = [...harbor.setups.keys()];
|
||||
export const title = harbor.meta.title;
|
||||
|
||||
console.log(title, parts, surfaces, setups);
|
||||
@@ -3,7 +3,7 @@ import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadDefs, collectPackages } from './collect.js';
|
||||
|
||||
const fixtureRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__');
|
||||
const fixtureRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__', 'harbor');
|
||||
|
||||
describe('collectPackages', () => {
|
||||
it('collects the harbor package from markdown code blocks', () => {
|
||||
|
||||
@@ -3,4 +3,5 @@ export * from './schemas.js';
|
||||
export * from './markdown.js';
|
||||
export * from './parse.js';
|
||||
export * from './variants.js';
|
||||
export * from './collect.js';
|
||||
export * from './collect.js';
|
||||
export * from './vite.js';
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { build } from 'vite';
|
||||
import { bgm } from './vite.js';
|
||||
|
||||
const fixtureRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'__fixtures__',
|
||||
'vite-build',
|
||||
);
|
||||
const gamesRoot = path.join(fixtureRoot, 'games');
|
||||
|
||||
describe('bgm vite plugin (integration)', () => {
|
||||
it('resolves bgm/ imports through a real vite build', async () => {
|
||||
const outDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'bgm-build-'));
|
||||
|
||||
try {
|
||||
await build({
|
||||
root: fixtureRoot,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
outDir,
|
||||
write: true,
|
||||
emptyOutDir: true,
|
||||
},
|
||||
plugins: [bgm({ root: gamesRoot })],
|
||||
});
|
||||
|
||||
// The fixture's entry re-exports the package data; find the bundle chunk.
|
||||
const chunk = walk(outDir).find((f) => f.endsWith('.js'))!;
|
||||
const code = fs.readFileSync(path.join(outDir, chunk), 'utf8');
|
||||
|
||||
// The plugin serialized the package's maps into the emitted module.
|
||||
expect(code).toContain('token#wood');
|
||||
expect(code).toContain('board#harbor');
|
||||
expect(code).toContain('game#main');
|
||||
expect(code).toContain('Harbor');
|
||||
} finally {
|
||||
await fs.promises.rm(outDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** Recursively list files under a directory, as paths relative to it. */
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
const visit = (current: string) => {
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) visit(full);
|
||||
else out.push(path.relative(dir, full));
|
||||
}
|
||||
};
|
||||
visit(dir);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Vite plugin: resolve `bgm/<package>` imports to the package's JSON.
|
||||
*
|
||||
* The loader reads a games root (markdown code blocks + real
|
||||
* yaml/json/toml/csv files), collects packages, and this plugin serves each
|
||||
* package as a module whose default export is the assembled JSON. Editing a
|
||||
* game definition hot-reloads the app via `addWatchFile`.
|
||||
*/
|
||||
import * as path from 'node:path';
|
||||
import type { Plugin } from 'vite';
|
||||
import { collectPackages, loadDefs } from './collect.js';
|
||||
import type { Package } from './types.js';
|
||||
|
||||
const VIRTUAL_PREFIX = '\0bgm:';
|
||||
|
||||
export interface BgmOptions {
|
||||
/** Absolute path to the games root (e.g. `<repo>/games`). */
|
||||
root: string;
|
||||
}
|
||||
|
||||
export function bgm(options: BgmOptions): Plugin {
|
||||
const root = options.root;
|
||||
|
||||
const collect = (): Package[] => {
|
||||
const defMap = loadDefs('', root);
|
||||
return collectPackages(defMap, root);
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'bgm',
|
||||
buildStart() {
|
||||
// Watch every source file so edits trigger a reload/re-collect.
|
||||
const defMap = loadDefs('', root);
|
||||
for (const name of defMap.files.keys()) {
|
||||
this.addWatchFile(path.join(root, name));
|
||||
}
|
||||
},
|
||||
resolveId(id) {
|
||||
if (id.startsWith('bgm/')) return VIRTUAL_PREFIX + id;
|
||||
},
|
||||
load(id) {
|
||||
if (!id.startsWith(VIRTUAL_PREFIX)) return;
|
||||
const name = id.slice(VIRTUAL_PREFIX.length + 'bgm/'.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): Record<string, unknown> {
|
||||
return {
|
||||
meta: pkg.meta,
|
||||
parts: Object.fromEntries(pkg.parts),
|
||||
surfaces: Object.fromEntries(pkg.surfaces),
|
||||
setups: Object.fromEntries(pkg.setups),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { bgm } from './vite.js';
|
||||
|
||||
const fixtureRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__');
|
||||
const gamesRoot = path.join(fixtureRoot, 'harbor');
|
||||
|
||||
/**
|
||||
* Vite 8 types plugin hooks as `ObjectHook`, which may be a plain function or
|
||||
* a `{ handler, order }` object. Our plugin uses plain functions, so cast the
|
||||
* hook to a callable for direct invocation in tests.
|
||||
*/
|
||||
type Callable<T> = T extends (...args: infer A) => infer R ? (...args: A) => R : never;
|
||||
|
||||
function resolveId(plugin: ReturnType<typeof bgm>, id: string): unknown {
|
||||
return (plugin.resolveId as Callable<typeof plugin.resolveId>)(id, undefined, {
|
||||
isEntry: false,
|
||||
});
|
||||
}
|
||||
|
||||
function load(plugin: ReturnType<typeof bgm>, id: string): unknown {
|
||||
return (plugin.load as Callable<typeof plugin.load>)(id, { ssr: false });
|
||||
}
|
||||
|
||||
describe('bgm vite plugin', () => {
|
||||
it('resolves bgm/ imports to the virtual module', () => {
|
||||
const plugin = bgm({ root: gamesRoot });
|
||||
expect(resolveId(plugin, 'bgm/harbor')).toBe('\0bgm:bgm/harbor');
|
||||
expect(resolveId(plugin, 'bgm/nope')).toBe('\0bgm:bgm/nope');
|
||||
expect(resolveId(plugin, 'other')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('loads a package as a JSON module', () => {
|
||||
const plugin = bgm({ root: gamesRoot });
|
||||
const code = load(plugin, '\0bgm:bgm/harbor');
|
||||
expect(code).toBeDefined();
|
||||
expect(String(code).startsWith('export default ')).toBe(true);
|
||||
const pkg = JSON.parse(String(code).slice('export default '.length));
|
||||
expect(pkg.meta.id).toBe('harbor');
|
||||
expect(pkg.parts).toHaveProperty('token#wood');
|
||||
expect(pkg.surfaces).toHaveProperty('board#harbor');
|
||||
});
|
||||
|
||||
it('errors on an unknown package', () => {
|
||||
const plugin = bgm({ root: gamesRoot });
|
||||
expect(() => load(plugin, '\0bgm:bgm/unknown')).toThrow(/not found/);
|
||||
});
|
||||
});
|
||||
@@ -5,5 +5,6 @@
|
||||
"rootDir": "./src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/__fixtures__", "src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user