@learnpack/learnpack 5.0.355 → 5.0.358

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/lib/commands/publish.js +2 -2
  2. package/lib/commands/serve.d.ts +0 -17
  3. package/lib/commands/serve.js +252 -147
  4. package/lib/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
  5. package/lib/creatorDist/assets/index-zrPponAn.css +1701 -0
  6. package/lib/creatorDist/index.html +2 -2
  7. package/lib/models/creator.d.ts +1 -1
  8. package/lib/services/ingest/adapters/gcsCourseStorage.d.ts +13 -0
  9. package/lib/services/ingest/adapters/gcsCourseStorage.js +58 -0
  10. package/lib/services/ingest/adapters/rigoUserTokenAuth.d.ts +15 -0
  11. package/lib/services/ingest/adapters/rigoUserTokenAuth.js +46 -0
  12. package/lib/services/ingest/adapters/rigobotPackageRegistry.d.ts +7 -0
  13. package/lib/services/ingest/adapters/rigobotPackageRegistry.js +119 -0
  14. package/lib/services/ingest/core/buildIngestConfig.d.ts +27 -0
  15. package/lib/services/ingest/core/buildIngestConfig.js +40 -0
  16. package/lib/services/ingest/core/buildInitialSyllabus.d.ts +24 -0
  17. package/lib/services/ingest/core/buildInitialSyllabus.js +104 -0
  18. package/lib/services/ingest/core/buildSidebar.d.ts +13 -0
  19. package/lib/services/ingest/core/buildSidebar.js +28 -0
  20. package/lib/services/ingest/core/coursePaths.d.ts +31 -0
  21. package/lib/services/ingest/core/coursePaths.js +50 -0
  22. package/lib/services/ingest/core/ingestCoursePackage.d.ts +52 -0
  23. package/lib/services/ingest/core/ingestCoursePackage.js +217 -0
  24. package/lib/services/ingest/core/normalizeLearnJson.d.ts +25 -0
  25. package/lib/services/ingest/core/normalizeLearnJson.js +29 -0
  26. package/lib/services/ingest/core/pairLessons.d.ts +29 -0
  27. package/lib/services/ingest/core/pairLessons.js +102 -0
  28. package/lib/services/ingest/core/planWrites.d.ts +44 -0
  29. package/lib/services/ingest/core/planWrites.js +59 -0
  30. package/lib/services/ingest/core/resolveSlug.d.ts +53 -0
  31. package/lib/services/ingest/core/resolveSlug.js +93 -0
  32. package/lib/services/ingest/core/types.d.ts +53 -0
  33. package/lib/services/ingest/core/types.js +2 -0
  34. package/lib/services/ingest/core/validatePackage.d.ts +38 -0
  35. package/lib/services/ingest/core/validatePackage.js +97 -0
  36. package/lib/services/ingest/createIngestService.d.ts +34 -0
  37. package/lib/services/ingest/createIngestService.js +38 -0
  38. package/lib/services/ingest/errors.d.ts +22 -0
  39. package/lib/services/ingest/errors.js +34 -0
  40. package/lib/services/ingest/http/errorMapping.d.ts +8 -0
  41. package/lib/services/ingest/http/errorMapping.js +40 -0
  42. package/lib/services/ingest/http/router.d.ts +32 -0
  43. package/lib/services/ingest/http/router.js +124 -0
  44. package/lib/services/ingest/http/zipPackageReader.d.ts +26 -0
  45. package/lib/services/ingest/http/zipPackageReader.js +62 -0
  46. package/lib/services/ingest/ports/courseStorage.d.ts +25 -0
  47. package/lib/services/ingest/ports/courseStorage.js +2 -0
  48. package/lib/services/ingest/ports/packageRegistry.d.ts +58 -0
  49. package/lib/services/ingest/ports/packageRegistry.js +2 -0
  50. package/lib/services/ingest/ports/requestAuthenticator.d.ts +25 -0
  51. package/lib/services/ingest/ports/requestAuthenticator.js +2 -0
  52. package/lib/ui/static/og-image.png +0 -0
  53. package/lib/utils/branding.d.ts +43 -0
  54. package/lib/utils/branding.js +75 -0
  55. package/lib/utils/coursePackage/bucketIo.d.ts +23 -0
  56. package/lib/utils/coursePackage/bucketIo.js +36 -0
  57. package/lib/utils/coursePackage/learnJson.d.ts +26 -0
  58. package/lib/utils/coursePackage/learnJson.js +36 -0
  59. package/lib/utils/coursePackage/sidebar.d.ts +15 -0
  60. package/lib/utils/coursePackage/sidebar.js +26 -0
  61. package/lib/utils/creatorSocket.js +15 -21
  62. package/lib/utils/gcpCredentials.d.ts +18 -0
  63. package/lib/utils/gcpCredentials.js +27 -0
  64. package/lib/utils/rigoActions.d.ts +16 -0
  65. package/lib/utils/rigoActions.js +102 -1
  66. package/lib/utils/socketRegistry.d.ts +46 -0
  67. package/lib/utils/socketRegistry.js +83 -0
  68. package/package.json +2 -2
  69. package/src/commands/publish.ts +2 -3
  70. package/src/commands/serve.ts +297 -136
  71. package/src/creator/README.md +63 -51
  72. package/src/creator/package-lock.json +1188 -8
  73. package/src/creator/package.json +5 -1
  74. package/src/creator/src/App.tsx +120 -54
  75. package/src/creator/src/components/FileUploader.tsx +1 -12
  76. package/src/creator/src/components/NotificationListener.tsx +1 -8
  77. package/src/creator/src/components/syllabus/SyllabusEditor.tsx +101 -36
  78. package/src/creator/src/locales/en.json +8 -0
  79. package/src/creator/src/locales/es.json +8 -0
  80. package/src/creator/src/utils/completionResult.test.ts +144 -0
  81. package/src/creator/src/utils/completionResult.ts +154 -0
  82. package/src/creator/src/utils/constants.ts +17 -4
  83. package/src/creator/src/utils/socket.test.ts +113 -0
  84. package/src/creator/src/utils/socket.ts +60 -37
  85. package/src/creator/src/utils/store.ts +84 -67
  86. package/src/creator/src/utils/useCompletionWatchdog.test.ts +99 -0
  87. package/src/creator/src/utils/useCompletionWatchdog.ts +37 -0
  88. package/src/creator/vitest.config.ts +16 -0
  89. package/src/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
  90. package/src/creatorDist/assets/index-zrPponAn.css +1701 -0
  91. package/src/creatorDist/index.html +2 -2
  92. package/src/models/creator.ts +1 -1
  93. package/src/services/ingest/adapters/gcsCourseStorage.ts +70 -0
  94. package/src/services/ingest/adapters/rigoUserTokenAuth.ts +65 -0
  95. package/src/services/ingest/adapters/rigobotPackageRegistry.ts +161 -0
  96. package/src/services/ingest/core/buildIngestConfig.ts +51 -0
  97. package/src/services/ingest/core/buildInitialSyllabus.ts +122 -0
  98. package/src/services/ingest/core/buildSidebar.ts +32 -0
  99. package/src/services/ingest/core/coursePaths.ts +59 -0
  100. package/src/services/ingest/core/ingestCoursePackage.ts +343 -0
  101. package/src/services/ingest/core/normalizeLearnJson.ts +47 -0
  102. package/src/services/ingest/core/pairLessons.ts +132 -0
  103. package/src/services/ingest/core/planWrites.ts +91 -0
  104. package/src/services/ingest/core/resolveSlug.ts +119 -0
  105. package/src/services/ingest/core/types.ts +57 -0
  106. package/src/services/ingest/core/validatePackage.ts +132 -0
  107. package/src/services/ingest/createIngestService.ts +66 -0
  108. package/src/services/ingest/errors.ts +45 -0
  109. package/src/services/ingest/http/errorMapping.ts +49 -0
  110. package/src/services/ingest/http/router.ts +156 -0
  111. package/src/services/ingest/http/zipPackageReader.ts +81 -0
  112. package/src/services/ingest/ports/courseStorage.ts +25 -0
  113. package/src/services/ingest/ports/packageRegistry.ts +60 -0
  114. package/src/services/ingest/ports/requestAuthenticator.ts +28 -0
  115. package/src/ui/_app/app.js +825 -825
  116. package/src/ui/app.tar.gz +0 -0
  117. package/src/ui/static/og-image.png +0 -0
  118. package/src/utils/branding.ts +78 -0
  119. package/src/utils/coursePackage/bucketIo.ts +46 -0
  120. package/src/utils/coursePackage/learnJson.ts +37 -0
  121. package/src/utils/coursePackage/sidebar.ts +28 -0
  122. package/src/utils/creatorSocket.ts +16 -22
  123. package/src/utils/gcpCredentials.ts +38 -0
  124. package/src/utils/rigoActions.ts +163 -0
  125. package/src/utils/socketRegistry.ts +85 -0
  126. package/lib/creatorDist/assets/index-CjddKHB_.css +0 -1
  127. package/src/creatorDist/assets/index-CjddKHB_.css +0 -1
