From 64c8bb60b3c0b533e1c404927e3e25a808aa7891 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 9 Aug 2026 19:44:27 +0800 Subject: [PATCH] feat(web): add bgm package browser --- apps/web/package.json | 1 + apps/web/src/App.tsx | 7 +++ apps/web/src/pages/BgmPackagePage.tsx | 89 +++++++++++++++++++++++++++ apps/web/src/pages/BgmPage.tsx | 42 +++++++++++++ apps/web/src/vite-env.d.ts | 20 +++++- apps/web/vite.config.ts | 8 ++- docs/bgm-loader-status.md | 57 +++++++++++++++++ 7 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/pages/BgmPackagePage.tsx create mode 100644 apps/web/src/pages/BgmPage.tsx create mode 100644 docs/bgm-loader-status.md diff --git a/apps/web/package.json b/apps/web/package.json index 53a3ffb..2dac665 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "@tts/extract": "workspace:*", "@tts/mesh": "workspace:*", "@tts/shared": "workspace:*", + "@tts/bgm": "workspace:*", "bson": "^7.3.1", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index bfce742..79ce2f8 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,6 +2,8 @@ import { Link, Route, Routes } from 'react-router-dom'; import SearchPage from './pages/SearchPage'; import ModPage from './pages/ModPage'; import FullSetupPage from './pages/FullSetupPage'; +import BgmPage from './pages/BgmPage'; +import BgmPackagePage from './pages/BgmPackagePage'; export default function App() { return ( @@ -15,6 +17,9 @@ export default function App() { Search + + BGM + @@ -23,6 +28,8 @@ export default function App() { } /> } /> } /> + } /> + } /> diff --git a/apps/web/src/pages/BgmPackagePage.tsx b/apps/web/src/pages/BgmPackagePage.tsx new file mode 100644 index 0000000..40dcc97 --- /dev/null +++ b/apps/web/src/pages/BgmPackagePage.tsx @@ -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 ( +
+

No package “{id}”.

+ + Back to all packages + +
+ ); + } + + const parts = Object.values(pkg.parts); + const surfaces = Object.values(pkg.surfaces); + const setups = Object.values(pkg.setups); + + return ( +
+
+ + ← All packages + +

{pkg.meta.title ?? pkg.meta.id}

+

+ {pkg.meta.designer ? `${pkg.meta.designer} · ` : ''} + {pkg.meta.players ? `${pkg.meta.players} players · ` : ''} + {pkg.meta.language} +

+
+ +
+

Parts

+
    + {parts.map((part) => ( +
  • +
    + {part.type}#{part.id} +
    +
    + {part.size ? `size ${part.size.join('×')}mm` : ''} + {part.fillet ? ` · fillet ${part.fillet}mm` : ''} +
    +
  • + ))} +
+
+ +
+

Surfaces

+
    + {surfaces.map((surface) => ( +
  • +
    + {surface.type}#{surface.id} +
    +
    + {surface.size ? `size ${surface.size.join('×')}mm` : ''} · {surface.layout.length} routes +
    +
  • + ))} +
+
+ +
+

Setups

+ {setups.map((setup) => ( +
+
+ {setup.type}#{setup.id} +
+
+              {JSON.stringify(setup.setup, null, 2)}
+            
+
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/BgmPage.tsx b/apps/web/src/pages/BgmPage.tsx new file mode 100644 index 0000000..1f50609 --- /dev/null +++ b/apps/web/src/pages/BgmPage.tsx @@ -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 ( +
+
+

Board games

+

+ {packages.length} package{packages.length === 1 ? '' : 's'} discovered. +

+
+ +
    + {packages.map((pkg) => ( +
  • + +
    {pkg.meta.title ?? pkg.meta.id}
    +
    + {pkg.meta.designer ? `${pkg.meta.designer} · ` : ''} + {pkg.meta.players ? `${pkg.meta.players} players · ` : ''} + {pkg.meta.language} +
    +
    + {Object.keys(pkg.parts).length} parts · {Object.keys(pkg.surfaces).length} surfaces ·{' '} + {Object.keys(pkg.setups).length} setups +
    + +
  • + ))} +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 151aa68..8a8f8a3 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -1 +1,19 @@ -/// \ No newline at end of file +/// + +/** + * Ambient types for the virtual `bgm` modules served by the bgm vite plugin. + * + * - `virtual:bgm/packages` — every discovered package. + * - `virtual:bgm/package/` — 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; +} \ No newline at end of file diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 508f973..436a2ef 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,9 +1,15 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; +import { fileURLToPath } from 'node:url'; +import { bgm } from '@tts/bgm'; export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [ + react(), + tailwindcss(), + bgm({ root: fileURLToPath(new URL('../../games', import.meta.url)) }), + ], server: { // Proxy API calls to the Hono backend during development. proxy: { diff --git a/docs/bgm-loader-status.md b/docs/bgm-loader-status.md new file mode 100644 index 0000000..a27c91e --- /dev/null +++ b/docs/bgm-loader-status.md @@ -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 (`./.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/` (one package) imports to `export default `, 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: /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).