# Course Site Skill

> Generate a static course/curriculum website from a folder of Markdown files. Use this skill whenever the user wants to: - Turn a folder of .md lesson files into a browsable static site - Build a course portal with phases, lessons, quizzes, and progress tracking - Create a multi-page curriculum site with Chinese course content - Migrate content from a flat md repo into a structured curriculum site - Reuse the "AI Engineering from Scratch" site template for a new course Do NOT use for: blog posts, single-doc sites, sites with user accounts, real-time collaboration, or anything requiring a backend.

- Skill: `liangdabiao/course-site-skill` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add liangdabiao/course-site-skill`
- Raw SKILL.md: https://api.skillmd.com/api/skills/liangdabiao/course-site-skill/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: liangdabiao (https://skillmd.com/u/liangdabiao)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/liangdabiao/course-site-skill

---


# course-site-skill

> 把任何 .md 课程内容仓库，**零代码**地变成跟 `aiengineeringfromscratch.com` 一样的静态课程网站。

## 核心能力

| 能力 | 说明 |
|------|------|
| 结构分析 | 启发式扫描 md 仓库，识别阶段 / 课程 / 顺序 |
| 自动归类 | 把散乱 md 文件按 `phases/NN-name/MM-lesson/` 整理 |
| **课程化** | **agent AI 在 init 后逐节课重写为专业课程格式（中文）——这是本 skill 的核心** |
| quiz 生成 | agent AI 为每节课生成 6 题真实测验（1 pre + 3 check + 2 post），不依赖外部 LLM key |
| 品牌定制 | 改 `templates/.brand.json` 一次，全站换色换 logo |
| 静态构建 | `bin/build.py` 生成 `data.js` / `sitemap.xml` |
| 中文课程 | 课程文档统一生成**中文**专业课程，不做多语言切换 |
| 部署 | `bin/deploy.py` 推送到任意静态服务器（EdgeOne / Pages / Vercel） |

## 工作流

```
1. 用户给：md 仓库路径（散乱或半结构化均可）
2. AI 跑：scripts/extract_structure.py → 输出结构建议
3. AI 引导用户确认：阶段边界 / 课程顺序 / 命名
4. AI 跑：bin/init.py → 生成 phases/ 目录 + 复制 md（原始素材入 docs/en.md）+ 占位 quiz.json
4.5. ★ 图片资源处理 —— 若源 md 仓库含 `图片和附件/` 或类似附件目录：
   (a) 复制整个目录到 `<out>/images/`（URL 友好，无中文/空格）
   (b) 改写所有 `docs/en.md` 中的 `图片和附件/xxx.png` → `images/xxx.png`（保留 `%20` 等 URL 编码）
   (c) 对 src 中的空格 / 中文等字符 URL 编码（在浏览器端或预处理时）
5. ★ AI 跑：课程化 —— agent 逐节课读取原始 md，重写为中文专业课程格式（见下"课程化规范"）
6. ★ AI 跑：quiz 生成 —— agent 逐节课基于课程化后的 en.md 生成真实 quiz.json（见下"quiz 生成规范"）
6.5. ★ AI 跑：课程 AI 元数据 —— agent 基于课程化后的 6 节内容，提炼**术语表 GLOSSARY** 和**先决图 PREREQS**，写入 `scripts/course_ai_meta.json`（见下"课程 AI 元数据规范"）。这两份数据是 prereqs.html 和 glossary.html 的数据源。
7. 用户填：templates/.brand.json（色 / 标题 / logo / 社交链接）
8. AI 跑：bin/build.py → 生成 data.js + 注入品牌
9. AI 跑：bin/serve.py → 本地预览
10. AI 用浏览器实测所有课程页面（图片渲染、quiz 显示、prereqs 节点、glossary 术语、无错误）
11. 用户确认后：bin/deploy.py
```

## 课程化规范（核心，不可省略）

**第 5 步"课程化"是本 skill 的核心价值**：原始 md 通常是博客/教程抓取的，直接展示会显得杂乱、不专业。agent AI 必须在 init 后逐节课把原始 md 重写为专业课程格式。

### 语言

- 课程内容统一用**中文**生成（用户默认中国国内）
- 代码块、提示词模板、专有名词保留原文

### 课程标准结构

每节课的 `docs/en.md`（文件名固定 en.md，内容用中文）必须包含：

```markdown
# 课程标题（中文，准确简洁）

