coursecast 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/bin/coursecast.mjs +4 -3
- package/package.json +1 -1
- package/src/engines/{claude-code.mjs → claude.mjs} +24 -16
- package/src/engines/codex.mjs +24 -45
- package/src/engines/gemini-cli.mjs +61 -0
- package/src/engines/gemini.mjs +18 -12
- package/src/engines/shared-prompts.mjs +117 -1
- package/src/index.mjs +8 -4
- package/src/render-dashboard.mjs +7 -6
- package/src/render.mjs +115 -21
- package/src/widgets/flashcards.mjs +3 -3
- package/src/widgets/index.mjs +21 -5
- package/src/widgets/reveal-diagram.mjs +3 -3
package/README.md
CHANGED
|
@@ -34,9 +34,10 @@ The engine is what writes the content. Pick with `--engine`:
|
|
|
34
34
|
|
|
35
35
|
| Engine | Runs on | Needs |
|
|
36
36
|
|---|---|---|
|
|
37
|
-
| `claude
|
|
38
|
-
| `codex` | your Codex CLI | `codex` on PATH |
|
|
39
|
-
| `gemini` |
|
|
37
|
+
| `claude` *(default)* | your [Claude Code](https://claude.com/claude-code) CLI | `claude` on PATH |
|
|
38
|
+
| `codex` | your [Codex](https://developers.openai.com/codex/cli/) CLI | `codex` on PATH |
|
|
39
|
+
| `gemini-cli` | your [Gemini CLI](https://github.com/google-gemini/gemini-cli) login | `gemini` on PATH — **no API key** |
|
|
40
|
+
| `gemini` | the [Gemini API](https://ai.google.dev/), search-grounded | `GEMINI_API_KEY` ([free key](https://aistudio.google.com/apikey)) + your `--model` choice |
|
|
40
41
|
|
|
41
42
|
```bash
|
|
42
43
|
coursecast "learn rust" --engine gemini --model gemini-3.5-flash-lite
|
|
@@ -48,7 +49,7 @@ whatever your key has access to. `COURSECAST_RESEARCH=0` skips the web-search re
|
|
|
48
49
|
## All flags
|
|
49
50
|
|
|
50
51
|
```
|
|
51
|
-
--engine <id> claude
|
|
52
|
+
--engine <id> claude (default) | gemini | codex
|
|
52
53
|
--model <id> model hint passed to the engine
|
|
53
54
|
--storage <id> progress storage: local (default) | cloud
|
|
54
55
|
--provider <id> deploy target: vercel (default) | netlify
|
package/bin/coursecast.mjs
CHANGED
|
@@ -24,7 +24,7 @@ USAGE
|
|
|
24
24
|
coursecast "<topic>" [options]
|
|
25
25
|
|
|
26
26
|
OPTIONS
|
|
27
|
-
--engine <id> generation engine: claude
|
|
27
|
+
--engine <id> generation engine: claude (default), gemini, gemini-cli, codex, stub
|
|
28
28
|
--model <id> model to use (required for gemini, e.g. gemini-3.5-flash-lite)
|
|
29
29
|
--storage <id> progress storage: local (default, no setup), cloud (sync across devices)
|
|
30
30
|
--provider <id> deploy target: vercel | netlify (auto-detects what you have installed)
|
|
@@ -37,8 +37,9 @@ OPTIONS
|
|
|
37
37
|
-h, --help show this help
|
|
38
38
|
|
|
39
39
|
EXAMPLES
|
|
40
|
-
coursecast "kubernetes basics" # default engine (claude
|
|
40
|
+
coursecast "kubernetes basics" # default engine (claude)
|
|
41
41
|
coursecast "how DNS works" --engine gemini --model gemini-3.5-flash-lite
|
|
42
|
+
coursecast "how DNS works" --engine gemini-cli # no API key — uses your Gemini CLI login
|
|
42
43
|
coursecast "intro to nuclear power" --deploy # generate + ship to your Vercel
|
|
43
44
|
`);
|
|
44
45
|
}
|
|
@@ -56,7 +57,7 @@ async function main() {
|
|
|
56
57
|
process.exit(1);
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
const engine = getFlag('engine', 'claude
|
|
60
|
+
const engine = getFlag('engine', 'claude');
|
|
60
61
|
const providerFlag = getFlag('provider', undefined);
|
|
61
62
|
const model = getFlag('model', undefined);
|
|
62
63
|
const storage = getFlag('storage', 'local');
|
package/package.json
CHANGED
|
@@ -1,30 +1,38 @@
|
|
|
1
|
-
// claude
|
|
1
|
+
// claude engine — drives the Claude Code CLI headlessly to generate real lesson content.
|
|
2
2
|
// Requires `claude` on PATH (https://claude.ai/code). Highest quality; reuses the same
|
|
3
3
|
// research+authoring the /learn skill uses. Output is a lesson content object (see shared-prompts).
|
|
4
4
|
|
|
5
5
|
import { spawn } from 'node:child_process'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
buildSyllabusPrompt, buildLessonPrompt, extractJsonObject,
|
|
8
|
+
generateSyllabusWithRepair, generateLessonWithRepair,
|
|
9
|
+
} from './shared-prompts.mjs'
|
|
7
10
|
|
|
8
|
-
export const name = 'claude
|
|
11
|
+
export const name = 'claude'
|
|
9
12
|
|
|
10
13
|
export async function generateSyllabus({ topic, lessons = 12, model }) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
return generateSyllabusWithRepair({
|
|
15
|
+
call: callModel(model),
|
|
16
|
+
prompt: buildSyllabusPrompt({ topic, lessons }),
|
|
17
|
+
label: 'claude engine',
|
|
18
|
+
})
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export async function generateLesson({ topic, slug, model, context }) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
return generateLessonWithRepair({
|
|
23
|
+
call: callModel(model),
|
|
24
|
+
prompt: buildLessonPrompt({ topic, slug, context }),
|
|
25
|
+
slug, context, label: 'claude engine',
|
|
26
|
+
})
|
|
27
|
+
}
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
// One text-in/text-out call through the local CLI, unwrapped from its JSON envelope.
|
|
30
|
+
function callModel(model) {
|
|
31
|
+
return async (prompt) => {
|
|
32
|
+
const args = ['-p', prompt, '--output-format', 'json']
|
|
33
|
+
if (model) args.push('--model', model)
|
|
34
|
+
return extractText(await run('claude', args))
|
|
35
|
+
}
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
function run(cmd, args) {
|
package/src/engines/codex.mjs
CHANGED
|
@@ -1,49 +1,41 @@
|
|
|
1
1
|
// codex engine — drives the OpenAI Codex CLI headlessly to generate real content, so a
|
|
2
|
-
// user with a Codex subscription can generate without a raw API key (mirrors claude
|
|
3
|
-
// Requires `codex` on PATH. Output is a lesson/syllabus content object (see
|
|
2
|
+
// user with a Codex subscription can generate without a raw API key (mirrors the claude engine).
|
|
3
|
+
// Requires `codex` on PATH. Output is a lesson/syllabus content object (see shared-prompts).
|
|
4
4
|
//
|
|
5
5
|
// Codex CLI non-interactive mode: `codex exec "<prompt>"` prints the model's final answer
|
|
6
6
|
// to stdout. We ask for a bare JSON object and extract it.
|
|
7
|
+
//
|
|
8
|
+
// This engine used to carry its OWN copy of the schemas and prompts, which meant it silently
|
|
9
|
+
// missed every authoring rule added to the shared contract (valid-HTML rules, labelled form
|
|
10
|
+
// controls, correct quiz answer keys). It now uses shared-prompts like the other engines, so
|
|
11
|
+
// improving the prompt improves every engine at once.
|
|
7
12
|
|
|
8
13
|
import { spawn } from 'node:child_process';
|
|
14
|
+
import {
|
|
15
|
+
buildSyllabusPrompt, buildLessonPrompt,
|
|
16
|
+
generateSyllabusWithRepair, generateLessonWithRepair,
|
|
17
|
+
} from './shared-prompts.mjs';
|
|
9
18
|
|
|
10
19
|
export const name = 'codex';
|
|
11
20
|
|
|
12
|
-
const
|
|
13
|
-
"slug": string (kebab-case), "title": string, "courseTitle": string, "lessonLabel": string,
|
|
14
|
-
"kicker": string, "lede": string (HTML ok), "tiles": [{ "big": string, "lbl": string, "note"?: string }],
|
|
15
|
-
"analogy": { "tag": "Analogy", "text": string }, "sections": [{ "h2": string, "html": string }],
|
|
16
|
-
"simulation": { "title": string, "sub": string, "html": string, "js": string },
|
|
17
|
-
"widgets": [{ "type": "what-if"|"flashcards"|"reveal-diagram", "config": object }],
|
|
18
|
-
"terminal": [{ "cmd": string, "explain": string }], "checklist": [{ "title": string, "desc": string, "hint"?: string }],
|
|
19
|
-
"quiz": [{ "q": string, "opts": [string], "a": number, "fb": string }], "next": null, "footer": string
|
|
20
|
-
}`;
|
|
21
|
-
|
|
22
|
-
const SYLLABUS_SCHEMA = `{
|
|
23
|
-
"courseTitle": string, "courseSubtitle": string,
|
|
24
|
-
"phases": [ { "name": string, "lessons": [ { "stem": string (kebab-case, unique), "title": string, "desc": string } ] } ]
|
|
25
|
-
}`;
|
|
21
|
+
const call = async (prompt) => run('codex', ['exec', prompt]);
|
|
26
22
|
|
|
27
23
|
export async function generateSyllabus({ topic, lessons = 12 }) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
return generateSyllabusWithRepair({
|
|
25
|
+
call,
|
|
26
|
+
prompt: buildSyllabusPrompt({ topic, lessons }),
|
|
27
|
+
label: 'codex engine',
|
|
28
|
+
});
|
|
32
29
|
}
|
|
33
30
|
|
|
34
31
|
export async function generateLesson({ topic, slug, context }) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (ctx.title) obj.title = ctx.title;
|
|
43
|
-
if (ctx.lessonLabel) obj.lessonLabel = ctx.lessonLabel;
|
|
44
|
-
if (ctx.kicker) obj.kicker = ctx.kicker;
|
|
45
|
-
if (ctx.next !== undefined) obj.next = ctx.next;
|
|
46
|
-
return obj;
|
|
32
|
+
return generateLessonWithRepair({
|
|
33
|
+
call,
|
|
34
|
+
prompt: buildLessonPrompt({ topic, slug, context }),
|
|
35
|
+
slug,
|
|
36
|
+
context,
|
|
37
|
+
label: 'codex engine',
|
|
38
|
+
});
|
|
47
39
|
}
|
|
48
40
|
|
|
49
41
|
function run(cmd, args) {
|
|
@@ -56,16 +48,3 @@ function run(cmd, args) {
|
|
|
56
48
|
p.on('close', code => code === 0 ? resolve(out) : reject(new Error(`${cmd} exited ${code}: ${err || out}`)));
|
|
57
49
|
});
|
|
58
50
|
}
|
|
59
|
-
|
|
60
|
-
// Tolerant JSON extraction: strip ``` fences, then take the outermost {...}. (json-repair-lite)
|
|
61
|
-
function extractJsonObject(text) {
|
|
62
|
-
let t = String(text).replace(/```(?:json)?/gi, '');
|
|
63
|
-
const s = t.indexOf('{'), e = t.lastIndexOf('}');
|
|
64
|
-
if (s === -1 || e === -1) return null;
|
|
65
|
-
const slice = t.slice(s, e + 1);
|
|
66
|
-
try { return JSON.parse(slice); }
|
|
67
|
-
catch {
|
|
68
|
-
try { return JSON.parse(slice.replace(/,\s*([}\]])/g, '$1')); } // drop trailing commas
|
|
69
|
-
catch { return null; }
|
|
70
|
-
}
|
|
71
|
-
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// gemini-cli engine — drives Google's Gemini CLI headlessly, so you can generate with your
|
|
2
|
+
// signed-in Google account and NO API key (the CLI's free tier covers a lot of courses).
|
|
3
|
+
// Requires `gemini` on PATH (https://github.com/google-gemini/gemini-cli, then `gemini` once
|
|
4
|
+
// to sign in). This is the key-less sibling of the `gemini` engine, which calls the API.
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import {
|
|
8
|
+
buildSyllabusPrompt, buildLessonPrompt, extractJsonObject,
|
|
9
|
+
generateSyllabusWithRepair, generateLessonWithRepair,
|
|
10
|
+
} from './shared-prompts.mjs'
|
|
11
|
+
|
|
12
|
+
export const name = 'gemini-cli'
|
|
13
|
+
|
|
14
|
+
// `--output-format json` wraps the answer in an envelope; the lesson JSON is inside it.
|
|
15
|
+
function extractText(raw) {
|
|
16
|
+
const env = extractJsonObject(raw)
|
|
17
|
+
if (env && typeof env.response === 'string') return env.response
|
|
18
|
+
if (env && typeof env.output === 'string') return env.output
|
|
19
|
+
return raw
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function call(model) {
|
|
23
|
+
return async (prompt) => {
|
|
24
|
+
const args = ['-p', prompt, '--output-format', 'json']
|
|
25
|
+
if (model) args.push('-m', model)
|
|
26
|
+
return extractText(await run('gemini', args))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function generateSyllabus({ topic, lessons = 12, model }) {
|
|
31
|
+
return generateSyllabusWithRepair({
|
|
32
|
+
call: call(model),
|
|
33
|
+
prompt: buildSyllabusPrompt({ topic, lessons }),
|
|
34
|
+
label: 'gemini-cli engine',
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function generateLesson({ topic, slug, model, context }) {
|
|
39
|
+
return generateLessonWithRepair({
|
|
40
|
+
call: call(model),
|
|
41
|
+
prompt: buildLessonPrompt({ topic, slug, context }),
|
|
42
|
+
slug,
|
|
43
|
+
context,
|
|
44
|
+
label: 'gemini-cli engine',
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function run(cmd, args) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
51
|
+
let out = '', err = ''
|
|
52
|
+
p.stdout.on('data', (d) => (out += d))
|
|
53
|
+
p.stderr.on('data', (d) => (err += d))
|
|
54
|
+
p.on('error', (e) =>
|
|
55
|
+
reject(new Error(
|
|
56
|
+
`Cannot run "gemini" — install Google's Gemini CLI and sign in:\n` +
|
|
57
|
+
` npm i -g @google/gemini-cli && gemini\n(${e.message})`
|
|
58
|
+
)))
|
|
59
|
+
p.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(`gemini exited ${code}: ${err || out}`))))
|
|
60
|
+
})
|
|
61
|
+
}
|
package/src/engines/gemini.mjs
CHANGED
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
// picks one for you (it's your key, your cost, your quality bar).
|
|
9
9
|
// Optional: COURSECAST_RESEARCH=0 to skip the web-search research pass.
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
buildSyllabusPrompt, buildLessonPrompt,
|
|
13
|
+
generateSyllabusWithRepair, generateLessonWithRepair,
|
|
14
|
+
} from './shared-prompts.mjs'
|
|
12
15
|
|
|
13
16
|
export const name = 'gemini'
|
|
14
17
|
|
|
@@ -18,7 +21,10 @@ const RESEARCH_ON = process.env.COURSECAST_RESEARCH !== '0'
|
|
|
18
21
|
|
|
19
22
|
function apiKey() {
|
|
20
23
|
const k = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
|
|
21
|
-
if (!k) throw new Error(
|
|
24
|
+
if (!k) throw new Error(
|
|
25
|
+
'gemini engine: set GEMINI_API_KEY (free key at https://aistudio.google.com/apikey)\n' +
|
|
26
|
+
' …or skip the key entirely with --engine gemini-cli, which uses your signed-in Gemini CLI.'
|
|
27
|
+
)
|
|
22
28
|
return k
|
|
23
29
|
}
|
|
24
30
|
|
|
@@ -80,20 +86,20 @@ function withResearch(prompt, brief) {
|
|
|
80
86
|
|
|
81
87
|
export async function generateSyllabus({ topic, lessons = 12, model }) {
|
|
82
88
|
const brief = await research({ topic, focus: 'the full scope a learner must cover, in learning order', model })
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
return generateSyllabusWithRepair({
|
|
90
|
+
call: (prompt) => callGemini({ model, prompt, json: true, maxOutputTokens: 8192 }),
|
|
91
|
+
prompt: withResearch(buildSyllabusPrompt({ topic, lessons }), brief),
|
|
92
|
+
label: 'gemini engine',
|
|
93
|
+
})
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
export async function generateLesson({ topic, slug, model, context }) {
|
|
91
97
|
const focus = context?.title ? `the lesson "${context.title}" (${context.kicker || ''})` : slug
|
|
92
98
|
const brief = await research({ topic, focus, model })
|
|
93
|
-
const prompt = withResearch(buildLessonPrompt({ topic, slug, context }), brief)
|
|
94
99
|
// Lessons are large (sections + a simulation + quiz), so allow plenty of output room.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
100
|
+
return generateLessonWithRepair({
|
|
101
|
+
call: (prompt) => callGemini({ model, prompt, json: true, maxOutputTokens: 65536 }),
|
|
102
|
+
prompt: withResearch(buildLessonPrompt({ topic, slug, context }), brief),
|
|
103
|
+
slug, context, label: 'gemini engine',
|
|
104
|
+
})
|
|
99
105
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Shared generation contract — the schemas, prompts, and post-processing every engine uses.
|
|
2
|
-
// Keeping these in one place means the claude
|
|
2
|
+
// Keeping these in one place means the claude, gemini, and any future engine all produce
|
|
3
3
|
// the exact same lesson shape and are held to the same authoring bar; only the transport differs.
|
|
4
4
|
|
|
5
5
|
export const LESSON_SCHEMA = `{
|
|
@@ -59,6 +59,29 @@ Rules:
|
|
|
59
59
|
- Playful but not childish; a total beginner must be able to follow. Analogies for abstract ideas.
|
|
60
60
|
- The simulation must be genuinely interactive and teach the lesson's deepest idea (e.g. a slider, a toggle, a click-through). Vanilla JS only; no external libraries. Give its elements unique ids.
|
|
61
61
|
- All facts must be current and correct. Commands must actually work.
|
|
62
|
+
- The quiz answer index "a" must be the genuinely correct option — a learner is graded on it.
|
|
63
|
+
- Only include "terminal" when the topic really involves a command line; for non-technical
|
|
64
|
+
subjects omit it rather than inventing shell commands.
|
|
65
|
+
|
|
66
|
+
HTML rules for every "html" field you write (sections[].html, simulation.html) — these are
|
|
67
|
+
injected into a page as-is, so malformed markup breaks the lesson:
|
|
68
|
+
- Write valid, self-contained HTML fragments only. NEVER include <style>, <script>, <html>,
|
|
69
|
+
<head> or <body> tags — style with inline style="" attributes or the shared CSS classes.
|
|
70
|
+
- Never put a block element (<div>, <p>, <table>, <ul>) inside an inline element (<span>, <b>, <a>).
|
|
71
|
+
- Escape ampersands as & (and < as <) in text; a raw "&" is invalid.
|
|
72
|
+
- Every <button> needs type="button".
|
|
73
|
+
- Every form control (<input>, <select>, <textarea>) needs an accessible name: either a
|
|
74
|
+
<label for="id"> pointing at it, or an aria-label. Sliders and toggles included.
|
|
75
|
+
- Don't use heading tags inside sections[].html — the section's "h2" is already the heading;
|
|
76
|
+
use <b>/<strong> for sub-emphasis so the page's heading order stays valid.
|
|
77
|
+
|
|
78
|
+
Math and symbols — there is NO LaTeX/MathJax renderer on the page:
|
|
79
|
+
- NEVER write $...$, \(...\), \[...\] or backslash macros (\frac, \sum, \Delta, \mathbf,
|
|
80
|
+
\text). They ship to the learner as literal, broken-looking text.
|
|
81
|
+
- Express maths in plain Unicode: superscripts ²³ⁿ, subscripts ₀₁ₙ, Δ α β θ λ π Σ ∫ √ ≈ ≤ ≥ ×
|
|
82
|
+
· → ∞ ∇, and chemistry as CO₂ / H₂O. Write "dA/dt = h/2", not "$\frac{dA}{dt}=\frac{h}{2}$".
|
|
83
|
+
- quiz "opts", flashcard "front"/"back" and tiles "big" are inserted as TEXT, not HTML — put no
|
|
84
|
+
tags in them at all. Inside sections[].html you may also use <sub>, <sup> and <code>.
|
|
62
85
|
- Output must be valid JSON and nothing else.`
|
|
63
86
|
}
|
|
64
87
|
|
|
@@ -86,3 +109,96 @@ export function extractJsonObject(text) {
|
|
|
86
109
|
catch { return null }
|
|
87
110
|
}
|
|
88
111
|
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------- validate + repair
|
|
114
|
+
//
|
|
115
|
+
// Models drop a required field or mis-index a quiz answer often enough that a silent
|
|
116
|
+
// half-broken lesson is a real quality problem — and a wrong quiz answer key actively
|
|
117
|
+
// mis-teaches. So every engine validates the parsed JSON against the schema and, on a
|
|
118
|
+
// miss, gets ONE repair attempt that shows the model its own output and the exact errors.
|
|
119
|
+
// One extra call on the minority of generations that need it; no cost on the ones that don't.
|
|
120
|
+
|
|
121
|
+
export function validateLesson(obj) {
|
|
122
|
+
const e = []
|
|
123
|
+
if (!obj || typeof obj !== 'object') return ['the output was not a JSON object']
|
|
124
|
+
if (!obj.title) e.push('"title" is missing')
|
|
125
|
+
if (!Array.isArray(obj.sections) || !obj.sections.length) e.push('"sections" must be a non-empty array of {h2, html}')
|
|
126
|
+
else obj.sections.forEach((s, i) => {
|
|
127
|
+
if (!s || !s.h2) e.push(`sections[${i}] is missing "h2"`)
|
|
128
|
+
if (!s || !s.html) e.push(`sections[${i}] is missing "html"`)
|
|
129
|
+
})
|
|
130
|
+
if (obj.quiz !== undefined) {
|
|
131
|
+
if (!Array.isArray(obj.quiz)) e.push('"quiz" must be an array')
|
|
132
|
+
else obj.quiz.forEach((q, i) => {
|
|
133
|
+
if (!q || !q.q) e.push(`quiz[${i}] is missing the question "q"`)
|
|
134
|
+
if (!q || !Array.isArray(q.opts) || q.opts.length < 2) e.push(`quiz[${i}] needs at least 2 "opts"`)
|
|
135
|
+
else if (typeof q.a !== 'number' || !Number.isInteger(q.a) || q.a < 0 || q.a >= q.opts.length)
|
|
136
|
+
e.push(`quiz[${i}].a must be the 0-based index of the CORRECT option (0..${q.opts.length - 1}), got ${JSON.stringify(q && q.a)}`)
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
if (obj.checklist !== undefined && !Array.isArray(obj.checklist)) e.push('"checklist" must be an array')
|
|
140
|
+
if (obj.tiles !== undefined && !Array.isArray(obj.tiles)) e.push('"tiles" must be an array')
|
|
141
|
+
if (obj.simulation) {
|
|
142
|
+
if (!obj.simulation.html) e.push('"simulation" is missing "html"')
|
|
143
|
+
if (!obj.simulation.js) e.push('"simulation" is missing "js"')
|
|
144
|
+
}
|
|
145
|
+
return e
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function validateSyllabus(obj) {
|
|
149
|
+
const e = []
|
|
150
|
+
if (!obj || typeof obj !== 'object') return ['the output was not a JSON object']
|
|
151
|
+
if (!obj.courseTitle) e.push('"courseTitle" is missing')
|
|
152
|
+
if (!Array.isArray(obj.phases) || !obj.phases.length) e.push('"phases" must be a non-empty array')
|
|
153
|
+
else obj.phases.forEach((p, i) => {
|
|
154
|
+
if (!p || !p.name) e.push(`phases[${i}] is missing "name"`)
|
|
155
|
+
if (!p || !Array.isArray(p.lessons) || !p.lessons.length) e.push(`phases[${i}].lessons must be a non-empty array`)
|
|
156
|
+
else p.lessons.forEach((l, k) => { if (!l || !l.title) e.push(`phases[${i}].lessons[${k}] is missing "title"`) })
|
|
157
|
+
})
|
|
158
|
+
return e
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function repairPrompt(original, output, errors) {
|
|
162
|
+
return `Your previous response did not match the required schema.
|
|
163
|
+
|
|
164
|
+
ERRORS:
|
|
165
|
+
${errors.map((x) => `- ${x}`).join('\n')}
|
|
166
|
+
|
|
167
|
+
YOUR PREVIOUS OUTPUT:
|
|
168
|
+
${String(output).slice(0, 12000)}
|
|
169
|
+
|
|
170
|
+
Fix ONLY those errors and return the COMPLETE corrected JSON object — all the original
|
|
171
|
+
content, with the problems above resolved. Output ONLY the JSON, no prose, no fences.
|
|
172
|
+
|
|
173
|
+
The original request, for reference:
|
|
174
|
+
${original}`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Run `call(prompt)` -> text, parse, validate, and retry once with the errors if needed.
|
|
178
|
+
// Keeps whichever attempt validates better, so a repair can never make things worse.
|
|
179
|
+
async function withRepair({ call, prompt, validate, label, what }) {
|
|
180
|
+
const raw = await call(prompt)
|
|
181
|
+
let obj = extractJsonObject(raw)
|
|
182
|
+
let errs = validate(obj)
|
|
183
|
+
if (errs.length) {
|
|
184
|
+
try {
|
|
185
|
+
const raw2 = await call(repairPrompt(prompt, raw, errs))
|
|
186
|
+
const obj2 = extractJsonObject(raw2)
|
|
187
|
+
const errs2 = validate(obj2)
|
|
188
|
+
if (errs2.length < errs.length) { obj = obj2; errs = errs2 }
|
|
189
|
+
} catch { /* keep the first attempt if the repair call itself fails */ }
|
|
190
|
+
}
|
|
191
|
+
if (!obj || errs.length) {
|
|
192
|
+
throw new Error(`${label}: could not produce a valid ${what} (${errs.slice(0, 3).join('; ') || 'unparseable output'})`)
|
|
193
|
+
}
|
|
194
|
+
return obj
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function generateSyllabusWithRepair({ call, prompt, label = 'engine' }) {
|
|
198
|
+
return withRepair({ call, prompt, validate: validateSyllabus, label, what: 'syllabus' })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function generateLessonWithRepair({ call, prompt, slug, context, label = 'engine' }) {
|
|
202
|
+
const obj = await withRepair({ call, prompt, validate: validateLesson, label, what: 'lesson' })
|
|
203
|
+
return finalizeLesson(obj, slug, context)
|
|
204
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -15,9 +15,13 @@ export function slugify(s) {
|
|
|
15
15
|
return slug;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// "claude-code" was the id before 0.2.6 — keep it working so existing scripts don't break.
|
|
19
|
+
const ENGINE_ALIASES = { 'claude-code': 'claude' };
|
|
20
|
+
|
|
18
21
|
async function loadEngine(id) {
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
const resolved = ENGINE_ALIASES[id] || id;
|
|
23
|
+
try { return await import(`./engines/${resolved}.mjs`); }
|
|
24
|
+
catch { throw new Error(`Unknown engine "${id}". Available: claude, gemini, gemini-cli, codex, stub.`); }
|
|
21
25
|
}
|
|
22
26
|
// Pick a deploy provider by what's actually installed: vercel first, then netlify.
|
|
23
27
|
// Auto-detection beats a silent default (which pretended netlify didn't exist) and
|
|
@@ -40,7 +44,7 @@ async function loadProvider(id) {
|
|
|
40
44
|
catch { throw new Error(`Unknown provider "${id}". Available: vercel, netlify.`); }
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
export async function createLesson({ topic, engine = 'claude
|
|
47
|
+
export async function createLesson({ topic, engine = 'claude', model, storage = 'local', outDir, log = () => {} }) {
|
|
44
48
|
const slug = slugify(topic);
|
|
45
49
|
const eng = await loadEngine(engine);
|
|
46
50
|
log(`🧠 Generating lesson with the "${eng.name}" engine…`);
|
|
@@ -64,7 +68,7 @@ export async function createLesson({ topic, engine = 'claude-code', model, stora
|
|
|
64
68
|
|
|
65
69
|
// Generate a whole multi-lesson course from one topic: syllabus -> lessons (parallel,
|
|
66
70
|
// with retry) -> mastery dashboard. Returns { dir, slug, manifest, lessons, failed }.
|
|
67
|
-
export async function createCourse({ topic, engine = 'claude
|
|
71
|
+
export async function createCourse({ topic, engine = 'claude', model, storage = 'local', outDir, lessons, concurrency = 4, log = () => {} }) {
|
|
68
72
|
concurrency = Math.max(1, concurrency || 4);
|
|
69
73
|
lessons = lessons ? Math.max(1, lessons) : undefined;
|
|
70
74
|
const slug = slugify(topic);
|
package/src/render-dashboard.mjs
CHANGED
|
@@ -14,8 +14,8 @@ const esc = (s = '') => String(s)
|
|
|
14
14
|
const jsonEmbed = v => JSON.stringify(v).replace(/</g, '\\u003c');
|
|
15
15
|
|
|
16
16
|
const CSS = `
|
|
17
|
-
:root{color-scheme:light;--page:#f9f9f7;--surface:#fcfcfb;--ink:#0b0b0b;--ink-2:#52514e;--muted:#
|
|
18
|
-
--mastered:#0ca30c;--progress:#2a78d6;--needs:#
|
|
17
|
+
:root{color-scheme:light;--page:#f9f9f7;--surface:#fcfcfb;--ink:#0b0b0b;--ink-2:#52514e;--muted:#67665f;--grid:#e1e0d9;--border:rgba(11,11,11,0.10);--accent:#2a78d6;
|
|
18
|
+
--mastered:#0ca30c;--progress:#2a78d6;--needs:#9a6400;--visited:#898781;--untouched:#c3c2b7;}
|
|
19
19
|
:root[data-theme="dark"]{color-scheme:dark;--page:#0d0d0d;--surface:#1a1a19;--ink:#fff;--ink-2:#c3c2b7;--muted:#898781;--grid:#2c2c2a;--border:rgba(255,255,255,0.10);--accent:#3987e5;
|
|
20
20
|
--mastered:#0ca30c;--progress:#3987e5;--needs:#c98500;--visited:#898781;--untouched:#3a3a38;}
|
|
21
21
|
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){color-scheme:dark;--page:#0d0d0d;--surface:#1a1a19;--ink:#fff;--ink-2:#c3c2b7;--muted:#898781;--grid:#2c2c2a;--border:rgba(255,255,255,0.10);--accent:#3987e5;--needs:#c98500;--visited:#898781;--untouched:#3a3a38;}}
|
|
@@ -49,6 +49,7 @@ const CSS = `
|
|
|
49
49
|
.badge{font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;border:1px solid var(--border);white-space:nowrap}
|
|
50
50
|
.phase-lessons a{border:1px solid var(--border);background:var(--surface);border-radius:11px;margin:8px 0}
|
|
51
51
|
.foot{margin-top:44px;font-size:13px;color:var(--muted);border-top:1px solid var(--grid);padding-top:16px;display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
|
52
|
+
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}
|
|
52
53
|
.reset{border:1px solid var(--border);background:none;color:var(--muted);border-radius:8px;padding:4px 10px;cursor:pointer;font-size:12px}
|
|
53
54
|
.empty{color:var(--muted);font-size:14px;padding:10px 16px}
|
|
54
55
|
`;
|
|
@@ -72,8 +73,8 @@ export function renderDashboard(manifest) {
|
|
|
72
73
|
<script>try{var t=localStorage.getItem("cc-theme");if(t)document.documentElement.dataset.theme=t;}catch(e){}</script>
|
|
73
74
|
</head>
|
|
74
75
|
<body>
|
|
75
|
-
<
|
|
76
|
-
<button class="theme-btn" id="themeBtn">◐ Theme</button>
|
|
76
|
+
<main class="wrap">
|
|
77
|
+
<button type="button" class="theme-btn" id="themeBtn">◐ Theme</button>
|
|
77
78
|
<div class="kicker">coursecast · Interactive Course</div>
|
|
78
79
|
<h1>${esc(m.courseTitle)}</h1>
|
|
79
80
|
<p class="lede">${esc(m.courseSubtitle || '')}</p>
|
|
@@ -98,9 +99,9 @@ export function renderDashboard(manifest) {
|
|
|
98
99
|
|
|
99
100
|
<div class="foot">
|
|
100
101
|
<span>Progress saves on this device. Generated by <b>coursecast</b>.</span>
|
|
101
|
-
<button class="reset" id="reset">Reset progress</button>
|
|
102
|
+
<button type="button" class="reset" id="reset">Reset progress</button>
|
|
102
103
|
</div>
|
|
103
|
-
</
|
|
104
|
+
</main>
|
|
104
105
|
<script>
|
|
105
106
|
(function(){
|
|
106
107
|
var LESSONS=${LDATA}, PHASES=${PHASES};
|
package/src/render.mjs
CHANGED
|
@@ -9,8 +9,87 @@ const esc = (s = '') => String(s)
|
|
|
9
9
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
10
10
|
.replace(/"/g, '"');
|
|
11
11
|
|
|
12
|
+
// Model-authored HTML (sections[].html, simulation.html) is injected as-is, so the prompt
|
|
13
|
+
// asks for well-formed fragments — but a generator that ignores the rules must not be able
|
|
14
|
+
// to break the page or its accessibility. This is a safety net, not a parser.
|
|
15
|
+
//
|
|
16
|
+
// Styles are HOISTED rather than dropped: a <style> inside a <div> is invalid markup, but
|
|
17
|
+
// deleting it would silently break the look of an AI-authored simulation. Collected CSS is
|
|
18
|
+
// emitted in <head>, which is valid and visually identical (each lesson is its own page, so
|
|
19
|
+
// there is nothing for it to leak into). <script> is left alone — it is valid flow content,
|
|
20
|
+
// and simulations legitimately inline their JS.
|
|
21
|
+
const INPUT_TYPES = new Set(['button', 'checkbox', 'color', 'date', 'datetime-local', 'email', 'file',
|
|
22
|
+
'hidden', 'image', 'month', 'number', 'password', 'radio', 'range', 'reset', 'search', 'submit',
|
|
23
|
+
'tel', 'text', 'time', 'url', 'week']);
|
|
24
|
+
|
|
25
|
+
function tidyFragment(html = '', sink) {
|
|
26
|
+
return String(html)
|
|
27
|
+
// Entity-encoded attribute quotes: a model that emits id="x" produces an
|
|
28
|
+
// attribute whose value is literally "x", so the simulation's own
|
|
29
|
+
// getElementById('x') finds nothing and throws. Only rewritten inside a tag, so prose
|
|
30
|
+
// that legitimately contains " is untouched.
|
|
31
|
+
.replace(/<[a-z][^>]*>/gi, (tag) =>
|
|
32
|
+
tag.includes('="') ? tag.replace(/="(.*?)"/g, '="$1"') : tag)
|
|
33
|
+
.replace(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, (_m, css) => { if (sink) sink.push(css); return ''; })
|
|
34
|
+
// A <button> with no type submits enclosing forms and trips validation.
|
|
35
|
+
.replace(/<button(?![^>]*\btype=)/gi, '<button type="button"')
|
|
36
|
+
// Repair broken <input type>. Two failure modes seen in real output, both of which
|
|
37
|
+
// silently turn a working slider into a text box:
|
|
38
|
+
// - duplicated type= (the browser honours the FIRST, which may be the bogus one)
|
|
39
|
+
// - a single type= whose value isn't an input type at all (e.g. type="id")
|
|
40
|
+
// A tag carrying min/max/step was clearly meant to be a range.
|
|
41
|
+
.replace(/<input\b[^>]*>/gi, (tag) => {
|
|
42
|
+
const types = [...tag.matchAll(/\stype\s*=\s*(["'])(.*?)\1/gi)];
|
|
43
|
+
if (!types.length) return tag;
|
|
44
|
+
const valid = types.find((t) => INPUT_TYPES.has(t[2].toLowerCase()));
|
|
45
|
+
const guess = /\s(?:min|max|step)\s*=/i.test(tag) ? 'range' : 'text';
|
|
46
|
+
const keep = valid ? valid[0] : ` type="${guess}"`;
|
|
47
|
+
let placed = false;
|
|
48
|
+
return tag.replace(/\stype\s*=\s*(["'])(.*?)\1/gi, () => {
|
|
49
|
+
if (placed) return '';
|
|
50
|
+
placed = true;
|
|
51
|
+
return keep;
|
|
52
|
+
});
|
|
53
|
+
})
|
|
54
|
+
// An id with surrounding whitespace is invalid AND breaks the simulation's own
|
|
55
|
+
// getElementById lookups, which then throw on a null element.
|
|
56
|
+
.replace(/\s(id|for)\s*=\s*(["'])\s*(.*?)\s*\2/gi, (_m, attr, q, val) => ` ${attr}=${q}${val}${q}`)
|
|
57
|
+
// Headings inside a fragment break the page's heading order (the section already owns
|
|
58
|
+
// its <h2>). Demote them to bold text, which is what they were being used for visually.
|
|
59
|
+
// Any style= the model already set is preserved rather than duplicated.
|
|
60
|
+
.replace(/<h[1-6]\b([^>]*)>/gi, (_m, attrs) =>
|
|
61
|
+
/\bstyle\s*=/i.test(attrs)
|
|
62
|
+
? `<strong${attrs.replace(/\bstyle\s*=\s*(["'])/i, 'style=$1display:block;font-size:1.05em;')}>`
|
|
63
|
+
: `<strong${attrs} style="display:block;font-size:1.05em">`)
|
|
64
|
+
.replace(/<\/h[1-6]>/gi, '</strong>')
|
|
65
|
+
// Bare "&" that isn't already an entity is invalid markup.
|
|
66
|
+
.replace(/&(?!#?[a-z0-9]{1,8};)/gi, '&');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Model-authored simulation JS, repaired the same way its HTML is.
|
|
70
|
+
//
|
|
71
|
+
// The failure this catches: a model double-escapes its newlines, so `simulation.js` arrives
|
|
72
|
+
// as one line containing literal backslash-n sequences. That is a hard SyntaxError, and the
|
|
73
|
+
// lesson's whole interactive simulation silently does nothing. Repair is only attempted in
|
|
74
|
+
// the unambiguous case (escapes present, no real newlines at all) so a script that
|
|
75
|
+
// legitimately contains "\n" inside a string is never touched.
|
|
76
|
+
function tidyScript(js = '') {
|
|
77
|
+
let src = String(js);
|
|
78
|
+
if (!src.trim()) return '';
|
|
79
|
+
if (!/\r?\n/.test(src) && /\\n/.test(src)) {
|
|
80
|
+
src = src.replace(/\\r\\n|\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\(["'`])/g, '$1');
|
|
81
|
+
}
|
|
82
|
+
// Generation runs in Node, so we can actually check it parses before shipping it.
|
|
83
|
+
try {
|
|
84
|
+
new Function(src);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
return `/* coursecast: simulation script omitted — it does not parse (${String(e.message).slice(0, 80)}) */`;
|
|
87
|
+
}
|
|
88
|
+
return src;
|
|
89
|
+
}
|
|
90
|
+
|
|
12
91
|
const CSS = `
|
|
13
|
-
:root{color-scheme:light;--page:#f9f9f7;--surface:#fcfcfb;--ink:#0b0b0b;--ink-2:#52514e;--muted:#
|
|
92
|
+
:root{color-scheme:light;--page:#f9f9f7;--surface:#fcfcfb;--ink:#0b0b0b;--ink-2:#52514e;--muted:#67665f;--grid:#e1e0d9;--baseline:#c3c2b7;--border:rgba(11,11,11,0.10);--accent:#2a78d6;--s1:#2a78d6;--s2:#eb6834;--s3:#1baf7a;--good:#0ca30c;--good-text:#006300;--critical:#d03b3b;--warning:#fab219;}
|
|
14
93
|
:root[data-theme="dark"]{color-scheme:dark;--page:#0d0d0d;--surface:#1a1a19;--ink:#fff;--ink-2:#c3c2b7;--muted:#898781;--grid:#2c2c2a;--baseline:#383835;--border:rgba(255,255,255,0.10);--accent:#3987e5;--s1:#3987e5;--s2:#d95926;--s3:#199e70;--good:#0ca30c;--good-text:#0ca30c;--critical:#d03b3b;--warning:#fab219;}
|
|
15
94
|
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){color-scheme:dark;--page:#0d0d0d;--surface:#1a1a19;--ink:#fff;--ink-2:#c3c2b7;--muted:#898781;--grid:#2c2c2a;--baseline:#383835;--border:rgba(255,255,255,0.10);--accent:#3987e5;--s1:#3987e5;--s2:#d95926;--s3:#199e70;--good:#0ca30c;--good-text:#0ca30c;--critical:#d03b3b;--warning:#fab219;}}
|
|
16
95
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
@@ -34,12 +113,12 @@ const CSS = `
|
|
|
34
113
|
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
|
35
114
|
.term{background:#16161a;color:#e6e6ec;border-radius:12px;padding:4px 0 10px;margin:20px 0;overflow-x:auto;border:1px solid var(--border)}
|
|
36
115
|
.term .thead{display:flex;gap:6px;padding:10px 14px 8px;border-bottom:1px solid #2a2a30;margin-bottom:6px}.term .dot{width:11px;height:11px;border-radius:50%;background:#3a3a42}
|
|
37
|
-
.tline{font-family:ui-monospace,"SF Mono",Menlo,monospace;font-size:13.5px;padding:5px 14px;cursor:pointer;white-space:pre;border-left:3px solid transparent}
|
|
116
|
+
.tline{display:block;width:100%;text-align:left;background:none;border:0;color:inherit;font-family:ui-monospace,"SF Mono",Menlo,monospace;font-size:13.5px;padding:5px 14px;cursor:pointer;white-space:pre;border-left:3px solid transparent}
|
|
38
117
|
.tline:hover,.tline.active{background:#1f1f26;border-left-color:var(--accent)}.tline .prompt{color:#5fb85f}
|
|
39
118
|
.term-explain{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:12px 16px;font-size:14px;color:var(--ink-2);min-height:52px;margin:-8px 0 20px}.term-explain b{color:var(--ink)}
|
|
40
119
|
.check-item{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px 16px;margin:10px 0}
|
|
41
120
|
.check-item label{display:flex;gap:12px;align-items:flex-start;cursor:pointer;font-weight:600}.check-item input{width:18px;height:18px;margin-top:3px;accent-color:var(--good)}
|
|
42
|
-
.check-item .desc{font-size:14px;color:var(--ink-2);font-weight:400;margin-top:2px}.check-item details{margin:8px 0 0 30px;font-size:13.5px;color:var(--ink-2)}
|
|
121
|
+
.check-item .desc{display:block;font-size:14px;color:var(--ink-2);font-weight:400;margin-top:2px}.check-item details{margin:8px 0 0 30px;font-size:13.5px;color:var(--ink-2)}
|
|
43
122
|
.check-item summary{cursor:pointer;color:var(--accent);font-weight:600;font-size:13px}.check-item.done label{text-decoration:line-through;color:var(--muted)}
|
|
44
123
|
.quiz-q{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:18px 20px;margin:14px 0}.quiz-q .qt{font-weight:700;margin-bottom:10px}
|
|
45
124
|
.quiz-opt{display:block;width:100%;text-align:left;margin:6px 0;padding:10px 14px;border-radius:9px;border:1px solid var(--border);background:var(--page);color:var(--ink);cursor:pointer;font-size:14.5px}
|
|
@@ -49,21 +128,34 @@ const CSS = `
|
|
|
49
128
|
.nextcard{display:flex;justify-content:space-between;align-items:center;gap:16px;background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:20px;margin-top:48px;text-decoration:none;color:inherit;cursor:pointer}
|
|
50
129
|
.nextcard:hover{border-color:var(--accent)}.nextcard .nk{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-weight:700}.nextcard .nt{font-size:18px;font-weight:700}
|
|
51
130
|
.foot{font-size:12.5px;color:var(--muted);margin-top:40px}
|
|
131
|
+
/* Keyboard users must be able to see where they are — every interactive element. */
|
|
132
|
+
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}
|
|
133
|
+
.skip{position:absolute;left:-9999px;top:0;background:var(--accent);color:#fff;padding:10px 16px;border-radius:0 0 8px 0;z-index:20;text-decoration:none;font-weight:600}
|
|
134
|
+
.skip:focus{left:0}
|
|
135
|
+
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}
|
|
52
136
|
`;
|
|
53
137
|
|
|
54
138
|
export function renderLesson(c) {
|
|
55
139
|
const slug = c.slug;
|
|
140
|
+
const hoistedCss = []; // CSS lifted out of model-authored fragments (see tidyFragment)
|
|
141
|
+
const lede = c.lede ? `<p class="lede">${tidyFragment(c.lede, hoistedCss)}</p>` : '';
|
|
56
142
|
const tiles = (c.tiles || []).map(t =>
|
|
57
|
-
`<div class="tile"><div class="big">${esc(t.big)}</div><div class="lbl">${t.lbl
|
|
143
|
+
`<div class="tile"><div class="big">${esc(t.big)}</div><div class="lbl">${tidyFragment(t.lbl, hoistedCss)}</div>${t.note ? `<div class="note">${tidyFragment(t.note, hoistedCss)}</div>` : ''}</div>`).join('');
|
|
58
144
|
const sections = (c.sections || []).map(s =>
|
|
59
|
-
`<h2>${esc(s.h2)}</h2>${s.html
|
|
60
|
-
const analogy = c.analogy ? `<div class="analogy"><span class="tag">${esc(c.analogy.tag || 'Analogy')}</span><p>${c.analogy.text
|
|
61
|
-
const sim = c.simulation ? `<div class="simwrap"><div class="ctitle">${esc(c.simulation.title || '')}</div><div class="csub">${esc(c.simulation.sub || '')}</div>${c.simulation.html
|
|
62
|
-
const widgets = renderWidgets(c.widgets);
|
|
63
|
-
const term = (c.terminal && c.terminal.length) ? `<div class="term"><div class="thead"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>${c.terminal.map(t => `<
|
|
145
|
+
`<h2>${esc(s.h2)}</h2>${tidyFragment(s.html, hoistedCss)}`).join('\n');
|
|
146
|
+
const analogy = c.analogy ? `<div class="analogy"><span class="tag">${esc(c.analogy.tag || 'Analogy')}</span><p>${tidyFragment(c.analogy.text, hoistedCss)}</p></div>` : '';
|
|
147
|
+
const sim = c.simulation ? `<div class="simwrap"><div class="ctitle">${esc(c.simulation.title || '')}</div><div class="csub">${esc(c.simulation.sub || '')}</div>${tidyFragment(c.simulation.html, hoistedCss)}</div>${c.simulation.js ? `<script>${tidyScript(c.simulation.js)}</script>` : ''}` : '';
|
|
148
|
+
const widgets = renderWidgets(c.widgets, hoistedCss);
|
|
149
|
+
const term = (c.terminal && c.terminal.length) ? `<div class="term"><div class="thead"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>${c.terminal.map(t => `<button type="button" class="tline" aria-pressed="false" data-x="${esc(t.explain)}"><span class="prompt" aria-hidden="true">$</span> ${esc(t.cmd)}</button>`).join('')}</div><div class="term-explain" id="termExplain" role="status" aria-live="polite">👆 <b>Click a command</b> to decode it.</div>` : '';
|
|
64
150
|
const checklist = (c.checklist || []).map((it, i) =>
|
|
65
|
-
`<div class="check-item"><label><input type="checkbox" data-c="${i}"><span>${esc(it.title)}<
|
|
66
|
-
|
|
151
|
+
`<div class="check-item"><label><input type="checkbox" data-c="${i}"><span>${esc(it.title)}<span class="desc">${tidyFragment(it.desc, hoistedCss)}</span></span></label>${it.hint ? `<details><summary>Stuck? Hint</summary>${esc(it.hint)}</details>` : ''}</div>`).join('');
|
|
152
|
+
// `next: null` means "last lesson of a course" (the course builder sets it explicitly);
|
|
153
|
+
// `undefined` means a standalone lesson, which has no dashboard to return to.
|
|
154
|
+
const next = c.next
|
|
155
|
+
? `<a class="nextcard" href="${esc(c.next.stem)}.html"><div><div class="nk">Up next</div><div class="nt">${esc(c.next.title)}</div><div style="font-size:14px;color:var(--ink-2)">${esc(c.next.desc || '')}</div></div><div style="font-size:28px">→</div></a>`
|
|
156
|
+
: c.next === null
|
|
157
|
+
? `<a class="nextcard" href="index.html"><div><div class="nk">Course complete</div><div class="nt">Back to ${esc(c.courseTitle || 'the dashboard')}</div><div style="font-size:14px;color:var(--ink-2)">Review your mastery map and revisit anything shaky.</div></div><div style="font-size:28px">🎓</div></a>`
|
|
158
|
+
: '';
|
|
67
159
|
|
|
68
160
|
const quizData = JSON.stringify(c.quiz || []);
|
|
69
161
|
|
|
@@ -74,19 +166,21 @@ export function renderLesson(c) {
|
|
|
74
166
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
75
167
|
<title>${esc(c.title)} — coursecast</title>
|
|
76
168
|
<style>${CSS}</style>
|
|
169
|
+
${hoistedCss.length ? `<style>${hoistedCss.join("\n")}</style>` : ''}
|
|
77
170
|
<script>try{var t=localStorage.getItem("cc-theme");if(t)document.documentElement.dataset.theme=t;}catch(e){}</script>
|
|
78
171
|
</head>
|
|
79
172
|
<body>
|
|
80
|
-
<
|
|
81
|
-
|
|
173
|
+
<a class="skip" href="#lesson-main">Skip to content</a>
|
|
174
|
+
<header class="topbar">
|
|
175
|
+
<div class="crumb">${c.courseTitle ? `<a class="crumb-home" href="index.html">🎓 ${esc(c.courseTitle)}</a>` : '🎓 coursecast'} · <b>${esc(c.lessonLabel || '')} — ${esc(c.title)}</b></div>
|
|
82
176
|
<div class="progressbar"><div id="lessonProgress"></div></div>
|
|
83
|
-
<button class="theme-btn" id="themeBtn">◐ Theme</button>
|
|
84
|
-
</
|
|
85
|
-
<
|
|
177
|
+
<button type="button" class="theme-btn" id="themeBtn">◐ Theme</button>
|
|
178
|
+
</header>
|
|
179
|
+
<main class="wrap" id="lesson-main">
|
|
86
180
|
<div class="hero">
|
|
87
181
|
${c.kicker ? `<div class="kicker">${esc(c.kicker)}</div>` : ''}
|
|
88
182
|
<h1>${esc(c.title)}</h1>
|
|
89
|
-
${
|
|
183
|
+
${lede}
|
|
90
184
|
</div>
|
|
91
185
|
${tiles ? `<div class="tiles">${tiles}</div>` : ''}
|
|
92
186
|
${analogy}
|
|
@@ -98,7 +192,7 @@ export function renderLesson(c) {
|
|
|
98
192
|
${(c.quiz && c.quiz.length) ? `<h2>🧠 Prove it</h2><div id="quiz"></div><div class="quiz-score" id="quizScore"><div class="big"></div><div class="msg" style="color:var(--ink-2)"></div></div>` : ''}
|
|
99
193
|
${next}
|
|
100
194
|
<p class="foot">Generated by the <b>coursecast</b> CLI · ${esc(c.footer || '')}</p>
|
|
101
|
-
</
|
|
195
|
+
</main>
|
|
102
196
|
<script>
|
|
103
197
|
(function(){
|
|
104
198
|
var KEY=${JSON.stringify("cc-" + slug)}, state={};
|
|
@@ -112,8 +206,8 @@ export function renderLesson(c) {
|
|
|
112
206
|
root.dataset.theme=nx;try{localStorage.setItem('cc-theme',nx)}catch(e){}
|
|
113
207
|
});
|
|
114
208
|
document.querySelectorAll('.tline').forEach(function(l){l.addEventListener('click',function(){
|
|
115
|
-
document.querySelectorAll('.tline').forEach(function(x){x.classList.remove('active')});
|
|
116
|
-
l.classList.add('active');document.getElementById('termExplain').innerHTML='<b>What this does:</b> '+l.dataset.x;
|
|
209
|
+
document.querySelectorAll('.tline').forEach(function(x){x.classList.remove('active');x.setAttribute('aria-pressed','false')});
|
|
210
|
+
l.classList.add('active');l.setAttribute('aria-pressed','true');document.getElementById('termExplain').innerHTML='<b>What this does:</b> '+l.dataset.x;
|
|
117
211
|
})});
|
|
118
212
|
var checks=document.querySelectorAll('.check-item input[type=checkbox]');
|
|
119
213
|
var QUIZ=${quizData};
|
|
@@ -127,7 +221,7 @@ export function renderLesson(c) {
|
|
|
127
221
|
var quizEl=document.getElementById('quiz'),answered=0,score=0;
|
|
128
222
|
if(quizEl)QUIZ.forEach(function(item,qi){var card=document.createElement('div');card.className='quiz-q';
|
|
129
223
|
card.innerHTML='<div class="qt">'+(qi+1)+'. '+item.q+'</div>';
|
|
130
|
-
item.opts.forEach(function(opt,oi){var b=document.createElement('button');b.className='quiz-opt';b.textContent=opt;
|
|
224
|
+
item.opts.forEach(function(opt,oi){var b=document.createElement('button');b.type='button';b.className='quiz-opt';b.textContent=opt;
|
|
131
225
|
b.addEventListener('click',function(){if(card.dataset.done)return;card.dataset.done='1';
|
|
132
226
|
var right=oi===item.a;if(right){score++;b.classList.add('correct')}else{b.classList.add('wrong');card.querySelectorAll('.quiz-opt')[item.a].classList.add('correct')}
|
|
133
227
|
var fb=card.querySelector('.quiz-fb');fb.style.display='block';fb.innerHTML=(right?'✅ <b>Nailed it.</b> ':'❌ <b>Not quite.</b> ')+(item.fb||'');
|
|
@@ -27,9 +27,9 @@ export function render(config, id) {
|
|
|
27
27
|
<div class="fc-meta">${cards.length} cards · click the card to flip</div>
|
|
28
28
|
<div class="fc-card" data-card><span class="hint" data-side>Front</span><span data-face></span></div>
|
|
29
29
|
<div class="fc-ctrls">
|
|
30
|
-
<button class="fc-btn" data-prev>‹ Prev</button>
|
|
31
|
-
<button class="fc-btn" data-next>Next ›</button>
|
|
32
|
-
<button class="fc-btn good" data-known>✓ I knew it</button>
|
|
30
|
+
<button type="button" class="fc-btn" data-prev>‹ Prev</button>
|
|
31
|
+
<button type="button" class="fc-btn" data-next>Next ›</button>
|
|
32
|
+
<button type="button" class="fc-btn good" data-known>✓ I knew it</button>
|
|
33
33
|
<span class="fc-prog" data-prog></span>
|
|
34
34
|
</div>
|
|
35
35
|
<script>(function(){
|
package/src/widgets/index.mjs
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
// self-contained HTML. Every widget is vanilla JS, no CDNs, no build step — so a lesson
|
|
7
7
|
// stays one offline file with no asset inliner needed.
|
|
8
8
|
//
|
|
9
|
-
// A widget module exports: `type` (string) and `render(config, id)
|
|
10
|
-
// `id` is a unique per-instance prefix so multiple widgets
|
|
9
|
+
// A widget module exports: `type` (string) and `render(config, id)`, returning either an
|
|
10
|
+
// HTML string or `{ html, css }`. `id` is a unique per-instance prefix so multiple widgets
|
|
11
|
+
// can share a page safely. CSS is returned separately (not inside a <style> in the markup)
|
|
12
|
+
// because a <style> element inside a <div> is invalid HTML — the page renderer collects it
|
|
13
|
+
// and emits it in <head>.
|
|
11
14
|
|
|
12
15
|
import * as whatIf from './what-if.mjs';
|
|
13
16
|
import * as flashcards from './flashcards.mjs';
|
|
@@ -31,12 +34,25 @@ export const jsonInline = (v) => JSON.stringify(v)
|
|
|
31
34
|
|
|
32
35
|
// Render a lesson's widgets array to a single HTML string. Unknown/invalid widgets are
|
|
33
36
|
// skipped (never crash a whole lesson over one bad widget).
|
|
34
|
-
|
|
37
|
+
//
|
|
38
|
+
// Each widget writes its own id-scoped CSS next to its markup, which keeps the widget
|
|
39
|
+
// modules self-contained and readable — but a <style> element inside a <div> is invalid
|
|
40
|
+
// HTML. So the style blocks are lifted out here and handed to `cssSink` for the page
|
|
41
|
+
// renderer to emit in <head>. Doing it centrally means widget authors keep writing their
|
|
42
|
+
// CSS inline and never have to think about it. The CSS is already `#id`-scoped, so moving
|
|
43
|
+
// it changes nothing visually.
|
|
44
|
+
export function renderWidgets(widgets, cssSink) {
|
|
35
45
|
if (!Array.isArray(widgets) || !widgets.length) return '';
|
|
36
46
|
return widgets.map((w, i) => {
|
|
37
47
|
const mod = w && REGISTRY[w.type];
|
|
38
48
|
if (!mod) return '';
|
|
39
|
-
try {
|
|
40
|
-
|
|
49
|
+
try {
|
|
50
|
+
const html = mod.render(w.config || {}, `w${i}`);
|
|
51
|
+
if (!cssSink) return html;
|
|
52
|
+
return String(html).replace(/<style>([\s\S]*?)<\/style>/gi, (_m, css) => {
|
|
53
|
+
cssSink.push(css);
|
|
54
|
+
return '';
|
|
55
|
+
});
|
|
56
|
+
} catch { return ''; }
|
|
41
57
|
}).filter(Boolean).join('\n');
|
|
42
58
|
}
|
|
@@ -37,9 +37,9 @@ export function render(config, id) {
|
|
|
37
37
|
<div class="rd-meta">Reveal the flow step by step</div>
|
|
38
38
|
<div data-nodes>${items}</div>
|
|
39
39
|
<div class="rd-ctrls">
|
|
40
|
-
<button class="rd-btn primary" data-next>Reveal next ↓</button>
|
|
41
|
-
<button class="rd-btn" data-all>Show all</button>
|
|
42
|
-
<button class="rd-btn" data-reset>Reset</button>
|
|
40
|
+
<button type="button" class="rd-btn primary" data-next>Reveal next ↓</button>
|
|
41
|
+
<button type="button" class="rd-btn" data-all>Show all</button>
|
|
42
|
+
<button type="button" class="rd-btn" data-reset>Reset</button>
|
|
43
43
|
<span class="rd-prog" data-prog></span>
|
|
44
44
|
</div>
|
|
45
45
|
<script>(function(){
|