feat(web): add bgm package browser
This commit is contained in:
@@ -21,6 +21,7 @@
|
|||||||
"@tts/extract": "workspace:*",
|
"@tts/extract": "workspace:*",
|
||||||
"@tts/mesh": "workspace:*",
|
"@tts/mesh": "workspace:*",
|
||||||
"@tts/shared": "workspace:*",
|
"@tts/shared": "workspace:*",
|
||||||
|
"@tts/bgm": "workspace:*",
|
||||||
"bson": "^7.3.1",
|
"bson": "^7.3.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Link, Route, Routes } from 'react-router-dom';
|
|||||||
import SearchPage from './pages/SearchPage';
|
import SearchPage from './pages/SearchPage';
|
||||||
import ModPage from './pages/ModPage';
|
import ModPage from './pages/ModPage';
|
||||||
import FullSetupPage from './pages/FullSetupPage';
|
import FullSetupPage from './pages/FullSetupPage';
|
||||||
|
import BgmPage from './pages/BgmPage';
|
||||||
|
import BgmPackagePage from './pages/BgmPackagePage';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -15,6 +17,9 @@ export default function App() {
|
|||||||
<Link to="/" className="hover:text-zinc-100">
|
<Link to="/" className="hover:text-zinc-100">
|
||||||
Search
|
Search
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/bgm" className="hover:text-zinc-100">
|
||||||
|
BGM
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -23,6 +28,8 @@ export default function App() {
|
|||||||
<Route path="/" element={<SearchPage />} />
|
<Route path="/" element={<SearchPage />} />
|
||||||
<Route path="/mod/:id" element={<ModPage />} />
|
<Route path="/mod/:id" element={<ModPage />} />
|
||||||
<Route path="/mod/:id/setup" element={<FullSetupPage />} />
|
<Route path="/mod/:id/setup" element={<FullSetupPage />} />
|
||||||
|
<Route path="/bgm" element={<BgmPage />} />
|
||||||
|
<Route path="/bgm/:id" element={<BgmPackagePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import packages from 'virtual:bgm/packages';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail view for a single bgm package, looked up by id from the
|
||||||
|
* `virtual:bgm/packages` module's list of all discovered packages.
|
||||||
|
*/
|
||||||
|
export default function BgmPackagePage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const pkg = packages.find((p) => p.meta.id === id);
|
||||||
|
|
||||||
|
if (!pkg) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-zinc-400">No package “{id}”.</p>
|
||||||
|
<Link to="/bgm" className="text-sm text-zinc-300 underline">
|
||||||
|
Back to all packages
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = Object.values(pkg.parts);
|
||||||
|
const surfaces = Object.values(pkg.surfaces);
|
||||||
|
const setups = Object.values(pkg.setups);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Link to="/bgm" className="text-sm text-zinc-400 hover:text-zinc-200">
|
||||||
|
← All packages
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-1 text-2xl font-semibold">{pkg.meta.title ?? pkg.meta.id}</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{pkg.meta.designer ? `${pkg.meta.designer} · ` : ''}
|
||||||
|
{pkg.meta.players ? `${pkg.meta.players} players · ` : ''}
|
||||||
|
{pkg.meta.language}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold">Parts</h2>
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{parts.map((part) => (
|
||||||
|
<li key={`${part.type}#${part.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
|
||||||
|
<div className="font-medium">
|
||||||
|
{part.type}#{part.id}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-400">
|
||||||
|
{part.size ? `size ${part.size.join('×')}mm` : ''}
|
||||||
|
{part.fillet ? ` · fillet ${part.fillet}mm` : ''}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold">Surfaces</h2>
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{surfaces.map((surface) => (
|
||||||
|
<li key={`${surface.type}#${surface.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
|
||||||
|
<div className="font-medium">
|
||||||
|
{surface.type}#{surface.id}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-400">
|
||||||
|
{surface.size ? `size ${surface.size.join('×')}mm` : ''} · {surface.layout.length} routes
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold">Setups</h2>
|
||||||
|
{setups.map((setup) => (
|
||||||
|
<div key={`${setup.type}#${setup.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
|
||||||
|
<div className="font-medium">
|
||||||
|
{setup.type}#{setup.id}
|
||||||
|
</div>
|
||||||
|
<pre className="mt-2 overflow-x-auto text-xs text-zinc-400">
|
||||||
|
{JSON.stringify(setup.setup, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import packages from 'virtual:bgm/packages';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists every discovered bgm package. The `virtual:bgm/packages` module's
|
||||||
|
* default export is an array of all packages the loader found in the games
|
||||||
|
* root.
|
||||||
|
*/
|
||||||
|
export default function BgmPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Board games</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{packages.length} package{packages.length === 1 ? '' : 's'} discovered.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{packages.map((pkg) => (
|
||||||
|
<li key={pkg.meta.id}>
|
||||||
|
<Link
|
||||||
|
to={`/bgm/${pkg.meta.id}`}
|
||||||
|
className="block rounded-lg border border-zinc-800 bg-zinc-900 p-3 hover:border-zinc-600"
|
||||||
|
>
|
||||||
|
<div className="font-medium">{pkg.meta.title ?? pkg.meta.id}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-400">
|
||||||
|
{pkg.meta.designer ? `${pkg.meta.designer} · ` : ''}
|
||||||
|
{pkg.meta.players ? `${pkg.meta.players} players · ` : ''}
|
||||||
|
{pkg.meta.language}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
|
{Object.keys(pkg.parts).length} parts · {Object.keys(pkg.surfaces).length} surfaces ·{' '}
|
||||||
|
{Object.keys(pkg.setups).length} setups
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+18
@@ -1 +1,19 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ambient types for the virtual `bgm` modules served by the bgm vite plugin.
|
||||||
|
*
|
||||||
|
* - `virtual:bgm/packages` — every discovered package.
|
||||||
|
* - `virtual:bgm/package/<id>` — a single package's assembled JSON.
|
||||||
|
*/
|
||||||
|
declare module 'virtual:bgm/packages' {
|
||||||
|
import type { SerializedPackage } from '@tts/bgm';
|
||||||
|
const packages: SerializedPackage[];
|
||||||
|
export default packages;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'virtual:bgm/package/*' {
|
||||||
|
import type { SerializedPackage } from '@tts/bgm';
|
||||||
|
const pkg: SerializedPackage;
|
||||||
|
export default pkg;
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { bgm } from '@tts/bgm';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
bgm({ root: fileURLToPath(new URL('../../games', import.meta.url)) }),
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
// Proxy API calls to the Hono backend during development.
|
// Proxy API calls to the Hono backend during development.
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# bgm Loader — Status
|
||||||
|
|
||||||
|
> WIP. What's built, what works, what's missing, and the known issues.
|
||||||
|
> Spec: [`bgm-format.md`](./bgm-format.md).
|
||||||
|
|
||||||
|
## What's built
|
||||||
|
|
||||||
|
### `packages/bgm` — the loader core (new)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/types.ts` | Roles (`Package`, `Part`, `Surface`, `Setup`, `Route`, `Stacking`, …), `SerializedPackage` (the JSON the plugin emits), `BgmError`, `DefFile`, `ParsedDef` |
|
||||||
|
| `src/schemas.ts` | zod schemas + `validate*` for each role |
|
||||||
|
| `src/markdown.ts` | Virtual def files from markdown code blocks, via **`marked`**. `file=` naming + content-hash auto-naming (`./<hash>.yaml`); multiple blocks may share a `file=` name |
|
||||||
|
| `src/parse.ts` | yaml/json/toml → def objects (`yaml`, `smol-toml`); real-file walker (incl. `.csv`) |
|
||||||
|
| `src/variants.ts` | `$variants` expansion via **`typed-csv`** (`typed-csv/csv-loader`). Inline (newline) vs path; paths resolve against the virtual def map |
|
||||||
|
| `src/collect.ts` | `loadDefs` (real + virtual, virtual wins), `collectPackages` (package decl → `include` globs via **picomatch** → parts/surfaces/setups, `type#id` uniqueness, `$variants` on defs and route candidates) |
|
||||||
|
| `src/vite.ts` | The **vite plugin** (`bgm()`): resolves `virtual:bgm/packages` (all packages) and `virtual:bgm/package/<id>` (one package) imports to `export default <json>`, watches source files for reload. Serializes the package's `Map`s to objects (`SerializedPackage`); throws on unknown packages; re-collects per `load` (no stale cache). |
|
||||||
|
| `src/*.test.ts` | **23 tests, all passing** — markdown extractor, typed-csv parsing (incl. the spec's empty-array + crop tuple cases), full harbor collection, plugin unit tests (resolve/load/shape/watch), and a real `vite build` integration test covering two packages |
|
||||||
|
|
||||||
|
Deps: `marked`, `typed-csv`, `yaml`, `smol-toml`, `picomatch`, `zod`, `vite`, `@types/picomatch`.
|
||||||
|
|
||||||
|
### `games/harbor/harbor.md` — example game (new)
|
||||||
|
|
||||||
|
Exercises the format end-to-end: package decl, two `file=parts/tokens.yaml` blocks, a surface with `candidates: $variants` against a virtual csv block, and a setup. Same content duplicated as the vitest fixture under `packages/bgm/src/__fixtures__/harbor/`.
|
||||||
|
|
||||||
|
### `apps/web` — consumer (new)
|
||||||
|
|
||||||
|
- `vite.config.ts` — wired with `bgm({ root: <repo>/games })`, importing the plugin from `@tts/bgm`.
|
||||||
|
- `src/vite-env.d.ts` — ambient `declare module 'virtual:bgm/packages'` (all packages) and `'virtual:bgm/package/*'` (one package).
|
||||||
|
- `src/pages/BgmPage.tsx` — list page: imports `virtual:bgm/packages` and shows every discovered package. Routed at `/bgm`.
|
||||||
|
- `src/pages/BgmPackagePage.tsx` — detail page: looks up a package by id from `bgm` and renders its parts/surfaces/setups. Routed at `/bgm/:id`.
|
||||||
|
|
||||||
|
The vite plugin itself lives in `packages/bgm/src/vite.ts` (exported from `@tts/bgm`), not in the web app — so the loader's plugin is tested in isolation from the web project.
|
||||||
|
|
||||||
|
## Works
|
||||||
|
|
||||||
|
- `pnpm --filter @tts/bgm build` and `typecheck` pass.
|
||||||
|
- Root `pnpm test`: **177 pass**.
|
||||||
|
- `pnpm --filter @tts/web build` succeeds; config warnings fixed.
|
||||||
|
- `src/vite.test.ts` runs a **real `vite build`** against a self-contained fixture (`src/__fixtures__/vite-build/`) and asserts the bundled output contains the package data — the plugin is proven end-to-end without touching the web app.
|
||||||
|
|
||||||
|
## Known issues
|
||||||
|
|
||||||
|
1. ~~Plugin emits empty maps~~ — fixed: `load` serializes `Map`s via `Object.fromEntries`.
|
||||||
|
2. ~~Plugin `load` uses `this.error`~~ — fixed: throws instead.
|
||||||
|
3. ~~HMR cache not invalidated~~ — fixed: dropped the closure cache; re-collects per `load`.
|
||||||
|
4. ~~`tinyglobby` leftover dep~~ — removed.
|
||||||
|
5. ~~Package-level test script finds no files~~ — added `packages/bgm/vitest.config.ts`.
|
||||||
|
|
||||||
|
## Not yet done
|
||||||
|
|
||||||
|
- ~~No consumer yet~~ — `apps/web/src/pages/BgmPage.tsx` lists packages from `virtual:bgm/packages`; `BgmPackagePage.tsx` shows one at `/bgm/:id`.
|
||||||
|
- **`$variants` URL paths** — spec mentions file/URL; URLs deferred.
|
||||||
|
- **zod `SerializedPackage` shape for the emitted JSON** — the plugin emits `SerializedPackage` objects; a zod schema for the emitted module would give runtime validation beyond the ambient `declare module`.
|
||||||
|
- **`setup` value expansion** — `type` without `id` → all parts of that type is documented but not implemented in the loader (it's a game-state init concern; noted as future).
|
||||||
|
- Docs for the loader itself (this file is the start).
|
||||||
Reference in New Issue
Block a user