| name | course-site-skill |
| description | 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.
|
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,内容用中文)必须包含:
# 课程标题(中文,准确简洁)
> 一段话课程摘要(说明本课教什么、解决什么问题)
## 学习目标
- 目标 1
- 目标 2
- ...(3-6 条,用动词开头)
## 适用读者(可选)
一段话说明目标受众。
## 正文(分章节,用 ## / ###)
正文内容,把原始 md 的:
- emoji 章节符号(🍌📄🎨)→ 转成 ## 标题
- 博客随笔式开头("I have been playing...")→ 改成客观课程陈述
- slug 自动生成的错误标题 → 改成准确的中文课程标题
- 超长图片 alt 文本 → 简化成简洁中文描述
- 杂乱内容 → 重组为有逻辑的章节
## 小结
1. 要点 1
2. 要点 2
...(回顾本课核心知识点)
不做
- 不做翻译/多语言切换
- 不丢失原始内容的核心知识点
- 不添加原文没有的技术细节
quiz 生成规范
第 6 步"quiz 生成"由 agent AI 直接完成,不依赖外部 LLM API key。
流程
bin/init.py 生成占位 quiz.json(中文占位题,extract_quiz.py 无 key 时返回)
- 课程化完成后,agent AI 逐节课读取
docs/en.md
- 基于课程实际内容生成 6 道真实测验题,用 Write 工具覆盖 quiz.json
quiz.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" ...
...
...
...
要求
- 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 个核心术语,每条:
{
"term": "术语中文名",
"definition": "中文定义(1-2 句)",
"category": "基础概念 / 工具与模型 / 提示词工程 / 视觉与排版 / 学术与合规"
}
PREREQS(先决图)
定义"学完第 X 节后才能学第 Y 节"的关系。用 1-based 业务 id(1, 2, 3, ..., N),build.py 会自动映射为 0-based。
{
"1": [],
"2": [1],
"3": [1],
"4": [2, 3],
"5": [3],
"6": [4, 5]
}
agent AI 要根据课程实际内容判断先后关系,不是机械编号。
完整文件结构
{
"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 中限制:
.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 必须处理图片语法"):
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 直查确认图片资源:
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 读取
var PREREQS = window.PREREQS || {};
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 必须处理图片语法
 必须在链接 [label](href) 之前处理,否则 ! 会被当作普通字符、[alt](url) 被匹配为链接,渲染成 ! + <a>alt</a>。
正确写法(src 允许空格、URL 编码空格/中文):
text = text.replace(/!\[([^\]]*)\]\(([^)]+?)\)/g, function (m, alt, src) {
var titleMatch = src.match(/^(.*?)\s+"([^"]*)"$/);
var cleanSrc = titleMatch ? titleMatch[1] : src;
var title = titleMatch ? titleMatch[2] : '';
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,禁止):
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 里确实有 。
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,防止浏览器缓存旧内容:
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 开头:
if (preQuizHtml && conceptH2) {
conceptH2.insertAdjacentHTML('beforebegin', preQuizHtml);
} else if (preQuizHtml && buildH2) {
buildH2.insertAdjacentHTML('beforebegin', preQuizHtml);
} else if (preQuizHtml) {
article.insertAdjacentHTML('afterbegin', preQuizHtml);
}
工具清单
脚本(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:
{
"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"
},
模板 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
cp -r course-site-skill ~/.claude/skills/
cp -r course-site-skill .claude/skills/
cp -r course-site-skill .cursor/skills/
加载后,AI Agent 通过 SKILL.md 的 frontmatter 注册本 skill,用户说"用 course-site-skill"即触发。
纯命令行(不需要 AI Agent)
python bin/init.py /path/to/md-repo ./out
python bin/build.py ./out
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
高级选项:
python bin/init.py /path/to/md-repo ./out --use-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:
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 是否处理了
 图片语法
- 图片文件名含空格(
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/ 内):
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:
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 直查(更可靠):
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}'
浏览器验证(带懒加载处理):
window.scrollTo(0, document.body.scrollHeight);
await new Promise(r => setTimeout(r, 2000));
await Promise.all([...document.querySelectorAll('img')].map(img =>
img.complete ? Promise.resolve() :
new Promise(r => { img.onload = img.onerror = r; setTimeout(r, 5000); })
));
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 资源依赖)