static-spa-conversion について
static-spa-conversionは、章立てのMarkdownを静的なコース用シングルページアプリに変換するスキルです。フレームワークやバンドラーを使わず、コースデータと表示を分離し、進捗などをlocalStorageに保存します。
主な機能
- 01Markdownのコース内容をSPAに変換
- 02フレームワーク不要のHTMLとJS構成
- 03window.COURSEでコースデータを管理
- 04localStorageで学習進捗を保存
- 05教材名と種類からファイルURLを解決
活用シーン
- 01章立ての教材をウェブサイト化
- 02コースサイトのデータとページ構成を作成
- 03教材ファイルをコース内容にリンク
- 04保守しやすいコース用SPAを設計
インストール
コマンドをコピーしてターミナルで実行します。
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 への変換
スキーマの正本:すべての
window.COURSEフィールド名は_shared/domain-primitives.mdに準拠します。以下のスキーマ例がこのファイルと異なる場合は、このファイルを優先します。スターターテンプレート(再構成せず、これらをコピーしてください):
templates/index.html— レンダリングパイプラインの骨組みを含むシェルtemplates/course-data.template.js— すべてのプリミティブに TODO マーカーを付けた、空のスキーマ例../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 コンテンツを、動作するシングルページアプリに変換します。アーキテクチャ上の方針は意図的に定められており、作業を始める前に理解しておく価値があります。
3 つのアーキテクチャ上の方針
-
バニラ HTML + JS、フレームワークもバンドラーもなし。 React も Vite も TypeScript のビルドも使いません。理由:コースのウェブサイトは何年も使われることが多く、開発者ではない講師に引き継がれる場合もあります。いつでも開いて編集できる
.htmlのほうが、node_modulesの墓場より優れています。また、価値があるのは技術ではなくコンテンツです。 -
データとレンダリングは別々のファイルに置きます。
course-data.jsには、コースのすべてを単一の JS オブジェクト(window.COURSE)として格納します。index.htmlには、コースの表示方法を記述します。講師が翌日のレッスンを編集する際に、HTML に触れる必要はありません。 -
永続化には 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 スキーマ
最も重要な成果物です。すべてのレンダリング関数がこれに依存するため、慎重に設計してください。標準的な形式:
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 つあります。その構成:
// 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`;
// ...
}
3 か所の同期ルール — 素材を追加するときは、次のすべてを更新します。
- ファイルを
course-package/materials/に配置する course-data.js:materials[](および各ユニットのmaterials[])にエントリーを追加する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 に移行する場合:
- まず概要を解析して、日/単元の骨組みを把握します。
- 各単元のコンテンツ
.mdから、grep のような方法でtasks、prompts、materialsの参照を抽出します(course-content-authoringのマーカーパターンを探します)。 - 大きなオブジェクトリテラルとして
course-data.jsを生成します。プログラムで見栄えを整えようとせず、prettier/eslint で整形するか、そのままにしておきます。 - 手作業で確認します。Markdown 内のすべての単元 ID が
course-data.jsに存在すること、日数と時間数がmetaと一致することを確認します。
このスキルから引き継ぐ際
ユーザーに次のように伝えます:
- "SPA のスキャフォールドが完成しました。
npm run serveを実行して、http://localhost:3000 を開いてください。" - "次に、進捗の永続化、サイドバー、テーマ、RWD のために
static-spa-interactionsを呼び出してください。" - "この時点から
course-data.jsのパスやタスク ID の名前を変更しないでください。学生の localStorage がこれらに依存しています。"