> 一段话课程摘要（说明本课教什么、解决什么问题）

## 学习目标

- 目标 1
- 目标 2
- ...（3-6 条，用动词开头）

## 适用读者（可选）

一段话说明目标受众。

## 正文（分章节，用 ## / ###）

正文内容，把原始 md 的：
- emoji 章节符号（🍌📄🎨）→ 转成 ## 标题
- 博客随笔式开头（"I have been playing..."）→ 改成客观课程陈述
- slug 自动生成的错误标题 → 改成准确的中文课程标题
- 超长图片 alt 文本 → 简化成简洁中文描述
- 杂乱内容 → 重组为有逻辑的章节

## 小结

1. 要点 1
2. 要点 2
...（回顾本课核心知识点）
```

### 不做

- 不做翻译/多语言切换
- 不丢失原始内容的核心知识点
- 不添加原文没有的技术细节

## quiz 生成规范

**第 6 步"quiz 生成"由 agent AI 直接完成**，不依赖外部 LLM API key。

### 流程

1. `bin/init.py` 生成占位 quiz.json（中文占位题，`extract_quiz.py` 无 key 时返回）
2. 课程化完成后，agent AI 逐节课读取 `docs/en.md`
3. 基于课程实际内容生成 6 道真实测验题，用 Write 工具覆盖 quiz.json

### quiz.json 格式

```json
{
  "lesson": "en",
  "title": "课程中文标题（从 en.md 的 # 标题获取）",
  "questions": [
    {
      "stage": "pre",
      "question": "预习题（中文，了解学生基础）",
      "options": ["选项A", "选项B", "选项C", "选项D"],
      "correct": 2,
      "explanation": "解析说明（中文）"
    },
    { "stage": "check", "question": "理解题1", ... },
    { "stage": "check", "question": "理解题2", ... },
    { "stage": "check", "question": "理解题3", ... },
    { "stage": "post", "question": "总结应用题1", ... },
    { "stage": "post", "question": "总结应用题2", ... }
  ]
}
```

### 要求

- title 用课程的中文标题
- 题目和选项都用中文
- correct 是正确选项的索引（0-3）
- 3 道 check 题要覆盖课程的核心知识点
- 每题都要有 explanation 解析
- JSON 格式合法（注意中文引号不要破坏 JSON）

## 课程 AI 元数据规范

**第 6.5 步"课程 AI 元数据"**：prereqs.html 和 glossary.html 不能是空模板。agent AI 必须在课程化完成后，基于全部课程内容提炼两份数据，写入 `scripts/course_ai_meta.json`，由 build.py 注入到 data.js。

### GLOSSARY（术语表）

每节课程都会引入新概念，agent AI 从所有课程中提取 **25-35 个核心术语**，每条：

```json
{
  "term": "术语中文名",
  "definition": "中文定义（1-2 句）",
  "category": "基础概念 / 工具与模型 / 提示词工程 / 视觉与排版 / 学术与合规"
}
```

### PREREQS（先决图）

定义"学完第 X 节后才能学第 Y 节"的关系。**用 1-based 业务 id**（1, 2, 3, ..., N），build.py 会自动映射为 0-based。

```json
{
  "1": [],
  "2": [1],
  "3": [1],
  "4": [2, 3],
  "5": [3],
  "6": [4, 5]
}
```

agent AI 要根据课程实际内容判断先后关系，不是机械编号。

### 完整文件结构

```json
{
  "glossary": [
    {"term": "...", "definition": "...", "category": "..."}
  ],
  "prereqs": {
    "1": [],
    "2": [1],
    "...": "..."
  }
}
```

### build.py 的处理

- 读取 `scripts/course_ai_meta.json`（不存在则回退为空）
- **单 phase 自动提升**：若 `phases/` 只有一个物理 phase 但多个 lessons，把每个 lesson 提升为独立 phase，让 prereqs.html 能展示课程依赖
- **PREREQS id 映射**：min_key ≥ 1 时（业务 id），把 key 和 value 都 -1 映射为 0-based
- 写入 `var GLOSSARY` / `var PREREQS` 到 data.js
- 给每个 phase 补中文 desc（`phase_descriptions` 字典）

## 课程图片限制规范

原始 md 抓取的图片可能非常大（2000+ px），会撑出 lesson-content 容器。`templates/lesson.html` 必须在 CSS 中限制：

```css
.lesson-article img {
  display: block;
  max-width: 100%;        /* 不超出内容区 */
  width: auto;
  height: auto;
  max-height: 480px;      /* 限制最大高度（超高图被压低） */
  margin: 24px auto;      /* 居中 */
  border: 1px solid var(--rule-soft);
  background: var(--bg-surface);
}
```

**禁止**：去掉 `max-height: 480px`，否则单张超高图会占满一屏。

### 图片文件名含空格 / 中文

源 md 仓库（特别是从博客 / 公众号抓取的）经常有 `image 4.png`、`image 29.png` 这类带空格的文件名，或 `图片.png` 这类中文文件名。**必须在 HTML 端 URL 编码 src**，否则浏览器把 `images/image 4.png` 解析为相对路径时会在空格处截断，导致 404。

处理方式（见下面"模板渲染规范 / inlineFormat 必须处理图片语法"）：

```javascript
// 把 src 的每个路径段单独 URL 编码空格 / 中文
var encodedSrc = src.split('/').map(function (seg) {
  try { return encodeURIComponent(decodeURIComponent(seg)); }
  catch (e) { return encodeURIComponent(seg); }
}).join('/');
return '<img alt="' + alt + '" src="' + encodedSrc + '" loading="lazy" />';
```

`decodeURIComponent` 是为了对已经编码过的 `%20` 不重复编码（双重编码会导致文件名变成 `%2520`）。

### 验证图片可访问

**不要**只看 `img.naturalWidth === 0` 判断图片加载失败 —— `<img loading="lazy">` 在视口外时 `naturalWidth=0` 是正常行为。**优先用 HTTP 直查**确认图片资源：

```python
import urllib.request
ok = urllib.request.urlopen(f'http://localhost:8765/{quote(img_src, safe="/")}').status == 200
```

只有当滚入视口后 `naturalWidth` 仍为 0，且 HTTP 也返回 404，才是真的加载失败。

## prereqs.html 渲染规范

`templates/prereqs.html` 必须有 **自动适配** 能力，不依赖硬编码数据：

### 1. PREREQS 从 data.js 读取

```javascript
var PREREQS = window.PREREQS || {};
// 若 PREREQS 为空，按 PHASES 顺序自动生成线性链
if (Object.keys(PREREQS).length === 0 && typeof PHASES !== 'undefined') {
  // ... 线性退化逻辑
}
```

**禁止**：硬编码 `var PREREQS = { 0: [], 1: [0], ... }`，否则换课程就失效。

### 2. TIER_ORDER 拓扑生成

`var TIER_ORDER` 不要写死。用 PREREQS 自动 BFS 拓扑排序：
- 入度为 0 的 phase 放第一层
- 每层结束后，把指向已放置节点的 child 减入度，入度归 0 加入下一层
- 排序：每层 id 升序
- 若 PREREQS 为空，退化为按 PHASES 顺序的每层一节点

**禁止**：硬编码 `var TIER_ORDER = [[0], [1], [2], ...]`，否则换课程就失效。

## 模板渲染规范

`templates/lesson.html` 是课程详情页的核心模板，有以下必须维护的规范：

### inlineFormat 必须处理图片语法

`![alt](url)` 必须在链接 `[label](href)` 之前处理，否则 `!` 会被当作普通字符、`[alt](url)` 被匹配为链接，渲染成 `!` + `<a>alt</a>`。

**正确写法**（src 允许空格、URL 编码空格/中文）：

```javascript
// ✅ 正确顺序：先图片，后链接；src 允许空格并 URL 编码
text = text.replace(/!\[([^\]]*)\]\(([^)]+?)\)/g, function (m, alt, src) {
  // 去除可选的 title 部分（"title" 紧跟在 src 后面）
  var titleMatch = src.match(/^(.*?)\s+"([^"]*)"$/);
  var cleanSrc = titleMatch ? titleMatch[1] : src;
  var title = titleMatch ? titleMatch[2] : '';
  // 每个路径段单独 URL 编码（避免双重编码已编码字符）
  var encodedSrc = cleanSrc.split('/').map(function (seg) {
    try { return encodeURIComponent(decodeURIComponent(seg)); }
    catch (e) { return encodeURIComponent(seg); }
  }).join('/');
  var titleAttr = title ? ' title="' + title + '"' : '';
  return '<img alt="' + alt + '" src="' + encodedSrc + '" loading="lazy"' + titleAttr + ' />';
});
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (m, label, href) { ... });
```

**错误写法**（常见 bug，**禁止**）：

```javascript
// ❌ src 用 [^)\s]+ 禁止空格，导致 images/image 4.png 不被解析为 <img>
text = text.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, function (m, alt, src) {
  return '<img alt="' + alt + '" src="' + src + '" loading="lazy" />';
});
```

`[^)\s]+` 中的 `\s` 排除了空格，碰到 `images/image 4.png` 时匹配在空格处停止，`\)` 无法匹配 `)`，整个表达式匹配失败 → 整张图片不渲染为 `<img>`。症状：浏览器 `article.querySelectorAll('img').length === 0`，但 en.md 里确实有 `![alt](url)`。

### inlineFormat 与 src 相对路径

`<img src="images/xxx.png">` 是**相对当前页面 URL** 解析的。lesson.html 通过 `?path=phases/00-xxx/01-yyy` 加载，浏览器会拼成 `phases/00-xxx/01-yyy/images/xxx.png`。`images/` 目录必须放在 **lesson.html 同级**（即 `<out>/images/`），不能放在 `phases/` 里面。

### fetch 必须加 cache-busting

课程 md 和 quiz.json 的 fetch URL 必须加 `?v=timestamp`，防止浏览器缓存旧内容：

```javascript
var cacheBust = '?v=' + (window.AIFS_VERSION || Date.now());
var rawUrl = base + path + '/docs/en.md' + cacheBust;
var quizUrl = base + path + '/quiz.json' + cacheBust;
```

### renderQuiz 的 pre quiz fallback

`renderQuiz` 函数在找不到 `#the-concept` / `#build-it` 等英文 heading id 时（中文课程的 heading slug 是中文），pre quiz 必须有 fallback 追加到 article 开头：

