@datajaddah/course 0.0.2

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.
Files changed (26) hide show
  1. package/README.md +31 -0
  2. package/bin/datajaddah-course.js +238 -0
  3. package/lib/init.js +375 -0
  4. package/lib/preview.js +1617 -0
  5. package/lib/repo-contract.js +1291 -0
  6. package/lib/slide-markdown.js +505 -0
  7. package/package.json +41 -0
  8. package/preview-dist/assets/index-CJUgarn8.css +1 -0
  9. package/preview-dist/assets/index-CavDNP3d.js +49 -0
  10. package/preview-dist/index.html +13 -0
  11. package/scaffold/.agents/rules/general.md +91 -0
  12. package/scaffold/.agents/skills/course-coding-exercises/SKILL.md +22 -0
  13. package/scaffold/.agents/skills/course-coding-exercises/references/coding-exercises.md +111 -0
  14. package/scaffold/.agents/skills/course-platform-overview/SKILL.md +36 -0
  15. package/scaffold/.agents/skills/course-platform-overview/references/platform-overview.md +105 -0
  16. package/scaffold/.agents/skills/course-quizzes/SKILL.md +23 -0
  17. package/scaffold/.agents/skills/course-quizzes/references/quizzes.md +121 -0
  18. package/scaffold/.agents/skills/course-repo-contract/SKILL.md +24 -0
  19. package/scaffold/.agents/skills/course-repo-contract/references/repo-contract.md +169 -0
  20. package/scaffold/.agents/skills/course-slides-v2/SKILL.md +28 -0
  21. package/scaffold/.agents/skills/course-slides-v2/references/fit-guidance.md +31 -0
  22. package/scaffold/.agents/skills/course-slides-v2/references/slides-v2.md +138 -0
  23. package/scaffold/.agents/skills/course-spreadsheet-labs/SKILL.md +23 -0
  24. package/scaffold/.agents/skills/course-spreadsheet-labs/references/spreadsheet-labs.md +239 -0
  25. package/scaffold/.agents/skills/course-video-lessons/SKILL.md +22 -0
  26. package/scaffold/.agents/skills/course-video-lessons/references/video-lessons.md +83 -0
