@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
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.processImage = exports.createLearnJson = void 0;
3
+ exports.processImage = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const command_1 = require("@oclif/command");
6
6
  const buffer_1 = require("buffer");
@@ -33,7 +33,12 @@ const creatorUtilities_2 = require("../utils/creatorUtilities");
33
33
  const sidebarGenerator_1 = require("../utils/sidebarGenerator");
34
34
  const publish_1 = require("./publish");
35
35
  const export_1 = require("../utils/export");
36
+ const router_1 = require("../services/ingest/http/router");
37
+ const bucketIo_1 = require("../utils/coursePackage/bucketIo");
38
+ const learnJson_1 = require("../utils/coursePackage/learnJson");
39
+ const sidebar_1 = require("../utils/coursePackage/sidebar");
36
40
  const packageManifest_1 = require("../utils/packageManifest");
41
+ const branding_1 = require("../utils/branding");
37
42
  const gcsBucketName_1 = require("../utils/gcsBucketName");
38
43
  const syllabusSync_1 = require("../utils/syllabusSync");
39
44
  const publishJournal_1 = require("../utils/publishJournal");
@@ -86,39 +91,6 @@ async function serializeByCourse(courseSlug, fn) {
86
91
  courseQueues.set(courseSlug, next);
87
92
  return next;
88
93
  }
89
- const createLearnJson = (courseInfo) => {
90
- // console.log("courseInfo to create learn json", courseInfo)
91
- const expectedPreviewUrl = `https://${courseInfo.slug}.learn-pack.com/preview.png`;
92
- const language = courseInfo.language || "en";
93
- const learnJson = {
94
- slug: courseInfo.slug,
95
- title: {
96
- [language]: courseInfo.title,
97
- },
98
- technologies: courseInfo.technologies || [],
99
- difficulty: "beginner",
100
- description: {
101
- [language]: courseInfo.description,
102
- },
103
- grading: "isolated",
104
- telemetry: {
105
- batch: api_1.BREATHECODE_TELEMETRY_URL,
106
- },
107
- preview: expectedPreviewUrl,
108
- };
109
- return learnJson;
110
- };
111
- exports.createLearnJson = createLearnJson;
112
- const uploadFileToBucket = async (bucket, file, path) => {
113
- const isReadme = /readme(\.\w+)?\.md$/i.test(path);
114
- const content = isReadme ? (0, readmeSanitizer_1.sanitizeReadmeNewlines)(file) : file;
115
- const fileRef = bucket.file(path);
116
- await fileRef.save(buffer_1.Buffer.from(content, "utf8"));
117
- };
118
- const uploadBinaryToBucket = async (bucket, buffer, path, contentType) => {
119
- const fileRef = bucket.file(path);
120
- await fileRef.save(buffer, Object.assign({ resumable: false }, (contentType && { contentType })));
121
- };
122
94
  const getGithubCredentials = () => {
123
95
  var _a, _b;
124
96
  const token = (_a = process.env.GITHUB_TOKEN) === null || _a === void 0 ? void 0 : _a.trim();
@@ -331,15 +303,6 @@ const processImage = async (url, description, rigoToken, courseSlug) => {
331
303
  }
332
304
  };
333
305
  exports.processImage = processImage;
334
- const createInitialSidebar = async (slugs, initialLanguage = "en") => {
335
- const sidebar = {};
336
- for (const slug of slugs) {
337
- sidebar[slug] = {
338
- [initialLanguage]: slug,
339
- };
340
- }
341
- return sidebar;
342
- };
343
306
  const uploadInitialReadme = async (bucket, exSlug, targetDir, packageContext) => {
344
307
  const isGeneratingText = `
345
308
  \`\`\`loader slug="${exSlug}"
@@ -347,7 +310,7 @@ const uploadInitialReadme = async (bucket, exSlug, targetDir, packageContext) =>
347
310
  \`\`\`
348
311
  `;
349
312
  const readmeFilename = `README${(0, creatorUtilities_1.getReadmeExtension)(packageContext.language || "en")}`;
350
- await uploadFileToBucket(bucket, isGeneratingText, `${targetDir}/${readmeFilename}`);
313
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, isGeneratingText, `${targetDir}/${readmeFilename}`);
351
314
  };
352
315
  const cleanFormState = (formState) => {
353
316
  const { description, technologies, purpose, hasContentIndex, duration, isCompleted, variables, currentStep, language } = formState, rest = tslib_1.__rest(formState, ["description", "technologies", "purpose", "hasContentIndex", "duration", "isCompleted", "variables", "currentStep", "language"]);
@@ -581,7 +544,7 @@ function sanitizeSyllabusFromUnknown(syllabus) {
581
544
  }
582
545
  async function saveSyllabus(courseSlug, syllabus, bucket) {
583
546
  sanitizeSyllabusFromUnknown(syllabus);
584
- await uploadFileToBucket(bucket, JSON.stringify(syllabus), `courses/${courseSlug}/.learn/initialSyllabus.json`);
547
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(syllabus), `courses/${courseSlug}/.learn/initialSyllabus.json`);
585
548
  }
586
549
  // Instrumented syllabus operations for race condition observability
587
550
  async function getSyllabusWithLog(courseSlug, bucket, context) {
@@ -771,44 +734,150 @@ async function continueWithNextLesson(courseSlug, currentExerciseIndex, rigoToke
771
734
  }) });
772
735
  await saveSyllabus(courseSlug, newSyllabus, bucket);
773
736
  }
