# Yxz Game Site Template Maker

> 把跑通的第一个游戏攻略站打磨成可复用模板。基于「三层分离」原则（框架层/配置层/内容层），把硬编码的游戏信息抽进配置、内容改成动态读取，换新游戏只动配置+内容、框架一行不改。当需要把已有站点模板化、批量复制出多个站时调用。是 yxz-game-site-builder 的下游技能。

- Skill: `byte886/yxz-game-site-template-maker` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add byte886/yxz-game-site-template-maker`
- Raw SKILL.md: https://api.skillmd.com/api/skills/byte886/yxz-game-site-template-maker/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: byte886 (https://skillmd.com/u/byte886)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/byte886/yxz-game-site-template-maker

---


# 游戏站模板化器 (Game Site Template Maker)

## 核心原则

**三层分离，是模板化的唯一心法。** 一套框架要服务好几个不同的游戏，每个游戏有不同的内容分类、主题色、官方链接、文章。如果全揉在一起，换一个游戏就要把代码翻一遍，又慢又容易改漏。把一个站拆成三层，各管各的：

| 层 | 内容 | 改动频率 | 模板化目标 |
|----|------|----------|-----------|
| **框架层** | 整体布局、页面结构、组件、技术栈、SEO/多语言生成逻辑 | 一次写好，以后基本不动 | 所有站共用的"地基" |
| **配置层** | 游戏名、主题色、官方链接、导航分类、SEO 标题描述、支持语言 | 每开一个新站，改几个地方 | 集中放进几个配置文件 |
| **内容层** | 所有内页文章、首页各模块文案数据、各语言翻译 | 每个站完全独立替换 | 动态读取，新增/删除自动跟随 |

**一句话：改内容不动代码，改配置不重写框架。换新游戏只动配置层和内容层，框架层一行不改。**

**模板成了没的验证标准**（来自源文档）：换个游戏名，看看是不是只动配置 + 固定提示词，就能让整站切过去。能做到，模板就成了。这是本技能唯一的"毕业考试"，必须实测通过。

**本技能由 AI 直接执行重构。** AI 自主完成：扫描现有站点 → 分类三层 → 抽取配置 → 改造动态读取 → 换皮验证 → 封装模板。不需要用户手动改代码。

---

## 前置条件

用户需提供：
- **第一个游戏站的项目路径**（已用 `yxz-game-site-builder` 跑通、能正常 `npm run build` 出 `out/` 的 Next.js 项目）
- **该站的游戏名**（用于识别哪些是"游戏特定信息"需要抽进配置）
- **对标站游戏名**（若复刻时参照了对标站，需告知以便一并清理残留）

### 技能关系

```
yxz-hot-word-miner      → 选定游戏主词
  ↓
yxz-keyword-miner       → 产出关键词清单 + 页面矩阵
  ↓
yxz-game-info-collector → 产出首页信息 + 内页素材
  ↓
yxz-game-site-builder   → 搭出第一个站（单站，硬编码）
  ↓
yxz-game-site-template-maker  ← 本技能：把第一个站打磨成模板（本技能）
  ↓
（后续）新站只需：复制模板 + 改配置 + 换内容 → 直接上线
  ↓
