@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/lib/preview.js ADDED
@@ -0,0 +1,1617 @@
1
+ import { createServer } from "node:http";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import {
8
+ RepoContractError,
9
+ buildValidationSummary,
10
+ parseCourseRepository,
11
+ } from "./repo-contract.js";
12
+ import { replaceSlotImagesForPreview } from "./slide-markdown.js";
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+ const PREVIEW_DIST = path.join(__dirname, "../preview-dist");
16
+
17
+ const MIME_TYPES = {
18
+ ".html": "text/html; charset=utf-8",
19
+ ".js": "application/javascript; charset=utf-8",
20
+ ".css": "text/css; charset=utf-8",
21
+ ".json": "application/json; charset=utf-8",
22
+ ".svg": "image/svg+xml",
23
+ ".png": "image/png",
24
+ ".ico": "image/x-icon",
25
+ };
26
+
27
+ function serveStaticFile(filePath, response) {
28
+ try {
29
+ const content = readFileSync(filePath);
30
+ const ext = path.extname(filePath);
31
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
32
+ response.writeHead(200, { "content-type": contentType });
33
+ response.end(content);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ function hasPreviewDist() {
41
+ return existsSync(path.join(PREVIEW_DIST, "index.html"));
42
+ }
43
+
44
+ function escapeHtml(value) {
45
+ return String(value)
46
+ .replace(/&/g, "&")
47
+ .replace(/</g, "&lt;")
48
+ .replace(/>/g, "&gt;")
49
+ .replace(/\"/g, "&quot;")
50
+ .replace(/'/g, "&#39;");
51
+ }
52
+
53
+ function renderInlineMarkdown(text) {
54
+ const placeholders = [];
55
+ let html = escapeHtml(text);
56
+
57
+ html = html.replace(/`([^`]+)`/g, (_, code) => {
58
+ const placeholder = `__INLINE_CODE_${placeholders.length}__`;
59
+ placeholders.push(`<code>${escapeHtml(code)}</code>`);
60
+ return placeholder;
61
+ });
62
+
63
+ html = html.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_, alt, src) => {
64
+ const safeUrl = escapeHtml(src);
65
+ return `<img src="${safeUrl}" alt="${escapeHtml(alt)}" />`;
66
+ });
67
+
68
+ html = html.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => {
69
+ const safeUrl = escapeHtml(url);
70
+ return `<a href="${safeUrl}" target="_blank" rel="noreferrer">${escapeHtml(label)}</a>`;
71
+ });
72
+ html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
73
+ html = html.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, "$1<em>$2</em>");
74
+
75
+ placeholders.forEach((placeholder, index) => {
76
+ html = html.replace(`__INLINE_CODE_${index}__`, placeholder);
77
+ });
78
+
79
+ return html;
80
+ }
81
+
82
+ function renderMarkdown(markdown) {
83
+ const normalized = (markdown || "").replace(/\r\n/g, "\n").trim();
84
+ if (!normalized) {
85
+ return '<p class="text-muted">No content provided.</p>';
86
+ }
87
+
88
+ const lines = normalized.split("\n");
89
+ const blocks = [];
90
+ let index = 0;
91
+
92
+ while (index < lines.length) {
93
+ const line = lines[index];
94
+ const trimmed = line.trim();
95
+
96
+ if (!trimmed) {
97
+ index += 1;
98
+ continue;
99
+ }
100
+
101
+ if (trimmed.startsWith("```")) {
102
+ const language = trimmed.slice(3).trim();
103
+ const codeLines = [];
104
+ index += 1;
105
+ while (index < lines.length && !lines[index].trim().startsWith("```")) {
106
+ codeLines.push(lines[index]);
107
+ index += 1;
108
+ }
109
+ if (index < lines.length) {
110
+ index += 1;
111
+ }
112
+ const langLabel = language ? `<span class="code-lang-label">${escapeHtml(language)}</span>` : "";
113
+ const className = language ? ` class="language-${escapeHtml(language)}"` : "";
114
+ blocks.push(`<div class="code-block-wrapper">${langLabel}<pre class="code-block"><code${className}>${escapeHtml(codeLines.join("\n"))}</code></pre></div>`);
115
+ continue;
116
+ }
117
+
118
+ const headingMatch = /^(#{1,6})\s+(.*)$/.exec(trimmed);
119
+ if (headingMatch) {
120
+ const level = headingMatch[1].length;
121
+ blocks.push(`<h${level}>${renderInlineMarkdown(headingMatch[2])}</h${level}>`);
122
+ index += 1;
123
+ continue;
124
+ }
125
+
126
+ if (/^(\|.+\|)\s*$/.test(trimmed)) {
127
+ const tableLines = [];
128
+ while (index < lines.length && /^\|.+\|/.test(lines[index].trim())) {
129
+ tableLines.push(lines[index].trim());
130
+ index += 1;
131
+ }
132
+ if (tableLines.length >= 2) {
133
+ const headerCells = tableLines[0].split("|").filter(Boolean).map((c) => c.trim());
134
+ const hasDelimiter = /^\|[\s:|-]+\|$/.test(tableLines[1]);
135
+ const dataStart = hasDelimiter ? 2 : 1;
136
+ let tableHtml = "<table><thead><tr>";
137
+ for (const cell of headerCells) {
138
+ tableHtml += `<th>${renderInlineMarkdown(cell)}</th>`;
139
+ }
140
+ tableHtml += "</tr></thead><tbody>";
141
+ for (let r = dataStart; r < tableLines.length; r++) {
142
+ const cells = tableLines[r].split("|").filter(Boolean).map((c) => c.trim());
143
+ tableHtml += "<tr>";
144
+ for (const cell of cells) {
145
+ tableHtml += `<td>${renderInlineMarkdown(cell)}</td>`;
146
+ }
147
+ tableHtml += "</tr>";
148
+ }
149
+ tableHtml += "</tbody></table>";
150
+ blocks.push(tableHtml);
151
+ continue;
152
+ }
153
+ }
154
+
155
+ if (/^(-|\*)\s+\[[ xX]\]\s/.test(trimmed)) {
156
+ const items = [];
157
+ while (index < lines.length && /^(-|\*)\s+\[[ xX]\]\s/.test(lines[index].trim())) {
158
+ const taskMatch = /^(-|\*)\s+\[([ xX])\]\s+(.*)$/.exec(lines[index].trim());
159
+ if (taskMatch) {
160
+ const checked = taskMatch[2] !== " ";
161
+ items.push(`<li class="task-item"><input type="checkbox" disabled ${checked ? "checked" : ""} />${renderInlineMarkdown(taskMatch[3])}</li>`);
162
+ }
163
+ index += 1;
164
+ }
165
+ blocks.push(`<ul class="task-list">${items.join("")}</ul>`);
166
+ continue;
167
+ }
168
+
169
+ if (/^(-|\*)\s+/.test(trimmed)) {
170
+ const items = [];
171
+ while (index < lines.length && /^(-|\*)\s+/.test(lines[index].trim())) {
172
+ items.push(`<li>${renderInlineMarkdown(lines[index].trim().replace(/^(-|\*)\s+/, ""))}</li>`);
173
+ index += 1;
174
+ }
175
+ blocks.push(`<ul>${items.join("")}</ul>`);
176
+ continue;
177
+ }
178
+
179
+ if (/^\d+\.\s+/.test(trimmed)) {
180
+ const items = [];
181
+ while (index < lines.length && /^\d+\.\s+/.test(lines[index].trim())) {
182
+ items.push(`<li>${renderInlineMarkdown(lines[index].trim().replace(/^\d+\.\s+/, ""))}</li>`);
183
+ index += 1;
184
+ }
185
+ blocks.push(`<ol>${items.join("")}</ol>`);
186
+ continue;
187
+ }
188
+
189
+ if (/^>\s?/.test(trimmed)) {
190
+ const quoteLines = [];
191
+ while (index < lines.length && /^>\s?/.test(lines[index].trim())) {
192
+ quoteLines.push(lines[index].trim().replace(/^>\s?/, ""));
193
+ index += 1;
194
+ }
195
+ blocks.push(`<blockquote>${renderMarkdown(quoteLines.join("\n"))}</blockquote>`);
196
+ continue;
197
+ }
198
+
199
+ if (/^---+$/.test(trimmed) || /^\*\*\*+$/.test(trimmed)) {
200
+ blocks.push("<hr />");
201
+ index += 1;
202
+ continue;
203
+ }
204
+
205
+ const paragraphLines = [];
206
+ while (index < lines.length) {
207
+ const paragraphLine = lines[index];
208
+ const paragraphTrimmed = paragraphLine.trim();
209
+ if (!paragraphTrimmed) {
210
+ break;
211
+ }
212
+ if (
213
+ paragraphTrimmed.startsWith("```") ||
214
+ /^(#{1,6})\s+/.test(paragraphTrimmed) ||
215
+ /^(-|\*)\s+/.test(paragraphTrimmed) ||
216
+ /^\d+\.\s+/.test(paragraphTrimmed) ||
217
+ /^>\s?/.test(paragraphTrimmed) ||
218
+ /^---+$/.test(paragraphTrimmed) ||
219
+ /^\|.+\|/.test(paragraphTrimmed)
220
+ ) {
221
+ break;
222
+ }
223
+ paragraphLines.push(paragraphTrimmed);
224
+ index += 1;
225
+ }
226
+ blocks.push(`<p>${renderInlineMarkdown(paragraphLines.join(" "))}</p>`);
227
+ }
228
+
229
+ return blocks.join("\n");
230
+ }
231
+
232
+ function safeJson(value) {
233
+ return JSON.stringify(value)
234
+ .replace(/</g, "\\u003c")
235
+ .replace(/>/g, "\\u003e")
236
+ .replace(/&/g, "\\u0026")
237
+ .replace(/\u2028/g, "\\u2028")
238
+ .replace(/\u2029/g, "\\u2029");
239
+ }
240
+
241
+ function readUtf8(filePath) {
242
+ return readFileSync(filePath, "utf8").replace(/\r\n/g, "\n");
243
+ }
244
+
245
+ function detectVideoEmbed(videoUrl) {
246
+ try {
247
+ const url = new URL(videoUrl);
248
+ if (url.hostname === "youtu.be") {
249
+ const videoId = url.pathname.replace(/^\//, "");
250
+ return videoId ? { type: "youtube", url: `https://www.youtube.com/embed/${videoId}` } : null;
251
+ }
252
+ if (url.hostname.includes("youtube.com")) {
253
+ const videoId = url.searchParams.get("v");
254
+ return videoId ? { type: "youtube", url: `https://www.youtube.com/embed/${videoId}` } : null;
255
+ }
256
+ if (/\.(mp4|webm|ogg)$/i.test(url.pathname)) {
257
+ return { type: "html5", url: videoUrl };
258
+ }
259
+ } catch {
260
+ return null;
261
+ }
262
+ return null;
263
+ }
264
+
265
+ function buildQuizQuestionPreview(question, index) {
266
+ return {
267
+ id: question.repoKey || `question-${index + 1}`,
268
+ title: question.title,
269
+ promptHtml: renderMarkdown(question.questionMarkdown),
270
+ correctFeedback: question.correctFeedback,
271
+ randomizeChoices: question.randomizeChoices,
272
+ isMultipleChoice: question.isMultipleChoice,
273
+ choices: question.choices.map((choice, choiceIndex) => ({
274
+ id: `${question.repoKey || `question-${index + 1}`}-choice-${choiceIndex + 1}`,
275
+ title: choice.title,
276
+ bodyHtml: renderMarkdown(choice.bodyMarkdown),
277
+ correct: choice.correct,
278
+ feedback: choice.feedback,
279
+ })),
280
+ };
281
+ }
282
+
283
+ function buildLessonPreview(rootPath, lesson) {
284
+ const base = {
285
+ repoKey: lesson.repoKey,
286
+ repoPath: lesson.repoPath,
287
+ title: lesson.title,
288
+ kind: lesson.kind,
289
+ summaryHtml: renderMarkdown(lesson.summaryMarkdown),
290
+ };
291
+
292
+ if (lesson.resource.type === "slides") {
293
+ return {
294
+ ...base,
295
+ resource: {
296
+ type: "slides",
297
+ title: lesson.resource.title,
298
+ slides: lesson.resource.slides.map((slide) => ({
299
+ title: slide.title,
300
+ repoPath: slide.repoPath,
301
+ contentHtml: replaceSlotImagesForPreview(slide.contentHtml || renderMarkdown(slide.contentMarkdown)),
302
+ script: slide.script,
303
+ })),
304
+ },
305
+ };
306
+ }
307
+
308
+ if (lesson.resource.type === "video") {
309
+ return {
310
+ ...base,
311
+ resource: {
312
+ type: "video",
313
+ title: lesson.resource.title,
314
+ videoUrl: lesson.resource.videoUrl,
315
+ notesHtml: renderMarkdown(lesson.resource.notesMarkdown),
316
+ embed: detectVideoEmbed(lesson.resource.videoUrl),
317
+ },
318
+ };
319
+ }
320
+
321
+ if (lesson.resource.type === "quiz") {
322
+ return {
323
+ ...base,
324
+ resource: {
325
+ type: "quiz",
326
+ title: lesson.resource.title,
327
+ descriptionHtml: renderMarkdown(lesson.resource.description),
328
+ questions: lesson.resource.questions.map((question, index) =>
329
+ buildQuizQuestionPreview(question, index)
330
+ ),
331
+ },
332
+ };
333
+ }
334
+
335
+ if (lesson.resource.type === "spreadsheet_lab") {
336
+ return {
337
+ ...base,
338
+ resource: {
339
+ type: "spreadsheet_lab",
340
+ title: lesson.resource.title,
341
+ engine: lesson.resource.engine,
342
+ promptHtml: renderMarkdown(lesson.resource.bodyMarkdown),
343
+ workbookPath: lesson.resource.workbookPath,
344
+ solutionPath: lesson.resource.solutionPath,
345
+ checksPath: lesson.resource.checksPath,
346
+ checks: lesson.resource.checks,
347
+ starterWorkbook: lesson.resource.starterWorkbook || null,
348
+ solutionWorkbook: lesson.resource.solutionWorkbook || null,
349
+ },
350
+ };
351
+ }
352
+
353
+ return {
354
+ ...base,
355
+ resource: {
356
+ type: "coding_exercise",
357
+ title: lesson.resource.title,
358
+ language: lesson.resource.language,
359
+ promptHtml: renderMarkdown(lesson.resource.bodyMarkdown),
360
+ starterCode: readUtf8(path.join(rootPath, lesson.resource.starterPath)),
361
+ solutionCode: readUtf8(path.join(rootPath, lesson.resource.solutionPath)),
362
+ starterPath: lesson.resource.starterPath,
363
+ solutionPath: lesson.resource.solutionPath,
364
+ },
365
+ };
366
+ }
367
+
368
+ export function buildPreviewModel(rootPath) {
369
+ const parsedRepo = parseCourseRepository(rootPath);
370
+ const summary = buildValidationSummary(rootPath, parsedRepo);
371
+
372
+ return {
373
+ summary,
374
+ warnings: parsedRepo.warnings,
375
+ course: {
376
+ title: parsedRepo.course.title,
377
+ descriptionHtml: renderMarkdown(parsedRepo.course.description),
378
+ duration: parsedRepo.course.duration,
379
+ thumbnail: parsedRepo.course.thumbnail,
380
+ tags: parsedRepo.course.tags,
381
+ },
382
+ chapters: parsedRepo.chapters.map((chapter) => ({
383
+ repoKey: chapter.repoKey,
384
+ repoPath: chapter.repoPath,
385
+ title: chapter.title,
386
+ descriptionHtml: renderMarkdown(chapter.description),
387
+ lessons: chapter.lessons.map((lesson) => buildLessonPreview(rootPath, lesson)),
388
+ })),
389
+ };
390
+ }
391
+
392
+ /* ---------------------------------------------------------------------------
393
+ Client-side application script (embedded in the HTML page)
394
+ --------------------------------------------------------------------------- */
395
+
396
+ function renderPreviewAppScript(previewData) {
397
+ return `
398
+ const previewData = ${safeJson(previewData)};
399
+ const state = {
400
+ chapterIndex: 0,
401
+ lessonIndex: 0,
402
+ slideIndexes: {},
403
+ quizSelections: {},
404
+ quizChecked: {},
405
+ codeTab: {},
406
+ spreadsheetTab: {},
407
+ };
408
+
409
+ function escapeHtml(value) {
410
+ return String(value)
411
+ .replace(/&/g, "&amp;")
412
+ .replace(/</g, "&lt;")
413
+ .replace(/>/g, "&gt;")
414
+ .replace(/\\"/g, "&quot;")
415
+ .replace(/'/g, "&#39;");
416
+ }
417
+
418
+ function getActiveLesson() {
419
+ const chapter = previewData.chapters[state.chapterIndex];
420
+ return chapter?.lessons?.[state.lessonIndex] || null;
421
+ }
422
+
423
+ function setActiveLesson(chapterIndex, lessonIndex) {
424
+ state.chapterIndex = chapterIndex;
425
+ state.lessonIndex = lessonIndex;
426
+ render();
427
+ }
428
+
429
+ function kindLabel(kind) {
430
+ const labels = {
431
+ slides: 'Slides',
432
+ video: 'Video',
433
+ quiz: 'Quiz',
434
+ coding_exercise: 'Coding Exercise',
435
+ spreadsheet_lab: 'Spreadsheet Lab',
436
+ };
437
+ return labels[kind] || kind;
438
+ }
439
+
440
+ function kindIcon(kind) {
441
+ const icons = {
442
+ slides: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" /><path d="M8 21h8M12 17v4" /></svg>',
443
+ video: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3" /></svg>',
444
+ quiz: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" /><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>',
445
+ coding_exercise: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" /></svg>',
446
+ spreadsheet_lab: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" /><line x1="3" y1="9" x2="21" y2="9" /><line x1="3" y1="15" x2="21" y2="15" /><line x1="9" y1="3" x2="9" y2="21" /><line x1="15" y1="3" x2="15" y2="21" /></svg>',
447
+ };
448
+ return icons[kind] || '';
449
+ }
450
+
451
+ /* ---- Outline ---- */
452
+
453
+ function renderOutline() {
454
+ const container = document.getElementById("outline");
455
+ container.innerHTML = previewData.chapters
456
+ .map((chapter, ci) => {
457
+ const lessonsHtml = chapter.lessons
458
+ .map((lesson, li) => {
459
+ const active = ci === state.chapterIndex && li === state.lessonIndex;
460
+ return (
461
+ '<button class="outline-lesson' + (active ? ' is-active' : '') + '" data-ci="' + ci + '" data-li="' + li + '">' +
462
+ '<span class="outline-lesson-icon">' + kindIcon(lesson.kind) + '</span>' +
463
+ '<span class="outline-lesson-info">' +
464
+ '<span class="outline-lesson-title">' + escapeHtml(lesson.title) + '</span>' +
465
+ '<span class="outline-lesson-kind">' + kindLabel(lesson.kind) + '</span>' +
466
+ '</span>' +
467
+ '</button>'
468
+ );
469
+ })
470
+ .join("");
471
+ return (
472
+ '<div class="outline-chapter">' +
473
+ '<div class="outline-chapter-title">' + escapeHtml(chapter.title) + '</div>' +
474
+ '<div class="outline-chapter-lessons">' + lessonsHtml + '</div>' +
475
+ '</div>'
476
+ );
477
+ })
478
+ .join("");
479
+
480
+ container.querySelectorAll("[data-ci][data-li]").forEach((el) => {
481
+ el.addEventListener("click", () => {
482
+ setActiveLesson(Number(el.dataset.ci), Number(el.dataset.li));
483
+ });
484
+ });
485
+ }
486
+
487
+ /* ---- Slides ---- */
488
+
489
+ function renderSlides(resource, lessonId) {
490
+ const idx = state.slideIndexes[lessonId] || 0;
491
+ const slide = resource.slides[idx] || resource.slides[0];
492
+ const total = resource.slides.length;
493
+
494
+ return (
495
+ '<div class="slide-container">' +
496
+ '<div class="slide-toolbar">' +
497
+ '<span class="badge badge-blue">' + kindIcon('slides') + ' Slide ' + (idx + 1) + ' of ' + total + '</span>' +
498
+ '<div class="slide-nav">' +
499
+ '<button class="btn btn-sm" data-slide-nav="prev" data-lid="' + lessonId + '"' + (idx === 0 ? ' disabled' : '') + '>&larr; Prev</button>' +
500
+ '<button class="btn btn-sm" data-slide-nav="next" data-lid="' + lessonId + '"' + (idx >= total - 1 ? ' disabled' : '') + '>Next &rarr;</button>' +
501
+ '</div>' +
502
+ '</div>' +
503
+ '<div class="slide-stage">' + slide.contentHtml + '</div>' +
504
+ '<div class="slide-meta">' +
505
+ '<div class="card card-inset">' +
506
+ '<div class="card-label">Slide Title</div>' +
507
+ '<div class="card-value">' + escapeHtml(slide.title) + '</div>' +
508
+ '<div class="text-muted text-xs">' + escapeHtml(slide.repoPath) + '</div>' +
509
+ '</div>' +
510
+ '<div class="card card-inset">' +
511
+ '<div class="card-label">Narration Script</div>' +
512
+ (slide.script
513
+ ? '<div class="narration-text">' + escapeHtml(slide.script).replace(/\\n/g, '<br />') + '</div>'
514
+ : '<div class="text-muted">No narration script.</div>') +
515
+ '</div>' +
516
+ '</div>' +
517
+ '</div>'
518
+ );
519
+ }
520
+
521
+ /* ---- Video ---- */
522
+
523
+ function renderVideo(resource) {
524
+ let player = '<div class="video-link"><a href="' + escapeHtml(resource.videoUrl) + '" target="_blank" rel="noreferrer">' + escapeHtml(resource.videoUrl) + '</a></div>';
525
+ if (resource.embed?.type === 'youtube') {
526
+ player = '<div class="video-frame"><iframe src="' + escapeHtml(resource.embed.url) + '" title="' + escapeHtml(resource.title) + '" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>';
527
+ } else if (resource.embed?.type === 'html5') {
528
+ player = '<div class="video-frame"><video controls src="' + escapeHtml(resource.embed.url) + '"></video></div>';
529
+ }
530
+
531
+ return (
532
+ '<div class="video-container">' +
533
+ player +
534
+ (resource.notesHtml ? '<div class="card card-inset"><div class="card-label">Notes</div>' + resource.notesHtml + '</div>' : '') +
535
+ '</div>'
536
+ );
537
+ }
538
+
539
+ /* ---- Quiz ---- */
540
+
541
+ function arraysEqual(a, b) {
542
+ if (a.length !== b.length) return false;
543
+ return a.every((v) => b.includes(v));
544
+ }
545
+
546
+ function renderQuiz(resource, lessonId) {
547
+ const total = resource.questions.length;
548
+ const questionsHtml = resource.questions
549
+ .map((q, qi) => {
550
+ const sel = state.quizSelections[q.id] || [];
551
+ const checked = state.quizChecked[q.id] || false;
552
+ const correctIds = q.choices.filter((c) => c.correct).map((c) => c.id);
553
+ const isCorrect = checked && arraysEqual(sel, correctIds);
554
+ const inputType = q.isMultipleChoice ? 'checkbox' : 'radio';
555
+
556
+ const choicesHtml = q.choices
557
+ .map((c) => {
558
+ const isSelected = sel.includes(c.id);
559
+ const checkedAttr = isSelected ? ' checked' : '';
560
+ const disabledAttr = checked ? ' disabled' : '';
561
+ let statusClass = '';
562
+ if (checked) {
563
+ if (c.correct) statusClass = ' choice-correct';
564
+ else if (isSelected) statusClass = ' choice-wrong';
565
+ }
566
+ const fb = checked && isSelected && c.feedback ? '<div class="choice-feedback">' + escapeHtml(c.feedback) + '</div>' : '';
567
+ return (
568
+ '<label class="choice' + statusClass + '">' +
569
+ '<input type="' + inputType + '" name="' + escapeHtml(q.id) + '" value="' + escapeHtml(c.id) + '" data-quiz-choice="true" data-qid="' + escapeHtml(q.id) + '"' + checkedAttr + disabledAttr + ' />' +
570
+ '<div class="choice-body">' + c.bodyHtml + fb + '</div>' +
571
+ (checked && c.correct ? '<span class="badge badge-green">Correct</span>' : '') +
572
+ '</label>'
573
+ );
574
+ })
575
+ .join('');
576
+
577
+ const feedbackBlock = checked
578
+ ? '<div class="quiz-result ' + (isCorrect ? 'quiz-result-correct' : 'quiz-result-wrong') + '">' +
579
+ (isCorrect ? 'Correct!' : 'Not quite.') +
580
+ (isCorrect && q.correctFeedback ? ' ' + escapeHtml(q.correctFeedback) : '') +
581
+ '</div>'
582
+ : '';
583
+
584
+ return (
585
+ '<div class="question-card">' +
586
+ '<div class="question-header">' +
587
+ '<span class="question-counter">Q' + (qi + 1) + '/' + total + '</span>' +
588
+ '<span class="badge">' + (q.isMultipleChoice ? 'Multi-select' : 'Single choice') + '</span>' +
589
+ '</div>' +
590
+ '<h3>' + escapeHtml(q.title) + '</h3>' +
591
+ '<div class="question-prompt">' + q.promptHtml + '</div>' +
592
+ '<div class="separator">POSSIBLE ANSWERS</div>' +
593
+ '<div class="choices-grid">' + choicesHtml + '</div>' +
594
+ '<div class="question-actions">' +
595
+ '<button class="btn" data-quiz-check="' + escapeHtml(q.id) + '"' + (checked ? ' disabled' : '') + '>Check</button>' +
596
+ '<button class="btn btn-ghost" data-quiz-reset="' + escapeHtml(q.id) + '">Reset</button>' +
597
+ '</div>' +
598
+ feedbackBlock +
599
+ '</div>'
600
+ );
601
+ })
602
+ .join('');
603
+
604
+ return (
605
+ '<div class="quiz-container">' +
606
+ (resource.descriptionHtml ? '<div class="card card-inset">' + resource.descriptionHtml + '</div>' : '') +
607
+ questionsHtml +
608
+ '</div>'
609
+ );
610
+ }
611
+
612
+ /* ---- Coding Exercise ---- */
613
+
614
+ function renderExercise(resource, lessonId) {
615
+ const tab = state.codeTab[lessonId] || 'starter';
616
+ const isStarter = tab === 'starter';
617
+ return (
618
+ '<div class="exercise-split">' +
619
+ '<div class="exercise-left">' +
620
+ '<div class="card-label">Instructions</div>' +
621
+ '<div class="exercise-prompt">' + resource.promptHtml + '</div>' +
622
+ '</div>' +
623
+ '<div class="exercise-right">' +
624
+ '<div class="code-toolbar">' +
625
+ '<div class="tab-bar">' +
626
+ '<button class="tab' + (isStarter ? ' tab-active' : '') + '" data-code-tab="starter" data-lid="' + lessonId + '">' + escapeHtml(resource.starterPath.split('/').pop()) + '</button>' +
627
+ '<button class="tab' + (!isStarter ? ' tab-active' : '') + '" data-code-tab="solution" data-lid="' + lessonId + '">' + escapeHtml(resource.solutionPath.split('/').pop()) + '</button>' +
628
+ '</div>' +
629
+ '<span class="badge badge-blue">' + escapeHtml(resource.language) + '</span>' +
630
+ '</div>' +
631
+ '<pre class="code-block"><code>' + escapeHtml(isStarter ? resource.starterCode : resource.solutionCode) + '</code></pre>' +
632
+ '</div>' +
633
+ '</div>'
634
+ );
635
+ }
636
+
637
+ /* ---- Spreadsheet Lab ---- */
638
+
639
+ function renderWorkbookTable(workbook) {
640
+ if (!workbook || !workbook.sheets) return '<div class="text-muted">No workbook data.</div>';
641
+ const sheetNames = Object.keys(workbook.sheets);
642
+ if (sheetNames.length === 0) return '<div class="text-muted">Empty workbook.</div>';
643
+ let html = '';
644
+ for (const sheetName of sheetNames) {
645
+ const sheet = workbook.sheets[sheetName];
646
+ const cellData = sheet.cellData || {};
647
+ const rowKeys = Object.keys(cellData).map(Number).sort((a, b) => a - b);
648
+ if (rowKeys.length === 0) {
649
+ html += '<div class="text-muted">Sheet "' + escapeHtml(sheetName) + '" is empty.</div>';
650
+ continue;
651
+ }
652
+ let maxCol = 0;
653
+ for (const r of rowKeys) {
654
+ const cols = Object.keys(cellData[r] || {}).map(Number);
655
+ for (const c of cols) { if (c > maxCol) maxCol = c; }
656
+ }
657
+ html += '<div class="sheet-label">' + escapeHtml(sheetName) + '</div>';
658
+ html += '<div class="table-scroll"><table class="wb-table"><thead><tr><th></th>';
659
+ for (let c = 0; c <= maxCol; c++) {
660
+ html += '<th>' + String.fromCharCode(65 + c) + '</th>';
661
+ }
662
+ html += '</tr></thead><tbody>';
663
+ for (const r of rowKeys) {
664
+ html += '<tr><td class="row-num">' + (r + 1) + '</td>';
665
+ for (let c = 0; c <= maxCol; c++) {
666
+ const cell = cellData[r]?.[c];
667
+ const val = cell ? (cell.f ? escapeHtml(cell.f) : (cell.v != null ? escapeHtml(String(cell.v)) : '')) : '';
668
+ const cls = cell?.f ? ' class="cell-formula"' : '';
669
+ html += '<td' + cls + '>' + val + '</td>';
670
+ }
671
+ html += '</tr>';
672
+ }
673
+ html += '</tbody></table></div>';
674
+ }
675
+ return html;
676
+ }
677
+
678
+ function renderSpreadsheetLab(resource, lessonId) {
679
+ const tab = state.spreadsheetTab[lessonId] || 'instructions';
680
+ const wbTab = state.codeTab[lessonId] || 'starter';
681
+
682
+ const checksHtml = resource.checks.length
683
+ ? resource.checks.map((c) =>
684
+ '<div class="check-item">' +
685
+ '<div class="check-label">' + escapeHtml(c.label) + '</div>' +
686
+ '<div class="check-meta">' +
687
+ '<span class="badge">' + escapeHtml(c.type.replace(/_/g, ' ')) + '</span>' +
688
+ '<span class="text-muted">Cell ' + escapeHtml(c.cell) + '</span>' +
689
+ (c.value != null ? '<span class="text-muted">= ' + escapeHtml(String(c.value)) + '</span>' : '') +
690
+ '</div>' +
691
+ '</div>'
692
+ ).join('')
693
+ : '<div class="text-muted">No checks defined.</div>';
694
+
695
+ return (
696
+ '<div class="exercise-split">' +
697
+ '<div class="exercise-left">' +
698
+ '<div class="tab-bar">' +
699
+ '<button class="tab' + (tab === 'instructions' ? ' tab-active' : '') + '" data-ss-tab="instructions" data-lid="' + lessonId + '">Instructions</button>' +
700
+ '<button class="tab' + (tab === 'requirements' ? ' tab-active' : '') + '" data-ss-tab="requirements" data-lid="' + lessonId + '">Requirements</button>' +
701
+ '</div>' +
702
+ (tab === 'instructions'
703
+ ? '<div class="exercise-prompt">' + resource.promptHtml + '</div>'
704
+ : '<div class="checks-list">' + checksHtml + '</div>') +
705
+ '</div>' +
706
+ '<div class="exercise-right">' +
707
+ '<div class="code-toolbar">' +
708
+ '<div class="tab-bar">' +
709
+ '<button class="tab' + (wbTab === 'starter' ? ' tab-active' : '') + '" data-code-tab="starter" data-lid="' + lessonId + '">Starter Workbook</button>' +
710
+ '<button class="tab' + (wbTab !== 'starter' ? ' tab-active' : '') + '" data-code-tab="solution" data-lid="' + lessonId + '">Solution Workbook</button>' +
711
+ '</div>' +
712
+ '<span class="badge badge-blue">' + escapeHtml(resource.engine) + '</span>' +
713
+ '</div>' +
714
+ '<div class="wb-container">' +
715
+ renderWorkbookTable(wbTab === 'starter' ? resource.starterWorkbook : resource.solutionWorkbook) +
716
+ '</div>' +
717
+ '</div>' +
718
+ '</div>'
719
+ );
720
+ }
721
+
722
+ /* ---- Main render ---- */
723
+
724
+ function renderLesson() {
725
+ const lesson = getActiveLesson();
726
+ const container = document.getElementById('lesson-content');
727
+ if (!lesson) {
728
+ container.innerHTML = '<div class="card card-inset text-muted">Select a lesson from the outline.</div>';
729
+ return;
730
+ }
731
+
732
+ let resourceHtml = '';
733
+ if (lesson.resource.type === 'slides') {
734
+ resourceHtml = renderSlides(lesson.resource, lesson.repoKey);
735
+ } else if (lesson.resource.type === 'video') {
736
+ resourceHtml = renderVideo(lesson.resource);
737
+ } else if (lesson.resource.type === 'quiz') {
738
+ resourceHtml = renderQuiz(lesson.resource, lesson.repoKey);
739
+ } else if (lesson.resource.type === 'spreadsheet_lab') {
740
+ resourceHtml = renderSpreadsheetLab(lesson.resource, lesson.repoKey);
741
+ } else {
742
+ resourceHtml = renderExercise(lesson.resource, lesson.repoKey);
743
+ }
744
+
745
+ container.innerHTML =
746
+ '<div class="lesson-header">' +
747
+ '<div>' +
748
+ '<h1 class="lesson-title">' + escapeHtml(lesson.title) + '</h1>' +
749
+ '<span class="text-muted text-xs">' + escapeHtml(lesson.repoPath) + '</span>' +
750
+ '</div>' +
751
+ '<span class="badge badge-kind">' + kindIcon(lesson.kind) + ' ' + kindLabel(lesson.kind) + '</span>' +
752
+ '</div>' +
753
+ (lesson.summaryHtml && lesson.summaryHtml !== '<p class="text-muted">No content provided.</p>'
754
+ ? '<div class="card card-inset lesson-summary">' + lesson.summaryHtml + '</div>'
755
+ : '') +
756
+ resourceHtml;
757
+
758
+ wireLessonInteractions();
759
+ }
760
+
761
+ function wireLessonInteractions() {
762
+ document.querySelectorAll('[data-slide-nav]').forEach((btn) => {
763
+ btn.addEventListener('click', () => {
764
+ const lid = btn.dataset.lid;
765
+ const lesson = getActiveLesson();
766
+ const total = lesson?.resource?.slides?.length || 0;
767
+ const cur = state.slideIndexes[lid] || 0;
768
+ if (btn.dataset.slideNav === 'prev' && cur > 0) state.slideIndexes[lid] = cur - 1;
769
+ if (btn.dataset.slideNav === 'next' && cur < total - 1) state.slideIndexes[lid] = cur + 1;
770
+ renderLesson();
771
+ });
772
+ });
773
+
774
+ document.querySelectorAll('[data-code-tab]').forEach((btn) => {
775
+ btn.addEventListener('click', () => {
776
+ state.codeTab[btn.dataset.lid] = btn.dataset.codeTab;
777
+ renderLesson();
778
+ });
779
+ });
780
+
781
+ document.querySelectorAll('[data-ss-tab]').forEach((btn) => {
782
+ btn.addEventListener('click', () => {
783
+ state.spreadsheetTab[btn.dataset.lid] = btn.dataset.ssTab;
784
+ renderLesson();
785
+ });
786
+ });
787
+
788
+ document.querySelectorAll('[data-quiz-choice="true"]').forEach((input) => {
789
+ input.addEventListener('change', () => {
790
+ const qid = input.dataset.qid;
791
+ const lesson = getActiveLesson();
792
+ const question = lesson?.resource?.questions?.find((q) => q.id === qid);
793
+ if (!question) return;
794
+ const current = new Set(state.quizSelections[qid] || []);
795
+ if (question.isMultipleChoice) {
796
+ if (input.checked) current.add(input.value);
797
+ else current.delete(input.value);
798
+ } else {
799
+ current.clear();
800
+ if (input.checked) current.add(input.value);
801
+ }
802
+ state.quizSelections[qid] = Array.from(current);
803
+ });
804
+ });
805
+
806
+ document.querySelectorAll('[data-quiz-check]').forEach((btn) => {
807
+ btn.addEventListener('click', () => {
808
+ state.quizChecked[btn.dataset.quizCheck] = true;
809
+ renderLesson();
810
+ });
811
+ });
812
+
813
+ document.querySelectorAll('[data-quiz-reset]').forEach((btn) => {
814
+ btn.addEventListener('click', () => {
815
+ const qid = btn.dataset.quizReset;
816
+ delete state.quizSelections[qid];
817
+ delete state.quizChecked[qid];
818
+ renderLesson();
819
+ });
820
+ });
821
+ }
822
+
823
+ function renderWarnings() {
824
+ const container = document.getElementById('warnings');
825
+ if (!previewData.warnings.length) { container.innerHTML = ''; return; }
826
+ container.innerHTML =
827
+ '<div class="warnings-bar">' +
828
+ '<strong>Warnings (' + previewData.warnings.length + ')</strong>' +
829
+ '<ul>' + previewData.warnings.map((w) => '<li>' + escapeHtml(w) + '</li>').join('') + '</ul>' +
830
+ '</div>';
831
+ }
832
+
833
+ function render() {
834
+ renderOutline();
835
+ renderWarnings();
836
+ renderLesson();
837
+ }
838
+
839
+ render();
840
+ `;
841
+ }
842
+
843
+ /* ---------------------------------------------------------------------------
844
+ HTML template
845
+ --------------------------------------------------------------------------- */
846
+
847
+ export function renderPreviewHtml(previewData) {
848
+ const s = previewData.summary;
849
+ const tags = previewData.course.tags.length
850
+ ? previewData.course.tags.map((t) => `<span class="badge">${escapeHtml(t)}</span>`).join(" ")
851
+ : "";
852
+ const statsLine = [
853
+ `${s.chapterCount} chapter${s.chapterCount === 1 ? "" : "s"}`,
854
+ `${s.lessonCount} lesson${s.lessonCount === 1 ? "" : "s"}`,
855
+ s.resourceCounts.slides ? `${s.resourceCounts.slides} slide deck${s.resourceCounts.slides === 1 ? "" : "s"}` : null,
856
+ s.resourceCounts.video ? `${s.resourceCounts.video} video${s.resourceCounts.video === 1 ? "" : "s"}` : null,
857
+ s.resourceCounts.quiz ? `${s.resourceCounts.quiz} quiz${s.resourceCounts.quiz === 1 ? "" : "zes"}` : null,
858
+ s.resourceCounts.coding_exercise ? `${s.resourceCounts.coding_exercise} coding exercise${s.resourceCounts.coding_exercise === 1 ? "" : "s"}` : null,
859
+ s.resourceCounts.spreadsheet_lab ? `${s.resourceCounts.spreadsheet_lab} spreadsheet lab${s.resourceCounts.spreadsheet_lab === 1 ? "" : "s"}` : null,
860
+ ].filter(Boolean).join(" &middot; ");
861
+
862
+ return `<!DOCTYPE html>
863
+ <html lang="en">
864
+ <head>
865
+ <meta charset="utf-8" />
866
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
867
+ <title>${escapeHtml(previewData.course.title)} · Datajaddah Preview</title>
868
+ <style>
869
+ /* ---- Reset & base ---- */
870
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
871
+ html { font-size: 15px; -webkit-font-smoothing: antialiased; }
872
+ body {
873
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
874
+ color: #111827;
875
+ background: #f9fafb;
876
+ line-height: 1.6;
877
+ }
878
+ a { color: #2563eb; text-decoration: none; }
879
+ a:hover { text-decoration: underline; }
880
+ code {
881
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
882
+ background: #f3f4f6;
883
+ padding: 0.15em 0.4em;
884
+ border-radius: 4px;
885
+ font-size: 0.875em;
886
+ }
887
+ pre { margin: 0; }
888
+ pre code { background: transparent; padding: 0; border-radius: 0; font-size: 0.875em; }
889
+ img { max-width: 100%; }
890
+ table { border-collapse: collapse; width: 100%; font-size: 0.875rem; }
891
+ th, td { border: 1px solid #e5e7eb; padding: 0.5rem 0.75rem; text-align: left; }
892
+ th { background: #f9fafb; font-weight: 600; }
893
+ blockquote {
894
+ border-left: 3px solid #d1d5db;
895
+ padding-left: 1rem;
896
+ color: #6b7280;
897
+ margin: 0.75rem 0;
898
+ }
899
+ hr { border: none; border-top: 1px solid #e5e7eb; margin: 1rem 0; }
900
+ h1, h2, h3, h4, h5, h6 { line-height: 1.3; margin-bottom: 0.5rem; }
901
+ h1 { font-size: 1.5rem; font-weight: 700; }
902
+ h2 { font-size: 1.25rem; font-weight: 600; }
903
+ h3 { font-size: 1.1rem; font-weight: 600; }
904
+ p { margin-bottom: 0.5rem; }
905
+ p:last-child { margin-bottom: 0; }
906
+ ul, ol { margin: 0.5rem 0; padding-left: 1.5rem; }
907
+ li { margin-bottom: 0.25rem; }
908
+
909
+ /* ---- Layout shell ---- */
910
+ .app {
911
+ display: grid;
912
+ grid-template-columns: 280px minmax(0, 1fr);
913
+ grid-template-rows: auto minmax(0, 1fr) auto;
914
+ min-height: 100vh;
915
+ }
916
+ .topbar {
917
+ grid-column: 1 / -1;
918
+ display: flex;
919
+ align-items: center;
920
+ justify-content: space-between;
921
+ gap: 1rem;
922
+ padding: 0.75rem 1.25rem;
923
+ background: #fff;
924
+ border-bottom: 1px solid #e5e7eb;
925
+ }
926
+ .topbar-title {
927
+ font-weight: 700;
928
+ font-size: 1rem;
929
+ white-space: nowrap;
930
+ overflow: hidden;
931
+ text-overflow: ellipsis;
932
+ }
933
+ .topbar-meta {
934
+ font-size: 0.8rem;
935
+ color: #6b7280;
936
+ display: flex;
937
+ align-items: center;
938
+ gap: 0.5rem;
939
+ flex-wrap: wrap;
940
+ }
941
+ .sidebar {
942
+ grid-row: 2 / 4;
943
+ border-right: 1px solid #e5e7eb;
944
+ background: #fff;
945
+ overflow-y: auto;
946
+ padding: 0.75rem;
947
+ }
948
+ .main {
949
+ grid-row: 2;
950
+ padding: 1.25rem;
951
+ overflow-y: auto;
952
+ }
953
+ .footer {
954
+ grid-column: 2;
955
+ padding: 0.5rem 1.25rem;
956
+ font-size: 0.8rem;
957
+ color: #6b7280;
958
+ border-top: 1px solid #e5e7eb;
959
+ background: #fff;
960
+ }
961
+
962
+ /* ---- Outline ---- */
963
+ .outline-chapter + .outline-chapter { margin-top: 0.75rem; }
964
+ .outline-chapter-title {
965
+ font-size: 0.7rem;
966
+ font-weight: 700;
967
+ text-transform: uppercase;
968
+ letter-spacing: 0.06em;
969
+ color: #6b7280;
970
+ padding: 0.25rem 0.5rem;
971
+ }
972
+ .outline-chapter-lessons { display: flex; flex-direction: column; gap: 2px; }
973
+ .outline-lesson {
974
+ display: flex;
975
+ align-items: center;
976
+ gap: 0.5rem;
977
+ width: 100%;
978
+ text-align: left;
979
+ border: none;
980
+ background: transparent;
981
+ border-radius: 0.5rem;
982
+ padding: 0.5rem;
983
+ cursor: pointer;
984
+ font: inherit;
985
+ color: inherit;
986
+ transition: background 0.1s;
987
+ }
988
+ .outline-lesson:hover { background: #f3f4f6; }
989
+ .outline-lesson.is-active { background: #eff6ff; color: #1d4ed8; }
990
+ .outline-lesson-icon {
991
+ flex-shrink: 0;
992
+ width: 28px; height: 28px;
993
+ display: flex;
994
+ align-items: center;
995
+ justify-content: center;
996
+ border-radius: 6px;
997
+ background: #f3f4f6;
998
+ color: #6b7280;
999
+ }
1000
+ .outline-lesson.is-active .outline-lesson-icon { background: #dbeafe; color: #2563eb; }
1001
+ .outline-lesson-info { display: flex; flex-direction: column; min-width: 0; }
1002
+ .outline-lesson-title {
1003
+ font-size: 0.85rem;
1004
+ font-weight: 500;
1005
+ white-space: nowrap;
1006
+ overflow: hidden;
1007
+ text-overflow: ellipsis;
1008
+ }
1009
+ .outline-lesson-kind { font-size: 0.7rem; color: #9ca3af; }
1010
+ .outline-lesson.is-active .outline-lesson-kind { color: #60a5fa; }
1011
+
1012
+ /* ---- Cards ---- */
1013
+ .card {
1014
+ background: #fff;
1015
+ border: 1px solid #e5e7eb;
1016
+ border-radius: 0.75rem;
1017
+ overflow: hidden;
1018
+ }
1019
+ .card-inset { padding: 1rem; }
1020
+ .card-label {
1021
+ font-size: 0.7rem;
1022
+ font-weight: 700;
1023
+ text-transform: uppercase;
1024
+ letter-spacing: 0.06em;
1025
+ color: #6b7280;
1026
+ margin-bottom: 0.5rem;
1027
+ }
1028
+ .card-value { font-weight: 500; }
1029
+
1030
+ /* ---- Badges ---- */
1031
+ .badge {
1032
+ display: inline-flex;
1033
+ align-items: center;
1034
+ gap: 0.25rem;
1035
+ font-size: 0.75rem;
1036
+ font-weight: 500;
1037
+ padding: 0.2rem 0.6rem;
1038
+ border-radius: 999px;
1039
+ background: #f3f4f6;
1040
+ color: #374151;
1041
+ white-space: nowrap;
1042
+ }
1043
+ .badge-blue { background: #eff6ff; color: #2563eb; }
1044
+ .badge-green { background: #ecfdf5; color: #059669; }
1045
+ .badge-kind { background: #f0fdf4; color: #16a34a; border: 1px solid #bbf7d0; }
1046
+
1047
+ /* ---- Buttons ---- */
1048
+ .btn {
1049
+ display: inline-flex;
1050
+ align-items: center;
1051
+ gap: 0.25rem;
1052
+ font: inherit;
1053
+ font-size: 0.85rem;
1054
+ font-weight: 500;
1055
+ padding: 0.45rem 1rem;
1056
+ border: 1px solid #d1d5db;
1057
+ border-radius: 0.5rem;
1058
+ background: #fff;
1059
+ color: #111827;
1060
+ cursor: pointer;
1061
+ transition: background 0.1s;
1062
+ }
1063
+ .btn:hover { background: #f9fafb; }
1064
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
1065
+ .btn-sm { padding: 0.3rem 0.7rem; font-size: 0.8rem; }
1066
+ .btn-ghost { border-color: transparent; background: transparent; color: #6b7280; }
1067
+ .btn-ghost:hover { background: #f3f4f6; }
1068
+
1069
+ /* ---- Tabs ---- */
1070
+ .tab-bar { display: flex; gap: 2px; background: #f3f4f6; border-radius: 0.5rem; padding: 2px; }
1071
+ .tab {
1072
+ font: inherit;
1073
+ font-size: 0.8rem;
1074
+ font-weight: 500;
1075
+ padding: 0.35rem 0.75rem;
1076
+ border: none;
1077
+ border-radius: 0.375rem;
1078
+ background: transparent;
1079
+ color: #6b7280;
1080
+ cursor: pointer;
1081
+ white-space: nowrap;
1082
+ }
1083
+ .tab:hover { color: #111827; }
1084
+ .tab-active { background: #fff; color: #111827; box-shadow: 0 1px 2px rgba(0,0,0,0.06); }
1085
+
1086
+ /* ---- Lesson content ---- */
1087
+ .lesson-header {
1088
+ display: flex;
1089
+ align-items: flex-start;
1090
+ justify-content: space-between;
1091
+ gap: 1rem;
1092
+ margin-bottom: 1rem;
1093
+ }
1094
+ .lesson-title { margin-bottom: 0.15rem; }
1095
+ .lesson-summary { margin-bottom: 1rem; }
1096
+ .text-muted { color: #6b7280; }
1097
+ .text-xs { font-size: 0.75rem; }
1098
+
1099
+ /* ---- Slides ---- */
1100
+ .slide-container { display: flex; flex-direction: column; gap: 0.75rem; }
1101
+ .slide-toolbar {
1102
+ display: flex;
1103
+ align-items: center;
1104
+ justify-content: space-between;
1105
+ }
1106
+ .slide-nav { display: flex; gap: 0.35rem; align-items: center; }
1107
+ .slide-stage {
1108
+ background: #fff;
1109
+ border: 1px solid #e5e7eb;
1110
+ border-radius: 0.75rem;
1111
+ padding: 2rem;
1112
+ min-height: 280px;
1113
+ aspect-ratio: 16 / 9;
1114
+ overflow: auto;
1115
+ }
1116
+ .slide-stage h1, .slide-stage h2, .slide-stage h3 { margin-top: 0; }
1117
+ .slide-stage [data-type="columns"] { display: grid; gap: 1.5rem; }
1118
+ .slide-stage [data-type="columns"].layout-two-column { grid-template-columns: 1fr 1fr; }
1119
+ .slide-stage [data-type="columns"].layout-sidebar-left { grid-template-columns: 40fr 60fr; }
1120
+ .slide-stage [data-type="columns"].layout-sidebar-right { grid-template-columns: 60fr 40fr; }
1121
+ .slide-stage img { max-width: 100%; border-radius: 0.5rem; display: block; margin: 1rem auto; }
1122
+ .slide-stage .slide-image-slot {
1123
+ border: 2px dashed #d1d5db;
1124
+ border-radius: 0.5rem;
1125
+ padding: 1rem;
1126
+ background: #f9fafb;
1127
+ }
1128
+ .slide-stage .slide-image-slot__badge {
1129
+ font-size: 0.7rem;
1130
+ text-transform: uppercase;
1131
+ letter-spacing: 0.06em;
1132
+ color: #6b7280;
1133
+ margin-bottom: 0.25rem;
1134
+ }
1135
+ .slide-stage .slide-image-slot__ref { font-weight: 600; }
1136
+ .slide-stage .slide-image-slot__alt,
1137
+ .slide-stage .slide-image-slot__title { color: #6b7280; margin-top: 0.25rem; }
1138
+ .slide-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
1139
+ .narration-text {
1140
+ font-style: italic;
1141
+ color: #374151;
1142
+ line-height: 1.7;
1143
+ }
1144
+
1145
+ /* ---- Video ---- */
1146
+ .video-container { display: flex; flex-direction: column; gap: 0.75rem; }
1147
+ .video-frame {
1148
+ border-radius: 0.75rem;
1149
+ overflow: hidden;
1150
+ aspect-ratio: 16 / 9;
1151
+ background: #111;
1152
+ }
1153
+ .video-frame iframe, .video-frame video { width: 100%; height: 100%; border: 0; display: block; }
1154
+ .video-link {
1155
+ padding: 2rem;
1156
+ background: #fff;
1157
+ border: 1px solid #e5e7eb;
1158
+ border-radius: 0.75rem;
1159
+ text-align: center;
1160
+ }
1161
+
1162
+ /* ---- Quiz ---- */
1163
+ .quiz-container { display: flex; flex-direction: column; gap: 1rem; }
1164
+ .question-card {
1165
+ background: #fff;
1166
+ border: 1px solid #e5e7eb;
1167
+ border-radius: 0.75rem;
1168
+ padding: 1.25rem;
1169
+ display: flex;
1170
+ flex-direction: column;
1171
+ gap: 0.75rem;
1172
+ }
1173
+ .question-header { display: flex; align-items: center; gap: 0.5rem; }
1174
+ .question-counter { font-size: 0.8rem; font-weight: 600; color: #6b7280; }
1175
+ .question-prompt { color: #374151; }
1176
+ .separator {
1177
+ font-size: 0.7rem;
1178
+ font-weight: 700;
1179
+ letter-spacing: 0.08em;
1180
+ color: #9ca3af;
1181
+ border-top: 1px solid #e5e7eb;
1182
+ padding-top: 0.75rem;
1183
+ }
1184
+ .choices-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
1185
+ .choice {
1186
+ display: flex;
1187
+ align-items: flex-start;
1188
+ gap: 0.6rem;
1189
+ padding: 0.75rem;
1190
+ border: 1px solid #e5e7eb;
1191
+ border-radius: 0.5rem;
1192
+ background: #fff;
1193
+ cursor: pointer;
1194
+ transition: border-color 0.1s;
1195
+ }
1196
+ .choice:hover { border-color: #93c5fd; }
1197
+ .choice input { margin-top: 0.2rem; flex-shrink: 0; }
1198
+ .choice-body { flex: 1; min-width: 0; }
1199
+ .choice-feedback { margin-top: 0.35rem; font-size: 0.85rem; color: #6b7280; }
1200
+ .choice-correct { border-color: #86efac; background: #f0fdf4; }
1201
+ .choice-wrong { border-color: #fca5a5; background: #fef2f2; }
1202
+ .question-actions { display: flex; gap: 0.5rem; }
1203
+ .quiz-result {
1204
+ padding: 0.75rem 1rem;
1205
+ border-radius: 0.5rem;
1206
+ font-weight: 500;
1207
+ font-size: 0.9rem;
1208
+ }
1209
+ .quiz-result-correct { background: #ecfdf5; color: #065f46; }
1210
+ .quiz-result-wrong { background: #fef2f2; color: #991b1b; }
1211
+
1212
+ /* ---- Coding Exercise + Spreadsheet Lab ---- */
1213
+ .exercise-split {
1214
+ display: grid;
1215
+ grid-template-columns: 1fr 1fr;
1216
+ gap: 0;
1217
+ border: 1px solid #e5e7eb;
1218
+ border-radius: 0.75rem;
1219
+ overflow: hidden;
1220
+ min-height: 400px;
1221
+ }
1222
+ .exercise-left {
1223
+ padding: 1rem;
1224
+ overflow-y: auto;
1225
+ border-right: 1px solid #e5e7eb;
1226
+ display: flex;
1227
+ flex-direction: column;
1228
+ gap: 0.75rem;
1229
+ }
1230
+ .exercise-right {
1231
+ display: flex;
1232
+ flex-direction: column;
1233
+ background: #fafafa;
1234
+ }
1235
+ .exercise-prompt { flex: 1; }
1236
+ .code-toolbar {
1237
+ display: flex;
1238
+ align-items: center;
1239
+ justify-content: space-between;
1240
+ padding: 0.5rem 0.75rem;
1241
+ border-bottom: 1px solid #e5e7eb;
1242
+ background: #fff;
1243
+ }
1244
+ .code-block {
1245
+ flex: 1;
1246
+ padding: 1rem;
1247
+ overflow: auto;
1248
+ background: #1e1e2e;
1249
+ color: #cdd6f4;
1250
+ font-size: 0.85rem;
1251
+ line-height: 1.6;
1252
+ border-radius: 0;
1253
+ margin: 0;
1254
+ }
1255
+ .code-block-wrapper { position: relative; }
1256
+ .code-block-wrapper .code-lang-label {
1257
+ position: absolute;
1258
+ top: 0.5rem;
1259
+ right: 0.75rem;
1260
+ font-size: 0.7rem;
1261
+ color: #6b7280;
1262
+ background: rgba(255,255,255,0.08);
1263
+ padding: 0.1rem 0.4rem;
1264
+ border-radius: 4px;
1265
+ }
1266
+ .code-block-wrapper .code-block {
1267
+ border-radius: 0.5rem;
1268
+ }
1269
+
1270
+ /* ---- Spreadsheet lab extras ---- */
1271
+ .checks-list { display: flex; flex-direction: column; gap: 0.5rem; }
1272
+ .check-item {
1273
+ padding: 0.5rem 0.75rem;
1274
+ border: 1px solid #e5e7eb;
1275
+ border-radius: 0.375rem;
1276
+ background: #fff;
1277
+ }
1278
+ .check-label { font-weight: 500; margin-bottom: 0.25rem; }
1279
+ .check-meta { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
1280
+ .wb-container { flex: 1; overflow: auto; padding: 0.75rem; }
1281
+ .wb-table { font-size: 0.8rem; border-collapse: collapse; }
1282
+ .wb-table th, .wb-table td { border: 1px solid #e5e7eb; padding: 0.25rem 0.5rem; min-width: 60px; }
1283
+ .wb-table th { background: #f3f4f6; text-align: center; font-weight: 600; color: #6b7280; font-size: 0.75rem; }
1284
+ .wb-table .row-num { background: #f3f4f6; text-align: center; font-weight: 600; color: #6b7280; font-size: 0.75rem; width: 32px; }
1285
+ .wb-table .cell-formula { color: #2563eb; font-family: ui-monospace, monospace; font-size: 0.8rem; }
1286
+ .sheet-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #6b7280; margin-bottom: 0.25rem; }
1287
+ .sheet-label + .table-scroll { margin-bottom: 0.75rem; }
1288
+ .table-scroll { overflow-x: auto; }
1289
+
1290
+ /* ---- Warnings ---- */
1291
+ .warnings-bar {
1292
+ background: #fffbeb;
1293
+ border: 1px solid #fde68a;
1294
+ border-radius: 0.75rem;
1295
+ padding: 0.75rem 1rem;
1296
+ margin-bottom: 1rem;
1297
+ font-size: 0.85rem;
1298
+ color: #92400e;
1299
+ }
1300
+ .warnings-bar ul { margin: 0.5rem 0 0; padding-left: 1.25rem; }
1301
+
1302
+ /* ---- Task list ---- */
1303
+ .task-list { list-style: none; padding-left: 0; }
1304
+ .task-item { display: flex; align-items: baseline; gap: 0.4rem; }
1305
+ .task-item input { flex-shrink: 0; }
1306
+
1307
+ /* ---- Responsive ---- */
1308
+ @media (max-width: 860px) {
1309
+ .app { grid-template-columns: 1fr; }
1310
+ .sidebar {
1311
+ grid-row: auto;
1312
+ border-right: 0;
1313
+ border-bottom: 1px solid #e5e7eb;
1314
+ max-height: 240px;
1315
+ }
1316
+ .footer { grid-column: 1; }
1317
+ .slide-meta { grid-template-columns: 1fr; }
1318
+ .exercise-split { grid-template-columns: 1fr; }
1319
+ .exercise-left { border-right: 0; border-bottom: 1px solid #e5e7eb; }
1320
+ .choices-grid { grid-template-columns: 1fr; }
1321
+ }
1322
+
1323
+ /* ---- Print ---- */
1324
+ @media print {
1325
+ .sidebar, .topbar, .footer { display: none; }
1326
+ .app { display: block; }
1327
+ .main { padding: 0; }
1328
+ .card, .question-card, .exercise-split { break-inside: avoid; }
1329
+ }
1330
+ </style>
1331
+ </head>
1332
+ <body>
1333
+ <div class="app">
1334
+ <header class="topbar">
1335
+ <span class="topbar-title">${escapeHtml(previewData.course.title)}</span>
1336
+ <span class="topbar-meta">
1337
+ ${previewData.course.duration ? `${previewData.course.duration} min` : ""}
1338
+ ${tags ? `&middot; ${tags}` : ""}
1339
+ &middot; schema v${previewData.summary.contract === "repo_tree_v1" ? "1" : "?"}
1340
+ </span>
1341
+ </header>
1342
+ <nav class="sidebar">
1343
+ <div id="outline"></div>
1344
+ </nav>
1345
+ <main class="main">
1346
+ <div id="warnings"></div>
1347
+ <div id="lesson-content"></div>
1348
+ </main>
1349
+ <footer class="footer">${statsLine}</footer>
1350
+ </div>
1351
+ <script>${renderPreviewAppScript(previewData)}</script>
1352
+ </body>
1353
+ </html>`;
1354
+ }
1355
+
1356
+ /* ---------------------------------------------------------------------------
1357
+ Error page
1358
+ --------------------------------------------------------------------------- */
1359
+
1360
+ export function renderPreviewErrorHtml(rootPath, error) {
1361
+ const message = error instanceof Error ? error.message : String(error);
1362
+ return `<!DOCTYPE html>
1363
+ <html lang="en">
1364
+ <head>
1365
+ <meta charset="utf-8" />
1366
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1367
+ <title>Preview error · Datajaddah</title>
1368
+ <style>
1369
+ body {
1370
+ margin: 0;
1371
+ min-height: 100vh;
1372
+ display: grid;
1373
+ place-items: center;
1374
+ background: #f9fafb;
1375
+ color: #111827;
1376
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
1377
+ }
1378
+ article {
1379
+ width: min(680px, calc(100vw - 2rem));
1380
+ background: #fff;
1381
+ border: 1px solid #e5e7eb;
1382
+ border-radius: 0.75rem;
1383
+ padding: 2rem;
1384
+ }
1385
+ h1 { font-size: 1.25rem; margin: 0.5rem 0; }
1386
+ pre {
1387
+ white-space: pre-wrap;
1388
+ word-break: break-word;
1389
+ background: #1e1e2e;
1390
+ color: #cdd6f4;
1391
+ border-radius: 0.5rem;
1392
+ padding: 1rem;
1393
+ font-size: 0.85rem;
1394
+ margin: 1rem 0;
1395
+ }
1396
+ p { color: #6b7280; margin: 0.5rem 0; }
1397
+ code { font-family: ui-monospace, monospace; }
1398
+ </style>
1399
+ </head>
1400
+ <body>
1401
+ <article>
1402
+ <p>Datajaddah course preview</p>
1403
+ <h1>Repository contract error</h1>
1404
+ <p>Fix the repo tree, then refresh the page.</p>
1405
+ <pre>${escapeHtml(message)}</pre>
1406
+ <p><strong>Path:</strong> <code>${escapeHtml(path.resolve(rootPath))}</code></p>
1407
+ </article>
1408
+ </body>
1409
+ </html>`;
1410
+ }
1411
+
1412
+ /* ---------------------------------------------------------------------------
1413
+ Preview server
1414
+ --------------------------------------------------------------------------- */
1415
+
1416
+ function parsePreviewArgs(args, cwd) {
1417
+ const normalizedArgs = [];
1418
+ let positionalOnly = false;
1419
+ for (const arg of args) {
1420
+ if (!positionalOnly && arg === "--") {
1421
+ positionalOnly = true;
1422
+ continue;
1423
+ }
1424
+ normalizedArgs.push(arg);
1425
+ }
1426
+
1427
+ let targetArg = ".";
1428
+ let seenPath = false;
1429
+ let port = 4310;
1430
+ let host = "127.0.0.1";
1431
+ let dev = false;
1432
+
1433
+ for (let index = 0; index < normalizedArgs.length; index += 1) {
1434
+ const arg = normalizedArgs[index];
1435
+ if (arg === "--port") {
1436
+ index += 1;
1437
+ if (index >= normalizedArgs.length) {
1438
+ throw new Error("Missing value for --port");
1439
+ }
1440
+ port = Number(normalizedArgs[index]);
1441
+ continue;
1442
+ }
1443
+ if (arg.startsWith("--port=")) {
1444
+ port = Number(arg.slice("--port=".length));
1445
+ continue;
1446
+ }
1447
+ if (arg === "--host") {
1448
+ index += 1;
1449
+ if (index >= normalizedArgs.length) {
1450
+ throw new Error("Missing value for --host");
1451
+ }
1452
+ host = normalizedArgs[index];
1453
+ continue;
1454
+ }
1455
+ if (arg.startsWith("--host=")) {
1456
+ host = arg.slice("--host=".length);
1457
+ continue;
1458
+ }
1459
+ if (arg === "--dev") {
1460
+ dev = true;
1461
+ continue;
1462
+ }
1463
+ if (arg.startsWith("-")) {
1464
+ throw new Error(`Unknown option: ${arg}`);
1465
+ }
1466
+ if (!seenPath) {
1467
+ targetArg = arg;
1468
+ seenPath = true;
1469
+ continue;
1470
+ }
1471
+ throw new Error(`Unexpected argument: ${arg}`);
1472
+ }
1473
+
1474
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
1475
+ throw new Error("--port must be an integer between 1 and 65535");
1476
+ }
1477
+ if (!host.trim()) {
1478
+ throw new Error("--host cannot be empty");
1479
+ }
1480
+
1481
+ return {
1482
+ rootPath: path.resolve(cwd, targetArg),
1483
+ port,
1484
+ host,
1485
+ dev,
1486
+ };
1487
+ }
1488
+
1489
+ async function createViteMiddleware() {
1490
+ const PREVIEW_APP = path.join(__dirname, "../preview-app");
1491
+ try {
1492
+ // Resolve Vite from preview-app's own node_modules
1493
+ const vitePath = path.join(PREVIEW_APP, "node_modules/vite/dist/node/index.js");
1494
+ const reactPluginPath = path.join(PREVIEW_APP, "node_modules/@vitejs/plugin-react/dist/index.js");
1495
+
1496
+ const viteModule = await import(/* @vite-ignore */ "file://" + vitePath);
1497
+ const createViteServer = viteModule.createServer;
1498
+ const reactModule = await import(/* @vite-ignore */ "file://" + reactPluginPath);
1499
+ const react = reactModule.default;
1500
+
1501
+ const vite = await createViteServer({
1502
+ root: PREVIEW_APP,
1503
+ plugins: [react()],
1504
+ server: { middlewareMode: true },
1505
+ appType: "spa",
1506
+ });
1507
+ return vite;
1508
+ } catch (err) {
1509
+ if (process.env.DEBUG) console.error("Vite init error:", err);
1510
+ return null;
1511
+ }
1512
+ }
1513
+
1514
+ function handleApiRequest(options, response) {
1515
+ try {
1516
+ const previewData = buildPreviewModel(options.rootPath);
1517
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1518
+ response.end(JSON.stringify(previewData));
1519
+ } catch (error) {
1520
+ const status = error instanceof RepoContractError ? 422 : 500;
1521
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
1522
+ response.end(JSON.stringify({ error: error.message }));
1523
+ }
1524
+ }
1525
+
1526
+ export async function startPreviewServer(args, cwd = process.cwd()) {
1527
+ const options = parsePreviewArgs(args, cwd);
1528
+ const initialPreview = buildPreviewModel(options.rootPath);
1529
+
1530
+ let vite = null;
1531
+ let mode;
1532
+
1533
+ if (options.dev) {
1534
+ vite = await createViteMiddleware();
1535
+ if (!vite) {
1536
+ console.error("Could not start Vite dev server. Make sure dependencies are installed:");
1537
+ console.error(" cd preview-app && npm install");
1538
+ console.error("Falling back to built preview or inline HTML.");
1539
+ }
1540
+ }
1541
+
1542
+ if (vite) {
1543
+ mode = "dev (Vite HMR)";
1544
+ } else if (hasPreviewDist()) {
1545
+ mode = "React app";
1546
+ } else {
1547
+ mode = "inline HTML (run npm run build:preview to enable React)";
1548
+ }
1549
+
1550
+ const server = createServer((request, response) => {
1551
+ const url = (request.url || "/").split("?")[0];
1552
+
1553
+ // Health check
1554
+ if (url === "/health") {
1555
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1556
+ response.end(JSON.stringify({ ok: true }));
1557
+ return;
1558
+ }
1559
+
1560
+ // JSON API — always available
1561
+ if (url === "/api/data") {
1562
+ handleApiRequest(options, response);
1563
+ return;
1564
+ }
1565
+
1566
+ // Dev mode — Vite middleware handles everything else (HMR, module serving, SPA fallback)
1567
+ if (vite) {
1568
+ vite.middlewares(request, response);
1569
+ return;
1570
+ }
1571
+
1572
+ // Production React app — serve built static files
1573
+ if (hasPreviewDist()) {
1574
+ if (url !== "/" && url !== "/index.html") {
1575
+ const filePath = path.join(PREVIEW_DIST, url);
1576
+ if (filePath.startsWith(PREVIEW_DIST) && serveStaticFile(filePath, response)) {
1577
+ return;
1578
+ }
1579
+ }
1580
+ serveStaticFile(path.join(PREVIEW_DIST, "index.html"), response);
1581
+ return;
1582
+ }
1583
+
1584
+ // Inline fallback — no React build available
1585
+ try {
1586
+ const previewData = buildPreviewModel(options.rootPath);
1587
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
1588
+ response.end(renderPreviewHtml(previewData));
1589
+ } catch (error) {
1590
+ const status = error instanceof RepoContractError ? 422 : 500;
1591
+ response.writeHead(status, { "content-type": "text/html; charset=utf-8" });
1592
+ response.end(renderPreviewErrorHtml(options.rootPath, error));
1593
+ }
1594
+ });
1595
+
1596
+ await new Promise((resolve, reject) => {
1597
+ server.once("error", reject);
1598
+ server.listen(options.port, options.host, resolve);
1599
+ });
1600
+
1601
+ const serverUrl = `http://${options.host}:${options.port}`;
1602
+ console.error(`Preview server running at ${serverUrl}`);
1603
+ console.error(`Path: ${path.resolve(options.rootPath)}`);
1604
+ console.error(`Course: ${initialPreview.course.title}`);
1605
+ console.error(`Mode: ${mode}`);
1606
+ console.error("Refresh the page after editing repo files to reparse from disk.");
1607
+
1608
+ const shutdown = () => {
1609
+ if (vite) vite.close();
1610
+ server.close(() => process.exit(0));
1611
+ setTimeout(() => process.exit(1), 3000);
1612
+ };
1613
+ process.once("SIGINT", shutdown);
1614
+ process.once("SIGTERM", shutdown);
1615
+
1616
+ return { server, url: serverUrl, options };
1617
+ }