@learnpack/learnpack 5.0.355 → 5.0.357

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.
Files changed (120) hide show
  1. package/lib/commands/serve.d.ts +0 -17
  2. package/lib/commands/serve.js +159 -129
  3. package/lib/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
  4. package/lib/creatorDist/assets/index-zrPponAn.css +1701 -0
  5. package/lib/creatorDist/index.html +2 -2
  6. package/lib/models/creator.d.ts +1 -1
  7. package/lib/services/ingest/adapters/gcsCourseStorage.d.ts +13 -0
  8. package/lib/services/ingest/adapters/gcsCourseStorage.js +58 -0
  9. package/lib/services/ingest/adapters/rigoUserTokenAuth.d.ts +15 -0
  10. package/lib/services/ingest/adapters/rigoUserTokenAuth.js +46 -0
  11. package/lib/services/ingest/adapters/rigobotPackageRegistry.d.ts +7 -0
  12. package/lib/services/ingest/adapters/rigobotPackageRegistry.js +119 -0
  13. package/lib/services/ingest/core/buildIngestConfig.d.ts +27 -0
  14. package/lib/services/ingest/core/buildIngestConfig.js +40 -0
  15. package/lib/services/ingest/core/buildInitialSyllabus.d.ts +24 -0
  16. package/lib/services/ingest/core/buildInitialSyllabus.js +104 -0
  17. package/lib/services/ingest/core/buildSidebar.d.ts +13 -0
  18. package/lib/services/ingest/core/buildSidebar.js +28 -0
  19. package/lib/services/ingest/core/coursePaths.d.ts +31 -0
  20. package/lib/services/ingest/core/coursePaths.js +50 -0
  21. package/lib/services/ingest/core/ingestCoursePackage.d.ts +52 -0
  22. package/lib/services/ingest/core/ingestCoursePackage.js +217 -0
  23. package/lib/services/ingest/core/normalizeLearnJson.d.ts +25 -0
  24. package/lib/services/ingest/core/normalizeLearnJson.js +29 -0
  25. package/lib/services/ingest/core/pairLessons.d.ts +29 -0
  26. package/lib/services/ingest/core/pairLessons.js +102 -0
  27. package/lib/services/ingest/core/planWrites.d.ts +44 -0
  28. package/lib/services/ingest/core/planWrites.js +59 -0
  29. package/lib/services/ingest/core/resolveSlug.d.ts +53 -0
  30. package/lib/services/ingest/core/resolveSlug.js +93 -0
  31. package/lib/services/ingest/core/types.d.ts +53 -0
  32. package/lib/services/ingest/core/types.js +2 -0
  33. package/lib/services/ingest/core/validatePackage.d.ts +38 -0
  34. package/lib/services/ingest/core/validatePackage.js +97 -0
  35. package/lib/services/ingest/createIngestService.d.ts +34 -0
  36. package/lib/services/ingest/createIngestService.js +38 -0
  37. package/lib/services/ingest/errors.d.ts +22 -0
  38. package/lib/services/ingest/errors.js +34 -0
  39. package/lib/services/ingest/http/errorMapping.d.ts +8 -0
  40. package/lib/services/ingest/http/errorMapping.js +40 -0
  41. package/lib/services/ingest/http/router.d.ts +32 -0
  42. package/lib/services/ingest/http/router.js +124 -0
  43. package/lib/services/ingest/http/zipPackageReader.d.ts +26 -0
  44. package/lib/services/ingest/http/zipPackageReader.js +62 -0
  45. package/lib/services/ingest/ports/courseStorage.d.ts +25 -0
  46. package/lib/services/ingest/ports/courseStorage.js +2 -0
  47. package/lib/services/ingest/ports/packageRegistry.d.ts +58 -0
  48. package/lib/services/ingest/ports/packageRegistry.js +2 -0
  49. package/lib/services/ingest/ports/requestAuthenticator.d.ts +25 -0
  50. package/lib/services/ingest/ports/requestAuthenticator.js +2 -0
  51. package/lib/utils/coursePackage/bucketIo.d.ts +23 -0
  52. package/lib/utils/coursePackage/bucketIo.js +36 -0
  53. package/lib/utils/coursePackage/learnJson.d.ts +26 -0
  54. package/lib/utils/coursePackage/learnJson.js +36 -0
  55. package/lib/utils/coursePackage/sidebar.d.ts +15 -0
  56. package/lib/utils/coursePackage/sidebar.js +26 -0
  57. package/lib/utils/creatorSocket.js +15 -21
  58. package/lib/utils/gcpCredentials.d.ts +18 -0
  59. package/lib/utils/gcpCredentials.js +27 -0
  60. package/lib/utils/rigoActions.d.ts +16 -0
  61. package/lib/utils/rigoActions.js +102 -1
  62. package/lib/utils/socketRegistry.d.ts +46 -0
  63. package/lib/utils/socketRegistry.js +83 -0
  64. package/package.json +1 -1
  65. package/src/commands/serve.ts +162 -114
  66. package/src/creator/README.md +63 -51
  67. package/src/creator/package-lock.json +1188 -8
  68. package/src/creator/package.json +5 -1
  69. package/src/creator/src/App.tsx +120 -54
  70. package/src/creator/src/components/FileUploader.tsx +1 -12
  71. package/src/creator/src/components/NotificationListener.tsx +1 -8
  72. package/src/creator/src/components/syllabus/SyllabusEditor.tsx +101 -36
  73. package/src/creator/src/locales/en.json +8 -0
  74. package/src/creator/src/locales/es.json +8 -0
  75. package/src/creator/src/utils/completionResult.test.ts +144 -0
  76. package/src/creator/src/utils/completionResult.ts +154 -0
  77. package/src/creator/src/utils/constants.ts +17 -4
  78. package/src/creator/src/utils/socket.test.ts +113 -0
  79. package/src/creator/src/utils/socket.ts +60 -37
  80. package/src/creator/src/utils/store.ts +84 -67
  81. package/src/creator/src/utils/useCompletionWatchdog.test.ts +99 -0
  82. package/src/creator/src/utils/useCompletionWatchdog.ts +37 -0
  83. package/src/creator/vitest.config.ts +16 -0
  84. package/src/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
  85. package/src/creatorDist/assets/index-zrPponAn.css +1701 -0
  86. package/src/creatorDist/index.html +2 -2
  87. package/src/models/creator.ts +1 -1
  88. package/src/services/ingest/adapters/gcsCourseStorage.ts +70 -0
  89. package/src/services/ingest/adapters/rigoUserTokenAuth.ts +65 -0
  90. package/src/services/ingest/adapters/rigobotPackageRegistry.ts +161 -0
  91. package/src/services/ingest/core/buildIngestConfig.ts +51 -0
  92. package/src/services/ingest/core/buildInitialSyllabus.ts +122 -0
  93. package/src/services/ingest/core/buildSidebar.ts +32 -0
  94. package/src/services/ingest/core/coursePaths.ts +59 -0
  95. package/src/services/ingest/core/ingestCoursePackage.ts +343 -0
  96. package/src/services/ingest/core/normalizeLearnJson.ts +47 -0
  97. package/src/services/ingest/core/pairLessons.ts +132 -0
  98. package/src/services/ingest/core/planWrites.ts +91 -0
  99. package/src/services/ingest/core/resolveSlug.ts +119 -0
  100. package/src/services/ingest/core/types.ts +57 -0
  101. package/src/services/ingest/core/validatePackage.ts +132 -0
  102. package/src/services/ingest/createIngestService.ts +66 -0
  103. package/src/services/ingest/errors.ts +45 -0
  104. package/src/services/ingest/http/errorMapping.ts +49 -0
  105. package/src/services/ingest/http/router.ts +156 -0
  106. package/src/services/ingest/http/zipPackageReader.ts +81 -0
  107. package/src/services/ingest/ports/courseStorage.ts +25 -0
  108. package/src/services/ingest/ports/packageRegistry.ts +60 -0
  109. package/src/services/ingest/ports/requestAuthenticator.ts +28 -0
  110. package/src/ui/_app/app.js +325 -325
  111. package/src/ui/app.tar.gz +0 -0
  112. package/src/utils/coursePackage/bucketIo.ts +46 -0
  113. package/src/utils/coursePackage/learnJson.ts +37 -0
  114. package/src/utils/coursePackage/sidebar.ts +28 -0
  115. package/src/utils/creatorSocket.ts +16 -22
  116. package/src/utils/gcpCredentials.ts +38 -0
  117. package/src/utils/rigoActions.ts +163 -0
  118. package/src/utils/socketRegistry.ts +85 -0
  119. package/lib/creatorDist/assets/index-CjddKHB_.css +0 -1
  120. package/src/creatorDist/assets/index-CjddKHB_.css +0 -1
