@learnpack/learnpack 5.0.334 → 5.0.339

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 (48) hide show
  1. package/bin/run +17 -17
  2. package/lib/commands/init.js +41 -41
  3. package/lib/commands/serve.js +645 -129
  4. package/lib/creatorDist/assets/index-BhqDgBS9.js +8448 -78631
  5. package/lib/creatorDist/assets/index-CjddKHB_.css +1 -1688
  6. package/lib/managers/config/exercise.js +2 -14
  7. package/lib/managers/readmeHistoryService.js +3 -1
  8. package/lib/managers/server/routes.js +2 -1
  9. package/lib/utils/configBuilder.js +2 -1
  10. package/lib/utils/creatorUtilities.js +14 -14
  11. package/lib/utils/exerciseFileOrder.d.ts +20 -0
  12. package/lib/utils/exerciseFileOrder.js +49 -0
  13. package/lib/utils/export/epub.js +26 -26
  14. package/lib/utils/readmeSanitizer.d.ts +8 -0
  15. package/lib/utils/readmeSanitizer.js +13 -0
  16. package/lib/utils/templates/epub/epub.css +146 -146
  17. package/lib/utils/templates/scorm/config/api.js +175 -175
  18. package/package.json +1 -1
  19. package/src/commands/init.ts +655 -655
  20. package/src/commands/publish.ts +670 -670
  21. package/src/commands/serve.ts +5853 -5148
  22. package/src/creator/eslint.config.js +28 -28
  23. package/src/creator/src/index.css +227 -227
  24. package/src/creator/src/utils/lib.ts +471 -471
  25. package/src/creatorDist/assets/index-BhqDgBS9.js +8448 -78631
  26. package/src/creatorDist/assets/index-CjddKHB_.css +1 -1688
  27. package/src/managers/config/exercise.ts +3 -15
  28. package/src/managers/readmeHistoryService.ts +3 -1
  29. package/src/managers/server/routes.ts +15 -6
  30. package/src/managers/session.ts +184 -184
  31. package/src/ui/_app/app.css +1 -1
  32. package/src/ui/_app/app.js +1950 -1878
  33. package/src/ui/app.tar.gz +0 -0
  34. package/src/utils/api.ts +675 -675
  35. package/src/utils/configBuilder.ts +102 -100
  36. package/src/utils/creatorUtilities.ts +536 -536
  37. package/src/utils/errors.ts +108 -108
  38. package/src/utils/exerciseFileOrder.ts +50 -0
  39. package/src/utils/export/epub.ts +553 -553
  40. package/src/utils/export/index.ts +4 -4
  41. package/src/utils/export/scorm.ts +121 -121
  42. package/src/utils/export/shared.ts +61 -61
  43. package/src/utils/export/types.ts +25 -25
  44. package/src/utils/export/zip.ts +55 -55
  45. package/src/utils/readmeSanitizer.ts +10 -0
  46. package/src/utils/rigoActions.ts +642 -642
  47. package/src/utils/templates/epub/epub.css +146 -146
  48. package/src/utils/templates/scorm/config/api.js +175 -175
