@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/lib/commands/serve.js +6 -0
- package/lib/utils/export/scorm.js +5 -0
- package/lib/utils/export/zip.js +4 -0
- package/lib/utils/packageManifest.d.ts +58 -0
- package/lib/utils/packageManifest.js +250 -0
- package/lib/utils/s3/packageManifestBackfill.d.ts +82 -0
- package/lib/utils/s3/packageManifestBackfill.js +485 -0
- package/lib/utils/titlefy.d.ts +2 -0
- package/lib/utils/titlefy.js +25 -0
- package/package.json +4 -1
- package/src/commands/serve.ts +7 -0
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +2097 -2097
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/export/README.md +3 -2
- package/src/utils/export/scorm.ts +6 -0
- package/src/utils/export/zip.ts +6 -1
- package/src/utils/packageManifest.ts +433 -0
- package/src/utils/s3/packageManifestBackfill.ts +776 -0
- package/src/utils/titlefy.ts +26 -0
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
|
-
- **
|
|
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
|
|
package/src/utils/export/zip.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|