@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.
- package/bin/run +17 -17
- package/lib/commands/init.js +41 -41
- package/lib/commands/serve.js +645 -129
- package/lib/creatorDist/assets/index-BhqDgBS9.js +8448 -78631
- package/lib/creatorDist/assets/index-CjddKHB_.css +1 -1688
- package/lib/managers/config/exercise.js +2 -14
- package/lib/managers/readmeHistoryService.js +3 -1
- package/lib/managers/server/routes.js +2 -1
- package/lib/utils/configBuilder.js +2 -1
- package/lib/utils/creatorUtilities.js +14 -14
- package/lib/utils/exerciseFileOrder.d.ts +20 -0
- package/lib/utils/exerciseFileOrder.js +49 -0
- package/lib/utils/export/epub.js +26 -26
- package/lib/utils/readmeSanitizer.d.ts +8 -0
- package/lib/utils/readmeSanitizer.js +13 -0
- package/lib/utils/templates/epub/epub.css +146 -146
- package/lib/utils/templates/scorm/config/api.js +175 -175
- package/package.json +1 -1
- package/src/commands/init.ts +655 -655
- package/src/commands/publish.ts +670 -670
- package/src/commands/serve.ts +5853 -5148
- package/src/creator/eslint.config.js +28 -28
- package/src/creator/src/index.css +227 -227
- package/src/creator/src/utils/lib.ts +471 -471
- package/src/creatorDist/assets/index-BhqDgBS9.js +8448 -78631
- package/src/creatorDist/assets/index-CjddKHB_.css +1 -1688
- package/src/managers/config/exercise.ts +3 -15
- package/src/managers/readmeHistoryService.ts +3 -1
- package/src/managers/server/routes.ts +15 -6
- package/src/managers/session.ts +184 -184
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +1950 -1878
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/api.ts +675 -675
- package/src/utils/configBuilder.ts +102 -100
- package/src/utils/creatorUtilities.ts +536 -536
- package/src/utils/errors.ts +108 -108
- package/src/utils/exerciseFileOrder.ts +50 -0
- package/src/utils/export/epub.ts +553 -553
- package/src/utils/export/index.ts +4 -4
- package/src/utils/export/scorm.ts +121 -121
- package/src/utils/export/shared.ts +61 -61
- package/src/utils/export/types.ts +25 -25
- package/src/utils/export/zip.ts +55 -55
- package/src/utils/readmeSanitizer.ts +10 -0
- package/src/utils/rigoActions.ts +642 -642
- package/src/utils/templates/epub/epub.css +146 -146
- package/src/utils/templates/scorm/config/api.js +175 -175
package/src/utils/export/epub.ts
CHANGED
|
@@ -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
|
-
``
|
|
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
|
+
``
|
|
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
|
+
}
|