（站内放大）yxz-bulk-page-factory ← 模板成型后，把页面矩阵/GSC补页面清单批量铺成内页，放大单站流量
```

> 本技能是 `yxz-game-site-builder` 的下游。前者负责"做一个站"，本技能负责"把一个站变成十个站的模板"。是路线 A「批量放大」的落点。

---

## 工具与数据获取方式

**文件操作**：`Read` 读取项目源码与素材文档；`Grep` 全项目扫描硬编码值与残留；`Glob` 按模式列出文件；`Edit` 重构代码（把硬编码改为读配置）；`Write` 生成配置文件、模板文档、脚手架。

**命令执行**：`Bash` 执行 `npm run build`（注意绕过 WorkBuddy 构建挂死，见踩坑）、`git` 命令、Python 脚本。

**Python 运行时**：脚本均为纯 Python 标准库，无第三方依赖。Managed 解释器路径为 `C:/Users/Brahma/.workbuddy/binaries/python/versions/3.13.12/python.exe`（若 `envs/default/python.exe` 这个 venv 不存在，直接用带版本号的解释器即可，无需 `pip install`）。

**浏览器操作**（仅换皮验证阶段可选）：`mcp__playwright-extension__browser_*` 访问本地静态服务，确认换皮后页面正常渲染。本地静态服务用 `python -m http.server <port> --directory out --bind 127.0.0.1`。

---

## 执行流程

### 第一阶段：三层分类扫描（AI 自动执行）

AI 扫描现有项目，把每一个"值"分类到三层之一，产出分类清单。这是后续重构的依据。

#### 步骤 1.1：识别框架层（冻结不动）

框架层是"换游戏不会变"的部分。AI 用 `Glob` + `Read` 通读项目，标记以下为框架层：

- **技术栈**：`package.json`、`next.config.js/mjs`、`tsconfig.json`、构建配置
- **全局布局**：`app/layout.tsx`（结构骨架，但其中的游戏名/导航文案属配置层，需抽出）
- **组件结构**：`components/` 下各组件的**结构**（不是其中写死的文案）
- **路由生成逻辑**：`app/[lang]/[slug]/page.tsx` 的动态路由读取逻辑
- **SEO/sitemap 生成逻辑**：`lib/seo.ts`、`next-sitemap.config.js` 的**生成函数**（不是写死的标题）
- **样式骨架**：`app/globals.css` 的设计 token 结构（变量名），但变量**值**属配置层
- **构建脚本**：`scripts/` 下的 mirror/validate 脚本

**判定规则**：问自己"换一个游戏，这个文件/这段代码要不要改？"——不要改的就是框架层。

#### 步骤 1.2：识别配置层（需抽取）

配置层是"换游戏就变、但量很小"的信息。AI 用 `Grep` + 人工判断，标记以下为配置层：

| 配置项 | 常见硬编码位置 | 示例 |
|--------|---------------|------|
| 游戏名 | `app/layout.tsx`、`components/Hero.tsx`、`components/Footer.tsx`、`lib/seo.ts` | "Big Walk" |
| 主题色 HSL | `app/globals.css` 的 `:root` 变量、组件内联 style | `--primary: 25 95% 53%` |
| 官方链接 | `components/Footer.tsx`、`components/Sidebar.tsx` | 官网/Discord/YouTube URL |
| 导航分类 | `components/Navbar.tsx` | Guide/Classes/Codes/Tier List |
| SEO title/description/keywords | `lib/seo.ts`、各页 `metadata` 导出 | 含游戏名的标题 |
| 支持语言 | `content/i18n.json`、`next.config.js` i18n 配置 | `['en','de','es']` |
| 首页 Hero stats | `components/Hero.tsx` | 发行日期/Metacritic/玩家数 |
| 兑换码 | `components/Sidebar.tsx` | 真实码或"暂无" |
| 底部号召文案 | `app/page.tsx` | CTA 文字 |

**判定规则**：问自己"换游戏时，这个值要不要换？"——要换的就是配置层。

#### 步骤 1.3：识别内容层（需独立化）

内容层是"每个站完全不一样、量大"的部分：

- **内页文章**：`content/en/*.mdx`（每个关键词一篇文章）
- **首页模块文案数据**：新手引导卡片内容、游戏介绍段落（若写在 `app/page.tsx` 里则属硬编码，需抽到内容层）
- **多语言翻译**：`content/de/`、`content/es/` 等

**判定规则**：问自己"换游戏时，这部分是不是要完全重新写？"——是的就是内容层。

#### 步骤 1.4：产出三层分类清单

AI 把扫描结果写成 `{项目目录}/_template_audit.md`：

```markdown
# 三层分类审计 - {游戏名}

## 框架层（冻结不动）
- app/layout.tsx（结构骨架）
- components/Navbar.tsx（结构）
- ...

## 配置层（需抽取 → site.config.json）
| 配置项 | 当前硬编码位置 | 当前值 |
|--------|---------------|--------|
| gameName | app/layout.tsx:12 | Big Walk |
| theme.primary | app/globals.css:3 | 25 95% 53% |
| officialLinks.homepage | components/Footer.tsx:8 | https://bigwalkgame.com |
| ...

## 内容层（需动态读取）
- content/en/guide.mdx
- content/en/tips.mdx
- （首页新手引导卡片文案目前硬编码在 app/page.tsx:45-60，需抽出）
```

---

### 第二阶段：配置层抽取（AI 重构代码）

把步骤 1.2 识别的所有配置层值，从代码里抽出来集中到一个配置文件，代码改为"读配置"不"写死"。

#### 步骤 2.1：定义配置文件 `site.config.json`

AI 在项目根目录创建 `site.config.json`，结构如下（所有游戏特定信息的唯一真相源）：

```json
{
  "gameName": "Big Walk",
  "gameSlug": "your-game",
  "tagline": "Co-op Adventure Guide",
  "theme": {
    "primary": "25 95% 53%",
    "primaryForeground": "0 0% 100%",
    "background": "0 0% 100%",
    "foreground": "222 47% 11%",
    "muted": "210 40% 96%",
    "border": "214 32% 91%",
    "accent": "142 71% 45%"
  },
  "officialLinks": {
    "homepage": "https://bigwalkgame.com",
    "discord": "https://discord.gg/bigwalk",
    "youtube": "https://youtube.com/@bigwalk",
    "steam": "https://store.steampowered.com/app/xxx"
  },
  "nav": [
    { "label": "Guide", "href": "/guide" },
    { "label": "Classes", "href": "/classes" },
    { "label": "Codes", "href": "/codes" },
    { "label": "Tier List", "href": "/tier-list" }
  ],
  "seo": {
    "title": "Big Walk Wiki & Guide | Tips, Codes, Classes",
    "description": "Complete Big Walk guide with beginner tips, all class rankings, active codes and co-op walkthroughs.",
    "keywords": "big walk guide, big walk wiki, big walk codes, big walk classes"
  },
  "languages": ["en", "de", "es"],
  "hero": {
    "title": "Big Walk Wiki",
    "subtitle": "Your complete guide to the co-op adventure",
    "stats": [
      { "label": "Release", "value": "2025" },
      { "label": "Metacritic", "value": "82" },
      { "label": "Steam Reviews", "value": "Mostly Positive" },
      { "label": "Players", "value": "10K+" },
      { "label": "Achievements", "value": "40" }
    ]
  },
  "sidebar": {
    "codes": [
      { "code": "BIGWALK2025", "reward": "500 Coins", "status": "active" }
    ]
  },
  "cta": {
    "title": "Start Your Adventure",
    "text": "Pick a guide below and jump in.",
    "buttonText": "Read the Beginner Guide"
  }
}
```

> **关键**：这是换新站时**唯一**需要改的文件（内容层另算）。所有游戏特定信息都在这里，代码里不允许再出现任何游戏名、主题色、官方链接的硬编码。

#### 步骤 2.2：重构代码读配置

AI 用 `Edit` 逐个重构，把硬编码改为读 `site.config.json`：

```tsx
// lib/config.ts —— 配置加载工具（框架层，所有站共用）
import config from "../site.config.json";
export const siteConfig = config;
export const { gameName, theme, officialLinks, nav, seo, hero, sidebar, cta } = config;
```

```tsx
// app/layout.tsx —— 改造前
export const metadata = { title: "Big Walk Wiki", ... };
// 改造后
import { seo, gameName } from "@/lib/config";
export const metadata = { title: seo.title, ... };

```

```tsx
// components/Hero.tsx —— 改造前
<h1>Big Walk Wiki</h1>
<p>Your complete guide to the co-op adventure</p>
// 改造后
import { hero } from "@/lib/config";
<h1>{hero.title}</h1>
<p>{hero.subtitle}</p>
```

```css
/* app/globals.css —— 主题色集中到变量，值来自配置 */
/* 改造前：:root { --primary: 25 95% 53%; } 写死 */
/* 改造后：CSS 变量名是框架层，值通过内联 style 从配置注入 */
```

```tsx
// app/layout.tsx —— 主题色从配置注入到 CSS 变量
import { theme } from "@/lib/config";
export default function RootLayout({ children }) {
  return (
    <html lang="en" style={{
      "--primary": theme.primary,
      "--primary-foreground": theme.primaryForeground,
      "--background": theme.background,
      // ...
    } as React.CSSProperties}>
      <body>{children}</body>
    </html>
  );
}
```

**重构检查清单**（AI 用 `Grep` 验证零硬编码）：

```
# 游戏名硬编码（应只剩 site.config.json + lib/config.ts 两处）
Grep(pattern="Big Walk", path="{项目目录}", glob="*.{tsx,ts,css,json,mdx}")
# 期望：只有 site.config.json 命中