package/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # @datajaddah/course
2
+
3
+ CLI for authoring, validating, and previewing Datajaddah courses from GitHub repositories.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @datajaddah/course
9
+ ```
10
+
11
+ ## Commands
12
+
13
+ ### `datajaddah-course init [path] [--title "Title"] [--force]`
14
+
15
+ Scaffold a new course repository with example lessons (slides, quiz, coding exercise, spreadsheet lab).
16
+
17
+ ### `datajaddah-course validate [path] [--json]`
18
+
19
+ Validate a course repository against the `repo_tree_v1` contract. Reports structural errors, missing files, and invalid frontmatter.
20
+
21
+ ### `datajaddah-course preview [path] [--port 4310] [--host 127.0.0.1]`
22
+
23
+ Start a local preview server that renders all course content in a browser. Refreshes from disk on each page load.
24
+
25
+ ## Supported Lesson Kinds
26
+
27
+ - **slides** — Markdown slides with narration scripts, layouts, and image slots
28
+ - **video** — External video URL with notes
29
+ - **quiz** — Multiple-choice questions with feedback
30
+ - **coding_exercise** — Python or R exercises with starter and solution code
31
+ - **spreadsheet_lab** — Univerjs-powered spreadsheet exercises with checks
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import { initCourseRepository } from "../lib/init.js";
9
+ import { startPreviewServer } from "../lib/preview.js";
10
+ import {
11
+ RepoContractError,
12
+ buildValidationSummary,
13
+ parseCourseRepository,
14
+ } from "../lib/repo-contract.js";
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+
18
+ function getVersion() {
19
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, "../package.json"), "utf8"));
20
+ return pkg.version;
21
+ }
22
+
23
+ function printUsage() {
24
+ console.error("Usage:");
25
+ console.error(" datajaddah-course validate [path] [--json]");
26
+ console.error(" datajaddah-course init [path] [--title \"Course Title\"] [--force]");
27
+ console.error(" datajaddah-course preview [path] [--host 127.0.0.1] [--port 4310]");
28
+ }
29
+
30
+ function printValidateHelp() {
31
+ console.error("Usage: datajaddah-course validate [path] [--json]");
32
+ console.error("");
33
+ console.error("Validate a course repository against the repo_tree_v1 contract.");
34
+ console.error("");
35
+ console.error("Options:");
36
+ console.error(" [path] Path to the course repository (default: current directory)");
37
+ console.error(" --json Output validation result as JSON");
38
+ }
39
+
40
+ function printInitHelp() {
41
+ console.error("Usage: datajaddah-course init [path] [--title \"Course Title\"] [--force]");
42
+ console.error("");
43
+ console.error("Initialize a new course scaffold.");
44
+ console.error("");
45
+ console.error("Options:");
46
+ console.error(" [path] Target directory (default: current directory)");
47
+ console.error(" --title <title> Course title (default: derived from directory name)");
48
+ console.error(" --force Overwrite existing files");
49
+ }
50
+
51
+ function printPreviewHelp() {
52
+ console.error("Usage: datajaddah-course preview [path] [--host 127.0.0.1] [--port 4310] [--dev]");
53
+ console.error("");
54
+ console.error("Start a local preview server for the course.");
55
+ console.error("");
56
+ console.error("Options:");
57
+ console.error(" [path] Path to the course repository (default: current directory)");
58
+ console.error(" --host <host> Bind address (default: 127.0.0.1)");
59
+ console.error(" --port <port> Port number (default: 4310)");
60
+ console.error(" --dev Enable Vite HMR for preview UI development");
61
+ }
62
+
63
+ function parseValidateArgs(args) {
64
+ const cwd = process.cwd();
65
+ const normalizedArgs = [];
66
+ let positionalOnly = false;
67
+
68
+ for (const arg of args) {
69
+ if (!positionalOnly && arg === "--") {
70
+ positionalOnly = true;
71
+ continue;
72
+ }
73
+ normalizedArgs.push(arg);
74
+ }
75
+
76
+ let targetArg = ".";
77
+ let json = false;
78
+ let seenPath = false;
79
+
80
+ for (const arg of normalizedArgs) {
81
+ if (arg === "--json") {
82
+ json = true;
83
+ continue;
84
+ }
85
+ if (arg.startsWith("-")) {
86
+ throw new Error(`Unknown option: ${arg}`);
87
+ }
88
+ if (!seenPath) {
89
+ targetArg = arg;
90
+ seenPath = true;
91
+ continue;
92
+ }
93
+ throw new Error(`Unexpected argument: ${arg}`);
94
+ }
95
+
96
+ return {
97
+ rootPath: path.resolve(cwd, targetArg),
98
+ json,
99
+ };
100
+ }
101
+
102
+ function printValidationSummary(summary) {
103
+ console.log("Repository contract valid.");
104
+ console.log(`Path: ${summary.path}`);
105
+ console.log(`Contract: ${summary.contract}`);
106
+ if (summary.courseTitle) {
107
+ console.log(`Course: ${summary.courseTitle}`);
108
+ }
109
+ console.log(`Chapters: ${summary.chapterCount}`);
110
+ console.log(`Lessons: ${summary.lessonCount}`);
111
+ console.log(
112
+ "Resources: " +
113
+ `slides=${summary.resourceCounts.slides}, ` +
114
+ `video=${summary.resourceCounts.video}, ` +
115
+ `quiz=${summary.resourceCounts.quiz}, ` +
116
+ `coding_exercise=${summary.resourceCounts.coding_exercise}, ` +
117
+ `spreadsheet_lab=${summary.resourceCounts.spreadsheet_lab}`
118
+ );
119
+ if (summary.warnings.length > 0) {
120
+ console.log("Warnings:");
121
+ for (const warning of summary.warnings) {
122
+ console.log(`- ${warning}`);
123
+ }
124
+ }
125
+ }
126
+
127
+ function runValidation(args) {
128
+ let options;
129
+ try {
130
+ options = parseValidateArgs(args);
131
+ } catch (error) {
132
+ console.error(error instanceof Error ? error.message : String(error));
133
+ return 1;
134
+ }
135
+
136
+ try {
137
+ const parsedRepo = parseCourseRepository(options.rootPath);
138
+ const summary = buildValidationSummary(options.rootPath, parsedRepo);
139
+ if (options.json) {
140
+ console.log(JSON.stringify(summary));
141
+ } else {
142
+ printValidationSummary(summary);
143
+ }
144
+ return 0;
145
+ } catch (error) {
146
+ if (error instanceof RepoContractError) {
147
+ if (options.json) {
148
+ console.log(
149
+ JSON.stringify({
150
+ ok: false,
151
+ path: options.rootPath,
152
+ error: {
153
+ path: error.path,
154
+ line: error.line,
155
+ message: error.message,
156
+ },
157
+ })
158
+ );
159
+ } else {
160
+ console.error("Repository contract invalid.");
161
+ console.error(`${error.path}:${error.line}: ${error.message}`);
162
+ }
163
+ return 1;
164
+ }
165
+ throw error;
166
+ }
167
+ }
168
+
169
+ function runValidate(args) {
170
+ process.exit(runValidation(args));
171
+ }
172
+
173
+ function runInit(args) {
174
+ let created;
175
+ try {
176
+ created = initCourseRepository(args, process.cwd());
177
+ } catch (error) {
178
+ console.error(error instanceof Error ? error.message : String(error));
179
+ process.exit(1);
180
+ }
181
+
182
+ console.error(`Initialized Datajaddah course scaffold at ${created.targetPath}`);
183
+ console.error(`Course title: ${created.title}`);
184
+ console.error(`Files written: ${created.writtenFiles.length}`);
185
+
186
+ const validationStatus = runValidation([created.targetPath]);
187
+ process.exit(validationStatus);
188
+ }
189
+
190
+ async function runPreview(args) {
191
+ try {
192
+ await startPreviewServer(args, process.cwd());
193
+ } catch (error) {
194
+ if (error instanceof RepoContractError) {
195
+ console.error("Repository contract invalid.");
196
+ console.error(`${error.path}:${error.line}: ${error.message}`);
197
+ process.exit(1);
198
+ }
199
+ console.error(error instanceof Error ? error.message : String(error));
200
+ process.exit(1);
201
+ }
202
+ }
203
+
204
+ const [command, ...args] = process.argv.slice(2);
205
+
206
+ if (command === "--version" || command === "-v") {
207
+ console.log(getVersion());
208
+ process.exit(0);
209
+ }
210
+
211
+ if (!command || command === "--help" || command === "-h") {
212
+ printUsage();
213
+ process.exit(command ? 0 : 1);
214
+ }
215
+
216
+ if (command === "validate") {
217
+ if (args.includes("--help") || args.includes("-h")) {
218
+ printValidateHelp();
219
+ process.exit(0);
220
+ }
221
+ runValidate(args);
222
+ } else if (command === "init") {
223
+ if (args.includes("--help") || args.includes("-h")) {
224
+ printInitHelp();
225
+ process.exit(0);
226
+ }
227
+ runInit(args);
228
+ } else if (command === "preview") {
229
+ if (args.includes("--help") || args.includes("-h")) {
230
+ printPreviewHelp();
231
+ process.exit(0);
232
+ }
233
+ await runPreview(args);
234
+ } else {
235
+ console.error(`Unknown command: ${command}`);
236
+ printUsage();
237
+ process.exit(1);
238
+ }
package/lib/init.js ADDED
@@ -0,0 +1,375 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const SCAFFOLD_ROOT = fileURLToPath(new URL("../scaffold", import.meta.url));
6
+
7
+ function toTitleCase(value) {
8
+ return value
9
+ .replace(/[^a-zA-Z0-9]+/g, " ")
10
+ .replace(/\s+/g, " ")
11
+ .trim()
12
+ .split(" ")
13
+ .filter(Boolean)
14
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
15
+ .join(" ");
16
+ }
17
+
18
+ function yamlString(value) {
19
+ return JSON.stringify(value);
20
+ }
21
+
22
+ function loadDirectoryTemplates(directoryPath, relativePrefix = "") {
23
+ const entries = readdirSync(directoryPath, { withFileTypes: true }).sort((left, right) =>
24
+ left.name.localeCompare(right.name)
25
+ );
26
+
27
+ return entries.flatMap((entry) => {
28
+ const absolutePath = path.join(directoryPath, entry.name);
29
+ const relativePath = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name;
30
+
31
+ if (entry.isDirectory()) {
32
+ return loadDirectoryTemplates(absolutePath, relativePath);
33
+ }
34
+
35
+ return [
36
+ {
37
+ path: relativePath,
38
+ content: readFileSync(absolutePath, "utf8"),
39
+ },
40
+ ];
41
+ });
42
+ }
43
+
44
+ function buildTemplates(courseTitle) {
45
+ return [
46
+ {
47
+ path: "course.md",
48
+ content: `---
49
+ schema_version: 1
50
+ title: ${yamlString(courseTitle)}
51
+ duration: 45
52
+ tags:
53
+ - sample
54
+ - draft
55
+ ---
56
+ Replace this description with the course overview.
57
+ `,
58
+ },
59
+ {
60
+ path: "assets/.gitkeep",
61
+ content: "",
62
+ },
63
+ ...loadDirectoryTemplates(path.join(SCAFFOLD_ROOT, ".agents"), ".agents"),
64
+ {
65
+ path: "chapters/01-intro/chapter.md",
66
+ content: `---
67
+ title: "Introduction"
68
+ ---
69
+ Use this chapter to orient learners, define the first learning goal, and establish the course workflow.
70
+ `,
71
+ },
72
+ {
73
+ path: "chapters/01-intro/lessons/01-welcome/lesson.md",
74
+ content: `---
75
+ kind: slides
76
+ title: "Welcome"
77
+ ---
78
+ Introduce the course and explain what the learner will build.
79
+ `,
80
+ },
81
+ {
82
+ path: "chapters/01-intro/lessons/01-welcome/slides/01-cover.md",
83
+ content: `---
84
+ title: "Welcome"
85
+ ---
86
+ # Welcome to ${courseTitle}
87
+
88
+ Use this first slide to introduce the topic and set expectations.
89
+
90
+ @script
91
+ Welcome to the course. Use this slide to set context, explain the value of the material, and tell the learner what comes next.
92
+ @endscript
93
+ `,
94
+ },
95
+ {
96
+ path: "chapters/01-intro/lessons/01-welcome/slides/02-outline.md",
97
+ content: `---
98
+ title: "Lesson Outline"
99
+ ---
100
+ ## Today
101
+
102
+ - Why this topic matters
103
+ - What the learner will build
104
+ - How the lesson is structured
105
+
106
+ @script
107
+ Walk through the lesson outline and explain the sequence of concepts, practice, and assessment.
108
+ @endscript
109
+ `,
110
+ },
111
+ {
112
+ path: "chapters/01-intro/lessons/02-checkpoint-quiz/lesson.md",
113
+ content: `---
114
+ kind: quiz
115
+ title: "Checkpoint Quiz"
116
+ ---
117
+ Check whether the learner understands the main goal of this opening lesson.
118
+ `,
119
+ },
120
+ {
121
+ path: "chapters/01-intro/lessons/02-checkpoint-quiz/quiz/quiz.md",
122
+ content: `---
123
+ title: "Checkpoint Quiz"
124
+ ---
125
+ Use this quiz to reinforce the core idea from the chapter. Replace the sample question with one that matches your course topic before publishing.
126
+ `,
127
+ },
128
+ {
129
+ path: "chapters/01-intro/lessons/02-checkpoint-quiz/quiz/questions/01-course-goal.md",
130
+ content: `---
131
+ title: "Course Goal Check"
132
+ correct_feedback: "Correct. The first chapter should build a practical foundation for the course topic."
133
+ randomize_choices: false
134
+ ---
135
+ What should this first chapter help a learner do in ${courseTitle}?
136
+
137
+ @choice
138
+ title: Build a practical foundation
139
+ correct: true
140
+
141
+ Build a practical foundation
142
+ @endchoice
143
+
144
+ @choice
145
+ title: Memorize repository files
146
+ correct: false
147
+ feedback: Repository filenames and authoring rules are internal details, not the learner objective for a topic course.
148
+
149
+ Memorize repository files
150
+ @endchoice
151
+
152
+ @choice
153
+ title: Skip practice entirely
154
+ correct: false
155
+ feedback: An introduction should prepare learners for practice, not replace it.
156
+
157
+ Skip practice entirely
158
+ @endchoice
159
+ `,
160
+ },
161
+ {
162
+ path: "chapters/01-intro/lessons/03-first-exercise/lesson.md",
163
+ content: `---
164
+ kind: coding_exercise
165
+ title: "First Exercise"
166
+ ---
167
+ Give the learner a small task that matches the concepts introduced in the chapter.
168
+ `,
169
+ },
170
+ {
171
+ path: "chapters/01-intro/lessons/03-first-exercise/exercise/exercise.md",
172
+ content: `---
173
+ title: "First Exercise"
174
+ language: "python"
175
+ ---
176
+ ## Context
177
+
178
+ You are given a helper function that should add all values in a list.
179
+
180
+ ## Instructions
181
+
182
+ Implement \`sum_values\` in \`starter.py\`.
183
+
184
+ - Keep the function signature unchanged.
185
+ - Return \`0\` for an empty list.
186
+ - Compare your answer with \`solution.py\` after you finish.
187
+ `,
188
+ },
189
+ {
190
+ path: "chapters/01-intro/lessons/03-first-exercise/exercise/starter.py",
191
+ content: `def sum_values(values: list[int]) -> int:
192
+ return 0
193
+ `,
194
+ },
195
+ {
196
+ path: "chapters/01-intro/lessons/03-first-exercise/exercise/solution.py",
197
+ content: `def sum_values(values: list[int]) -> int:
198
+ return sum(values)
199
+ `,
200
+ },
201
+ {
202
+ path: "chapters/01-intro/lessons/04-first-spreadsheet/lesson.md",
203
+ content: `---
204
+ kind: spreadsheet_lab
205
+ title: "First Spreadsheet"
206
+ ---
207
+ Give the learner a small spreadsheet task that reinforces the concepts from the chapter.
208
+ `,
209
+ },
210
+ {
211
+ path: "chapters/01-intro/lessons/04-first-spreadsheet/spreadsheet/exercise.md",
212
+ content: `---
213
+ title: "First Spreadsheet"
214
+ ---
215
+ ## Context
216
+
217
+ You have a small table with two values that need to be summed.
218
+
219
+ ## Instructions
220
+
221
+ - In cell \`B4\`, enter a SUM formula to calculate the total of cells \`B2\` and \`B3\`.
222
+ - The result should equal \`300\`.
223
+ `,
224
+ },
225
+ {
226
+ path: "chapters/01-intro/lessons/04-first-spreadsheet/spreadsheet/workbook.json",
227
+ content: JSON.stringify(
228
+ {
229
+ sheets: {
230
+ Sheet1: {
231
+ name: "Sheet1",
232
+ cellData: {
233
+ 0: { 0: { v: "Item" }, 1: { v: "Amount" } },
234
+ 1: { 0: { v: "Item A" }, 1: { v: 100 } },
235
+ 2: { 0: { v: "Item B" }, 1: { v: 200 } },
236
+ 3: { 0: { v: "Total" } },
237
+ },
238
+ },
239
+ },
240
+ },
241
+ null,
242
+ 2
243
+ ) + "\n",
244
+ },
245
+ {
246
+ path: "chapters/01-intro/lessons/04-first-spreadsheet/spreadsheet/solution.json",
247
+ content: JSON.stringify(
248
+ {
249
+ sheets: {
250
+ Sheet1: {
251
+ name: "Sheet1",
252
+ cellData: {
253
+ 0: { 0: { v: "Item" }, 1: { v: "Amount" } },
254
+ 1: { 0: { v: "Item A" }, 1: { v: 100 } },
255
+ 2: { 0: { v: "Item B" }, 1: { v: 200 } },
256
+ 3: { 0: { v: "Total" }, 1: { v: 300, f: "=SUM(B2:B3)" } },
257
+ },
258
+ },
259
+ },
260
+ },
261
+ null,
262
+ 2
263
+ ) + "\n",
264
+ },
265
+ {
266
+ path: "chapters/01-intro/lessons/04-first-spreadsheet/spreadsheet/checks.json",
267
+ content: JSON.stringify(
268
+ [
269
+ {
270
+ id: "total-value",
271
+ label: "Total equals 300",
272
+ type: "value_equals",
273
+ cell: "B4",
274
+ value: 300,
275
+ },
276
+ ],
277
+ null,
278
+ 2
279
+ ) + "\n",
280
+ },
281
+ ];
282
+ }
283
+
284
+ function parseInitArgs(args, cwd) {
285
+ let targetArg = ".";
286
+ let title;
287
+ let force = false;
288
+ let seenPath = false;
289
+ let positionalOnly = false;
290
+
291
+ for (let index = 0; index < args.length; index += 1) {
292
+ const arg = args[index];
293
+
294
+ if (!positionalOnly && arg === "--") {
295
+ positionalOnly = true;
296
+ continue;
297
+ }
298
+
299
+ if (!positionalOnly && arg === "--force") {
300
+ force = true;
301
+ continue;
302
+ }
303
+
304
+ if (!positionalOnly && arg === "--title") {
305
+ index += 1;
306
+ if (index >= args.length) {
307
+ throw new Error("Missing value for --title");
308
+ }
309
+ title = args[index];
310
+ continue;
311
+ }
312
+
313
+ if (!positionalOnly && arg.startsWith("--title=")) {
314
+ title = arg.slice("--title=".length);
315
+ continue;
316
+ }
317
+
318
+ if (!seenPath) {
319
+ targetArg = arg;
320
+ seenPath = true;
321
+ continue;
322
+ }
323
+
324
+ throw new Error(`Unexpected argument: ${arg}`);
325
+ }
326
+
327
+ const targetPath = path.resolve(cwd, targetArg);
328
+ const fallbackTitle = toTitleCase(path.basename(targetPath)) || "New Course";
329
+
330
+ return {
331
+ targetPath,
332
+ title: title?.trim() || fallbackTitle,
333
+ force,
334
+ };
335
+ }
336
+
337
+ export function initCourseRepository(args, cwd = process.cwd()) {
338
+ const { targetPath, title, force } = parseInitArgs(args, cwd);
339
+
340
+ if (existsSync(targetPath)) {
341
+ const stats = statSync(targetPath);
342
+ if (!stats.isDirectory()) {
343
+ throw new Error(`Target path must be a directory: ${targetPath}`);
344
+ }
345
+ } else {
346
+ mkdirSync(targetPath, { recursive: true });
347
+ }
348
+
349
+ const templates = buildTemplates(title);
350
+ const conflicts = templates
351
+ .map((template) => path.join(targetPath, template.path))
352
+ .filter((filePath) => existsSync(filePath));
353
+
354
+ if (conflicts.length > 0 && !force) {
355
+ const conflictList = conflicts.map((filePath) => `- ${path.relative(targetPath, filePath)}`).join("\n");
356
+ throw new Error(
357
+ `Refusing to overwrite existing scaffold files in ${targetPath}.\n${conflictList}\nRe-run with --force to overwrite these files.`
358
+ );
359
+ }
360
+
361
+ const writtenFiles = [];
362
+ for (const template of templates) {
363
+ const absolutePath = path.join(targetPath, template.path);
364
+ mkdirSync(path.dirname(absolutePath), { recursive: true });
365
+ writeFileSync(absolutePath, template.content, "utf8");
366
+ writtenFiles.push(template.path);
367
+ }
368
+
369
+ return {
370
+ targetPath,
371
+ title,
372
+ force,
373
+ writtenFiles,
374
+ };
375
+ }