@@ -0,0 +1,343 @@
1
+ import Console from "../../../utils/console"
2
+ import { mapWithConcurrency } from "../../../utils/s3/packageManifestBackfill"
3
+ import { CourseStorage } from "../ports/courseStorage"
4
+ import { PackageRegistry, RigoPackage } from "../ports/packageRegistry"
5
+ import { IngestError } from "../errors"
6
+ import { buildIngestConfig } from "./buildIngestConfig"
7
+ import { buildInitialSyllabus } from "./buildInitialSyllabus"
8
+ import { buildSidebar } from "./buildSidebar"
9
+ import { configPath, coursePrefix } from "./coursePaths"
10
+ import { normalizeLearnJson } from "./normalizeLearnJson"
11
+ import { pairLessons } from "./pairLessons"
12
+ import { planDeletions, planWrites } from "./planWrites"
13
+ import { resolveSlug, suggestSlugs } from "./resolveSlug"
14
+ import {
15
+ LEARN_JSON_PATH,
16
+ LESSON_PLAN_PATH,
17
+ PackageFiles,
18
+ readExercises,
19
+ validatePackage,
20
+ } from "./validatePackage"
21
+ import { LessonPlan, SkippedLesson } from "./types"
22
+
23
+ /** Enough parallelism to hide the round trip without opening a socket per file. */
24
+ const WRITE_CONCURRENCY = 10
25
+
26
+ export type IngestInput = {
27
+ files: PackageFiles;
28
+ /** Slug the caller asked for; derived from the title when absent. */
29
+ requestedSlug?: string;
30
+ /** Whether the caller accepted overwriting a course they already own. */
31
+ overwrite: boolean;
32
+ };
33
+
34
+ export type IngestDeps = {
35
+ storage: CourseStorage;
36
+ /** Registry already scoped to the calling user's token. */
37
+ registry: PackageRegistry;
38
+ /** Organization every ingested package must end up under. */
39
+ organization: string;
40
+ now?: number;
41
+ };
42
+
43
+ export type IngestReport = {
44
+ slug: string;
45
+ packageId: number;
46
+ organization: string;
47
+ lessons: number;
48
+ /** Planned lessons the package held no content for. */
49
+ skipped: SkippedLesson[];
50
+ written: number;
51
+ deleted: number;
52
+ /**
53
+ * True when the slug was already registered but its bucket was empty, so this
54
+ * run completed an earlier attempt instead of overwriting anything.
55
+ */
56
+ recoveredEmptyPackage: boolean;
57
+ };
58
+
59
+ const parseJson = <T>(raw: string, path: string): T => {
60
+ try {
61
+ return JSON.parse(raw) as T
62
+ } catch (error) {
63
+ throw new IngestError(
64
+ "INVALID_PACKAGE",
65
+ `${path} is not valid JSON: ${(error as Error).message}`,
66
+ error
67
+ )
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Reads the GitHub connection a previous config may hold.
73
+ *
74
+ * A user can link a course to a repository from the creator, and that link
75
+ * lives in `config.github`. Re-ingesting rewrites `config.json` wholesale, so
76
+ * without carrying this across, an overwrite would quietly disconnect a
77
+ * repository nobody asked to disconnect. `GET /config` guards the same field
78
+ * the same way.
79
+ * @param storage - Course storage.
80
+ * @param slug - Course slug.
81
+ * @returns The stored github block, or undefined.
82
+ */
83
+ async function readGithubConnection(
84
+ storage: CourseStorage,
85
+ slug: string
86
+ ): Promise<unknown> {
87
+ const raw = await storage.read(configPath(slug))
88
+ if (!raw) {
89
+ return undefined
90
+ }
91
+
92
+ try {
93
+ const existing = JSON.parse(raw)
94
+ return existing?.config?.github
95
+ } catch {
96
+ // A config we cannot parse has nothing to preserve; the fresh one wins.
97
+ return undefined
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Turns the configured organization into the id everything else speaks.
103
+ *
104
+ * Configuration names an organization the way a person would, but a package
105
+ * reports its organization as a numeric id: the field is a plain ForeignKey on
106
+ * a ModelSerializer, so it serializes to the primary key, and no endpoint ever
107
+ * returns a slug. Comparing the two directly is how a correctly configured
108
+ * ingest ends up refused with "belongs to organization 105, not learnpack-labs".
109
+ *
110
+ * Accepts an id as-is, and otherwise matches case-insensitively against the
111
+ * names of the organizations the caller belongs to. A miss lists them, since
112
+ * nothing else in the flow would ever show the reader an id to use.
113
+ * @param configured - Value of INGEST_ORGANIZATION.
114
+ * @param registry - Used to list the caller's organizations.
115
+ * @returns The organization id.
116
+ * @throws IngestError `ORGANIZATION_MISMATCH` listing the available options.
117
+ */
118
+ async function resolveOrganizationId(
119
+ configured: string,
120
+ registry: PackageRegistry
121
+ ): Promise<number> {
122
+ const asId = Number(configured)
123
+ if (Number.isInteger(asId) && asId > 0) {
124
+ return asId
125
+ }
126
+
127
+ const organizations = await registry.listOrganizations()
128
+ const wanted = configured.trim().toLowerCase()
129
+ const match = organizations.find(
130
+ org => (org.name || "").trim().toLowerCase() === wanted
131
+ )
132
+
133
+ if (match) {
134
+ return match.id
135
+ }
136
+
137
+ const available = organizations
138
+ .map(org => `${org.id} (${org.name})`)
139
+ .join(", ")
140
+
141
+ throw new IngestError(
142
+ "ORGANIZATION_MISMATCH",
143
+ available ?
144
+ `INGEST_ORGANIZATION is "${configured}", which matches none of your organizations: ${available}. Use one of those ids or its exact name.` :
145
+ `INGEST_ORGANIZATION is "${configured}", but this token belongs to no organization`
146
+ )
147
+ }
148
+
149
+ /**
150
+ * Settles the package registration for a slug the caller may already own.
151
+ *
152
+ * Registering before writing is deliberate: it is the cheap check that catches
153
+ * a slug collision or a wrong organization while the bucket is still untouched.
154
+ * @param slug - Resolved slug.
155
+ * @param learnJson - Normalized learn.json, stored as the package config.
156
+ * @param deps - Registry and expected organization.
157
+ * @param overwrite - Whether the caller accepted overwriting their own course.
158
+ * @param storage - Used to tell an abandoned registration from a real course.
159
+ * @returns The package and whether this run is completing an earlier attempt.
160
+ */
161
+ async function registerPackage(
162
+ slug: string,
163
+ learnJson: Record<string, unknown>,
164
+ deps: IngestDeps,
165
+ overwrite: boolean,
166
+ storage: CourseStorage
167
+ ): Promise<{ pkg: RigoPackage; recovered: boolean }> {
168
+ const available = await deps.registry.isSlugAvailable(slug)
169
+
170
+ if (available) {
171
+ return {
172
+ pkg: await deps.registry.createPackage(slug, learnJson),
173
+ recovered: false,
174
+ }
175
+ }
176
+
177
+ const existing = await deps.registry.getPackageBySlug(slug)
178
+
179
+ if (!existing) {
180
+ // Taken, and the registry will not show it to us: it belongs to another
181
+ // user. No suggestion is offered here — the caller cannot fix this by
182
+ // retrying, only by choosing a different slug.
183
+ throw new IngestError(
184
+ "SLUG_OWNED_BY_OTHER",
185
+ `The slug "${slug}" belongs to another user`
186
+ )
187
+ }
188
+
189
+ if (!overwrite) {
190
+ const contents = await storage.list(coursePrefix(slug))
191
+
192
+ // A registered slug with an empty bucket is the fingerprint of a run that
193
+ // registered and then failed before writing. Refusing it would strand the
194
+ // user behind a 409 for a course that, as far as they can tell, does not
195
+ // exist.
196
+ if (contents.length === 0) {
197
+ Console.info(
198
+ `[ingest] "${slug}" was registered but never written; completing that attempt`
199
+ )
200
+ return { pkg: existing, recovered: true }
201
+ }
202
+
203
+ const suggestions = await suggestSlugs(slug, deps.registry)
204
+ throw new IngestError(
205
+ "SLUG_TAKEN",
206
+ `You already have a course at "${slug}". Re-run with overwrite to replace it, or use one of: ${suggestions.join(
207
+ ", "
208
+ )}`
209
+ )
210
+ }
211
+
212
+ return { pkg: existing, recovered: false }
213
+ }
214
+
215
+ /**
216
+ * Ingests a finished course package: registers it and writes it to the bucket.
217
+ *
218
+ * The order is validate → resolve slug → register → write → prune, and it is
219
+ * not arbitrary. Validation and slug resolution are free and catch most bad
220
+ * requests; registration is the first thing that can conflict, and doing it
221
+ * before any write means a rejected course leaves nothing behind.
222
+ *
223
+ * Failures throw `IngestError` rather than being collected into the report. A
224
+ * partial ingest has no useful middle state to report — unlike publishing,
225
+ * nothing here runs detached after the response, so there is nothing for a
226
+ * later sweep to resume. Re-running is the recovery path, and the empty-package
227
+ * case above is what keeps that from being blocked.
228
+ * @param input - The extracted package and the caller's choices.
229
+ * @param deps - Storage, registry and expected organization.
230
+ * @returns What was written.
231
+ */
232
+ export async function ingestCoursePackage(
233
+ input: IngestInput,
234
+ deps: IngestDeps
235
+ ): Promise<IngestReport> {
236
+ validatePackage(input.files)
237
+
238
+ const rawLearnJson = parseJson<Record<string, unknown>>(
239
+ input.files[LEARN_JSON_PATH],
240
+ LEARN_JSON_PATH
241
+ )
242
+ const plan = parseJson<LessonPlan>(
243
+ input.files[LESSON_PLAN_PATH],
244
+ LESSON_PLAN_PATH
245
+ )
246
+
247
+ const slug = resolveSlug({
248
+ requestedSlug: input.requestedSlug,
249
+ courseTitle: plan.course_title,
250
+ })
251
+
252
+ const learnJson = normalizeLearnJson({ learnJson: rawLearnJson, slug })
253
+
254
+ const { pkg, recovered } = await registerPackage(
255
+ slug,
256
+ learnJson,
257
+ deps,
258
+ input.overwrite,
259
+ deps.storage
260
+ )
261
+
262
+ const organizationId = await resolveOrganizationId(
263
+ deps.organization,
264
+ deps.registry
265
+ )
266
+
267
+ if (pkg.organization === null) {
268
+ await deps.registry.assignOrganization(pkg.id, organizationId)
269
+ } else if (pkg.organization !== organizationId) {
270
+ // There is no way to detach an organization through the API, so landing in
271
+ // the wrong one is only fixable by hand. Better to stop before writing.
272
+ throw new IngestError(
273
+ "ORGANIZATION_MISMATCH",
274
+ `"${slug}" belongs to organization ${pkg.organization}, not ${organizationId} (${deps.organization})`
275
+ )
276
+ }
277
+
278
+ const { slugs, languagesBySlug } = readExercises(input.files)
279
+ const { lessons, skipped } = pairLessons(plan, slugs, languagesBySlug)
280
+
281
+ const language = (plan.language || "en").trim().toLowerCase() || "en"
282
+ const syllabus = buildInitialSyllabus({
283
+ slug,
284
+ language,
285
+ lessons,
286
+ learnJson,
287
+ now: deps.now,
288
+ })
289
+ const sidebar = buildSidebar(lessons)
290
+
291
+ const github = input.overwrite ?
292
+ await readGithubConnection(deps.storage, slug) :
293
+ undefined
294
+ const projected = buildIngestConfig({ lessons, learnJson })
295
+ const config = github ?
296
+ { ...projected, config: { ...projected.config, github } } :
297
+ projected
298
+
299
+ const writes = planWrites({
300
+ slug,
301
+ files: input.files,
302
+ lessons,
303
+ learnJson,
304
+ syllabus,
305
+ sidebar,
306
+ config,
307
+ })
308
+
309
+ let deleted = 0
310
+ if (input.overwrite) {
311
+ const existing = await deps.storage.list(coursePrefix(slug))
312
+ const stale = planDeletions(existing, new Set(writes.keys()), slug)
313
+ await deps.storage.deleteMany(stale)
314
+ deleted = stale.length
315
+ }
316
+
317
+ // Written in parallel, with a cap. A course is around fifty small objects —
318
+ // one README per lesson plus the generation artifacts — and one round trip at
319
+ // a time turned a few seconds of work into most of a minute, long enough for
320
+ // the uploading client to give up while the server was still writing.
321
+ await mapWithConcurrency(
322
+ [...writes.entries()],
323
+ WRITE_CONCURRENCY,
324
+ ([path, contents]) => deps.storage.write(path, contents)
325
+ )
326
+
327
+ if (skipped.length > 0) {
328
+ Console.warning(
329
+ `[ingest] "${slug}": ${skipped.length} planned lesson(s) had no content and were left out`
330
+ )
331
+ }
332
+
333
+ return {
334
+ slug,
335
+ packageId: pkg.id,
336
+ organization: deps.organization,
337
+ lessons: lessons.length,
338
+ skipped,
339
+ written: writes.size,
340
+ deleted,
341
+ recoveredEmptyPackage: recovered,
342
+ }
343
+ }
@@ -0,0 +1,47 @@
1
+ import { BREATHECODE_TELEMETRY_URL } from "../../../utils/api"
2
+
3
+ export type NormalizeLearnJsonInput = {
4
+ /** The `learn.json` shipped inside the package. */
5
+ learnJson: Record<string, unknown>;
6
+ /** The slug the ingest resolved, which wins over whatever the package says. */
7
+ slug: string;
8
+ };
9
+
10
+ /**
11
+ * Fills in the fields the generator's exporter omits.
12
+ *
13
+ * The generator writes a `learn.json` good enough to run locally, but three
14
+ * fields only matter once the course lives in the bucket, and each fails
15
+ * quietly when missing:
16
+ *
17
+ * - `telemetry.batch` — without it student telemetry is never sent.
18
+ * - `preview` — publish falls back to the generic LearnPack logo.
19
+ * - `technologies` — the organization package listing filters on it.
20
+ *
21
+ * The slug is overwritten rather than trusted: it is resolved against the
22
+ * registry before anything is written, and the copy inside the package predates
23
+ * that. Leaving them to disagree puts the bucket path and `learn.json` out of
24
+ * sync, which `change-slug` would then fail to reconcile.
25
+ * @param input - The package's learn.json and the resolved slug.
26
+ * @returns A learn.json ready to be written to the bucket.
27
+ */
28
+ export function normalizeLearnJson(
29
+ input: NormalizeLearnJsonInput
30
+ ): Record<string, unknown> {
31
+ const { learnJson, slug } = input
32
+ const telemetry =
33
+ (learnJson.telemetry as Record<string, unknown> | undefined) || {}
34
+
35
+ return {
36
+ ...learnJson,
37
+ slug,
38
+ technologies: Array.isArray(learnJson.technologies) ?
39
+ learnJson.technologies :
40
+ [],
41
+ telemetry: {
42
+ ...telemetry,
43
+ batch: telemetry.batch || BREATHECODE_TELEMETRY_URL,
44
+ },
45
+ preview: learnJson.preview || `https://${slug}.learn-pack.com/preview.png`,
46
+ }
47
+ }
@@ -0,0 +1,132 @@
1
+ import Console from "../../../utils/console"
2
+ import { LessonType } from "../../../utils/packageManifest"
3
+ import {
4
+ IngestLesson,
5
+ LessonPlan,
6
+ PlannedLesson,
7
+ SkippedLesson,
8
+ } from "./types"
9
+
10
+ const VALID_LESSON_TYPES = new Set<LessonType>(["READ", "CODE", "QUIZ"])
11
+
12
+ /**
13
+ * The exercise folder a lesson id owns, e.g. "01.1" for "01.1-split-a-class".
14
+ *
15
+ * Parsed by splitting rather than by a two-digit regex: the generator's own id
16
+ * parser accepts "1.0", so a stricter pattern here would reject a plan the
17
+ * generator considers valid.
18
+ * @param exerciseSlug - Folder name inside `exercises/`.
19
+ * @returns The leading id, or the whole slug when there is no hyphen.
20
+ */
21
+ export function lessonIdFromSlug(exerciseSlug: string): string {
22
+ const [head] = exerciseSlug.split("-")
23
+ return head
24
+ }
25
+
26
+ /**
27
+ * Reads the lesson type, falling back rather than failing.
28
+ *
29
+ * The generator declares this as a Pydantic Literal, so a successfully
30
+ * generated course cannot carry anything else — but the package is consumed
31
+ * long after the fact, and a course that renders every lesson as READ beats one
32
+ * that refuses to ingest.
33
+ * @param type - Raw value from the plan.
34
+ * @param lessonId - Used to make the warning actionable.
35
+ * @returns A valid lesson type.
36
+ */
37
+ function coerceLessonType(
38
+ type: string | undefined,
39
+ lessonId: string
40
+ ): LessonType {
41
+ if (type && VALID_LESSON_TYPES.has(type as LessonType)) {
42
+ return type as LessonType
43
+ }
44
+
45
+ if (type) {
46
+ Console.warning(
47
+ `[ingest] Lesson ${lessonId} has unknown type "${type}"; treating it as READ`
48
+ )
49
+ }
50
+
51
+ return "READ"
52
+ }
53
+
54
+ /**
55
+ * The generation topic for a lesson.
56
+ *
57
+ * `lesson_focus` is absent in packages from the pre-ADR-0001 schema, and this
58
+ * value is not what students read — the student-facing description lives per
59
+ * language in `translations[lang].description`, which the descriptions backfill
60
+ * fills in later. The title is a fine stand-in.
61
+ * @param lesson - Planned lesson.
62
+ * @returns The focus, or the title.
63
+ */
64
+ function describeLesson(lesson: PlannedLesson): string {
65
+ const focus = (lesson.lesson_focus || "").trim()
66
+ return focus || lesson.title
67
+ }
68
+
69
+ export type PairLessonsResult = {
70
+ lessons: IngestLesson[];
71
+ skipped: SkippedLesson[];
72
+ };
73
+
74
+ /**
75
+ * Matches planned lessons to the exercise folders that actually hold content.
76
+ *
77
+ * A plan lists every lesson that was *planned*; module-batched generation can
78
+ * stop early, leaving later modules without files. Pairing against the folders
79
+ * present is what makes a partial course ingest as the eight lessons it has
80
+ * rather than the twelve it intended — and the leftovers are reported instead
81
+ * of dropped, so nobody discovers the gap by scrolling the IDE.
82
+ * @param plan - The generator's lesson plan.
83
+ * @param exerciseSlugs - Folder names found under `exercises/` in the package.
84
+ * @param languagesBySlug - Language codes with a README, per exercise slug.
85
+ * @returns Paired lessons in plan order, plus the entries that had no content.
86
+ */
87
+ export function pairLessons(
88
+ plan: LessonPlan,
89
+ exerciseSlugs: string[],
90
+ languagesBySlug: Record<string, string[]>
91
+ ): PairLessonsResult {
92
+ const slugsById = new Map<string, string>()
93
+ for (const slug of exerciseSlugs) {
94
+ const id = lessonIdFromSlug(slug)
95
+ // First folder wins: a duplicate id means the package is malformed, and
96
+ // picking arbitrarily is no worse than picking the last one.
97
+ if (!slugsById.has(id)) {
98
+ slugsById.set(id, slug)
99
+ }
100
+ }
101
+
102
+ const lessons: IngestLesson[] = []
103
+ const skipped: SkippedLesson[] = []
104
+
105
+ for (const planned of plan.lessons || []) {
106
+ const exerciseSlug = slugsById.get(planned.id)
107
+
108
+ if (!exerciseSlug) {
109
+ skipped.push({
110
+ id: planned.id,
111
+ title: planned.title,
112
+ reason: "no-content",
113
+ })
114
+ continue
115
+ }
116
+
117
+ lessons.push({
118
+ id: planned.id,
119
+ exerciseSlug,
120
+ title: planned.title,
121
+ type: coerceLessonType(planned.type, planned.id),
122
+ durationMinutes:
123
+ typeof planned.duration_minutes === "number" ?
124
+ planned.duration_minutes :
125
+ 0,
126
+ description: describeLesson(planned),
127
+ languages: languagesBySlug[exerciseSlug] || [],
128
+ })
129
+ }
130
+
131
+ return { lessons, skipped }
132
+ }
@@ -0,0 +1,91 @@
1
+ import {
2
+ configPath,
3
+ coursePrefix,
4
+ learnJsonPath,
5
+ prunablePrefixes,
6
+ sidebarPath,
7
+ syllabusPath,
8
+ } from "./coursePaths"
9
+ import { PackageFiles } from "./validatePackage"
10
+ import { IngestLesson } from "./types"
11
+
12
+ /** Package paths copied through untouched, keyed by their prefix. */
13
+ const PASSTHROUGH_PREFIXES = [".learn/artifacts/", "exercises/"]
14
+
15
+ export type PlanWritesInput = {
16
+ slug: string;
17
+ files: PackageFiles;
18
+ lessons: IngestLesson[];
19
+ learnJson: Record<string, unknown>;
20
+ syllabus: unknown;
21
+ sidebar: unknown;
22
+ config: unknown;
23
+ };
24
+
25
+ const normalize = (path: string): string => path.replace(/\\/g, "/")
26
+
27
+ const stringify = (value: unknown): string => JSON.stringify(value, null, 2)
28
+
29
+ /**
30
+ * Lays out every object the ingest will write, keyed by its bucket path.
31
+ *
32
+ * Returned rather than written so the orchestrator can compare the plan against
33
+ * what is already in the bucket before touching anything — which is what makes
34
+ * pruning stale lessons possible, and what lets a test assert the whole tree
35
+ * without a storage double.
36
+ *
37
+ * Content files ride through verbatim; only the four derived documents are
38
+ * generated. Note `.learn/config.json` from the package is *not* among them:
39
+ * it is a projection of the other files, and the copy the generator ships
40
+ * carries localhost URLs and a stale exercise list.
41
+ * @param input - Slug, package files and the derived documents.
42
+ * @returns Bucket path → contents.
43
+ */
44
+ export function planWrites(input: PlanWritesInput): Map<string, string> {
45
+ const writes = new Map<string, string>()
46
+ const prefix = coursePrefix(input.slug)
47
+
48
+ for (const [rawPath, contents] of Object.entries(input.files)) {
49
+ const path = normalize(rawPath)
50
+
51
+ if (PASSTHROUGH_PREFIXES.some(known => path.startsWith(known))) {
52
+ writes.set(`${prefix}${path}`, contents)
53
+ }
54
+ }
55
+
56
+ writes.set(learnJsonPath(input.slug), stringify(input.learnJson))
57
+ writes.set(syllabusPath(input.slug), stringify(input.syllabus))
58
+ writes.set(sidebarPath(input.slug), stringify(input.sidebar))
59
+ writes.set(configPath(input.slug), stringify(input.config))
60
+
61
+ return writes
62
+ }
63
+
64
+ /**
65
+ * Objects the course still holds that this ingest is not going to rewrite.
66
+ *
67
+ * Scoped to the prefixes the ingest owns. Everything else under the course
68
+ * belongs to somebody else — preview images the user uploaded,
69
+ * `package-manifest.json`, the publish journal — and deleting those would
70
+ * destroy work the ingest never created.
71
+ *
72
+ * Pruning matters because `buildConfig` derives the exercise list from whatever
73
+ * files exist: a regenerated course with fewer lessons would otherwise keep
74
+ * listing the old folders as lessons that open empty.
75
+ * @param existing - Every object path currently under the course.
76
+ * @param planned - Paths this ingest is about to write.
77
+ * @param slug - Course slug, for scoping the prunable prefixes.
78
+ * @returns Paths safe to delete.
79
+ */
80
+ export function planDeletions(
81
+ existing: string[],
82
+ planned: Set<string>,
83
+ slug: string
84
+ ): string[] {
85
+ const prunable = prunablePrefixes(slug)
86
+
87
+ return existing.filter(
88
+ path =>
89
+ !planned.has(path) && prunable.some(prefix => path.startsWith(prefix))
90
+ )
91
+ }