# 主题色硬编码（globals.css 的 :root 不应再有具体 HSL 值）
Grep(pattern="--primary:\s*\d", path="{项目目录}/app/globals.css")

# 官方链接硬编码
Grep(pattern="bigwalkgame\.com|discord\.gg/bigwalk", path="{项目目录}")
```

#### 步骤 2.2b：可见文案的占位符替换（换皮真正生效的机制）

光把配置拆出来还不够。换皮验证是**扫产物 HTML 找真实游戏名残留**（见第四阶段），而配置里里外外到处是游戏名。要让"换游戏只动配置"成立，必须区分两类字符串：

- **SEO 元数据**（`metadata.title` / `description` / `keywords`、sitemap/robots 文案）：这些是**给搜索引擎看的真实字段**，应保留**字面游戏名**（对真实站是正确的；换皮测试时会被虚拟配置整文件覆盖，所以不会残留）。
- **可见正文**（hero 标题、About 段落、四塔卡片、页面 H1/lede、footer 文案）：这些是**给人看的渲染文案**，必须用占位符 `{gameName}` / `{developer}` / `{publisher}`，运行时由 `gt()` 替换成配置值。**不能写死游戏名**——否则换皮后产物 HTML 里仍残留旧名，测试 FAIL。

**配置加载器（框架层，所有站共用）**——注意 import 绑定名不要和导出 const 同名（同名会触发 `Identifier 'siteConfig' has already been declared` 的构建报错）：

```tsx
// lib/config.ts  ← 推荐 JS 版 lib/config.js
import rawConfig from "../site.config.json";   // 绑定名用 rawConfig，避开冲突
export const siteConfig = rawConfig;
export const { gameName, gameSlug, domain, developer, publisher, tagline,
              theme, officialLinks, seo, nav, hero, about, fourTowers,
              startJourney, finalCta, footer, pages } = siteConfig;

// 占位符替换：把 "{gameName}" 等就地展开成当前配置值
export function gt(text: unknown): string {
  if (!text) return text as string;
  return String(text)
    .split("{gameName}").join(gameName)
    .split("{developer}").join(developer)
    .split("{publisher}").join(publisher);
}
export default siteConfig;
```

**配置里的可见字符串一律写占位符**：

```jsonc
// site.config.json
{
  "gameName": "Big Walk",
  "hero":  { "title": "{gameName} Wiki", "subtitle": "Your complete {gameName} guide" },
  "about": { "title": "What is {gameName}?", "paragraphs": ["{gameName} is made by {developer} and published by {publisher}."] },
  "pages": {
    "guide": { "h1": "Complete {gameName} Guide", "lede": "Everything about {gameName}." }
  }
}
```

**渲染时两路都过 `gt()`**：

```tsx
// app/page.tsx
<h1>{gt(hero.title)}</h1>        {/* "{gameName} Wiki" → "Big Walk Wiki" */}
<p>{gt(hero.subtitle)}</p>