```javascript
if (preQuizHtml && conceptH2) {
  conceptH2.insertAdjacentHTML('beforebegin', preQuizHtml);
} else if (preQuizHtml && buildH2) {
  buildH2.insertAdjacentHTML('beforebegin', preQuizHtml);
} else if (preQuizHtml) {
  article.insertAdjacentHTML('afterbegin', preQuizHtml);  // fallback
}
```

## 工具清单

### 脚本（scripts/）

| 脚本 | 用途 | 调用方式 |
|------|------|---------|
| `extract_structure.py` | 启发式分析 md 仓库 | `python scripts/extract_structure.py <md_repo> [--out <path>]` |
| `phase_layout.py` | 生成 phases/ 结构 | `python scripts/phase_layout.py <md_repo> <out_phases>` |
| `extract_quiz.py` | 生成 quiz（无 key 时返回占位，由 agent AI 填充） | `python scripts/extract_quiz.py <md_file> <out_quiz.json>` |
| `clean_markdown.py` | 清洗 PDF 元数据头（Title/URL Source/Published Time 等） | 被 init.py 自动调用 |

> `extract_structure.py` 的 `--out` 输出（如 `structure_suggest.json`）是**临时建议文件**，应写到**用户工作目录**（如 `d:/course-skill-test/`），**不要写到 skill 根目录**——它是 skill 之外的产物，不属于 skill 仓库。

