@learnpack/learnpack 5.0.350 → 5.0.352

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/src/ui/app.tar.gz CHANGED
Binary file
@@ -4,8 +4,9 @@ This directory contains utilities for exporting courses to different formats.
4
4
 
5
5
  ## Supported Formats
6
6
 
7
- - **SCORM**: Learning Management System compatible package
8
- - **EPUB**: E-book format for offline reading
7
+ - **SCORM**: Learning Management System compatible package (includes `.learn/package-manifest.json`)
8
+ - **ZIP**: Full course mirror from GCS (includes `.learn/package-manifest.json`)
9
+ - **EPUB**: E-book format for offline reading (does not include `package-manifest.json`)
9
10
 
10
11
  ## Usage
11
12
 
@@ -12,6 +12,7 @@ import {
12
12
  getCourseMetadata,
13
13
  escapeXml,
14
14
  } from "./shared";
15
+ import { generateAndPersistPackageManifest } from "../packageManifest";
15
16
 
16
17
  export async function exportToScorm(options: ExportOptions): Promise<string> {
17
18
  const { courseSlug, bucket, outDir } = options;
@@ -67,6 +68,11 @@ export async function exportToScorm(options: ExportOptions): Promise<string> {
67
68
  path.join(scormOutDir, ".learn")
68
69
  );
69
70
 
71
+ await generateAndPersistPackageManifest(bucket, courseSlug, {
72
+ publishedAt: null,
73
+ localDir: scormOutDir,
74
+ });
75
+
70
76
  // 4. Read learn.json for course info
71
77
  const learnJson = await getCourseMetadata(bucket, courseSlug);
72
78
 
@@ -5,7 +5,8 @@ import * as mkdirp from "mkdirp";
5
5
  import * as rimraf from "rimraf";
6
6
  import { v4 as uuidv4 } from "uuid";
7
7
  import { ExportOptions } from "./types";
8
- import { downloadS3Folder, getCourseMetadata } from "./shared";
8
+ import { downloadS3Folder } from "./shared";
9
+ import { generateAndPersistPackageManifest } from "../packageManifest";
9
10
 
10
11
  export async function exportToZip(options: ExportOptions): Promise<string> {
11
12
  const { courseSlug, bucket, outDir } = options;
@@ -16,6 +17,10 @@ export async function exportToZip(options: ExportOptions): Promise<string> {
16
17
  rimraf.sync(zipOutDir);
17
18
  mkdirp.sync(zipOutDir);
18
19
 
20
+ await generateAndPersistPackageManifest(bucket, courseSlug, {
21
+ publishedAt: null,
22
+ });
23
+
19
24
  // 2. Download all course files from bucket to temporary directory
20
25
  await downloadS3Folder(bucket, `courses/${courseSlug}/`, zipOutDir);
21
26
 
@@ -0,0 +1,433 @@
1
+ import { Bucket } from "@google-cloud/storage"
2
+ import * as fs from "fs"
3
+ import * as mkdirp from "mkdirp"
4
+ import * as path from "path"
5
+ import { Lesson, Syllabus } from "../models/creator"
6
+ import { buildConfig, Exercise } from "./configBuilder"
7
+ import { getReadmeExtension, slugify } from "./creatorUtilities"
8
+ import { formatSidebarTitle } from "./titlefy"
9
+
10
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
11
+ const frontMatter = require("front-matter")
12
+
13
+ export const PACKAGE_MANIFEST_FILENAME = "package-manifest.json"
14
+ export const PACKAGE_MANIFEST_REL_PATH = ".learn/package-manifest.json"
15
+ export const SCHEMA_VERSION = 1
16
+
17
+ export type LessonType = "READ" | "CODE" | "QUIZ";
18
+
19
+ export type PackageManifestLesson = {
20
+ id: string;
21
+ slug: string;
22
+ position: number;
23
+ type: LessonType;
24
+ title: Record<string, string>;
25
+ description: Record<string, string | null>;
26
+ video: Record<string, string | null>;
27
+ };
28
+
29
+ export type PackageManifest = {
30
+ schemaVersion: number;
31
+ generatedAt: string;
32
+ publishedAt: string | null;
33
+ slug: string;
34
+ title: Record<string, string>;
35
+ description: Record<string, string>;
36
+ preview: string;
37
+ technologies?: string[];
38
+ difficulty?: string;
39
+ duration?: number;
40
+ lessons: PackageManifestLesson[];
41
+ };
42
+
43
+ export type ReadmeFrontmatter = {
44
+ tutorial?: string;
45
+ intro?: string;
46
+ title?: string;
47
+ };
48
+
49
+ export type PackageManifestBuildInput = {
50
+ learnJson: Record<string, unknown>;
51
+ syllabus: Syllabus | null;
52
+ sidebar: Record<string, Record<string, string>>;
53
+ exercises: Exercise[];
54
+ readmeFrontmatters: Record<string, Record<string, ReadmeFrontmatter>>;
55
+ options: {
56
+ slug: string;
57
+ generatedAt: string;
58
+ publishedAt: string | null;
59
+ };
60
+ };
61
+
62
+ const VALID_LESSON_TYPES = new Set<LessonType>(["READ", "CODE", "QUIZ"])
63
+
64
+ export function packageManifestGcsPath(courseSlug: string): string {
65
+ return `courses/${courseSlug}/${PACKAGE_MANIFEST_REL_PATH}`
66
+ }
67
+
68
+ export function resolvePreviewUrl(slug: string, preview?: string): string {
69
+ if (preview && preview.startsWith("http")) {
70
+ return preview
71
+ }
72
+
73
+ return `https://${slug}.learn-pack.com/preview.png`
74
+ }
75
+
76
+ function findSyllabusLesson(
77
+ exerciseSlug: string,
78
+ lessons: Lesson[]
79
+ ): Lesson | undefined {
80
+ return lessons.find(lesson => {
81
+ const candidates = [
82
+ slugify(lesson.id + "-" + lesson.title),
83
+ lesson.uid,
84
+ ].filter(Boolean)
85
+ return candidates.includes(exerciseSlug)
86
+ })
87
+ }
88
+
89
+ function deriveLessonId(exerciseSlug: string): string {
90
+ const match = exerciseSlug.match(/^(\d+(?:\.\d+)?)/)
91
+ return match ? match[1] : exerciseSlug
92
+ }
93
+
94
+ function coerceLessonType(type: string | undefined): LessonType {
95
+ if (type && VALID_LESSON_TYPES.has(type as LessonType)) {
96
+ return type as LessonType
97
+ }
98
+
99
+ if (type) {
100
+ console.warn(
101
+ `[package-manifest] Invalid lesson type "${type}", defaulting to READ`
102
+ )
103
+ }
104
+
105
+ return "READ"
106
+ }
107
+
108
+ export function extractReadmeH1(body: string): string | null {
109
+ const match = body.match(/^#\s+(.+)$/m)
110
+ if (!match) {
111
+ return null
112
+ }
113
+
114
+ const title = match[1].replace(/\r/g, "").trim()
115
+ return title || null
116
+ }
117
+
118
+ export function normalizeReadmeTitle(raw: string): string | null {
119
+ const normalized = raw
120
+ .trim()
121
+ .replace(/^\d+(?:\.\d+)?\s*[.:-]?\s*/, "")
122
+ .trim()
123
+ return normalized || null
124
+ }
125
+
126
+ function firstNonEmpty(
127
+ ...candidates: Array<string | null | undefined>
128
+ ): string | null {
129
+ for (const candidate of candidates) {
130
+ const value = candidate?.trim()
131
+ if (value) {
132
+ return value
133
+ }
134
+ }
135
+
136
+ return null
137
+ }
138
+
139
+ function resolveLessonTitle(
140
+ exercise: Exercise,
141
+ sidebar: Record<string, Record<string, string>>,
142
+ syllabusLesson: Lesson | undefined,
143
+ courseLang: string,
144
+ readmeByLang: Record<string, ReadmeFrontmatter>
145
+ ): Record<string, string> {
146
+ const sidebarEntry = sidebar[exercise.slug] ?? {}
147
+ const languages = new Set([
148
+ ...Object.keys(exercise.translations),
149
+ ...Object.keys(sidebarEntry),
150
+ ])
151
+
152
+ const titles: Record<string, string> = {}
153
+
154
+ for (const lang of languages) {
155
+ const title = firstNonEmpty(
156
+ readmeByLang[lang]?.title,
157
+ sidebarEntry[lang] ? formatSidebarTitle(sidebarEntry[lang]) : null,
158
+ lang === courseLang ? syllabusLesson?.title : null
159
+ )
160
+
161
+ if (title) {
162
+ titles[lang] = title
163
+ }
164
+ }
165
+
166
+ return titles
167
+ }
168
+
169
+ function resolveLessonDescriptions(
170
+ exercise: Exercise
171
+ ): Record<string, string | null> {
172
+ const description: Record<string, string | null> = {}
173
+
174
+ for (const lang of Object.keys(exercise.translations)) {
175
+ // TODO: add description content
176
+ description[lang] = null
177
+ }
178
+
179
+ return description
180
+ }
181
+
182
+ function resolveLessonVideos(
183
+ exercise: Exercise,
184
+ readmeFrontmatters: Record<string, Record<string, ReadmeFrontmatter>>
185
+ ): Record<string, string | null> {
186
+ const video: Record<string, string | null> = {}
187
+ const frontmatters = readmeFrontmatters[exercise.slug] || {}
188
+
189
+ for (const lang of Object.keys(exercise.translations)) {
190
+ const attrs = frontmatters[lang] || {}
191
+ const url = attrs.tutorial || attrs.intro || null
192
+ video[lang] = url && url.trim().length > 0 ? url.trim() : null
193
+ }
194
+
195
+ return video
196
+ }
197
+
198
+ export function buildPackageManifestFromSources(
199
+ input: PackageManifestBuildInput
200
+ ): PackageManifest {
201
+ const {
202
+ learnJson,
203
+ syllabus,
204
+ sidebar,
205
+ exercises,
206
+ readmeFrontmatters,
207
+ options,
208
+ } = input
209
+
210
+ const syllabusLessons = syllabus?.lessons ?? []
211
+ const courseLang = syllabus?.courseInfo?.language || "en"
212
+
213
+ const sortedExercises = [...exercises].sort(
214
+ (a, b) => a.position - b.position
215
+ )
216
+
217
+ const lessons: PackageManifestLesson[] = sortedExercises.map(exercise => {
218
+ const syllabusLesson = findSyllabusLesson(exercise.slug, syllabusLessons)
219
+
220
+ const id = syllabusLesson?.id ?? deriveLessonId(exercise.slug)
221
+ const type = syllabusLesson ?
222
+ coerceLessonType(syllabusLesson.type) :
223
+ "READ"
224
+
225
+ if (!syllabusLesson) {
226
+ console.warn(
227
+ `[package-manifest] No syllabus match for exercise "${exercise.slug}", using derived id "${id}"`
228
+ )
229
+ }
230
+
231
+ return {
232
+ id,
233
+ slug: exercise.slug,
234
+ position: exercise.position,
235
+ type,
236
+ title: resolveLessonTitle(
237
+ exercise,
238
+ sidebar,
239
+ syllabusLesson,
240
+ courseLang,
241
+ readmeFrontmatters[exercise.slug] ?? {}
242
+ ),
243
+ description: resolveLessonDescriptions(exercise),
244
+ video: resolveLessonVideos(exercise, readmeFrontmatters),
245
+ }
246
+ })
247
+
248
+ const title = (learnJson.title as Record<string, string>) || {}
249
+ const description = (learnJson.description as Record<string, string>) || {}
250
+
251
+ return {
252
+ schemaVersion: SCHEMA_VERSION,
253
+ generatedAt: options.generatedAt,
254
+ publishedAt: options.publishedAt,
255
+ slug: options.slug,
256
+ title,
257
+ description,
258
+ preview: resolvePreviewUrl(
259
+ options.slug,
260
+ learnJson.preview as string | undefined
261
+ ),
262
+ technologies: learnJson.technologies as string[] | undefined,
263
+ difficulty: learnJson.difficulty as string | undefined,
264
+ duration: learnJson.duration as number | undefined,
265
+ lessons,
266
+ }
267
+ }
268
+
269
+ async function downloadJsonFile<T>(
270
+ bucket: Bucket,
271
+ filePath: string
272
+ ): Promise<T | null> {
273
+ try {
274
+ const [buf] = await bucket.file(filePath).download()
275
+ return JSON.parse(buf.toString()) as T
276
+ } catch {
277
+ return null
278
+ }
279
+ }
280
+
281
+ export function parseReadmeContent(content: string): ReadmeFrontmatter {
282
+ const parsed = frontMatter(content)
283
+ const attributes = parsed.attributes || {}
284
+ const h1 = extractReadmeH1(parsed.body || "")
285
+ const title = h1 ? normalizeReadmeTitle(h1) : null
286
+ return {
287
+ ...(attributes.tutorial ? { tutorial: attributes.tutorial } : {}),
288
+ ...(attributes.intro ? { intro: attributes.intro } : {}),
289
+ ...(title ? { title } : {}),
290
+ }
291
+ }
292
+
293
+ export function comparePackageManifests(
294
+ existing: PackageManifest | null,
295
+ next: PackageManifest
296
+ ): "created" | "updated" | "skipped" {
297
+ if (existing === null) {
298
+ return "created"
299
+ }
300
+
301
+ const { generatedAt: _existingGeneratedAt, ...existingRest } = existing
302
+ const { generatedAt: _nextGeneratedAt, ...nextRest } = next
303
+
304
+ if (JSON.stringify(existingRest) === JSON.stringify(nextRest)) {
305
+ return "skipped"
306
+ }
307
+
308
+ return "updated"
309
+ }
310
+
311
+ export function serializePackageManifest(manifest: PackageManifest): string {
312
+ return JSON.stringify(manifest, null, 2)
313
+ }
314
+
315
+ async function readReadmeFrontmatter(
316
+ bucket: Bucket,
317
+ exerciseSlug: string,
318
+ lang: string,
319
+ readmePath: string
320
+ ): Promise<{
321
+ exerciseSlug: string;
322
+ lang: string;
323
+ frontmatter: ReadmeFrontmatter;
324
+ }> {
325
+ try {
326
+ const [buf] = await bucket.file(readmePath).download()
327
+ return {
328
+ exerciseSlug,
329
+ lang,
330
+ frontmatter: parseReadmeContent(buf.toString()),
331
+ }
332
+ } catch (error) {
333
+ console.warn(
334
+ `[package-manifest] Could not read README for ${exerciseSlug} (${lang}):`,
335
+ (error as Error).message
336
+ )
337
+ return { exerciseSlug, lang, frontmatter: {} }
338
+ }
339
+ }
340
+
341
+ async function collectReadmeFrontmatters(
342
+ bucket: Bucket,
343
+ courseSlug: string,
344
+ exercises: Exercise[]
345
+ ): Promise<Record<string, Record<string, ReadmeFrontmatter>>> {
346
+ const readmeTasks = exercises.flatMap(exercise =>
347
+ Object.keys(exercise.translations).map(lang => {
348
+ const readmeFilename = `README${getReadmeExtension(lang)}`
349
+ const readmePath = `courses/${courseSlug}/exercises/${exercise.slug}/${readmeFilename}`
350
+ return readReadmeFrontmatter(bucket, exercise.slug, lang, readmePath)
351
+ })
352
+ )
353
+
354
+ const readmeEntries = await Promise.all(readmeTasks)
355
+ const result: Record<string, Record<string, ReadmeFrontmatter>> = {}
356
+
357
+ for (const { exerciseSlug, lang, frontmatter } of readmeEntries) {
358
+ if (!result[exerciseSlug]) {
359
+ result[exerciseSlug] = {}
360
+ }
361
+
362
+ result[exerciseSlug][lang] = frontmatter
363
+ }
364
+
365
+ return result
366
+ }
367
+
368
+ function writeManifestJson(
369
+ manifest: PackageManifest,
370
+ localDir?: string
371
+ ): string {
372
+ const json = serializePackageManifest(manifest)
373
+
374
+ if (localDir) {
375
+ const learnDir = path.join(localDir, ".learn")
376
+ mkdirp.sync(learnDir)
377
+ const localPath = path.join(learnDir, PACKAGE_MANIFEST_FILENAME)
378
+ fs.writeFileSync(localPath, json, "utf-8")
379
+ }
380
+
381
+ return json
382
+ }
383
+
384
+ export async function generateAndPersistPackageManifest(
385
+ bucket: Bucket,
386
+ courseSlug: string,
387
+ options: { publishedAt: string | null; localDir?: string }
388
+ ): Promise<PackageManifest> {
389
+ const generatedAt = new Date().toISOString()
390
+
391
+ const { config: learnJson, exercises } = await buildConfig(
392
+ bucket,
393
+ courseSlug
394
+ )
395
+
396
+ const syllabus = await downloadJsonFile<Syllabus>(
397
+ bucket,
398
+ `courses/${courseSlug}/.learn/initialSyllabus.json`
399
+ )
400
+
401
+ const sidebar =
402
+ (await downloadJsonFile<Record<string, Record<string, string>>>(
403
+ bucket,
404
+ `courses/${courseSlug}/.learn/sidebar.json`
405
+ )) || {}
406
+
407
+ const readmeFrontmatters = await collectReadmeFrontmatters(
408
+ bucket,
409
+ courseSlug,
410
+ exercises
411
+ )
412
+
413
+ const manifest = buildPackageManifestFromSources({
414
+ learnJson,
415
+ syllabus,
416
+ sidebar,
417
+ exercises,
418
+ readmeFrontmatters,
419
+ options: {
420
+ slug: courseSlug,
421
+ generatedAt,
422
+ publishedAt: options.publishedAt,
423
+ },
424
+ })
425
+
426
+ const json = writeManifestJson(manifest, options.localDir)
427
+
428
+ await bucket
429
+ .file(packageManifestGcsPath(courseSlug))
430
+ .save(Buffer.from(json, "utf8"))
431
+
432
+ return manifest
433
+ }