@learnpack/learnpack 5.0.329 → 5.0.332

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.
@@ -0,0 +1,203 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HistoryManager = void 0;
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ class HistoryManager {
7
+ constructor(redisClient) {
8
+ this.TTL = "432000"; // 5 days in seconds
9
+ this.LIMIT = 5; // Keep last 5 states
10
+ this.redis = redisClient;
11
+ // Load Lua scripts
12
+ const luaDir = path.join(__dirname, "../lua");
13
+ this.saveStateLua = fs.readFileSync(path.join(luaDir, "saveState.lua"), "utf8");
14
+ this.undoLua = fs.readFileSync(path.join(luaDir, "undo.lua"), "utf8");
15
+ this.redoLua = fs.readFileSync(path.join(luaDir, "redo.lua"), "utf8");
16
+ }
17
+ /**
18
+ * Builds Redis keys for an exercise in a specific language
19
+ * @param courseSlug - The course slug identifier
20
+ * @param exerciseSlug - The exercise slug identifier
21
+ * @param lang - The language code
22
+ * @returns Object containing undo, redo, and version keys
23
+ */
24
+ getKeys(courseSlug, exerciseSlug, lang) {
25
+ const baseKey = `${courseSlug}:${exerciseSlug}:${lang}`;
26
+ return {
27
+ undo: `undo:${baseKey}`,
28
+ redo: `redo:${baseKey}`,
29
+ version: `undo:${baseKey}:version`,
30
+ };
31
+ }
32
+ /**
33
+ * Saves a new state in history
34
+ * @param courseSlug - The course slug identifier
35
+ * @param exerciseSlug - The exercise slug identifier
36
+ * @param lang - The language code
37
+ * @param content - The content of the state
38
+ * @param versionId - The version ID
39
+ * @returns Object containing success, newVersion, and error
40
+ */
41
+ async saveState(courseSlug, exerciseSlug, lang, content, versionId) {
42
+ const keys = this.getKeys(courseSlug, exerciseSlug, lang);
43
+ const state = {
44
+ content,
45
+ timestamp: Date.now(),
46
+ };
47
+ const stateJson = JSON.stringify(state);
48
+ try {
49
+ // Official Redis client syntax: eval(script, { keys: [...], arguments: [...] })
50
+ const result = await this.redis.eval(this.saveStateLua, {
51
+ keys: [keys.undo, keys.redo, keys.version],
52
+ arguments: [
53
+ stateJson,
54
+ versionId,
55
+ this.TTL.toString(),
56
+ this.LIMIT.toString(),
57
+ ],
58
+ });
59
+ // Check if it's an error response (table with err field)
60
+ if (result && typeof result === "object" && result.err) {
61
+ return { success: false, error: result.err };
62
+ }
63
+ // Success response is a string (the new version)
64
+ const newVersion = typeof result === "string" ? result : String(result);
65
+ return { success: true, newVersion };
66
+ }
67
+ catch (error) {
68
+ console.error("⏮️ HISTORY: Error saving state to Redis:", error);
69
+ return { success: false, error: error.message };
70
+ }
71
+ }
72
+ /**
73
+ * Performs an undo operation
74
+ * @param courseSlug - The course slug identifier
75
+ * @param exerciseSlug - The exercise slug identifier
76
+ * @param lang - The language code
77
+ * @param currentContent - The current content of the state
78
+ * @param versionId - The version ID
79
+ * @returns Object containing success, newVersion, and error
80
+ */
81
+ async undo(courseSlug, exerciseSlug, lang, currentContent, versionId) {
82
+ const keys = this.getKeys(courseSlug, exerciseSlug, lang);
83
+ const currentState = {
84
+ content: currentContent,
85
+ timestamp: Date.now(),
86
+ };
87
+ try {
88
+ const result = await this.redis.eval(this.undoLua, {
89
+ keys: [keys.undo, keys.redo, keys.version],
90
+ arguments: [
91
+ JSON.stringify(currentState),
92
+ versionId,
93
+ this.TTL.toString(),
94
+ ],
95
+ });
96
+ // Check if it's an error response (table with err field)
97
+ if (result && typeof result === "object" && result.err) {
98
+ return { success: false, error: result.err };
99
+ }
100
+ // Success response is an array: [newVersion, previousState]
101
+ if (!Array.isArray(result) || result.length < 2) {
102
+ console.error("⏮️ HISTORY: Invalid response from undo script:", result);
103
+ return { success: false, error: "INVALID_RESPONSE" };
104
+ }
105
+ const [newVersion, previousStateJson] = result;
106
+ if (!previousStateJson) {
107
+ console.error("⏮️ HISTORY: No state returned from undo script");
108
+ return { success: false, error: "NO_STATE_RETURNED" };
109
+ }
110
+ const previousState = JSON.parse(previousStateJson);
111
+ return {
112
+ success: true,
113
+ newVersion: String(newVersion),
114
+ content: previousState.content,
115
+ };
116
+ }
117
+ catch (error) {
118
+ console.error("⏮️ HISTORY: Error performing undo:", error);
119
+ return { success: false, error: error.message };
120
+ }
121
+ }
122
+ /**
123
+ * Performs a redo operation
124
+ * @param courseSlug - The course slug identifier
125
+ * @param exerciseSlug - The exercise slug identifier
126
+ * @param lang - The language code
127
+ * @param currentContent - The current content of the state
128
+ * @param versionId - The version ID
129
+ * @returns Object containing success, newVersion, and error
130
+ */
131
+ async redo(courseSlug, exerciseSlug, lang, currentContent, versionId) {
132
+ const keys = this.getKeys(courseSlug, exerciseSlug, lang);
133
+ const currentState = {
134
+ content: currentContent,
135
+ timestamp: Date.now(),
136
+ };
137
+ try {
138
+ const result = await this.redis.eval(this.redoLua, {
139
+ keys: [keys.undo, keys.redo, keys.version],
140
+ arguments: [
141
+ JSON.stringify(currentState),
142
+ versionId,
143
+ this.TTL.toString(),
144
+ ],
145
+ });
146
+ // Check if it's an error response (table with err field)
147
+ if (result && typeof result === "object" && result.err) {
148
+ return { success: false, error: result.err };
149
+ }
150
+ // Success response is an array: [newVersion, futureState]
151
+ if (!Array.isArray(result) || result.length < 2) {
152
+ console.error("⏮️ HISTORY: Invalid response from redo script:", result);
153
+ return { success: false, error: "INVALID_RESPONSE" };
154
+ }
155
+ const [newVersion, futureStateJson] = result;
156
+ if (!futureStateJson) {
157
+ console.error("⏮️ HISTORY: No state returned from redo script");
158
+ return { success: false, error: "NO_STATE_RETURNED" };
159
+ }
160
+ const futureState = JSON.parse(futureStateJson);
161
+ return {
162
+ success: true,
163
+ newVersion: String(newVersion),
164
+ content: futureState.content,
165
+ };
166
+ }
167
+ catch (error) {
168
+ console.error("⏮️ HISTORY: Error performing redo:", error);
169
+ return { success: false, error: error.message };
170
+ }
171
+ }
172
+ /**
173
+ * Gets the history status (can undo/redo, current version)
174
+ * @param courseSlug - The course slug identifier
175
+ * @param exerciseSlug - The exercise slug identifier
176
+ * @param lang - The language code
177
+ * @returns Object containing canUndo, canRedo, and version
178
+ */
179
+ async getHistoryStatus(courseSlug, exerciseSlug, lang) {
180
+ const keys = this.getKeys(courseSlug, exerciseSlug, lang);
181
+ try {
182
+ const [undoLen, redoLen, version] = await Promise.all([
183
+ this.redis.lLen(keys.undo),
184
+ this.redis.lLen(keys.redo),
185
+ this.redis.get(keys.version),
186
+ ]);
187
+ return {
188
+ canUndo: undoLen >= 1, // Need at least 1 (we save state BEFORE changes)
189
+ canRedo: redoLen > 0,
190
+ version: version || "0",
191
+ };
192
+ }
193
+ catch (error) {
194
+ console.error("⏮️ HISTORY: Error getting history status:", error);
195
+ return {
196
+ canUndo: false,
197
+ canRedo: false,
198
+ version: "0",
199
+ };
200
+ }
201
+ }
202
+ }
203
+ exports.HistoryManager = HistoryManager;
@@ -0,0 +1,76 @@
1
+ import type { Bucket } from "@google-cloud/storage";
2
+ import type { HistoryManager } from "./historyManager";
3
+ export interface SaveReadmeWithHistoryOptions {
4
+ courseSlug: string;
5
+ exerciseSlug: string;
6
+ lang: string;
7
+ fileName: string;
8
+ newContent: string;
9
+ previousContent?: string;
10
+ currentVersion?: string;
11
+ throwOnConflict?: boolean;
12
+ }
13
+ export interface SaveReadmeResult {
14
+ success: boolean;
15
+ newVersion: string;
16
+ error?: string;
17
+ }
18
+ /**
19
+ * Service to handle README file updates with automatic history tracking
20
+ * Encapsulates the logic of saving state to history before updating files
21
+ */
22
+ export declare class ReadmeHistoryService {
23
+ private historyManager;
24
+ private bucket;
25
+ /**
26
+ * Constructor for the ReadmeHistoryService
27
+ * @param historyManager - The history manager to use
28
+ * @param bucket - The bucket to use
29
+ */
30
+ constructor(historyManager: HistoryManager | null, bucket: Bucket);
31
+ /**
32
+ * Saves a README file to GCS with automatic history tracking
33
+ *
34
+ * Workflow:
35
+ * 1. If previousContent is not provided, downloads it from GCS
36
+ * 2. If currentVersion is not provided, gets it from history
37
+ * 3. Saves the PREVIOUS state to history (for undo)
38
+ * 4. Saves the NEW content to GCS
39
+ *
40
+ * @param options - Options for the saveReadmeWithHistory method
41
+ * @returns Result with success status and new version
42
+ */
43
+ saveReadmeWithHistory(options: SaveReadmeWithHistoryOptions): Promise<SaveReadmeResult>;
44
+ /**
45
+ * Gets the previous content of a README from GCS
46
+ * Returns undefined if file doesn't exist (first version)
47
+ * @param courseSlug - The slug of the course
48
+ * @param exerciseSlug - The slug of the exercise
49
+ * @param fileName - The name of the file
50
+ * @returns The previous content of the file or undefined if it doesn't exist
51
+ */
52
+ private getPreviousContent;
53
+ /**
54
+ * Gets the current version from history
55
+ * @param courseSlug - The slug of the course
56
+ * @param exerciseSlug - The slug of the exercise
57
+ * @param lang - The language of the file
58
+ * @returns The current version of the file
59
+ */
60
+ private getCurrentVersion;
61
+ /**
62
+ * Saves content to Google Cloud Storage
63
+ * @param courseSlug - The slug of the course
64
+ * @param exerciseSlug - The slug of the exercise
65
+ * @param fileName - The name of the file
66
+ * @param content - The content to save
67
+ * @returns void
68
+ */
69
+ private saveToGCS;
70
+ /**
71
+ * Helper to get README extension from language code
72
+ * @param lang - The language of the file
73
+ * @returns The name of the README file
74
+ */
75
+ static getReadmeFileName(lang: string): string;
76
+ }
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ReadmeHistoryService = void 0;
4
+ /**
5
+ * Service to handle README file updates with automatic history tracking
6
+ * Encapsulates the logic of saving state to history before updating files
7
+ */
8
+ class ReadmeHistoryService {
9
+ /**
10
+ * Constructor for the ReadmeHistoryService
11
+ * @param historyManager - The history manager to use
12
+ * @param bucket - The bucket to use
13
+ */
14
+ constructor(historyManager, bucket) {
15
+ this.historyManager = historyManager;
16
+ this.bucket = bucket;
17
+ }
18
+ /**
19
+ * Saves a README file to GCS with automatic history tracking
20
+ *
21
+ * Workflow:
22
+ * 1. If previousContent is not provided, downloads it from GCS
23
+ * 2. If currentVersion is not provided, gets it from history
24
+ * 3. Saves the PREVIOUS state to history (for undo)
25
+ * 4. Saves the NEW content to GCS
26
+ *
27
+ * @param options - Options for the saveReadmeWithHistory method
28
+ * @returns Result with success status and new version
29
+ */
30
+ async saveReadmeWithHistory(options) {
31
+ const { courseSlug, exerciseSlug, lang, fileName, newContent, previousContent: providedPreviousContent, currentVersion: providedVersion, throwOnConflict = false, } = options;
32
+ // Guard: Only process README files
33
+ if (!fileName.startsWith("README")) {
34
+ console.warn(`⚠️ ReadmeHistoryService called for non-README file: ${fileName}`);
35
+ return { success: false, newVersion: "0", error: "NOT_README_FILE" };
36
+ }
37
+ // Guard: History manager not available
38
+ if (!this.historyManager) {
39
+ console.warn("⚠️ History manager not available, skipping history");
40
+ // Still save the file without history
41
+ await this.saveToGCS(courseSlug, exerciseSlug, fileName, newContent);
42
+ return {
43
+ success: false,
44
+ newVersion: "0",
45
+ error: "HISTORY_NOT_AVAILABLE",
46
+ };
47
+ }
48
+ try {
49
+ // Step 1: Get previous content if not provided
50
+ const previousContent = providedPreviousContent !== null && providedPreviousContent !== void 0 ? providedPreviousContent : (await this.getPreviousContent(courseSlug, exerciseSlug, fileName));
51
+ // If there's no previous content, we can't save to history
52
+ // (this is the first version of the file)
53
+ if (!previousContent) {
54
+ console.log(`📝 HISTORY: First version of ${fileName} for ${lang}, skipping history`);
55
+ await this.saveToGCS(courseSlug, exerciseSlug, fileName, newContent);
56
+ return {
57
+ success: false,
58
+ newVersion: "0",
59
+ error: "NO_PREVIOUS_CONTENT",
60
+ };
61
+ }
62
+ // Step 2: Get current version if not provided
63
+ const currentVersion = providedVersion !== null && providedVersion !== void 0 ? providedVersion : (await this.getCurrentVersion(courseSlug, exerciseSlug, lang));
64
+ // Step 3: Save to history BEFORE updating the file
65
+ const historyResult = await this.historyManager.saveState(courseSlug, exerciseSlug, lang, previousContent, // Save the content BEFORE the change
66
+ currentVersion);
67
+ // Step 4: Handle history save result
68
+ if (!historyResult.success) {
69
+ // VERSION_CONFLICT: Another user/process modified the file
70
+ if (historyResult.error === "VERSION_CONFLICT") {
71
+ const message = "Version conflict detected. Content may have been modified by another process.";
72
+ if (throwOnConflict) {
73
+ throw new Error(message);
74
+ }
75
+ else {
76
+ console.error(`⏮️ HISTORY: ${message}`);
77
+ // Still save the file despite conflict
78
+ await this.saveToGCS(courseSlug, exerciseSlug, fileName, newContent);
79
+ return {
80
+ success: false,
81
+ newVersion: currentVersion,
82
+ error: "VERSION_CONFLICT",
83
+ };
84
+ }
85
+ }
86
+ // Other errors: log but don't fail
87
+ console.error(`⏮️ HISTORY: Failed to save history (non-critical): ${historyResult.error}`);
88
+ await this.saveToGCS(courseSlug, exerciseSlug, fileName, newContent);
89
+ return {
90
+ success: false,
91
+ newVersion: currentVersion,
92
+ error: historyResult.error,
93
+ };
94
+ }
95
+ // Step 5: History saved successfully, now save the new content
96
+ await this.saveToGCS(courseSlug, exerciseSlug, fileName, newContent);
97
+ console.log(`✅ HISTORY: Saved ${fileName} (${lang}) - version ${historyResult.newVersion}`);
98
+ return {
99
+ success: true,
100
+ newVersion: historyResult.newVersion,
101
+ };
102
+ }
103
+ catch (error) {
104
+ const errorMessage = error.message || "Unknown error";
105
+ console.error(`❌ HISTORY: Error in saveReadmeWithHistory: ${errorMessage}`);
106
+ // Try to save the file anyway (best effort)
107
+ try {
108
+ await this.saveToGCS(courseSlug, exerciseSlug, fileName, newContent);
109
+ }
110
+ catch (saveError) {
111
+ console.error("❌ Failed to save file after history error:", saveError);
112
+ }
113
+ return {
114
+ success: false,
115
+ newVersion: "0",
116
+ error: errorMessage,
117
+ };
118
+ }
119
+ }
120
+ /**
121
+ * Gets the previous content of a README from GCS
122
+ * Returns undefined if file doesn't exist (first version)
123
+ * @param courseSlug - The slug of the course
124
+ * @param exerciseSlug - The slug of the exercise
125
+ * @param fileName - The name of the file
126
+ * @returns The previous content of the file or undefined if it doesn't exist
127
+ */
128
+ async getPreviousContent(courseSlug, exerciseSlug, fileName) {
129
+ try {
130
+ const filePath = `courses/${courseSlug}/exercises/${exerciseSlug}/${fileName}`;
131
+ const [content] = await this.bucket.file(filePath).download();
132
+ return content.toString();
133
+ }
134
+ catch (error) {
135
+ // File doesn't exist (404) - this is normal for first save
136
+ if (error.code === 404) {
137
+ return undefined;
138
+ }
139
+ console.error(`⚠️ Error downloading previous content: ${error.message}`);
140
+ return undefined;
141
+ }
142
+ }
143
+ /**
144
+ * Gets the current version from history
145
+ * @param courseSlug - The slug of the course
146
+ * @param exerciseSlug - The slug of the exercise
147
+ * @param lang - The language of the file
148
+ * @returns The current version of the file
149
+ */
150
+ async getCurrentVersion(courseSlug, exerciseSlug, lang) {
151
+ if (!this.historyManager) {
152
+ return "0";
153
+ }
154
+ try {
155
+ const status = await this.historyManager.getHistoryStatus(courseSlug, exerciseSlug, lang);
156
+ return status.version;
157
+ }
158
+ catch (error) {
159
+ console.error("⚠️ Error getting current version, defaulting to '0':", error);
160
+ return "0";
161
+ }
162
+ }
163
+ /**
164
+ * Saves content to Google Cloud Storage
165
+ * @param courseSlug - The slug of the course
166
+ * @param exerciseSlug - The slug of the exercise
167
+ * @param fileName - The name of the file
168
+ * @param content - The content to save
169
+ * @returns void
170
+ */
171
+ async saveToGCS(courseSlug, exerciseSlug, fileName, content) {
172
+ const filePath = `courses/${courseSlug}/exercises/${exerciseSlug}/${fileName}`;
173
+ const file = this.bucket.file(filePath);
174
+ await file.save(content, {
175
+ resumable: false,
176
+ });
177
+ }
178
+ /**
179
+ * Helper to get README extension from language code
180
+ * @param lang - The language of the file
181
+ * @returns The name of the README file
182
+ */
183
+ static getReadmeFileName(lang) {
184
+ if (lang === "en") {
185
+ return "README.md";
186
+ }
187
+ // es -> README.es.md, fr -> README.fr.md
188
+ return `README.${lang}.md`;
189
+ }
190
+ }
191
+ exports.ReadmeHistoryService = ReadmeHistoryService;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@learnpack/learnpack",
3
3
  "description": "Seamlessly build, sell and/or take interactive & auto-graded tutorials, start learning now or build a new tutorial to your audience.",