// components/Sections.jsx 的 PageHead/SectionTitle/QuickFacts/FinalCta 全部用 gt() 包裹文案
// 内页：title/lede 从配置读，含占位符
<PageHead title={pages["guide"].h1} lede={pages["guide"].lede} />   {/* 经 gt() 渲染 */}
```

**正文里嵌游戏名用 JSX Token 组件**（避免和周围文字的空格/标点拼接出错）：

```tsx
// components/Tokens.tsx
import { gameName, developer, publisher } from "@/lib/config";
export function GameName({ children }: { children?: React.ReactNode }) {
  return <>{gameName}{children}</>;
}
export function Developer({ children }: { children?: React.ReactNode }) {
  return <>{developer}{children}</>;
}
export function Publisher({ children }: { children?: React.ReactNode }) {
  return <>{publisher}{children}</>;
}
```

```tsx
// 页面正文
<p><GameName /> is developed by <Developer /> and published by <Publisher />.</p>
<p>Open <GameName />'s settings to change the language.</p>   {/* 所有格 's 紧贴组件不换行 */}
```

> **核心记忆点**：换皮 = 把"真实游戏名"从所有可见文案里抽成 `{gameName}` 占位符；`gt()` + Token 组件在渲染时展开。这样换配置时整站文案自动跟随，**不需要改任何一格代码**，产物 HTML 里自然零残留。

#### 步骤 2.3：导航/路由动态化

导航菜单从配置读（已在 2.2 完成）。路由结构改为根据内容目录自动生成：

```
# 改造前：导航和路由是写死的
app/guide/page.tsx
app/classes/page.tsx

