Loading...
Loading...
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).
npx skill4agent add kevintsai1202/teaching-site-skills static-spa-conversionSchema authority: allfield names come fromwindow.COURSE. When the schema example below diverges from that file, that file wins._shared/domain-primitives.mdStarter templates (copy these, don't re-derive):
— scaffold shell with render pipeline skeletontemplates/index.html — empty schema example with TODO markers for every primitivetemplates/course-data.template.js — paste into../teaching-site-design-system/templates/tokens.cssblock, or copy as<style>next tostyle.cssindex.htmlReference implementation:(4125 lines, fully populated). Use as visual + render-pattern reference; do not copy course-specific content from it.d:/GitHub/ai-workshop/index.htmlFilename convention (English-first):,course-package/,day{n}/outline.md,day{n}/content.md,materials/. Legacy Chinese names (overview.md,完整課程包/,課程大綱.md, etc.) are deprecated — see教學素材/§0 for full mapping._shared/domain-primitives.md
.htmlnode_modulescourse-data.jswindow.COURSEindex.htmlproject-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>window.COURSEinit()window.COURSEwindow.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);getMaterialUrlnametypefunction 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`;
// ...
}course-package/materials/course-data.js:materials[]materials[]name.includes(...)getMaterialUrl()// 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.idtasks[task.id]# Required: file:// blocks localStorage in Chrome — always serve via HTTP
npx serve .
# → http://localhost:3000package.json{
"scripts": {
"serve": "npx serve ."
}
}index.html.mdcourse-data.js.mdtaskspromptsmaterialscourse-content-authoringcourse-data.jscourse-data.jsmetanpm run servestatic-spa-interactionscourse-data.js