@@ -26,6 +26,7 @@ import * as fs from "fs"
26
26
  import {
27
27
  // createCodeFile,
28
28
  translateExercise,
29
+ translateExerciseAndWait,
29
30
  isValidRigoToken,
30
31
  readmeCreator,
31
32
  // makeReadmeReadable,
@@ -51,7 +52,6 @@ import {
51
52
  import axios from "axios"
52
53
  import * as FormData from "form-data"
53
54
  import api, {
54
- BREATHECODE_TELEMETRY_URL,
55
55
  RIGOBOT_HOST,
56
56
  RIGOBOT_REALTIME_HOST,
57
57
  listUserAcademies,
@@ -74,7 +74,23 @@ import {
74
74
  SyncNotification,
75
75
  } from "../models/creator"
76
76
  import { exportToScorm, exportToEpub, exportToZip } from "../utils/export"
77
- import { generateAndPersistPackageManifest } from "../utils/packageManifest"
77
+ import { createIngestRouter } from "../services/ingest/http/router"
78
+ import {
79
+ uploadFileToBucket,
80
+ uploadBinaryToBucket,
81
+ } from "../utils/coursePackage/bucketIo"
82
+ import { createLearnJson } from "../utils/coursePackage/learnJson"
83
+ import { createInitialSidebar } from "../utils/coursePackage/sidebar"
84
+ import {
85
+ generateAndPersistPackageManifest,
86
+ resolvePreviewUrl,
87
+ } from "../utils/packageManifest"
88
+ import {
89
+ DRAFT_PREVIEW_DESCRIPTION,
90
+ DRAFT_PREVIEW_TITLE,
91
+ getFallbackPreviewUrl,
92
+ getPublicHost,
93
+ } from "../utils/branding"
78
94
  import { requireGcsBucketName } from "../utils/gcsBucketName"
79
95
  import {
80
96
  createGcsSyllabusSyncStorage,
@@ -161,56 +177,6 @@ async function serializeByCourse<T>(
161
177
  return next
162
178
  }
163
179
 
164
- export const createLearnJson = (courseInfo: FormState) => {
165
- // console.log("courseInfo to create learn json", courseInfo)
166
-
167
- const expectedPreviewUrl = `https://${courseInfo.slug}.learn-pack.com/preview.png`
168
-
169
- const language = courseInfo.language || "en"
170
-
171
- const learnJson = {
172
- slug: courseInfo.slug,
173
- title: {
174
- [language]: courseInfo.title,
175
- },
176
- technologies: courseInfo.technologies || [],
177
- difficulty: "beginner",
178
- description: {
179
- [language]: courseInfo.description,
180
- },
181
- grading: "isolated",
182
- telemetry: {
183
- batch: BREATHECODE_TELEMETRY_URL,
184
- },
185
- preview: expectedPreviewUrl,
186
- }
187
- return learnJson
188
- }
189
-
190
- const uploadFileToBucket = async (
191
- bucket: Bucket,
192
- file: string,
193
- path: string
194
- ) => {
195
- const isReadme = /readme(\.\w+)?\.md$/i.test(path)
196
- const content = isReadme ? sanitizeReadmeNewlines(file) : file
197
- const fileRef = bucket.file(path)
198
- await fileRef.save(Buffer.from(content, "utf8"))
199
- }
200
-
201
- const uploadBinaryToBucket = async (
202
- bucket: Bucket,
203
- buffer: Buffer,
204
- path: string,
205
- contentType?: string
206
- ) => {
207
- const fileRef = bucket.file(path)
208
- await fileRef.save(buffer, {
209
- resumable: false,
210
- ...(contentType && { contentType }),
211
- })
212
- }
213
-
214
180
  const getGithubCredentials = () => {
215
181
  const token = process.env.GITHUB_TOKEN?.trim()
216
182
  const username = process.env.GITHUB_USERNAME?.trim()
@@ -518,20 +484,6 @@ export const processImage = async (
518
484
  }
519
485
  }
520
486
 
521
- const createInitialSidebar = async (
522
- slugs: string[],
523
- initialLanguage = "en"
524
- ) => {
525
- const sidebar: Record<string, Record<string, string>> = {}
526
- for (const slug of slugs) {
527
- sidebar[slug] = {
528
- [initialLanguage]: slug,
529
- }
530
- }
531
-
532
- return sidebar
533
- }
534
-
535
487
  const uploadInitialReadme = async (
536
488
  bucket: Bucket,
537
489
  exSlug: string,
@@ -1238,17 +1190,43 @@ async function continueWithNextLesson(
1238
1190
  await saveSyllabus(courseSlug, newSyllabus, bucket)
1239
1191
  }
1240
1192
 
1241
- const fixPreviewUrl = (slug: string, previewUrl: string) => {
1242
- if (!previewUrl) {
1243
- return null
1193
+ /**
1194
+ * The `og:image` of a published course.
1195
+ *
1196
+ * A published package is served from `{slug}.learn-pack.com`, so the course's
1197
+ * own `preview.png` is the right card whenever it exists. This used to go
1198
+ * through a helper that returned null for an empty preview, and both call sites
1199
+ * passed an empty string — so every published course, preview or not, fell back
1200
+ * to a shared SVG that no unfurl crawler can render.
1201
+ */
1202
+ const resolveCoursePreviewUrl = async (
1203
+ bucket: Bucket,
1204
+ slug: string,
1205
+ configuredPreview?: string
1206
+ ): Promise<string> => {
1207
+ // `config` is parsed from a config.json in the bucket, so its declared types
1208
+ // are an assertion rather than a guarantee: an old or hand-edited course can
1209
+ // carry anything here, and a throw would fail the whole publication.
1210
+ if (
1211
+ typeof configuredPreview === "string" &&
1212
+ configuredPreview.startsWith("http")
1213
+ ) {
1214
+ return configuredPreview
1244
1215
  }
1245
1216
 
1246
- if (previewUrl.startsWith("http")) {
1247
- return previewUrl
1217
+ try {
1218
+ const [exists] = await bucket.file(`courses/${slug}/preview.png`).exists()
1219
+ if (exists) {
1220
+ return resolvePreviewUrl(slug)
1221
+ }
1222
+ } catch (error) {
1223
+ console.error(
1224
+ `WARNING: could not check preview.png for ${slug}, using the brand card`,
1225
+ error
1226
+ )
1248
1227
  }
1249
1228
 
1250
- const expectedUrl = `https://${slug}.learn-pack.com/preview.png`
1251
- return expectedUrl
1229
+ return getFallbackPreviewUrl()
1252
1230
  }
1253
1231
 
1254
1232
  const getTitleFromHTML = (html: string) => {
@@ -1257,6 +1235,101 @@ const getTitleFromHTML = (html: string) => {
1257
1235
  return titleMatch ? titleMatch[1] : null
1258
1236
  }
1259
1237
 
1238
+ const escapeHtmlAttribute = (value: string) =>
1239
+ value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;")
1240
+
1241
+ /**
1242
+ * The IDE shell served to draft courses, with its meta tags filled in.
1243
+ *
1244
+ * The shell ships with `{{title}}`, `{{description}}` and `{{preview}}`
1245
+ * placeholders that only get substituted when a course is published. Drafts were
1246
+ * served the raw template, so a link shared in Slack or WhatsApp unfurled to a
1247
+ * literal `{{title}}` / `{{description}}`.
1248
+ */
1249
+ let draftShellTemplate: string | null = null
1250
+
1251
+ const loadDraftShellTemplate = (): string => {
1252
+ const file = path.resolve(__dirname, "../ui/_app/index.html")
1253
+
1254
+ draftShellTemplate = fs
1255
+ .readFileSync(file, "utf-8")
1256
+ // A draft is not a real Course, so it must not emit Course structured data.
1257
+ // The match is anchored to the typed opening tag and non-greedy on purpose:
1258
+ // the shell also carries an inline <script> that loads the app bundle from
1259
+ // CloudFront, and a looser pattern would swallow it and blank the page.
1260
+ .replace(
1261
+ /[^\S\n]*<script type="application\/ld\+json">[\S\s]*?<\/script>\n?/,
1262
+ ""
1263
+ )
1264
+
1265
+ return draftShellTemplate
1266
+ }
1267
+
1268
+ const renderDraftShell = (req: express.Request): string => {
1269
+ const template = draftShellTemplate || loadDraftShellTemplate()
1270
+
1271
+ // Only the path: a preview URL can carry tokens in its query string, and
1272
+ // og:url ends up in whatever crawler cache unfurls the link.
1273
+ const canonicalUrl = escapeHtmlAttribute(`${getPublicHost()}${req.path}`)
1274
+
1275
+ return (
1276
+ template
1277
+ .replace(/<title>.*<\/title>/, `<title>${DRAFT_PREVIEW_TITLE}</title>`)
1278
+ .replace(/{{title}}/g, DRAFT_PREVIEW_TITLE)
1279
+ .replace(/{{description}}/g, DRAFT_PREVIEW_DESCRIPTION)
1280
+ .replace(/{{preview}}/g, getFallbackPreviewUrl())
1281
+ .replace(
1282
+ "</head>",
1283
+ ` <meta property="og:url" content="${canonicalUrl}" />\n` +
1284
+ ` <meta name="robots" content="noindex, nofollow, noarchive" />\n </head>`
1285
+ )
1286
+ // Safety net for placeholders a future shell may add: shipping an empty
1287
+ // value to a crawler beats shipping "{{whatever}}".
1288
+ .replace(/{{\w+}}/g, "")
1289
+ )
1290
+ }
1291
+
1292
+ /**
1293
+ * Drafts are unlisted work in progress. `noindex` goes out as both a meta tag
1294
+ * and a header so it reaches crawlers that skip the markup; a `robots.txt`
1295
+ * `Disallow` would be the wrong tool, since it blocks the fetch itself and
1296
+ * would stop Slack and X from unfurling the link at all.
1297
+ */
1298
+ const sendDraftShell = (req: express.Request, res: express.Response) => {
1299
+ res
1300
+ .type("html")
1301
+ .set("X-Robots-Tag", "noindex, nofollow")
1302
+ .send(renderDraftShell(req))
1303
+ }
1304
+
1305
+ /** How often the notification is touched while a translation is running. */
1306
+ const SYNC_HEARTBEAT_INTERVAL_MS = 60 * 1000
1307
+
1308
+ /**
1309
+ * A synchronization with no news for this long is considered stuck: the GET
1310
+ * endpoint flags it as failed and it can be accepted again.
1311
+ */
1312
+ const SYNC_PROCESSING_TIMEOUT_MS = 3 * 60 * 1000
1313
+
1314
+ // True when a processing notification stopped reporting progress.
1315
+ function isSyncProcessingStale(notification: SyncNotification): boolean {
1316
+ const lastUpdate =
1317
+ notification.processingLastUpdate || notification.updatedAt
1318
+ return Date.now() - lastUpdate > SYNC_PROCESSING_TIMEOUT_MS
1319
+ }
1320
+
1321
+ function findSyncNotification(
1322
+ syllabus: Syllabus,
1323
+ exerciseSlug: string,
1324
+ notificationId: string
1325
+ ) {
1326
+ const lesson = syllabus.lessons.find(
1327
+ lesson => slugify(lesson.id + "-" + lesson.title) === exerciseSlug
1328
+ )
1329
+
1330
+ return lesson?.syncNotifications?.find(n => n.id === notificationId)
1331
+ }
1332
+
1260
1333
  async function processSyncTranslationsSequentially(
1261
1334
  courseSlug: string,
1262
1335
  exerciseSlug: string,
@@ -1269,30 +1342,72 @@ async function processSyncTranslationsSequentially(
1269
1342
  ) {
1270
1343
  try {
1271
1344
  const sanitizedSource = sanitizeReadmeNewlines(sourceReadmeContent)
1345
+
1346
+ let lastHeartbeatAt = Date.now()
1347
+
1348
+ // Keeps `processingLastUpdate` fresh while a language is being translated,
1349
+ // otherwise the PROCESSING_TIMEOUT in GET /sync-notifications can flag a
1350
+ // running synchronization as failed. Throttled, since every beat rewrites
1351
+ // the syllabus in the bucket.
1352
+ const heartbeat = async (targetLang: string) => {
1353
+ if (Date.now() - lastHeartbeatAt < SYNC_HEARTBEAT_INTERVAL_MS) {
1354
+ return
1355
+ }
1356
+
1357
+ lastHeartbeatAt = Date.now()
1358
+
1359
+ try {
1360
+ await serializeByCourse(courseSlug, async () => {
1361
+ const syllabus = await getSyllabus(courseSlug, bucket)
1362
+ const notification = findSyncNotification(
1363
+ syllabus,
1364
+ exerciseSlug,
1365
+ notificationId
1366
+ )
1367
+
1368
+ if (!notification) {
1369
+ return
1370
+ }
1371
+
1372
+ notification.processingLastUpdate = Date.now()
1373
+
1374
+ if (notification.syncProgress) {
1375
+ notification.syncProgress.currentLanguage = targetLang
1376
+ }
1377
+
1378
+ await saveSyllabus(courseSlug, syllabus, bucket)
1379
+ })
1380
+ } catch (error) {
1381
+ console.warn(
1382
+ `🔄 SYNC: heartbeat failed for ${exerciseSlug}:`,
1383
+ (error as Error).message
1384
+ )
1385
+ }
1386
+ }
1387
+
1272
1388
  // Process translations sequentially (no race conditions)
1273
1389
  for (const targetLang of targetLanguages) {
1274
1390
  try {
1275
- // Call Rigobot directly with synchronous execution (no webhook needed)
1391
+ const languageStartedAt = Date.now()
1392
+
1393
+ // The translation runs as an asynchronous Rigobot job that we poll:
1394
+ // waiting for it inside a single request takes 28-45s for a full
1395
+ // README and Heroku kills anything above 30s.
1276
1396
  // eslint-disable-next-line no-await-in-loop
1277
- const response = await axios.post(
1278
- `${RIGOBOT_HOST}/v1/prompting/completion/translate-asset-markdown/`,
1397
+ const translationResult = await translateExerciseAndWait(
1398
+ rigoToken,
1279
1399
  {
1280
- inputs: {
1281
- text_to_translate: sanitizedSource,
1282
- output_language: targetLang,
1283
- },
1284
- include_purpose_objective: false,
1285
- execute_async: false, // Synchronous execution
1400
+ text_to_translate: sanitizedSource,
1401
+ output_language: targetLang,
1286
1402
  },
1287
- {
1288
- headers: {
1289
- "Content-Type": "application/json",
1290
- Authorization: "Token " + rigoToken,
1291
- },
1292
- }
1403
+ { onPoll: () => heartbeat(targetLang) }
1293
1404
  )
1294
1405
 
1295
- const translationResult = response.data
1406
+ console.log(
1407
+ `🔄 SYNC: ${targetLang} translated in ${Math.round(
1408
+ (Date.now() - languageStartedAt) / 1000
1409
+ )}s (job ${translationResult.id})`
1410
+ )
1296
1411
 
1297
1412
  // Check if translation was successful
1298
1413
  if (!translationResult.parsed?.translation) {
@@ -1428,10 +1543,22 @@ async function processSyncTranslationsSequentially(
1428
1543
  (notification.syncProgress.failedLanguages?.length || 0)
1429
1544
 
1430
1545
  if (totalProcessed === notification.syncProgress.totalLanguages) {
1431
- notification.status =
1432
- notification.syncProgress.completedLanguages.length === 0 ?
1433
- "error" :
1434
- "completed"
1546
+ const completedCount =
1547
+ notification.syncProgress.completedLanguages.length
1548
+ const failedCount =
1549
+ notification.syncProgress.failedLanguages?.length || 0
1550
+
1551
+ // A partial result stays actionable: the notification remains
1552
+ // visible so the user can retry just the languages that failed.
1553
+ if (completedCount === 0) {
1554
+ notification.status = "error"
1555
+ } else if (failedCount > 0) {
1556
+ notification.status = "partial"
1557
+ } else {
1558
+ notification.status = "completed"
1559
+ }
1560
+
1561
+ notification.updatedAt = Date.now()
1435
1562
 
1436
1563
  await saveSyllabus(courseSlug, syllabus, bucket)
1437
1564
 
@@ -1598,6 +1725,7 @@ class ServeCommand extends SessionCommand {
1598
1725
  // app.use(express.static(distPath))
1599
1726
  app.use(express.json({ limit: "20mb" }))
1600
1727
  app.use(cors())
1728
+ app.use("/generation", createIngestRouter())
1601
1729
 
1602
1730
  const appPath = path.resolve(__dirname, "../ui/_app")
1603
1731
  const tarPath = path.resolve(__dirname, "../ui/app.tar.gz")
@@ -1619,6 +1747,11 @@ class ServeCommand extends SessionCommand {
1619
1747
  const localAppPath = path.resolve(__dirname, "../ui/_app")
1620
1748
  // app.use(express.static(localAppPath))
1621
1749
 
1750
+ // Warm the draft shell now that _app is on disk. Doing it here rather than
1751
+ // on first request means a corrupt tarball fails at boot, with a stack
1752
+ // trace, instead of on some user's preview link.
1753
+ loadDraftShellTemplate()
1754
+
1622
1755
  app.post("/upload", async (req, res) => {
1623
1756
  const { content, destination } = req.body
1624
1757
  // console.log("UPLOAD", content, destination)
@@ -3104,8 +3237,7 @@ class ServeCommand extends SessionCommand {
3104
3237
  const slug = req.query.slug
3105
3238
 
3106
3239
  if (slug) {
3107
- const file = path.resolve(__dirname, "../ui/_app/index.html")
3108
- res.sendFile(file)
3240
+ sendDraftShell(req, res)
3109
3241
  } else {
3110
3242
  res.redirect("https://learnpack.co")
3111
3243
  }
@@ -3114,13 +3246,18 @@ class ServeCommand extends SessionCommand {
3114
3246
  app.get("/preview/:slug", async (req, res) => {
3115
3247
  console.log("GET COURSE PREVIEW /")
3116
3248
 
3117
- const file = path.resolve(__dirname, "../ui/_app/index.html")
3118
- res.sendFile(file)
3249
+ sendDraftShell(req, res)
3119
3250
  })
3120
3251
 
3121
3252
  app.get("/preview/:slug/webview", async (req, res) => {
3122
- const file = path.resolve(__dirname, "../ui/_app/index.html")
3123
- res.sendFile(file)
3253
+ sendDraftShell(req, res)
3254
+ })
3255
+
3256
+ // The brand card used as og:image by drafts, and as the last-resort
3257
+ // fallback by published courses without a preview of their own.
3258
+ app.get("/og-image.png", (req, res) => {
3259
+ res.set("Cache-Control", "public, max-age=86400")
3260
+ res.sendFile(path.resolve(__dirname, "../ui/static/og-image.png"))
3124
3261
  })
3125
3262
 
3126
3263
  app.get("/config", async (req, res) => {
@@ -4343,45 +4480,42 @@ class ServeCommand extends SessionCommand {
4343
4480
  return res.status(404).json({ error: "Syllabus not found" })
4344
4481
  }
4345
4482
 
4346
- const PROCESSING_TIMEOUT = 3 * 60 * 1000 // 3 minutes
4347
4483
  let modified = false
4348
4484
 
4349
- // Collect active notifications (pending, processing, or error)
4485
+ // Collect active notifications (pending, processing, partial or error)
4350
4486
  const notifications: any[] = []
4351
4487
 
4352
4488
  for (const lesson of syllabus.lessons) {
4353
4489
  if (lesson.syncNotifications && lesson.syncNotifications.length > 0) {
4354
4490
  for (const notification of lesson.syncNotifications) {
4355
4491
  // Check for timeout in processing notifications
4356
- if (notification.status === "processing") {
4357
- // Use processingLastUpdate if available, otherwise fallback to updatedAt
4358
- const processingLastUpdateTime =
4359
- notification.processingLastUpdate || notification.updatedAt
4360
- const timeSinceProcessingStarted =
4361
- Date.now() - processingLastUpdateTime
4362
-
4363
- if (timeSinceProcessingStarted > PROCESSING_TIMEOUT) {
4364
- notification.status = "error"
4365
- notification.error = {
4366
- message: "Synchronization timeout - process took too long",
4367
- code: "PROCESSING_TIMEOUT",
4368
- timestamp: Date.now(),
4369
- }
4370
- modified = true
4371
-
4372
- // Emit error event
4373
- emitToCourse(courseSlug, "sync-notification-error", {
4374
- exerciseSlug: slugify(lesson.id + "-" + lesson.title),
4375
- notificationId: notification.id,
4376
- error: "Processing timeout",
4377
- })
4492
+ if (
4493
+ notification.status === "processing" &&
4494
+ isSyncProcessingStale(notification)
4495
+ ) {
4496
+ notification.status = "error"
4497
+ notification.error = {
4498
+ message: "Synchronization timeout - process took too long",
4499
+ code: "PROCESSING_TIMEOUT",
4500
+ timestamp: Date.now(),
4378
4501
  }
4502
+ modified = true
4503
+
4504
+ // Emit error event
4505
+ emitToCourse(courseSlug, "sync-notification-error", {
4506
+ exerciseSlug: slugify(lesson.id + "-" + lesson.title),
4507
+ notificationId: notification.id,
4508
+ error: "Processing timeout",
4509
+ })
4379
4510
  }
4380
4511
 
4381
- // Include active notifications (pending, processing, or error)
4512
+ // Include active notifications (pending, processing, partial or
4513
+ // error): a partial sync is still actionable, the user can retry
4514
+ // the languages that failed.
4382
4515
  if (
4383
4516
  notification.status === "pending" ||
4384
4517
  notification.status === "processing" ||
4518
+ notification.status === "partial" ||
4385
4519
  notification.status === "error"
4386
4520
  ) {
4387
4521
  notifications.push({
@@ -4521,9 +4655,24 @@ class ServeCommand extends SessionCommand {
4521
4655
  return res.status(404).json({ error: "Notification not found" })
4522
4656
  }
4523
4657
 
4524
- if (notification.status !== "pending") {
4658
+ // A synchronization can be accepted again unless it is actively
4659
+ // running: after a failure or a partial result this same
4660
+ // notification is what the Retry button acts on.
4661
+ if (
4662
+ notification.status === "processing" &&
4663
+ !isSyncProcessingStale(notification)
4664
+ ) {
4665
+ return res.status(409).json({
4666
+ error: "Notification is already being synchronized",
4667
+ code: "ALREADY_PROCESSING",
4668
+ currentStatus: notification.status,
4669
+ })
4670
+ }
4671
+
4672
+ if (notification.status === "completed") {
4525
4673
  return res.status(400).json({
4526
- error: "Notification is not pending",
4674
+ error: "Notification is already synchronized",
4675
+ code: "ALREADY_COMPLETED",
4527
4676
  currentStatus: notification.status,
4528
4677
  })
4529
4678
  }
@@ -4545,10 +4694,16 @@ class ServeCommand extends SessionCommand {
4545
4694
  })
4546
4695
  }
4547
4696
 
4548
- // Determine target languages
4697
+ // Determine target languages. On a retry only what is still missing
4698
+ // is translated again, so the languages that already succeeded are
4699
+ // neither paid for twice nor overwritten.
4700
+ const alreadyCompleted =
4701
+ notification.syncProgress?.completedLanguages || []
4549
4702
  const availableLanguages = Object.keys(lesson.translations || {})
4550
4703
  const targetLanguages = availableLanguages.filter(
4551
- lang => lang !== notification.sourceLanguage
4704
+ lang =>
4705
+ lang !== notification.sourceLanguage &&
4706
+ !alreadyCompleted.includes(lang)
4552
4707
  )
4553
4708
 
4554
4709
  if (targetLanguages.length === 0) {
@@ -4563,8 +4718,12 @@ class ServeCommand extends SessionCommand {
4563
4718
  lesson.syncNotifications?.filter(n => n.id === notificationId) ||
4564
4719
  []
4565
4720
 
4721
+ // Start from a clean slate so a previous failure does not leak into
4722
+ // this run.
4566
4723
  notification.status = "processing"
4724
+ notification.updatedAt = Date.now()
4567
4725
  notification.processingLastUpdate = Date.now()
4726
+ delete notification.error
4568
4727
  notification.syncProgress = {
4569
4728
  totalLanguages: targetLanguages.length,
4570
4729
  completedLanguages: [], // Array of completed language codes
@@ -5358,16 +5517,18 @@ class ServeCommand extends SessionCommand {
5358
5517
  selectedLang = availableLangs[0]
5359
5518
  }
5360
5519
 
5520
+ const previewUrl = await resolveCoursePreviewUrl(
5521
+ bucket,
5522
+ slug,
5523
+ config.preview
5524
+ )
5525
+
5361
5526
  const idxTpl = fs.readFileSync(path.join(uiSrc, "index.html"), "utf-8")
5362
5527
  const idxHtml = idxTpl
5363
5528
  .replace(/{{title}}/g, title)
5364
5529
  .replace(/<title>.*<\/title>/, `<title>${title}</title>`)
5365
5530
  .replace(/{{description}}/g, config.description[selectedLang])
5366
- .replace(
5367
- /{{preview}}/g,
5368
- fixPreviewUrl(slug, "") ||
5369
- "https://raw.githubusercontent.com/learnpack/ide/master/public/learnpack.svg"
5370
- )
5531
+ .replace(/{{preview}}/g, previewUrl)
5371
5532
  .replace(/{{slug}}/g, slug)
5372
5533
  .replace(/{{duration}}/g, minutesToISO8601Duration(config.duration))
5373
5534
  fs.writeFileSync(path.join(buildRoot, "index.html"), idxHtml)
@@ -5415,9 +5576,9 @@ class ServeCommand extends SessionCommand {
5415
5576
  // 8) Crear el config.json en buildRoot con lo que retorna buildConfig
5416
5577
  const fullConfig = { config, exercises }
5417
5578
  fullConfig.config.slug = slug
5418
- fullConfig.config.preview =
5419
- fixPreviewUrl(slug, "") ||
5420
- "https://raw.githubusercontent.com/learnpack/ide/master/public/learnpack.svg"
5579
+ // Same URL the meta tags got: this used to overwrite the real preview
5580
+ // of every published package with the unusable SVG fallback.
5581
+ fullConfig.config.preview = previewUrl
5421
5582
 
5422
5583
  fs.writeFileSync(
5423
5584
  path.join(buildRoot, "config.json"),