coursecast 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tomas Sestak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # coursecast
2
+
3
+ **One prompt → a full interactive course.** Real multi-lesson content with live widgets,
4
+ quizzes, a custom simulation per lesson, and a mastery dashboard — rendered to
5
+ self-contained HTML you can open from disk or deploy anywhere with one command.
6
+
7
+ > Prefer zero setup? The hosted version lives at **[trycoursecast.com](https://trycoursecast.com)** —
8
+ > type a topic in the browser and it generates and hosts the course for you.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm i -g coursecast # or: npx coursecast …
14
+ ```
15
+
16
+ Requires Node 18+.
17
+
18
+ ## Use
19
+
20
+ ```bash
21
+ # one interactive lesson
22
+ coursecast new "how DNS works"
23
+
24
+ # a whole course (~12 lessons + dashboard), deployed live
25
+ coursecast course "intro to nuclear power" --deploy
26
+ ```
27
+
28
+ Open the printed `index.html` — that's the whole product: no server, no build step,
29
+ works offline, progress saved in the browser.
30
+
31
+ ## Engines
32
+
33
+ The engine is what writes the content. Pick with `--engine`:
34
+
35
+ | Engine | Runs on | Needs |
36
+ |---|---|---|
37
+ | `claude-code` *(default)* | your [Claude Code](https://claude.com/claude-code) CLI | `claude` on PATH |
38
+ | `codex` | your Codex CLI | `codex` on PATH |
39
+ | `gemini` | Google Gemini API, search-grounded | `GEMINI_API_KEY` ([free key](https://aistudio.google.com/apikey)) |
40
+ | `stub` | nothing — offline placeholder content | nothing |
41
+
42
+ ```bash
43
+ coursecast course "learn rust" --engine gemini
44
+ ```
45
+
46
+ Gemini extras: `GEMINI_MODEL` overrides the model (default `gemini-2.5-flash`);
47
+ `COURSECAST_RESEARCH=0` skips the web-search research pass (faster, cheaper, less current).
48
+
49
+ ## All flags
50
+
51
+ ```
52
+ --engine <id> claude-code (default) | gemini | codex | stub
53
+ --model <id> model hint passed to the engine
54
+ --storage <id> progress storage: local (default) | cloud
55
+ --provider <id> deploy target: vercel (default) | netlify
56
+ --lessons <n> (course) target number of lessons (default ~12)
57
+ --concurrency <n> (course) parallel lesson generation (default 4)
58
+ --deploy deploy after generating
59
+ --dry-run with --deploy, print the deploy command without running it
60
+ --preview deploy as a preview instead of production
61
+ --out <dir> output directory (default ./out/<slug>)
62
+ ```
63
+
64
+ ## Deploy providers
65
+
66
+ `--deploy` ships the output directory with your own `vercel` or `netlify` CLI —
67
+ your account, your URL, no middleman. Or skip it and host the files anywhere that
68
+ serves static HTML. Add a provider via `src/providers/<id>.mjs` exporting `name`,
69
+ `isAvailable()`, and `async deploy({ dir, prod, dryRun })`.
70
+
71
+ ## Architecture
72
+
73
+ `engine` produces a **content object** (data) → `render.mjs` turns it into polished
74
+ interactive HTML (presentation) → `provider` deploys the folder. Content and
75
+ presentation are separated, so every lesson looks like one product no matter which
76
+ engine wrote it. Progress (checklist, quiz, theme) saves per-device in `localStorage`.
77
+
78
+ ## Writing an engine
79
+
80
+ An engine module exports `name`, `async generateLesson({ topic, slug, model, context })`,
81
+ and — required for whole courses — `async generateSyllabus({ topic, lessons, model })`.
82
+ Drop it in `src/engines/<id>.mjs` and pass `--engine <id>`. See `src/engines/stub.mjs`
83
+ for the minimal shape and `src/engines/shared-prompts.mjs` for the content contract.
84
+
85
+ ## License
86
+
87
+ MIT © Tomas Sestak
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ // coursecast — one prompt → an interactive lesson → deployed live.
3
+
4
+ import { createLesson, createCourse, deployDir, slugify } from '../src/index.mjs';
5
+
6
+ const argv = process.argv.slice(2);
7
+
8
+ // Value-taking flags: return the following token, or `def` if the flag is bare/last
9
+ // (never a bare `true`, which would crash string-typed consumers like spawn/resolve).
10
+ function getFlag(name, def) {
11
+ const i = argv.indexOf(`--${name}`);
12
+ if (i === -1) return def;
13
+ const v = argv[i + 1];
14
+ return (v && !v.startsWith('--')) ? v : def;
15
+ }
16
+ const has = name => argv.includes(`--${name}`);
17
+ const log = (...a) => console.log(...a);
18
+
19
+ function usage() {
20
+ log(`
21
+ coursecast — generate an interactive lesson from one prompt, and deploy it.
22
+
23
+ USAGE
24
+ coursecast new "<topic>" [options] generate ONE interactive lesson
25
+ coursecast course "<topic>" [options] generate a whole multi-lesson course + dashboard
26
+
27
+ OPTIONS
28
+ --engine <id> generation engine: claude-code (default), gemini, codex, stub
29
+ --model <id> model hint passed to the engine (e.g. gemini-2.5-flash)
30
+ --storage <id> progress storage: local (default, no setup), cloud (sync across devices)
31
+ --provider <id> deploy target: vercel (default), netlify
32
+ --lessons <n> (course) target number of lessons (default ~12)
33
+ --concurrency <n> (course) how many lessons to generate at once (default 4)
34
+ --deploy deploy after generating (otherwise generate only)
35
+ --dry-run with --deploy, print the deploy command without running it
36
+ --preview deploy as a preview instead of production
37
+ --out <dir> output directory (default ./out/<slug>)
38
+ -h, --help show this help
39
+
40
+ EXAMPLES
41
+ coursecast new "kubernetes basics" --engine stub
42
+ coursecast course "how DNS works" --engine gemini # cheap, search-grounded
43
+ GEMINI_API_KEY=... coursecast course "learn rust" --engine gemini
44
+ coursecast course "intro to nuclear power" --deploy --provider netlify
45
+ `);
46
+ }
47
+
48
+ async function main() {
49
+ const cmd = argv[0];
50
+ if (!cmd || has('help') || argv.includes('-h')) { usage(); process.exit(cmd ? 0 : 1); }
51
+
52
+ if (cmd !== 'new' && cmd !== 'course') { log(`Unknown command "${cmd}".`); usage(); process.exit(1); }
53
+
54
+ const topic = argv[1] && !argv[1].startsWith('--') ? argv[1] : null;
55
+ if (!topic) { log(`❌ Provide a topic: coursecast ${cmd} "your topic"`); process.exit(1); }
56
+
57
+ const engine = getFlag('engine', 'claude-code');
58
+ const provider = getFlag('provider', 'vercel');
59
+ const model = getFlag('model', undefined);
60
+ const storage = getFlag('storage', 'local');
61
+ const out = getFlag('out', undefined);
62
+ const lessons = parseInt(getFlag('lessons', ''), 10) || undefined;
63
+ const concurrency = parseInt(getFlag('concurrency', ''), 10) || undefined;
64
+
65
+ try {
66
+ let dir, entry;
67
+ if (cmd === 'course') {
68
+ const res = await createCourse({ topic, engine, model, storage, outDir: out, lessons, concurrency, log });
69
+ dir = res.dir; entry = `${res.dir}/index.html`;
70
+ log(`✅ Course ready: ${res.lessons.length} lessons${res.failed.length ? ` (${res.failed.length} failed: ${res.failed.join(', ')})` : ''}`);
71
+ } else {
72
+ const res = await createLesson({ topic, engine, model, storage, outDir: out, log });
73
+ dir = res.dir; entry = res.file;
74
+ log(`✅ Lesson ready: ${res.file}`);
75
+ }
76
+
77
+ if (has('deploy')) {
78
+ const res = await deployDir({
79
+ dir, provider, prod: !has('preview'), dryRun: has('dry-run'), log,
80
+ });
81
+ if (res.dryRun) log(`ℹ️ Dry run — would run: ${res.command}`);
82
+ else log(`\n🎉 Live at: ${res.url}`);
83
+ } else {
84
+ log(`\nPreview locally: npx serve "${dir}" (or open ${entry})`);
85
+ log(`Deploy: coursecast ${cmd} "${topic}" --deploy --provider ${provider}`);
86
+ }
87
+ } catch (e) {
88
+ log(`\n❌ ${e.message}`);
89
+ process.exit(1);
90
+ }
91
+ }
92
+
93
+ main();
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "coursecast",
3
+ "version": "0.1.0",
4
+ "description": "One prompt → an interactive lesson or a full multi-lesson course with a mastery dashboard — rendered to self-contained HTML and deployed live.",
5
+ "type": "module",
6
+ "bin": {
7
+ "coursecast": "bin/coursecast.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src"
15
+ ],
16
+ "keywords": [
17
+ "learning",
18
+ "education",
19
+ "ai",
20
+ "generator",
21
+ "vercel",
22
+ "cli",
23
+ "lessons",
24
+ "courses",
25
+ "course-generator",
26
+ "interactive",
27
+ "gemini",
28
+ "claude",
29
+ "codex",
30
+ "netlify",
31
+ "static-site"
32
+ ],
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/TomasSestak/coursecast.git",
37
+ "directory": "cli"
38
+ },
39
+ "homepage": "https://trycoursecast.com",
40
+ "bugs": "https://github.com/TomasSestak/coursecast/issues",
41
+ "author": "Tomas Sestak"
42
+ }
@@ -0,0 +1,49 @@
1
+ // claude-code engine — drives the Claude Code CLI headlessly to generate real lesson content.
2
+ // Requires `claude` on PATH (https://claude.ai/code). Highest quality; reuses the same
3
+ // research+authoring the /learn skill uses. Output is a lesson content object (see shared-prompts).
4
+
5
+ import { spawn } from 'node:child_process'
6
+ import { buildSyllabusPrompt, buildLessonPrompt, finalizeLesson, extractJsonObject } from './shared-prompts.mjs'
7
+
8
+ export const name = 'claude-code'
9
+
10
+ export async function generateSyllabus({ topic, lessons = 12, model }) {
11
+ const prompt = buildSyllabusPrompt({ topic, lessons })
12
+ const raw = await run('claude', model ? ['-p', prompt, '--output-format', 'json', '--model', model] : ['-p', prompt, '--output-format', 'json'])
13
+ const obj = extractJsonObject(extractText(raw))
14
+ if (!obj || !Array.isArray(obj.phases)) throw new Error('claude-code engine: could not parse a syllabus object')
15
+ return obj
16
+ }
17
+
18
+ export async function generateLesson({ topic, slug, model, context }) {
19
+ const prompt = buildLessonPrompt({ topic, slug, context })
20
+ const args = ['-p', prompt, '--output-format', 'json']
21
+ if (model) args.push('--model', model)
22
+
23
+ const raw = await run('claude', args)
24
+ // Claude Code --output-format json wraps the result; find the lesson JSON inside.
25
+ const obj = extractJsonObject(extractText(raw))
26
+ if (!obj || !obj.title) throw new Error('claude-code engine: could not parse a lesson object from output')
27
+ return finalizeLesson(obj, slug, context)
28
+ }
29
+
30
+ function run(cmd, args) {
31
+ return new Promise((resolve, reject) => {
32
+ const p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] })
33
+ let out = '', err = ''
34
+ p.stdout.on('data', (d) => (out += d))
35
+ p.stderr.on('data', (d) => (err += d))
36
+ p.on('error', (e) => reject(new Error(`Cannot run "${cmd}" — is it installed and on PATH? (${e.message})`)))
37
+ p.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(`${cmd} exited ${code}: ${err || out}`))))
38
+ })
39
+ }
40
+
41
+ function extractText(raw) {
42
+ try {
43
+ const j = JSON.parse(raw)
44
+ if (typeof j === 'string') return j
45
+ if (j.result) return j.result
46
+ if (Array.isArray(j)) { const last = j[j.length - 1]; return last?.result || raw }
47
+ } catch { /* not json-wrapped; use as-is */ }
48
+ return raw
49
+ }
@@ -0,0 +1,71 @@
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-code).
3
+ // Requires `codex` on PATH. Output is a lesson/syllabus content object (see schemas).
4
+ //
5
+ // Codex CLI non-interactive mode: `codex exec "<prompt>"` prints the model's final answer
6
+ // to stdout. We ask for a bare JSON object and extract it.
7
+
8
+ import { spawn } from 'node:child_process';
9
+
10
+ export const name = 'codex';
11
+
12
+ const LESSON_SCHEMA = `{
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
+ }`;
26
+
27
+ export async function generateSyllabus({ topic, lessons = 12 }) {
28
+ const prompt = `Design a course syllabus for someone learning "${topic}". Output ONLY one JSON object, no prose, no fences, matching:\n${SYLLABUS_SCHEMA}\nAim for about ${lessons} lessons in 3-6 phases (foundations -> core -> advanced -> practice). Unique kebab-case stems.`;
29
+ const obj = extractJsonObject(await run('codex', ['exec', prompt]));
30
+ if (!obj || !Array.isArray(obj.phases)) throw new Error('codex engine: could not parse a syllabus object');
31
+ return obj;
32
+ }
33
+
34
+ export async function generateLesson({ topic, slug, context }) {
35
+ const ctx = context || {};
36
+ const position = ctx.lessonLabel ? ` It is ${ctx.lessonLabel} of "${ctx.courseTitle}" in the "${ctx.kicker}" section.` : '';
37
+ const prompt = `Generate ONE interactive lesson for a learner of "${topic}".${position}\nOutput ONLY one JSON object, no prose, no fences, matching:\n${LESSON_SCHEMA}\nRules: slug must be "${slug}"; beginner-friendly with analogies; the simulation and any widgets must be genuinely interactive, vanilla JS, no external libraries; all facts current and correct.`;
38
+ const obj = extractJsonObject(await run('codex', ['exec', prompt]));
39
+ if (!obj || !obj.title) throw new Error('codex engine: could not parse a lesson object');
40
+ obj.slug = slug;
41
+ if (ctx.courseTitle) obj.courseTitle = ctx.courseTitle;
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;
47
+ }
48
+
49
+ function run(cmd, args) {
50
+ return new Promise((resolve, reject) => {
51
+ const p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
52
+ let out = '', err = '';
53
+ p.stdout.on('data', d => (out += d));
54
+ p.stderr.on('data', d => (err += d));
55
+ p.on('error', e => reject(new Error(`Cannot run "${cmd}" — is the Codex CLI installed and on PATH? (${e.message})`)));
56
+ p.on('close', code => code === 0 ? resolve(out) : reject(new Error(`${cmd} exited ${code}: ${err || out}`)));
57
+ });
58
+ }
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,92 @@
1
+ // gemini engine — generates lessons via the Google Gemini API over plain HTTPS (no SDK).
2
+ // Designed so a CHEAP model still produces good courses: every generation is preceded by a
3
+ // Google-Search-grounded research pass, so the model formats *current, correct* facts into our
4
+ // schema rather than inventing them from stale training data.
5
+ //
6
+ // Requires: GEMINI_API_KEY (free tier works — https://aistudio.google.com/apikey)
7
+ // Optional: GEMINI_MODEL (default "gemini-2.5-flash"), COURSECAST_RESEARCH=0 to skip search.
8
+
9
+ import { buildSyllabusPrompt, buildLessonPrompt, finalizeLesson, extractJsonObject } from './shared-prompts.mjs'
10
+
11
+ export const name = 'gemini'
12
+
13
+ const API = 'https://generativelanguage.googleapis.com/v1beta/models'
14
+ const DEFAULT_MODEL = process.env.GEMINI_MODEL || 'gemini-2.5-flash'
15
+ const RESEARCH_ON = process.env.COURSECAST_RESEARCH !== '0'
16
+
17
+ function apiKey() {
18
+ const k = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
19
+ if (!k) throw new Error('gemini engine: set GEMINI_API_KEY (free key at https://aistudio.google.com/apikey).')
20
+ return k
21
+ }
22
+
23
+ // One Gemini call. `json:true` forces a JSON body; `search:true` enables Google Search grounding.
24
+ // (Gemini disallows forced-JSON + grounding together, so a call is one or the other.)
25
+ async function callGemini({ model = DEFAULT_MODEL, prompt, json = false, search = false, maxOutputTokens = 8192 }) {
26
+ const body = {
27
+ contents: [{ parts: [{ text: prompt }] }],
28
+ generationConfig: { temperature: 0.7, maxOutputTokens },
29
+ }
30
+ if (json) body.generationConfig.responseMimeType = 'application/json'
31
+ if (search) body.tools = [{ google_search: {} }]
32
+
33
+ const key = apiKey() // resolve first so a missing key reports cleanly, not as a "network error"
34
+ let res
35
+ try {
36
+ res = await fetch(`${API}/${model}:generateContent`, {
37
+ method: 'POST',
38
+ headers: { 'content-type': 'application/json', 'x-goog-api-key': key },
39
+ body: JSON.stringify(body),
40
+ })
41
+ } catch (e) {
42
+ throw new Error(`gemini engine: network error calling the API (${e.message})`)
43
+ }
44
+ if (!res.ok) {
45
+ const detail = await res.text().catch(() => '')
46
+ throw new Error(`gemini engine: API ${res.status} — ${detail.slice(0, 300)}`)
47
+ }
48
+ const data = await res.json()
49
+ const parts = data?.candidates?.[0]?.content?.parts
50
+ const text = Array.isArray(parts) ? parts.map((p) => p.text || '').join('') : ''
51
+ if (!text) throw new Error('gemini engine: empty response from the model')
52
+ return text
53
+ }
54
+
55
+ // Search-grounded research pass — a compact brief of current, correct facts to ground generation.
56
+ async function research({ topic, focus, model }) {
57
+ if (!RESEARCH_ON) return ''
58
+ const prompt = `Research "${topic}"${focus ? ` — specifically: ${focus}` : ''} using web search.
59
+ Return a concise factual brief (bullet points, max ~250 words) capturing the most important,
60
+ CURRENT facts: key concepts, real tool/library names and versions, real commands, common
61
+ pitfalls, and any 2024-2026 changes. Facts only — no preamble, no course structure.`
62
+ try {
63
+ return await callGemini({ model, prompt, search: true, maxOutputTokens: 2048 })
64
+ } catch {
65
+ return '' // research is best-effort; never block generation on a failed search pass
66
+ }
67
+ }
68
+
69
+ function withResearch(prompt, brief) {
70
+ if (!brief) return prompt
71
+ return `Use these researched, current facts as your source of truth (prefer them over prior knowledge):\n<research>\n${brief}\n</research>\n\n${prompt}`
72
+ }
73
+
74
+ export async function generateSyllabus({ topic, lessons = 12, model }) {
75
+ const brief = await research({ topic, focus: 'the full scope a learner must cover, in learning order', model })
76
+ const prompt = withResearch(buildSyllabusPrompt({ topic, lessons }), brief)
77
+ const raw = await callGemini({ model, prompt, json: true, maxOutputTokens: 8192 })
78
+ const obj = extractJsonObject(raw)
79
+ if (!obj || !Array.isArray(obj.phases)) throw new Error('gemini engine: could not parse a syllabus object')
80
+ return obj
81
+ }
82
+
83
+ export async function generateLesson({ topic, slug, model, context }) {
84
+ const focus = context?.title ? `the lesson "${context.title}" (${context.kicker || ''})` : slug
85
+ const brief = await research({ topic, focus, model })
86
+ const prompt = withResearch(buildLessonPrompt({ topic, slug, context }), brief)
87
+ // Lessons are large (sections + a simulation + quiz), so allow plenty of output room.
88
+ const raw = await callGemini({ model, prompt, json: true, maxOutputTokens: 65536 })
89
+ const obj = extractJsonObject(raw)
90
+ if (!obj || !obj.title) throw new Error('gemini engine: could not parse a lesson object from output')
91
+ return finalizeLesson(obj, slug, context)
92
+ }
@@ -0,0 +1,88 @@
1
+ // Shared generation contract — the schemas, prompts, and post-processing every engine uses.
2
+ // Keeping these in one place means the claude-code, gemini, and any future engine all produce
3
+ // the exact same lesson shape and are held to the same authoring bar; only the transport differs.
4
+
5
+ export const LESSON_SCHEMA = `{
6
+ "slug": string (kebab-case),
7
+ "title": string,
8
+ "courseTitle": string,
9
+ "lessonLabel": string (e.g. "Lesson 1"),
10
+ "kicker": string (short category label),
11
+ "lede": string (HTML ok; a 2-sentence standalone summary of why this matters),
12
+ "tiles": [{ "big": string, "lbl": string, "note"?: string }] (2-4 calibrating stat tiles),
13
+ "analogy": { "tag": "Analogy", "text": string (HTML ok) },
14
+ "sections": [{ "h2": string (emoji + title), "html": string (2-4 short paragraphs; visual-first) }] (3-5),
15
+ "simulation": { "title": string, "sub": string, "html": string, "js": string } (ONE bespoke interactive toy that teaches the single most important idea; vanilla JS; may use the shared CSS classes .btn/.btn.primary and CSS vars),
16
+ "widgets": [ ... ] (0-3 pre-built interactive widgets; prefer these over hand-writing sim code. Each is { "type", "config" }. Types:
17
+ - "what-if": config { title, sub, variables:[{name(JS identifier),label,min,max,default,step,unit}], expression(arithmetic over the variable names, Math.* allowed), output:{label,unit,decimals,max} } — a live calculator/sim with sliders.
18
+ - "flashcards": config { title, cards:[{front,back}] } — recall practice.
19
+ - "reveal-diagram": config { title, nodes:[{label,detail}] } — a process/flow revealed step by step.),
20
+ "terminal": [{ "cmd": string, "explain": string }] (3-5 real, current commands),
21
+ "checklist": [{ "title": string, "desc": string, "hint"?: string }] (4-6 free hands-on steps),
22
+ "quiz": [{ "q": string, "opts": [string,...], "a": number (index), "fb": string }] (5),
23
+ "next": null,
24
+ "footer": string
25
+ }`
26
+
27
+ export const SYLLABUS_SCHEMA = `{
28
+ "courseTitle": string,
29
+ "courseSubtitle": string (one sentence),
30
+ "phases": [ { "name": string, "lessons": [ { "stem": string (kebab-case, unique), "title": string, "desc": string (one line) } ] } ]
31
+ }`
32
+
33
+ export function buildSyllabusPrompt({ topic, lessons = 12 }) {
34
+ return `Design a course syllabus for someone who wants to learn: "${topic}".
35
+
36
+ Research the topic if needed, then output ONLY a single JSON object matching this schema — no prose, no markdown fences:
37
+
38
+ ${SYLLABUS_SCHEMA}
39
+
40
+ Rules:
41
+ - Aim for about ${lessons} lessons total, grouped into 3-6 phases that follow a natural learning progression (foundations -> core -> advanced -> practice/next steps).
42
+ - Every "stem" must be unique, kebab-case, and filename-safe.
43
+ - Output valid JSON and nothing else.`
44
+ }
45
+
46
+ export function buildLessonPrompt({ topic, slug, context }) {
47
+ const ctx = context || {}
48
+ const position = ctx.lessonLabel
49
+ ? `\nThis is ${ctx.lessonLabel} of the course "${ctx.courseTitle}", in the "${ctx.kicker}" section.${ctx.next ? ` The next lesson is "${ctx.next.title}".` : ''}`
50
+ : ''
51
+ return `You are generating ONE interactive lesson for a learner who wants to learn: "${topic}".${position}
52
+
53
+ Research the topic as needed (use web search for current facts, versions, prices), then output ONLY a single JSON object matching this schema — no prose, no markdown fences:
54
+
55
+ ${LESSON_SCHEMA}
56
+
57
+ Rules:
58
+ - slug must be "${slug}".
59
+ - Playful but not childish; a total beginner must be able to follow. Analogies for abstract ideas.
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
+ - All facts must be current and correct. Commands must actually work.
62
+ - Output must be valid JSON and nothing else.`
63
+ }
64
+
65
+ // Course context wins for positioning fields so links/labels stay consistent across engines.
66
+ export function finalizeLesson(obj, slug, context) {
67
+ const ctx = context || {}
68
+ obj.slug = slug
69
+ if (ctx.courseTitle) obj.courseTitle = ctx.courseTitle
70
+ if (ctx.title) obj.title = ctx.title
71
+ if (ctx.lessonLabel) obj.lessonLabel = ctx.lessonLabel
72
+ if (ctx.kicker) obj.kicker = ctx.kicker
73
+ if (ctx.next !== undefined) obj.next = ctx.next
74
+ return obj
75
+ }
76
+
77
+ // Pull the first {...} JSON object out of a model's text, tolerating fences and trailing commas.
78
+ export function extractJsonObject(text) {
79
+ const t = String(text).replace(/```(?:json)?/gi, '')
80
+ const s = t.indexOf('{'), e = t.lastIndexOf('}')
81
+ if (s === -1 || e === -1) return null
82
+ const slice = t.slice(s, e + 1)
83
+ try { return JSON.parse(slice) }
84
+ catch {
85
+ try { return JSON.parse(slice.replace(/,\s*([}\]])/g, '$1')) } // tolerate trailing commas
86
+ catch { return null }
87
+ }
88
+ }
@@ -0,0 +1,98 @@
1
+ // stub engine — deterministic, no AI. Produces valid syllabus + lesson content objects
2
+ // from a topic so the whole generate->render->deploy pipeline can be tested offline.
3
+
4
+ export const name = 'stub';
5
+
6
+ function titleCase(s) { return s.replace(/\b\w/g, m => m.toUpperCase()); }
7
+
8
+ // A small but real multi-phase syllabus so the course pipeline is exercised end-to-end.
9
+ export async function generateSyllabus({ topic }) {
10
+ const t = topic.trim();
11
+ const T = titleCase(t);
12
+ return {
13
+ courseTitle: T,
14
+ courseSubtitle: `A hands-on introduction to ${t}, generated end-to-end by the offline stub engine. Swap in an AI engine for real content.`,
15
+ phases: [
16
+ { name: 'Foundations', lessons: [
17
+ { stem: 'intro', title: `Intro to ${T}`, desc: 'What it is and why it matters' },
18
+ { stem: 'core-concepts', title: 'Core Concepts', desc: 'The ideas everything builds on' },
19
+ { stem: 'getting-started', title: 'Getting Started', desc: 'Your first hands-on steps' },
20
+ ]},
21
+ { name: 'Going Further', lessons: [
22
+ { stem: 'common-patterns', title: 'Common Patterns', desc: 'How it is used in practice' },
23
+ { stem: 'pitfalls', title: 'Pitfalls & Gotchas', desc: 'What trips people up' },
24
+ { stem: 'next-steps', title: 'Next Steps', desc: 'Where to go from here' },
25
+ ]},
26
+ ],
27
+ };
28
+ }
29
+
30
+ // context (optional) carries course position: { courseTitle, lessonLabel, kicker, next }
31
+ export async function generateLesson({ topic, slug, context }) {
32
+ const t = topic.trim();
33
+ const c = context || {};
34
+ return {
35
+ slug,
36
+ title: c.title || titleCase(t),
37
+ courseTitle: c.courseTitle || titleCase(t),
38
+ lessonLabel: c.lessonLabel || 'Lesson 1',
39
+ kicker: c.kicker || 'Getting Started',
40
+ lede: `A hands-on introduction to <b>${t}</b>. This placeholder lesson was produced by the offline <code>stub</code> engine to verify the pipeline — swap in an AI engine for real content.`,
41
+ tiles: [
42
+ { big: '1', lbl: 'lesson generated end-to-end' },
43
+ { big: '0', lbl: 'dependencies — works offline' },
44
+ { big: '~5', lbl: 'min to read (when real)' },
45
+ ],
46
+ analogy: { tag: 'Note', text: `This is a stub. The AI engine replaces this body with a researched, interactive lesson on <b>${t}</b> — diagrams, a simulation, labs, and a quiz.` },
47
+ sections: [
48
+ { h2: '📘 What you will learn', html: `<p>The real generator produces several sections here, each explaining a core idea of <b>${t}</b> with a visual before prose.</p>` },
49
+ ],
50
+ simulation: {
51
+ title: '🎛️ Interactive simulation slot',
52
+ sub: 'Each real lesson ships one custom simulation here.',
53
+ html: `<button class="btn primary" id="stubBtn">Click me</button> <span id="stubOut" style="margin-left:10px;color:var(--ink-2)"></span>`,
54
+ js: `document.getElementById('stubBtn').addEventListener('click',function(){document.getElementById('stubOut').textContent='✅ Pipeline works — this is where a real simulation lives.';});`,
55
+ },
56
+ terminal: [
57
+ { cmd: `coursecast new "${t}"`, explain: 'Generates this lesson from a single prompt.' },
58
+ { cmd: `coursecast course "${t}" --deploy`, explain: 'Generates a whole course and deploys it live.' },
59
+ ],
60
+ checklist: [
61
+ { title: 'Confirm the page renders', desc: 'You are reading it — the render step works.', hint: 'If styles are missing, the CSS did not inline correctly.' },
62
+ { title: 'Try the theme toggle', desc: 'Top-right button; the choice persists across lessons.' },
63
+ { title: 'Take the quiz below', desc: 'Score saves to this device.' },
64
+ ],
65
+ quiz: [
66
+ { q: 'What produced this particular lesson body?', opts: ['A real AI engine', 'The offline stub engine', 'A human author'], a: 1, fb: 'The stub proves the pipeline without calling any AI.' },
67
+ { q: 'Where is progress saved?', opts: ['A cloud database', "The browser's localStorage", 'Nowhere'], a: 1, fb: 'Per-device localStorage, keyed per lesson.' },
68
+ ],
69
+ widgets: [
70
+ { type: 'what-if', config: {
71
+ title: 'What-if: compound growth', sub: 'Drag the sliders — the result updates live.',
72
+ variables: [
73
+ { name: 'p', label: 'Principal', min: 100, max: 10000, default: 1000, step: 100, unit: '$' },
74
+ { name: 'r', label: 'Rate', min: 1, max: 20, default: 7, step: 1, unit: '%' },
75
+ { name: 'y', label: 'Years', min: 1, max: 40, default: 10, step: 1 },
76
+ ],
77
+ expression: 'p * Math.pow(1 + r/100, y)',
78
+ output: { label: 'Future value', unit: '$', decimals: 0, max: 50000 },
79
+ } },
80
+ { type: 'flashcards', config: {
81
+ title: 'Quick recall',
82
+ cards: [
83
+ { front: `What is ${t}?`, back: 'The stub leaves this blank — a real engine fills it in.' },
84
+ { front: 'Why does it matter?', back: 'Because the concept is foundational.' },
85
+ ],
86
+ } },
87
+ { type: 'reveal-diagram', config: {
88
+ title: 'The flow', nodes: [
89
+ { label: 'Input', detail: 'Where it starts.' },
90
+ { label: 'Process', detail: 'What happens in the middle.' },
91
+ { label: 'Output', detail: 'What you get.' },
92
+ ],
93
+ } },
94
+ ],
95
+ next: c.next || null,
96
+ footer: 'stub engine · replace with an AI engine for real content',
97
+ };
98
+ }