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 +21 -0
- package/README.md +87 -0
- package/bin/coursecast.mjs +93 -0
- package/package.json +42 -0
- package/src/engines/claude-code.mjs +49 -0
- package/src/engines/codex.mjs +71 -0
- package/src/engines/gemini.mjs +92 -0
- package/src/engines/shared-prompts.mjs +88 -0
- package/src/engines/stub.mjs +98 -0
- package/src/index.mjs +168 -0
- package/src/progress-client.mjs +56 -0
- package/src/providers/netlify.mjs +27 -0
- package/src/providers/vercel.mjs +28 -0
- package/src/render-dashboard.mjs +218 -0
- package/src/render.mjs +147 -0
- package/src/storage/cloud.mjs +48 -0
- package/src/storage/index.mjs +14 -0
- package/src/storage/local.mjs +10 -0
- package/src/widgets/flashcards.mjs +51 -0
- package/src/widgets/index.mjs +42 -0
- package/src/widgets/reveal-diagram.mjs +61 -0
- package/src/widgets/what-if.mjs +99 -0
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
|
+
}
|