4
- "version": "5.0.329",
4
+ "version": "5.0.332",
5
5
  "author": "Alejandro Sanchez @alesanchezr",
6
6
  "contributors": [
7
7
  {
@@ -62,6 +62,7 @@
62
62
  "node-persist": "^3.1.0",
63
63
  "ora": "^8.2.0",
64
64
  "prompts": "^2.3.2",
65
+ "redis": "^5.10.0",
65
66
  "rimraf": "^6.0.1",
66
67
  "shelljs": "^0.8.4",
67
68
  "socket.io": "^4.4.1",
@@ -19,10 +19,41 @@ import api, { getConsumable, RIGOBOT_HOST, TAcademy } from "../utils/api"
19
19
  import * as prompts from "prompts"
20
20
  import { isValidRigoToken } from "../utils/rigoActions"
21
21
  import { minutesToISO8601Duration } from "../utils/misc"
22
- import { slugify } from "../utils/creatorUtilities"
22
+ import { getReadmeExtension, slugify } from "../utils/creatorUtilities"
23
23
 
24
24
  const uploadZipEndpont = RIGOBOT_HOST + "/v1/learnpack/upload"
25
25
 
26
+ const getAvailableLangs = (learnJson: any): string[] => {
27
+ const langs = Object.keys(learnJson?.title || {})
28
+ return langs.filter((l) => typeof l === "string" && l.length > 0)
29
+ }
30
+
31
+ const getDefaultLang = (learnJson: any): string => {
32
+ const availableLangs = getAvailableLangs(learnJson)
33
+ if (availableLangs.length === 0) return "en"
34
+ return availableLangs.includes("en") ? "en" : availableLangs[0]
35
+ }
36
+
37
+ const getLocalizedValue = (
38
+ translations: Record<string, any> | undefined,
39
+ lang: string,
40
+ fallbackLangs: string[] = ["en", "us"]
41
+ ): string => {
42
+ if (!translations || typeof translations !== "object") return ""
43
+
44
+ const direct = translations[lang]
45
+ if (typeof direct === "string" && direct.trim().length > 0) return direct
46
+
47
+ for (const fb of fallbackLangs) {
48
+ const v = translations[fb]
49
+ if (typeof v === "string" && v.trim().length > 0) return v
50
+ }
51
+
52
+ const firstKey = Object.keys(translations)[0]
53
+ const first = firstKey ? translations[firstKey] : ""
54
+ return typeof first === "string" ? first : ""
55
+ }
56
+
26
57
  export const handleAssetCreation = async (
27
58
  sessionPayload: { token: string; rigobotToken: string },
28
59
  learnJson: any,
@@ -36,7 +67,19 @@ export const handleAssetCreation = async (
36
67
  try {
37
68
  const user = await api.validateToken(sessionPayload.token)
38
69
 
39
- let slug = slugify(learnJson.title[selectedLang]).slice(0, 47)
70
+ const assetTitle = getLocalizedValue(learnJson?.title, selectedLang)
71
+ const assetDescription = getLocalizedValue(
72
+ learnJson?.description,
73
+ selectedLang
74
+ )
75
+
76
+ if (!assetTitle) {
77
+ throw new Error(
78
+ `Missing learn.json title for language "${selectedLang}"`
79
+ )
80
+ }
81
+
82
+ let slug = slugify(assetTitle).slice(0, 47)
40
83
  slug = `${slug}-${selectedLang}`
41
84
 
42
85
  const { exists } = await api.doesAssetExists(sessionPayload.token, slug)
@@ -45,10 +88,10 @@ export const handleAssetCreation = async (
45
88
  Console.info("Asset does not exist in this academy, creating it")
46
89
  const asset = await api.createAsset(sessionPayload.token, {
47
90
  slug: slug,
48
- title: learnJson.title[selectedLang],
91
+ title: assetTitle,
49
92
  lang: selectedLang,
50
93
  graded: true,
51
- description: learnJson.description[selectedLang],
94
+ description: assetDescription,
52
95
  learnpack_deploy_url: learnpackDeployUrl,
53
96
  technologies: learnJson.technologies.map((tech: string) =>
54
97
  tech.toLowerCase().replace(/\s+/g, "-")
@@ -81,9 +124,9 @@ export const handleAssetCreation = async (
81
124
  const asset = await api.updateAsset(sessionPayload.token, slug, {
82
125
  graded: true,
83
126
  learnpack_deploy_url: learnpackDeployUrl,
84
- title: learnJson.title[selectedLang],
127
+ title: assetTitle,
85
128
  category: category,
86
- description: learnJson.description[selectedLang],
129
+ description: assetDescription,
87
130
  all_translations,
88
131
  })
89
132
  try {
@@ -106,6 +149,58 @@ export const handleAssetCreation = async (
106
149
  }
107
150
  }
108
151
 
152
+ const createMultiLangAssetFromDisk = async (
153
+ sessionPayload: { token: string; rigobotToken: string },
154
+ learnJson: any,
155
+ deployUrl: string
156
+ ) => {
157
+ const availableLangs = getAvailableLangs(learnJson)
158
+
159
+ if (availableLangs.length === 0) {
160
+ Console.error(
161
+ "No languages found in learn.json.title. Add at least one language (e.g. title.en)."
162
+ )
163
+ return
164
+ }
165
+
166
+ const all_translations: string[] = []
167
+ for (const lang of availableLangs) {
168
+ const readmePath = path.join(
169
+ process.cwd(),
170
+ `README${getReadmeExtension(lang)}`
171
+ )
172
+
173
+ let indexReadmeString = ""
174
+ try {
175
+ if (fs.existsSync(readmePath)) {
176
+ indexReadmeString = fs.readFileSync(readmePath, "utf-8")
177
+ }
178
+ } catch (error) {
179
+ Console.error("Error reading index readme:", error)
180
+ indexReadmeString = ""
181
+ }
182
+
183
+ const b64IndexReadme = Buffer.from(indexReadmeString).toString("base64")
184
+
185
+ // eslint-disable-next-line no-await-in-loop
186
+ const asset = await handleAssetCreation(
187
+ sessionPayload,
188
+ learnJson,
189
+ lang,
190
+ deployUrl,
191
+ b64IndexReadme,
192
+ all_translations
193
+ )
194
+
195
+ if (!asset) {
196
+ Console.debug("Could not create/update asset for lang", lang)
197
+ continue
198
+ }
199
+
200
+ all_translations.push(asset.slug)
201
+ }
202
+ }
203
+
109
204
  const runAudit = (strict: boolean) => {
110
205
  try {
111
206
  Console.info("Running learnpack audit before publishing...")
@@ -362,9 +457,13 @@ class BuildCommand extends SessionCommand {
362
457
  if (fs.existsSync(indexHtmlPath)) {
363
458
  let indexHtmlContent = fs.readFileSync(indexHtmlPath, "utf-8")
364
459
 
365
- const description = learnJson.description.en || "LearnPack is awesome!"
460
+ const selectedLang = getDefaultLang(learnJson)
461
+ const description =
462
+ getLocalizedValue(learnJson?.description, selectedLang) ||
463
+ "LearnPack is awesome!"
366
464
  const title =
367
- learnJson.title.en || "LearnPack: Interactive Learning as a Service"
465
+ getLocalizedValue(learnJson?.title, selectedLang) ||
466
+ "LearnPack: Interactive Learning as a Service"
368
467
 
369
468
  const previewUrl =
370
469
  learnJson.preview ||
@@ -386,9 +485,15 @@ class BuildCommand extends SessionCommand {
386
485
 
387
486
  if (fs.existsSync(manifestPWA)) {
388
487
  let manifestPWAContent = fs.readFileSync(manifestPWA, "utf-8")
488
+ const selectedLang = getDefaultLang(learnJson)
489
+ const courseTitle =
490
+ getLocalizedValue(learnJson?.title, selectedLang) ||
491
+ getLocalizedValue(learnJson?.title, "us") ||
492
+ getLocalizedValue(learnJson?.title, "en") ||
493
+ "LearnPack"
389
494
  manifestPWAContent = manifestPWAContent.replace(
390
495
  "{{course_title}}",
391
- learnJson.title.us
496
+ courseTitle
392
497
  )
393
498
 
394
499
  const courseShortName = { answer: "testing-tutorial" }
@@ -454,13 +559,10 @@ class BuildCommand extends SessionCommand {
454
559
  fs.unlinkSync(zipFilePath)
455
560
  this.removeDirectory(buildDir)
456
561
 
457
- await handleAssetCreation(
458
- sessionPayload,
562
+ await createMultiLangAssetFromDisk(
563
+ { token: sessionPayload.token, rigobotToken: rigoToken },
459
564
  learnJson,
460
- "en",
461
- res.data.url,
462
- "",
463
- []
565
+ res.data.url
464
566
  )
465
567
  } catch (error) {
466
568
  if (axios.isAxiosError(error)) {