@learnpack/learnpack 5.0.357 → 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.
@@ -19,6 +19,7 @@ const prompts = require("prompts");
19
19
  const rigoActions_1 = require("../utils/rigoActions");
20
20
  const misc_1 = require("../utils/misc");
21
21
  const creatorUtilities_1 = require("../utils/creatorUtilities");
22
+ const branding_1 = require("../utils/branding");
22
23
  const uploadZipEndpont = api_1.RIGOBOT_HOST + "/v1/learnpack/upload";
23
24
  const getAvailableLangs = (learnJson) => {
24
25
  const langs = Object.keys((learnJson === null || learnJson === void 0 ? void 0 : learnJson.title) || {});
@@ -383,8 +384,7 @@ class BuildCommand extends SessionCommand_1.default {
383
384
  "LearnPack is awesome!";
384
385
  const title = getLocalizedValue(learnJson === null || learnJson === void 0 ? void 0 : learnJson.title, selectedLang) ||
385
386
  "LearnPack: Interactive Learning as a Service";
386
- const previewUrl = learnJson.preview ||
387
- "https://raw.githubusercontent.com/learnpack/ide/refs/heads/master/public/learnpack.svg";
387
+ const previewUrl = learnJson.preview || (0, branding_1.getFallbackPreviewUrl)();
388
388
  // Replace placeholders and the <title>Old title </title> tag for a new tag with the title
389
389
  indexHtmlContent = indexHtmlContent
390
390
  .replace(/{{description}}/g, description)
@@ -38,6 +38,7 @@ const bucketIo_1 = require("../utils/coursePackage/bucketIo");
38
38
  const learnJson_1 = require("../utils/coursePackage/learnJson");
39
39
  const sidebar_1 = require("../utils/coursePackage/sidebar");
40
40
  const packageManifest_1 = require("../utils/packageManifest");
41
+ const branding_1 = require("../utils/branding");
41
42
  const gcsBucketName_1 = require("../utils/gcsBucketName");
42
43
  const syllabusSync_1 = require("../utils/syllabusSync");
43
44
  const publishJournal_1 = require("../utils/publishJournal");
@@ -733,21 +734,88 @@ async function continueWithNextLesson(courseSlug, currentExerciseIndex, rigoToke
733
734
  }) });
734
735
  await saveSyllabus(courseSlug, newSyllabus, bucket);
735
736
  }
736
- const fixPreviewUrl = (slug, previewUrl) => {
737
- if (!previewUrl) {
738
- 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;
739
753
  }
740
- if (previewUrl.startsWith("http")) {
741
- 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
+ }
742
759
  }
743
- const expectedUrl = `https://${slug}.learn-pack.com/preview.png`;
744
- 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)();
745
764
  };
746
765
  const getTitleFromHTML = (html) => {
747
766
  const titleRegex = /<title>(.*?)<\/title>/;
748
767
  const titleMatch = html.match(titleRegex);
749
768
  return titleMatch ? titleMatch[1] : null;
750
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
+ };
751
819
  /** How often the notification is touched while a translation is running. */
752
820
  const SYNC_HEARTBEAT_INTERVAL_MS = 60 * 1000;
