@learnpack/learnpack 5.0.275 → 5.0.277
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/README.md +409 -409
- package/lib/commands/audit.js +15 -15
- package/lib/commands/breakToken.js +19 -19
- package/lib/commands/clean.js +3 -3
- package/lib/commands/init.js +41 -41
- package/lib/commands/logout.js +3 -3
- package/lib/commands/publish.js +5 -10
- package/lib/commands/serve.js +55 -2
- package/lib/creatorDist/assets/index-BfLyIQVh.js +10343 -10224
- package/lib/managers/config/index.js +77 -77
- package/lib/utils/api.d.ts +1 -1
- package/lib/utils/api.js +12 -9
- package/lib/utils/creatorUtilities.js +14 -14
- package/lib/utils/export/epub.d.ts +2 -0
- package/lib/utils/export/epub.js +298 -0
- package/lib/utils/export/index.d.ts +3 -0
- package/lib/utils/export/index.js +7 -0
- package/lib/utils/export/scorm.d.ts +2 -0
- package/lib/utils/export/scorm.js +84 -0
- package/lib/utils/export/shared.d.ts +4 -0
- package/lib/utils/export/shared.js +61 -0
- package/lib/utils/export/types.d.ts +15 -0
- package/lib/utils/export/types.js +2 -0
- package/package.json +2 -1
- package/src/commands/audit.ts +487 -487
- package/src/commands/breakToken.ts +67 -67
- package/src/commands/clean.ts +30 -30
- package/src/commands/init.ts +650 -650
- package/src/commands/logout.ts +38 -38
- package/src/commands/publish.ts +20 -25
- package/src/commands/serve.ts +69 -4
- package/src/commands/start.ts +333 -333
- package/src/commands/translate.ts +123 -123
- package/src/creator/README.md +54 -54
- package/src/creator/eslint.config.js +7 -7
- package/src/creator/src/components/syllabus/ContentIndex.tsx +312 -312
- package/src/creator/src/i18n.ts +28 -28
- package/src/creator/src/index.css +217 -217
- package/src/creator/src/locales/en.json +126 -126
- package/src/creator/src/locales/es.json +126 -126
- package/src/creator/src/utils/configTypes.ts +122 -122
- package/src/creator/src/utils/constants.ts +13 -13
- package/src/creator/src/utils/creatorUtils.ts +46 -46
- package/src/creator/src/utils/eventBus.ts +2 -2
- package/src/creator/src/utils/lib.ts +468 -468
- package/src/creator/src/utils/socket.ts +61 -61
- package/src/creator/src/utils/store.ts +222 -222
- package/src/creator/src/vite-env.d.ts +1 -1
- package/src/creator/vite.config.ts +13 -13
- package/src/creatorDist/assets/index-BfLyIQVh.js +10343 -10224
- package/src/managers/config/defaults.ts +49 -49
- package/src/managers/config/exercise.ts +364 -364
- package/src/managers/config/index.ts +775 -775
- package/src/managers/file.ts +236 -236
- package/src/managers/server/routes.ts +554 -554
- package/src/managers/session.ts +182 -182
- package/src/managers/telemetry.ts +188 -188
- package/src/models/action.ts +13 -13
- package/src/models/config-manager.ts +28 -28
- package/src/models/config.ts +106 -106
- package/src/models/creator.ts +47 -47
- package/src/models/exercise-obj.ts +30 -30
- package/src/models/session.ts +39 -39
- package/src/models/socket.ts +61 -61
- package/src/models/status.ts +16 -16
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +400 -397
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/BaseCommand.ts +56 -56
- package/src/utils/api.ts +53 -39
- package/src/utils/audit.ts +392 -392
- package/src/utils/checkNotInstalled.ts +267 -267
- package/src/utils/configBuilder.ts +82 -82
- package/src/utils/convertCreds.js +34 -34
- package/src/utils/creatorUtilities.ts +504 -504
- package/src/utils/export/README.md +178 -0
- package/src/utils/export/epub.ts +400 -0
- package/src/utils/export/index.ts +3 -0
- package/src/utils/export/scorm.ts +121 -0
- package/src/utils/export/shared.ts +61 -0
- package/src/utils/export/types.ts +17 -0
- package/src/utils/incrementVersion.js +74 -74
- package/src/utils/misc.ts +58 -58
- package/src/utils/rigoActions.ts +500 -500
- package/src/utils/sidebarGenerator.ts +195 -195
- package/src/utils/templates/epub/epub.css +133 -0
- package/src/utils/templates/isolated/exercises/01-hello-world/README.es.md +26 -26
- package/src/utils/templates/isolated/exercises/01-hello-world/README.md +26 -26
- package/src/utils/templates/scorm/adlcp_rootv1p2.xsd +110 -0
- package/src/utils/templates/scorm/config/api.js +175 -0
- package/src/utils/templates/scorm/config/index.html +210 -0
- package/src/utils/templates/scorm/ims_xml.xsd +1 -0
- package/src/utils/templates/scorm/imscp_rootv1p1p2.xsd +345 -0
- package/src/utils/templates/scorm/imsmanifest.xml +38 -0
- package/src/utils/templates/scorm/imsmd_rootv1p2p1.xsd +573 -0
@@ -0,0 +1,178 @@
|
|
1
|
+
# Export Utilities
|
2
|
+
|
3
|
+
This directory contains utilities for exporting courses to different formats.
|
4
|
+
|
5
|
+
## Supported Formats
|
6
|
+
|
7
|
+
- **SCORM**: Learning Management System compatible package
|
8
|
+
- **EPUB**: E-book format for offline reading
|
9
|
+
|
10
|
+
## Usage
|
11
|
+
|
12
|
+
### API Endpoints
|
13
|
+
|
14
|
+
The export functionality is available through the `/export/:course_slug/:format` endpoint:
|
15
|
+
|
16
|
+
- `GET /export/{course_slug}/scorm` - Export course to SCORM format
|
17
|
+
- `GET /export/{course_slug}/epub` - Export course to EPUB format
|
18
|
+
|
19
|
+
### Example Usage
|
20
|
+
|
21
|
+
```bash
|
22
|
+
# Export course to SCORM
|
23
|
+
curl -O "http://localhost:3000/export/my-course/scorm"
|
24
|
+
|
25
|
+
# Export course to EPUB (default English)
|
26
|
+
curl -O "http://localhost:3000/export/my-course/epub"
|
27
|
+
|
28
|
+
# Export course to EPUB in Spanish
|
29
|
+
curl -O "http://localhost:3000/export/my-course/epub?language=es"
|
30
|
+
|
31
|
+
# Export course to EPUB in French
|
32
|
+
curl -O "http://localhost:3000/export/my-course/epub?language=fr"
|
33
|
+
```
|
34
|
+
|
35
|
+
## Requirements
|
36
|
+
|
37
|
+
### SCORM Export
|
38
|
+
|
39
|
+
- No additional requirements
|
40
|
+
|
41
|
+
### EPUB Export
|
42
|
+
|
43
|
+
- **Pandoc** must be installed on the system
|
44
|
+
- Pandoc is used to convert markdown to HTML and generate EPUB files
|
45
|
+
- **Language Support**: Use the `language` query parameter to specify the language
|
46
|
+
- Default: `en` (English)
|
47
|
+
- The system will prioritize language-specific files (e.g., `README.es.md` for Spanish)
|
48
|
+
- Falls back to `README.md` if no language-specific file is found
|
49
|
+
|
50
|
+
### Installing Pandoc
|
51
|
+
|
52
|
+
#### Windows
|
53
|
+
|
54
|
+
```bash
|
55
|
+
# Using Chocolatey
|
56
|
+
choco install pandoc
|
57
|
+
|
58
|
+
# Using Scoop
|
59
|
+
scoop install pandoc
|
60
|
+
|
61
|
+
# Or download from https://pandoc.org/installing.html
|
62
|
+
```
|
63
|
+
|
64
|
+
#### macOS
|
65
|
+
|
66
|
+
```bash
|
67
|
+
# Using Homebrew
|
68
|
+
brew install pandoc
|
69
|
+
|
70
|
+
# Using MacPorts
|
71
|
+
sudo port install pandoc
|
72
|
+
```
|
73
|
+
|
74
|
+
#### Linux
|
75
|
+
|
76
|
+
```bash
|
77
|
+
# Ubuntu/Debian
|
78
|
+
sudo apt-get install pandoc
|
79
|
+
|
80
|
+
# CentOS/RHEL
|
81
|
+
sudo yum install pandoc
|
82
|
+
|
83
|
+
# Arch Linux
|
84
|
+
sudo pacman -S pandoc
|
85
|
+
```
|
86
|
+
|
87
|
+
## Architecture
|
88
|
+
|
89
|
+
The export system is modular and consists of:
|
90
|
+
|
91
|
+
- **`types.ts`**: Type definitions for export options and course metadata
|
92
|
+
- **`shared.ts`**: Common utility functions used by all export formats
|
93
|
+
- **`scorm.ts`**: SCORM export implementation
|
94
|
+
- **`epub.ts`**: EPUB export implementation using Pandoc
|
95
|
+
- **`index.ts`**: Main export interface
|
96
|
+
|
97
|
+
## Adding New Export Formats
|
98
|
+
|
99
|
+
To add a new export format:
|
100
|
+
|
101
|
+
1. Create a new file in the export directory (e.g., `pdf.ts`)
|
102
|
+
2. Implement the export function following the same pattern
|
103
|
+
3. Add the new format to the `ExportFormat` type in `types.ts`
|
104
|
+
4. Update the main export endpoint in `serve.ts`
|
105
|
+
5. Add the new format to the export index
|
106
|
+
|
107
|
+
### Example New Format
|
108
|
+
|
109
|
+
```typescript
|
110
|
+
// pdf.ts
|
111
|
+
export async function exportToPdf(options: ExportOptions): Promise<string> {
|
112
|
+
// Implementation here
|
113
|
+
return outputPath;
|
114
|
+
}
|
115
|
+
|
116
|
+
// types.ts
|
117
|
+
export type ExportFormat = 'scorm' | 'epub' | 'pdf';
|
118
|
+
|
119
|
+
// serve.ts
|
120
|
+
} else if (format === "pdf") {
|
121
|
+
outputPath = await exportToPdf({
|
122
|
+
courseSlug: course_slug,
|
123
|
+
format: "pdf",
|
124
|
+
bucket,
|
125
|
+
outDir: path.join(__dirname, "../output/directory")
|
126
|
+
});
|
127
|
+
filename = `${course_slug}.pdf`;
|
128
|
+
}
|
129
|
+
```
|
130
|
+
|
131
|
+
## File Structure
|
132
|
+
|
133
|
+
```
|
134
|
+
src/utils/export/
|
135
|
+
├── index.ts # Main export interface
|
136
|
+
├── types.ts # Type definitions
|
137
|
+
├── shared.ts # Shared utilities
|
138
|
+
├── scorm.ts # SCORM export
|
139
|
+
├── epub.ts # EPUB export
|
140
|
+
├── templates/ # Export templates
|
141
|
+
│ ├── scorm/ # SCORM template files
|
142
|
+
│ └── epub/ # EPUB template files
|
143
|
+
└── README.md # This file
|
144
|
+
```
|
145
|
+
|
146
|
+
## Templates
|
147
|
+
|
148
|
+
### SCORM Template
|
149
|
+
|
150
|
+
Contains the necessary files for a SCORM package:
|
151
|
+
|
152
|
+
- `imsmanifest.xml` - Course manifest
|
153
|
+
- `config/` - Configuration files
|
154
|
+
- XSD schema files for validation
|
155
|
+
|
156
|
+
### EPUB Template
|
157
|
+
|
158
|
+
Contains styling and assets for EPUB generation:
|
159
|
+
|
160
|
+
- `epub.css` - Basic EPUB styling
|
161
|
+
- `cover.png` - Default cover image (placeholder)
|
162
|
+
|
163
|
+
## Error Handling
|
164
|
+
|
165
|
+
The export system includes comprehensive error handling:
|
166
|
+
|
167
|
+
- Invalid format validation
|
168
|
+
- File system operation errors
|
169
|
+
- Pandoc execution errors (for EPUB)
|
170
|
+
- Network/download errors
|
171
|
+
- Cleanup on failure
|
172
|
+
|
173
|
+
## Performance Considerations
|
174
|
+
|
175
|
+
- Temporary files are automatically cleaned up
|
176
|
+
- Large courses may take time to process
|
177
|
+
- Consider implementing progress indicators for long-running exports
|
178
|
+
- Memory usage scales with course size
|
@@ -0,0 +1,400 @@
|
|
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 } from "./types";
|
8
|
+
import { downloadS3Folder, getCourseMetadata } from "./shared";
|
9
|
+
|
10
|
+
// Convert markdown to HTML using pandoc
|
11
|
+
async function convertMarkdownToHtml(
|
12
|
+
markdownPath: string,
|
13
|
+
outputPath: string
|
14
|
+
): Promise<void> {
|
15
|
+
return new Promise((resolve, reject) => {
|
16
|
+
let stdout = "";
|
17
|
+
let stderr = "";
|
18
|
+
|
19
|
+
const pandocArgs = [
|
20
|
+
markdownPath,
|
21
|
+
"-o",
|
22
|
+
outputPath,
|
23
|
+
"--from",
|
24
|
+
"markdown",
|
25
|
+
"--to",
|
26
|
+
"html",
|
27
|
+
"--standalone",
|
28
|
+
"--css",
|
29
|
+
path.join(__dirname, "../templates/epub/epub.css"),
|
30
|
+
];
|
31
|
+
|
32
|
+
console.log("Executing pandoc command:", "pandoc", pandocArgs.join(" "));
|
33
|
+
console.log("Input file exists:", fs.existsSync(markdownPath));
|
34
|
+
console.log(
|
35
|
+
"Output directory exists:",
|
36
|
+
fs.existsSync(path.dirname(outputPath))
|
37
|
+
);
|
38
|
+
|
39
|
+
const pandoc = spawn("pandoc", pandocArgs);
|
40
|
+
|
41
|
+
pandoc.stdout.on("data", (data) => {
|
42
|
+
stdout += data.toString();
|
43
|
+
});
|
44
|
+
|
45
|
+
pandoc.stderr.on("data", (data) => {
|
46
|
+
stderr += data.toString();
|
47
|
+
});
|
48
|
+
|
49
|
+
pandoc.on("close", (code) => {
|
50
|
+
if (code === 0) {
|
51
|
+
resolve();
|
52
|
+
} else {
|
53
|
+
const errorMessage = `Pandoc process exited with code ${code}\n\nSTDOUT:\n${stdout}\n\nSTDERR:\n${stderr}`;
|
54
|
+
console.error("Pandoc Error Details:", errorMessage);
|
55
|
+
reject(new Error(errorMessage));
|
56
|
+
}
|
57
|
+
});
|
58
|
+
|
59
|
+
pandoc.on("error", (err) => {
|
60
|
+
console.error("Pandoc spawn error:", err);
|
61
|
+
reject(err);
|
62
|
+
});
|
63
|
+
});
|
64
|
+
}
|
65
|
+
|
66
|
+
// Generate EPUB using pandoc
|
67
|
+
async function generateEpub(
|
68
|
+
htmlFiles: string[],
|
69
|
+
outputPath: string,
|
70
|
+
metadata: CourseMetadata
|
71
|
+
): Promise<void> {
|
72
|
+
return new Promise((resolve, reject) => {
|
73
|
+
let stdout = "";
|
74
|
+
let stderr = "";
|
75
|
+
|
76
|
+
// Include all HTML files in the EPUB
|
77
|
+
const pandocArgs = [
|
78
|
+
...htmlFiles, // All HTML files
|
79
|
+
"-o",
|
80
|
+
outputPath,
|
81
|
+
"--from",
|
82
|
+
"html",
|
83
|
+
"--to",
|
84
|
+
"epub",
|
85
|
+
"--metadata",
|
86
|
+
`title=${metadata.title}`,
|
87
|
+
"--metadata",
|
88
|
+
`language=${metadata.language || "en"}`,
|
89
|
+
"--metadata",
|
90
|
+
`creator=FourGeeksAcademy`,
|
91
|
+
"--metadata",
|
92
|
+
`description=${metadata.description || ""}`,
|
93
|
+
"--metadata",
|
94
|
+
`subject=${metadata.technologies?.join(", ") || "Programming"}`,
|
95
|
+
"--css",
|
96
|
+
path.join(__dirname, "../templates/epub/epub.css"),
|
97
|
+
];
|
98
|
+
|
99
|
+
console.log(
|
100
|
+
"Executing pandoc EPUB command:",
|
101
|
+
"pandoc",
|
102
|
+
pandocArgs.join(" ")
|
103
|
+
);
|
104
|
+
console.log("HTML files to include:", htmlFiles);
|
105
|
+
console.log(
|
106
|
+
"All input files exist:",
|
107
|
+
htmlFiles.every((file) => fs.existsSync(file))
|
108
|
+
);
|
109
|
+
console.log(
|
110
|
+
"Output directory exists:",
|
111
|
+
fs.existsSync(path.dirname(outputPath))
|
112
|
+
);
|
113
|
+
|
114
|
+
const pandoc = spawn("pandoc", pandocArgs);
|
115
|
+
|
116
|
+
pandoc.stdout.on("data", (data) => {
|
117
|
+
stdout += data.toString();
|
118
|
+
});
|
119
|
+
|
120
|
+
pandoc.stderr.on("data", (data) => {
|
121
|
+
stderr += data.toString();
|
122
|
+
});
|
123
|
+
|
124
|
+
pandoc.on("close", (code) => {
|
125
|
+
if (code === 0) {
|
126
|
+
resolve();
|
127
|
+
} else {
|
128
|
+
const errorMessage = `Pandoc process exited with code ${code}\n\nSTDOUT:\n${stdout}\n\nSTDERR:\n${stderr}`;
|
129
|
+
console.error("Pandoc Error Details:", errorMessage);
|
130
|
+
reject(new Error(errorMessage));
|
131
|
+
}
|
132
|
+
});
|
133
|
+
|
134
|
+
pandoc.on("error", (err) => {
|
135
|
+
console.error("Pandoc spawn error:", err);
|
136
|
+
reject(err);
|
137
|
+
});
|
138
|
+
});
|
139
|
+
}
|
140
|
+
|
141
|
+
// Process exercises and convert to HTML
|
142
|
+
async function processExercises(
|
143
|
+
exercisesDir: string,
|
144
|
+
outputDir: string,
|
145
|
+
language: string = "en"
|
146
|
+
): Promise<string[]> {
|
147
|
+
const htmlFiles: string[] = [];
|
148
|
+
|
149
|
+
console.log("Processing exercises directory:", exercisesDir);
|
150
|
+
console.log("Output directory:", outputDir);
|
151
|
+
console.log("Language:", language);
|
152
|
+
|
153
|
+
if (!fs.existsSync(exercisesDir)) {
|
154
|
+
console.log("Exercises directory does not exist:", exercisesDir);
|
155
|
+
return htmlFiles;
|
156
|
+
}
|
157
|
+
|
158
|
+
console.log("Exercises directory exists, scanning for files...");
|
159
|
+
|
160
|
+
const processDirectory = async (dir: string, relativePath: string = "") => {
|
161
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
162
|
+
|
163
|
+
// Sort entries to prioritize README files first
|
164
|
+
entries.sort((a, b) => {
|
165
|
+
const aName = a.name.toLowerCase();
|
166
|
+
const bName = b.name.toLowerCase();
|
167
|
+
|
168
|
+
// Prioritize README files
|
169
|
+
if (aName === "readme.md" && bName !== "readme.md") return -1;
|
170
|
+
if (bName === "readme.md" && aName !== "readme.md") return 1;
|
171
|
+
|
172
|
+
return aName.localeCompare(bName);
|
173
|
+
});
|
174
|
+
|
175
|
+
for (const entry of entries) {
|
176
|
+
const fullPath = path.join(dir, entry.name);
|
177
|
+
const relativeFilePath = path.join(relativePath, entry.name);
|
178
|
+
|
179
|
+
if (entry.isDirectory()) {
|
180
|
+
console.log("Processing subdirectory:", relativeFilePath);
|
181
|
+
// esling-disable-next-line
|
182
|
+
await processDirectory(fullPath, relativeFilePath);
|
183
|
+
} else if (
|
184
|
+
entry.name.endsWith(".md") ||
|
185
|
+
entry.name.endsWith(".markdown")
|
186
|
+
) {
|
187
|
+
// Apply language filtering for all markdown files
|
188
|
+
const baseName = entry.name.replace(/\.(md|markdown)$/, "");
|
189
|
+
|
190
|
+
if (language === "en") {
|
191
|
+
// For English: exclude files with language suffixes
|
192
|
+
if (
|
193
|
+
baseName.includes(".") &&
|
194
|
+
/\.(es|fr|de|it|pt|ja|ko|zh|ar|ru)$/i.test(baseName)
|
195
|
+
) {
|
196
|
+
console.log(
|
197
|
+
"Skipping language-specific file for English:",
|
198
|
+
relativeFilePath
|
199
|
+
);
|
200
|
+
continue;
|
201
|
+
}
|
202
|
+
} else {
|
203
|
+
// For other languages: only include files with the correct language suffix
|
204
|
+
if (!baseName.endsWith(`.${language}`)) {
|
205
|
+
console.log(
|
206
|
+
"Skipping file with different language:",
|
207
|
+
relativeFilePath
|
208
|
+
);
|
209
|
+
continue;
|
210
|
+
}
|
211
|
+
}
|
212
|
+
|
213
|
+
// Convert markdown to HTML
|
214
|
+
console.log("Converting markdown file:", relativeFilePath);
|
215
|
+
const htmlFileName = entry.name.replace(/\.(md|markdown)$/, ".html");
|
216
|
+
const htmlPath = path.join(
|
217
|
+
outputDir,
|
218
|
+
relativeFilePath.replace(/\.(md|markdown)$/, ".html")
|
219
|
+
);
|
220
|
+
|
221
|
+
console.log("HTML output path:", htmlPath);
|
222
|
+
mkdirp.sync(path.dirname(htmlPath));
|
223
|
+
// esling-disable-next-line
|
224
|
+
await convertMarkdownToHtml(fullPath, htmlPath);
|
225
|
+
|
226
|
+
htmlFiles.push(htmlPath);
|
227
|
+
console.log(
|
228
|
+
"Successfully converted:",
|
229
|
+
relativeFilePath,
|
230
|
+
"->",
|
231
|
+
htmlPath
|
232
|
+
);
|
233
|
+
} else if (entry.name.endsWith(".html")) {
|
234
|
+
// Copy HTML files as-is
|
235
|
+
console.log("Copying HTML file:", relativeFilePath);
|
236
|
+
const destPath = path.join(outputDir, relativeFilePath);
|
237
|
+
mkdirp.sync(path.dirname(destPath));
|
238
|
+
// esling-disable-next-line
|
239
|
+
fs.copyFileSync(fullPath, destPath);
|
240
|
+
htmlFiles.push(destPath);
|
241
|
+
console.log("Successfully copied:", relativeFilePath, "->", destPath);
|
242
|
+
} else {
|
243
|
+
console.log(
|
244
|
+
"Skipping file:",
|
245
|
+
relativeFilePath,
|
246
|
+
"(not markdown or HTML)"
|
247
|
+
);
|
248
|
+
}
|
249
|
+
}
|
250
|
+
};
|
251
|
+
|
252
|
+
await processDirectory(exercisesDir);
|
253
|
+
return htmlFiles;
|
254
|
+
}
|
255
|
+
|
256
|
+
// Create EPUB table of contents
|
257
|
+
function createTocHtml(htmlFiles: string[], metadata: CourseMetadata): string {
|
258
|
+
let tocContent = `
|
259
|
+
<!DOCTYPE html>
|
260
|
+
<html>
|
261
|
+
<head>
|
262
|
+
<meta charset="UTF-8">
|
263
|
+
<title>Table of Contents - ${metadata.title}</title>
|
264
|
+
<link rel="stylesheet" href="epub.css">
|
265
|
+
</head>
|
266
|
+
<body>
|
267
|
+
<h1>${metadata.title}</h1>
|
268
|
+
<nav id="toc">
|
269
|
+
<h2>Table of Contents</h2>
|
270
|
+
<ol>
|
271
|
+
<li><a href="main.html">Course Overview</a></li>
|
272
|
+
`;
|
273
|
+
|
274
|
+
htmlFiles.forEach((file) => {
|
275
|
+
const fileName = path.basename(file, ".html");
|
276
|
+
const displayName = fileName
|
277
|
+
.replace(/[-_]/g, " ")
|
278
|
+
.replace(/\b\w/g, (l) => l.toUpperCase());
|
279
|
+
// Use the full relative path for the href to ensure proper navigation
|
280
|
+
const relativePath = file.replace(/.*\/html\//, "").replace(/\\/g, "/");
|
281
|
+
tocContent += ` <li><a href="${relativePath}">${displayName}</a></li>\n`;
|
282
|
+
});
|
283
|
+
|
284
|
+
tocContent += `
|
285
|
+
</ol>
|
286
|
+
</nav>
|
287
|
+
</body>
|
288
|
+
</html>`;
|
289
|
+
|
290
|
+
return tocContent;
|
291
|
+
}
|
292
|
+
|
293
|
+
export async function exportToEpub(options: ExportOptions): Promise<string> {
|
294
|
+
const { courseSlug, bucket, outDir, language = "en" } = options;
|
295
|
+
|
296
|
+
console.log("Starting EPUB export for course:", courseSlug);
|
297
|
+
console.log("Output directory:", outDir);
|
298
|
+
console.log("Language:", language);
|
299
|
+
|
300
|
+
// 1. Create temporary folder
|
301
|
+
const tmpName = uuidv4();
|
302
|
+
const epubOutDir = path.join(outDir, tmpName);
|
303
|
+
rimraf.sync(epubOutDir);
|
304
|
+
mkdirp.sync(epubOutDir);
|
305
|
+
|
306
|
+
console.log("Created temporary directory:", epubOutDir);
|
307
|
+
|
308
|
+
// 2. Create EPUB template directory
|
309
|
+
const epubTemplateDir = path.join(epubOutDir, "template");
|
310
|
+
mkdirp.sync(epubTemplateDir);
|
311
|
+
|
312
|
+
// 3. Download exercises and .learn from bucket
|
313
|
+
console.log("Downloading exercises...");
|
314
|
+
await downloadS3Folder(
|
315
|
+
bucket,
|
316
|
+
`courses/${courseSlug}/exercises/`,
|
317
|
+
path.join(epubOutDir, "exercises")
|
318
|
+
);
|
319
|
+
console.log("Downloading .learn files...");
|
320
|
+
await downloadS3Folder(
|
321
|
+
bucket,
|
322
|
+
`courses/${courseSlug}/.learn/`,
|
323
|
+
path.join(epubOutDir, ".learn")
|
324
|
+
);
|
325
|
+
|
326
|
+
// 4. Read learn.json for course info
|
327
|
+
console.log("Reading course metadata...");
|
328
|
+
const learnJson = await getCourseMetadata(bucket, courseSlug);
|
329
|
+
|
330
|
+
const metadata: CourseMetadata = {
|
331
|
+
title: learnJson.title?.en || learnJson.title || courseSlug,
|
332
|
+
description: learnJson.description?.en || learnJson.description,
|
333
|
+
language: learnJson.language || "en",
|
334
|
+
technologies: learnJson.technologies || [],
|
335
|
+
difficulty: learnJson.difficulty || "beginner",
|
336
|
+
};
|
337
|
+
|
338
|
+
console.log("Course metadata:", metadata);
|
339
|
+
|
340
|
+
// 5. Process exercises and convert to HTML
|
341
|
+
console.log("Processing exercises and converting to HTML...");
|
342
|
+
const htmlFiles = await processExercises(
|
343
|
+
path.join(epubOutDir, "exercises"),
|
344
|
+
path.join(epubOutDir, "html"),
|
345
|
+
language
|
346
|
+
);
|
347
|
+
|
348
|
+
console.log("Generated HTML files:", htmlFiles.length);
|
349
|
+
console.log("HTML files:", htmlFiles);
|
350
|
+
|
351
|
+
// 6. Create table of contents
|
352
|
+
const tocHtml = createTocHtml(htmlFiles, metadata);
|
353
|
+
const tocPath = path.join(epubOutDir, "html", "toc.html");
|
354
|
+
mkdirp.sync(path.dirname(tocPath));
|
355
|
+
fs.writeFileSync(tocPath, tocHtml, "utf-8");
|
356
|
+
|
357
|
+
// 7. Create main content file
|
358
|
+
const mainContent = `
|
359
|
+
<!DOCTYPE html>
|
360
|
+
<html>
|
361
|
+
<head>
|
362
|
+
<meta charset="UTF-8">
|
363
|
+
<title>${metadata.title}</title>
|
364
|
+
<link rel="stylesheet" href="epub.css">
|
365
|
+
</head>
|
366
|
+
<body>
|
367
|
+
<h1>${metadata.title}</h1>
|
368
|
+
<p>${metadata.description || ""}</p>
|
369
|
+
<p><strong>Technologies:</strong> ${
|
370
|
+
metadata.technologies?.join(", ") || "Programming"
|
371
|
+
}</p>
|
372
|
+
<p><strong>Difficulty:</strong> ${metadata.difficulty}</p>
|
373
|
+
<p><strong>Language:</strong> ${language}</p>
|
374
|
+
<p><a href="toc.html">Go to Table of Contents</a></p>
|
375
|
+
</body>
|
376
|
+
</html>`;
|
377
|
+
|
378
|
+
const mainContentPath = path.join(epubOutDir, "html", "main.html");
|
379
|
+
fs.writeFileSync(mainContentPath, mainContent, "utf-8");
|
380
|
+
|
381
|
+
// 8. Prepare all HTML files for EPUB generation
|
382
|
+
const allHtmlFiles = [mainContentPath, tocPath, ...htmlFiles];
|
383
|
+
console.log("All HTML files for EPUB:", allHtmlFiles);
|
384
|
+
|
385
|
+
// 9. Generate EPUB using pandoc
|
386
|
+
console.log("Generating EPUB file...");
|
387
|
+
const epubPath = path.join(epubOutDir, `${courseSlug}.epub`);
|
388
|
+
await generateEpub(allHtmlFiles, epubPath, metadata);
|
389
|
+
|
390
|
+
console.log("EPUB generated successfully:", epubPath);
|
391
|
+
|
392
|
+
// Clean up temporary files
|
393
|
+
console.log("Cleaning up temporary files...");
|
394
|
+
rimraf.sync(path.join(epubOutDir, "html"));
|
395
|
+
rimraf.sync(path.join(epubOutDir, "exercises"));
|
396
|
+
rimraf.sync(path.join(epubOutDir, ".learn"));
|
397
|
+
|
398
|
+
console.log("EPUB export completed successfully");
|
399
|
+
return epubPath;
|
400
|
+
}
|
@@ -0,0 +1,121 @@
|
|
1
|
+
import * as path from "path";
|
2
|
+
import * as fs from "fs";
|
3
|
+
import * as archiver from "archiver";
|
4
|
+
import * as mkdirp from "mkdirp";
|
5
|
+
import * as rimraf from "rimraf";
|
6
|
+
import { v4 as uuidv4 } from "uuid";
|
7
|
+
import { ExportOptions, CourseMetadata } from "./types";
|
8
|
+
import {
|
9
|
+
copyDir,
|
10
|
+
downloadS3Folder,
|
11
|
+
getFilesList,
|
12
|
+
getCourseMetadata,
|
13
|
+
} from "./shared";
|
14
|
+
|
15
|
+
export async function exportToScorm(options: ExportOptions): Promise<string> {
|
16
|
+
const { courseSlug, bucket, outDir } = options;
|
17
|
+
|
18
|
+
// 1. Create temporary folder
|
19
|
+
const tmpName = uuidv4();
|
20
|
+
const scormOutDir = path.join(outDir, tmpName);
|
21
|
+
rimraf.sync(scormOutDir);
|
22
|
+
mkdirp.sync(scormOutDir);
|
23
|
+
|
24
|
+
// 2. Copy SCORM template
|
25
|
+
const scormTpl = path.join(__dirname, "../templates/scorm");
|
26
|
+
copyDir(scormTpl, scormOutDir);
|
27
|
+
|
28
|
+
// Paths
|
29
|
+
const appDir = path.resolve(__dirname, "../../ui/_app");
|
30
|
+
const configDir = path.join(scormOutDir, "config");
|
31
|
+
|
32
|
+
// Check if _app exists and has content
|
33
|
+
if (fs.existsSync(appDir) && fs.readdirSync(appDir).length > 0) {
|
34
|
+
// Copy app.css and app.js to config/
|
35
|
+
for (const file of ["app.css", "app.js"]) {
|
36
|
+
const src = path.join(appDir, file);
|
37
|
+
const dest = path.join(configDir, file);
|
38
|
+
if (fs.existsSync(src)) {
|
39
|
+
fs.copyFileSync(src, dest);
|
40
|
+
}
|
41
|
+
}
|
42
|
+
|
43
|
+
// Copy logo-192.png and logo-512.png to SCORM build root
|
44
|
+
for (const file of ["logo-192.png", "logo-512.png"]) {
|
45
|
+
const src = path.join(appDir, file);
|
46
|
+
const dest = path.join(scormOutDir, file);
|
47
|
+
if (fs.existsSync(src)) {
|
48
|
+
fs.copyFileSync(src, dest);
|
49
|
+
}
|
50
|
+
}
|
51
|
+
}
|
52
|
+
|
53
|
+
// 3. Download exercises and .learn from bucket
|
54
|
+
await downloadS3Folder(
|
55
|
+
bucket,
|
56
|
+
`courses/${courseSlug}/exercises/`,
|
57
|
+
path.join(scormOutDir, "exercises")
|
58
|
+
);
|
59
|
+
await downloadS3Folder(
|
60
|
+
bucket,
|
61
|
+
`courses/${courseSlug}/.learn/`,
|
62
|
+
path.join(scormOutDir, ".learn")
|
63
|
+
);
|
64
|
+
|
65
|
+
// 4. Read learn.json for course info
|
66
|
+
const learnJson = await getCourseMetadata(bucket, courseSlug);
|
67
|
+
|
68
|
+
// 5. Replace imsmanifest.xml
|
69
|
+
const manifestPath = path.join(scormOutDir, "imsmanifest.xml");
|
70
|
+
let manifest = fs.readFileSync(manifestPath, "utf-8");
|
71
|
+
manifest = manifest.replace(/{{organization_name}}/g, "FourGeeksAcademy");
|
72
|
+
manifest = manifest.replace(
|
73
|
+
/{{course_title}}/g,
|
74
|
+
learnJson.title?.en || learnJson.title || courseSlug
|
75
|
+
);
|
76
|
+
|
77
|
+
// Generate exercises list
|
78
|
+
const exercisesList = getFilesList(
|
79
|
+
path.join(scormOutDir, "exercises"),
|
80
|
+
"exercises"
|
81
|
+
).join("\n ");
|
82
|
+
manifest = manifest.replace(/{{exercises_list}}/g, exercisesList);
|
83
|
+
|
84
|
+
// Generate images list (optional, if you have assets)
|
85
|
+
let imagesList = "";
|
86
|
+
const assetsDir = path.join(scormOutDir, ".learn", "assets");
|
87
|
+
if (fs.existsSync(assetsDir)) {
|
88
|
+
imagesList = getFilesList(assetsDir, ".learn/assets").join("\n ");
|
89
|
+
}
|
90
|
+
manifest = manifest.replace(/{{images_list}}/g, imagesList);
|
91
|
+
|
92
|
+
fs.writeFileSync(manifestPath, manifest, "utf-8");
|
93
|
+
|
94
|
+
// 6. Replace title in config/index.html
|
95
|
+
const configIndexPath = path.join(scormOutDir, "config", "index.html");
|
96
|
+
let indexHtml = fs.readFileSync(configIndexPath, "utf-8");
|
97
|
+
indexHtml = indexHtml.replace(
|
98
|
+
/{{title}}/g,
|
99
|
+
learnJson.title?.en || learnJson.title || courseSlug
|
100
|
+
);
|
101
|
+
fs.writeFileSync(configIndexPath, indexHtml, "utf-8");
|
102
|
+
|
103
|
+
// 7. Compress as ZIP
|
104
|
+
const zipPath = `${scormOutDir}.zip`;
|
105
|
+
await new Promise((resolve: any, reject: any) => {
|
106
|
+
const output = fs.createWriteStream(zipPath);
|
107
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
108
|
+
|
109
|
+
output.on("close", resolve);
|
110
|
+
archive.on("error", reject);
|
111
|
+
|
112
|
+
archive.pipe(output);
|
113
|
+
archive.directory(scormOutDir, false);
|
114
|
+
archive.finalize();
|
115
|
+
});
|
116
|
+
|
117
|
+
// Clean up temporary directory
|
118
|
+
rimraf.sync(scormOutDir);
|
119
|
+
|
120
|
+
return zipPath;
|
121
|
+
}
|