@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.
- package/lib/commands/publish.js +2 -2
- package/lib/commands/serve.js +93 -18
- package/lib/ui/static/og-image.png +0 -0
- package/lib/utils/branding.d.ts +43 -0
- package/lib/utils/branding.js +75 -0
- package/package.json +2 -2
- package/src/commands/publish.ts +2 -3
- package/src/commands/serve.ts +135 -22
- package/src/ui/_app/app.js +825 -825
- package/src/ui/app.tar.gz +0 -0
- package/src/ui/static/og-image.png +0 -0
- package/src/utils/branding.ts +78 -0
package/lib/commands/publish.js
CHANGED
|
@@ -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)
|
package/lib/commands/serve.js
CHANGED
|
@@ -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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
741
|
-
|
|
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
|
-
|
|
744
|
-
|
|
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, "&").replace(/"/g, """).replace(/</g, "<");
|
|
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
|
-
|
|
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
|
-
|
|
2173
|
-
res.sendFile(file);
|
|
2243
|
+
sendDraftShell(req, res);
|
|
2174
2244
|
});
|
|
2175
2245
|
app.get("/preview/:slug/webview", async (req, res) => {
|
|
2176
|
-
|
|
2177
|
-
|
|
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,
|
|
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
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
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.
|
|
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",
|
package/src/commands/publish.ts
CHANGED
|
@@ -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)
|
package/src/commands/serve.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
-
|
|
1190
|
-
|
|
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
|
-
|
|
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, "&").replace(/"/g, """).replace(/</g, "<")
|
|
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
|
-
|
|
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
|
-
|
|
3144
|
-
res.sendFile(file)
|
|
3249
|
+
sendDraftShell(req, res)
|
|
3145
3250
|
})
|
|
3146
3251
|
|
|
3147
3252
|
app.get("/preview/:slug/webview", async (req, res) => {
|
|
3148
|
-
|
|
3149
|
-
|
|
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
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
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"),
|