# 改造后：动态路由根据 content/ 目录自动生成
app/[lang]/[slug]/page.tsx  ← 读取 content/{lang}/*.mdx，新增/删除文章自动跟随
```

AI 检查 `app/[lang]/[slug]/page.tsx` 的 `generateStaticParams` 是否扫描 `content/` 目录：

```tsx
// lib/content.ts —— 框架层，根据 content/ 目录动态生成路由参数
import fs from "fs";
import path from "path";

export function getAllSlugs(lang = "en") {
  const dir = path.join(process.cwd(), "content", lang);
  if (!fs.existsSync(dir)) return [];
  return fs.readdirSync(dir)
    .filter(f => f.endsWith(".mdx"))
    .map(f => f.replace(/\.mdx$/, ""));
}

export async function getArticle(slug, lang = "en") {
  const file = path.join(process.cwd(), "content", lang, `${slug}.mdx`);
  // ...读取并解析 MDX
}
```

#### 步骤 2.4：SEO 文件（sitemap/robots/manifest）生成

> **血的教训（本技能实战踩坑）**：Next.js 14 + `output: "export"` 下，`app/sitemap.js` / `app/robots.js` / `app/manifest.js` 这类 **metadata route 会要求 `generateStaticParams()`**，在 `next build` 里能生成静态文件，但**在 `next dev` 下直接 HTTP 500**。当你"把站跑起来验证"（第四阶段 4.4 或用户要求本地起服务）时，`/sitemap.xml`、`/robots.txt`、`/manifest.webmanifest` 全挂，体验极差。
>
> **模板化正确做法：不要依赖 metadata route，改用静态生成脚本**。用一个 `scripts/gen-seo.mjs`（纯 Node，无依赖）从 `site.config.json` 生成 `public/sitemap.xml` + `public/robots.txt`，再用 `package.json` 的 `predev`/`prebuild` 钩子自动跑。静态文件在 `next dev` 和 `next build` 下都正常（build 会原样拷进 `out/`）。PWA manifest 同理——用 `public/favicons/site.webmanifest` 静态文件，别用 `app/manifest.js`。

`scripts/gen-seo.mjs`（已随技能分发，复制进模板的 `scripts/` 即可）：

```js
// scripts/gen-seo.mjs —— 从 site.config.json 生成 public/sitemap.xml + public/robots.txt
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, "..");
const cfg = JSON.parse(fs.readFileSync(path.join(root, "site.config.json"), "utf-8"));

const base = process.env.BASE_URL || `https://${cfg.domain}`;
const slugs = new Set(Object.keys(cfg.pages || {}));
for (const row of cfg.nav || []) {
  const href = row?.href || row?.url;
  if (href && href.startsWith("/") && href.length > 1) slugs.add(href.replace(/^\//, "").replace(/\/$/, ""));
}
const urls = [`${base}/`, ...[...slugs].map(s => `${base}/${s}`)];
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset ...>${urls.map(u => `  <url><loc>${u}</loc></url>`).join("\n")}</urlset>`;
const robots = `User-Agent: *\nAllow: /\n\nSitemap: ${base}/sitemap.xml\n`;
fs.writeFileSync(path.join(root, "public", "sitemap.xml"), xml, "utf-8");
fs.writeFileSync(path.join(root, "public", "robots.txt"), robots, "utf-8");
```

`package.json` 钩子（确保每次 dev/build 前都刷新）：

```json
{
  "scripts": {
    "predev":   "node scripts/gen-seo.mjs",
    "dev":      "next dev",
    "prebuild": "node scripts/gen-seo.mjs",
    "build":    "next build",
    "gen-seo":  "node scripts/gen-seo.mjs"
  }
}
```

> 若原站已有 `app/sitemap.js` / `app/robots.js` / `app/manifest.js`，用 `mv` 移出 `app/`（见踩坑 #8，`rm` 会被安全删除 shim 拦截），改由静态文件 + 脚本提供。SEO 元数据（`title`/`description`/`keywords`）仍从 `site.config.json` 的 `seo` 字段读，不受影响。

---

### 第三阶段：内容层独立化

确保内容层完全独立——新增/删除一篇文章，前端自动跟随，不用手动改任何代码。

#### 步骤 3.1：内容目录结构标准化

```
content/
├── en/
│   ├── guide.mdx
│   ├── tips.mdx
│   ├── codes.mdx
│   └── ...
├── de/
│   └── (同结构，德语翻译)
└── i18n.json    ← 语言映射，从 site.config.json.languages 派生
```

#### 步骤 3.2：首页模块文案抽到内容层

若首页新手引导卡片、游戏介绍段落的文案硬编码在 `app/page.tsx`，AI 抽到 `content/en/home.json`：

```json
// content/en/home.json
{
  "beginnerCards": [
    { "title": "Beginner Guide", "desc": "First 2 hours walkthrough", "href": "/guide" },
    { "title": "Best Classes", "desc": "Rankings for every playstyle", "href": "/classes" }
  ],
  "aboutGame": "Big Walk is a co-op adventure where you..."
}
```

```tsx
// app/page.tsx 改造后
import home from "@/content/en/home.json";
{home.beginnerCards.map(card => <Card {...card} />)}
```

#### 步骤 3.3：验证内容动态跟随

AI 新增一个测试文章 `content/en/test-dynamic.mdx`，确认：
1. 不改任何其他代码
2. `npm run build` 后 `out/en/test-dynamic/index.html` 自动生成
3. 导航/ sitemap 自动包含该页

验证通过后删除测试文章。

---

### 第四阶段：换皮验证（毕业考试）

这是源文档定义的"模板成了没"的验证：换个游戏名，只动配置 + 固定提示词，让整站切过去。

#### 步骤 4.1：准备虚拟游戏配置

AI 创建一个虚拟游戏的配置 `site.config.test.json`（用一个明显是测试的名字，如 "TestGame XYZ"）：

```json
{
  "gameName": "TestGame XYZ",
  "gameSlug": "testgame-xyz",
  "theme": { "primary": "280 80% 55%", ... },
  "officialLinks": { "homepage": "https://example.com", ... },
  "nav": [ { "label": "Guide", "href": "/guide" } ],
  "seo": { "title": "TestGame XYZ Wiki | Guide & Tips", ... },
  ...
}
```

#### 步骤 4.2：应用虚拟配置并构建

```
# 1. 备份真实配置
cp site.config.json site.config.real.bak

# 2. 替换为虚拟配置
cp site.config.test.json site.config.json

# 3. 构建（绕过 WorkBuddy 挂死）
NODE_OPTIONS="" npm run build

# 4. 用自带脚本扫描残留（--no-seo 跳过 SEO 长度噪声，只看换皮残留）
python scripts/swap_test.py out/ --real-game "Big Walk" --ref-game "farever" --no-seo

# 5. 恢复真实配置
cp site.config.real.bak site.config.json
```

#### 步骤 4.3：换皮测试通过标准

`scripts/swap_test.py` 扫描 `out/` 全部 HTML，断言：

| 检查项 | 要求 | 说明 |
|--------|------|------|
| 真实游戏名残留 | 0 处 | "Big Walk" 不应出现在任何产物 HTML（说明框架层无硬编码） |
| 对标站游戏名残留 | 0 处 | "farever" 等不应残留 |
| 虚拟游戏名出现 | ≥1 处 | "TestGame XYZ" 应出现（说明配置已生效） |
| 主题色已切换 | 是 | 产物 CSS 中 primary 应是虚拟配置的紫色，不是原来的橙色 |
| CJK 泄漏 | 0 处 | 英文站无中文 |
| 占位符 | 0 处 | 无 {GAME_NAME}/TODO/WIP |
| 内页可访问 | 全部 200 | 所有 slug 页面正常渲染 |

**全部通过 = 模板成了。** 任一失败，AI 回到第二/三阶段排查硬编码残留，修复后重测。

**实战要点：**
- **用 `--no-seo` 拿干净闸门**。虚拟测试配置里的 title/description/keywords 往往是占位短句，`swap_test.py` 的 SEO 长度检查（`title≤60` / `desc 140-160` / `kw≤100`）会刷出一堆 **WARN**——这些是**换皮无关噪声**，不是框架层缺陷。跑毕业考试用 `swap_test.py out/ --real-game "Big Walk" --old-name "House House" --old-name "Panic" --test-game "TestGame XYZ 7841" --no-seo`，只看真实名/对标名/CJK/占位符零残留 + 虚拟名出现，exit 0 即 "TEMPLATE IS READY."。
- **虚拟名要明显到不像真词**：用 "TestGame XYZ 7841" 这类带随机后缀的，避免和真实代码/库名撞车导致误判。
- **常见英文短语会误报**：`swap_test.py` 是子串/词匹配，`"still got a big walk to go"` 这种双关句会命中 "Big Walk"，`"Don't panic"` 会命中 "Panic"。每一条命中**都必须人工复核**：确属游戏名残留就修；确属普通英文就改写措辞（如 "big walk to go"→"long way to go"、"Don't panic"→"Don't freak out"）。
- `--no-placeholder` / `--no-color` 也可按需关闭对应检查（占位符 WARN、主题色 INFO）。

#### 步骤 4.4：（可选）浏览器渲染验证

```
# 起静态服务
python -m http.server 4321 --bind 127.0.0.1 --directory out

# 用 Playwright MCP 访问，确认虚拟配置已生效
browser_navigate("http://127.0.0.1:4321/")
browser_take_screenshot(...)  # 标题应是 TestGame XYZ，主题色应是紫色
```

---

### 第五阶段：模板封装与文档

换皮验证通过后，AI 把项目封装成可复用模板，并产出新站脚手架。

#### 步骤 5.1：清理真实数据，写入模板示例配置

```
# 1. 配置文件保留为"示例配置"（带注释说明每项含义）
#    site.config.json 改名为 site.config.example.json，游戏名改为占位 "YOUR_GAME_NAME"

# 2. content/ 下保留 1-2 篇示例文章（占位内容），删除真实内容
#    content/en/guide.mdx → 改为占位示例

# 3. 恢复 site.config.json 为示例配置
cp site.config.example.json site.config.json
```

#### 步骤 5.2：编写模板 README

AI 用 `Write` 生成 `TEMPLATE_README.md`：

```markdown
# {游戏名} Wiki —— 站点模板

这是一个三层分离的游戏攻略站模板。开新站只需 3 步：

## 快速开新站

1. 复制本模板到新目录
2. 编辑 `site.config.json`，填入新游戏信息
3. 把内页文章放进 `content/{lang}/`，执行 `new_site.py` 校验

## 三层结构

- **框架层**（不要动）：app/layout.tsx, components/, lib/, 构建配置
- **配置层**（改这里）：site.config.json
- **内容层**（换这里）：content/{lang}/*.mdx

## 配置项说明

| 字段 | 含义 | 示例 |
|------|------|------|
| gameName | 游戏全名 | "Big Walk" |
| theme.primary | 主题色 HSL | "25 95% 53%" |
| ...

## 换皮验证

改完配置后，跑换皮测试确认零残留：
  python scripts/swap_test.py out/ --real-game "旧游戏名"
```

#### 步骤 5.3：新站脚手架脚本

AI 确认 `scripts/new_site.py` 可用（本技能自带，见下文"复用脚本"）。开新站时：

```
python scripts/new_site.py --template {模板目录} --dest {新站目录} --config {新游戏配置.json}
```

脚本自动：复制框架层 → 写入新配置 → 创建空 content 目录 → 首次构建校验。

---

## 复用脚本

- `scripts/extract_config.py <project_dir> [--game-name X]`：扫描现有项目，识别硬编码的游戏特定信息（游戏名、主题色、官方链接、导航分类），产出候选 `site.config.json` + 硬编码位置清单 `_template_audit.md`。第一阶段扫描用。
- `scripts/swap_test.py <out_dir> --real-game <真实游戏名> [--ref-game <对标站名>] [--old-name <旧名>] [--test-game X] [--no-seo] [--no-placeholder] [--no-color]`：换皮测试闸门。扫描 build 产物 HTML，断言真实游戏名/对标站名/CJK/占位符零残留 + 虚拟游戏名已生效 + 主题色已切换。第四阶段毕业考试用。跑毕业考试加 `--no-seo` 拿干净闸门（跳过 SEO 长度 WARN 噪声）。
- `scripts/new_site.py --template <模板目录> --dest <新站目录> --config <新配置.json>`：从模板脚手架新站。复制框架层、写入配置、初始化 content 目录、首次构建校验。必填字段从模板自身 config 派生（不写死）。第五阶段及后续开新站用。
- `scripts/gen-seo.mjs`（纯 Node，无依赖）：从 `site.config.json` 生成 `public/sitemap.xml` + `public/robots.txt`。**替代 `app/sitemap.js`/`app/robots.js`**（后者在 `output:"export"` + `next dev` 下 500）。`package.json` 用 `predev`/`prebuild` 钩子自动跑。

> Python 脚本均纯标准库，用 managed 解释器运行：`C:/Users/Brahma/.workbuddy/binaries/python/versions/3.13.12/python.exe scripts/xxx.py ...`（若 `envs/default/python.exe` venv 不存在，用带版本号的解释器即可）。

---

## 执行要点

1. **三层分离是唯一心法**：框架层冻结、配置层集中、内容层动态。每改一处先问"这属哪一层"。
2. **配置层是唯一真相源**：所有游戏特定信息只活在 `site.config.json`，代码里零硬编码。换新站只改这一个文件。
3. **内容层动态读取**：新增/删除文章不改任何代码，前端自动跟随。用 `generateStaticParams` 扫描 content 目录。
4. **主题色集中管理**：CSS 变量名是框架层，变量值从配置注入。换皮只改 `site.config.json` 的 `theme` 字段。
5. **毕业考试必测**：用虚拟游戏名换皮，build 后扫描产物零真实游戏名残留。不测不算模板成。
6. **扫产物不扫源码**：源码合规不代表产物合规（build 会把残留带进 HTML，CSS 注释里的游戏名会泄漏）。用 `swap_test.py` 扫 `out/*.html`。
7. **AI 直接执行重构**：扫描 → 抽配置 → 改代码 → 换皮验证 → 封装，全程 AI 自主，不需用户手动改代码。
8. **不破坏现有站**：重构前 `cp -r` 整站备份，重构后换皮验证通过再删备份。

---

## 浏览器抓取要点

1. **换皮验证可选浏览器渲染**：静态扫描（`swap_test.py`）通过后，可选起服务用 Playwright MCP 截图确认主题色/标题已切换。
2. **统一用 `mcp_playwright-extension`**：复用已装 Chrome，不另下 Chromium。
3. **两种起服务方式**：
   - **A. `next dev`（推荐，可交互验证）**：`NODE_OPTIONS="" npx next dev -p 3000`，访问 `http://localhost:3000/`。前提是按步骤 2.4 把 sitemap/robots/manifest 改成静态生成——否则 `/sitemap.xml` 在 dev 下 500。改完源码 dev 会热更新，最适合"把站跑起来看效果"。
   - **B. 静态产物**：`python -m http.server <port> --directory out --bind 127.0.0.1`，再 navigate `http://127.0.0.1:<port>/`。适合只验证 build 产物。
   - **MCP 禁 `file://`**：必须用 http 服务，不要直接打开本地 html。
4. **截图限 MCP 工作目录**：`browser_take_screenshot` 落在 MCP runtime 目录（如 `C:/Users/Brahma/.workbuddy/logs/mcp-runtime/custom-mcp_playwright-extension-*/`），用 `Read` 直接看那个路径即可，或 `cp` 出来。
5. **路由验证用 `navigate()` 别用点导航**：Playwright 点顶部 `<a>` 偶发 actionability timeout——常见于原站残留的 off-screen 浮动元素（`button`/`generic` 在 `[-20,0]` 之类坐标）干扰稳定判定。直接 `browser_navigate("http://localhost:3000/<slug>")` 验证每个路由 200 + 标题正确，比点导航稳。
6. **dev 缓存坏了先清 `.next`**：改了路由/配置后若 dev 报 `MODULE_NOT_FOUND` 之类诡异错，杀掉 3000 端口进程，`mv .next .next.bak.<日期>`（用 `mv` 不用 `rm`），重启 `next dev`。

---

## 输出文件

执行完成后产出：

1. **模板化的 Next.js 项目**（原项目原地重构）
   - 框架层代码零硬编码（所有游戏特定信息已抽进配置）
   - `site.config.json` 作为唯一配置真相源
   - `content/{lang}/` 动态读取，新增/删除文章自动跟随
   - 导航/路由/sitemap/SEO 全部根据配置 + 内容自动生成
   - `TEMPLATE_README.md` 说明如何开新站

2. **三层分类审计文档**
   - `_template_audit.md`：记录每个值归哪一层、硬编码位置、重构方案

3. **换皮测试报告**
   - `_swap_test_report.txt`：虚拟游戏名换皮结果，证明框架层零残留

4. **配套脚本**（已自带，随模板分发）
   - `scripts/extract_config.py`：配置抽取
   - `scripts/swap_test.py`：换皮闸门
   - `scripts/new_site.py`：新站脚手架

5. **示例配置**
   - `site.config.example.json`：带注释的配置模板，开新站时复制改名填值

6. **模板级 `.gitignore`**（随模板分发，只提交源码）
   - 忽略：`node_modules/`、`.next/`、`.next.bak*/`、`out/`、`backup_*/`、`*.bak`、`site.config.*.bak`、`site.config.test.json`、`public/sitemap.xml`、`public/robots.txt`（脚本生成，不提交）、`.env*.local`、`.DS_Store`。
   - 提交：`app/`、`components/`、`lib/`、`scripts/`、`site.config.json`（真实配置）、`site.config.example.json`、`public/` 下非生成的素材。
   - 见本技能实战：临时备份 `backup_*/`、构建产物 `out/`/`.next/`、换皮测试配置 `site.config.test.json`、脚本生成的 `sitemap.xml`/`robots.txt` 都不应入库。

### 三层分类审计文档结构

```markdown
# 三层分类审计 - {游戏名}