@@ -1,553 +1,553 @@
1
- import * as path from "path";
2
- import * as fs from "fs";
3
- import * as mkdirp from "mkdirp";
4
- import * as rimraf from "rimraf";
5
- import { v4 as uuidv4 } from "uuid";
6
- import { spawn } from "child_process";
7
- import { ExportOptions, CourseMetadata, EpubMetadata } from "./types";
8
- import { downloadS3Folder, getCourseMetadata } from "./shared";
9
-
10
- const getLocalizedText = (language: string) => {
11
- const translations = {
12
- en: "Write your answer here",
13
- es: "Escribe tu respuesta aquí",
14
- fr: "Écrivez votre réponse ici",
15
- de: "Schreiben Sie Ihre Antwort hier",
16
- it: "Scrivi la tua risposta qui",
17
- pt: "Escreva sua resposta aqui",
18
- ja: "ここに答えを書いてください",
19
- ko: "여기에 답을 작성하세요",
20
- zh: "在这里写下你的答案",
21
- ar: "اكتب إجابتك هنا",
22
- ru: "Напишите свой ответ здесь",
23
- };
24
-
25
- return translations[language as keyof typeof translations] || translations.en;
26
- };
27
-
28
- const replaceQuestionsFromMarkdown = (
29
- markdown: string,
30
- language: string = "en"
31
- ) => {
32
- // Search for all code blocks like this (No breaking lines sensitive)
33
- // ```question some parameters
34
- // some text
35
- // ```
36
- const questionRegex = /```question([\s\S]*?)```/g;
37
- const localizedText = getLocalizedText(language);
38
- return markdown.replace(
39
- questionRegex,
40
- `<div type="text" class="open-question">${localizedText}</div>`
41
- );
42
- };
43
-
44
- const replaceMermaidFromMarkdown = (markdown: string) => {
45
- // Search for all code blocks like this (No breaking lines sensitive)
46
- // ```mermaid
47
- // some graphs in mermaid syntax
48
- // ```
49
- const mermaidRegex = /```mermaid([\s\S]*?)```/g;
50
- return markdown.replace(mermaidRegex, "");
51
- };
52
-
53
- const cleanQuizOptions = (markdown: string) => {
54
- // Remove [x] from quiz options, keeping only [ ] for all options
55
- // This handles both - [x] and - [x] patterns
56
- const quizRegex = /^(\s*-\s*)\[x\](\s+.*)$/gm;
57
- return markdown.replace(quizRegex, "$1[ ]$2");
58
- };
59
-
60
- // Download cover image from bucket
61
- async function downloadCoverImage(
62
- bucket: any,
63
- courseSlug: string,
64
- outputDir: string
65
- ): Promise<string | undefined> {
66
- try {
67
- const coverImagePath = path.join(outputDir, "cover.png");
68
- const [coverBuf] = await bucket
69
- .file(`courses/${courseSlug}/preview.png`)
70
- .download();
71
- fs.writeFileSync(coverImagePath, coverBuf);
72
- console.log("Cover image downloaded:", coverImagePath);
73
- return coverImagePath;
74
- } catch (error) {
75
- console.log("No cover image found, skipping...");
76
- return undefined;
77
- }
78
- }
79
-
80
- // Generate EPUB using pandoc directly from markdown files
81
- async function generateEpub(
82
- markdownFiles: string[],
83
- outputPath: string,
84
- metadata: CourseMetadata,
85
- epubMetadata: EpubMetadata,
86
- coverImagePath?: string
87
- ): Promise<void> {
88
- return new Promise((resolve, reject) => {
89
- let stdout = "";
90
- let stderr = "";
91
-
92
- const yamlPath = path.join(path.dirname(outputPath), "metadata.yaml");
93
-
94
- const escapeYaml = (str: string) => {
95
- if (!str) return "";
96
- return str.replace(/"/g, '\\"').replace(/\n/g, "\\n");
97
- };
98
-
99
- const yamlContent = `---
100
- title: "${epubMetadata.title}"
101
- creator: "${epubMetadata.creator}"
102
- publisher: "${epubMetadata.publisher}"
103
- rights: "${epubMetadata.rights}"
104
- lang: "${epubMetadata.lang}"
105
- description: "${escapeYaml(metadata.description || "")}"
106
- subject: "${metadata.technologies?.join(", ") || "Programming"}"
107
- ---`;
108
-
109
- fs.writeFileSync(yamlPath, yamlContent, "utf-8");
110
-
111
- // Include all markdown files in the EPUB
112
- const pandocArgs = [
113
- ...markdownFiles, // All markdown files
114
- "-o",
115
- outputPath,
116
- "--from",
117
- "markdown",
118
- "--to",
119
- "epub",
120
- "--metadata-file",
121
- yamlPath,
122
- "--css",
123
- path.join(__dirname, "../templates/epub/epub.css"),
124
- ];
125
-
126
- // Add cover image if available - use absolute path
127
- if (coverImagePath) {
128
- pandocArgs.push("--epub-cover-image", coverImagePath);
129
- }
130
-
131
- console.log("🔧 Executing pandoc EPUB command:");
132
- console.log("Command:", "pandoc", pandocArgs.join(" "));
133
-
134
- const pandoc = spawn("pandoc", pandocArgs);
135
-
136
- pandoc.stdout.on("data", (data) => {
137
- const output = data.toString();
138
- stdout += output;
139
- console.log("📤 Pandoc STDOUT:", output);
140
- });
141
-
142
- pandoc.stderr.on("data", (data) => {
143
- const output = data.toString();
144
- stderr += output;
145
- console.log("⚠️ Pandoc STDERR:", output);
146
- });
147
-
148
- pandoc.on("close", (code) => {
149
- console.log(`🏁 Pandoc process finished with exit code: ${code}`);
150
- if (code === 0) {
151
- console.log("✅ EPUB generation successful!");
152
- resolve();
153
- } else {
154
- const errorMessage = `Pandoc process exited with code ${code}\n\nSTDOUT:\n${stdout}\n\nSTDERR:\n${stderr}`;
155
- console.error("❌ Pandoc Error Details:", errorMessage);
156
- reject(new Error(errorMessage));
157
- }
158
- });
159
-
160
- pandoc.on("error", (err) => {
161
- console.error("❌ Pandoc spawn error:", err);
162
- reject(err);
163
- });
164
- });
165
- }
166
-
167
- // Process exercises and collect markdown files for direct EPUB conversion
168
- async function processExercises(
169
- exercisesDir: string,
170
- outputDir: string,
171
- language: string = "en",
172
- learnAssetsDir?: string
173
- ): Promise<string[]> {
174
- const processedMarkdownFiles: string[] = [];
175
-
176
- console.log("Processing exercises directory:", exercisesDir);
177
- console.log("Output directory:", outputDir);
178
- console.log("Language:", language);
179
- console.log("Learn assets directory:", learnAssetsDir);
180
-
181
- if (!fs.existsSync(exercisesDir)) {
182
- console.log("Exercises directory does not exist:", exercisesDir);
183
- return processedMarkdownFiles;
184
- }
185
-
186
- console.log("Exercises directory exists, scanning for files...");
187
-
188
- const processDirectory = async (dir: string, relativePath: string = "") => {
189
- console.log(`🔍 Processing directory: ${dir} (relative: ${relativePath})`);
190
- const entries = fs.readdirSync(dir, { withFileTypes: true });
191
- console.log(
192
- `📁 Found ${entries.length} entries in ${dir}:`,
193
- entries.map((e) => e.name)
194
- );
195
-
196
- // Sort entries to prioritize README files first
197
- entries.sort((a, b) => {
198
- const aName = a.name.toLowerCase();
199
- const bName = b.name.toLowerCase();
200
-
201
- // Prioritize README files
202
- if (aName === "readme.md" && bName !== "readme.md") return -1;
203
- if (bName === "readme.md" && aName !== "readme.md") return 1;
204
-
205
- return aName.localeCompare(bName);
206
- });
207
-
208
- for (const entry of entries) {
209
- const fullPath = path.join(dir, entry.name);
210
- const relativeFilePath = path.join(relativePath, entry.name);
211
-
212
- if (entry.isDirectory()) {
213
- console.log("Processing subdirectory:", relativeFilePath);
214
- // esling-disable-next-line
215
- await processDirectory(fullPath, relativeFilePath);
216
- } else if (
217
- entry.name.endsWith(".md") ||
218
- entry.name.endsWith(".markdown")
219
- ) {
220
- // Apply language filtering for all markdown files
221
- const baseName = entry.name.replace(/\.(md|markdown)$/, "");
222
-
223
- if (language === "en") {
224
- // For English: only include README.md and files without language suffixes
225
- if (entry.name.toLowerCase() === "readme.md") {
226
- console.log(`✅ Including English README: ${relativeFilePath}`);
227
- } else if (
228
- baseName.includes(".") &&
229
- /\.(es|fr|de|it|pt|ja|ko|zh|ar|ru)$/i.test(baseName)
230
- ) {
231
- console.log(
232
- `❌ Skipping language-specific file for English: ${relativeFilePath}`
233
- );
234
- continue;
235
- } else {
236
- console.log(`✅ Including English file: ${relativeFilePath}`);
237
- }
238
- } else {
239
- // For other languages: only include files with the correct language suffix
240
- if (entry.name.toLowerCase() === "readme.md") {
241
- console.log(
242
- `❌ Skipping English README for language ${language}: ${relativeFilePath}`
243
- );
244
- continue;
245
- } else if (!baseName.endsWith(`.${language}`)) {
246
- console.log(
247
- `❌ Skipping file with different language: ${relativeFilePath}`
248
- );
249
- continue;
250
- } else {
251
- console.log(`✅ Including ${language} file: ${relativeFilePath}`);
252
- }
253
- }
254
-
255
- // Process markdown content and save to output directory
256
- console.log("Processing markdown file:", relativeFilePath);
257
- const processedContent = processMarkdownContent(
258
- fullPath,
259
- learnAssetsDir,
260
- language
261
- );
262
-
263
- const processedMarkdownPath = path.join(
264
- outputDir,
265
- relativeFilePath.replace(/\.(md|markdown)$/, ".processed.md")
266
- );
267
-
268
- console.log("Processed markdown output path:", processedMarkdownPath);
269
- mkdirp.sync(path.dirname(processedMarkdownPath));
270
- fs.writeFileSync(processedMarkdownPath, processedContent, "utf-8");
271
-
272
- processedMarkdownFiles.push(processedMarkdownPath);
273
- console.log(
274
- "Successfully processed:",
275
- relativeFilePath,
276
- "->",
277
- processedMarkdownPath
278
- );
279
- } else if (entry.name.endsWith(".html")) {
280
- // Convert HTML files to markdown for consistency
281
- console.log("Converting HTML file to markdown:", relativeFilePath);
282
- const htmlContent = fs.readFileSync(fullPath, "utf-8");
283
- const processedMarkdownPath = path.join(
284
- outputDir,
285
- relativeFilePath.replace(/\.html$/, ".processed.md")
286
- );
287
-
288
- mkdirp.sync(path.dirname(processedMarkdownPath));
289
- fs.writeFileSync(processedMarkdownPath, htmlContent, "utf-8");
290
- processedMarkdownFiles.push(processedMarkdownPath);
291
- console.log(
292
- "Successfully converted HTML:",
293
- relativeFilePath,
294
- "->",
295
- processedMarkdownPath
296
- );
297
- } else {
298
- console.log(
299
- "Skipping file:",
300
- relativeFilePath,
301
- "(not markdown or HTML)"
302
- );
303
- }
304
- }
305
- };
306
-
307
- await processDirectory(exercisesDir);
308
- return processedMarkdownFiles;
309
- }
310
-
311
- // Process markdown content for EPUB conversion
312
- function processMarkdownContent(
313
- markdownPath: string,
314
- learnAssetsDir?: string,
315
- language: string = "en"
316
- ): string {
317
- let markdownContent = fs.readFileSync(markdownPath, "utf-8");
318
-
319
- if (learnAssetsDir && fs.existsSync(learnAssetsDir)) {
320
- // Find all image references in markdown
321
- const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
322
- let match;
323
-
324
- while ((match = imageRegex.exec(markdownContent)) !== null) {
325
- const [fullMatch, altText, imagePath] = match;
326
-
327
- // Check if it's a relative path starting with /learn/assets/
328
- if (imagePath.startsWith("/.learn/assets/")) {
329
- const imageName = path.basename(imagePath);
330
- const absoluteImagePath = path.join(learnAssetsDir, imageName);
331
-
332
- console.log(`📸 Looking for image: ${imageName}`);
333
- console.log(`📁 Learn assets dir: ${learnAssetsDir}`);
334
- console.log(`📁 Absolute path: ${absoluteImagePath}`);
335
- console.log(`📁 Path exists: ${fs.existsSync(absoluteImagePath)}`);
336
-
337
- if (fs.existsSync(absoluteImagePath)) {
338
- // Use absolute path for the image
339
- console.log(
340
- `✅ Found image, replacing path: ${imagePath} -> ${absoluteImagePath}`
341
- );
342
- markdownContent = markdownContent.replace(
343
- fullMatch,
344
- `![${altText}](${absoluteImagePath})`
345
- );
346
- } else {
347
- console.log(`❌ Image not found: ${absoluteImagePath}`);
348
- // List files in the assets directory for debugging
349
- if (fs.existsSync(learnAssetsDir)) {
350
- const files = fs.readdirSync(learnAssetsDir);
351
- console.log(`📁 Files in assets directory: ${files.join(", ")}`);
352
- } else {
353
- console.log(
354
- `❌ Assets directory does not exist: ${learnAssetsDir}`
355
- );
356
- }
357
- }
358
- }
359
- }
360
- }
361
-
362
- markdownContent = replaceQuestionsFromMarkdown(markdownContent, language);
363
- markdownContent = replaceMermaidFromMarkdown(markdownContent);
364
- markdownContent = cleanQuizOptions(markdownContent);
365
-
366
- return markdownContent;
367
- }
368
-
369
- const titlelify = (title: string) => {
370
- return title.replace(/[-_]/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
371
- };
372
-
373
- // Read sidebar.json to get exercise titles
374
- function readSidebarData(learnDir: string): any {
375
- try {
376
- const sidebarPath = path.join(learnDir, "sidebar.json");
377
- if (fs.existsSync(sidebarPath)) {
378
- const sidebarContent = fs.readFileSync(sidebarPath, "utf-8");
379
- return JSON.parse(sidebarContent);
380
- }
381
- } catch (error) {
382
- console.log("⚠️ Could not read sidebar.json:", error);
383
- }
384
- return null;
385
- }
386
-
387
- export async function exportToEpub(
388
- options: ExportOptions,
389
- epubMetadata: EpubMetadata
390
- ): Promise<string> {
391
- const { courseSlug, bucket, outDir, language = "en" } = options;
392
-
393
- console.log("🚀 Starting EPUB export for course:", courseSlug);
394
- console.log("📁 Output directory:", outDir);
395
- console.log("🌍 Language:", language);
396
- console.log("📋 EPUB Metadata:", epubMetadata);
397
-
398
- // 1. Create temporary folder
399
- const tmpName = uuidv4();
400
- const epubOutDir = path.join(outDir, tmpName);
401
- rimraf.sync(epubOutDir);
402
- mkdirp.sync(epubOutDir);
403
-
404
- console.log("Created temporary directory:", epubOutDir);
405
-
406
- // 2. Create EPUB template directory
407
- const epubTemplateDir = path.join(epubOutDir, "template");
408
- mkdirp.sync(epubTemplateDir);
409
-
410
- // 3. Download exercises, .learn, and cover image from bucket
411
- console.log("📥 Downloading exercises...");
412
- await downloadS3Folder(
413
- bucket,
414
- `courses/${courseSlug}/exercises/`,
415
- path.join(epubOutDir, "exercises")
416
- );
417
- console.log("✅ Exercises downloaded successfully");
418
-
419
- console.log("📥 Downloading .learn files...");
420
- await downloadS3Folder(
421
- bucket,
422
- `courses/${courseSlug}/.learn/`,
423
- path.join(epubOutDir, "learn")
424
- );
425
- console.log("✅ .learn files downloaded successfully");
426
-
427
- console.log("📥 Downloading cover image...");
428
- const coverImagePath = await downloadCoverImage(
429
- bucket,
430
- courseSlug,
431
- epubOutDir
432
- );
433
- console.log(
434
- "✅ Cover image download completed:",
435
- coverImagePath ? "Found" : "Not found"
436
- );
437
-
438
- // 4. Read learn.json for course info
439
- console.log("Reading course metadata...");
440
- const learnJson = await getCourseMetadata(bucket, courseSlug);
441
-
442
- const metadata: CourseMetadata = {
443
- title: learnJson.title?.[epubMetadata.lang] || courseSlug,
444
- description:
445
- learnJson.description?.[epubMetadata.lang] || learnJson.description,
446
- language: epubMetadata.lang,
447
- technologies: learnJson.technologies || [],
448
- difficulty: learnJson.difficulty || "beginner",
449
- };
450
-
451
- console.log("Course metadata:", metadata);
452
-
453
- // 5. Process exercises and prepare markdown files
454
- console.log("🔄 Processing exercises and preparing markdown files...");
455
-
456
- // First, let's see what files were downloaded
457
- const exercisesPath = path.join(epubOutDir, "exercises");
458
- console.log("📂 Exercises directory contents:");
459
- if (fs.existsSync(exercisesPath)) {
460
- const exerciseDirs = fs.readdirSync(exercisesPath, { withFileTypes: true });
461
- console.log(`Found ${exerciseDirs.length} exercise directories:`);
462
- exerciseDirs.forEach((dir) => {
463
- if (dir.isDirectory()) {
464
- const dirPath = path.join(exercisesPath, dir.name);
465
- const files = fs.readdirSync(dirPath);
466
- console.log(
467
- ` 📁 ${dir.name}: ${files.length} files - ${files.join(", ")}`
468
- );
469
- }
470
- });
471
- } else {
472
- console.log("❌ Exercises directory does not exist!");
473
- }
474
-
475
- const processedMarkdownFiles = await processExercises(
476
- path.join(epubOutDir, "exercises"),
477
- path.join(epubOutDir, "processed"),
478
- language,
479
- path.join(epubOutDir, "learn", "assets")
480
- );
481
-
482
- // 6. Read sidebar data and create table of contents
483
- const sidebarData = readSidebarData(path.join(epubOutDir, "learn"));
484
- console.log("📋 Sidebar data loaded:", sidebarData ? "Success" : "Not found");
485
-
486
- // 7. Create main content file as markdown
487
- const mainContent = `# ${metadata.title}
488
-
489
- ${metadata.description || ""}
490
-
491
- **Technologies:** ${metadata.technologies?.join(", ") || "Programming"}
492
-
493
- **Difficulty:** ${metadata.difficulty}
494
-
495
- **Language:** ${language}
496
-
497
- ---
498
-
499
- ## Table of Contents
500
-
501
- ${processedMarkdownFiles
502
- .map((file, index) => {
503
- const fileName = path.basename(file, ".processed.md");
504
- const parentDir = path.basename(path.dirname(file));
505
-
506
- // Get the title from sidebar.json using the parent directory name
507
- let displayName = fileName;
508
- if (sidebarData && sidebarData[parentDir]) {
509
- displayName =
510
- sidebarData[parentDir][language] ||
511
- sidebarData[parentDir]["en"] ||
512
- titlelify(parentDir);
513
- } else {
514
- displayName = titlelify(parentDir);
515
- }
516
-
517
- return `${index + 1}. ${displayName}`;
518
- })
519
- .join("\n")}
520
-
521
- ---
522
-
523
- `;
524
-
525
- const mainContentPath = path.join(epubOutDir, "processed", "main.md");
526
- mkdirp.sync(path.dirname(mainContentPath));
527
- fs.writeFileSync(mainContentPath, mainContent, "utf-8");
528
-
529
- // 8. Prepare all markdown files for EPUB generation
530
- const allMarkdownFiles = [mainContentPath, ...processedMarkdownFiles];
531
-
532
- // 9. Generate EPUB using pandoc
533
- console.log("📖 Generating EPUB file...");
534
- const epubPath = path.join(epubOutDir, `${courseSlug}.epub`);
535
- await generateEpub(
536
- allMarkdownFiles,
537
- epubPath,
538
- metadata,
539
- epubMetadata,
540
- coverImagePath
541
- );
542
-
543
- console.log("EPUB generated successfully:", epubPath);
544
-
545
- // Clean up temporary files
546
- console.log("Cleaning up temporary files...");
547
- rimraf.sync(path.join(epubOutDir, "processed"));
548
- rimraf.sync(path.join(epubOutDir, "exercises"));
549
- rimraf.sync(path.join(epubOutDir, "learn"));
550
-
551
- console.log("EPUB export completed successfully");
552
- return epubPath;
553
- }
1
+ import * as path from "path";
2
+ import * as fs from "fs";
3
+ import * as mkdirp from "mkdirp";
4
+ import * as rimraf from "rimraf";
5
+ import { v4 as uuidv4 } from "uuid";
6
+ import { spawn } from "child_process";
7
+ import { ExportOptions, CourseMetadata, EpubMetadata } from "./types";
8
+ import { downloadS3Folder, getCourseMetadata } from "./shared";
9
+
10
+ const getLocalizedText = (language: string) => {
11
+ const translations = {
12
+ en: "Write your answer here",
13
+ es: "Escribe tu respuesta aquí",
14
+ fr: "Écrivez votre réponse ici",
15
+ de: "Schreiben Sie Ihre Antwort hier",
16
+ it: "Scrivi la tua risposta qui",
17
+ pt: "Escreva sua resposta aqui",
18
+ ja: "ここに答えを書いてください",
19
+ ko: "여기에 답을 작성하세요",
20
+ zh: "在这里写下你的答案",
21
+ ar: "اكتب إجابتك هنا",
22
+ ru: "Напишите свой ответ здесь",
23
+ };
24
+
25
+ return translations[language as keyof typeof translations] || translations.en;
26
+ };
27
+
28
+ const replaceQuestionsFromMarkdown = (
29
+ markdown: string,
30
+ language: string = "en"
31
+ ) => {
32
+ // Search for all code blocks like this (No breaking lines sensitive)
33
+ // ```question some parameters
34
+ // some text
35
+ // ```
36
+ const questionRegex = /```question([\s\S]*?)```/g;
37
+ const localizedText = getLocalizedText(language);
38
+ return markdown.replace(
39
+ questionRegex,
40
+ `<div type="text" class="open-question">${localizedText}</div>`
41
+ );
42
+ };
43
+
44
+ const replaceMermaidFromMarkdown = (markdown: string) => {
45
+ // Search for all code blocks like this (No breaking lines sensitive)
46
+ // ```mermaid
47
+ // some graphs in mermaid syntax
48
+ // ```
49
+ const mermaidRegex = /```mermaid([\s\S]*?)```/g;
50
+ return markdown.replace(mermaidRegex, "");
51
+ };
52
+
53
+ const cleanQuizOptions = (markdown: string) => {
54
+ // Remove [x] from quiz options, keeping only [ ] for all options
55
+ // This handles both - [x] and - [x] patterns
56
+ const quizRegex = /^(\s*-\s*)\[x\](\s+.*)$/gm;
57
+ return markdown.replace(quizRegex, "$1[ ]$2");
58
+ };
59
+
60
+ // Download cover image from bucket
61
+ async function downloadCoverImage(
62
+ bucket: any,
63
+ courseSlug: string,
64
+ outputDir: string
65
+ ): Promise<string | undefined> {
66
+ try {
67
+ const coverImagePath = path.join(outputDir, "cover.png");
68
+ const [coverBuf] = await bucket
69
+ .file(`courses/${courseSlug}/preview.png`)
70
+ .download();
71
+ fs.writeFileSync(coverImagePath, coverBuf);
72
+ console.log("Cover image downloaded:", coverImagePath);
73
+ return coverImagePath;
74
+ } catch (error) {
75
+ console.log("No cover image found, skipping...");
76
+ return undefined;
77
+ }
78
+ }
79
+
80
+ // Generate EPUB using pandoc directly from markdown files
81
+ async function generateEpub(
82
+ markdownFiles: string[],
83
+ outputPath: string,
84
+ metadata: CourseMetadata,
85
+ epubMetadata: EpubMetadata,
86
+ coverImagePath?: string
87
+ ): Promise<void> {
88
+ return new Promise((resolve, reject) => {
89
+ let stdout = "";
90
+ let stderr = "";
91
+
92
+ const yamlPath = path.join(path.dirname(outputPath), "metadata.yaml");
93
+
94
+ const escapeYaml = (str: string) => {
95
+ if (!str) return "";
96
+ return str.replace(/"/g, '\\"').replace(/\n/g, "\\n");
97
+ };
98
+
99
+ const yamlContent = `---
100
+ title: "${epubMetadata.title}"
101
+ creator: "${epubMetadata.creator}"
102
+ publisher: "${epubMetadata.publisher}"
103
+ rights: "${epubMetadata.rights}"
104
+ lang: "${epubMetadata.lang}"
105
+ description: "${escapeYaml(metadata.description || "")}"
106
+ subject: "${metadata.technologies?.join(", ") || "Programming"}"
107
+ ---`;
108
+
109
+ fs.writeFileSync(yamlPath, yamlContent, "utf-8");
110
+
111
+ // Include all markdown files in the EPUB
112
+ const pandocArgs = [
113
+ ...markdownFiles, // All markdown files
114
+ "-o",
115
+ outputPath,
116
+ "--from",
117
+ "markdown",
118
+ "--to",
119
+ "epub",
120
+ "--metadata-file",
121
+ yamlPath,
122
+ "--css",
123
+ path.join(__dirname, "../templates/epub/epub.css"),
124
+ ];
125
+
126
+ // Add cover image if available - use absolute path
127
+ if (coverImagePath) {
128
+ pandocArgs.push("--epub-cover-image", coverImagePath);
129
+ }
130
+
131
+ console.log("🔧 Executing pandoc EPUB command:");
132
+ console.log("Command:", "pandoc", pandocArgs.join(" "));
133
+
134
+ const pandoc = spawn("pandoc", pandocArgs);
135
+
136
+ pandoc.stdout.on("data", (data) => {
137
+ const output = data.toString();
138
+ stdout += output;
139
+ console.log("📤 Pandoc STDOUT:", output);
140
+ });
141
+
142
+ pandoc.stderr.on("data", (data) => {
143
+ const output = data.toString();
144
+ stderr += output;
145
+ console.log("⚠️ Pandoc STDERR:", output);
146
+ });
147
+
148
+ pandoc.on("close", (code) => {
149
+ console.log(`🏁 Pandoc process finished with exit code: ${code}`);
150
+ if (code === 0) {
151
+ console.log("✅ EPUB generation successful!");
152
+ resolve();
153
+ } else {
154
+ const errorMessage = `Pandoc process exited with code ${code}\n\nSTDOUT:\n${stdout}\n\nSTDERR:\n${stderr}`;
155
+ console.error("❌ Pandoc Error Details:", errorMessage);
156
+ reject(new Error(errorMessage));
157
+ }
158
+ });
159
+
160
+ pandoc.on("error", (err) => {
161
+ console.error("❌ Pandoc spawn error:", err);
162
+ reject(err);
163
+ });
164
+ });
165
+ }
166
+
167
+ // Process exercises and collect markdown files for direct EPUB conversion
168
+ async function processExercises(
169
+ exercisesDir: string,
170
+ outputDir: string,
171
+ language: string = "en",
172
+ learnAssetsDir?: string
173
+ ): Promise<string[]> {
174
+ const processedMarkdownFiles: string[] = [];
175
+
176
+ console.log("Processing exercises directory:", exercisesDir);
177
+ console.log("Output directory:", outputDir);
178
+ console.log("Language:", language);
179
+ console.log("Learn assets directory:", learnAssetsDir);
180
+
181
+ if (!fs.existsSync(exercisesDir)) {
182
+ console.log("Exercises directory does not exist:", exercisesDir);
183
+ return processedMarkdownFiles;
184
+ }
185
+
186
+ console.log("Exercises directory exists, scanning for files...");
187
+
188
+ const processDirectory = async (dir: string, relativePath: string = "") => {
189
+ console.log(`🔍 Processing directory: ${dir} (relative: ${relativePath})`);
190
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
191
+ console.log(
192
+ `📁 Found ${entries.length} entries in ${dir}:`,
193
+ entries.map((e) => e.name)
194
+ );
195
+
196
+ // Sort entries to prioritize README files first
197
+ entries.sort((a, b) => {
198
+ const aName = a.name.toLowerCase();
199
+ const bName = b.name.toLowerCase();
200
+
201
+ // Prioritize README files
202
+ if (aName === "readme.md" && bName !== "readme.md") return -1;
203
+ if (bName === "readme.md" && aName !== "readme.md") return 1;
204
+
205
+ return aName.localeCompare(bName);
206
+ });
207
+
208
+ for (const entry of entries) {
209
+ const fullPath = path.join(dir, entry.name);
210
+ const relativeFilePath = path.join(relativePath, entry.name);
211
+
212
+ if (entry.isDirectory()) {
213
+ console.log("Processing subdirectory:", relativeFilePath);
214
+ // esling-disable-next-line
215
+ await processDirectory(fullPath, relativeFilePath);
216
+ } else if (
217
+ entry.name.endsWith(".md") ||
218
+ entry.name.endsWith(".markdown")
219
+ ) {
220
+ // Apply language filtering for all markdown files
221
+ const baseName = entry.name.replace(/\.(md|markdown)$/, "");
222
+
223
+ if (language === "en") {
224
+ // For English: only include README.md and files without language suffixes
225
+ if (entry.name.toLowerCase() === "readme.md") {
226
+ console.log(`✅ Including English README: ${relativeFilePath}`);
227
+ } else if (
228
+ baseName.includes(".") &&
229
+ /\.(es|fr|de|it|pt|ja|ko|zh|ar|ru)$/i.test(baseName)
230
+ ) {
231
+ console.log(
232
+ `❌ Skipping language-specific file for English: ${relativeFilePath}`
233
+ );
234
+ continue;
235
+ } else {
236
+ console.log(`✅ Including English file: ${relativeFilePath}`);
237
+ }
238
+ } else {
239
+ // For other languages: only include files with the correct language suffix
240
+ if (entry.name.toLowerCase() === "readme.md") {
241
+ console.log(
242
+ `❌ Skipping English README for language ${language}: ${relativeFilePath}`
243
+ );
244
+ continue;
245
+ } else if (!baseName.endsWith(`.${language}`)) {
246
+ console.log(
247
+ `❌ Skipping file with different language: ${relativeFilePath}`
248
+ );
249
+ continue;
250
+ } else {
251
+ console.log(`✅ Including ${language} file: ${relativeFilePath}`);
252
+ }
253
+ }
254
+
255
+ // Process markdown content and save to output directory
256
+ console.log("Processing markdown file:", relativeFilePath);
257
+ const processedContent = processMarkdownContent(
258
+ fullPath,
259
+ learnAssetsDir,
260
+ language
261
+ );
262
+
263
+ const processedMarkdownPath = path.join(
264
+ outputDir,
265
+ relativeFilePath.replace(/\.(md|markdown)$/, ".processed.md")
266
+ );
267
+
268
+ console.log("Processed markdown output path:", processedMarkdownPath);
269
+ mkdirp.sync(path.dirname(processedMarkdownPath));
270
+ fs.writeFileSync(processedMarkdownPath, processedContent, "utf-8");
271
+
272
+ processedMarkdownFiles.push(processedMarkdownPath);
273
+ console.log(
274
+ "Successfully processed:",
275
+ relativeFilePath,
276
+ "->",
277
+ processedMarkdownPath
278
+ );
279
+ } else if (entry.name.endsWith(".html")) {
280
+ // Convert HTML files to markdown for consistency
281
+ console.log("Converting HTML file to markdown:", relativeFilePath);
282
+ const htmlContent = fs.readFileSync(fullPath, "utf-8");
283
+ const processedMarkdownPath = path.join(
284
+ outputDir,
285
+ relativeFilePath.replace(/\.html$/, ".processed.md")
286
+ );
287
+
288
+ mkdirp.sync(path.dirname(processedMarkdownPath));
289
+ fs.writeFileSync(processedMarkdownPath, htmlContent, "utf-8");
290
+ processedMarkdownFiles.push(processedMarkdownPath);
291
+ console.log(
292
+ "Successfully converted HTML:",
293
+ relativeFilePath,
294
+ "->",
295
+ processedMarkdownPath
296
+ );
297
+ } else {
298
+ console.log(
299
+ "Skipping file:",
300
+ relativeFilePath,
301
+ "(not markdown or HTML)"
302
+ );
303
+ }
304
+ }
305
+ };
306
+
307
+ await processDirectory(exercisesDir);
308
+ return processedMarkdownFiles;
309
+ }
310
+
311
+ // Process markdown content for EPUB conversion
312
+ function processMarkdownContent(
313
+ markdownPath: string,
314
+ learnAssetsDir?: string,
315
+ language: string = "en"
316
+ ): string {
317
+ let markdownContent = fs.readFileSync(markdownPath, "utf-8");
318
+
319
+ if (learnAssetsDir && fs.existsSync(learnAssetsDir)) {
320
+ // Find all image references in markdown
321
+ const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
322
+ let match;
323
+
324
+ while ((match = imageRegex.exec(markdownContent)) !== null) {
325
+ const [fullMatch, altText, imagePath] = match;
326
+
327
+ // Check if it's a relative path starting with /learn/assets/
328
+ if (imagePath.startsWith("/.learn/assets/")) {
329
+ const imageName = path.basename(imagePath);
330
+ const absoluteImagePath = path.join(learnAssetsDir, imageName);
331
+
332
+ console.log(`📸 Looking for image: ${imageName}`);
333
+ console.log(`📁 Learn assets dir: ${learnAssetsDir}`);
334
+ console.log(`📁 Absolute path: ${absoluteImagePath}`);
335
+ console.log(`📁 Path exists: ${fs.existsSync(absoluteImagePath)}`);
336
+
337
+ if (fs.existsSync(absoluteImagePath)) {
338
+ // Use absolute path for the image
339
+ console.log(
340
+ `✅ Found image, replacing path: ${imagePath} -> ${absoluteImagePath}`
341
+ );
342
+ markdownContent = markdownContent.replace(
343
+ fullMatch,
344
+ `![${altText}](${absoluteImagePath})`
345
+ );
346
+ } else {
347
+ console.log(`❌ Image not found: ${absoluteImagePath}`);
348
+ // List files in the assets directory for debugging
349
+ if (fs.existsSync(learnAssetsDir)) {
350
+ const files = fs.readdirSync(learnAssetsDir);
351
+ console.log(`📁 Files in assets directory: ${files.join(", ")}`);
352
+ } else {
353
+ console.log(
354
+ `❌ Assets directory does not exist: ${learnAssetsDir}`
355
+ );
356
+ }
357
+ }
358
+ }
359
+ }
360
+ }
361
+
362
+ markdownContent = replaceQuestionsFromMarkdown(markdownContent, language);
363
+ markdownContent = replaceMermaidFromMarkdown(markdownContent);
364
+ markdownContent = cleanQuizOptions(markdownContent);
365
+
366
+ return markdownContent;
367
+ }
368
+
369
+ const titlelify = (title: string) => {
370
+ return title.replace(/[-_]/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
371
+ };
372
+
373
+ // Read sidebar.json to get exercise titles
374
+ function readSidebarData(learnDir: string): any {
375
+ try {
376
+ const sidebarPath = path.join(learnDir, "sidebar.json");
377
+ if (fs.existsSync(sidebarPath)) {
378
+ const sidebarContent = fs.readFileSync(sidebarPath, "utf-8");
379
+ return JSON.parse(sidebarContent);
380
+ }
381
+ } catch (error) {
382
+ console.log("⚠️ Could not read sidebar.json:", error);
383
+ }
384
+ return null;
385
+ }
386
+
387
+ export async function exportToEpub(
388
+ options: ExportOptions,
389
+ epubMetadata: EpubMetadata
390
+ ): Promise<string> {
391
+ const { courseSlug, bucket, outDir, language = "en" } = options;
392
+
393
+ console.log("🚀 Starting EPUB export for course:", courseSlug);
394
+ console.log("📁 Output directory:", outDir);
395
+ console.log("🌍 Language:", language);
396
+ console.log("📋 EPUB Metadata:", epubMetadata);
397
+
398
+ // 1. Create temporary folder
399
+ const tmpName = uuidv4();
400
+ const epubOutDir = path.join(outDir, tmpName);
401
+ rimraf.sync(epubOutDir);
402
+ mkdirp.sync(epubOutDir);
403
+
404
+ console.log("Created temporary directory:", epubOutDir);
405
+
406
+ // 2. Create EPUB template directory
407
+ const epubTemplateDir = path.join(epubOutDir, "template");
408
+ mkdirp.sync(epubTemplateDir);
409
+
410
+ // 3. Download exercises, .learn, and cover image from bucket
411
+ console.log("📥 Downloading exercises...");
412
+ await downloadS3Folder(
413
+ bucket,
414
+ `courses/${courseSlug}/exercises/`,
415
+ path.join(epubOutDir, "exercises")
416
+ );
417
+ console.log("✅ Exercises downloaded successfully");
418
+
419
+ console.log("📥 Downloading .learn files...");
420
+ await downloadS3Folder(
421
+ bucket,
422
+ `courses/${courseSlug}/.learn/`,
423
+ path.join(epubOutDir, "learn")
424
+ );
425
+ console.log("✅ .learn files downloaded successfully");
426
+
427
+ console.log("📥 Downloading cover image...");
428
+ const coverImagePath = await downloadCoverImage(
429
+ bucket,
430
+ courseSlug,
431
+ epubOutDir
432
+ );
433
+ console.log(
434
+ "✅ Cover image download completed:",
435
+ coverImagePath ? "Found" : "Not found"
436
+ );
437
+
438
+ // 4. Read learn.json for course info
439
+ console.log("Reading course metadata...");
440
+ const learnJson = await getCourseMetadata(bucket, courseSlug);
441
+
442
+ const metadata: CourseMetadata = {
443
+ title: learnJson.title?.[epubMetadata.lang] || courseSlug,
444
+ description:
445
+ learnJson.description?.[epubMetadata.lang] || learnJson.description,
446
+ language: epubMetadata.lang,
447
+ technologies: learnJson.technologies || [],
448
+ difficulty: learnJson.difficulty || "beginner",
449
+ };
450
+
451
+ console.log("Course metadata:", metadata);
452
+
453
+ // 5. Process exercises and prepare markdown files
454
+ console.log("🔄 Processing exercises and preparing markdown files...");
455
+
456
+ // First, let's see what files were downloaded
457
+ const exercisesPath = path.join(epubOutDir, "exercises");
458
+ console.log("📂 Exercises directory contents:");
459
+ if (fs.existsSync(exercisesPath)) {
460
+ const exerciseDirs = fs.readdirSync(exercisesPath, { withFileTypes: true });
461
+ console.log(`Found ${exerciseDirs.length} exercise directories:`);
462
+ exerciseDirs.forEach((dir) => {
463
+ if (dir.isDirectory()) {
464
+ const dirPath = path.join(exercisesPath, dir.name);
465
+ const files = fs.readdirSync(dirPath);
466
+ console.log(
467
+ ` 📁 ${dir.name}: ${files.length} files - ${files.join(", ")}`
468
+ );
469
+ }
470
+ });
471
+ } else {
472
+ console.log("❌ Exercises directory does not exist!");
473
+ }
474
+
475
+ const processedMarkdownFiles = await processExercises(
476
+ path.join(epubOutDir, "exercises"),
477
+ path.join(epubOutDir, "processed"),
478
+ language,
479
+ path.join(epubOutDir, "learn", "assets")
480
+ );
481
+
482
+ // 6. Read sidebar data and create table of contents
483
+ const sidebarData = readSidebarData(path.join(epubOutDir, "learn"));
484
+ console.log("📋 Sidebar data loaded:", sidebarData ? "Success" : "Not found");
485
+
486
+ // 7. Create main content file as markdown
487
+ const mainContent = `# ${metadata.title}
488
+
489
+ ${metadata.description || ""}
490
+
491
+ **Technologies:** ${metadata.technologies?.join(", ") || "Programming"}
492
+
493
+ **Difficulty:** ${metadata.difficulty}
494
+
495
+ **Language:** ${language}
496
+
497
+ ---
498
+
499
+ ## Table of Contents
500
+
501
+ ${processedMarkdownFiles
502
+ .map((file, index) => {
503
+ const fileName = path.basename(file, ".processed.md");
504
+ const parentDir = path.basename(path.dirname(file));
505
+
506
+ // Get the title from sidebar.json using the parent directory name
507
+ let displayName = fileName;
508
+ if (sidebarData && sidebarData[parentDir]) {
509
+ displayName =
510
+ sidebarData[parentDir][language] ||
511
+ sidebarData[parentDir]["en"] ||
512
+ titlelify(parentDir);
513
+ } else {
514
+ displayName = titlelify(parentDir);
515
+ }
516
+
517
+ return `${index + 1}. ${displayName}`;
518
+ })
519
+ .join("\n")}
520
+
521
+ ---
522
+
523
+ `;
524
+
525
+ const mainContentPath = path.join(epubOutDir, "processed", "main.md");
526
+ mkdirp.sync(path.dirname(mainContentPath));
527
+ fs.writeFileSync(mainContentPath, mainContent, "utf-8");
528
+
529
+ // 8. Prepare all markdown files for EPUB generation
530
+ const allMarkdownFiles = [mainContentPath, ...processedMarkdownFiles];
531
+
532
+ // 9. Generate EPUB using pandoc
533
+ console.log("📖 Generating EPUB file...");
534
+ const epubPath = path.join(epubOutDir, `${courseSlug}.epub`);
535
+ await generateEpub(
536
+ allMarkdownFiles,
537
+ epubPath,
538
+ metadata,
539
+ epubMetadata,
540
+ coverImagePath
541
+ );
542
+
543
+ console.log("EPUB generated successfully:", epubPath);
544
+
545
+ // Clean up temporary files
546
+ console.log("Cleaning up temporary files...");
547
+ rimraf.sync(path.join(epubOutDir, "processed"));
548
+ rimraf.sync(path.join(epubOutDir, "exercises"));
549
+ rimraf.sync(path.join(epubOutDir, "learn"));
550
+
551
+ console.log("EPUB export completed successfully");
552
+ return epubPath;
553
+ }