| name | static-spa-conversion |
| description | Use this skill when you have structured course content (or any chapter-based dataset) in markdown form and need to turn it into a working interactive website — without picking a framework, without a build step. Triggers on phrases like "做成網頁", "轉成 SPA", "course-data.js", "render 函式", "把講義變網頁", "static site from markdown", "vanilla JS site", "no-framework site", "single-page app from markdown". The output is a vanilla HTML + JS single-page app that opens with `npx serve` and persists state in localStorage. Always invoke AFTER `course-content-authoring` (content stable), BEFORE `static-spa-interactions` (this skill produces the scaffold; interactions adorn it). |
Static SPA Conversion
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):
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.
The Three Architectural Commitments
-
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.
-
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.
-
localStorage for persistence, no backend. Progress, theme, zoom — all client-side. Easier hosting, no privacy headaches. The cost: state is per-browser. Accept it.
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.
The window.COURSE Schema
The single most important artifact. Design it carefully — every render function depends on it. Standard shape:
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',
title: '...',
timeRange: '14:00–14:50',
goals: [...],
tasks: [
{ id: 'd1-u1-t1', text: '...', done: false }
],
prompts: [
{ id: '...', title: '...', body: '...' }
],
materials: [
{ id: '...', name: '...', type: 'PDF 文件' }
],
illustrations: [
{ name: 'day1-u1-hero.png', kind: 'hero', alt: '...', spec: '...' },
{ name: 'day1-u1-flow.svg', kind: 'diagram', alt: '...', spec: '...' }
]
},
...
]
},
day2: { },
...
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:
const el = (tag, attrs, children) => { };
const store = {
load() { },
save(state) { },
reset() { }
};
function renderOverview(meta) { ... }
function renderSharedCase(case_) { ... }
function renderDay(day) { ... }
function renderUnit(unit, dayId) { ... }
function renderMaterials(materials) { ... }
function renderToolbox(tools) { ... }
function renderQuiz(quiz) { ... }
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);
}
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.
The Material URL Router (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:
function getMaterialUrl(name, type) {
const base = 'course-package/materials';
if (type === 'PDF 文件') {
if (name.includes('員工差勤')) return `${base}/pdf/員工差勤辦法.pdf`;
if (name.includes('客訴SOP')) return `${base}/pdf/客訴處理SOP.pdf`;
return null;
}
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:
- Drop the file into
course-package/materials/
- Add an entry to
course-data.js:materials[] (and any unit's materials[])
- 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.
The Task ID Lifecycle
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
]);
}
Never change task.id once published. The localStorage key tasks[task.id] is what survives across visits. Renaming wipes progress for every student.
Local Development
# Required: file:// blocks localStorage in Chrome — always serve via HTTP
npx serve .
# → http://localhost:3000
Add this to package.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.
Migration: Markdown → course-data.js
When porting an existing .md outline + content to course-data.js:
- Parse the outline first to get the day/unit skeleton.
- For each unit's content
.md, extract tasks, prompts, materials references using grep-style passes (look for the marker patterns from course-content-authoring).
- 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.
- 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."