> 审计日期：{日期}
> 项目路径：{路径}

## 框架层（冻结不动）
| 文件 | 说明 |
|------|------|
| app/layout.tsx | 全局布局骨架 |
| components/Navbar.tsx | 导航栏结构 |
| ...

## 配置层（→ site.config.json）
| 配置项 | 当前硬编码位置 | 当前值 | 配置字段路径 |
|--------|---------------|--------|-------------|
| 游戏名 | app/layout.tsx:12 | Big Walk | gameName |
| 主题色 | app/globals.css:3 | 25 95% 53% | theme.primary |
| 官网链接 | components/Footer.tsx:8 | https://... | officialLinks.homepage |
| ...

## 内容层（→ content/{lang}/）
| 内容 | 当前位置 | 目标位置 |
|------|----------|----------|
| 新手引导文章 | content/en/guide.mdx | 保持 |
| 首页新手卡片文案 | app/page.tsx:45-60 | content/en/home.json |
| ...

## 重构方案
1. 创建 site.config.json，填入配置层所有值
2. 创建 lib/config.ts 加载配置
3. 逐个 Edit 重构：硬编码 → 读配置
4. 首页文案抽到 content/en/home.json
5. 换皮测试验证
```

---

## 实战踩坑教训

1. **配置层要"全"不要"漏"**：最容易漏的是 footer 法律页的游戏名、sidebar 的兑换码、CSS 注释里的游戏名。**对策**：第一阶段用 `extract_config.py` 扫描，再人工核对照清单逐项确认，宁多勿漏。

2. **主题色不能只在 globals.css 改**：如果组件里用了内联 `style={{ color: "#ff6b35" }}`，换皮时 globals.css 变了但内联没变。**对策**：所有颜色必须走 CSS 变量 `var(--primary)`，内联色值一律 Grep 出来改成变量引用。

3. **换皮测试必须扫产物不扫源码**：源码里 `gameName` 只在 `site.config.json` 和 `lib/config.ts` 出现，看似合规。但 build 后产物 HTML 可能在 SEO 结构化数据、og 标签、JSON-LD 里残留旧值。**对策**：`swap_test.py` 扫 `out/**/*.html`，不扫 `app/`。

4. **HTML 实体膨胀让源码合规的 meta 在产物里超长**：`'` → `&#x27;` 会让源码 60 字符的 title 在产物里变成 67 字符。**对策**：换皮测试的 SEO 长度检查应扫产物，源码长度只作参考。

