From f563c9724f40408997d7df691e991414769652c3 Mon Sep 17 00:00:00 2001 From: Fendy Date: Thu, 17 Sep 2026 02:44:26 +0800 Subject: [PATCH] docs: add implementation plan and align spec with keel and node 24 --- .../plans/2026-09-17-webgame-collection.md | 2901 +++++++++++++++++ .../2026-09-17-webgame-collection-design.md | 13 +- 2 files changed, 2908 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-17-webgame-collection.md diff --git a/docs/superpowers/plans/2026-09-17-webgame-collection.md b/docs/superpowers/plans/2026-09-17-webgame-collection.md new file mode 100644 index 0000000..84d1a92 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-webgame-collection.md @@ -0,0 +1,2901 @@ +# webgame-collection 实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现收集静态网页游戏的开源目录站:仓库内 JSON 数据源 + Vue3 静态站点 + markdown 文档系统,CI 构建镜像推 GHCR 并由 keel 更新 k3s。 + +**Architecture:** 站点工程根为 `src/`(Vue3 + Vite + Tailwind v4),构建脚本校验 `src/games/*.json` 并生成 `src/public/data/*` 静态数据;前端经 `ContentRepository` 抽象层异步读取数据(未来换后端只替换实现)。部署为多阶段 Docker 镜像(nginx 托管 dist),GitHub Actions 推 GHCR 后调 keel native webhook,k3s 滚动更新。 + +**Tech Stack:** Vue 3 · TypeScript · Vite · Vue Router · Tailwind CSS v4 · markdown-it · Vitest · Ajv(draft 2020-12)· nginx · GitHub Actions · GHCR · keel.sh · k3s + +**Spec:** `docs/superpowers/specs/2026-09-17-webgame-collection-design.md` + +## Global Constraints + +- 仓库根 = `webgame-collection/`(已 `git init`,spec 已提交)。所有相对路径以仓库根为基准。 +- 站点 npm 工程根 = `src/`;所有 `npm` 命令在 `src/` 目录执行(Docker/CI 亦然)。 +- 构建只针对 `src/`:Docker 构建上下文为仓库根,但只拷贝 `src/` 与 `deploy/nginx.conf`。 +- 数据源:`src/games/.json`,`id` 满足 `^[a-z0-9-]{1,64}$` 且等于文件名。 +- JSON Schema:draft 2020-12,所有对象层级 `additionalProperties: false`。 +- 文档源:`src/docs/*.md`,slug = 文件名(`^[a-z0-9-]+$`),frontmatter 仅允许 `title`(必填)、`order`(可选数字,缺省 999)。 +- 站点文案与文档中文为主;界面不引入 i18n。 +- 不引 Pinia;状态用模块级 composable + URL query。 +- 数据层接口 `ContentRepository`(`listGames` / `getGame` / `listDocs` / `getDoc`);未来接后端只改 `src/app/data/index.ts` 的单例。 +- 数据基础路径:`import.meta.env.VITE_DATA_BASE_URL ?? '/data'`。 +- 封面:`cover` 字段为 https 外链或 `/data/assets/covers/.`(文件须存在于 `src/assets/covers/`);无封面或加载失败用首字 + 确定性渐变占位。 +- 详情页只做外链跳转(`target="_blank" rel="noopener noreferrer"`),不做 iframe 试玩。 +- 镜像:`ghcr.io//webgame-collection:` + `:latest`;CI Node 24,Docker 构建阶段 `node:24-alpine`,运行阶段 `nginx:1.27-alpine`。 +- 每个任务结束提交一次 git(conventional commits:`feat:` / `chore:` / `ci:` / `docs:` / `test:`)。 +- Node 本地版本 v25(开发可用);CI/镜像统一 24。 +- 本地无 kubectl:k8s 清单的正确性由 YAML 解析测试保证,集群侧 apply 属用户侧验收。 + +--- + +### Task 1: src 工程脚手架(Vite + Vue3 + TS + Tailwind + Vitest) + +**Files:** +- Create: `.gitignore`(仓库根) +- Create: `src/package.json` +- Create: `src/vite.config.ts` +- Create: `src/tsconfig.json` +- Create: `src/index.html` +- Create: `src/app/main.ts` +- Create: `src/app/App.vue` +- Create: `src/app/styles/main.css` +- Create: `src/public/favicon.svg` + +**Interfaces:** +- Consumes: 无 +- Produces: `npm run dev` / `npm run build` / `npm run typecheck` 可跑;`@` 别名指向 `src/app/`(后续所有任务使用 `@/...` 导入) + +- [ ] **Step 1: 写 `.gitignore`(仓库根)** + +```gitignore +node_modules/ +dist/ +src/public/data/ +.DS_Store +*.log +``` + +- [ ] **Step 2: 建目录骨架并写 `src/package.json`** + +```bash +mkdir -p src/app/router src/app/views src/app/components src/app/composables src/app/data src/app/lib src/app/styles src/games src/docs src/assets/covers src/schema src/scripts src/public +``` + +`src/package.json`: + +```json +{ + "name": "webgame-collection", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "vue-tsc --noEmit" + } +} +``` + +- [ ] **Step 3: 安装依赖** + +```bash +cd src +npm install vue vue-router markdown-it +npm install -D vite @vitejs/plugin-vue vue-tsc typescript tailwindcss @tailwindcss/vite vitest @types/node @types/markdown-it +``` + +(不锁定版本,npm 取当前最新稳定版;若 `markdown-it` 已自带类型,`@types/markdown-it` 保留无碍。) + +- [ ] **Step 4: 写构建与类型配置** + +`src/vite.config.ts`: + +```ts +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [vue(), tailwindcss()], + resolve: { + alias: { '@': fileURLToPath(new URL('./app', import.meta.url)) } + } +}) +``` + +`src/tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client", "node"], + "strict": true, + "noEmit": true, + "allowJs": true, + "checkJs": false, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { "@/*": ["app/*"] } + }, + "include": ["app/**/*.ts", "app/**/*.vue", "scripts/**/*.mjs", "scripts/**/*.ts"] +} +``` + +- [ ] **Step 5: 写入口与样式** + +`src/index.html`: + +```html + + + + + + + 网页游戏收藏馆 + + +
+ + + +``` + +`src/app/styles/main.css`: + +```css +@import "tailwindcss"; +``` + +`src/app/main.ts`: + +```ts +import { createApp } from 'vue' +import App from './App.vue' +import './styles/main.css' + +createApp(App).mount('#app') +``` + +`src/app/App.vue`(Task 6 之前的临时壳,Task 6 会替换): + +```vue + +``` + +`src/public/favicon.svg`: + +```svg + + + + + + +``` + +- [ ] **Step 6: 验证** + +```bash +cd src +npm run typecheck # 期望:无输出,退出码 0 +npm run build # 期望:vite build 成功,生成 src/dist/ +npm run dev # 期望:dev server 启动,浏览器显示“脚手架就绪”;Ctrl-C 退出 +``` + +- [ ] **Step 7: 提交** + +```bash +git add .gitignore src +git commit -m "chore: scaffold vue3 app in src with vite, tailwind and vitest" +``` + +--- + +### Task 2: 游戏 schema、校验/生成脚本与种子数据 + +**Files:** +- Create: `src/schema/game.schema.json` +- Create: `src/scripts/build-data.mjs` +- Create: `src/scripts/build-data.test.ts` +- Create: `src/games/2048.json` +- Create: `src/games/hextris.json` +- Create: `src/games/a-dark-room.json` +- Create: `src/docs/about.md` +- Create: `src/docs/contribute.md` +- Modify: `src/package.json`(加 predev/prebuild/validate:data/test/check 脚本) + +**Interfaces:** +- Consumes: Task 1 的工程骨架 +- Produces: + - `src/public/data/index.json`:`{ schemaVersion: 1, generatedAt: string, games: GameSummary[] }` + - `src/public/data/games/.json`:完整游戏对象(含 `intro`) + - `src/public/data/docs.json`:`{ generatedAt: string, docs: { slug, title, order, content }[] }`(按 order、slug 排序) + - `src/public/data/assets/covers/*`:从 `src/assets/covers/` 复制的封面 + - 导出函数(供测试与后续任务):`SRC_ROOT`、`parseFrontmatter(raw, label)`、`createValidator(schema)`、`loadGames({ gamesDir, coversDir, validate })`、`loadDocs({ docsDir })`、`buildIndex(games, generatedAt)`、`generate({ srcRoot, check, now })` + +- [ ] **Step 1: 写 JSON Schema** + +`src/schema/game.schema.json`: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/webgame-collection/schema/game.schema.json", + "title": "Webgame Collection Game", + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "url", "author", "description", "durationMinutes", "type", "tags", "addedAt"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9-]{1,64}$" }, + "name": { "type": "string", "minLength": 1, "maxLength": 60 }, + "url": { "type": "string", "pattern": "^https://[^\\s]+$" }, + "author": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 60 }, + "url": { "type": "string", "pattern": "^https://[^\\s]+$" } + } + }, + "description": { "type": "string", "minLength": 1, "maxLength": 140 }, + "intro": { "type": "string", "maxLength": 5000 }, + "durationMinutes": { + "type": "object", + "additionalProperties": false, + "required": ["min", "max"], + "properties": { + "min": { "type": "integer", "minimum": 1 }, + "max": { "type": "integer", "minimum": 1, "maximum": 600 } + } + }, + "type": { + "enum": ["puzzle", "action", "idle", "strategy", "simulation", "narrative", "music", "creative", "casual", "other"] + }, + "tags": { + "type": "array", + "maxItems": 8, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1, "maxLength": 12, "pattern": "^\\S(.*\\S)?$" } + }, + "cover": { "type": "string", "pattern": "^(https://[^\\s]+|/data/assets/covers/)" }, + "addedAt": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" } + } +} +``` + +(不使用 `format: uri`/`date`,避免引入 ajv-formats;Pattern 已覆盖本项目所需的严格性。) + +- [ ] **Step 2: 写种子数据(3 个游戏、2 篇站点文档)** + +`src/games/2048.json`: + +```json +{ + "id": "2048", + "name": "2048", + "url": "https://play2048.co/", + "author": { "name": "Gabriele Cirulli", "url": "https://github.com/gabrielecirulli/2048" }, + "description": "滑动合并数字方块,凑出 2048 的经典益智游戏。", + "intro": "使用方向键(移动端滑动)移动所有方块,相同数字碰撞后合并。\n\n全部方块卡死且无法再合并时游戏结束。", + "durationMinutes": { "min": 5, "max": 20 }, + "type": "puzzle", + "tags": ["数字", "休闲"], + "addedAt": "2026-09-17" +} +``` + +`src/games/hextris.json`: + +```json +{ + "id": "hextris", + "name": "Hextris", + "url": "https://hextris.io/", + "author": { "name": "Hextris Team", "url": "https://github.com/Hextris/hextris" }, + "description": "旋转六边形接住下落的彩色方块,速度越来越快的反应类游戏。", + "durationMinutes": { "min": 3, "max": 15 }, + "type": "action", + "tags": ["反应", "街机"], + "addedAt": "2026-09-17" +} +``` + +`src/games/a-dark-room.json`: + +```json +{ + "id": "a-dark-room", + "name": "A Dark Room", + "url": "https://adarkroom.doublespeakgames.com/", + "author": { "name": "Doublespeak Games", "url": "https://www.doublespeakgames.com/" }, + "description": "从一堆篝火开始,逐步建设定居点并探索黑暗世界的文字放置游戏。", + "intro": "游戏从“生火”开始,资源、建筑与事件逐步展开。\n\n耐心是这款游戏的核心机制之一。", + "durationMinutes": { "min": 30, "max": 300 }, + "type": "idle", + "tags": ["文字", "生存", "放置"], + "addedAt": "2026-09-17" +} +``` + +`src/docs/about.md`: + +```md +--- +title: 关于本站 +order: 1 +--- + +## 这是什么 + +网页游戏收藏馆收集**静态网页游戏**:打开网页即玩、无需安装、无需服务端。 + +## 收录范围 + +- 纯前端交互的网页游戏(含 HTML5、WebGL、Canvas 实现) +- 访问链接长期可用的独立游戏页面 +- 不收录需要下载客户端、注册账号才能玩的游戏 + +## 免责声明 + +所有游戏版权归原作者所有,本站仅提供链接与介绍。若您是权利人且不希望被收录,请提交 issue 或 PR 移除。 +``` + +`src/docs/contribute.md`: + +```md +--- +title: 收录与提交 +order: 2 +--- + +## 提交一个游戏 + +1. 在仓库 `src/games/` 目录新增 `.json`,`id` 只能包含小写字母、数字与连字符,且与文件名一致 +2. 本地运行校验:`cd src && npm install && npm run validate:data` +3. 提交 PR,CI 会自动校验数据格式 + +## 字段说明 + +| 字段 | 说明 | +| --- | --- | +| `name` | 游戏名(≤ 60 字) | +| `url` | 游戏访问链接(仅 https) | +| `author` | 作者或团队,`url` 可选 | +| `description` | 一句话简介(≤ 140 字) | +| `intro` | 可选,详情页 Markdown 长简介 | +| `durationMinutes` | 预计时长分钟数区间 | +| `type` | 单选类型,见 schema 枚举 | +| `tags` | 自由标签,最多 8 个、每个 ≤ 12 字 | +| `cover` | 可选封面:外链 https 或 `/data/assets/covers/.<扩展名>` | +| `addedAt` | 收录日期(`YYYY-MM-DD`) | +``` + +- [ ] **Step 3: 写失败测试 `src/scripts/build-data.test.ts`** + +```ts +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { SRC_ROOT, generate } from './build-data.mjs' + +const validGame = { + id: '2048', + name: '2048', + url: 'https://play2048.co/', + author: { name: 'Gabriel' }, + description: '滑动合并数字方块。', + durationMinutes: { min: 5, max: 20 }, + type: 'puzzle', + tags: ['数字'], + addedAt: '2026-09-17' +} + +const dirs: string[] = [] + +async function fixture(files: Record): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'wgc-')) + dirs.push(root) + await mkdir(path.join(root, 'schema'), { recursive: true }) + await copyFile(path.join(SRC_ROOT, 'schema', 'game.schema.json'), path.join(root, 'schema', 'game.schema.json')) + for (const [rel, content] of Object.entries(files)) { + const target = path.join(root, rel) + await mkdir(path.dirname(target), { recursive: true }) + await writeFile(target, typeof content === 'string' ? content : JSON.stringify(content)) + } + return root +} + +afterEach(async () => { + while (dirs.length) await rm(dirs.pop()!, { recursive: true, force: true }) +}) + +describe('loadGames via generate', () => { + it('接受合法游戏并生成 index/games/docs', async () => { + const root = await fixture({ + 'games/2048.json': { ...validGame, intro: '## 玩法' }, + 'docs/about.md': '---\ntitle: 关于\norder: 1\n---\n\n## 小节\n\n正文' + }) + const result = await generate({ srcRoot: root, now: new Date('2026-09-17T00:00:00Z') }) + expect(result).toMatchObject({ ok: true, games: 1, docs: 1 }) + const index = JSON.parse(await readFile(path.join(root, 'public/data/index.json'), 'utf8')) + expect(index.games).toHaveLength(1) + expect(index.games[0].intro).toBeUndefined() + const detail = JSON.parse(await readFile(path.join(root, 'public/data/games/2048.json'), 'utf8')) + expect(detail.intro).toBe('## 玩法') + const docs = JSON.parse(await readFile(path.join(root, 'public/data/docs.json'), 'utf8')) + expect(docs.docs[0]).toEqual({ slug: 'about', title: '关于', order: 1, content: '## 小节\n\n正文' }) + }) + + it('check 模式只校验不写文件', async () => { + const root = await fixture({ 'games/2048.json': validGame }) + const result = await generate({ srcRoot: root, check: true }) + expect(result.ok).toBe(true) + await expect(readFile(path.join(root, 'public/data/index.json'))).rejects.toThrow() + }) + + it('拒绝 id 与文件名不一致、未知字段、重复标签、非法文件名', async () => { + const cases: Array<[string, unknown]> = [ + ['games/other.json', validGame], + ['games/2048.json', { ...validGame, writer: 'x' }], + ['games/2048.json', { ...validGame, tags: ['dup', 'dup'] }], + ['games/Bad Name.json', validGame] + ] + for (const [file, content] of cases) { + const root = await fixture({ [file]: content }) + const result = await generate({ srcRoot: root, check: true }) + expect(result.ok, `${file} 应校验失败`).toBe(false) + } + }) + + it('拒绝 max { + const maxMin = await fixture({ 'games/2048.json': { ...validGame, durationMinutes: { min: 30, max: 5 } } }) + expect((await generate({ srcRoot: maxMin, check: true })).ok).toBe(false) + + const missing = await fixture({ 'games/2048.json': { ...validGame, cover: '/data/assets/covers/2048.png' } }) + expect((await generate({ srcRoot: missing, check: true })).ok).toBe(false) + + const wrongName = await fixture({ + 'games/2048.json': { ...validGame, cover: '/data/assets/covers/other.png' }, + 'assets/covers/other.png': 'png' + }) + expect((await generate({ srcRoot: wrongName, check: true })).ok).toBe(false) + }) + + it('本地封面存在时校验通过', async () => { + const root = await fixture({ + 'games/2048.json': { ...validGame, cover: '/data/assets/covers/2048.webp' }, + 'assets/covers/2048.webp': 'webp' + }) + expect((await generate({ srcRoot: root, check: true })).ok).toBe(true) + }) + + it('文档:缺 title、未知 frontmatter 字段报错;order 排序生效', async () => { + const noTitle = await fixture({ 'games/2048.json': validGame, 'docs/a.md': '---\norder: 1\n---\n正文' }) + expect((await generate({ srcRoot: noTitle, check: true })).ok).toBe(false) + + const unknownKey = await fixture({ 'games/2048.json': validGame, 'docs/a.md': '---\ntitle: A\nweight: 1\n---\n正文' }) + expect((await generate({ srcRoot: unknownKey, check: true })).ok).toBe(false) + + const sorted = await fixture({ + 'games/2048.json': validGame, + 'docs/b.md': '---\ntitle: B\norder: 2\n---\nB', + 'docs/a.md': '---\ntitle: A\norder: 1\n---\nA' + }) + await generate({ srcRoot: sorted }) + const docs = JSON.parse(await readFile(path.join(sorted, 'public/data/docs.json'), 'utf8')) + expect(docs.docs.map((d: { slug: string }) => d.slug)).toEqual(['a', 'b']) + }) +}) +``` + +- [ ] **Step 4: 跑测试确认失败** + +```bash +cd src +npx vitest run scripts/build-data.test.ts +``` + +期望:FAIL(`build-data.mjs` 尚未实现 / 导入报错)。 + +- [ ] **Step 5: 实现构建脚本 `src/scripts/build-data.mjs`** + +```js +#!/usr/bin/env node +import { cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import Ajv2020 from 'ajv/dist/2020.js' + +export const SRC_ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url))) +export const GAME_ID_PATTERN = /^[a-z0-9-]{1,64}$/ +export const DOC_SLUG_PATTERN = /^[a-z0-9-]+$/ +export const LOCAL_COVER_PATTERN = /^\/data\/assets\/covers\/([a-z0-9-]{1,64})\.(png|jpg|jpeg|webp|avif|gif)$/ + +export function createValidator(schema) { + return new Ajv2020({ allErrors: true, strict: true }).compile(schema) +} + +export function parseFrontmatter(raw, label = 'frontmatter') { + const errors = [] + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw) + if (!match) { + return { title: '', order: 999, content: raw, errors: [`${label}: 缺少 frontmatter(--- / title: ... / ---)`] } + } + let title = '' + let order = 999 + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim()) continue + const idx = line.indexOf(':') + if (idx < 0) { + errors.push(`${label}: frontmatter 行无法解析:${line}`) + continue + } + const key = line.slice(0, idx).trim() + const value = line.slice(idx + 1).trim() + if (key === 'title') title = value + else if (key === 'order') { + const n = Number(value) + if (!Number.isFinite(n)) errors.push(`${label}: order 必须是数字,得到 "${value}"`) + else order = n + } else errors.push(`${label}: 未知 frontmatter 字段 "${key}"(仅支持 title、order)`) + } + if (!title) errors.push(`${label}: 缺少 title`) + return { title, order, content: raw.slice(match[0].length).trim(), errors } +} + +async function checkCover(game, coversDir) { + if (!game.cover || game.cover.startsWith('https://')) return null + const match = LOCAL_COVER_PATTERN.exec(game.cover) + if (!match) return 'cover 必须是 https URL 或 /data/assets/covers/.' + if (match[1] !== game.id) return `cover 文件名必须与 id 一致(应为 ${game.id}.${match[2]})` + if (!existsSync(path.join(coversDir, `${game.id}.${match[2]}`))) return `cover 文件不存在:assets/covers/${game.id}.${match[2]}` + return null +} + +export async function loadGames({ gamesDir, coversDir, validate }) { + const errors = [] + const games = [] + const seen = new Map() + let files = [] + try { + files = (await readdir(gamesDir)).filter((f) => f.endsWith('.json')).sort() + } catch { + return { games, errors: [`无法读取目录:${gamesDir}`] } + } + for (const file of files) { + const label = `games/${file}` + const fileId = file.slice(0, -'.json'.length) + if (!GAME_ID_PATTERN.test(fileId)) errors.push(`${label}: 文件名必须是 [a-z0-9-]{1,64}`) + const lower = fileId.toLowerCase() + if (seen.has(lower)) errors.push(`${label}: id 与 ${seen.get(lower)} 重复(忽略大小写)`) + seen.set(lower, label) + let raw + try { + raw = JSON.parse(await readFile(path.join(gamesDir, file), 'utf8')) + } catch (e) { + errors.push(`${label}: JSON 解析失败:${e.message}`) + continue + } + if (!validate(raw)) { + for (const err of validate.errors ?? []) errors.push(`${label}${err.instancePath || ''}: ${err.message}`) + continue + } + if (raw.id !== fileId) { + errors.push(`${label}: id "${raw.id}" 必须等于文件名 "${fileId}"`) + continue + } + if (raw.durationMinutes.max < raw.durationMinutes.min) { + errors.push(`${label}: durationMinutes.max 必须 >= min`) + continue + } + const coverError = await checkCover(raw, coversDir) + if (coverError) { + errors.push(`${label}: ${coverError}`) + continue + } + games.push(raw) + } + return { games, errors } +} + +export async function loadDocs({ docsDir }) { + const errors = [] + const docs = [] + let files = [] + try { + files = (await readdir(docsDir)).filter((f) => f.endsWith('.md')).sort() + } catch { + return { docs, errors } + } + for (const file of files) { + const label = `docs/${file}` + const slug = file.slice(0, -'.md'.length) + if (!DOC_SLUG_PATTERN.test(slug)) { + errors.push(`${label}: 文件名必须是 [a-z0-9-]+`) + continue + } + const parsed = parseFrontmatter(await readFile(path.join(docsDir, file), 'utf8'), label) + if (parsed.errors.length) { + errors.push(...parsed.errors) + continue + } + docs.push({ slug, title: parsed.title, order: parsed.order, content: parsed.content }) + } + return { docs, errors } +} + +function pickGame(game, withIntro) { + return { + id: game.id, + name: game.name, + url: game.url, + author: { name: game.author.name, ...(game.author.url ? { url: game.author.url } : {}) }, + description: game.description, + ...(withIntro && game.intro ? { intro: game.intro } : {}), + durationMinutes: { min: game.durationMinutes.min, max: game.durationMinutes.max }, + type: game.type, + tags: [...game.tags], + ...(game.cover ? { cover: game.cover } : {}), + addedAt: game.addedAt + } +} + +export function buildIndex(games, generatedAt) { + return { + schemaVersion: 1, + generatedAt, + games: [...games].sort((a, b) => a.id.localeCompare(b.id)).map((g) => pickGame(g, false)) + } +} + +export async function generate({ srcRoot = SRC_ROOT, check = false, now = new Date() } = {}) { + const gamesDir = path.join(srcRoot, 'games') + const docsDir = path.join(srcRoot, 'docs') + const coversDir = path.join(srcRoot, 'assets', 'covers') + const outDir = path.join(srcRoot, 'public', 'data') + const schema = JSON.parse(await readFile(path.join(srcRoot, 'schema', 'game.schema.json'), 'utf8')) + const validate = createValidator(schema) + const { games, errors: gameErrors } = await loadGames({ gamesDir, coversDir, validate }) + const { docs, errors: docErrors } = await loadDocs({ docsDir }) + const errors = [...gameErrors, ...docErrors] + if (errors.length) return { ok: false, errors } + if (check) return { ok: true, errors: [], games: games.length, docs: docs.length } + + const generatedAt = now.toISOString() + await rm(outDir, { recursive: true, force: true }) + await mkdir(path.join(outDir, 'games'), { recursive: true }) + await writeFile(path.join(outDir, 'index.json'), JSON.stringify(buildIndex(games, generatedAt), null, 2) + '\n') + for (const game of games) { + await writeFile(path.join(outDir, 'games', `${game.id}.json`), JSON.stringify(pickGame(game, true), null, 2) + '\n') + } + const sortedDocs = [...docs].sort((a, b) => a.order - b.order || a.slug.localeCompare(b.slug)) + await writeFile(path.join(outDir, 'docs.json'), JSON.stringify({ generatedAt, docs: sortedDocs }, null, 2) + '\n') + if (existsSync(coversDir)) await cp(coversDir, path.join(outDir, 'assets', 'covers'), { recursive: true }) + return { ok: true, errors: [], games: games.length, docs: docs.length } +} + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +if (isMain) { + const check = process.argv.includes('--check') + const result = await generate({ check }) + if (!result.ok) { + console.error(`数据校验失败(${result.errors.length} 个问题):`) + for (const err of result.errors) console.error(` - ${err}`) + process.exit(1) + } + console.log(check ? `校验通过:${result.games} 个游戏,${result.docs} 篇文档` : `数据已生成:${result.games} 个游戏,${result.docs} 篇文档`) +} +``` + +(若安装到的 Ajv 主版本不是 8.x 导致 `ajv/dist/2020.js` 导入失败,按 Ajv 版本文档调整导入路径,其余逻辑不变。) + +- [ ] **Step 6: 补全 `src/package.json` 脚本** + +```json +"scripts": { + "dev": "vite", + "predev": "node scripts/build-data.mjs", + "build": "vue-tsc --noEmit && vite build", + "prebuild": "node scripts/build-data.mjs", + "preview": "vite preview", + "typecheck": "vue-tsc --noEmit", + "validate:data": "node scripts/build-data.mjs --check", + "test": "vitest run", + "check": "vitest run && npm run build" +} +``` + +```bash +cd src +npm install -D ajv +``` + +- [ ] **Step 7: 跑测试确认通过并验证生成产物** + +```bash +cd src +npx vitest run scripts/build-data.test.ts # 期望:全部 PASS +npm run validate:data # 期望:“校验通过:3 个游戏,2 篇文档” +npm run build # 期望:prebuild 生成 public/data,vite build 成功 +ls public/data public/data/games # 期望:index.json docs.json games/ assets/ +``` + +- [ ] **Step 8: 提交** + +```bash +git add src +git commit -m "feat: add strict game schema, data validation and static data generation" +``` + +--- + +### Task 3: 数据抽象层(types / repository / staticRepo) + +**Files:** +- Create: `src/app/data/types.ts` +- Create: `src/app/data/repository.ts` +- Create: `src/app/data/staticRepo.ts` +- Create: `src/app/data/index.ts` +- Create: `src/app/data/staticRepo.test.ts` + +**Interfaces:** +- Consumes: Task 2 生成的 `/data/index.json`、`/data/games/.json`、`/data/docs.json` 结构与字段 +- Produces: + - `GAME_TYPES: readonly string[]`、`GameType` + - `GameSummary`、`Game`、`DocMeta`、`Doc`、`GamesIndex`、`DocsIndex` + - `NotFoundError`、`ContentRepository` 接口 + - `StaticContentRepository` 类;`repo` 单例(后续页面唯一数据入口) + +- [ ] **Step 1: 写类型与接口** + +`src/app/data/types.ts`: + +```ts +export const GAME_TYPES = [ + 'puzzle', 'action', 'idle', 'strategy', 'simulation', + 'narrative', 'music', 'creative', 'casual', 'other' +] as const + +export type GameType = (typeof GAME_TYPES)[number] + +export interface Author { + name: string + url?: string +} + +export interface GameSummary { + id: string + name: string + url: string + author: Author + description: string + durationMinutes: { min: number; max: number } + type: GameType + tags: string[] + cover?: string + addedAt: string +} + +export interface Game extends GameSummary { + intro?: string +} + +export interface DocMeta { + slug: string + title: string + order: number +} + +export interface Doc extends DocMeta { + content: string +} + +export interface GamesIndex { + schemaVersion: number + generatedAt: string + games: GameSummary[] +} + +export interface DocsIndex { + generatedAt: string + docs: Doc[] +} +``` + +`src/app/data/repository.ts`: + +```ts +import type { Doc, DocMeta, Game, GameSummary } from './types' + +export class NotFoundError extends Error { + constructor(what: string) { + super(`Not found: ${what}`) + this.name = 'NotFoundError' + } +} + +export interface ContentRepository { + listGames(): Promise + getGame(id: string): Promise + listDocs(): Promise + getDoc(slug: string): Promise +} +``` + +- [ ] **Step 2: 写失败测试 `src/app/data/staticRepo.test.ts`** + +```ts +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NotFoundError } from './repository' +import { StaticContentRepository } from './staticRepo' + +const index = { + schemaVersion: 1, + generatedAt: '2026-09-17T00:00:00.000Z', + games: [{ + id: '2048', + name: '2048', + url: 'https://play2048.co/', + author: { name: 'Gabriel' }, + description: '滑动合并数字方块。', + durationMinutes: { min: 5, max: 20 }, + type: 'puzzle', + tags: ['数字'], + addedAt: '2026-09-17' + }] +} + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body + } as Response +} + +describe('StaticContentRepository', () => { + let fetchMock: ReturnType + + beforeEach(() => { + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + return () => vi.unstubAllGlobals() + }) + + it('使用 base 拼 URL 并返回摘要列表', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(index)) + const repo = new StaticContentRepository('/data') + await expect(repo.listGames()).resolves.toEqual(index.games) + expect(fetchMock).toHaveBeenCalledWith('/data/index.json') + }) + + it('列表请求有内存缓存(只 fetch 一次)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(index)) + const repo = new StaticContentRepository('/data') + await repo.listGames() + await repo.listGames() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('404 抛 NotFoundError,且失败不缓存(可重试成功)', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({}, 404)) + .mockResolvedValueOnce(jsonResponse({ ...index.games[0] })) + const repo = new StaticContentRepository('/data') + await expect(repo.getGame('2048')).rejects.toBeInstanceOf(NotFoundError) + await expect(repo.getGame('2048')).resolves.toMatchObject({ id: '2048' }) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('非 404 错误抛 Error', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({}, 500)) + const repo = new StaticContentRepository('/data') + await expect(repo.listGames()).rejects.toThrow('请求失败 500') + }) + + it('响应结构非法时报错', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ schemaVersion: 1, games: [{ id: 1 }] })) + const repo = new StaticContentRepository('/data') + await expect(repo.listGames()).rejects.toThrow('数据格式错误') + }) + + it('listDocs 返回元信息,getDoc 返回正文', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ + generatedAt: 't', + docs: [{ slug: 'about', title: '关于', order: 1, content: '# x' }] + })) + const repo = new StaticContentRepository('/data') + await expect(repo.listDocs()).resolves.toEqual([{ slug: 'about', title: '关于', order: 1 }]) + await expect(repo.getDoc('about')).resolves.toMatchObject({ title: '关于', content: '# x' }) + }) +}) +``` + +- [ ] **Step 3: 跑测试确认失败** + +```bash +cd src +npx vitest run app/data/staticRepo.test.ts +``` + +期望:FAIL(`staticRepo.ts` 不存在)。 + +- [ ] **Step 4: 实现 `src/app/data/staticRepo.ts` 与单例** + +`src/app/data/staticRepo.ts`: + +```ts +import { NotFoundError, type ContentRepository } from './repository' +import type { Doc, DocMeta, DocsIndex, Game, GameSummary, GamesIndex } from './types' + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(`数据格式错误: ${message}`) +} + +function assertGameSummary(value: unknown, path: string): asserts value is GameSummary { + const g = value as GameSummary + assert(g && typeof g.id === 'string' && typeof g.name === 'string' && typeof g.url === 'string', `${path} 缺少 id/name/url`) + assert(Array.isArray(g.tags), `${path}.tags 必须是数组`) + assert(g.author && typeof g.author.name === 'string', `${path}.author.name 缺失`) + assert(g.durationMinutes && typeof g.durationMinutes.min === 'number' && typeof g.durationMinutes.max === 'number', `${path}.durationMinutes 非法`) +} + +function assertGamesIndex(value: unknown): GamesIndex { + const data = value as GamesIndex + assert(data && Array.isArray(data.games), 'index.json 缺少 games 数组') + data.games.forEach((game, i) => assertGameSummary(game, `games[${i}]`)) + return data +} + +function assertDocsIndex(value: unknown): DocsIndex { + const data = value as DocsIndex + assert(data && Array.isArray(data.docs), 'docs.json 缺少 docs 数组') + data.docs.forEach((doc, i) => { + assert(doc && typeof doc.slug === 'string' && typeof doc.title === 'string' && typeof doc.content === 'string', `docs[${i}] 非法`) + }) + return data +} + +export class StaticContentRepository implements ContentRepository { + private cachedGames?: Promise + private cachedDocs?: Promise + private readonly cachedGamesById = new Map>() + + constructor(private readonly base: string = import.meta.env.VITE_DATA_BASE_URL ?? '/data') {} + + private async fetchJson(path: string): Promise { + const response = await fetch(`${this.base}${path}`) + if (response.status === 404) throw new NotFoundError(path) + if (!response.ok) throw new Error(`请求失败 ${response.status}: ${path}`) + return response.json() + } + + listGames(): Promise { + this.cachedGames ??= this.fetchJson('/index.json') + .then(assertGamesIndex) + .then((data) => data.games) + .catch((error) => { + this.cachedGames = undefined + throw error + }) + return this.cachedGames + } + + getGame(id: string): Promise { + let pending = this.cachedGamesById.get(id) + if (!pending) { + pending = this.fetchJson(`/games/${encodeURIComponent(id)}.json`) + .then((data) => { + assertGameSummary(data, `games/${id}`) + return data as Game + }) + .catch((error) => { + this.cachedGamesById.delete(id) + throw error + }) + this.cachedGamesById.set(id, pending) + } + return pending + } + + listDocs(): Promise { + this.cachedDocs ??= this.fetchJson('/docs.json') + .then(assertDocsIndex) + .then((data) => data.docs) + .catch((error) => { + this.cachedDocs = undefined + throw error + }) + return this.cachedDocs + } + + async getDoc(slug: string): Promise { + const docs = await this.listDocs() + const doc = docs.find((d) => d.slug === slug) + if (!doc) throw new NotFoundError(slug) + return doc + } +} +``` + +`src/app/data/index.ts`: + +```ts +import type { ContentRepository } from './repository' +import { StaticContentRepository } from './staticRepo' + +export * from './repository' +export * from './types' + +// 未来接入后端:实现 ApiRepository 后仅替换这里的单例 +export const repo: ContentRepository = new StaticContentRepository() +``` + +- [ ] **Step 5: 跑测试确认通过** + +```bash +cd src +npx vitest run app/data/staticRepo.test.ts # 期望:全部 PASS +npm run typecheck # 期望:退出码 0 +``` + +- [ ] **Step 6: 提交** + +```bash +git add src/app/data +git commit -m "feat: add content repository abstraction with static JSON implementation" +``` + +--- + +### Task 4: 筛选/搜索/排序、URL 状态与文案纯函数 + +**Files:** +- Create: `src/app/lib/filter.ts` +- Create: `src/app/lib/filter.test.ts` +- Create: `src/app/lib/labels.ts` +- Create: `src/app/lib/labels.test.ts` + +**Interfaces:** +- Consumes: Task 3 的 `GameSummary`、`GameType`、`GAME_TYPES` +- Produces: + - `FilterState { q: string; type: GameType | 'all'; tags: string[]; dur: 'all' | 'short' | 'mid' | 'long'; sort: 'new' | 'name' | 'duration' }` + - `DEFAULT_FILTER`、`parseFilterState(query: Record)`、`toQuery(state): Record`、`durationBucket(game): 'short' | 'mid' | 'long'`、`filterGames(games, state): GameSummary[]` + - `GAME_TYPE_LABELS: Record`、`durationText({ min, max }): string` + +- [ ] **Step 1: 写失败测试 `src/app/lib/filter.test.ts` 与 `labels.test.ts`** + +`src/app/lib/filter.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import type { GameSummary } from '@/data/types' +import { DEFAULT_FILTER, durationBucket, filterGames, parseFilterState, toQuery } from './filter' + +function game(partial: Partial & Pick): GameSummary { + return { + name: partial.id, + url: 'https://example.com/', + author: { name: '作者' }, + description: '描述', + durationMinutes: { min: 5, max: 20 }, + type: 'puzzle', + tags: [], + addedAt: '2026-01-01', + ...partial + } +} + +const games: GameSummary[] = [ + game({ id: 'alpha', name: 'Alpha', tags: ['数字', '休闲'], addedAt: '2026-01-02', durationMinutes: { min: 5, max: 10 } }), + game({ id: 'beta', name: 'Beta', description: '探索黑暗世界', tags: ['文字'], type: 'idle', addedAt: '2026-03-01', durationMinutes: { min: 30, max: 300 } }), + game({ id: 'gamma', name: 'Gamma', author: { name: '某人' }, tags: ['数字'], type: 'action', addedAt: '2026-02-01', durationMinutes: { min: 1, max: 4 } }) +] + +describe('parseFilterState', () => { + it('解析合法 query,非法值回退默认', () => { + expect(parseFilterState({ q: ' 2048 ', type: 'idle', tag: '文字,数字', dur: 'long', sort: 'name' })) + .toEqual({ q: '2048', type: 'idle', tags: ['文字', '数字'], dur: 'long', sort: 'name' }) + expect(parseFilterState({ type: 'nope', dur: 'x', sort: 'y' })).toEqual(DEFAULT_FILTER) + expect(parseFilterState({})).toEqual(DEFAULT_FILTER) + }) +}) + +describe('toQuery', () => { + it('省略默认值,tags 用逗号连接', () => { + expect(toQuery(DEFAULT_FILTER)).toEqual({}) + expect(toQuery({ ...DEFAULT_FILTER, q: 'x', type: 'idle', tags: ['a', 'b'], dur: 'short', sort: 'name' })) + .toEqual({ q: 'x', type: 'idle', tag: 'a,b', dur: 'short', sort: 'name' }) + }) + + it('round-trip:toQuery -> parseFilterState 保持一致', () => { + const state = { ...DEFAULT_FILTER, q: '2048', tags: ['数字'], dur: 'mid' as const } + expect(parseFilterState(toQuery(state))).toEqual(state) + }) +}) + +describe('durationBucket', () => { + it('按 max 分桶:short ≤5,mid ≤30,long >30', () => { + expect(durationBucket(game({ id: 'a', durationMinutes: { min: 1, max: 5 } }))).toBe('short') + expect(durationBucket(game({ id: 'b', durationMinutes: { min: 10, max: 30 } }))).toBe('mid') + expect(durationBucket(game({ id: 'c', durationMinutes: { min: 30, max: 31 } }))).toBe('long') + }) +}) + +describe('filterGames', () => { + it('搜索匹配 name/description/author/tags,忽略大小写', () => { + expect(filterGames(games, { ...DEFAULT_FILTER, q: 'alpha' }).map((g) => g.id)).toEqual(['alpha']) + expect(filterGames(games, { ...DEFAULT_FILTER, q: '黑暗' }).map((g) => g.id)).toEqual(['beta']) + expect(filterGames(games, { ...DEFAULT_FILTER, q: '作者' }).map((g) => g.id)).toEqual(['alpha', 'beta']) + }) + + it('类型与标签为 AND 语义,标签多选须全部命中', () => { + expect(filterGames(games, { ...DEFAULT_FILTER, type: 'puzzle' }).map((g) => g.id)).toEqual(['alpha']) + expect(filterGames(games, { ...DEFAULT_FILTER, tags: ['数字'] }).map((g) => g.id)).toEqual(['alpha', 'gamma']) + expect(filterGames(games, { ...DEFAULT_FILTER, tags: ['数字', '休闲'] }).map((g) => g.id)).toEqual(['alpha']) + }) + + it('时长桶与排序(new 倒序、duration 按 min 升序、name 字母序)', () => { + expect(filterGames(games, { ...DEFAULT_FILTER, dur: 'short' }).map((g) => g.id)).toEqual(['gamma']) + expect(filterGames(games, { ...DEFAULT_FILTER, sort: 'new' }).map((g) => g.id)).toEqual(['beta', 'gamma', 'alpha']) + expect(filterGames(games, { ...DEFAULT_FILTER, sort: 'duration' }).map((g) => g.id)).toEqual(['gamma', 'alpha', 'beta']) + expect(filterGames(games, { ...DEFAULT_FILTER, sort: 'name' }).map((g) => g.id)).toEqual(['alpha', 'beta', 'gamma']) + }) + + it('组合筛选', () => { + expect(filterGames(games, { ...DEFAULT_FILTER, q: '数字', type: 'action' }).map((g) => g.id)).toEqual(['gamma']) + expect(filterGames(games, { ...DEFAULT_FILTER, q: '不存在' })).toEqual([]) + }) +}) +``` + +`src/app/lib/labels.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { GAME_TYPES } from '@/data/types' +import { GAME_TYPE_LABELS, durationText } from './labels' + +describe('labels', () => { + it('每个类型都有中文标签', () => { + for (const type of GAME_TYPES) expect(GAME_TYPE_LABELS[type]).toBeTruthy() + }) + + it('durationText:区间与单值', () => { + expect(durationText({ min: 5, max: 20 })).toBe('5–20 分钟') + expect(durationText({ min: 5, max: 5 })).toBe('约 5 分钟') + }) +}) +``` + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd src +npx vitest run app/lib/filter.test.ts app/lib/labels.test.ts +``` + +期望:FAIL(模块不存在)。 + +- [ ] **Step 3: 实现 `filter.ts` 与 `labels.ts`** + +`src/app/lib/filter.ts`: + +```ts +import { GAME_TYPES, type GameSummary, type GameType } from '@/data/types' + +export type SortKey = 'new' | 'name' | 'duration' +export type DurationBucket = 'short' | 'mid' | 'long' + +export interface FilterState { + q: string + type: GameType | 'all' + tags: string[] + dur: DurationBucket | 'all' + sort: SortKey +} + +export const DEFAULT_FILTER: FilterState = { q: '', type: 'all', tags: [], dur: 'all', sort: 'new' } + +export function parseFilterState(query: Record): FilterState { + const asString = (v: unknown) => (typeof v === 'string' ? v : '') + const type = asString(query.type) + const dur = asString(query.dur) + const sort = asString(query.sort) + const tag = asString(query.tag) + return { + q: asString(query.q).trim(), + type: (GAME_TYPES as readonly string[]).includes(type) ? (type as GameType) : 'all', + tags: tag ? tag.split(',').filter(Boolean) : [], + dur: ['short', 'mid', 'long'].includes(dur) ? (dur as DurationBucket) : 'all', + sort: ['new', 'name', 'duration'].includes(sort) ? (sort as SortKey) : 'new' + } +} + +export function toQuery(state: FilterState): Record { + const query: Record = {} + if (state.q) query.q = state.q + if (state.type !== 'all') query.type = state.type + if (state.tags.length) query.tag = state.tags.join(',') + if (state.dur !== 'all') query.dur = state.dur + if (state.sort !== 'new') query.sort = state.sort + return query +} + +export function durationBucket(game: GameSummary): DurationBucket { + const max = game.durationMinutes.max + if (max <= 5) return 'short' + if (max <= 30) return 'mid' + return 'long' +} + +export function filterGames(games: GameSummary[], state: FilterState): GameSummary[] { + const q = state.q.trim().toLowerCase() + const result = games.filter((game) => { + if (state.type !== 'all' && game.type !== state.type) return false + if (state.tags.length && !state.tags.every((tag) => game.tags.includes(tag))) return false + if (state.dur !== 'all' && durationBucket(game) !== state.dur) return false + if (q) { + const haystack = [game.name, game.description, game.author.name, ...game.tags].join(' ').toLowerCase() + if (!haystack.includes(q)) return false + } + return true + }) + const sorted = [...result] + if (state.sort === 'new') sorted.sort((a, b) => b.addedAt.localeCompare(a.addedAt) || a.id.localeCompare(b.id)) + else if (state.sort === 'duration') { + sorted.sort((a, b) => + a.durationMinutes.min - b.durationMinutes.min || + a.durationMinutes.max - b.durationMinutes.max || + a.id.localeCompare(b.id) + ) + } else sorted.sort((a, b) => a.name.localeCompare(b.name, 'zh') || a.id.localeCompare(b.id)) + return sorted +} +``` + +`src/app/lib/labels.ts`: + +```ts +import type { GameType } from '@/data/types' + +export const GAME_TYPE_LABELS: Record = { + puzzle: '解谜', + action: '动作', + idle: '放置', + strategy: '策略', + simulation: '模拟', + narrative: '文字叙事', + music: '音乐', + creative: '创意', + casual: '休闲', + other: '其他' +} + +export function durationText(duration: { min: number; max: number }): string { + return duration.min === duration.max ? `约 ${duration.min} 分钟` : `${duration.min}–${duration.max} 分钟` +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +```bash +cd src +npx vitest run app/lib # 期望:全部 PASS +``` + +- [ ] **Step 5: 提交** + +```bash +git add src/app/lib +git commit -m "feat: add filter, sort and label utilities with tests" +``` + +--- + +### Task 5: markdown 渲染、TOC 提取与封面兜底纯函数 + +**Files:** +- Create: `src/app/lib/markdown.ts` +- Create: `src/app/lib/markdown.test.ts` +- Create: `src/app/lib/cover.ts` +- Create: `src/app/lib/cover.test.ts` + +**Interfaces:** +- Consumes: 无(纯函数) +- Produces: + - `TocItem { level: 2 | 3; text: string; id: string }` + - `renderMarkdown(source: string): string`(h2/h3 自动加锚点 id,其余交给 markdown-it) + - `extractToc(source: string): TocItem[]`(仅 h2/h3,slug 与渲染结果一致) + - `hashString(input: string): number`、`coverGradient(id: string): [string, string]`、`coverInitial(name: string): string` + +- [ ] **Step 1: 写失败测试** + +`src/app/lib/markdown.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { extractToc, renderMarkdown } from './markdown' + +describe('extractToc', () => { + it('只提取 h2/h3,忽略 h1/h4', () => { + const toc = extractToc('# 标题\n\n## 第一节\n\n### 小节\n\n#### 更深\n') + expect(toc.map((t) => [t.level, t.text])).toEqual([[2, '第一节'], [3, '小节']]) + }) + + it('中文标题保留,标点被去掉', () => { + const [item] = extractToc('## 怎么玩:新手教程!\n') + expect(item.id).toBe('怎么玩新手教程') + }) + + it('同名标题自动去重(-2 后缀)', () => { + const toc = extractToc('## 玩法\n\n## 玩法\n') + expect(toc.map((t) => t.id)).toEqual(['玩法', '玩法-2']) + }) + + it('空标题回退 section', () => { + const [item] = extractToc('## !!!\n') + expect(item.id).toBe('section') + }) +}) + +describe('renderMarkdown', () => { + it('渲染出的 h2/h3 带与 TOC 一致的 id', () => { + const html = renderMarkdown('## 第一节\n\n正文\n\n### 小节\n') + expect(html).toContain('id="第一节"') + expect(html).toContain('id="小节"') + expect(html).toContain('