### bin 命令（统一用 .py，跨平台）

| 命令 | 用途 |
|------|------|
| `bin/init.py <md_repo> <out_dir>` | 一步：分析 + 归类 + 复制 + 占位 quiz |
| `bin/build.py <out_dir>` | 构建 data.js / sitemap / 注入品牌 |
| `bin/serve.py <out_dir> [port]` | 本地预览（默认 8000） |
| `bin/deploy.py <target>` | 部署到 EdgeOne / Pages / Vercel |

> `bin/*.sh` 是 Linux 包装（调用 .py），Windows 用 .py。

## 模板定制点

只需修改 `templates/.brand.json`：

```json
{
  "site": {
    "title": "My Course",
    "subtitle": "A free, open-source curriculum",
    "domain": "https://mycourse.com",
    "github": "https://github.com/me/my-course"
  },
  "brand": {
    "primary": "#3553ff",
    "accent": "#ff6b35",
    "ink": "#1a1a1a",
    "ink_soft": "#4a4a4a",
    "bg": "#fafaf5",
    "font_display": "VT323",
    "font_body": "Source Serif 4",
    "font_mono": "JetBrains Mono"
  },
  "social": {
    "github_user": "yourname",
    "twitter": "@yourname",
    "email": "hi@example.com"
  },
  "footer": {
    "author": "Your Name",
    "license": "MIT"
  }
}
```