5. **动态路由的 `generateStaticParams` 必须扫 content 目录**：若仍写死 `[{ params: { slug: "guide" } }, ...]`，新增文章不会自动生成页面。**对策**：检查 `lib/content.ts` 的 `getAllSlugs()` 是 `fs.readdirSync(content/{lang})`，不是硬编码数组。

6. **导航分类硬编码在 Navbar 组件里**：`<Link href="/guide">Guide</Link>` 写死，换游戏后导航不跟随。**对策**：导航项从 `site.config.json` 的 `nav` 数组 `.map()` 渲染。

7. **多语言配置两层易脱节**：`site.config.json` 的 `languages` 和 `content/` 目录结构、`next.config.js` 的 i18n locales 三处容易不同步。**对策**：以 `site.config.json.languages` 为唯一源，`lib/config.ts` 派生 i18n 配置，`new_site.py` 校验 content 目录与配置一致。

8. **WorkBuddy 构建/清理拦截（genie-safe-delete shim）**：同一 shim 既拦截 `fs.unlink`/`rm` 导致 `next build` 收尾卡死，也拦截普通 `rm`/PowerShell `Remove-Item` 删文件。**对策**：① 构建用 `NODE_OPTIONS="" npm run build`（最稳）；② 清理临时文件/备份用 `mv` 改名移走（`mv foo.py _backup/`），不要用 `rm`；③ 含中文的路径在 PowerShell 下 `Remove-Item` 会因编码乱码失败，一律走 `mv` 或 Git Bash。