正文

') + }) + + it('不渲染原始 HTML(html: false)', () => { + expect(renderMarkdown('')).not.toContain(' + + +``` + +`src/app/views/NotFoundView.vue`: + +```vue + +``` + +临时页面(Task 7/8/9 会替换,先保证路由可构建): + +`src/app/views/HomeView.vue` / `GameView.vue` / `DocsView.vue`: + +```vue + +``` + +- [ ] **Step 2: 追加 markdown 正文样式到 `src/app/styles/main.css`** + +```css +@import "tailwindcss"; + +.markdown-body { + line-height: 1.75; + color: var(--color-neutral-300); +} +.markdown-body h2 { + margin: 1.75rem 0 0.75rem; + font-size: 1.25rem; + font-weight: 600; + color: var(--color-neutral-100); + scroll-margin-top: 4rem; +} +.markdown-body h3 { + margin: 1.25rem 0 0.5rem; + font-size: 1.05rem; + font-weight: 600; + color: var(--color-neutral-200); + scroll-margin-top: 4rem; +} +.markdown-body p { margin: 0.6rem 0; } +.markdown-body a { color: var(--color-violet-400); text-decoration: underline; } +.markdown-body ul, .markdown-body ol { margin: 0.6rem 0; padding-left: 1.4rem; list-style: disc; } +.markdown-body ol { list-style: decimal; } +.markdown-body code { + background: var(--color-neutral-800); + border-radius: 0.25rem; + padding: 0.1rem 0.35rem; + font-size: 0.875em; +} +.markdown-body pre { + background: var(--color-neutral-900); + border: 1px solid var(--color-neutral-800); + border-radius: 0.5rem; + padding: 0.75rem 1rem; + overflow-x: auto; + margin: 0.75rem 0; +} +.markdown-body pre code { background: transparent; padding: 0; } +.markdown-body blockquote { + border-left: 3px solid var(--color-neutral-700); + padding-left: 0.9rem; + color: var(--color-neutral-400); + margin: 0.75rem 0; +} +``` + +- [ ] **Step 3: 验证** + +```bash +cd src +npm run typecheck # 期望:退出码 0 +npm run dev # 人工验证: +# / -> 空白(Task 7 实现,允许) +# /docs -> 空白(Task 9 实现,允许) +# /whatever -> 显示 404 页面 +# 页头 游戏/文档 链接可点击 +``` + +- [ ] **Step 4: 提交** + +```bash +git add src/app +git commit -m "feat: add app shell, router and 404 page" +``` + +--- + +### Task 7: 目录页(搜索 / 筛选 / 排序 / 卡片网格) + +**Files:** +- Create: `src/app/composables/useAsync.ts` +- Create: `src/app/composables/useFilterState.ts` +- Create: `src/app/components/StatePanel.vue` +- Create: `src/app/components/GameCover.vue` +- Create: `src/app/components/GameCard.vue` +- Create: `src/app/components/GameFilters.vue` +- Modify: `src/app/views/HomeView.vue`(替换临时实现) + +**Interfaces:** +- Consumes: Task 3 `repo`、Task 4 `filter.ts`/`labels.ts`、Task 5 `cover.ts` +- Produces: + - `useAsync(loader: () => Promise, deps?: Ref[]): { data: Ref; error: Ref; loading: Ref; reload: () => Promise }` + - `useFilterState(): { state: ComputedRef; update: (patch: Partial) => void }` + - 组件 props:`StatePanel { loading: boolean; error: Error | null }`(emit `retry`)、`GameCover { game: GameSummary }`、`GameCard { game: GameSummary }`、`GameFilters { games: GameSummary[] }` + +- [ ] **Step 1: 写 composables** + +`src/app/composables/useAsync.ts`: + +```ts +import { ref, watch, type Ref } from 'vue' + +export function useAsync(loader: () => Promise, deps: Ref[] = []) { + const data = ref(null) as Ref + const error = ref(null) + const loading = ref(true) + let runId = 0 + + async function reload(): Promise { + const id = ++runId + loading.value = true + error.value = null + try { + const result = await loader() + if (id === runId) data.value = result + } catch (e) { + if (id === runId) error.value = e instanceof Error ? e : new Error(String(e)) + } finally { + if (id === runId) loading.value = false + } + } + + watch(deps, reload, { immediate: true }) + return { data, error, loading, reload } +} +``` + +`src/app/composables/useFilterState.ts`: + +```ts +import { computed } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { parseFilterState, toQuery, type FilterState } from '@/lib/filter' + +export function useFilterState() { + const route = useRoute() + const router = useRouter() + const state = computed(() => parseFilterState(route.query)) + + function update(patch: Partial): void { + void router.replace({ query: toQuery({ ...state.value, ...patch }) }) + } + + return { state, update } +} +``` + +- [ ] **Step 2: 写组件** + +`src/app/components/StatePanel.vue`: + +```vue + + + +``` + +`src/app/components/GameCover.vue`: + +```vue + + + +``` + +`src/app/components/GameCard.vue`: + +```vue + + + +``` + +`src/app/components/GameFilters.vue`: + +```vue + + + +``` + +(`@click="update({ dur: option.value })"` 中 `option.value` 类型由 `as const` 收窄为字面量联合,可直接传入 `Partial`。) + +- [ ] **Step 3: 实现 `HomeView.vue`** + +```vue + + + +``` + +- [ ] **Step 4: 验证** + +```bash +cd src +npm run typecheck && npx vitest run # 期望:通过(含既有测试) +npm run dev +``` + +人工验证(浏览器): +- `/` 显示 3 张游戏卡片,占位封面分别为 2、H、A 的渐变卡 +- 搜索框输入 `黑暗` → 只剩 A Dark Room;URL 出现 `?q=黑暗` +- 点击类型「解谜」→ 只剩 2048;点击标签「数字」与「休闲」→ AND 语义生效 +- 时长「≤5 分钟」→ 只剩 Hextris;排序切换「名称」→ 顺序 A Dark Room / Hextris / 2048(locale zh) +- 刷新页面筛选保持;无匹配时显示空状态文案 + +- [ ] **Step 5: 提交** + +```bash +git add src/app +git commit -m "feat: add catalog page with search, filters, sorting and cover fallback" +``` + +--- + +### Task 8: 详情页(intro markdown + 外链跳转) + +**Files:** +- Modify: `src/app/views/GameView.vue`(替换临时实现) + +**Interfaces:** +- Consumes: Task 3 `repo`/`NotFoundError`、Task 5 `renderMarkdown`、Task 7 的 `StatePanel`/`GameCover`/`useAsync`、Task 4 `labels` +- Produces: `/games/:id` 完整页面(无新增导出) + +- [ ] **Step 1: 实现 `GameView.vue`** + +```vue + + + +``` + +- [ ] **Step 2: 验证** + +```bash +cd src +npm run typecheck && npx vitest run +npm run dev +``` + +人工验证: +- `/games/2048` → 显示名称、作者外链、时长、标签、简介;「开始游戏 ↗」新标签打开 `https://play2048.co/` +- `/games/hextris` → 无 intro 不显示正文区 +- `/games/not-exist` → 显示「该游戏不存在或已移除」+ 返回目录 +- 目录卡片点击进入详情,返回目录回到原筛选(URL query 保留) + +- [ ] **Step 3: 提交** + +```bash +git add src/app +git commit -m "feat: add game detail page with markdown intro and external link" +``` + +--- + +### Task 9: 文档页(左侧列表 + 右侧 TOC 滚动高亮) + +**Files:** +- Create: `src/app/components/DocSidebar.vue` +- Create: `src/app/components/DocToc.vue` +- Modify: `src/app/views/DocsView.vue`(替换临时实现) + +**Interfaces:** +- Consumes: Task 3 `repo`、Task 5 `renderMarkdown`/`extractToc`/`TocItem`、Task 7 `StatePanel`/`useAsync` +- Produces: 组件 props `DocSidebar { docs: DocMeta[]; activeSlug: string }`、`DocToc { items: TocItem[] }`;`/docs` 自动跳转第一篇 + +- [ ] **Step 1: 实现组件** + +`src/app/components/DocSidebar.vue`: + +```vue + + + +``` + +`src/app/components/DocToc.vue`: + +```vue + + + +``` + +- [ ] **Step 2: 实现 `DocsView.vue`** + +```vue + + + +``` + +- [ ] **Step 3: 验证** + +```bash +cd src +npm run typecheck && npx vitest run +npm run dev +``` + +人工验证: +- `/docs` → 自动跳转 `/docs/about`,左侧高亮「关于本站」,右侧 TOC 显示「这是什么 / 收录范围 / 免责声明」 +- 点击 TOC 项平滑滚动;滚到「收录范围」时该项文本高亮 +- `/docs/contribute` → 切换文档,TOC 更新为「提交一个游戏 / 字段说明」 +- `/docs/不存在的` → 显示加载失败 + 重试按钮 +- 刷新 `/docs/contribute` 直接进入该文档 + +- [ ] **Step 4: 提交** + +```bash +git add src/app +git commit -m "feat: add docs page with sidebar and scroll-spy table of contents" +``` + +--- + +### Task 10: 部署资产(Dockerfile / nginx / .dockerignore)+ 本地镜像验收 + +**Files:** +- Create: `deploy/Dockerfile` +- Create: `deploy/nginx.conf` +- Create: `.dockerignore`(仓库根) + +**Interfaces:** +- Consumes: Task 2 生成流程(`npm run build` 内含 prebuild)、Task 6-9 的完整站点 +- Produces: 可运行的本地镜像 `webgame-collection:local`;镜像内 nginx 配置(Task 11/12 的 CI 与 k8s 直接引用) + +- [ ] **Step 1: 写三个文件** + +`deploy/Dockerfile`: + +```dockerfile +FROM node:24-alpine AS build +WORKDIR /app +COPY src/package.json src/package-lock.json ./ +RUN npm ci +COPY src/ ./ +RUN npm run build + +FROM nginx:1.27-alpine +COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +``` + +`.dockerignore`(仓库根): + +```gitignore +.git +docs +deploy/k8s +src/node_modules +src/dist +src/public/data +**/*.log +.DS_Store +``` + +`deploy/nginx.conf`: + +```nginx +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + gzip_min_length 1024; + + location = /index.html { + add_header Cache-Control "no-cache"; + } + + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location /data/ { + add_header Cache-Control "no-cache"; + try_files $uri =404; + } + + location / { + try_files $uri /index.html; + } +} +``` + +- [ ] **Step 2: 构建并运行镜像** + +```bash +docker build -f deploy/Dockerfile -t webgame-collection:local . +docker run --rm -d -p 8080:80 --name wgc-smoke webgame-collection:local +``` + +- [ ] **Step 3: 验收(关键断言)** + +```bash +# 首页 200 +curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/ # 期望 200 +# SPA fallback:详情路由直连可打开 +curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/games/2048 # 期望 200 +# 数据文件存在且 content-type 为 JSON +curl -s -o /dev/null -w '%{http_code} %{content_type}\n' http://localhost:8080/data/index.json +# 缓存头 +curl -sI http://localhost:8080/data/index.json | grep -i 'cache-control' # 期望 no-cache +curl -sI "http://localhost:8080/$(cd src/dist && ls assets/*.js | head -1)" | grep -i 'cache-control' # 期望 immutable +# 文档数据 +curl -s http://localhost:8080/data/docs.json | head -c 200 # 期望包含 "slug": "about" +``` + +浏览器打开 `http://localhost:8080/` 快速过一遍目录页、详情页与 `/docs/about`(与 dev 一致)。 + +```bash +docker stop wgc-smoke +``` + +- [ ] **Step 4: 提交** + +```bash +git add deploy/Dockerfile deploy/nginx.conf .dockerignore +git commit -m "feat: add production docker image with nginx config" +``` + +--- + +### Task 11: CI(PR 校验 + 发布推 GHCR + keel webhook) + +**Files:** +- Create: `.github/workflows/validate.yml` +- Create: `.github/workflows/publish.yml` +- Create: `src/scripts/repo-yaml.test.ts`(校验 workflows YAML) +- Modify: `src/package.json`(加 `yaml` devDependency) + +**Interfaces:** +- Consumes: Task 2 的 `validate:data`/`check` 脚本、Task 10 的 `deploy/Dockerfile` +- Produces: 推 `main` 后镜像 `ghcr.io//webgame-collection:latest` + `sha-`;keel native webhook 调用(secrets 未配置则跳过) + +- [ ] **Step 1: 写失败测试 `src/scripts/repo-yaml.test.ts`** + +```ts +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { parse } from 'yaml' +import { describe, expect, it } from 'vitest' +import { SRC_ROOT } from './build-data.mjs' + +const REPO_ROOT = path.resolve(SRC_ROOT, '..') + +async function loadYaml(relative: string): Promise> { + return parse(await readFile(path.join(REPO_ROOT, relative), 'utf8')) +} + +describe('GitHub workflows', () => { + it('validate.yml 在 PR 上跑数据校验与完整检查', async () => { + const workflow = await loadYaml('.github/workflows/validate.yml') + expect(Object.keys(workflow.on).map(String)).toContain('pull_request') + const body = JSON.stringify(workflow) + expect(body).toContain('npm ci') + expect(body).toContain('validate:data') + expect(body).toContain('npm run check') + }) + + it('publish.yml 推到 GHCR 并调用 keel webhook', async () => { + const workflow = await loadYaml('.github/workflows/publish.yml') + const steps = workflow.jobs.publish.steps as Array> + expect(steps.some((step) => String(step.uses ?? '').startsWith('docker/build-push-action'))).toBe(true) + expect(steps.some((step) => String(step.uses ?? '').startsWith('docker/login-action'))).toBe(true) + const body = JSON.stringify(workflow) + expect(body).toContain('ghcr.io') + expect(body).toContain('keel:${KEEL_TOKEN}') + expect(body).toContain('KEEL_WEBHOOK_URL') + expect(body).toContain('deploy/Dockerfile') + }) +}) +``` + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd src +npm install -D yaml +npx vitest run scripts/repo-yaml.test.ts +``` + +期望:FAIL(workflow 文件不存在)。 + +- [ ] **Step 3: 写 workflows** + +`.github/workflows/validate.yml`: + +```yaml +name: validate + +on: + pull_request: + paths: + - 'src/**' + - '.github/workflows/validate.yml' + +jobs: + check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: src + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: src/package-lock.json + - name: Install + run: npm ci + - name: Validate game data + run: npm run validate:data + - name: Check (vitest + typecheck + build) + run: npm run check +``` + +`.github/workflows/publish.yml`: + +```yaml +name: publish + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +env: + IMAGE: ghcr.io/${{ github.repository }} + +jobs: + publish: + runs-on: ubuntu-latest + env: + KEEL_WEBHOOK_URL: ${{ secrets.KEEL_WEBHOOK_URL }} + KEEL_TOKEN: ${{ secrets.KEEL_TOKEN }} + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/metadata-action@v5 + id: meta + with: + images: ${{ env.IMAGE }} + tags: | + type=sha,format=long + type=raw,value=latest + - uses: docker/build-push-action@v6 + with: + context: . + file: deploy/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + - name: Notify keel + if: env.KEEL_WEBHOOK_URL != '' + run: | + curl -fsS -X POST "${KEEL_WEBHOOK_URL}" \ + -u "keel:${KEEL_TOKEN}" \ + -H 'Content-Type: application/json' \ + -d "{\"name\":\"${IMAGE}\",\"tag\":\"latest\"}" + - name: Keel skipped + if: env.KEEL_WEBHOOK_URL == '' + run: echo "KEEL_WEBHOOK_URL 未配置,跳过 webhook 通知(依赖 keel 轮询兜底)" +``` + +- [ ] **Step 4: 验证** + +```bash +cd src +npx vitest run scripts/repo-yaml.test.ts # 期望:全部 PASS +# 本地模拟 validate workflow 的两条命令: +npm run validate:data && npm run check # 期望:全部通过 +``` + +(发布 workflow 依赖 GitHub 远端与集群 secret,无法本地执行;Task 12 收尾时在 README 中记录远端配置步骤。) + +- [ ] **Step 5: 提交** + +```bash +git add .github src/scripts src/package.json src/package-lock.json +git commit -m "ci: validate data on PR, publish image to ghcr and notify keel on main" +``` + +--- + +### Task 12: k8s 清单 + 仓库文档(README / CONTRIBUTING / LICENSE)+ 端到端验收 + +**Files:** +- Create: `deploy/k8s/namespace.yaml` +- Create: `deploy/k8s/deployment.yaml` +- Create: `deploy/k8s/service.yaml` +- Create: `deploy/k8s/ingress.yaml` +- Modify: `src/scripts/repo-yaml.test.ts`(追加 k8s 断言) +- Create: `docs/README.md` +- Create: `docs/CONTRIBUTING.md` +- Create: `LICENSE` + +**Interfaces:** +- Consumes: Task 11 的测试文件与测试基建;Task 10 的镜像名约定 +- Produces: 可 `kubectl apply -f deploy/k8s/` 的清单;GitHub 首页 README 与贡献指南;MIT LICENSE + +- [ ] **Step 1: 追加失败测试(k8s 部分)到 `src/scripts/repo-yaml.test.ts`** + +在文件末尾追加: + +```ts +describe('k8s manifests', () => { + const files = ['namespace.yaml', 'deployment.yaml', 'service.yaml', 'ingress.yaml'] + + it('全部可解析且命名空间一致', async () => { + for (const file of files) { + const doc = await loadYaml(`deploy/k8s/${file}`) + expect(doc, `${file} 无法解析`).toBeTruthy() + if (doc.metadata?.namespace) expect(doc.metadata.namespace).toBe('webgame-collection') + else expect(doc.metadata?.name).toBe('webgame-collection') + } + }) + + it('deployment 带 keel 注解、镜像指向 ghcr、带探针与资源限制', async () => { + const deployment = await loadYaml('deploy/k8s/deployment.yaml') + const annotations = deployment.metadata.annotations + expect(annotations['keel.sh/policy']).toBe('force') + expect(annotations['keel.sh/match-tag']).toBe('true') + expect(annotations['keel.sh/trigger']).toBe('poll') + const container = deployment.spec.template.spec.containers[0] + expect(container.image).toContain('ghcr.io/') + expect(container.image.endsWith(':latest')).toBe(true) + expect(container.readinessProbe.httpGet.path).toBe('/') + expect(container.resources.requests).toBeTruthy() + expect(container.resources.limits).toBeTruthy() + }) + + it('service 指向应用端口 80', async () => { + const service = await loadYaml('deploy/k8s/service.yaml') + expect(service.spec.ports[0].port).toBe(80) + expect(service.spec.selector.app).toBe('webgame-collection') + }) +}) +``` + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd src +npx vitest run scripts/repo-yaml.test.ts +``` + +期望:k8s 部分 FAIL(文件不存在)。 + +- [ ] **Step 3: 写 k8s 清单** + +`deploy/k8s/namespace.yaml`: + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: webgame-collection +``` + +`deploy/k8s/deployment.yaml`: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: webgame-collection + namespace: webgame-collection + annotations: + keel.sh/policy: force + keel.sh/match-tag: "true" + keel.sh/trigger: poll + keel.sh/pollSchedule: "@every 5m" +spec: + replicas: 2 + strategy: + type: RollingUpdate + selector: + matchLabels: + app: webgame-collection + template: + metadata: + labels: + app: webgame-collection + spec: + containers: + - name: web + image: ghcr.io/OWNER/webgame-collection:latest + imagePullPolicy: Always + ports: + - containerPort: 80 + readinessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 2 + periodSeconds: 10 + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 200m + memory: 128Mi +``` + +(提交前把 `OWNER` 替换为实际 GitHub 用户名/组织名;这是唯一的仓库变量,CI 侧镜像名由 `github.repository` 自动推导。) + +`deploy/k8s/service.yaml`: + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: webgame-collection + namespace: webgame-collection +spec: + selector: + app: webgame-collection + ports: + - port: 80 + targetPort: 80 +``` + +`deploy/k8s/ingress.yaml`(k3s 默认 Traefik): + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: webgame-collection + namespace: webgame-collection +spec: + rules: + - host: games.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: webgame-collection + port: + number: 80 +``` + +(`host` 替换为实际域名;如需 TLS,按集群 cert-manager 配置追加 `tls` 段与 `cert-manager.io/cluster-issuer` 注解。) + +- [ ] **Step 4: 跑测试确认通过** + +```bash +cd src +npx vitest run scripts/repo-yaml.test.ts # 期望:全部 PASS +npm run check # 期望:全绿 +``` + +- [ ] **Step 5: 写仓库文档** + +`docs/README.md`: + +````md +# webgame-collection + +收集**静态网页游戏**(打开网页即玩、无需服务端)的开源目录站。 + +- 站点:Vue 3 静态构建;目录页支持搜索、按类型/时长/标签筛选与排序 +- 详情页:简介(支持 Markdown)与外链跳转 +- 文档:内置 Markdown 渲染,带文档目录(TOC) +- 部署:GitHub Actions 构建镜像 → GHCR → keel.sh webhook → k3s 滚动更新 + +## 提交一个游戏 + +1. 在 `src/games/` 新增 `.json`(`id` 仅含小写字母、数字、连字符,且与文件名一致) +2. 本地校验:`cd src && npm install && npm run validate:data` +3. 提 PR,CI 会跑数据校验与完整检查 + +字段与规则详见 [CONTRIBUTING.md](./CONTRIBUTING.md)。 + +## 本地开发 + +```bash +cd src +npm install +npm run dev # 自动生成 src/public/data +npm run check # vitest + vue-tsc + vite build +``` + +生产形态本地验收: + +```bash +docker build -f deploy/Dockerfile -t webgame-collection:local . +docker run --rm -p 8080:80 webgame-collection:local +``` + +## 部署 + +1. 推送 `main` 后 Actions 自动推送镜像 `ghcr.io//webgame-collection:{latest,sha-}`(首次需把 Package 可见性设为 public) +2. k3s:替换 `deploy/k8s/deployment.yaml` 中的 `OWNER`、`deploy/k8s/ingress.yaml` 中的 `host` 后 `kubectl apply -f deploy/k8s/` +3. keel:集群内安装 [keel.sh](https://keel.sh)(`helm upgrade --install keel --namespace=keel keel/keel --set helmProvider.enabled=false`),仓库 Secrets 配置 `KEEL_WEBHOOK_URL`(如 `http://keel.keel.svc.cluster.local:9300/v1/webhooks/native`)与 `KEEL_TOKEN`(keel 的 `TOKEN_SECRET`;keel 未开 `AUTHENTICATED_WEBHOOKS` 时留空即可) +4. 发布链路:推镜像后 Action POST keel native webhook(`{"name":"<镜像>","tag":"latest"}`),keel 滚动更新 Deployment;webhook 不可用时依赖 `@every 5m` 轮询兜底 + +设计文档:[`docs/superpowers/specs/2026-09-17-webgame-collection-design.md`](./superpowers/specs/2026-09-17-webgame-collection-design.md) +```` + +`docs/CONTRIBUTING.md`: + +````md +# 贡献指南 + +## 提交游戏 + +1. Fork 仓库并新建分支 +2. 在 `src/games/` 新增 `.json`,参考现有文件: + +```json +{ + "id": "your-game", + "name": "游戏名", + "url": "https://example.com/", + "author": { "name": "作者", "url": "https://github.com/author" }, + "description": "一句话简介(≤140 字)", + "intro": "可选,详情页展示的 Markdown 长简介", + "durationMinutes": { "min": 5, "max": 20 }, + "type": "puzzle", + "tags": ["休闲"], + "cover": "/data/assets/covers/your-game.png", + "addedAt": "2026-09-17" +} +``` + +3. 校验:`cd src && npm install && npm run validate:data`(CI 同样会跑) +4. 提 PR,描述游戏玩法与链接来源 + +## 硬性规则(CI 会拒绝) + +- `id` 必须等于文件名,仅含 `[a-z0-9-]` +- 字段不允许自创(schema 为 `additionalProperties: false`) +- `url` 与封面外链仅允许 https +- `tags` 最多 8 个、每个不超过 12 字、不允许重复 +- `type` 只能取枚举值:`puzzle | action | idle | strategy | simulation | narrative | music | creative | casual | other` +- 本地封面只能放在 `src/assets/covers/.`,并在 JSON 中写 `/data/assets/covers/.` +- `durationMinutes.max >= min` 且 `max <= 600` + +## 提交站点文档 + +- 文档放在 `src/docs/*.md`,文件名即 URL slug(`[a-z0-9-]+`) +- frontmatter 仅支持 `title`(必填)与 `order`(可选,数字,越小越靠前) +- 正文从 `##` 开始(页面标题已由 `title` 渲染) + +## 改代码 + +```bash +cd src +npm install +npm run dev # 本地开发 +npm run check # 提交前必须全绿:vitest + vue-tsc + vite build +``` + +- 数据层改动保持 `ContentRepository` 接口不变(未来接后端的迁移边界) +- 需要新字段时同时更新 `src/schema/game.schema.json`、`src/app/data/types.ts`、本文件与 spec +```` + +`LICENSE`(MIT 标准文本,Copyright 行): + +``` +MIT License + +Copyright (c) 2026 webgame-collection contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +- [ ] **Step 6: 端到端本地验收(对照 spec §11)** + +```bash +cd src +# 1. 合法数据通过 / 非法数据报错(验收 schema 严格性) +cp games/2048.json /tmp/2048.bak +node -e "const g=require('./games/2048.json'); g.id='wrong-id'; require('fs').writeFileSync('games/2048.json', JSON.stringify(g))" +npm run validate:data; echo "exit=$?" # 期望退出码 1,报错信息含 “id ... 必须等于文件名” +cp /tmp/2048.bak games/2048.json +npm run validate:data # 期望“校验通过:3 个游戏,2 篇文档” + +# 2. 全量门槛 +npm run check # 期望全绿 + +# 3. 生产形态(若本任务未重复构建,可复用 Task 10 镜像流程) +docker build -f ../deploy/Dockerfile -t webgame-collection:local .. +docker run --rm -d -p 8080:80 --name wgc-final webgame-collection:local +curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/games/2048 # 期望 200 +docker stop wgc-final +``` + +浏览器人工过一遍:目录筛选 + URL 可分享、详情页外链、`/docs/about` TOC 高亮。 + +- [ ] **Step 7: 提交** + +```bash +git add -A +git commit -m "chore: add k8s manifests, repo docs and license" +``` + +--- + +## 用户侧收尾(不在本计划自动执行,README 已记录) + +- 在 GitHub 创建仓库并推送(`gh repo create webgame-collection --public --source=. --push`) +- GHCR Package 可见性设为 public +- 集群安装 keel 并配置 secrets `KEEL_WEBHOOK_URL` / `KEEL_TOKEN` +- 替换 `deploy/k8s/deployment.yaml` 的 `OWNER` 与 `ingress.yaml` 的 `host`,`kubectl apply -f deploy/k8s/` +- 合并首个游戏 PR 验证 validate.yml;推 main 验证 publish.yml → GHCR → keel → 滚动更新 diff --git a/docs/superpowers/specs/2026-09-17-webgame-collection-design.md b/docs/superpowers/specs/2026-09-17-webgame-collection-design.md index 005e758..ae8a6e8 100644 --- a/docs/superpowers/specs/2026-09-17-webgame-collection-design.md +++ b/docs/superpowers/specs/2026-09-17-webgame-collection-design.md @@ -204,7 +204,7 @@ interface ContentRepository { `deploy/Dockerfile` 多阶段: -1. `node:22-alpine`:拷 `src/package*.json` → `npm ci` → 拷 `src/` → `npm run build` +1. `node:24-alpine`:拷 `src/package*.json` → `npm ci` → 拷 `src/` → `npm run build` 2. `nginx:alpine`:拷 `deploy/nginx.conf` 与构建产物 `dist/` `.dockerignore` 排除 `docs/`、`deploy/k8s/`、`.git`、`node_modules`、`src/dist`、`src/public/data`。 @@ -220,7 +220,7 @@ interface ContentRepository { **`validate.yml`**(PR 触发,`paths: src/**`): -1. setup-node 22 + npm cache(`cache-dependency-path: src/package-lock.json`) +1. setup-node 24 + npm cache(`cache-dependency-path: src/package-lock.json`) 2. `cd src && npm ci` 3. `npm run validate:data`(只校验,错误信息面向贡献者) 4. `npm run check`(`vue-tsc --noEmit && vitest run && vite build`) @@ -229,7 +229,7 @@ interface ContentRepository { 1. `permissions: contents: read, packages: write` 2. 登录 GHCR(`GITHUB_TOKEN`),`docker/build-push-action` 推 `sha-<短哈希>` + `latest` -3. 调 keel webhook:`POST ${{ secrets.KEEL_WEBHOOK_URL }}`(token 在 body/header,按集群 keel 版本约定);secret 未配置则跳过并 log 提示(依赖轮询兜底) +3. 调 keel native webhook:POST `${{ secrets.KEEL_WEBHOOK_URL }}`(`/v1/webhooks/native`),body `{"name":"<镜像名>","tag":"latest"}`;keel 开启 `AUTHENTICATED_WEBHOOKS` 时带 Basic auth(用户名 `keel`,密码 `KEEL_TOKEN`);secret 未配置则跳过并 log 提示(依赖轮询兜底) 4. `concurrency` 保证同时间仅一次发布 ### 8.4 k3s 清单(`deploy/k8s/`) @@ -237,12 +237,13 @@ interface ContentRepository { - `namespace.yaml`、`deployment.yaml`:2 副本、RollingUpdate、readinessProbe GET `/`、资源 requests cpu 10m / 内存 16Mi,limits cpu 200m / 内存 128Mi - `service.yaml`:ClusterIP:80 - `ingress.yaml`:k3s 默认 Traefik;host 与 TLS 用占位注释,按实际集群填 -- keel 注解(Deployment): +- keel 注解(Deployment,依据 keel 官方文档:`latest` 这类可变标签更新用 `force` + `match-tag` 对比 manifest digest;设置 `trigger: poll` 后 webhook 事件仍然有效): ```yaml keel.sh/policy: force -keel.sh/trigger: webhook # 未配 webhook 时退回轮询 -keel.sh/pollSchedule: "@every 1m" +keel.sh/match-tag: "true" +keel.sh/trigger: poll # 轮询兜底(webhook 未配置/失败时) +keel.sh/pollSchedule: "@every 5m" ``` - 不引入 HPA(纯静态 nginx)