模板 HTML/CSS 全部走 CSS 变量（`--blueprint` / `--ink` / `--bg` 等），`bin/build.py` 注入品牌。

## 输出物结构

```
my-course-site/                    # 用户最终拿到的网站
├── index.html
├── lesson.html
├── catalog.html
├── glossary.html
├── prereqs.html
├── about.html
├── app.js
├── header.js
├── progress.js
├── cmdpalette.js
├── style.css
├── data.js                        # 自动生成（单语言，不生成 data.zh.js）
├── sitemap.xml
├── images/                        # 课程图片（从源 md 仓库的「图片和附件/」复制过来）
├── phases/
│   ├── 00-intro/
│   │   └── 01-welcome/
│   │       ├── docs/en.md         # 课程化后的中文课程内容
│   │       └── quiz.json          # AI 生成的真实测验
│   └── ...
└── assets/
```

## 触发对话的关键词

- "基于我的 md 仓库做个网站"
- "用 course-site-skill 帮我生成"
- "我想把这些课程笔记变成可浏览的网站"
- "复刻这个课程站点的风格"
- "用 AI Engineering from Scratch 的模板"

## 安装 / 集成

### 加载到 AI Agent

```bash
# Claude / Cursor / Aider 等（任选一个目标）
cp -r course-site-skill ~/.claude/skills/        # 全局
cp -r course-site-skill .claude/skills/           # 项目级
cp -r course-site-skill .cursor/skills/           # Cursor Composer
```

加载后，AI Agent 通过 SKILL.md 的 frontmatter 注册本 skill，用户说"用 course-site-skill"即触发。

### 纯命令行（不需要 AI Agent）

```bash
python bin/init.py   /path/to/md-repo  ./out          # 分析 + 归类 + 复制 + 占位 quiz
python bin/build.py  ./out                            # 注入品牌 + 生成 data.js
python bin/serve.py  ./out  8000                      # 本地预览
python bin/deploy.py ./out  edgeone                   # 部署
python bin/deploy.py ./out  vercel
python bin/deploy.py ./out  zip  my-site.zip
```

高级选项：

```bash
python bin/init.py /path/to/md-repo ./out --use-llm-quiz   # 用 LLM 生成 quiz
python bin/init.py /path/to/md-repo ./out --force           # 覆盖已存在输出
```

### AI Agent 用法示例

**Claude Code**：

```
User: 我有一个课程笔记仓库在 /Users/me/my-course-notes，能帮我做成网站吗？
AI: 我用 course-site-skill 帮你做。先扫描下结构……
```

**Cursor**：

```
@course-site-skill 用这个 skill 把 ./lessons 变成网站
```

**Aider**：

```bash
aider --load-skill course-site-skill
```

AI Agent 通过调用 skill 提供的**命令**（`scripts/` 和 `bin/` 下的脚本）完成任务。**不**直接修改 `templates/` 里的代码（那是模板，由 build.py 注入到产物，不是给 AI 改的）。

## 不做什么

- ❌ 不做用户系统（登录 / 评论 / 收藏）
- ❌ 不做实时搜索 / 协作
- ❌ 不需要后端
- ❌ 不做多语言切换（单中文站点，不生成 data.zh.js / zh.md）
- ❌ 不管理课程进度同步（仅 localStorage）

## 异常处理