9. **`out/404.html` 文件锁**：build 退出后 404.html 残留句柄删不动（EPERM）。**对策**：开新目录用 junction 软链 node_modules 重建，或换皮测试时直接在新 out 目录扫。

10. **重构前必须整站备份**：三层分离重构改动面大，万一改坏要能回退。**对策**：`cp -r {项目} {项目}_backup_$(date +%s)`，换皮验证通过后再删备份。

11. **配置字段命名要稳定**：一旦 `site.config.json` 的字段名定下，框架层代码就依赖它。开第二个站时若改字段名，框架层代码要跟着改——违背"框架层不动"原则。**对策**：字段名一次定死，开新站只改值不改结构。`new_site.py` 校验配置 schema 完整性。

12. **首页文案混在配置层和内容层边界**：Hero 的 title/subtitle 量小、换游戏就变，属配置层；但新手引导卡片内容量大、每站独立重写，属内容层。**对策**：量小且结构稳定的进 `site.config.json`（hero/cta/sidebar.codes）；量大且每站重写的进 `content/{lang}/home.json`。判定标准：换游戏时是"改值"还是"重写"——改值进配置，重写进内容。

13. **换皮测试的虚拟游戏名要够"假"**：用 "TestGame" 可能和真实测试代码冲突。**对策**：用带随机后缀的名字如 "TestGame XYZ 7841"，确保不会误判为残留。

14. **对标站游戏名是最易漏的残留**：从 `yxz-game-site-builder` 复刻来的代码，对标站游戏名（如 farever）常藏在 CSS 注释 `/* Structure mirrored from farever */`、JS 字符串里。**对策**：`extract_config.py` 把对标站游戏名也列为扫描目标，换皮测试 `--ref-game` 参数必传。

15. **内容层动态读取的 MDX 路径必须匹配路由**：`content/en/guide.mdx` 必须能被 `app/[lang]/[slug]/page.tsx` 通过 `getArticle("guide","en")` 读到，否则 404。**对策**：`new_site.py` 校验 content 目录结构与路由配置一致，缺文件报错。

16. **Next.js 14 + `output:"export"` 下 metadata route 在 dev 模式 500**：`app/sitemap.js` / `app/robots.js` / `app/manifest.js` 会要求 `generateStaticParams()`，`next build` 能出静态文件但 `next dev` 直接 500（报错 `is missing exported function "generateStaticParams()"`）。用户"把站跑起来看效果"时 `/sitemap.xml` 等全挂。**对策**：模板一律**不用 metadata route**，改用 `scripts/gen-seo.mjs` 静态生成 `public/sitemap.xml` + `public/robots.txt`，PWA manifest 用 `public/favicons/site.webmanifest` 静态文件；`package.json` 接 `predev`/`prebuild` 钩子自动跑。dev 和 build 都能用。

17. **`swap_test.py` 子串匹配会误报普通英文**：脚本对游戏名/旧名做大小写无关子串匹配，普通英文短语如 `"still got a big walk to go"`（双关）、`"Don't panic"` 会分别命中 "Big Walk"/"Panic"。**对策**：每条命中都人工复核——确属残留就修；确属普通英文就改写措辞（"big walk to go"→"long way to go"、"Don't panic"→"Don't freak out"）。虚拟测试名仍要够"假"以免误判。

18. **`new_site.py` 的必填字段要跟着模板走**：脚本原先用写死的 `REQUIRED_KEYS`（如 `languages`/`sidebar`/`cta`），但不同模板的 `site.config.json` schema 并不一样（本模板用 `developer`/`publisher`/`domain`/`pages`，没有 `languages`/`sidebar`/`cta`），硬校验会让脚手架对新模板直接 FAIL。**对策**：必填字段改为**从模板自身的 `site.config.json`（或 `site.config.example.json`）派生**——新配置必须含同样的顶层 key 与 `theme` 子 key，schema 自然跟随模板，不再写死。

