mcpField
S
static-spa-conversion

static-spa-conversion

github.com/kevintsai1202开发5265 次安装24 周前更新
代码仓库

关于 static-spa-conversion

static-spa-conversion 是将分章节的 Markdown 内容转换为静态单页应用的技能,面向课程网站制作者。它采用原生 HTML 和 JS、课程数据与渲染分离,并用 localStorage 保存进度等状态。

主要功能

  1. 01将 Markdown 课程内容迁移为 SPA
  2. 02使用原生 HTML 与 JS,无框架和打包器
  3. 03以 window.COURSE 集中管理课程数据
  4. 04用 localStorage 保存学习进度
  5. 05按材料名称和类型映射文件 URL

适用场景

  1. 01把分章节课程大纲和内容转为网页
  2. 02为课程网站搭建数据与页面结构
  3. 03将材料文件链接到课程内容
  4. 04规划可持续维护的课程单页应用

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/kevintsai1202/teaching-site-skills --skill static-spa-conversion

安装后在 Claude Code 中输入/static-spa-conversion确认已加载。

附带文件templates/course-data.template.jstemplates/index.html

详细介绍

静态 SPA 转换

Schema 权威来源:所有 window.COURSE 字段名均来自 _shared/domain-primitives.md。如果下面的 schema 示例与该文件不一致,以该文件为准。

入门模板(直接复制,不要重新推导):

参考实现: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 内容转换为可运行的单页应用。在开始之前,应先了解这些经过深思熟虑的架构决策。

三项架构决策

  1. 使用原生 HTML + JS,不使用框架或打包工具。 不用 React、Vite 或 TypeScript 构建。原因:课程网站的生命周期长达数年,而且经常需要交接给非开发人员的讲师。相比留下满是 node_modules 的目录,一个可直接打开和编辑的 .html 文件始终更好。此外,真正有价值的是内容,而不是技术。

  2. 数据与渲染分别放在不同文件中。 course-data.js 将课程的所有内容作为单个 JS 对象(window.COURSE)保存。index.html 保存课程的展示方式。讲师编辑明天的课程时,无需修改 HTML。

  3. 使用 localStorage 持久化,不设置后端。 进度、主题、缩放——全部在客户端完成。托管更简单,也无需担心隐私问题。代价是:状态按浏览器分别保存。请接受这一点。

标准文件布局

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 通过普通的 <script src="course-data.js"></script> 引入数据文件——不使用模块导入或 fetch。浏览器会按顺序加载这些文件,在 init() 运行前填充 window.COURSE。

window.COURSE Schema

这是最重要的产物。请谨慎设计——每个渲染函数都依赖它。标准结构如下:

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' }
  ]
};

渲染流水线

index.html 底部有一个大型 <script>。其结构如下:

// 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);

让每个渲染函数都保持纯粹:它接收数据,并生成 DOM。除参数外,不读取任何全局变量。这样添加新章节就会非常简单。

素材 URL 路由器(getMaterialUrl)

这是最棘手的部分。SPA 需要将素材的 name 和 type 映射到磁盘上的真实 URL。路由器位于脚本区靠上方的位置:

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`;
  // ...
}

三处同步规则——添加素材时,以下各项都要更新:

  1. 将文件放入 course-package/materials/
  2. 在 course-data.js:materials[] 中添加条目(并添加到任意单元的 materials[] 中)
  3. 在 getMaterialUrl() 中添加 name.includes(...) 规则

漏掉任何一项都会导致链接失效。可以考虑添加 CI 检查,比较文件系统、数据和路由器之间的差异。

任务 ID 生命周期

// 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
  ]);
}

发布后绝不要更改 task.id。 localStorage 键 tasks[task.id] 用于在多次访问之间保留数据。重命名会清除所有学生的进度。

本地开发

# Required: file:// blocks localStorage in Chrome — always serve via HTTP
npx serve .
# → http://localhost:3000

将以下内容添加到 package.json:

{
  "scripts": {
    "serve": "npx serve ."
  }
}

请在任何 README 中醒目地说明这一点。双击 index.html 会导致一个令人困惑的“我的进度没有保存”问题,白白浪费数小时。

迁移:Markdown → course-data.js

将现有的 .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 一致。

此技能移交时

告诉用户:

  • “SPA 脚手架已就绪。运行 npm run serve,然后打开 http://localhost:3000。”
  • “接下来:调用 static-spa-interactions,实现进度持久化、侧边栏、主题和 RWD。”
  • “从现在起,不要重命名 course-data.js 路径或任务 ID——学生的 localStorage 依赖这些内容。”

评论

支持 Markdown