- md 仓库为空 → 提示用户提供
- 阶段边界模糊 → 列出候选项让用户选
- 缺 quiz → agent AI 直接生成（不依赖外部 LLM key）
- 颜色对比度不够 → 警告但不阻止
- 缺 logo → 使用默认占位
- 图片渲染异常 → 检查 inlineFormat 是否处理了 `![alt](url)` 图片语法
- **图片文件名含空格（`image 4.png`）导致 `article.querySelectorAll('img').length === 0`** → 检查 inlineFormat 的图片正则是否用了 `[^)\s]+`（禁止空格），改为 `[^)]+?` 并 URL 编码 src
- **图片存在但浏览器报 naturalWidth=0** → 先确认是懒加载（`loading="lazy"` 在视口外是正常的），用 HTTP 直查确认 200 后才算正常
- **图片 404** → 检查 src 是否做了 URL 编码（`image 4.png` → `image%204.png`）；检查 `images/` 目录是否在 lesson.html 同级（不能放 `phases/` 里面）
- **图片资源没被复制** → 源 md 仓库里的 `图片和附件/` 目录需手动复制到 `<out>/images/`，并把所有 en.md 里的 `图片和附件/xxx` 改写为 `images/xxx`
- 课程内容不更新 → 检查 fetch 是否加了 cache-busting
- 课程图片太大撑出界面 → 检查 lesson.html 是否有 `.lesson-article img { max-width:100%; max-height:480px }`
- prereqs.html 只显示 1 个 phase → 检查 build.py 单 phase 自动提升是否生效（`physical_phase_count == 1 and len(all_lessons) > 1`）
- prereqs.html 节点 id 与 PREREQS key 不匹配 → 检查 build.py PREREQS 是否做了 1-based → 0-based 映射
- prereqs.html 节点全堆在一行 → 检查 TIER_ORDER 是否自动拓扑生成
- glossary.html 术语不显示 → 检查 `scripts/course_ai_meta.json` 是否生成、build.py 是否正确加载
- pre quiz 不显示 → 检查 lesson.html renderQuiz 是否有 pre quiz fallback（找不到 #the-concept 时插入到 article 开头）

## 图片处理 Pipeline（实战总结）

从含图片的源 md 仓库生成可用的课程站点，完整流程：

### 1. 资源迁移

源 md 仓库通常有 `图片和附件/` 或 `assets/` 目录含原图。复制到产物根目录的 `images/`（**必须在 lesson.html 同级**，不在 phases/ 内）：

```python
import shutil
from pathlib import Path
shutil.copytree(src_repo / '图片和附件', out_dir / 'images', dirs_exist_ok=True)
```

### 2. 引用改写

把所有 `phases/*/*/docs/en.md` 中的 `图片和附件/xxx.png` 改写为 `images/xxx.png`：

```python
import re
for en in out_dir.glob('phases/*/*/docs/en.md'):
    text = en.read_text(encoding='utf-8')
    text = re.sub(r'图片和附件/', 'images/', text)
    en.write_text(text, encoding='utf-8')
```

如果源 md 中已经 URL 编码过（如 `image%2029.png`），改写后保持编码。

### 3. 浏览器端 URL 编码

lesson.html 的 inlineFormat 在渲染时再对每个路径段 URL 编码（处理空格/中文），详见上面"inlineFormat 必须处理图片语法"。

### 4. 验证

**HTTP 直查**（更可靠）：

```python
import re, urllib.request
from urllib.parse import quote
img_re = re.compile(r'!\[[^\]]*\]\(([^)]+)\)')
for lesson in LESSONS:
    md = urllib.request.urlopen(f'http://localhost:8765/{lesson}/docs/en.md').read().decode('utf-8')
    for src in set(img_re.findall(md)):
        url = f'http://localhost:8765/{quote(src, safe="/")}'
        r = urllib.request.urlopen(url)
        assert r.status == 200, f'{src} → {r.status}'
```

**浏览器验证**（带懒加载处理）：

```javascript
// 1. 滚到底触发所有 lazy-load
window.scrollTo(0, document.body.scrollHeight);
await new Promise(r => setTimeout(r, 2000));
// 2. 等所有 img 加载完
await Promise.all([...document.querySelectorAll('img')].map(img =>
  img.complete ? Promise.resolve() :
  new Promise(r => { img.onload = img.onerror = r; setTimeout(r, 5000); })
));
// 3. 再检查 naturalWidth
const failed = [...document.querySelectorAll('img')].filter(i => i.naturalWidth === 0);
```

`naturalWidth === 0` 不一定是真失败 —— 可能是懒加载还没触发，**必须 HTTP 直查 200 才能确认**。

## 模板与产物的同步陷阱

`bin/build.py` 会用 `templates/lesson.html` 覆盖产物 `lesson.html`。如果直接修改产物的 `lesson.html`（如修复 inlineFormat），**下次 build 会被冲掉**。**修复必须改 `templates/lesson.html`**，然后 `bin/build.py` 才会把修复同步到所有产物。

## 进阶

- **自定义首页**：在 `templates/` 改 `index.html` 骨架，build.py 会保留用户覆盖
- **私有部署**：`bin/deploy.py local` 复制到指定目录
- **CDN**：默认国内友好（已去除 GitHub 资源依赖）

