@learnpack/learnpack 5.0.349 → 5.0.351

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,11 @@ 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`)
10
+
11
+ `package-manifest.json` is regenerated on each ZIP/SCORM export with `publishedAt: null`. See [`docs/package-manifest.md`](../../../../docs/package-manifest.md) in the monorepo.
9
12
 
10
13
  ## Usage
11
14
 
@@ -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,392 @@
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
+ video: Record<string, string | null>;
26
+ };
27
+
28
+ export type PackageManifest = {
29
+ schemaVersion: number;
30
+ generatedAt: string;
31
+ publishedAt: string | null;
32
+ slug: string;
33
+ title: Record<string, string>;
34
+ description: Record<string, string>;
35
+ preview: string;
36
+ technologies?: string[];
37
+ difficulty?: string;
38
+ duration?: number;
39
+ lessons: PackageManifestLesson[];
40
+ };
41
+
42
+ export type ReadmeFrontmatter = {
43
+ tutorial?: string;
44
+ intro?: string;
45
+ title?: string;
46
+ };
47
+
48
+ export type PackageManifestBuildInput = {
49
+ learnJson: Record<string, unknown>;
50
+ syllabus: Syllabus | null;
51
+ sidebar: Record<string, Record<string, string>>;
52
+ exercises: Exercise[];
53
+ readmeFrontmatters: Record<string, Record<string, ReadmeFrontmatter>>;
54
+ options: {
55
+ slug: string;
56
+ generatedAt: string;
57
+ publishedAt: string | null;
58
+ };
59
+ };
60
+
61
+ const VALID_LESSON_TYPES = new Set<LessonType>(["READ", "CODE", "QUIZ"])
62
+
63
+ export function packageManifestGcsPath(courseSlug: string): string {
64
+ return `courses/${courseSlug}/${PACKAGE_MANIFEST_REL_PATH}`
65
+ }
66
+
67
+ export function resolvePreviewUrl(slug: string, preview?: string): string {
68
+ if (preview && preview.startsWith("http")) {
69
+ return preview
70
+ }
71
+
72
+ return `https://${slug}.learn-pack.com/preview.png`
73
+ }
74
+
75
+ function findSyllabusLesson(
76
+ exerciseSlug: string,
77
+ lessons: Lesson[]
78
+ ): Lesson | undefined {
79
+ return lessons.find(lesson => {
80
+ const candidates = [
81
+ slugify(lesson.id + "-" + lesson.title),
82
+ lesson.uid,
83
+ ].filter(Boolean)
84
+ return candidates.includes(exerciseSlug)
85
+ })
86
+ }
87
+
88
+ function deriveLessonId(exerciseSlug: string): string {
89
+ const match = exerciseSlug.match(/^(\d+(?:\.\d+)?)/)
90
+ return match ? match[1] : exerciseSlug
91
+ }
92
+
93
+ function coerceLessonType(type: string | undefined): LessonType {
94
+ if (type && VALID_LESSON_TYPES.has(type as LessonType)) {
95
+ return type as LessonType
96
+ }
97
+
98
+ if (type) {
99
+ console.warn(
100
+ `[package-manifest] Invalid lesson type "${type}", defaulting to READ`
101
+ )
102
+ }
103
+
104
+ return "READ"
105
+ }
106
+
107
+ export function extractReadmeH1(body: string): string | null {
108
+ const match = body.match(/^#\s+(.+)$/m)
109
+ if (!match) {
110
+ return null
111
+ }
112
+
113
+ const title = match[1].replace(/\r/g, "").trim()
114
+ return title || null
115
+ }
116
+
117
+ export function normalizeReadmeTitle(raw: string): string | null {
118
+ const normalized = raw
119
+ .trim()
120
+ .replace(/^\d+(?:\.\d+)?\s*[.:-]?\s*/, "")
121
+ .trim()
122
+ return normalized || null
123
+ }
124
+
125
+ function firstNonEmpty(
126
+ ...candidates: Array<string | null | undefined>
127
+ ): string | null {
128
+ for (const candidate of candidates) {
129
+ const value = candidate?.trim()
130
+ if (value) {
131
+ return value
132
+ }
133
+ }
134
+
135
+ return null
136
+ }
137
+
138
+ function resolveLessonTitle(
139
+ exercise: Exercise,
140
+ sidebar: Record<string, Record<string, string>>,
141
+ syllabusLesson: Lesson | undefined,
142
+ courseLang: string,
143
+ readmeByLang: Record<string, ReadmeFrontmatter>
144
+ ): Record<string, string> {
145
+ const sidebarEntry = sidebar[exercise.slug] ?? {}
146
+ const languages = new Set([
147
+ ...Object.keys(exercise.translations),
148
+ ...Object.keys(sidebarEntry),
149
+ ])
150
+
151
+ const titles: Record<string, string> = {}
152
+
153
+ for (const lang of languages) {
154
+ const title = firstNonEmpty(
155
+ readmeByLang[lang]?.title,
156
+ sidebarEntry[lang] ? formatSidebarTitle(sidebarEntry[lang]) : null,
157
+ lang === courseLang ? syllabusLesson?.title : null
158
+ )
159
+
160
+ if (title) {
161
+ titles[lang] = title
162
+ }
163
+ }
164
+
165
+ return titles
166
+ }
167
+
168
+ function resolveLessonVideos(
169
+ exercise: Exercise,
170
+ readmeFrontmatters: Record<string, Record<string, ReadmeFrontmatter>>
171
+ ): Record<string, string | null> {
172
+ const video: Record<string, string | null> = {}
173
+ const frontmatters = readmeFrontmatters[exercise.slug] || {}
174
+
175
+ for (const lang of Object.keys(exercise.translations)) {
176
+ const attrs = frontmatters[lang] || {}
177
+ const url = attrs.tutorial || attrs.intro || null
178
+ video[lang] = url && url.trim().length > 0 ? url.trim() : null
179
+ }
180
+
181
+ return video
182
+ }
183
+
184
+ export function buildPackageManifestFromSources(
185
+ input: PackageManifestBuildInput
186
+ ): PackageManifest {
187
+ const {
188
+ learnJson,
189
+ syllabus,
190
+ sidebar,
191
+ exercises,
192
+ readmeFrontmatters,
193
+ options,
194
+ } = input
195
+
196
+ const syllabusLessons = syllabus?.lessons ?? []
197
+ const courseLang = syllabus?.courseInfo?.language || "en"
198
+
199
+ const sortedExercises = [...exercises].sort(
200
+ (a, b) => a.position - b.position
201
+ )
202
+
203
+ const lessons: PackageManifestLesson[] = sortedExercises.map(exercise => {
204
+ const syllabusLesson = findSyllabusLesson(exercise.slug, syllabusLessons)
205
+
206
+ const id = syllabusLesson?.id ?? deriveLessonId(exercise.slug)
207
+ const type = syllabusLesson ?
208
+ coerceLessonType(syllabusLesson.type) :
209
+ "READ"
210
+
211
+ if (!syllabusLesson) {
212
+ console.warn(
213
+ `[package-manifest] No syllabus match for exercise "${exercise.slug}", using derived id "${id}"`
214
+ )
215
+ }
216
+
217
+ return {
218
+ id,
219
+ slug: exercise.slug,
220
+ position: exercise.position,
221
+ type,
222
+ title: resolveLessonTitle(
223
+ exercise,
224
+ sidebar,
225
+ syllabusLesson,
226
+ courseLang,
227
+ readmeFrontmatters[exercise.slug] ?? {}
228
+ ),
229
+ video: resolveLessonVideos(exercise, readmeFrontmatters),
230
+ }
231
+ })
232
+
233
+ const title = (learnJson.title as Record<string, string>) || {}
234
+ const description = (learnJson.description as Record<string, string>) || {}
235
+
236
+ return {
237
+ schemaVersion: SCHEMA_VERSION,
238
+ generatedAt: options.generatedAt,
239
+ publishedAt: options.publishedAt,
240
+ slug: options.slug,
241
+ title,
242
+ description,
243
+ preview: resolvePreviewUrl(
244
+ options.slug,
245
+ learnJson.preview as string | undefined
246
+ ),
247
+ technologies: learnJson.technologies as string[] | undefined,
248
+ difficulty: learnJson.difficulty as string | undefined,
249
+ duration: learnJson.duration as number | undefined,
250
+ lessons,
251
+ }
252
+ }
253
+
254
+ async function downloadJsonFile<T>(
255
+ bucket: Bucket,
256
+ filePath: string
257
+ ): Promise<T | null> {
258
+ try {
259
+ const [buf] = await bucket.file(filePath).download()
260
+ return JSON.parse(buf.toString()) as T
261
+ } catch {
262
+ return null
263
+ }
264
+ }
265
+
266
+ async function readReadmeFrontmatter(
267
+ bucket: Bucket,
268
+ exerciseSlug: string,
269
+ lang: string,
270
+ readmePath: string
271
+ ): Promise<{
272
+ exerciseSlug: string;
273
+ lang: string;
274
+ frontmatter: ReadmeFrontmatter;
275
+ }> {
276
+ try {
277
+ 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
+ return {
283
+ exerciseSlug,
284
+ lang,
285
+ frontmatter: {
286
+ tutorial: attributes.tutorial,
287
+ intro: attributes.intro,
288
+ ...(title ? { title } : {}),
289
+ },
290
+ }
291
+ } catch (error) {
292
+ console.warn(
293
+ `[package-manifest] Could not read README for ${exerciseSlug} (${lang}):`,
294
+ (error as Error).message
295
+ )
296
+ return { exerciseSlug, lang, frontmatter: {} }
297
+ }
298
+ }
299
+
300
+ async function collectReadmeFrontmatters(
301
+ bucket: Bucket,
302
+ courseSlug: string,
303
+ exercises: Exercise[]
304
+ ): Promise<Record<string, Record<string, ReadmeFrontmatter>>> {
305
+ const readmeTasks = exercises.flatMap(exercise =>
306
+ Object.keys(exercise.translations).map(lang => {
307
+ const readmeFilename = `README${getReadmeExtension(lang)}`
308
+ const readmePath = `courses/${courseSlug}/exercises/${exercise.slug}/${readmeFilename}`
309
+ return readReadmeFrontmatter(bucket, exercise.slug, lang, readmePath)
310
+ })
311
+ )
312
+
313
+ const readmeEntries = await Promise.all(readmeTasks)
314
+ const result: Record<string, Record<string, ReadmeFrontmatter>> = {}
315
+
316
+ for (const { exerciseSlug, lang, frontmatter } of readmeEntries) {
317
+ if (!result[exerciseSlug]) {
318
+ result[exerciseSlug] = {}
319
+ }
320
+
321
+ result[exerciseSlug][lang] = frontmatter
322
+ }
323
+
324
+ return result
325
+ }
326
+
327
+ function writeManifestJson(
328
+ manifest: PackageManifest,
329
+ localDir?: string
330
+ ): string {
331
+ const json = JSON.stringify(manifest, null, 2)
332
+
333
+ if (localDir) {
334
+ const learnDir = path.join(localDir, ".learn")
335
+ mkdirp.sync(learnDir)
336
+ const localPath = path.join(learnDir, PACKAGE_MANIFEST_FILENAME)
337
+ fs.writeFileSync(localPath, json, "utf-8")
338
+ }
339
+
340
+ return json
341
+ }
342
+
343
+ export async function generateAndPersistPackageManifest(
344
+ bucket: Bucket,
345
+ courseSlug: string,
346
+ options: { publishedAt: string | null; localDir?: string }
347
+ ): Promise<PackageManifest> {
348
+ const generatedAt = new Date().toISOString()
349
+
350
+ const { config: learnJson, exercises } = await buildConfig(
351
+ bucket,
352
+ courseSlug
353
+ )
354
+
355
+ const syllabus = await downloadJsonFile<Syllabus>(
356
+ bucket,
357
+ `courses/${courseSlug}/.learn/initialSyllabus.json`
358
+ )
359
+
360
+ const sidebar =
361
+ (await downloadJsonFile<Record<string, Record<string, string>>>(
362
+ bucket,
363
+ `courses/${courseSlug}/.learn/sidebar.json`
364
+ )) || {}
365
+
366
+ const readmeFrontmatters = await collectReadmeFrontmatters(
367
+ bucket,
368
+ courseSlug,
369
+ exercises
370
+ )
371
+
372
+ const manifest = buildPackageManifestFromSources({
373
+ learnJson,
374
+ syllabus,
375
+ sidebar,
376
+ exercises,
377
+ readmeFrontmatters,
378
+ options: {
379
+ slug: courseSlug,
380
+ generatedAt,
381
+ publishedAt: options.publishedAt,
382
+ },
383
+ })
384
+
385
+ const json = writeManifestJson(manifest, options.localDir)
386
+
387
+ await bucket
388
+ .file(packageManifestGcsPath(courseSlug))
389
+ .save(Buffer.from(json, "utf8"))
390
+
391
+ return manifest
392
+ }
@@ -9,43 +9,22 @@
9
9
  <script type="text/javascript">
10
10
 
11
11
 
12
- var currentPage = null;
13
12
  var startTimeStamp = null;
14
13
  var processedUnload = false;
15
14
  var reachedEnd = false;
16
- var language = 'es';
17
15
 
18
16
  function doStart() {
19
17
  startTimeStamp = new Date();
20
18
 
21
19
  ScormProcessInitialize();
22
20
 
21
+ // Mark the attempt as started as an early fallback. The IDE's
22
+ // ScormReporter re-asserts this (with a commit) and reports
23
+ // per-step progress/score once it mounts.
23
24
  var completionStatus = ScormProcessGetValue('cmi.core.lesson_status');
24
25
  if (completionStatus == 'not attempted') {
25
26
  ScormProcessSetValue('cmi.core.lesson_status', 'incomplete');
26
27
  }
27
-
28
- var bookmark = ScormProcessGetValue('cmi.core.lesson_location');
29
-
30
- if (bookmark == '') {
31
- currentPage = 0;
32
- } else {
33
- if (
34
- confirm(
35
- 'Would you like to resume from where you previously left off?'
36
- )
37
- ) {
38
- currentPage = parseInt(bookmark, 10);
39
- } else {
40
- currentPage = 0;
41
- }
42
- }
43
-
44
- goToPage();
45
- }
46
-
47
- function goToPage() {
48
- ScormProcessSetValue('cmi.core.lesson_location', currentPage);
49
28
  }
50
29
 
51
30
  function doUnload(pressedExit) {
@@ -72,20 +51,6 @@
72
51
  ScormProcessFinish();
73
52
  }
74
53
 
75
- function doPrevious() {
76
- if (currentPage > 0) {
77
- currentPage--;
78
- }
79
- goToPage();
80
- }
81
-
82
- function doNext() {
83
- if (currentPage < pageArray.length - 1) {
84
- currentPage++;
85
- }
86
- goToPage();
87
- }
88
-
89
54
  function doExit() {
90
55
  if (
91
56
  reachedEnd == false &&
@@ -0,0 +1,26 @@
1
+ const SLUG_PATTERN = /^\d+(?:\.\d+)?-/
2
+
3
+ export function titlefy(str: string): string {
4
+ const arr = str.split("-")
5
+ arr.shift()
6
+ const result = arr.join(" ")
7
+ if (!result) {
8
+ return ""
9
+ }
10
+
11
+ return result.charAt(0).toUpperCase() + result.slice(1)
12
+ }
13
+
14
+ export function formatSidebarTitle(value: string): string | null {
15
+ const trimmed = value.trim()
16
+ if (!trimmed) {
17
+ return null
18
+ }
19
+
20
+ if (/^\d+(?:\.\d+)?$/.test(trimmed)) {
21
+ return null
22
+ }
23
+
24
+ const formatted = SLUG_PATTERN.test(trimmed) ? titlefy(trimmed) : trimmed
25
+ return formatted.trim() || null
26
+ }