@learnpack/learnpack 5.0.357 → 5.0.359

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.
@@ -55,9 +55,7 @@ import api, {
55
55
  RIGOBOT_HOST,
56
56
  RIGOBOT_REALTIME_HOST,
57
57
  listUserAcademies,
58
- doesAssetExists,
59
58
  type AssetSyncError,
60
- resolveLearnpackPackageId,
61
59
  } from "../utils/api"
62
60
  import {
63
61
  createUploadMiddleware,
@@ -66,7 +64,12 @@ import {
66
64
  import { buildConfig } from "../utils/configBuilder"
67
65
  import { checkReadability, slugify } from "../utils/creatorUtilities"
68
66
  import { checkAndFixSidebarPure } from "../utils/sidebarGenerator"
69
- import { handleAssetCreation } from "./publish"
67
+ import {
68
+ determineAcademyMode,
69
+ hasPublishedAsset,
70
+ resolvePackageAssets,
71
+ } from "../utils/assetResolver"
72
+ import { syncPackageAssets } from "../utils/assetSync"
70
73
  import {
71
74
  FormState,
72
75
  Lesson,
@@ -81,7 +84,16 @@ import {
81
84
  } from "../utils/coursePackage/bucketIo"
82
85
  import { createLearnJson } from "../utils/coursePackage/learnJson"
83
86
  import { createInitialSidebar } from "../utils/coursePackage/sidebar"
84
- import { generateAndPersistPackageManifest } from "../utils/packageManifest"
87
+ import {
88
+ generateAndPersistPackageManifest,
89
+ resolvePreviewUrl,
90
+ } from "../utils/packageManifest"
91
+ import {
92
+ DRAFT_PREVIEW_DESCRIPTION,
93
+ DRAFT_PREVIEW_TITLE,
94
+ getFallbackPreviewUrl,
95
+ getPublicHost,
96
+ } from "../utils/branding"
85
97
  import { requireGcsBucketName } from "../utils/gcsBucketName"
86
98
  import {
87
99
  createGcsSyllabusSyncStorage,
@@ -529,36 +541,6 @@ const cleanFormStateForSyllabus = (formState: FormState) => {
529
541
  }
530
542
  }
531
543
 
532
- const getLocalizedValue = (
533
- translations: Record<string, any> | undefined,
534
- lang: string,
535
- fallbackLangs: string[] = ["en", "us"]
536
- ): string => {
537
- if (!translations || typeof translations !== "object") return ""
538
-
539
- const direct = translations[lang]
540
- if (typeof direct === "string" && direct.trim().length > 0) return direct
541
-
542
- for (const fb of fallbackLangs) {
543
- const v = translations[fb]
544
- if (typeof v === "string" && v.trim().length > 0) return v
545
- }
546
-
547
- const firstKey = Object.keys(translations)[0]
548
- const first = firstKey ? translations[firstKey] : ""
549
- return typeof first === "string" ? first : ""
550
- }
551
-
552
- function assetSyncErrorDetail(err: any): string {
553
- if (typeof err?.detail === "string") return err.detail
554
- if (typeof err?.message === "string") return err.message
555
- try {
556
- return JSON.stringify(err)
557
- } catch {
558
- return String(err)
559
- }
560
- }
561
-
562
544
  const createMultiLangAsset = async (
563
545
  bucket: Bucket,
564
546
  rigoToken: string,
@@ -568,85 +550,38 @@ const createMultiLangAsset = async (
568
550
  deployUrl: string,
569
551
  academyId?: number
570
552
  ): Promise<{ errors: AssetSyncError[] }> => {
571
- const learnpackId = await resolveLearnpackPackageId(rigoToken, courseSlug)
572
- if (learnpackId === null) {
573
- return {
574
- errors: [
575
- {
576
- kind: "package_error",
577
- error: {
578
- detail:
579
- "Could not resolve Learnpack package id; assets not synced to Breathecode.",
580
- },
581
- },
582
- ],
583
- }
584
- }
585
-
586
- const availableLangs = Object.keys(courseJson.title)
587
- console.log("AVAILABLE LANGUAGES to upload asset", availableLangs)
588
-
589
- const all_translations: string[] = []
590
- const errors: AssetSyncError[] = []
591
-
592
- for (const lang of availableLangs) {
593
- // eslint-disable-next-line no-await-in-loop
594
- const indexReadme = await bucket.file(
553
+ const readReadme = async (lang: string): Promise<string> => {
554
+ const indexReadme = bucket.file(
595
555
  `courses/${courseSlug}/README${getReadmeExtension(lang)}`
596
556
  )
597
- let indexReadmeString = ""
598
557
  try {
599
- // eslint-disable-next-line no-await-in-loop
600
- const [indexReadmeContent] = await indexReadme.download()
601
- indexReadmeString = indexReadmeContent.toString()
558
+ const [content] = await indexReadme.download()
559
+ return content.toString()
602
560
  } catch (error) {
603
561
  console.error("Error downloading index readme", error)
604
562
  // TODO: Trigger generation of the index readme
605
- indexReadmeString = ""
563
+ return ""
606
564
  }
565
+ }
607
566
 
608
- const b64IndexReadme = Buffer.from(indexReadmeString).toString("base64")
609
-
610
- try {
611
- // eslint-disable-next-line no-await-in-loop
612
- const asset = await handleAssetCreation(
613
- { token: bcToken, rigobotToken: rigoToken.trim() },
614
- courseJson,
615
- lang,
616
- deployUrl,
617
- b64IndexReadme,
618
- academyId,
619
- learnpackId,
620
- undefined,
621
- all_translations
622
- )
623
-
624
- if (!asset) {
625
- errors.push({
626
- kind: "lang_error",
627
- lang,
628
- error: { detail: "Failed to create asset" },
629
- })
630
- console.log("No se pudo crear el asset, saltando idioma", lang)
631
- continue
632
- }
567
+ const result = await syncPackageAssets({
568
+ bcToken,
569
+ rigoToken,
570
+ packageSlug: courseSlug,
571
+ learnJson: courseJson,
572
+ deployUrl,
573
+ academyId,
574
+ readReadme,
575
+ })
633
576
 
634
- all_translations.push(asset.slug)
635
- } catch (error) {
636
- const errorData =
637
- error && typeof error === "object" && "response" in error ?
638
- (error as any).response?.data || error :
639
- error
640
- errors.push({
641
- kind: "lang_error",
642
- lang,
643
- error: { detail: assetSyncErrorDetail(errorData) },
644
- })
645
- console.error(`Error creating asset for language ${lang}:`, error)
646
- }
577
+ for (const warning of result.warnings) console.warn("[assets]", warning)
578
+ for (const synced of result.synced) {
579
+ console.log(
580
+ `[assets] ${synced.action} "${synced.lang}": #${synced.asset.id} ${synced.asset.slug}`
581
+ )
647
582
  }
648
583
 
649
- return { errors }
584
+ return { errors: result.errors }
650
585
  }
651
586
 
652
587
  const lessonCleaner = (lesson: Lesson) => {
@@ -1181,17 +1116,43 @@ async function continueWithNextLesson(
1181
1116
  await saveSyllabus(courseSlug, newSyllabus, bucket)
1182
1117
  }
1183
1118
 
1184
- const fixPreviewUrl = (slug: string, previewUrl: string) => {
1185
- if (!previewUrl) {
1186
- return null
1119
+ /**
1120
+ * The `og:image` of a published course.
1121
+ *
1122
+ * A published package is served from `{slug}.learn-pack.com`, so the course's
1123
+ * own `preview.png` is the right card whenever it exists. This used to go
1124
+ * through a helper that returned null for an empty preview, and both call sites
1125
+ * passed an empty string — so every published course, preview or not, fell back
1126
+ * to a shared SVG that no unfurl crawler can render.
1127
+ */
1128
+ const resolveCoursePreviewUrl = async (
1129
+ bucket: Bucket,
1130
+ slug: string,
1131
+ configuredPreview?: string
1132
+ ): Promise<string> => {
1133
+ // `config` is parsed from a config.json in the bucket, so its declared types
1134
+ // are an assertion rather than a guarantee: an old or hand-edited course can
1135
+ // carry anything here, and a throw would fail the whole publication.
1136
+ if (
1137
+ typeof configuredPreview === "string" &&
1138
+ configuredPreview.startsWith("http")
1139
+ ) {
1140
+ return configuredPreview
1187
1141
  }
1188
1142
 
1189
- if (previewUrl.startsWith("http")) {
1190
- return previewUrl
1143
+ try {
1144
+ const [exists] = await bucket.file(`courses/${slug}/preview.png`).exists()
1145
+ if (exists) {
1146
+ return resolvePreviewUrl(slug)
1147
+ }
1148
+ } catch (error) {
1149
+ console.error(
1150
+ `WARNING: could not check preview.png for ${slug}, using the brand card`,
1151
+ error
1152
+ )
1191
1153
  }
1192
1154
 
1193
- const expectedUrl = `https://${slug}.learn-pack.com/preview.png`
1194
- return expectedUrl
1155
+ return getFallbackPreviewUrl()
1195
1156
  }
1196
1157
 
1197
1158
  const getTitleFromHTML = (html: string) => {
@@ -1200,6 +1161,73 @@ const getTitleFromHTML = (html: string) => {
1200
1161
  return titleMatch ? titleMatch[1] : null
1201
1162
  }
1202
1163
 
1164
+ const escapeHtmlAttribute = (value: string) =>
1165
+ value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;")
1166
+
1167
+ /**
1168
+ * The IDE shell served to draft courses, with its meta tags filled in.
1169
+ *
1170
+ * The shell ships with `{{title}}`, `{{description}}` and `{{preview}}`
1171
+ * placeholders that only get substituted when a course is published. Drafts were
1172
+ * served the raw template, so a link shared in Slack or WhatsApp unfurled to a
1173
+ * literal `{{title}}` / `{{description}}`.
1174
+ */
1175
+ let draftShellTemplate: string | null = null
1176
+
1177
+ const loadDraftShellTemplate = (): string => {
1178
+ const file = path.resolve(__dirname, "../ui/_app/index.html")
1179
+
1180
+ draftShellTemplate = fs
1181
+ .readFileSync(file, "utf-8")
1182
+ // A draft is not a real Course, so it must not emit Course structured data.
1183
+ // The match is anchored to the typed opening tag and non-greedy on purpose:
1184
+ // the shell also carries an inline <script> that loads the app bundle from
1185
+ // CloudFront, and a looser pattern would swallow it and blank the page.
1186
+ .replace(
1187
+ /[^\S\n]*<script type="application\/ld\+json">[\S\s]*?<\/script>\n?/,
1188
+ ""
1189
+ )
1190
+
1191
+ return draftShellTemplate
1192
+ }
1193
+
1194
+ const renderDraftShell = (req: express.Request): string => {
1195
+ const template = draftShellTemplate || loadDraftShellTemplate()
1196
+
1197
+ // Only the path: a preview URL can carry tokens in its query string, and
1198
+ // og:url ends up in whatever crawler cache unfurls the link.
1199
+ const canonicalUrl = escapeHtmlAttribute(`${getPublicHost()}${req.path}`)
1200
+
1201
+ return (
1202
+ template
1203
+ .replace(/<title>.*<\/title>/, `<title>${DRAFT_PREVIEW_TITLE}</title>`)
1204
+ .replace(/{{title}}/g, DRAFT_PREVIEW_TITLE)
1205
+ .replace(/{{description}}/g, DRAFT_PREVIEW_DESCRIPTION)
1206
+ .replace(/{{preview}}/g, getFallbackPreviewUrl())
1207
+ .replace(
1208
+ "</head>",
1209
+ ` <meta property="og:url" content="${canonicalUrl}" />\n` +
1210
+ ` <meta name="robots" content="noindex, nofollow, noarchive" />\n </head>`
1211
+ )
1212
+ // Safety net for placeholders a future shell may add: shipping an empty
1213
+ // value to a crawler beats shipping "{{whatever}}".
1214
+ .replace(/{{\w+}}/g, "")
1215
+ )
1216
+ }
1217
+
1218
+ /**
1219
+ * Drafts are unlisted work in progress. `noindex` goes out as both a meta tag
1220
+ * and a header so it reaches crawlers that skip the markup; a `robots.txt`
1221
+ * `Disallow` would be the wrong tool, since it blocks the fetch itself and
1222
+ * would stop Slack and X from unfurling the link at all.
1223
+ */
1224
+ const sendDraftShell = (req: express.Request, res: express.Response) => {
1225
+ res
1226
+ .type("html")
1227
+ .set("X-Robots-Tag", "noindex, nofollow")
1228
+ .send(renderDraftShell(req))
1229
+ }
1230
+
1203
1231
  /** How often the notification is touched while a translation is running. */
1204
1232
  const SYNC_HEARTBEAT_INTERVAL_MS = 60 * 1000
1205
1233
 
@@ -1645,6 +1673,11 @@ class ServeCommand extends SessionCommand {
1645
1673
  const localAppPath = path.resolve(__dirname, "../ui/_app")
1646
1674
  // app.use(express.static(localAppPath))
1647
1675
 
1676
+ // Warm the draft shell now that _app is on disk. Doing it here rather than
1677
+ // on first request means a corrupt tarball fails at boot, with a stack
1678
+ // trace, instead of on some user's preview link.
1679
+ loadDraftShellTemplate()
1680
+
1648
1681
  app.post("/upload", async (req, res) => {
1649
1682
  const { content, destination } = req.body
1650
1683
  // console.log("UPLOAD", content, destination)
@@ -3130,8 +3163,7 @@ class ServeCommand extends SessionCommand {
3130
3163
  const slug = req.query.slug
3131
3164
 
3132
3165
  if (slug) {
3133
- const file = path.resolve(__dirname, "../ui/_app/index.html")
3134
- res.sendFile(file)
3166
+ sendDraftShell(req, res)
3135
3167
  } else {
3136
3168
  res.redirect("https://learnpack.co")
3137
3169
  }
@@ -3140,13 +3172,18 @@ class ServeCommand extends SessionCommand {
3140
3172
  app.get("/preview/:slug", async (req, res) => {
3141
3173
  console.log("GET COURSE PREVIEW /")
3142
3174
 
3143
- const file = path.resolve(__dirname, "../ui/_app/index.html")
3144
- res.sendFile(file)
3175
+ sendDraftShell(req, res)
3145
3176
  })
3146
3177
 
3147
3178
  app.get("/preview/:slug/webview", async (req, res) => {
3148
- const file = path.resolve(__dirname, "../ui/_app/index.html")
3149
- res.sendFile(file)
3179
+ sendDraftShell(req, res)
3180
+ })
3181
+
3182
+ // The brand card used as og:image by drafts, and as the last-resort
3183
+ // fallback by published courses without a preview of their own.
3184
+ app.get("/og-image.png", (req, res) => {
3185
+ res.set("Cache-Control", "public, max-age=86400")
3186
+ res.sendFile(path.resolve(__dirname, "../ui/static/og-image.png"))
3150
3187
  })
3151
3188
 
3152
3189
  app.get("/config", async (req, res) => {
@@ -5281,6 +5318,10 @@ class ServeCommand extends SessionCommand {
5281
5318
  })
5282
5319
  }
5283
5320
 
5321
+ // Older IDEs only send the Breathecode token; without the Rigobot
5322
+ // token the lookup falls back to the title-derived slugs.
5323
+ const rigoToken = req.header("x-rigo-token")
5324
+
5284
5325
  const configFile = await bucket.file(
5285
5326
  `courses/${slug}/.learn/config.json`
5286
5327
  )
@@ -5288,56 +5329,51 @@ class ServeCommand extends SessionCommand {
5288
5329
  const configJson = JSON.parse(configContent.toString())
5289
5330
  const { config } = configJson
5290
5331
 
5291
- const availableLangs = Object.keys(config.title || {})
5292
- let isPublished = false
5293
-
5294
- // Collect academy ids from all existing assets
5295
- const foundAcademyIds: number[] = []
5296
-
5297
- const slugsToCheck = availableLangs
5298
- .map(lang => {
5299
- const assetTitle = getLocalizedValue(config.title, lang)
5300
- if (!assetTitle) return null
5301
- const assetSlug = slugify(assetTitle).slice(0, 47)
5302
- return `${assetSlug}-${lang}`
5303
- })
5304
- .filter((slug): slug is string => slug !== null)
5332
+ const resolution = await resolvePackageAssets({
5333
+ bcToken,
5334
+ rigoToken,
5335
+ packageSlug: slug,
5336
+ learnJson: config,
5337
+ })
5338
+ for (const warning of resolution.warnings) {
5339
+ console.warn("[package-academy]", warning)
5340
+ }
5305
5341
 
5306
- const assetChecks = await Promise.all(
5307
- slugsToCheck.map(slug => doesAssetExists(bcToken, slug))
5308
- )
5342
+ const isPublished = hasPublishedAsset(resolution)
5343
+ const academyMode = determineAcademyMode(resolution)
5344
+ const warnings = [...resolution.warnings]
5345
+ if (resolution.packageState === "unknown") {
5346
+ warnings.push(
5347
+ `${resolution.packageError}; existing assets could not be checked.`
5348
+ )
5349
+ }
5309
5350
 
5310
- for (const { exists, academyId: existingAcademyId } of assetChecks) {
5311
- if (exists) {
5312
- isPublished = true
5313
- if (existingAcademyId !== undefined) {
5314
- foundAcademyIds.push(existingAcademyId)
5315
- }
5351
+ for (const [lang, entry] of resolution.byLang) {
5352
+ if (entry.status === "unknown") {
5353
+ warnings.push(`"${lang}": ${entry.error}`)
5354
+ } else if (entry.status === "ambiguous") {
5355
+ warnings.push(
5356
+ `"${lang}": ${
5357
+ entry.candidates.length
5358
+ } assets found (${entry.candidates
5359
+ .map(c => `#${c.id} ${c.slug}`)
5360
+ .join(", ")})`
5361
+ )
5316
5362
  }
5317
5363
  }
5318
5364
 
5319
- const uniqueAcademies = [...new Set(foundAcademyIds)]
5320
-
5321
- type AcademyMode = "select" | "locked" | "conflict";
5322
- let mode: AcademyMode
5323
- let lockedAcademyId: number | undefined
5324
- let conflictAcademies: number[] | undefined
5325
-
5326
- if (uniqueAcademies.length === 0) {
5327
- mode = "select"
5328
- } else if (uniqueAcademies.length === 1) {
5329
- mode = "locked"
5330
- lockedAcademyId = uniqueAcademies[0]
5331
- } else {
5332
- mode = "conflict"
5333
- conflictAcademies = uniqueAcademies
5334
- }
5365
+ const mode = academyMode.type
5366
+ const lockedAcademyId =
5367
+ academyMode.type === "locked" ? academyMode.academyId : undefined
5368
+ const conflictAcademies =
5369
+ academyMode.type === "conflict" ? academyMode.academies : undefined
5335
5370
 
5336
5371
  return res.json({
5337
5372
  isPublished,
5338
5373
  mode,
5339
5374
  lockedAcademyId,
5340
5375
  conflictAcademies,
5376
+ warnings,
5341
5377
  })
5342
5378
  } catch (error) {
5343
5379
  console.error("Error fetching package academy:", error)
@@ -5406,16 +5442,18 @@ class ServeCommand extends SessionCommand {
5406
5442
  selectedLang = availableLangs[0]
5407
5443
  }
5408
5444
 
5445
+ const previewUrl = await resolveCoursePreviewUrl(
5446
+ bucket,
5447
+ slug,
5448
+ config.preview
5449
+ )
5450
+
5409
5451
  const idxTpl = fs.readFileSync(path.join(uiSrc, "index.html"), "utf-8")
5410
5452
  const idxHtml = idxTpl
5411
5453
  .replace(/{{title}}/g, title)
5412
5454
  .replace(/<title>.*<\/title>/, `<title>${title}</title>`)
5413
5455
  .replace(/{{description}}/g, config.description[selectedLang])
5414
- .replace(
5415
- /{{preview}}/g,
5416
- fixPreviewUrl(slug, "") ||
5417
- "https://raw.githubusercontent.com/learnpack/ide/master/public/learnpack.svg"
5418
- )
5456
+ .replace(/{{preview}}/g, previewUrl)
5419
5457
  .replace(/{{slug}}/g, slug)
5420
5458
  .replace(/{{duration}}/g, minutesToISO8601Duration(config.duration))
5421
5459
  fs.writeFileSync(path.join(buildRoot, "index.html"), idxHtml)
@@ -5463,9 +5501,9 @@ class ServeCommand extends SessionCommand {
5463
5501
  // 8) Crear el config.json en buildRoot con lo que retorna buildConfig
5464
5502
  const fullConfig = { config, exercises }
5465
5503
  fullConfig.config.slug = slug
5466
- fullConfig.config.preview =
5467
- fixPreviewUrl(slug, "") ||
5468
- "https://raw.githubusercontent.com/learnpack/ide/master/public/learnpack.svg"
5504
+ // Same URL the meta tags got: this used to overwrite the real preview
5505
+ // of every published package with the unusable SVG fallback.
5506
+ fullConfig.config.preview = previewUrl
5469
5507
 
5470
5508
  fs.writeFileSync(
5471
5509
  path.join(buildRoot, "config.json"),