@learnpack/learnpack 5.0.351 → 5.0.353
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 +6 -0
- package/lib/commands/serve.js +85 -259
- package/lib/models/creator.d.ts +6 -0
- package/lib/scripts/descriptionsGcsBackfill.d.ts +1 -0
- package/lib/scripts/descriptionsGcsBackfill.js +141 -0
- package/lib/scripts/descriptionsS3Backfill.d.ts +1 -0
- package/lib/scripts/descriptionsS3Backfill.js +157 -0
- package/lib/scripts/descriptionsSweep.d.ts +1 -0
- package/lib/scripts/descriptionsSweep.js +142 -0
- package/lib/utils/api.d.ts +7 -0
- package/lib/utils/api.js +8 -1
- package/lib/utils/creatorUtilities.js +2 -1
- package/lib/utils/descriptionHash.d.ts +66 -0
- package/lib/utils/descriptionHash.js +173 -0
- package/lib/utils/descriptions/gcsStorage.d.ts +16 -0
- package/lib/utils/descriptions/gcsStorage.js +60 -0
- package/lib/utils/descriptions/generateCourseDescriptions.d.ts +59 -0
- package/lib/utils/descriptions/generateCourseDescriptions.js +173 -0
- package/lib/utils/descriptions/mirrorDescriptions.d.ts +49 -0
- package/lib/utils/descriptions/mirrorDescriptions.js +109 -0
- package/lib/utils/descriptions/publishStage.d.ts +69 -0
- package/lib/utils/descriptions/publishStage.js +234 -0
- package/lib/utils/descriptions/resumePublication.d.ts +36 -0
- package/lib/utils/descriptions/resumePublication.js +113 -0
- package/lib/utils/descriptions/s3Storage.d.ts +30 -0
- package/lib/utils/descriptions/s3Storage.js +134 -0
- package/lib/utils/descriptions/workList.d.ts +75 -0
- package/lib/utils/descriptions/workList.js +177 -0
- package/lib/utils/gcsBucketName.d.ts +10 -0
- package/lib/utils/gcsBucketName.js +19 -0
- package/lib/utils/packageManifest.d.ts +18 -0
- package/lib/utils/packageManifest.js +81 -7
- package/lib/utils/publishEvents.d.ts +66 -0
- package/lib/utils/publishEvents.js +111 -0
- package/lib/utils/publishJournal.d.ts +119 -0
- package/lib/utils/publishJournal.js +275 -0
- package/lib/utils/rigoActions.d.ts +44 -0
- package/lib/utils/rigoActions.js +75 -1
- package/lib/utils/s3/packageManifestBackfill.d.ts +84 -0
- package/lib/utils/s3/packageManifestBackfill.js +487 -0
- package/lib/utils/syllabusSync.d.ts +71 -0
- package/lib/utils/syllabusSync.js +273 -0
- package/package.json +4 -1
- package/src/commands/publish.ts +7 -0
- package/src/commands/serve.ts +144 -335
- package/src/models/creator.ts +9 -0
- package/src/scripts/descriptionsGcsBackfill.ts +193 -0
- package/src/scripts/descriptionsS3Backfill.ts +208 -0
- package/src/scripts/descriptionsSweep.ts +185 -0
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +143 -141
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/api.ts +9 -0
- package/src/utils/creatorUtilities.ts +2 -1
- package/src/utils/descriptionHash.ts +196 -0
- package/src/utils/descriptions/gcsStorage.ts +67 -0
- package/src/utils/descriptions/generateCourseDescriptions.ts +301 -0
- package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
- package/src/utils/descriptions/publishStage.ts +382 -0
- package/src/utils/descriptions/resumePublication.ts +200 -0
- package/src/utils/descriptions/s3Storage.ts +206 -0
- package/src/utils/descriptions/workList.ts +283 -0
- package/src/utils/export/README.md +0 -2
- package/src/utils/gcsBucketName.ts +19 -0
- package/src/utils/packageManifest.ts +101 -10
- package/src/utils/publishEvents.ts +181 -0
- package/src/utils/publishJournal.ts +383 -0
- package/src/utils/rigoActions.ts +130 -0
- package/src/utils/s3/packageManifestBackfill.ts +776 -0
- package/src/utils/syllabusSync.ts +390 -0
|
@@ -13,6 +13,14 @@ const frontMatter = require("front-matter")
|
|
|
13
13
|
export const PACKAGE_MANIFEST_FILENAME = "package-manifest.json"
|
|
14
14
|
export const PACKAGE_MANIFEST_REL_PATH = ".learn/package-manifest.json"
|
|
15
15
|
export const SCHEMA_VERSION = 1
|
|
16
|
+
// Version of the Rigobot prompt/model used to generate step descriptions. It
|
|
17
|
+
// travels inside the completion inputs, so bumping it both busts Rigobot's
|
|
18
|
+
// cache and marks every description stored at a lower version for regeneration.
|
|
19
|
+
// Keep in sync with the prompt body provisioned in Rigobot.
|
|
20
|
+
export const DESCRIPTION_PROMPT_VERSION = 1
|
|
21
|
+
|
|
22
|
+
// Target length, in words, of a generated step description.
|
|
23
|
+
export const DESCRIPTION_TARGET_WORD_COUNT = 25
|
|
16
24
|
|
|
17
25
|
export type LessonType = "READ" | "CODE" | "QUIZ";
|
|
18
26
|
|
|
@@ -22,6 +30,7 @@ export type PackageManifestLesson = {
|
|
|
22
30
|
position: number;
|
|
23
31
|
type: LessonType;
|
|
24
32
|
title: Record<string, string>;
|
|
33
|
+
description: Record<string, string | null>;
|
|
25
34
|
video: Record<string, string | null>;
|
|
26
35
|
};
|
|
27
36
|
|
|
@@ -165,6 +174,24 @@ function resolveLessonTitle(
|
|
|
165
174
|
return titles
|
|
166
175
|
}
|
|
167
176
|
|
|
177
|
+
function resolveLessonDescriptions(
|
|
178
|
+
exercise: Exercise,
|
|
179
|
+
syllabusLesson: Lesson | undefined
|
|
180
|
+
): Record<string, string | null> {
|
|
181
|
+
const description: Record<string, string | null> = {}
|
|
182
|
+
|
|
183
|
+
// Descriptions are a projection: the source of truth lives in the syllabus
|
|
184
|
+
// lesson (translations[lang].description), populated by the sweep/backfill.
|
|
185
|
+
// When absent we emit null (the frontend hides the area) rather than a
|
|
186
|
+
// low-quality placeholder.
|
|
187
|
+
for (const lang of Object.keys(exercise.translations)) {
|
|
188
|
+
description[lang] =
|
|
189
|
+
syllabusLesson?.translations?.[lang]?.description ?? null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return description
|
|
193
|
+
}
|
|
194
|
+
|
|
168
195
|
function resolveLessonVideos(
|
|
169
196
|
exercise: Exercise,
|
|
170
197
|
readmeFrontmatters: Record<string, Record<string, ReadmeFrontmatter>>
|
|
@@ -226,6 +253,7 @@ export function buildPackageManifestFromSources(
|
|
|
226
253
|
courseLang,
|
|
227
254
|
readmeFrontmatters[exercise.slug] ?? {}
|
|
228
255
|
),
|
|
256
|
+
description: resolveLessonDescriptions(exercise, syllabusLesson),
|
|
229
257
|
video: resolveLessonVideos(exercise, readmeFrontmatters),
|
|
230
258
|
}
|
|
231
259
|
})
|
|
@@ -263,6 +291,77 @@ async function downloadJsonFile<T>(
|
|
|
263
291
|
}
|
|
264
292
|
}
|
|
265
293
|
|
|
294
|
+
export function parseReadmeContent(content: string): ReadmeFrontmatter {
|
|
295
|
+
const parsed = frontMatter(content)
|
|
296
|
+
const attributes = parsed.attributes || {}
|
|
297
|
+
const h1 = extractReadmeH1(parsed.body || "")
|
|
298
|
+
const title = h1 ? normalizeReadmeTitle(h1) : null
|
|
299
|
+
return {
|
|
300
|
+
...(attributes.tutorial ? { tutorial: attributes.tutorial } : {}),
|
|
301
|
+
...(attributes.intro ? { intro: attributes.intro } : {}),
|
|
302
|
+
...(title ? { title } : {}),
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function comparePackageManifests(
|
|
307
|
+
existing: PackageManifest | null,
|
|
308
|
+
next: PackageManifest
|
|
309
|
+
): "created" | "updated" | "skipped" {
|
|
310
|
+
if (existing === null) {
|
|
311
|
+
return "created"
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const { generatedAt: _existingGeneratedAt, ...existingRest } = existing
|
|
315
|
+
const { generatedAt: _nextGeneratedAt, ...nextRest } = next
|
|
316
|
+
|
|
317
|
+
if (JSON.stringify(existingRest) === JSON.stringify(nextRest)) {
|
|
318
|
+
return "skipped"
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return "updated"
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function serializePackageManifest(manifest: PackageManifest): string {
|
|
325
|
+
return JSON.stringify(manifest, null, 2)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The manifest as it travels inside a publication event. */
|
|
329
|
+
export type PackageManifestEventView = Omit<PackageManifest, "generatedAt">;
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Project a manifest into the shape sent to breathecode.
|
|
333
|
+
*
|
|
334
|
+
* Today this is nearly the identity — only `generatedAt`, an internal detail of
|
|
335
|
+
* the projection, is dropped. It exists as an explicit pick so that fields
|
|
336
|
+
* added to the manifest later (hashes, generation flags) do not silently become
|
|
337
|
+
* part of an external contract: joining the event payload has to be a decision
|
|
338
|
+
* visible in a diff.
|
|
339
|
+
*/
|
|
340
|
+
export function serializePackageManifestForEvent(
|
|
341
|
+
manifest: PackageManifest
|
|
342
|
+
): PackageManifestEventView {
|
|
343
|
+
return {
|
|
344
|
+
schemaVersion: manifest.schemaVersion,
|
|
345
|
+
publishedAt: manifest.publishedAt,
|
|
346
|
+
slug: manifest.slug,
|
|
347
|
+
title: manifest.title,
|
|
348
|
+
description: manifest.description,
|
|
349
|
+
preview: manifest.preview,
|
|
350
|
+
technologies: manifest.technologies,
|
|
351
|
+
difficulty: manifest.difficulty,
|
|
352
|
+
duration: manifest.duration,
|
|
353
|
+
lessons: manifest.lessons.map(lesson => ({
|
|
354
|
+
id: lesson.id,
|
|
355
|
+
slug: lesson.slug,
|
|
356
|
+
position: lesson.position,
|
|
357
|
+
type: lesson.type,
|
|
358
|
+
title: lesson.title,
|
|
359
|
+
description: lesson.description,
|
|
360
|
+
video: lesson.video,
|
|
361
|
+
})),
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
266
365
|
async function readReadmeFrontmatter(
|
|
267
366
|
bucket: Bucket,
|
|
268
367
|
exerciseSlug: string,
|
|
@@ -275,18 +374,10 @@ async function readReadmeFrontmatter(
|
|
|
275
374
|
}> {
|
|
276
375
|
try {
|
|
277
376
|
const [buf] = await bucket.file(readmePath).download()
|
|
278
|
-
const parsed = frontMatter(buf.toString())
|
|
279
|
-
const attributes = parsed.attributes || {}
|
|
280
|
-
const h1 = extractReadmeH1(parsed.body || "")
|
|
281
|
-
const title = h1 ? normalizeReadmeTitle(h1) : null
|
|
282
377
|
return {
|
|
283
378
|
exerciseSlug,
|
|
284
379
|
lang,
|
|
285
|
-
frontmatter:
|
|
286
|
-
tutorial: attributes.tutorial,
|
|
287
|
-
intro: attributes.intro,
|
|
288
|
-
...(title ? { title } : {}),
|
|
289
|
-
},
|
|
380
|
+
frontmatter: parseReadmeContent(buf.toString()),
|
|
290
381
|
}
|
|
291
382
|
} catch (error) {
|
|
292
383
|
console.warn(
|
|
@@ -328,7 +419,7 @@ function writeManifestJson(
|
|
|
328
419
|
manifest: PackageManifest,
|
|
329
420
|
localDir?: string
|
|
330
421
|
): string {
|
|
331
|
-
const json =
|
|
422
|
+
const json = serializePackageManifest(manifest)
|
|
332
423
|
|
|
333
424
|
if (localDir) {
|
|
334
425
|
const learnDir = path.join(localDir, ".learn")
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import axios from "axios"
|
|
2
|
+
import { BREATHECODE_EVENTS_URL, RIGOBOT_HOST } from "./api"
|
|
3
|
+
import {
|
|
4
|
+
PackageManifest,
|
|
5
|
+
PackageManifestEventView,
|
|
6
|
+
serializePackageManifestForEvent,
|
|
7
|
+
} from "./packageManifest"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Publication events sent to breathecode.
|
|
11
|
+
*
|
|
12
|
+
* They are emitted from here, and not from Rigobot, because learnpack-cli is
|
|
13
|
+
* the only party that witnesses the facts: Rigobot's part ends when the zip is
|
|
14
|
+
* deployed, before the breathecode asset sync, and it never sees the end of the
|
|
15
|
+
* asynchronous description stage.
|
|
16
|
+
*
|
|
17
|
+
* Transport is the existing telemetry endpoint. The discriminator is a
|
|
18
|
+
* top-level `event` key, which is what breathecode's webhook log already looks
|
|
19
|
+
* for; a body without it keeps being processed as plain telemetry.
|
|
20
|
+
*
|
|
21
|
+
* `package_slug` and `package_id` are duplicated at the top level on purpose:
|
|
22
|
+
* `LearnPack.add_webhook_to_log` fills the indexed columns from there, so
|
|
23
|
+
* without them the events would land as rows nobody can filter in the webhook
|
|
24
|
+
* viewer.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const PACKAGE_PUBLISHED_EVENT = "package_published"
|
|
28
|
+
export const PACKAGE_MANIFEST_UPDATED_EVENT = "package_manifest_updated"
|
|
29
|
+
|
|
30
|
+
/** Whether a second event is coming for this publication. */
|
|
31
|
+
export type DescriptionsOutcome = "queued" | "up_to_date";
|
|
32
|
+
|
|
33
|
+
export type PublishEventEnvelope = {
|
|
34
|
+
event: string;
|
|
35
|
+
package_slug: string;
|
|
36
|
+
package_id?: number;
|
|
37
|
+
payload: Record<string, unknown>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type PackagePublishedPayload = {
|
|
41
|
+
publish_id: string;
|
|
42
|
+
package: Record<string, unknown> | null;
|
|
43
|
+
manifest: PackageManifestEventView | null;
|
|
44
|
+
descriptions: DescriptionsOutcome;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type PackageManifestUpdatedPayload = {
|
|
48
|
+
publish_id: string;
|
|
49
|
+
status: "success" | "failed";
|
|
50
|
+
package: Record<string, unknown> | null;
|
|
51
|
+
manifest: PackageManifestEventView | null;
|
|
52
|
+
stats?: {
|
|
53
|
+
generated: number;
|
|
54
|
+
failed: number;
|
|
55
|
+
missing: number;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type PublishEventContext = {
|
|
60
|
+
publishId: string;
|
|
61
|
+
courseSlug: string;
|
|
62
|
+
/** `GET /v1/learnpack/package/{slug}/` from Rigobot. */
|
|
63
|
+
packageInfo: Record<string, unknown> | null;
|
|
64
|
+
manifest: PackageManifest | null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function packageIdOf(
|
|
68
|
+
packageInfo: Record<string, unknown> | null
|
|
69
|
+
): number | undefined {
|
|
70
|
+
const id = packageInfo?.id
|
|
71
|
+
return typeof id === "number" ? id : undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildPackagePublishedEvent(
|
|
75
|
+
context: PublishEventContext,
|
|
76
|
+
descriptions: DescriptionsOutcome
|
|
77
|
+
): PublishEventEnvelope {
|
|
78
|
+
const payload: PackagePublishedPayload = {
|
|
79
|
+
publish_id: context.publishId,
|
|
80
|
+
package: context.packageInfo,
|
|
81
|
+
manifest: context.manifest ?
|
|
82
|
+
serializePackageManifestForEvent(context.manifest) :
|
|
83
|
+
null,
|
|
84
|
+
descriptions,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
event: PACKAGE_PUBLISHED_EVENT,
|
|
89
|
+
package_slug: context.courseSlug,
|
|
90
|
+
package_id: packageIdOf(context.packageInfo),
|
|
91
|
+
payload: payload as unknown as Record<string, unknown>,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function buildPackageManifestUpdatedEvent(
|
|
96
|
+
context: PublishEventContext,
|
|
97
|
+
status: "success" | "failed",
|
|
98
|
+
stats?: PackageManifestUpdatedPayload["stats"]
|
|
99
|
+
): PublishEventEnvelope {
|
|
100
|
+
const payload: PackageManifestUpdatedPayload = {
|
|
101
|
+
publish_id: context.publishId,
|
|
102
|
+
status,
|
|
103
|
+
package: context.packageInfo,
|
|
104
|
+
manifest: context.manifest ?
|
|
105
|
+
serializePackageManifestForEvent(context.manifest) :
|
|
106
|
+
null,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (stats) {
|
|
110
|
+
payload.stats = stats
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
event: PACKAGE_MANIFEST_UPDATED_EVENT,
|
|
115
|
+
package_slug: context.courseSlug,
|
|
116
|
+
package_id: packageIdOf(context.packageInfo),
|
|
117
|
+
payload: payload as unknown as Record<string, unknown>,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Fire and forget: a publication must never fail because breathecode is down,
|
|
123
|
+
* so every error is swallowed and logged. Returns whether it was delivered, for
|
|
124
|
+
* the journal, not for control flow.
|
|
125
|
+
*/
|
|
126
|
+
export async function sendPublishEvent(
|
|
127
|
+
envelope: PublishEventEnvelope,
|
|
128
|
+
breathecodeToken: string,
|
|
129
|
+
timeoutMs = 10_000
|
|
130
|
+
): Promise<boolean> {
|
|
131
|
+
if (!breathecodeToken) {
|
|
132
|
+
console.warn(
|
|
133
|
+
`[publish-events] Skipping "${envelope.event}": no breathecode token`
|
|
134
|
+
)
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await axios.post(BREATHECODE_EVENTS_URL, envelope, {
|
|
140
|
+
timeout: timeoutMs,
|
|
141
|
+
headers: {
|
|
142
|
+
"Content-Type": "application/json",
|
|
143
|
+
Authorization: "Token " + breathecodeToken.trim(),
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
return true
|
|
147
|
+
} catch (error) {
|
|
148
|
+
console.error(
|
|
149
|
+
`[publish-events] Could not deliver "${envelope.event}" for "${envelope.package_slug}":`,
|
|
150
|
+
(error as Error).message
|
|
151
|
+
)
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Package information for the event payload, straight from Rigobot's own
|
|
158
|
+
* serializer so the shape stays owned by the system that owns the model.
|
|
159
|
+
* Returns null on failure: an event with less context still beats no event.
|
|
160
|
+
*/
|
|
161
|
+
export async function fetchPackageInfo(
|
|
162
|
+
courseSlug: string,
|
|
163
|
+
rigobotToken: string
|
|
164
|
+
): Promise<Record<string, unknown> | null> {
|
|
165
|
+
try {
|
|
166
|
+
const response = await axios.get(
|
|
167
|
+
`${RIGOBOT_HOST}/v1/learnpack/package/${courseSlug}/`,
|
|
168
|
+
{
|
|
169
|
+
timeout: 10_000,
|
|
170
|
+
headers: { Authorization: "Token " + rigobotToken.trim() },
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
return response.data as Record<string, unknown>
|
|
174
|
+
} catch (error) {
|
|
175
|
+
console.error(
|
|
176
|
+
`[publish-events] Could not read package "${courseSlug}" from Rigobot:`,
|
|
177
|
+
(error as Error).message
|
|
178
|
+
)
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
181
|
+
}
|