753
821
  /**
@@ -1045,6 +1113,10 @@ class ServeCommand extends SessionCommand_1.default {
1045
1113
  await (0, file_1.decompress)(`${__dirname}/../ui/app.tar.gz`, `${__dirname}/../ui/_app/`);
1046
1114
  const localAppPath = path.resolve(__dirname, "../ui/_app");
1047
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();
1048
1120
  app.post("/upload", async (req, res) => {
1049
1121
  const { content, destination } = req.body;
1050
1122
  // console.log("UPLOAD", content, destination)
@@ -2160,8 +2232,7 @@ class ServeCommand extends SessionCommand_1.default {
2160
2232
  console.log("GET /");
2161
2233
  const slug = req.query.slug;
2162
2234
  if (slug) {
2163
- const file = path.resolve(__dirname, "../ui/_app/index.html");
2164
- res.sendFile(file);
2235
+ sendDraftShell(req, res);
2165
2236
  }
2166
2237
  else {
2167
2238
  res.redirect("https://learnpack.co");
@@ -2169,12 +2240,16 @@ class ServeCommand extends SessionCommand_1.default {
2169
2240
  });
2170
2241
  app.get("/preview/:slug", async (req, res) => {
2171
2242
  console.log("GET COURSE PREVIEW /");
2172
- const file = path.resolve(__dirname, "../ui/_app/index.html");
2173
- res.sendFile(file);
2243
+ sendDraftShell(req, res);
2174
2244
  });
2175
2245
  app.get("/preview/:slug/webview", async (req, res) => {
2176
- const file = path.resolve(__dirname, "../ui/_app/index.html");
2177
- 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"));
2178
2253
  });
2179
2254
  app.get("/config", async (req, res) => {
2180
2255
  const courseSlug = req.query.slug;
@@ -3742,13 +3817,13 @@ class ServeCommand extends SessionCommand_1.default {
3742
3817
  title = config.title[availableLangs[0]];
3743
3818
  selectedLang = availableLangs[0];
3744
3819
  }
3820
+ const previewUrl = await resolveCoursePreviewUrl(bucket, slug, config.preview);
3745
3821
  const idxTpl = fs.readFileSync(path.join(uiSrc, "index.html"), "utf-8");
3746
3822
  const idxHtml = idxTpl
3747
3823
  .replace(/{{title}}/g, title)
3748
3824
  .replace(/<title>.*<\/title>/, `<title>${title}</title>`)
3749
3825
  .replace(/{{description}}/g, config.description[selectedLang])
3750
- .replace(/{{preview}}/g, fixPreviewUrl(slug, "") ||
3751
- "https://raw.githubusercontent.com/learnpack/ide/master/public/learnpack.svg")
3826
+ .replace(/{{preview}}/g, previewUrl)
3752
3827
  .replace(/{{slug}}/g, slug)
3753
3828
  .replace(/{{duration}}/g, (0, misc_1.minutesToISO8601Duration)(config.duration));
3754
3829
  fs.writeFileSync(path.join(buildRoot, "index.html"), idxHtml);
@@ -3778,9 +3853,9 @@ class ServeCommand extends SessionCommand_1.default {
3778
3853
  // 8) Crear el config.json en buildRoot con lo que retorna buildConfig
3779
3854
  const fullConfig = { config, exercises };
3780
3855
  fullConfig.config.slug = slug;
3781
- fullConfig.config.preview =
3782
- fixPreviewUrl(slug, "") ||
3783
- "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;
3784
3859
  fs.writeFileSync(path.join(buildRoot, "config.json"), JSON.stringify(fullConfig, null, 2), "utf-8");
3785
3860
  // 9) Empaquetar en ZIP (solo contenido de buildRoot)
3786
3861
  const zipName = `${slug}.zip`;
Binary file
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Branding used by link previews (Open Graph / Twitter cards).
3
+ *
4
+ * Both the draft server and the publish pipeline need an absolute image URL for
5
+ * `og:image`, and drafts additionally need copy that says nothing about the
6
+ * course they belong to.
7
+ */
8
+ /**
9
+ * Where `learnpack serve` runs in production. It is only a fallback: the server
10
+ * itself already has the real origin in `process.env.HOST` (it uses it to build
11
+ * webhook URLs). The `publish` command, however, runs on the author's machine
12
+ * with no such variable, and still has to write an absolute `og:image` into the
13
+ * package it uploads.
14
+ */
15
+ export declare const PRODUCTION_PUBLIC_HOST = "https://app.learnpack.co";
16
+ /**
17
+ * Public origin of the app server, without a trailing slash.
18
+ *
19
+ * Only an absolute http(s) origin is accepted. `HOST` is also the conventional
20
+ * name for a bind address, so a deployment that sets it to `0.0.0.0` would
21
+ * otherwise yield a relative `og:image` — which no crawler can resolve, and
22
+ * which would be baked into every package published from that machine.
23
+ * @returns The origin to build absolute preview URLs from.
24
+ */
25
+ export declare function getPublicHost(): string;
26
+ /**
27
+ * Neutral brand card, used as `og:image` when a course has no preview of its own.
28
+ *
29
+ * It has to be a raster image: no unfurl crawler renders SVG. The previous
30
+ * fallback pointed at `learnpack.svg` on raw.githubusercontent.com, which fails
31
+ * twice over — SVG is unsupported, and that host serves it as `text/plain` with
32
+ * `X-Content-Type-Options: nosniff`.
33
+ */
34
+ export declare function getFallbackPreviewUrl(): string;
35
+ /**
36
+ * Copy for the link preview of a draft course.
37
+ *
38
+ * A draft is unlisted work in progress, so the card carries branding only: it
39
+ * must not disclose the title, the description or anything else about the
40
+ * course, and the pages that use it are served `noindex`.
41
+ */
42
+ export declare const DRAFT_PREVIEW_TITLE = "LearnPack \u2014 Draft course";
43
+ export declare const DRAFT_PREVIEW_DESCRIPTION = "This course is still a draft and is not published yet.";
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ /**
3
+ * Branding used by link previews (Open Graph / Twitter cards).
4
+ *
5
+ * Both the draft server and the publish pipeline need an absolute image URL for
6
+ * `og:image`, and drafts additionally need copy that says nothing about the
7
+ * course they belong to.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.DRAFT_PREVIEW_DESCRIPTION = exports.DRAFT_PREVIEW_TITLE = exports.PRODUCTION_PUBLIC_HOST = void 0;
11
+ exports.getPublicHost = getPublicHost;
12
+ exports.getFallbackPreviewUrl = getFallbackPreviewUrl;
13
+ /**
14
+ * Where `learnpack serve` runs in production. It is only a fallback: the server
15
+ * itself already has the real origin in `process.env.HOST` (it uses it to build
16
+ * webhook URLs). The `publish` command, however, runs on the author's machine
17
+ * with no such variable, and still has to write an absolute `og:image` into the
18
+ * package it uploads.
19
+ */
20
+ exports.PRODUCTION_PUBLIC_HOST = "https://app.learnpack.co";
21
+ /**
22
+ * Strips what `.env` files routinely smuggle in — a trailing comment, quotes,
23
+ * stray whitespace, a trailing slash — the same way `serve` sanitises `HOST`.
24
+ * @param raw - The raw environment value, if set.
25
+ * @returns The cleaned value, or an empty string.
26
+ */
27
+ function normalizeOrigin(raw) {
28
+ if (!raw) {
29
+ return "";
30
+ }
31
+ return raw
32
+ .trim()
33
+ .split(/\s+#/)[0]
34
+ .trim()
35
+ .replace(/["']/g, "")
36
+ .replace(/\/+$/, "");
37
+ }
38
+ /**
39
+ * Public origin of the app server, without a trailing slash.
40
+ *
41
+ * Only an absolute http(s) origin is accepted. `HOST` is also the conventional
42
+ * name for a bind address, so a deployment that sets it to `0.0.0.0` would
43
+ * otherwise yield a relative `og:image` — which no crawler can resolve, and
44
+ * which would be baked into every package published from that machine.
45
+ * @returns The origin to build absolute preview URLs from.
46
+ */
47
+ function getPublicHost() {
48
+ for (const raw of [process.env.PUBLIC_HOST, process.env.HOST]) {
49
+ const cleaned = normalizeOrigin(raw);
50
+ if (/^https?:\/\/\S+$/.test(cleaned)) {
51
+ return cleaned;
52
+ }
53
+ }
54
+ return exports.PRODUCTION_PUBLIC_HOST;
55
+ }
56
+ /**
57
+ * Neutral brand card, used as `og:image` when a course has no preview of its own.
58
+ *
59
+ * It has to be a raster image: no unfurl crawler renders SVG. The previous
60
+ * fallback pointed at `learnpack.svg` on raw.githubusercontent.com, which fails
61
+ * twice over — SVG is unsupported, and that host serves it as `text/plain` with
62
+ * `X-Content-Type-Options: nosniff`.
63
+ */
64
+ function getFallbackPreviewUrl() {
65
+ return `${getPublicHost()}/og-image.png`;
66
+ }
67
+ /**
68
+ * Copy for the link preview of a draft course.
69
+ *
70
+ * A draft is unlisted work in progress, so the card carries branding only: it
71
+ * must not disclose the title, the description or anything else about the
72
+ * course, and the pages that use it are served `noindex`.
73
+ */
74
+ exports.DRAFT_PREVIEW_TITLE = "LearnPack — Draft course";
75
+ exports.DRAFT_PREVIEW_DESCRIPTION = "This course is still a draft and is not published yet.";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@learnpack/learnpack",
3
3
  "description": "Seamlessly build, sell and/or take interactive & auto-graded tutorials, start learning now or build a new tutorial to your audience.",
4
- "version": "5.0.357",
4
+ "version": "5.0.358",
5
5
  "author": "Alejandro Sanchez @alesanchezr",
6
6
  "contributors": [
7
7
  {
@@ -166,7 +166,7 @@
166
166
  "audit:package-sources": "ts-node --project ./tsconfig.json ./scripts/audit-package-sources.ts",
167
167
  "backfill:package-manifest": "ts-node --project ./tsconfig.json ./scripts/backfill-package-manifest-s3.ts",
168
168
  "repair:legacy-packages": "ts-node --project ./tsconfig.json ./scripts/repair-legacy-packages.ts",
169
- "copy-assets": "npx cpy src/creatorDist/**/* lib/creatorDist --parents --verbose && npx cpy src/utils/templates/**/* lib/utils/templates --parents --verbose && npx cpy src/lua/**/* lib/lua --parents --verbose",
169
+ "copy-assets": "npx cpy src/creatorDist/**/* lib/creatorDist --parents --verbose && npx cpy src/utils/templates/**/* lib/utils/templates --parents --verbose && npx cpy src/lua/**/* lib/lua --parents --verbose && npx cpy src/ui/static/**/* lib/ui/static --parents --verbose",
170
170
  "tsc": "tsc -b",
171
171
  "postpack": "rm -f oclif.manifest.json && eslint . --ext .ts --config .eslintrc",
172
172
  "prepack": "rm -rf lib && tsc -b && npm run copy-assets",
@@ -20,6 +20,7 @@ import * as prompts from "prompts"
20
20
  import { isValidRigoToken } from "../utils/rigoActions"
21
21
  import { minutesToISO8601Duration } from "../utils/misc"
22
22
  import { getReadmeExtension, slugify } from "../utils/creatorUtilities"
23
+ import { getFallbackPreviewUrl } from "../utils/branding"
23
24
 
24
25
  const uploadZipEndpont = RIGOBOT_HOST + "/v1/learnpack/upload"
25
26
 
@@ -588,9 +589,7 @@ class BuildCommand extends SessionCommand {
588
589
  getLocalizedValue(learnJson?.title, selectedLang) ||
589
590
  "LearnPack: Interactive Learning as a Service"
590
591
 
591
- const previewUrl =
592
- learnJson.preview ||
593
- "https://raw.githubusercontent.com/learnpack/ide/refs/heads/master/public/learnpack.svg"
592
+ const previewUrl = learnJson.preview || getFallbackPreviewUrl()
594
593
  // Replace placeholders and the <title>Old title </title> tag for a new tag with the title
595
594
  indexHtmlContent = indexHtmlContent
596
595
  .replace(/{{description}}/g, description)
@@ -81,7 +81,16 @@ import {
81
81
  } from "../utils/coursePackage/bucketIo"
82
82
  import { createLearnJson } from "../utils/coursePackage/learnJson"
83
83
  import { createInitialSidebar } from "../utils/coursePackage/sidebar"
84
- import { generateAndPersistPackageManifest } from "../utils/packageManifest"
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"
85
94
  import { requireGcsBucketName } from "../utils/gcsBucketName"
86
95
  import {
87
96
  createGcsSyllabusSyncStorage,
@@ -1181,17 +1190,43 @@ async function continueWithNextLesson(
1181
1190
  await saveSyllabus(courseSlug, newSyllabus, bucket)
1182
1191
  }
1183
1192
 
1184
- const fixPreviewUrl = (slug: string, previewUrl: string) => {
1185
- if (!previewUrl) {
1186
- 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
1187
1215
  }
1188
1216
 
1189
- if (previewUrl.startsWith("http")) {
1190
- 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
+ )
1191
1227
  }
1192
1228
 
1193
- const expectedUrl = `https://${slug}.learn-pack.com/preview.png`
1194
- return expectedUrl
1229
+ return getFallbackPreviewUrl()
1195
1230
  }
1196
1231
 
1197
1232
  const getTitleFromHTML = (html: string) => {
@@ -1200,6 +1235,73 @@ const getTitleFromHTML = (html: string) => {
1200
1235
  return titleMatch ? titleMatch[1] : null
1201
1236
  }
1202
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
+
1203
1305
  /** How often the notification is touched while a translation is running. */
1204
1306
  const SYNC_HEARTBEAT_INTERVAL_MS = 60 * 1000
1205
1307
 
@@ -1645,6 +1747,11 @@ class ServeCommand extends SessionCommand {
1645
1747
  const localAppPath = path.resolve(__dirname, "../ui/_app")
1646
1748
  // app.use(express.static(localAppPath))
1647
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
+
1648
1755
  app.post("/upload", async (req, res) => {
1649
1756
  const { content, destination } = req.body
1650
1757
  // console.log("UPLOAD", content, destination)
@@ -3130,8 +3237,7 @@ class ServeCommand extends SessionCommand {
3130
3237
  const slug = req.query.slug
3131
3238
 
3132
3239
  if (slug) {
3133
- const file = path.resolve(__dirname, "../ui/_app/index.html")
3134
- res.sendFile(file)
3240
+ sendDraftShell(req, res)
3135
3241
  } else {
3136
3242
  res.redirect("https://learnpack.co")
3137
3243
  }
@@ -3140,13 +3246,18 @@ class ServeCommand extends SessionCommand {
3140
3246
  app.get("/preview/:slug", async (req, res) => {
3141
3247
  console.log("GET COURSE PREVIEW /")
3142
3248
 
3143
- const file = path.resolve(__dirname, "../ui/_app/index.html")
3144
- res.sendFile(file)
3249
+ sendDraftShell(req, res)
3145
3250
  })
3146
3251
 
3147
3252
  app.get("/preview/:slug/webview", async (req, res) => {
3148
- const file = path.resolve(__dirname, "../ui/_app/index.html")
3149
- 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"))
3150
3261
  })
3151
3262
 
3152
3263
  app.get("/config", async (req, res) => {
@@ -5406,16 +5517,18 @@ class ServeCommand extends SessionCommand {
5406
5517
  selectedLang = availableLangs[0]
5407
5518
  }
5408
5519
 
5520
+ const previewUrl = await resolveCoursePreviewUrl(
5521
+ bucket,
5522
+ slug,
5523
+ config.preview
5524
+ )
5525
+
5409
5526
  const idxTpl = fs.readFileSync(path.join(uiSrc, "index.html"), "utf-8")
5410
5527
  const idxHtml = idxTpl
5411
5528
  .replace(/{{title}}/g, title)
5412
5529
  .replace(/<title>.*<\/title>/, `<title>${title}</title>`)
5413
5530
  .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
- )
5531
+ .replace(/{{preview}}/g, previewUrl)
5419
5532
  .replace(/{{slug}}/g, slug)
5420
5533
  .replace(/{{duration}}/g, minutesToISO8601Duration(config.duration))
5421
5534
  fs.writeFileSync(path.join(buildRoot, "index.html"), idxHtml)
@@ -5463,9 +5576,9 @@ class ServeCommand extends SessionCommand {
5463
5576
  // 8) Crear el config.json en buildRoot con lo que retorna buildConfig
5464
5577
  const fullConfig = { config, exercises }
5465
5578
  fullConfig.config.slug = slug
5466
- fullConfig.config.preview =
5467
- fixPreviewUrl(slug, "") ||
5468
- "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
5469
5582
 
5470
5583
  fs.writeFileSync(
5471
5584
  path.join(buildRoot, "config.json"),