774
- const fixPreviewUrl = (slug, previewUrl) => {
775
- if (!previewUrl) {
776
- return null;
737
+ /**
738
+ * The `og:image` of a published course.
739
+ *
740
+ * A published package is served from `{slug}.learn-pack.com`, so the course's
741
+ * own `preview.png` is the right card whenever it exists. This used to go
742
+ * through a helper that returned null for an empty preview, and both call sites
743
+ * passed an empty string — so every published course, preview or not, fell back
744
+ * to a shared SVG that no unfurl crawler can render.
745
+ */
746
+ const resolveCoursePreviewUrl = async (bucket, slug, configuredPreview) => {
747
+ // `config` is parsed from a config.json in the bucket, so its declared types
748
+ // are an assertion rather than a guarantee: an old or hand-edited course can
749
+ // carry anything here, and a throw would fail the whole publication.
750
+ if (typeof configuredPreview === "string" &&
751
+ configuredPreview.startsWith("http")) {
752
+ return configuredPreview;
777
753
  }
778
- if (previewUrl.startsWith("http")) {
779
- return previewUrl;
754
+ try {
755
+ const [exists] = await bucket.file(`courses/${slug}/preview.png`).exists();
756
+ if (exists) {
757
+ return (0, packageManifest_1.resolvePreviewUrl)(slug);
758
+ }
780
759
  }
781
- const expectedUrl = `https://${slug}.learn-pack.com/preview.png`;
782
- return expectedUrl;
760
+ catch (error) {
761
+ console.error(`WARNING: could not check preview.png for ${slug}, using the brand card`, error);
762
+ }
763
+ return (0, branding_1.getFallbackPreviewUrl)();
783
764
  };
784
765
  const getTitleFromHTML = (html) => {
785
766
  const titleRegex = /<title>(.*?)<\/title>/;
786
767
  const titleMatch = html.match(titleRegex);
787
768
  return titleMatch ? titleMatch[1] : null;
788
769
  };
770
+ const escapeHtmlAttribute = (value) => value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
771
+ /**
772
+ * The IDE shell served to draft courses, with its meta tags filled in.
773
+ *
774
+ * The shell ships with `{{title}}`, `{{description}}` and `{{preview}}`
775
+ * placeholders that only get substituted when a course is published. Drafts were
776
+ * served the raw template, so a link shared in Slack or WhatsApp unfurled to a
777
+ * literal `{{title}}` / `{{description}}`.
778
+ */
779
+ let draftShellTemplate = null;
780
+ const loadDraftShellTemplate = () => {
781
+ const file = path.resolve(__dirname, "../ui/_app/index.html");
782
+ draftShellTemplate = fs
783
+ .readFileSync(file, "utf-8")
784
+ // A draft is not a real Course, so it must not emit Course structured data.
785
+ // The match is anchored to the typed opening tag and non-greedy on purpose:
786
+ // the shell also carries an inline <script> that loads the app bundle from
787
+ // CloudFront, and a looser pattern would swallow it and blank the page.
788
+ .replace(/[^\S\n]*<script type="application\/ld\+json">[\S\s]*?<\/script>\n?/, "");
789
+ return draftShellTemplate;
790
+ };
791
+ const renderDraftShell = (req) => {
792
+ const template = draftShellTemplate || loadDraftShellTemplate();
793
+ // Only the path: a preview URL can carry tokens in its query string, and
794
+ // og:url ends up in whatever crawler cache unfurls the link.
795
+ const canonicalUrl = escapeHtmlAttribute(`${(0, branding_1.getPublicHost)()}${req.path}`);
796
+ return (template
797
+ .replace(/<title>.*<\/title>/, `<title>${branding_1.DRAFT_PREVIEW_TITLE}</title>`)
798
+ .replace(/{{title}}/g, branding_1.DRAFT_PREVIEW_TITLE)
799
+ .replace(/{{description}}/g, branding_1.DRAFT_PREVIEW_DESCRIPTION)
800
+ .replace(/{{preview}}/g, (0, branding_1.getFallbackPreviewUrl)())
801
+ .replace("</head>", ` <meta property="og:url" content="${canonicalUrl}" />\n` +
802
+ ` <meta name="robots" content="noindex, nofollow, noarchive" />\n </head>`)
803
+ // Safety net for placeholders a future shell may add: shipping an empty
804
+ // value to a crawler beats shipping "{{whatever}}".
805
+ .replace(/{{\w+}}/g, ""));
806
+ };
807
+ /**
808
+ * Drafts are unlisted work in progress. `noindex` goes out as both a meta tag
809
+ * and a header so it reaches crawlers that skip the markup; a `robots.txt`
810
+ * `Disallow` would be the wrong tool, since it blocks the fetch itself and
811
+ * would stop Slack and X from unfurling the link at all.
812
+ */
813
+ const sendDraftShell = (req, res) => {
814
+ res
815
+ .type("html")
816
+ .set("X-Robots-Tag", "noindex, nofollow")
817
+ .send(renderDraftShell(req));
818
+ };
819
+ /** How often the notification is touched while a translation is running. */
820
+ const SYNC_HEARTBEAT_INTERVAL_MS = 60 * 1000;
821
+ /**
822
+ * A synchronization with no news for this long is considered stuck: the GET
823
+ * endpoint flags it as failed and it can be accepted again.
824
+ */
825
+ const SYNC_PROCESSING_TIMEOUT_MS = 3 * 60 * 1000;
826
+ // True when a processing notification stopped reporting progress.
827
+ function isSyncProcessingStale(notification) {
828
+ const lastUpdate = notification.processingLastUpdate || notification.updatedAt;
829
+ return Date.now() - lastUpdate > SYNC_PROCESSING_TIMEOUT_MS;
830
+ }
831
+ function findSyncNotification(syllabus, exerciseSlug, notificationId) {
832
+ var _a;
833
+ const lesson = syllabus.lessons.find(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title) === exerciseSlug);
834
+ return (_a = lesson === null || lesson === void 0 ? void 0 : lesson.syncNotifications) === null || _a === void 0 ? void 0 : _a.find(n => n.id === notificationId);
835
+ }
789
836
  async function processSyncTranslationsSequentially(courseSlug, exerciseSlug, notificationId, sourceReadmeContent, targetLanguages, rigoToken, bucket, historyManager) {
790
- var _a, _b, _c, _d, _e, _f;
837
+ var _a, _b, _c, _d, _e, _f, _g;
791
838
  try {
792
839
  const sanitizedSource = (0, readmeSanitizer_1.sanitizeReadmeNewlines)(sourceReadmeContent);
840
+ let lastHeartbeatAt = Date.now();
841
+ // Keeps `processingLastUpdate` fresh while a language is being translated,
842
+ // otherwise the PROCESSING_TIMEOUT in GET /sync-notifications can flag a
843
+ // running synchronization as failed. Throttled, since every beat rewrites
844
+ // the syllabus in the bucket.
845
+ const heartbeat = async (targetLang) => {
846
+ if (Date.now() - lastHeartbeatAt < SYNC_HEARTBEAT_INTERVAL_MS) {
847
+ return;
848
+ }
849
+ lastHeartbeatAt = Date.now();
850
+ try {
851
+ await serializeByCourse(courseSlug, async () => {
852
+ const syllabus = await getSyllabus(courseSlug, bucket);
853
+ const notification = findSyncNotification(syllabus, exerciseSlug, notificationId);
854
+ if (!notification) {
855
+ return;
856
+ }
857
+ notification.processingLastUpdate = Date.now();
858
+ if (notification.syncProgress) {
859
+ notification.syncProgress.currentLanguage = targetLang;
860
+ }
861
+ await saveSyllabus(courseSlug, syllabus, bucket);
862
+ });
863
+ }
864
+ catch (error) {
865
+ console.warn(`🔄 SYNC: heartbeat failed for ${exerciseSlug}:`, error.message);
866
+ }
867
+ };
793
868
  // Process translations sequentially (no race conditions)
794
869
  for (const targetLang of targetLanguages) {
795
870
  try {
796
- // Call Rigobot directly with synchronous execution (no webhook needed)
871
+ const languageStartedAt = Date.now();
872
+ // The translation runs as an asynchronous Rigobot job that we poll:
873
+ // waiting for it inside a single request takes 28-45s for a full
874
+ // README and Heroku kills anything above 30s.
797
875
  // eslint-disable-next-line no-await-in-loop
798
- const response = await axios_1.default.post(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/translate-asset-markdown/`, {
799
- inputs: {
800
- text_to_translate: sanitizedSource,
801
- output_language: targetLang,
802
- },
803
- include_purpose_objective: false,
804
- execute_async: false, // Synchronous execution
805
- }, {
806
- headers: {
807
- "Content-Type": "application/json",
808
- Authorization: "Token " + rigoToken,
809
- },
810
- });
811
- const translationResult = response.data;
876
+ const translationResult = await (0, rigoActions_1.translateExerciseAndWait)(rigoToken, {
877
+ text_to_translate: sanitizedSource,
878
+ output_language: targetLang,
879
+ }, { onPoll: () => heartbeat(targetLang) });
880
+ console.log(`🔄 SYNC: ${targetLang} translated in ${Math.round((Date.now() - languageStartedAt) / 1000)}s (job ${translationResult.id})`);
812
881
  // Check if translation was successful
813
882
  if (!((_a = translationResult.parsed) === null || _a === void 0 ? void 0 : _a.translation)) {
814
883
  throw new Error("Translation result is empty");
@@ -897,10 +966,20 @@ async function processSyncTranslationsSequentially(courseSlug, exerciseSlug, not
897
966
  const totalProcessed = notification.syncProgress.completedLanguages.length +
898
967
  (((_e = notification.syncProgress.failedLanguages) === null || _e === void 0 ? void 0 : _e.length) || 0);
899
968
  if (totalProcessed === notification.syncProgress.totalLanguages) {
900
- notification.status =
901
- notification.syncProgress.completedLanguages.length === 0 ?
902
- "error" :
903
- "completed";
969
+ const completedCount = notification.syncProgress.completedLanguages.length;
970
+ const failedCount = ((_f = notification.syncProgress.failedLanguages) === null || _f === void 0 ? void 0 : _f.length) || 0;
971
+ // A partial result stays actionable: the notification remains
972
+ // visible so the user can retry just the languages that failed.
973
+ if (completedCount === 0) {
974
+ notification.status = "error";
975
+ }
976
+ else if (failedCount > 0) {
977
+ notification.status = "partial";
978
+ }
979
+ else {
980
+ notification.status = "completed";
981
+ }
982
+ notification.updatedAt = Date.now();
904
983
  await saveSyllabus(courseSlug, syllabus, bucket);
905
984
  console.log(`🔄 SYNC: ✅ All translations completed for ${exerciseSlug} - Status: ${notification.status}`);
906
985
  (0, creatorSocket_1.emitToCourse)(courseSlug, "sync-notification-completed", {
@@ -908,7 +987,7 @@ async function processSyncTranslationsSequentially(courseSlug, exerciseSlug, not
908
987
  notificationId,
909
988
  status: notification.status,
910
989
  completed: notification.syncProgress.completedLanguages.length,
911
- failed: ((_f = notification.syncProgress.failedLanguages) === null || _f === void 0 ? void 0 : _f.length) || 0,
990
+ failed: ((_g = notification.syncProgress.failedLanguages) === null || _g === void 0 ? void 0 : _g.length) || 0,
912
991
  });
913
992
  }
914
993
  }
@@ -1021,6 +1100,7 @@ class ServeCommand extends SessionCommand_1.default {
1021
1100
  // app.use(express.static(distPath))
1022
1101
  app.use(express.json({ limit: "20mb" }));
1023
1102
  app.use(cors());
1103
+ app.use("/generation", (0, router_1.createIngestRouter)());
1024
1104
  const appPath = path.resolve(__dirname, "../ui/_app");
1025
1105
  const tarPath = path.resolve(__dirname, "../ui/app.tar.gz");
1026
1106
  if (fs.existsSync(appPath)) {
@@ -1033,6 +1113,10 @@ class ServeCommand extends SessionCommand_1.default {
1033
1113
  await (0, file_1.decompress)(`${__dirname}/../ui/app.tar.gz`, `${__dirname}/../ui/_app/`);
1034
1114
  const localAppPath = path.resolve(__dirname, "../ui/_app");
1035
1115
  // app.use(express.static(localAppPath))
1116
+ // Warm the draft shell now that _app is on disk. Doing it here rather than
1117
+ // on first request means a corrupt tarball fails at boot, with a stack
1118
+ // trace, instead of on some user's preview link.
1119
+ loadDraftShellTemplate();
1036
1120
  app.post("/upload", async (req, res) => {
1037
1121
  const { content, destination } = req.body;
1038
1122
  // console.log("UPLOAD", content, destination)
@@ -1207,7 +1291,7 @@ class ServeCommand extends SessionCommand_1.default {
1207
1291
  });
1208
1292
  }
1209
1293
  const filePath = `courses/${courseSlug}/README${(0, creatorUtilities_1.getReadmeExtension)(langCode)}`;
1210
- await uploadFileToBucket(bucket, content, filePath);
1294
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, content, filePath);
1211
1295
  res.json({ status: "SUCCESS" });
1212
1296
  });
1213
1297
  app.post("/webhooks/:courseSlug/images/:imageId", async (req, res) => {
@@ -1302,7 +1386,7 @@ class ServeCommand extends SessionCommand_1.default {
1302
1386
  if (fileObj.name && fileObj.content) {
1303
1387
  const filePath = `${exerciseDir}/${flatFileName}`;
1304
1388
  // eslint-disable-next-line no-await-in-loop
1305
- await uploadFileToBucket(bucket, fileObj.content, filePath);
1389
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, fileObj.content, filePath);
1306
1390
  console.log(`✅ Saved file: ${filePath}`);
1307
1391
  }
1308
1392
  // Save the solution file if it exists
@@ -1314,7 +1398,7 @@ class ServeCommand extends SessionCommand_1.default {
1314
1398
  const solutionFileName = `${baseName}.solution.hide.${extension}`;
1315
1399
  const solutionFilePath = `${exerciseDir}/${solutionFileName}`;
1316
1400
  // eslint-disable-next-line no-await-in-loop
1317
- await uploadFileToBucket(bucket, fileObj.solution, solutionFilePath);
1401
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, fileObj.solution, solutionFilePath);
1318
1402
  console.log(`✅ Saved solution file: ${solutionFilePath}`);
1319
1403
  }
1320
1404
  else {
@@ -1322,7 +1406,7 @@ class ServeCommand extends SessionCommand_1.default {
1322
1406
  const solutionFileName = `${flatFileName}.solution.hide`;
1323
1407
  const solutionFilePath = `${exerciseDir}/${solutionFileName}`;
1324
1408
  // eslint-disable-next-line no-await-in-loop
1325
- await uploadFileToBucket(bucket, fileObj.solution, solutionFilePath);
1409
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, fileObj.solution, solutionFilePath);
1326
1410
  console.log(`✅ Saved solution file: ${solutionFilePath}`);
1327
1411
  }
1328
1412
  }
@@ -1870,7 +1954,7 @@ class ServeCommand extends SessionCommand_1.default {
1870
1954
  const readmeFilename = `README${(0, creatorUtilities_1.getReadmeExtension)(response.parsed.output_language ||
1871
1955
  syllabus.courseInfo.language ||
1872
1956
  "en")}`;
1873
- await uploadFileToBucket(bucket, readability.newMarkdown, `${targetDir}/${readmeFilename}`);
1957
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, readability.newMarkdown, `${targetDir}/${readmeFilename}`);
1874
1958
  // Update used components if provided by the AI
1875
1959
  if (response.parsed.used_components &&
1876
1960
  Array.isArray(response.parsed.used_components)) {
@@ -1994,7 +2078,7 @@ class ServeCommand extends SessionCommand_1.default {
1994
2078
  }
1995
2079
  // Translation successful
1996
2080
  const readmePath = `courses/${courseSlug}/exercises/${exSlug}/README${(0, creatorUtilities_1.getReadmeExtension)(body.parsed.output_language_code)}`;
1997
- await uploadFileToBucket(bucket, body.parsed.translation, readmePath);
2081
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, body.parsed.translation, readmePath);
1998
2082
  // Verify file exists before updating syllabus (resilience: ensure file was actually saved)
1999
2083
  const [fileExists] = await bucket.file(readmePath).exists();
2000
2084
  // Update syllabus with completed status only if file was successfully saved
@@ -2132,7 +2216,7 @@ class ServeCommand extends SessionCommand_1.default {
2132
2216
  console.log("BODY", body);
2133
2217
  const readmePath = `courses/${courseSlug}/exercises/${exSlug}/README${(0, creatorUtilities_1.getReadmeExtension)(lang)}`;
2134
2218
  if (body.parsed.content) {
2135
- await uploadFileToBucket(bucket, body.parsed.content, readmePath);
2219
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, body.parsed.content, readmePath);
2136
2220
  }
2137
2221
  (0, creatorSocket_1.emitToNotification)(notificationId, {
2138
2222
  status: "SUCCESS",
@@ -2148,8 +2232,7 @@ class ServeCommand extends SessionCommand_1.default {
2148
2232
  console.log("GET /");
2149
2233
  const slug = req.query.slug;
2150
2234
  if (slug) {
2151
- const file = path.resolve(__dirname, "../ui/_app/index.html");
2152
- res.sendFile(file);
2235
+ sendDraftShell(req, res);
2153
2236
  }
2154
2237
  else {
2155
2238
  res.redirect("https://learnpack.co");
@@ -2157,12 +2240,16 @@ class ServeCommand extends SessionCommand_1.default {
2157
2240
  });
2158
2241
  app.get("/preview/:slug", async (req, res) => {
2159
2242
  console.log("GET COURSE PREVIEW /");
2160
- const file = path.resolve(__dirname, "../ui/_app/index.html");
2161
- res.sendFile(file);
2243
+ sendDraftShell(req, res);
2162
2244
  });
2163
2245
  app.get("/preview/:slug/webview", async (req, res) => {
2164
- const file = path.resolve(__dirname, "../ui/_app/index.html");
2165
- res.sendFile(file);
2246
+ sendDraftShell(req, res);
2247
+ });
2248
+ // The brand card used as og:image by drafts, and as the last-resort
2249
+ // fallback by published courses without a preview of their own.
2250
+ app.get("/og-image.png", (req, res) => {
2251
+ res.set("Cache-Control", "public, max-age=86400");
2252
+ res.sendFile(path.resolve(__dirname, "../ui/static/og-image.png"));
2166
2253
  });
2167
2254
  app.get("/config", async (req, res) => {
2168
2255
  const courseSlug = req.query.slug;
@@ -2178,7 +2265,7 @@ class ServeCommand extends SessionCommand_1.default {
2178
2265
  const config = await mergeConfigPreservingGithub(bucket, courseSlug, builtConfig);
2179
2266
  res.set("X-Creator-Web", "true");
2180
2267
  res.set("Access-Control-Expose-Headers", "X-Creator-Web");
2181
- await uploadFileToBucket(bucket, JSON.stringify({ config, exercises }), `courses/${courseSlug}/.learn/config.json`);
2268
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify({ config, exercises }), `courses/${courseSlug}/.learn/config.json`);
2182
2269
  res.json({ config, exercises });
2183
2270
  }
2184
2271
  catch (error) {
@@ -2590,7 +2677,7 @@ class ServeCommand extends SessionCommand_1.default {
2590
2677
  completedAt: 0,
2591
2678
  },
2592
2679
  };
2593
- await uploadFileToBucket(bucket, JSON.stringify(Object.assign(Object.assign({}, initialSyllabus), { lessons: newLessons })), `courses/${courseSlug}/.learn/initialSyllabus.json`);
2680
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(Object.assign(Object.assign({}, initialSyllabus), { lessons: newLessons })), `courses/${courseSlug}/.learn/initialSyllabus.json`);
2594
2681
  const targetDir = `courses/${courseSlug}/exercises/${stepSlug}`;
2595
2682
  await uploadInitialReadme(bucket, stepSlug, targetDir, initialSyllabus.courseInfo);
2596
2683
  res.json({ status: "SUCCESS", message: "Exercise generati on started!" });
@@ -2633,20 +2720,20 @@ class ServeCommand extends SessionCommand_1.default {
2633
2720
  if (!learnJson.title)
2634
2721
  learnJson.title = {};
2635
2722
  learnJson.title[language] = title;
2636
- await uploadFileToBucket(bucket, JSON.stringify(learnJson), `courses/${courseSlug}/learn.json`);
2723
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(learnJson), `courses/${courseSlug}/learn.json`);
2637
2724
  const configFile = bucket.file(`courses/${courseSlug}/.learn/config.json`);
2638
2725
  const [configContent] = await configFile.download();
2639
2726
  const configJson = JSON.parse(configContent.toString());
2640
2727
  configJson.config = configJson.config || {};
2641
2728
  configJson.config.title = Object.assign(Object.assign({}, configJson.config.title), learnJson.title);
2642
- await uploadFileToBucket(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
2729
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
2643
2730
  try {
2644
2731
  const syllabusFile = bucket.file(`courses/${courseSlug}/.learn/initialSyllabus.json`);
2645
2732
  const [syllabusContent] = await syllabusFile.download();
2646
2733
  const syllabusJson = JSON.parse(syllabusContent.toString());
2647
2734
  if (syllabusJson === null || syllabusJson === void 0 ? void 0 : syllabusJson.courseInfo) {
2648
2735
  syllabusJson.courseInfo.title = title;
2649
- await uploadFileToBucket(bucket, JSON.stringify(syllabusJson), `courses/${courseSlug}/.learn/initialSyllabus.json`);
2736
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(syllabusJson), `courses/${courseSlug}/.learn/initialSyllabus.json`);
2650
2737
  }
2651
2738
  }
2652
2739
  catch (error) {
@@ -2664,9 +2751,9 @@ class ServeCommand extends SessionCommand_1.default {
2664
2751
  if ((_a = result === null || result === void 0 ? void 0 : result.parsed) === null || _a === void 0 ? void 0 : _a.title) {
2665
2752
  const translatedTitle = JSON.parse(result.parsed.title);
2666
2753
  learnJson.title = Object.assign(Object.assign(Object.assign({}, learnJson.title), translatedTitle), { [language]: title });
2667
- await uploadFileToBucket(bucket, JSON.stringify(learnJson), `courses/${courseSlug}/learn.json`);
2754
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(learnJson), `courses/${courseSlug}/learn.json`);
2668
2755
  configJson.config.title = Object.assign(Object.assign({}, configJson.config.title), learnJson.title);
2669
- await uploadFileToBucket(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
2756
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
2670
2757
  }
2671
2758
  }
2672
2759
  catch (translationError) {
@@ -2791,7 +2878,7 @@ class ServeCommand extends SessionCommand_1.default {
2791
2878
  const translatedDescription = JSON.parse(result.parsed.description);
2792
2879
  courseJson.title = translatedTitle;
2793
2880
  courseJson.description = translatedDescription;
2794
- await uploadFileToBucket(bucket, JSON.stringify(courseJson), `courses/${courseSlug}/learn.json`);
2881
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(courseJson), `courses/${courseSlug}/learn.json`);
2795
2882
  currentLanguages = Object.keys(courseJson.title);
2796
2883
  }
2797
2884
  const missingReadmeTranslations = [];
@@ -2989,37 +3076,35 @@ class ServeCommand extends SessionCommand_1.default {
2989
3076
  if (!syllabus) {
2990
3077
  return res.status(404).json({ error: "Syllabus not found" });
2991
3078
  }
2992
- const PROCESSING_TIMEOUT = 3 * 60 * 1000; // 3 minutes
2993
3079
  let modified = false;
2994
- // Collect active notifications (pending, processing, or error)
3080
+ // Collect active notifications (pending, processing, partial or error)
2995
3081
  const notifications = [];
2996
3082
  for (const lesson of syllabus.lessons) {
2997
3083
  if (lesson.syncNotifications && lesson.syncNotifications.length > 0) {
2998
3084
  for (const notification of lesson.syncNotifications) {
2999
3085
  // Check for timeout in processing notifications
3000
- if (notification.status === "processing") {
3001
- // Use processingLastUpdate if available, otherwise fallback to updatedAt
3002
- const processingLastUpdateTime = notification.processingLastUpdate || notification.updatedAt;
3003
- const timeSinceProcessingStarted = Date.now() - processingLastUpdateTime;
3004
- if (timeSinceProcessingStarted > PROCESSING_TIMEOUT) {
3005
- notification.status = "error";
3006
- notification.error = {
3007
- message: "Synchronization timeout - process took too long",
3008
- code: "PROCESSING_TIMEOUT",
3009
- timestamp: Date.now(),
3010
- };
3011
- modified = true;
3012
- // Emit error event
3013
- (0, creatorSocket_1.emitToCourse)(courseSlug, "sync-notification-error", {
3014
- exerciseSlug: (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title),
3015
- notificationId: notification.id,
3016
- error: "Processing timeout",
3017
- });
3018
- }
3086
+ if (notification.status === "processing" &&
3087
+ isSyncProcessingStale(notification)) {
3088
+ notification.status = "error";
3089
+ notification.error = {
3090
+ message: "Synchronization timeout - process took too long",
3091
+ code: "PROCESSING_TIMEOUT",
3092
+ timestamp: Date.now(),
3093
+ };
3094
+ modified = true;
3095
+ // Emit error event
3096
+ (0, creatorSocket_1.emitToCourse)(courseSlug, "sync-notification-error", {
3097
+ exerciseSlug: (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title),
3098
+ notificationId: notification.id,
3099
+ error: "Processing timeout",
3100
+ });
3019
3101
  }
3020
- // Include active notifications (pending, processing, or error)
3102
+ // Include active notifications (pending, processing, partial or
3103
+ // error): a partial sync is still actionable, the user can retry
3104
+ // the languages that failed.
3021
3105
  if (notification.status === "pending" ||
3022
3106
  notification.status === "processing" ||
3107
+ notification.status === "partial" ||
3023
3108
  notification.status === "error") {
3024
3109
  notifications.push(Object.assign(Object.assign({}, notification), { lessonSlug: (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title), lessonTitle: lesson.title }));
3025
3110
  }
@@ -3083,7 +3168,7 @@ class ServeCommand extends SessionCommand_1.default {
3083
3168
  });
3084
3169
  // Accept sync notification and start synchronization
3085
3170
  app.post("/courses/:courseSlug/lessons/:exerciseSlug/sync-notification/:notificationId/accept", express.json(), async (req, res) => {
3086
- var _a, _b;
3171
+ var _a, _b, _c;
3087
3172
  console.log("POST /courses/:courseSlug/lessons/:exerciseSlug/sync-notification/:notificationId/accept");
3088
3173
  const { courseSlug, exerciseSlug, notificationId } = req.params;
3089
3174
  const rigoToken = req.header("x-rigo-token");
@@ -3111,9 +3196,21 @@ class ServeCommand extends SessionCommand_1.default {
3111
3196
  if (!notification) {
3112
3197
  return res.status(404).json({ error: "Notification not found" });
3113
3198
  }
3114
- if (notification.status !== "pending") {
3199
+ // A synchronization can be accepted again unless it is actively
3200
+ // running: after a failure or a partial result this same
3201
+ // notification is what the Retry button acts on.
3202
+ if (notification.status === "processing" &&
3203
+ !isSyncProcessingStale(notification)) {
3204
+ return res.status(409).json({
3205
+ error: "Notification is already being synchronized",
3206
+ code: "ALREADY_PROCESSING",
3207
+ currentStatus: notification.status,
3208
+ });
3209
+ }
3210
+ if (notification.status === "completed") {
3115
3211
  return res.status(400).json({
3116
- error: "Notification is not pending",
3212
+ error: "Notification is already synchronized",
3213
+ code: "ALREADY_COMPLETED",
3117
3214
  currentStatus: notification.status,
3118
3215
  });
3119
3216
  }
@@ -3131,9 +3228,13 @@ class ServeCommand extends SessionCommand_1.default {
3131
3228
  code: "SOURCE_README_ERROR",
3132
3229
  });
3133
3230
  }
3134
- // Determine target languages
3231
+ // Determine target languages. On a retry only what is still missing
3232
+ // is translated again, so the languages that already succeeded are
3233
+ // neither paid for twice nor overwritten.
3234
+ const alreadyCompleted = ((_b = notification.syncProgress) === null || _b === void 0 ? void 0 : _b.completedLanguages) || [];
3135
3235
  const availableLanguages = Object.keys(lesson.translations || {});
3136
- const targetLanguages = availableLanguages.filter(lang => lang !== notification.sourceLanguage);
3236
+ const targetLanguages = availableLanguages.filter(lang => lang !== notification.sourceLanguage &&
3237
+ !alreadyCompleted.includes(lang));
3137
3238
  if (targetLanguages.length === 0) {
3138
3239
  return res.status(400).json({
3139
3240
  error: "No target languages found",
@@ -3142,10 +3243,14 @@ class ServeCommand extends SessionCommand_1.default {
3142
3243
  }
3143
3244
  // Remove all other notifications for this lesson
3144
3245
  lesson.syncNotifications =
3145
- ((_b = lesson.syncNotifications) === null || _b === void 0 ? void 0 : _b.filter(n => n.id === notificationId)) ||
3246
+ ((_c = lesson.syncNotifications) === null || _c === void 0 ? void 0 : _c.filter(n => n.id === notificationId)) ||
3146
3247
  [];
3248
+ // Start from a clean slate so a previous failure does not leak into
3249
+ // this run.
3147
3250
  notification.status = "processing";
3251
+ notification.updatedAt = Date.now();
3148
3252
  notification.processingLastUpdate = Date.now();
3253
+ delete notification.error;
3149
3254
  notification.syncProgress = {
3150
3255
  totalLanguages: targetLanguages.length,
3151
3256
  completedLanguages: [], // Array of completed language codes
@@ -3391,7 +3496,7 @@ class ServeCommand extends SessionCommand_1.default {
3391
3496
  const removedCount = initialCount - kept.length;
3392
3497
  exercise.files = kept;
3393
3498
  if (removedCount > 0) {
3394
- await uploadFileToBucket(bucket, JSON.stringify({ config: configJson.config, exercises }), `courses/${courseSlug}/.learn/config.json`);
3499
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify({ config: configJson.config, exercises }), `courses/${courseSlug}/.learn/config.json`);
3395
3500
  }
3396
3501
  if (movedCount > 0 || removedCount > 0) {
3397
3502
  console.log(`[sync-lesson-files] ${lessonSlug}: ${movedCount} moved from subdir, ${removedCount} removed from config`);
@@ -3427,7 +3532,7 @@ class ServeCommand extends SessionCommand_1.default {
3427
3532
  const { exercises } = await (0, configBuilder_1.buildConfig)(bucket, courseSlug);
3428
3533
  const { fixedSidebar, valid } = await (0, sidebarGenerator_1.checkAndFixSidebarPure)(sidebar, exercises, rigoToken);
3429
3534
  if (fixedSidebar) {
3430
- await uploadFileToBucket(bucket, JSON.stringify(fixedSidebar), `courses/${courseSlug}/.learn/sidebar.json`);
3535
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(fixedSidebar), `courses/${courseSlug}/.learn/sidebar.json`);
3431
3536
  return res.json(fixedSidebar);
3432
3537
  }
3433
3538
  }
@@ -3437,12 +3542,12 @@ class ServeCommand extends SessionCommand_1.default {
3437
3542
  if (error.code === 404) {
3438
3543
  const { exercises } = await (0, configBuilder_1.buildConfig)(bucket, courseSlug);
3439
3544
  const exerciseSlugsArray = exercises.map(exercise => exercise.slug);
3440
- const sidebar = await createInitialSidebar(exerciseSlugsArray);
3441
- await uploadFileToBucket(bucket, JSON.stringify(sidebar), `courses/${courseSlug}/.learn/sidebar.json`);
3545
+ const sidebar = await (0, sidebar_1.createInitialSidebar)(exerciseSlugsArray);
3546
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(sidebar), `courses/${courseSlug}/.learn/sidebar.json`);
3442
3547
  if (rigoToken) {
3443
3548
  const { fixedSidebar } = await (0, sidebarGenerator_1.checkAndFixSidebarPure)(sidebar, exercises, rigoToken);
3444
3549
  if (fixedSidebar) {
3445
- await uploadFileToBucket(bucket, JSON.stringify(fixedSidebar), `courses/${courseSlug}/.learn/sidebar.json`);
3550
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(fixedSidebar), `courses/${courseSlug}/.learn/sidebar.json`);
3446
3551
  }
3447
3552
  return res.status(200).json(fixedSidebar);
3448
3553
  }
@@ -3486,7 +3591,7 @@ class ServeCommand extends SessionCommand_1.default {
3486
3591
  }
3487
3592
  const courseSlug = syllabus.courseInfo.slug;
3488
3593
  const tutorialDir = `courses/${courseSlug}`;
3489
- const learnJson = (0, exports.createLearnJson)(syllabus.courseInfo);
3594
+ const learnJson = (0, learnJson_1.createLearnJson)(syllabus.courseInfo);
3490
3595
  try {
3491
3596
  await api_1.default.createRigoPackage(rigoToken, courseSlug, learnJson);
3492
3597
  }
@@ -3495,7 +3600,7 @@ class ServeCommand extends SessionCommand_1.default {
3495
3600
  return res.status(400).json({ error: "Failed to create Rigo package" });
3496
3601
  }
3497
3602
  try {
3498
- await uploadFileToBucket(bucket, JSON.stringify(learnJson), `${tutorialDir}/learn.json`);
3603
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(learnJson), `${tutorialDir}/learn.json`);
3499
3604
  }
3500
3605
  catch (error) {
3501
3606
  console.error("Failed to upload learn.json:", error);
@@ -3509,15 +3614,15 @@ class ServeCommand extends SessionCommand_1.default {
3509
3614
  // eslint-disable-next-line no-await-in-loop
3510
3615
  await uploadInitialReadme(bucket, exSlug, targetDir, syllabus.courseInfo);
3511
3616
  }
3512
- const sidebar = await createInitialSidebar(syllabus.lessons.map(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title)), syllabus.courseInfo.language);
3617
+ const sidebar = await (0, sidebar_1.createInitialSidebar)(syllabus.lessons.map(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title)), syllabus.courseInfo.language);
3513
3618
  const initialSyllabus = Object.assign(Object.assign({}, syllabus), { lessons: syllabus.lessons.map((lesson, index) => {
3514
3619
  if (index < 1) {
3515
3620
  return Object.assign(Object.assign({}, lesson), { generated: false, status: "GENERATING" });
3516
3621
  }
3517
3622
  return Object.assign(Object.assign({}, lesson), { generated: false, status: "PENDING" });
3518
3623
  }) });
3519
- await uploadFileToBucket(bucket, JSON.stringify(initialSyllabus), `${tutorialDir}/.learn/initialSyllabus.json`);
3520
- await uploadFileToBucket(bucket, JSON.stringify(sidebar), `${tutorialDir}/.learn/sidebar.json`);
3624
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(initialSyllabus), `${tutorialDir}/.learn/initialSyllabus.json`);
3625
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(sidebar), `${tutorialDir}/.learn/sidebar.json`);
3521
3626
  const firstLesson = syllabus.lessons[0];
3522
3627
  const lastResult = "---";
3523
3628
  // Use new two-phase generation workflow
@@ -3712,13 +3817,13 @@ class ServeCommand extends SessionCommand_1.default {
3712
3817
  title = config.title[availableLangs[0]];
3713
3818
  selectedLang = availableLangs[0];
3714
3819
  }
3820
+ const previewUrl = await resolveCoursePreviewUrl(bucket, slug, config.preview);
3715
3821
  const idxTpl = fs.readFileSync(path.join(uiSrc, "index.html"), "utf-8");
3716
3822
  const idxHtml = idxTpl
3717
3823
  .replace(/{{title}}/g, title)
3718
3824
  .replace(/<title>.*<\/title>/, `<title>${title}</title>`)
3719
3825
  .replace(/{{description}}/g, config.description[selectedLang])
3720
- .replace(/{{preview}}/g, fixPreviewUrl(slug, "") ||
3721
- "https://raw.githubusercontent.com/learnpack/ide/master/public/learnpack.svg")
3826
+ .replace(/{{preview}}/g, previewUrl)
3722
3827
  .replace(/{{slug}}/g, slug)
3723
3828
  .replace(/{{duration}}/g, (0, misc_1.minutesToISO8601Duration)(config.duration));
3724
3829
  fs.writeFileSync(path.join(buildRoot, "index.html"), idxHtml);
@@ -3748,9 +3853,9 @@ class ServeCommand extends SessionCommand_1.default {
3748
3853
  // 8) Crear el config.json en buildRoot con lo que retorna buildConfig
3749
3854
  const fullConfig = { config, exercises };
3750
3855
  fullConfig.config.slug = slug;
3751
- fullConfig.config.preview =
3752
- fixPreviewUrl(slug, "") ||
3753
- "https://raw.githubusercontent.com/learnpack/ide/master/public/learnpack.svg";
3856
+ // Same URL the meta tags got: this used to overwrite the real preview
3857
+ // of every published package with the unusable SVG fallback.
3858
+ fullConfig.config.preview = previewUrl;
3754
3859
  fs.writeFileSync(path.join(buildRoot, "config.json"), JSON.stringify(fullConfig, null, 2), "utf-8");
3755
3860
  // 9) Empaquetar en ZIP (solo contenido de buildRoot)
3756
3861
  const zipName = `${slug}.zip`;
@@ -4220,19 +4325,19 @@ class ServeCommand extends SessionCommand_1.default {
4220
4325
  const [learnJsonContent] = await learnJsonFile.download();
4221
4326
  const learnJson = JSON.parse(learnJsonContent.toString());
4222
4327
  learnJson.slug = newSlug;
4223
- await uploadFileToBucket(bucket, JSON.stringify(learnJson), `${newPrefix}learn.json`);
4328
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(learnJson), `${newPrefix}learn.json`);
4224
4329
  // Update initialSyllabus.json with new slug
4225
4330
  const syllabusFile = bucket.file(`${newPrefix}.learn/initialSyllabus.json`);
4226
4331
  const [syllabusContent] = await syllabusFile.download();
4227
4332
  const syllabus = JSON.parse(syllabusContent.toString());
4228
4333
  syllabus.courseInfo.slug = newSlug;
4229
- await uploadFileToBucket(bucket, JSON.stringify(syllabus), `${newPrefix}.learn/initialSyllabus.json`);
4334
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(syllabus), `${newPrefix}.learn/initialSyllabus.json`);
4230
4335
  // Update config.json with new slug
4231
4336
  const configFile = bucket.file(`${newPrefix}.learn/config.json`);
4232
4337
  const [configContent] = await configFile.download();
4233
4338
  const config = JSON.parse(configContent.toString());
4234
4339
  config.config.slug = newSlug;
4235
- await uploadFileToBucket(bucket, JSON.stringify(config), `${newPrefix}.learn/config.json`);
4340
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(config), `${newPrefix}.learn/config.json`);
4236
4341
  // Update Rigobot package slug
4237
4342
  const updateUrl = `${api_1.RIGOBOT_HOST}/v1/learnpack/package/${currentSlug}/`;
4238
4343
  await axios_1.default.put(updateUrl, { new_slug: newSlug }, {
@@ -4394,7 +4499,7 @@ class ServeCommand extends SessionCommand_1.default {
4394
4499
  defaultBranch,
4395
4500
  lastSyncSHA: commitRes.sha,
4396
4501
  };
4397
- await uploadFileToBucket(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
4502
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
4398
4503
  return res.json({
4399
4504
  success: true,
4400
4505
  repository,
@@ -4568,7 +4673,7 @@ class ServeCommand extends SessionCommand_1.default {
4568
4673
  compareRes.files.length === 0) {
4569
4674
  configJson.config = configJson.config || {};
4570
4675
  configJson.config.github = Object.assign(Object.assign({}, configJson.config.github), { lastSyncSHA: targetSHA });
4571
- await uploadFileToBucket(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
4676
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
4572
4677
  return res.json({
4573
4678
  success: true,
4574
4679
  syncedLessons: [],
@@ -4700,12 +4805,12 @@ class ServeCommand extends SessionCommand_1.default {
4700
4805
  const buffer = buffer_1.Buffer.from(content, "base64");
4701
4806
  if (item.isAsset || isImageFile(item.relativePath)) {
4702
4807
  // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
4703
- await uploadBinaryToBucket(bucket, buffer, item.bucketPath, "application/octet-stream");
4808
+ await (0, bucketIo_1.uploadBinaryToBucket)(bucket, buffer, item.bucketPath, "application/octet-stream");
4704
4809
  }
4705
4810
  else {
4706
4811
  const text = buffer.toString("utf8");
4707
4812
  // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
4708
- await uploadFileToBucket(bucket, text, item.bucketPath);
4813
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, text, item.bucketPath);
4709
4814
  }
4710
4815
  if (item.isAsset) {
4711
4816
  syncedAssetsUploaded++;
@@ -4718,7 +4823,7 @@ class ServeCommand extends SessionCommand_1.default {
4718
4823
  }
4719
4824
  configJson.config = configJson.config || {};
4720
4825
  configJson.config.github = Object.assign(Object.assign({}, configJson.config.github), { lastSyncSHA: targetSHA });
4721
- await uploadFileToBucket(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
4826
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
4722
4827
  return res.json({
4723
4828
  success: true,
4724
4829
  syncedLessons: [...syncedLessonSlugs],
@@ -4787,7 +4892,7 @@ class ServeCommand extends SessionCommand_1.default {
4787
4892
  });
4788
4893
  configJson.config = configJson.config || {};
4789
4894
  configJson.config.github = Object.assign(Object.assign({}, configJson.config.github), { lastSyncSHA: commitRes.sha });
4790
- await uploadFileToBucket(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
4895
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
4791
4896
  return res.json({
4792
4897
  success: true,
4793
4898
  repository,
@@ -4862,7 +4967,7 @@ class ServeCommand extends SessionCommand_1.default {
4862
4967
  }
4863
4968
  try {
4864
4969
  const memoryBankPath = `courses/${courseSlug}/.learn/memory_bank.txt`;
4865
- await uploadFileToBucket(bucket, content, memoryBankPath);
4970
+ await (0, bucketIo_1.uploadFileToBucket)(bucket, content, memoryBankPath);
4866
4971
  console.log(`✅ Memory bank updated for course: ${courseSlug}`);
4867
4972
  res.json({ message: "Memory bank updated successfully" });
4868
4973
  }