@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.
- package/lib/commands/publish.d.ts +0 -10
- package/lib/commands/publish.js +54 -170
- package/lib/commands/serve.js +145 -134
- package/lib/ui/static/og-image.png +0 -0
- package/lib/utils/api.d.ts +62 -15
- package/lib/utils/api.js +124 -45
- package/lib/utils/assetResolver.d.ts +116 -0
- package/lib/utils/assetResolver.js +268 -0
- package/lib/utils/assetSync.d.ts +48 -0
- package/lib/utils/assetSync.js +267 -0
- package/lib/utils/branding.d.ts +43 -0
- package/lib/utils/branding.js +75 -0
- package/lib/utils/creatorUtilities.d.ts +1 -0
- package/lib/utils/creatorUtilities.js +17 -1
- package/package.json +2 -2
- package/src/commands/publish.ts +63 -258
- package/src/commands/serve.ts +200 -162
- package/src/ui/_app/app.js +2072 -2072
- package/src/ui/app.tar.gz +0 -0
- package/src/ui/static/og-image.png +0 -0
- package/src/utils/api.ts +157 -42
- package/src/utils/assetResolver.ts +368 -0
- package/src/utils/assetSync.ts +343 -0
- package/src/utils/awsCredentials.ts +57 -57
- package/src/utils/branding.ts +78 -0
- package/src/utils/creatorUtilities.ts +16 -0
- package/src/utils/descriptions/publishStage.ts +394 -394
- package/src/utils/descriptions/resumePublication.ts +217 -217
- package/src/utils/descriptions/s3Storage.ts +214 -214
- package/src/utils/s3/packageManifestBackfill.ts +771 -771
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates or updates the Breathecode assets of a published package, one per
|
|
3
|
+
* language, and writes the resulting ids back to the Rigobot package.
|
|
4
|
+
*
|
|
5
|
+
* Kept free of fs, oclif and express so the whole decision table can be
|
|
6
|
+
* exercised with HTTP mocks alone; `publish` and `serve` only supply the
|
|
7
|
+
* readme of each language and the tokens.
|
|
8
|
+
*/
|
|
9
|
+
import api, { AssetSyncError, BcAsset } from "./api"
|
|
10
|
+
import Console from "./console"
|
|
11
|
+
import { buildAssetSlug } from "./creatorUtilities"
|
|
12
|
+
import {
|
|
13
|
+
dedupeLangs,
|
|
14
|
+
describeAmbiguity,
|
|
15
|
+
getAvailableLangs,
|
|
16
|
+
PackageAssetsResolution,
|
|
17
|
+
resolvePackageAssets,
|
|
18
|
+
canonicalLang,
|
|
19
|
+
} from "./assetResolver"
|
|
20
|
+
|
|
21
|
+
export type AssetSyncInput = {
|
|
22
|
+
bcToken: string;
|
|
23
|
+
rigoToken: string;
|
|
24
|
+
packageSlug: string;
|
|
25
|
+
learnJson: any;
|
|
26
|
+
deployUrl: string;
|
|
27
|
+
academyId?: number;
|
|
28
|
+
/** Raw readme for a language ("" when there is none). Base64 encoding happens here. */
|
|
29
|
+
readReadme: (lang: string) => Promise<string>;
|
|
30
|
+
/** A resolution computed earlier (e.g. before the upload) to reuse instead of resolving again. */
|
|
31
|
+
resolution?: PackageAssetsResolution;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type SyncedAsset = {
|
|
35
|
+
lang: string;
|
|
36
|
+
action: "created" | "updated";
|
|
37
|
+
asset: { id: number; slug: string };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type AssetSyncResult = {
|
|
41
|
+
errors: AssetSyncError[];
|
|
42
|
+
warnings: string[];
|
|
43
|
+
synced: SyncedAsset[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Localized value with the same fallbacks the publish flow always used.
|
|
48
|
+
* @param translations - A learn.json map such as `title` or `description`.
|
|
49
|
+
* @param lang - Preferred language key.
|
|
50
|
+
* @returns The value for `lang`, else "en"/"us", else the first one, else "".
|
|
51
|
+
*/
|
|
52
|
+
export const getLocalizedValue = (
|
|
53
|
+
translations: Record<string, any> | undefined,
|
|
54
|
+
lang: string
|
|
55
|
+
): string => {
|
|
56
|
+
if (!translations || typeof translations !== "object") return ""
|
|
57
|
+
|
|
58
|
+
const direct = translations[lang]
|
|
59
|
+
if (typeof direct === "string" && direct.trim().length > 0) return direct
|
|
60
|
+
|
|
61
|
+
for (const fb of ["en", "us"]) {
|
|
62
|
+
const v = translations[fb]
|
|
63
|
+
if (typeof v === "string" && v.trim().length > 0) return v
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const firstKey = Object.keys(translations)[0]
|
|
67
|
+
const first = firstKey ? translations[firstKey] : ""
|
|
68
|
+
return typeof first === "string" ? first : ""
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type LangPlan =
|
|
72
|
+
| { lang: string; kind: "update"; asset: BcAsset; slug: string }
|
|
73
|
+
| { lang: string; kind: "create"; slug: string }
|
|
74
|
+
| { lang: string; kind: "skip"; detail: string };
|
|
75
|
+
|
|
76
|
+
const packageError = (detail: string): AssetSyncError => ({
|
|
77
|
+
kind: "package_error",
|
|
78
|
+
error: { detail },
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const langError = (lang: string, detail: string): AssetSyncError => ({
|
|
82
|
+
kind: "lang_error",
|
|
83
|
+
lang,
|
|
84
|
+
error: { detail },
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const errorDetail = (error: unknown): string => {
|
|
88
|
+
const err: any = error
|
|
89
|
+
if (typeof err?.detail === "string") return err.detail
|
|
90
|
+
if (typeof err?.message === "string") return err.message
|
|
91
|
+
try {
|
|
92
|
+
return JSON.stringify(err)
|
|
93
|
+
} catch {
|
|
94
|
+
return String(err)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Synchronises every language of a package with Breathecode.
|
|
100
|
+
* @param input - Tokens, package data, deploy url and readme provider.
|
|
101
|
+
* @returns Per-language errors (the publish itself is never blocked), warnings worth logging, and what was written.
|
|
102
|
+
*/
|
|
103
|
+
export async function syncPackageAssets(
|
|
104
|
+
input: AssetSyncInput
|
|
105
|
+
): Promise<AssetSyncResult> {
|
|
106
|
+
const warnings: string[] = []
|
|
107
|
+
const errors: AssetSyncError[] = []
|
|
108
|
+
const synced: SyncedAsset[] = []
|
|
109
|
+
|
|
110
|
+
const user = await api.validateToken(input.bcToken)
|
|
111
|
+
if (!user || typeof user.id !== "number") {
|
|
112
|
+
return {
|
|
113
|
+
errors: [packageError("Invalid Breathecode token; assets not synced.")],
|
|
114
|
+
warnings,
|
|
115
|
+
synced,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const resolution =
|
|
120
|
+
input.resolution ??
|
|
121
|
+
(await resolvePackageAssets({
|
|
122
|
+
bcToken: input.bcToken,
|
|
123
|
+
rigoToken: input.rigoToken,
|
|
124
|
+
packageSlug: input.packageSlug,
|
|
125
|
+
learnJson: input.learnJson,
|
|
126
|
+
}))
|
|
127
|
+
warnings.push(...resolution.warnings)
|
|
128
|
+
|
|
129
|
+
if (resolution.packageState === "unknown") {
|
|
130
|
+
return {
|
|
131
|
+
errors: [
|
|
132
|
+
packageError(
|
|
133
|
+
`${
|
|
134
|
+
resolution.packageError ?? "Existing assets could not be checked"
|
|
135
|
+
}; nothing was created or updated to avoid duplicates.`
|
|
136
|
+
),
|
|
137
|
+
],
|
|
138
|
+
warnings,
|
|
139
|
+
synced,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const learnpackId = resolution.learnpackId
|
|
144
|
+
if (learnpackId === null) {
|
|
145
|
+
return {
|
|
146
|
+
errors: [
|
|
147
|
+
packageError(
|
|
148
|
+
"Could not resolve Learnpack package id; assets not synced to Breathecode."
|
|
149
|
+
),
|
|
150
|
+
],
|
|
151
|
+
warnings,
|
|
152
|
+
synced,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Decide every language before touching anything, so all_translations can
|
|
157
|
+
// name the full set up front (the PUT replaces the relation, it does not add).
|
|
158
|
+
const langs = dedupeLangs(getAvailableLangs(input.learnJson))
|
|
159
|
+
const plans: LangPlan[] = []
|
|
160
|
+
for (const lang of langs) {
|
|
161
|
+
const title = getLocalizedValue(input.learnJson?.title, lang)
|
|
162
|
+
if (!title) {
|
|
163
|
+
plans.push({
|
|
164
|
+
lang,
|
|
165
|
+
kind: "skip",
|
|
166
|
+
detail: `Missing learn.json title for language "${lang}"`,
|
|
167
|
+
})
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const entry = resolution.byLang.get(canonicalLang(lang) as string)
|
|
172
|
+
if (!entry || entry.status === "unknown") {
|
|
173
|
+
plans.push({
|
|
174
|
+
lang,
|
|
175
|
+
kind: "skip",
|
|
176
|
+
detail: `${
|
|
177
|
+
entry?.status === "unknown" ?
|
|
178
|
+
entry.error :
|
|
179
|
+
"Existing asset could not be checked"
|
|
180
|
+
}; nothing was created or updated for this language to avoid duplicates.`,
|
|
181
|
+
})
|
|
182
|
+
} else if (entry.status === "ambiguous") {
|
|
183
|
+
plans.push({
|
|
184
|
+
lang,
|
|
185
|
+
kind: "skip",
|
|
186
|
+
detail: describeAmbiguity(lang, entry.candidates),
|
|
187
|
+
})
|
|
188
|
+
} else if (entry.status === "found") {
|
|
189
|
+
if (entry.asset.ownerId !== null && entry.asset.ownerId !== user.id) {
|
|
190
|
+
plans.push({
|
|
191
|
+
lang,
|
|
192
|
+
kind: "skip",
|
|
193
|
+
detail: `Asset #${entry.asset.id} ${entry.asset.slug} belongs to another user (owner ${entry.asset.ownerId}); it was not updated and no duplicate was created.`,
|
|
194
|
+
})
|
|
195
|
+
} else {
|
|
196
|
+
plans.push({
|
|
197
|
+
lang,
|
|
198
|
+
kind: "update",
|
|
199
|
+
asset: entry.asset,
|
|
200
|
+
slug: entry.asset.slug,
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
plans.push({ lang, kind: "create", slug: buildAssetSlug(title, lang) })
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const allTranslations = plans
|
|
209
|
+
.filter(p => p.kind !== "skip")
|
|
210
|
+
.map(p => (p as { slug: string }).slug)
|
|
211
|
+
|
|
212
|
+
const rigoToken = input.rigoToken.trim()
|
|
213
|
+
const createdOrUpdatedIds: number[] = []
|
|
214
|
+
|
|
215
|
+
for (const plan of plans) {
|
|
216
|
+
if (plan.kind === "skip") {
|
|
217
|
+
errors.push(langError(plan.lang, plan.detail))
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const title = getLocalizedValue(input.learnJson?.title, plan.lang)
|
|
222
|
+
const description = getLocalizedValue(
|
|
223
|
+
input.learnJson?.description,
|
|
224
|
+
plan.lang
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
if (plan.kind === "update") {
|
|
229
|
+
// Never send lang or slug: the language of an existing asset is not
|
|
230
|
+
// ours to change, and a slug would rename it.
|
|
231
|
+
const updatePayload: any = {
|
|
232
|
+
graded: true,
|
|
233
|
+
learnpack_deploy_url: input.deployUrl,
|
|
234
|
+
title,
|
|
235
|
+
category: "uncategorized",
|
|
236
|
+
description,
|
|
237
|
+
all_translations: allTranslations,
|
|
238
|
+
learnpack_id: learnpackId,
|
|
239
|
+
}
|
|
240
|
+
if (plan.asset.academyId === null && input.academyId !== undefined) {
|
|
241
|
+
updatePayload.academy_id = input.academyId
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
Console.info(
|
|
245
|
+
`Asset exists for "${plan.lang}" (#${plan.asset.id} ${plan.slug}), updating it`
|
|
246
|
+
)
|
|
247
|
+
// eslint-disable-next-line no-await-in-loop
|
|
248
|
+
const asset = await api.updateAsset(
|
|
249
|
+
input.bcToken,
|
|
250
|
+
plan.asset.id,
|
|
251
|
+
updatePayload
|
|
252
|
+
)
|
|
253
|
+
const id = typeof asset?.id === "number" ? asset.id : plan.asset.id
|
|
254
|
+
createdOrUpdatedIds.push(id)
|
|
255
|
+
synced.push({
|
|
256
|
+
lang: plan.lang,
|
|
257
|
+
action: "updated",
|
|
258
|
+
asset: { id, slug: asset?.slug ?? plan.slug },
|
|
259
|
+
})
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// eslint-disable-next-line no-await-in-loop
|
|
264
|
+
const readme = await input.readReadme(plan.lang)
|
|
265
|
+
const assetPayload: any = {
|
|
266
|
+
slug: plan.slug,
|
|
267
|
+
title,
|
|
268
|
+
lang: plan.lang,
|
|
269
|
+
graded: true,
|
|
270
|
+
description,
|
|
271
|
+
learnpack_deploy_url: input.deployUrl,
|
|
272
|
+
technologies: [],
|
|
273
|
+
url: input.deployUrl,
|
|
274
|
+
category: "uncategorized",
|
|
275
|
+
owner: user.id,
|
|
276
|
+
author: user.id,
|
|
277
|
+
preview: input.learnJson?.preview,
|
|
278
|
+
readme_raw: Buffer.from(readme).toString("base64"),
|
|
279
|
+
all_translations: allTranslations,
|
|
280
|
+
learnpack_id: learnpackId,
|
|
281
|
+
}
|
|
282
|
+
if (input.academyId !== undefined) {
|
|
283
|
+
assetPayload.academy_id = input.academyId
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
Console.info(`No asset for "${plan.lang}", creating ${plan.slug}`)
|
|
287
|
+
// eslint-disable-next-line no-await-in-loop
|
|
288
|
+
const asset = await api.createAsset(input.bcToken, assetPayload)
|
|
289
|
+
createdOrUpdatedIds.push(asset.id)
|
|
290
|
+
synced.push({
|
|
291
|
+
lang: plan.lang,
|
|
292
|
+
action: "created",
|
|
293
|
+
asset: { id: asset.id, slug: asset.slug ?? plan.slug },
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
// Record the id right away: a stale Breathecode cache could hide the
|
|
297
|
+
// new slug from the next publish, but the id never lies.
|
|
298
|
+
try {
|
|
299
|
+
// eslint-disable-next-line no-await-in-loop
|
|
300
|
+
await api.updateRigoPackage(rigoToken, input.packageSlug, {
|
|
301
|
+
asset_id: asset.id,
|
|
302
|
+
})
|
|
303
|
+
} catch (error) {
|
|
304
|
+
warnings.push(
|
|
305
|
+
`Could not record asset #${
|
|
306
|
+
asset.id
|
|
307
|
+
} in the Rigobot package: ${errorDetail(error)}`
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
} catch (error) {
|
|
311
|
+
const errorData =
|
|
312
|
+
error && typeof error === "object" && "response" in error ?
|
|
313
|
+
(error as any).response?.data || error :
|
|
314
|
+
error
|
|
315
|
+
Console.error(`Error syncing asset for language ${plan.lang}:`, error)
|
|
316
|
+
errors.push(langError(plan.lang, errorDetail(errorData)))
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Final list: everything Rigobot knew that still exists and is ours, plus
|
|
321
|
+
// whatever this run touched. Ids that 404 or belong to another package are
|
|
322
|
+
// the only ones that fall off; duplicates awaiting a manual decision stay.
|
|
323
|
+
const finalIds = [
|
|
324
|
+
...new Set([
|
|
325
|
+
...resolution.knownAssetIds,
|
|
326
|
+
...resolution.orphanIds,
|
|
327
|
+
...createdOrUpdatedIds,
|
|
328
|
+
]),
|
|
329
|
+
]
|
|
330
|
+
try {
|
|
331
|
+
await api.updateRigoPackage(rigoToken, input.packageSlug, {
|
|
332
|
+
asset_ids: finalIds,
|
|
333
|
+
})
|
|
334
|
+
} catch (error) {
|
|
335
|
+
warnings.push(
|
|
336
|
+
`Could not write asset_ids ${JSON.stringify(
|
|
337
|
+
finalIds
|
|
338
|
+
)} to the Rigobot package: ${errorDetail(error)}`
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { errors, warnings, synced }
|
|
343
|
+
}
|
|
@@ -1,57 +1,57 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AWS credentials for the published bucket, required rather than implicit.
|
|
3
|
-
*
|
|
4
|
-
* Building an `S3Client` with no credentials succeeds: the SDK defers to its
|
|
5
|
-
* provider chain and only fails at the first request, with
|
|
6
|
-
* "Could not load credentials from any providers". Inside a background job that
|
|
7
|
-
* surfaces minutes later, detached from the cause. Validating up front turns it
|
|
8
|
-
* into an obvious misconfiguration.
|
|
9
|
-
*
|
|
10
|
-
* Note this deliberately rejects the empty-string fallback pattern
|
|
11
|
-
* (`process.env.X || ""`): passing empty credentials defeats the provider chain
|
|
12
|
-
* instead of failing, which is how the opaque error appeared in the first place.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export type AwsCredentials = {
|
|
16
|
-
accessKeyId: string;
|
|
17
|
-
secretAccessKey: string;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export function requireAwsCredentials(): AwsCredentials {
|
|
21
|
-
const accessKeyId = (process.env.AWS_ACCESS_KEY_ID || "").trim()
|
|
22
|
-
const secretAccessKey = (process.env.AWS_SECRET_ACCESS_KEY || "").trim()
|
|
23
|
-
|
|
24
|
-
const missing: string[] = []
|
|
25
|
-
if (!accessKeyId) {
|
|
26
|
-
missing.push("AWS_ACCESS_KEY_ID")
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
if (!secretAccessKey) {
|
|
30
|
-
missing.push("AWS_SECRET_ACCESS_KEY")
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
if (missing.length > 0) {
|
|
34
|
-
throw new Error(
|
|
35
|
-
`${missing.join(" and ")} (env) ${
|
|
36
|
-
missing.length > 1 ? "are" : "is"
|
|
37
|
-
} required to reach the published package bucket`
|
|
38
|
-
)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return { accessKeyId, secretAccessKey }
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function requireS3PackagesBucket(): string {
|
|
45
|
-
const bucket = (process.env.S3_PACKAGES_BUCKET || "").trim()
|
|
46
|
-
if (!bucket) {
|
|
47
|
-
throw new Error(
|
|
48
|
-
"S3_PACKAGES_BUCKET (env) is required: it names the bucket holding published packages"
|
|
49
|
-
)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return bucket
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function awsRegion(): string {
|
|
56
|
-
return (process.env.AWS_REGION || "").trim() || "us-east-1"
|
|
57
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* AWS credentials for the published bucket, required rather than implicit.
|
|
3
|
+
*
|
|
4
|
+
* Building an `S3Client` with no credentials succeeds: the SDK defers to its
|
|
5
|
+
* provider chain and only fails at the first request, with
|
|
6
|
+
* "Could not load credentials from any providers". Inside a background job that
|
|
7
|
+
* surfaces minutes later, detached from the cause. Validating up front turns it
|
|
8
|
+
* into an obvious misconfiguration.
|
|
9
|
+
*
|
|
10
|
+
* Note this deliberately rejects the empty-string fallback pattern
|
|
11
|
+
* (`process.env.X || ""`): passing empty credentials defeats the provider chain
|
|
12
|
+
* instead of failing, which is how the opaque error appeared in the first place.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type AwsCredentials = {
|
|
16
|
+
accessKeyId: string;
|
|
17
|
+
secretAccessKey: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function requireAwsCredentials(): AwsCredentials {
|
|
21
|
+
const accessKeyId = (process.env.AWS_ACCESS_KEY_ID || "").trim()
|
|
22
|
+
const secretAccessKey = (process.env.AWS_SECRET_ACCESS_KEY || "").trim()
|
|
23
|
+
|
|
24
|
+
const missing: string[] = []
|
|
25
|
+
if (!accessKeyId) {
|
|
26
|
+
missing.push("AWS_ACCESS_KEY_ID")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!secretAccessKey) {
|
|
30
|
+
missing.push("AWS_SECRET_ACCESS_KEY")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (missing.length > 0) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`${missing.join(" and ")} (env) ${
|
|
36
|
+
missing.length > 1 ? "are" : "is"
|
|
37
|
+
} required to reach the published package bucket`
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { accessKeyId, secretAccessKey }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function requireS3PackagesBucket(): string {
|
|
45
|
+
const bucket = (process.env.S3_PACKAGES_BUCKET || "").trim()
|
|
46
|
+
if (!bucket) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
"S3_PACKAGES_BUCKET (env) is required: it names the bucket holding published packages"
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return bucket
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function awsRegion(): string {
|
|
56
|
+
return (process.env.AWS_REGION || "").trim() || "us-east-1"
|
|
57
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* Where `learnpack serve` runs in production. It is only a fallback: the server
|
|
11
|
+
* itself already has the real origin in `process.env.HOST` (it uses it to build
|
|
12
|
+
* webhook URLs). The `publish` command, however, runs on the author's machine
|
|
13
|
+
* with no such variable, and still has to write an absolute `og:image` into the
|
|
14
|
+
* package it uploads.
|
|
15
|
+
*/
|
|
16
|
+
export const PRODUCTION_PUBLIC_HOST = "https://app.learnpack.co"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Strips what `.env` files routinely smuggle in — a trailing comment, quotes,
|
|
20
|
+
* stray whitespace, a trailing slash — the same way `serve` sanitises `HOST`.
|
|
21
|
+
* @param raw - The raw environment value, if set.
|
|
22
|
+
* @returns The cleaned value, or an empty string.
|
|
23
|
+
*/
|
|
24
|
+
function normalizeOrigin(raw?: string): string {
|
|
25
|
+
if (!raw) {
|
|
26
|
+
return ""
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return raw
|
|
30
|
+
.trim()
|
|
31
|
+
.split(/\s+#/)[0]
|
|
32
|
+
.trim()
|
|
33
|
+
.replace(/["']/g, "")
|
|
34
|
+
.replace(/\/+$/, "")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Public origin of the app server, without a trailing slash.
|
|
39
|
+
*
|
|
40
|
+
* Only an absolute http(s) origin is accepted. `HOST` is also the conventional
|
|
41
|
+
* name for a bind address, so a deployment that sets it to `0.0.0.0` would
|
|
42
|
+
* otherwise yield a relative `og:image` — which no crawler can resolve, and
|
|
43
|
+
* which would be baked into every package published from that machine.
|
|
44
|
+
* @returns The origin to build absolute preview URLs from.
|
|
45
|
+
*/
|
|
46
|
+
export function getPublicHost(): string {
|
|
47
|
+
for (const raw of [process.env.PUBLIC_HOST, process.env.HOST]) {
|
|
48
|
+
const cleaned = normalizeOrigin(raw)
|
|
49
|
+
if (/^https?:\/\/\S+$/.test(cleaned)) {
|
|
50
|
+
return cleaned
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return PRODUCTION_PUBLIC_HOST
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Neutral brand card, used as `og:image` when a course has no preview of its own.
|
|
59
|
+
*
|
|
60
|
+
* It has to be a raster image: no unfurl crawler renders SVG. The previous
|
|
61
|
+
* fallback pointed at `learnpack.svg` on raw.githubusercontent.com, which fails
|
|
62
|
+
* twice over — SVG is unsupported, and that host serves it as `text/plain` with
|
|
63
|
+
* `X-Content-Type-Options: nosniff`.
|
|
64
|
+
*/
|
|
65
|
+
export function getFallbackPreviewUrl(): string {
|
|
66
|
+
return `${getPublicHost()}/og-image.png`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Copy for the link preview of a draft course.
|
|
71
|
+
*
|
|
72
|
+
* A draft is unlisted work in progress, so the card carries branding only: it
|
|
73
|
+
* must not disclose the title, the description or anything else about the
|
|
74
|
+
* course, and the pages that use it are served `noindex`.
|
|
75
|
+
*/
|
|
76
|
+
export const DRAFT_PREVIEW_TITLE = "LearnPack — Draft course"
|
|
77
|
+
export const DRAFT_PREVIEW_DESCRIPTION =
|
|
78
|
+
"This course is still a draft and is not published yet."
|
|
@@ -134,6 +134,22 @@ export const slugify = (text: string) => {
|
|
|
134
134
|
.replace(/^-+|-+$/g, "") // Trim hyphens from start/end
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// Builds the slug of the Breathecode asset for a given title and language.
|
|
138
|
+
// The backend only accepts letters, numbers, underscores and hyphens, so the
|
|
139
|
+
// periods that slugify() keeps (e.g. "Next.js") are turned into hyphens here.
|
|
140
|
+
// Hyphens are collapsed and trimmed BEFORE the 47-char cut on purpose: for a
|
|
141
|
+
// title without periods this must yield exactly slugify(title).slice(0, 47),
|
|
142
|
+
// which is the slug every existing asset was created with (a trailing hyphen
|
|
143
|
+
// left by the cut is part of that historical slug and is kept).
|
|
144
|
+
export const buildAssetSlug = (title: string, lang: string): string => {
|
|
145
|
+
const slug = slugify(title)
|
|
146
|
+
.replace(/\./g, "-")
|
|
147
|
+
.replace(/-+/g, "-")
|
|
148
|
+
.replace(/^-+|-+$/g, "")
|
|
149
|
+
.slice(0, 47)
|
|
150
|
+
return `${slug}-${lang}`
|
|
151
|
+
}
|
|
152
|
+
|
|
137
153
|
export const getExInfo = (title: string) => {
|
|
138
154
|
// Example title: '1.0 - Introduction to AI [READ: Small introduction to important concepts such as AI, machine learning, and their applications]'
|
|
139
155
|
let [exNumber, exTitle] = title.split(" - ")
|