static-spa-conversion

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Static SPA Conversion

静态SPA转换

Schema authority: all
window.COURSE
field names come from
_shared/domain-primitives.md
. When the schema example below diverges from that file, that file wins.
Starter templates (copy these, don't re-derive):
  • templates/index.html
    — scaffold shell with render pipeline skeleton
  • templates/course-data.template.js
    — empty schema example with TODO markers for every primitive
  • ../teaching-site-design-system/templates/tokens.css
    — paste into
    <style>
    block, or copy as
    style.css
    next to
    index.html
Reference implementation:
d:/GitHub/ai-workshop/index.html
(4125 lines, fully populated). Use as visual + render-pattern reference; do not copy course-specific content from it.
Filename convention (English-first):
course-package/
,
day{n}/outline.md
,
day{n}/content.md
,
materials/
,
overview.md
. Legacy Chinese names (
完整課程包/
,
課程大綱.md
,
教學素材/
, etc.) are deprecated — see
_shared/domain-primitives.md
§0 for full mapping.
This skill turns chapter-based markdown content into a working single-page app. The architectural commitments are deliberate and worth understanding before you start.
Schema 权威定义:所有
window.COURSE
字段名称均来自
_shared/domain-primitives.md
。若下方示例与该文件存在差异,以该文件为准。
起始模板(直接复制使用,无需重新推导):
  • templates/index.html
    — 包含渲染流水线骨架的脚手架外壳
  • templates/course-data.template.js
    — 带有所有基础类型TODO标记的空Schema示例
  • ../teaching-site-design-system/templates/tokens.css
    — 粘贴至
    <style>
    块中,或复制为
    index.html
    旁的
    style.css
    文件
参考实现
d:/GitHub/ai-workshop/index.html
(共4125行,完整填充)。用作视觉效果与渲染模式参考;请勿复制其中课程专属内容。
文件名规范(优先英文)
course-package/
day{n}/outline.md
day{n}/content.md
materials/
overview.md
。旧版中文命名(
完整課程包/
課程大綱.md
教學素材/
等)已弃用——详见
_shared/domain-primitives.md
第0节的完整映射关系。
此技能可将基于章节的Markdown内容转换为可运行的单页应用。其架构约定经过审慎设计,开始前请务必理解。

The Three Architectural Commitments

三项架构约定

  1. Vanilla HTML + JS, no framework, no bundler. No React, no Vite, no TypeScript build. Why: course websites have multi-year lifespans and are often handed to non-developer instructors. A
    .html
    you can open and edit beats a
    node_modules
    graveyard every time. Also: the content is the value, not the tech.
  2. Data and rendering live in different files. A
    course-data.js
    holds everything the course is as a single JS object (
    window.COURSE
    ). An
    index.html
    holds how the course is shown. Instructors editing tomorrow's lesson never need to touch HTML.
  3. localStorage for persistence, no backend. Progress, theme, zoom — all client-side. Easier hosting, no privacy headaches. The cost: state is per-browser. Accept it.
  1. Vanilla HTML + JS,无框架、无打包工具。不使用React、Vite或TypeScript构建。原因:课程网站需长期使用,且常交付给非开发人员讲师。一个可直接打开编辑的
    .html
    文件,永远优于堆满
    node_modules
    的项目。此外:内容才是核心价值,而非技术栈。
  2. 数据与渲染分离存储
    course-data.js
    以单个JS对象(
    window.COURSE
    )存储课程的全部内容
    index.html
    负责课程的展示方式。讲师编辑明日课程时,无需触碰HTML文件。
  3. 基于localStorage实现持久化,无后端。进度、主题、缩放——所有状态均在客户端处理。部署更简单,无隐私顾虑。代价:状态仅针对单个浏览器生效,请接受这一限制。

Standard File Layout

标准文件结构

project-root/
├── index.html              ← the single SPA (CSS + JS inline; ~3000 lines is normal)
├── course-data.js          ← window.COURSE = { ... }
├── instructor-data.js      ← optional: window.INSTRUCTOR (if scraped/external)
├── tools-data.js           ← optional: window.TOOLS (if showcasing tools)
├── package.json            ← just `{"scripts": {"serve": "npx serve ."}}` and scraping tooling
├── assets/                 ← images, illustrations
└── course-package/             ← markdown source (read-only from SPA's perspective)
    └── materials/           ← files linked from the SPA via getMaterialUrl()
index.html
includes data files via plain
<script src="course-data.js"></script>
— no module imports, no fetch. The browser loads them in order, populating
window.COURSE
before
init()
runs.
project-root/
├── index.html              ← 单页SPA文件(CSS + JS内联;约3000行属正常范围)
├── course-data.js          ← window.COURSE = { ... }
├── instructor-data.js      ← 可选:window.INSTRUCTOR(若为抓取的外部数据)
├── tools-data.js           ← 可选:window.TOOLS(若需展示工具)
├── package.json            ← 仅包含`{"scripts": {"serve": "npx serve ."}}`与抓取工具配置
├── assets/                 ← 图片、插图资源
└── course-package/             ← Markdown源文件(SPA视角下为只读)
    └── materials/           ← SPA通过getMaterialUrl()链接的文件
index.html
通过普通的
<script src="course-data.js"></script>
引入数据文件——无需模块导入,无需fetch请求。浏览器按顺序加载文件,在
init()
运行前完成
window.COURSE
的填充。

The
window.COURSE
Schema

window.COURSE
Schema

The single most important artifact. Design it carefully — every render function depends on it. Standard shape:
js
window.COURSE = {
  meta: {                              // top-level course metadata
    title: '...',
    audience: '...',
    hoursTotal: 24,
    days: [                            // index drives sidebar chapter list
      { id: 'day1', title: 'Day 1: ...', hours: 6, schedule: '...' },
      ...
    ],
    classroom: { name: '...', mapUrl: '...' }
  },
  sharedCase: { /* persistent fictional scenario */ },
  day1: {
    hero: { title, lead, illustration },
    units: [
      {
        id: 'u-1',                     // stable, never renamed
        title: '...',
        timeRange: '14:00–14:50',
        goals: [...],                  // learning outcomes
        tasks: [                       // ← these IDs are localStorage keys
          { id: 'd1-u1-t1', text: '...', done: false }
        ],
        prompts: [                     // optional: copyable templates
          { id: '...', title: '...', body: '...' }
        ],
        materials: [                   // optional: per-unit material refs
          { id: '...', name: '...', type: 'PDF 文件' }
        ],
        illustrations: [               // 1–3 entries, see Stage 5 Coverage Floor
          { name: 'day1-u1-hero.png',  kind: 'hero',    alt: '...', spec: '...' },
          { name: 'day1-u1-flow.svg',  kind: 'diagram', alt: '...', spec: '...' }
        ]
        // legacy single `illustration: 'day1-u1.png'` still accepted; treat as
        // illustrations: [{ name, kind: 'hero' }] and migrate when convenient
      },
      ...
    ]
  },
  day2: { /* same shape */ },
  ...
  materials: [                         // cross-day material index for the "all materials" tab
    { id: 'm-1', name: '...', type: '...', desc: '...' }
  ],
  quiz: [                              // optional: end-of-course assessment
    { id: 'q1', question: '...', options: [...], answer: 0, sourceUnit: 'day1.u-2' }
  ]
};
这是最重要的核心结构,请谨慎设计——所有渲染函数均依赖于此。标准结构如下:
js
window.COURSE = {
  meta: {                              // 顶层课程元数据
    title: '...',
    audience: '...',
    hoursTotal: 24,
    days: [                            // 索引决定侧边栏章节列表顺序
      { id: 'day1', title: 'Day 1: ...', hours: 6, schedule: '...' },
      ...
    ],
    classroom: { name: '...', mapUrl: '...' }
  },
  sharedCase: { /* 持久化的虚构场景 */ },
  day1: {
    hero: { title, lead, illustration },
    units: [
      {
        id: 'u-1',                     // 稳定ID,切勿重命名
        title: '...',
        timeRange: '14:00–14:50',
        goals: [...],                  // 学习目标
        tasks: [                       // ← 这些ID是localStorage的键
          { id: 'd1-u1-t1', text: '...', done: false }
        ],
        prompts: [                     // 可选:可复制的模板
          { id: '...', title: '...', body: '...' }
        ],
        materials: [                   // 可选:单元专属素材引用
          { id: '...', name: '...', type: 'PDF 文件' }
        ],
        illustrations: [               // 1–3个条目,参考Stage 5覆盖标准
          { name: 'day1-u1-hero.png',  kind: 'hero',    alt: '...', spec: '...' },
          { name: 'day1-u1-flow.svg',  kind: 'diagram', alt: '...', spec: '...' }
        ]
        // 旧版单个`illustration: 'day1-u1.png'`仍可兼容;将其视为
        // illustrations: [{ name, kind: 'hero' }],方便时进行迁移
      },
      ...
    ]
  },
  day2: { /* 结构与day1一致 */ },
  ...
  materials: [                         // 跨章节素材索引,用于「全部素材」标签页
    { id: 'm-1', name: '...', type: '...', desc: '...' }
  ],
  quiz: [                              // 可选:课程结束评估
    { id: 'q1', question: '...', options: [...], answer: 0, sourceUnit: 'day1.u-2' }
  ]
};

The Render Pipeline

渲染流水线

index.html
is one big
<script>
at the bottom. Its shape:
js
// 1. Utilities (top, ~50 lines)
const el = (tag, attrs, children) => { /* tiny DOM helper */ };
const store = {
  load() { /* read localStorage */ },
  save(state) { /* write localStorage */ },
  reset() { /* clear */ }
};

// 2. Render functions, each takes data → returns/appends DOM
function renderOverview(meta) { ... }
function renderSharedCase(case_) { ... }
function renderDay(day) { ... }
function renderUnit(unit, dayId) { ... }
function renderMaterials(materials) { ... }
function renderToolbox(tools) { ... }    // optional
function renderQuiz(quiz) { ... }        // optional

// 3. Sidebar + scrollspy + theme — see static-spa-interactions

// 4. Entry point
function init() {
  renderOverview(window.COURSE.meta);
  renderSharedCase(window.COURSE.sharedCase);
  for (const dayKey of ['day1', 'day2', 'day3', 'day4']) {
    renderDay(window.COURSE[dayKey]);
  }
  renderMaterials(window.COURSE.materials);
  renderQuiz(window.COURSE.quiz);
  // ... then interactivity wiring (see static-spa-interactions)
}
document.addEventListener('DOMContentLoaded', init);
Keep each render function pure: it receives data, it produces DOM. No reading from globals beyond its parameter. This makes adding a new section trivial.
index.html
底部包含一个完整的
<script>
块,结构如下:
js
// 1. 工具函数(顶部,约50行)
const el = (tag, attrs, children) => { /* 轻量DOM辅助函数 */ };
const store = {
  load() { /* 读取localStorage */ },
  save(state) { /* 写入localStorage */ },
  reset() { /* 清空数据 */ }
};

// 2. 渲染函数,接收数据并返回/追加DOM元素
function renderOverview(meta) { ... }
function renderSharedCase(case_) { ... }
function renderDay(day) { ... }
function renderUnit(unit, dayId) { ... }
function renderMaterials(materials) { ... }
function renderToolbox(tools) { ... }    // 可选
function renderQuiz(quiz) { ... }        // 可选

// 3. 侧边栏 + 滚动监听 + 主题 — 详见static-spa-interactions

// 4. 入口函数
function init() {
  renderOverview(window.COURSE.meta);
  renderSharedCase(window.COURSE.sharedCase);
  for (const dayKey of ['day1', 'day2', 'day3', 'day4']) {
    renderDay(window.COURSE[dayKey]);
  }
  renderMaterials(window.COURSE.materials);
  renderQuiz(window.COURSE.quiz);
  // ... 后续为交互逻辑绑定(详见static-spa-interactions)
}
document.addEventListener('DOMContentLoaded', init);
保持每个渲染函数的纯函数特性:仅接收数据,生成DOM元素。除参数外不读取全局变量。这会让新增章节变得极为简单。

The Material URL Router (
getMaterialUrl
)

素材URL路由(
getMaterialUrl

The trickiest piece. The SPA needs to map a material's
name
and
type
to a real URL on disk. The router lives near the top of the script section:
js
function getMaterialUrl(name, type) {
  const base = 'course-package/materials';
  if (type === 'PDF 文件') {
    // PDFs trigger download instead of inline view
    if (name.includes('員工差勤')) return `${base}/pdf/員工差勤辦法.pdf`;
    if (name.includes('客訴SOP')) return `${base}/pdf/客訴處理SOP.pdf`;
    // ... one rule per PDF
    return null;  // unknown PDF → caller hides the link
  }
  // Other types open in new tab
  if (name.includes('FAQ')) return `${base}/FAQ官方版.md`;
  if (name.includes('差勤')) return `${base}/員工差勤辦法.md`;
  // ...
}
The three-place sync rule — when adding a material, update all of:
  1. Drop the file into
    course-package/materials/
  2. Add an entry to
    course-data.js:materials[]
    (and any unit's
    materials[]
    )
  3. Add the
    name.includes(...)
    rule to
    getMaterialUrl()
Forgetting any one breaks the link. Consider adding a CI check that diffs filesystem vs. data vs. router.
这是最复杂的部分。SPA需要将素材的
name
type
映射到磁盘上的真实URL。路由函数位于脚本块顶部附近:
js
function getMaterialUrl(name, type) {
  const base = 'course-package/materials';
  if (type === 'PDF 文件') {
    // PDF文件触发下载而非在线预览
    if (name.includes('員工差勤')) return `${base}/pdf/員工差勤辦法.pdf`;
    if (name.includes('客訴SOP')) return `${base}/pdf/客訴處理SOP.pdf`;
    // ... 每个PDF对应一条规则
    return null;  // 未知PDF → 调用方隐藏链接
  }
  // 其他类型在新标签页打开
  if (name.includes('FAQ')) return `${base}/FAQ官方版.md`;
  if (name.includes('差勤')) return `${base}/員工差勤辦法.md`;
  // ...
}
三处同步规则 — 添加素材时,需同时更新以下三处:
  1. 将文件放入
    course-package/materials/
    目录
  2. course-data.js:materials[]
    (以及对应单元的
    materials[]
    )中添加条目
  3. getMaterialUrl()
    中添加
    name.includes(...)
    规则
遗漏任何一处都会导致链接失效。可考虑添加CI检查,比对文件系统、数据与路由的一致性。

The Task ID Lifecycle

任务ID生命周期

js
// Save
function toggleTask(taskId) {
  const state = store.load();
  state.tasks[taskId] = !state.tasks[taskId];
  store.save(state);
  // re-render checkbox only, not whole page
}

// Load on render
function renderTask(task) {
  const state = store.load();
  const checked = !!state.tasks[task.id];
  return el('label', {}, [
    el('input', { type: 'checkbox', checked, onchange: () => toggleTask(task.id) }),
    task.text
  ]);
}
Never change
task.id
once published.
The localStorage key
tasks[task.id]
is what survives across visits. Renaming wipes progress for every student.
js
// 保存任务状态
function toggleTask(taskId) {
  const state = store.load();
  state.tasks[taskId] = !state.tasks[taskId];
  store.save(state);
  // 仅重新渲染复选框,而非整个页面
}

// 渲染时加载状态
function renderTask(task) {
  const state = store.load();
  const checked = !!state.tasks[task.id];
  return el('label', {}, [
    el('input', { type: 'checkbox', checked, onchange: () => toggleTask(task.id) }),
    task.text
  ]);
}
发布后切勿修改
task.id
。localStorage中的键
tasks[task.id]
是用户跨访问会话保留的进度依据。重命名会清除所有学生的进度数据。

Local Development

本地开发

powershell
undefined
powershell
// 注意:Chrome中file://协议会阻止localStorage — 始终通过HTTP服务运行
npx serve .
// → http://localhost:3000
package.json
中添加以下配置:
json
{
  "scripts": {
    "serve": "npx serve ."
  }
}
请在README中显著提及这一点。直接双击
index.html
会导致「进度无法保存」的迷惑性问题,浪费大量时间。

Required: file:// blocks localStorage in Chrome — always serve via HTTP

迁移:Markdown → course-data.js

npx serve .
将现有
.md
大纲与内容迁移至
course-data.js
时:
  1. 先解析大纲,获取章节/单元骨架。
  2. 针对每个单元的内容
    .md
    文件,使用类grep的方式提取
    tasks
    prompts
    materials
    引用(查找
    course-content-authoring
    中的标记模式)。
  3. 生成
    course-data.js
    作为一个完整的对象字面量。无需通过代码格式化使其美观——可使用prettier/eslint进行格式化,或保持原样。
  4. 手动审核:Markdown中的每个单元ID都应存在于
    course-data.js
    中;章节数量与总时长需与
    meta
    中的数据匹配。

技能交接说明


Add this to `package.json`:

```json
{
  "scripts": {
    "serve": "npx serve ."
  }
}
Mention this prominently in any README. Double-clicking
index.html
produces a confusing "my progress doesn't save" bug that wastes hours.
请告知用户:
  • "SPA脚手架已生成。运行
    npm run serve
    并打开http://localhost:3000即可查看。"
  • "下一步:调用
    static-spa-interactions
    添加进度持久化、侧边栏、主题、响应式设计等功能。"
  • "从此刻起请勿重命名
    course-data.js
    路径或任务ID——学生的localStorage依赖这些标识。"

Migration: Markdown → course-data.js

When porting an existing
.md
outline + content to
course-data.js
:
  1. Parse the outline first to get the day/unit skeleton.
  2. For each unit's content
    .md
    , extract
    tasks
    ,
    prompts
    ,
    materials
    references using grep-style passes (look for the marker patterns from
    course-content-authoring
    ).
  3. Generate
    course-data.js
    as one big object literal. Don't try to make it pretty programmatically — let prettier/eslint format it, or just leave it as-is.
  4. Manually review: every unit ID in markdown should exist in
    course-data.js
    ; the day count and hours should match
    meta
    .

When This Skill Hands Off

Tell the user:
  • "SPA scaffold is live. Run
    npm run serve
    and open http://localhost:3000."
  • "Next: invoke
    static-spa-interactions
    for progress persistence, sidebar, theme, RWD."
  • "Don't rename
    course-data.js
    paths or task IDs from this point — students' localStorage depends on them."