@learnpack/learnpack 5.0.352 → 5.0.354

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 (85) hide show
  1. package/lib/commands/publish.js +6 -0
  2. package/lib/commands/serve.js +85 -259
  3. package/lib/models/creator.d.ts +6 -0
  4. package/lib/scripts/descriptionsGcsBackfill.d.ts +1 -0
  5. package/lib/scripts/descriptionsGcsBackfill.js +141 -0
  6. package/lib/scripts/descriptionsS3Backfill.d.ts +1 -0
  7. package/lib/scripts/descriptionsS3Backfill.js +277 -0
  8. package/lib/scripts/descriptionsSweep.d.ts +1 -0
  9. package/lib/scripts/descriptionsSweep.js +142 -0
  10. package/lib/utils/api.d.ts +7 -0
  11. package/lib/utils/api.js +8 -1
  12. package/lib/utils/awsCredentials.d.ts +20 -0
  13. package/lib/utils/awsCredentials.js +43 -0
  14. package/lib/utils/creatorUtilities.js +2 -1
  15. package/lib/utils/descriptionHash.d.ts +66 -0
  16. package/lib/utils/descriptionHash.js +173 -0
  17. package/lib/utils/descriptions/backfillEvents.d.ts +60 -0
  18. package/lib/utils/descriptions/backfillEvents.js +107 -0
  19. package/lib/utils/descriptions/gcsStorage.d.ts +16 -0
  20. package/lib/utils/descriptions/gcsStorage.js +60 -0
  21. package/lib/utils/descriptions/generateCourseDescriptions.d.ts +66 -0
  22. package/lib/utils/descriptions/generateCourseDescriptions.js +176 -0
  23. package/lib/utils/descriptions/mirrorDescriptions.d.ts +49 -0
  24. package/lib/utils/descriptions/mirrorDescriptions.js +109 -0
  25. package/lib/utils/descriptions/publishStage.d.ts +69 -0
  26. package/lib/utils/descriptions/publishStage.js +245 -0
  27. package/lib/utils/descriptions/resumePublication.d.ts +36 -0
  28. package/lib/utils/descriptions/resumePublication.js +128 -0
  29. package/lib/utils/descriptions/s3Storage.d.ts +30 -0
  30. package/lib/utils/descriptions/s3Storage.js +141 -0
  31. package/lib/utils/descriptions/workList.d.ts +75 -0
  32. package/lib/utils/descriptions/workList.js +177 -0
  33. package/lib/utils/gcsBucketName.d.ts +10 -0
  34. package/lib/utils/gcsBucketName.js +19 -0
  35. package/lib/utils/packageManifest.d.ts +22 -0
  36. package/lib/utils/packageManifest.js +57 -5
  37. package/lib/utils/publishEvents.d.ts +66 -0
  38. package/lib/utils/publishEvents.js +111 -0
  39. package/lib/utils/publishJournal.d.ts +119 -0
  40. package/lib/utils/publishJournal.js +275 -0
  41. package/lib/utils/repair/legacyPackageRepair.d.ts +131 -0
  42. package/lib/utils/repair/legacyPackageRepair.js +492 -0
  43. package/lib/utils/repair/repairStorage.d.ts +68 -0
  44. package/lib/utils/repair/repairStorage.js +89 -0
  45. package/lib/utils/rigoActions.d.ts +44 -0
  46. package/lib/utils/rigoActions.js +75 -1
  47. package/lib/utils/s3/packageManifestBackfill.d.ts +2 -0
  48. package/lib/utils/s3/packageManifestBackfill.js +5 -8
  49. package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
  50. package/lib/utils/s3/packageSourcesAudit.js +184 -0
  51. package/lib/utils/syllabusSync.d.ts +71 -0
  52. package/lib/utils/syllabusSync.js +273 -0
  53. package/package.json +3 -1
  54. package/src/commands/publish.ts +7 -0
  55. package/src/commands/serve.ts +144 -335
  56. package/src/models/creator.ts +9 -0
  57. package/src/scripts/README.md +244 -0
  58. package/src/scripts/descriptionsGcsBackfill.ts +193 -0
  59. package/src/scripts/descriptionsS3Backfill.ts +376 -0
  60. package/src/scripts/descriptionsSweep.ts +185 -0
  61. package/src/ui/_app/app.css +1 -1
  62. package/src/ui/_app/app.js +142 -140
  63. package/src/ui/app.tar.gz +0 -0
  64. package/src/utils/api.ts +9 -0
  65. package/src/utils/awsCredentials.ts +57 -0
  66. package/src/utils/creatorUtilities.ts +2 -1
  67. package/src/utils/descriptionHash.ts +196 -0
  68. package/src/utils/descriptions/backfillEvents.ts +152 -0
  69. package/src/utils/descriptions/gcsStorage.ts +67 -0
  70. package/src/utils/descriptions/generateCourseDescriptions.ts +311 -0
  71. package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
  72. package/src/utils/descriptions/publishStage.ts +394 -0
  73. package/src/utils/descriptions/resumePublication.ts +217 -0
  74. package/src/utils/descriptions/s3Storage.ts +214 -0
  75. package/src/utils/descriptions/workList.ts +283 -0
  76. package/src/utils/gcsBucketName.ts +19 -0
  77. package/src/utils/packageManifest.ts +62 -5
  78. package/src/utils/publishEvents.ts +181 -0
  79. package/src/utils/publishJournal.ts +383 -0
  80. package/src/utils/repair/legacyPackageRepair.ts +731 -0
  81. package/src/utils/repair/repairStorage.ts +168 -0
  82. package/src/utils/rigoActions.ts +130 -0
  83. package/src/utils/s3/packageManifestBackfill.ts +771 -776
  84. package/src/utils/s3/packageSourcesAudit.ts +311 -0
  85. package/src/utils/syllabusSync.ts +390 -0
@@ -0,0 +1,731 @@
1
+ import { Lesson, Syllabus, TDifficulty } from "../../models/creator"
2
+ import { Exercise } from "../configBuilder"
3
+ import { sortExerciseFiles } from "../exerciseFileOrder"
4
+ import {
5
+ LessonType,
6
+ deriveLessonId,
7
+ parseReadmeContent,
8
+ } from "../packageManifest"
9
+ import { RepairStorage, RepairTarget } from "./repairStorage"
10
+
11
+ /**
12
+ * Reconstruct the manifest inputs missing from legacy packages.
13
+ *
14
+ * Some packages predate the creator — a few were uploaded straight from a GitHub
15
+ * repository — and never got a `.learn/initialSyllabus.json`, a
16
+ * `.learn/sidebar.json`, or a usable `config.json`. The manifest tolerates the
17
+ * first two and comes out degraded: every lesson typed `READ`, and descriptions
18
+ * pinned to `null` forever, because `descriptionsS3Backfill` builds its work
19
+ * list from the syllabus keys and never even sees a course without one.
20
+ *
21
+ * This module writes those files back, derived from what the package *does*
22
+ * have: its exercise folders and their READMEs. It never invents content — a
23
+ * title comes from the README's own `# H1`, never from an LLM.
24
+ *
25
+ * It runs once per bucket (see `repairStorage.ts` for why the draft is repaired
26
+ * from the draft rather than copied from the snapshot). Ordering afterwards:
27
+ *
28
+ * 1. this script, on both targets (structure)
29
+ * 2. `descriptionsS3Backfill --reproject-manifest --mirror-draft`
30
+ * 3. `backfill:package-manifest`, unless step 2 projected it
31
+ *
32
+ * Safety: with one documented exception (a `config.json` whose `exercises` array
33
+ * is empty, which is merged rather than replaced) it only ever writes keys that
34
+ * do not exist. It never touches a file the real pipeline produced.
35
+ */
36
+
37
+ export type RepairFile = "config" | "syllabus" | "sidebar";
38
+
39
+ export const ALL_REPAIR_FILES: RepairFile[] = ["config", "syllabus", "sidebar"]
40
+
41
+ export type FileOutcome =
42
+ | "created"
43
+ | "merged"
44
+ | "present"
45
+ | "not-requested"
46
+ | "impossible";
47
+
48
+ export type FileResult = {
49
+ outcome: FileOutcome;
50
+ key?: string;
51
+ reason?: string;
52
+ };
53
+
54
+ export type RepairStats = {
55
+ exercises: number;
56
+ languages: string[];
57
+ primaryLanguage: string;
58
+ lessonTypes: Record<LessonType, number>;
59
+ titlesFromReadme: number;
60
+ titlesFromFolderName: number;
61
+ };
62
+
63
+ export type RepairResult = {
64
+ slug: string;
65
+ target: RepairTarget;
66
+ /**
67
+ * `absent` is not a failure: half the legacy packages published in S3 have no
68
+ * draft in GCS at all, so a run over both buckets is expected to find nothing
69
+ * to repair on one side. Only a package that *is* in the bucket but yields no
70
+ * exercises counts as `failed`.
71
+ */
72
+ status: "repaired" | "unchanged" | "absent" | "failed";
73
+ durationMs: number;
74
+ files: Record<RepairFile, FileResult>;
75
+ stats?: RepairStats;
76
+ error?: string;
77
+ };
78
+
79
+ export type RepairOptions = {
80
+ dryRun: boolean;
81
+ files: RepairFile[];
82
+ };
83
+
84
+ const README_FILENAME_PATTERN = /^readme(?:\.([a-z]{2}))?\.md$/i
85
+ /**
86
+ * A markdown task list is how LearnPack renders a quiz, so two or more options
87
+ * in one README is the only signal a legacy package gives us about its type.
88
+ */
89
+ const QUIZ_OPTION_PATTERN = /^\s*[*-]\s+\[[\sXx]]\s+\S/gm
90
+ const CODE_FILE_EXTENSIONS = new Set([
91
+ "c",
92
+ "cpp",
93
+ "cs",
94
+ "css",
95
+ "go",
96
+ "html",
97
+ "java",
98
+ "js",
99
+ "json",
100
+ "jsx",
101
+ "php",
102
+ "py",
103
+ "rb",
104
+ "rs",
105
+ "scss",
106
+ "sh",
107
+ "sql",
108
+ "ts",
109
+ "tsx",
110
+ "vue",
111
+ ])
112
+
113
+ type ConfigJsonShape = {
114
+ config?: Record<string, unknown>;
115
+ exercises?: Exercise[];
116
+ };
117
+
118
+ /** README body + parsed title, by exercise slug and language. */
119
+ export type ReadmeIndex = Record<
120
+ string,
121
+ Record<string, { title: string | null; body: string }>
122
+ >;
123
+
124
+ function humanizeFolderName(folderSlug: string): string {
125
+ const match = folderSlug.match(/^(\d+(?:\.\d+)?)(?:-(.*))?$/)
126
+ const titlePart = match && match[2] ? match[2] : folderSlug
127
+ return titlePart.replace(/-/g, " ").trim() || folderSlug
128
+ }
129
+
130
+ function naturalCompare(a: string, b: string): number {
131
+ const regex = /(\d+|\D+)/g
132
+ const ax = a.match(regex) || []
133
+ const bx = b.match(regex) || []
134
+ for (let i = 0; i < Math.max(ax.length, bx.length); i++) {
135
+ const an = Number.parseInt(ax[i], 10)
136
+ const bn = Number.parseInt(bx[i], 10)
137
+ if (!Number.isNaN(an) && !Number.isNaN(bn)) {
138
+ if (an !== bn) {
139
+ return an - bn
140
+ }
141
+ } else if (ax[i] !== bx[i]) {
142
+ return (ax[i] || "").localeCompare(bx[i] || "")
143
+ }
144
+ }
145
+
146
+ return 0
147
+ }
148
+
149
+ /**
150
+ * Exercises from the bucket listing alone, mirroring `configBuilder.buildConfig`
151
+ * key for key: same README language rule, same natural ordering, same
152
+ * `graded: false` default.
153
+ */
154
+ export function buildExercisesFromKeys(
155
+ exercisesPrefix: string,
156
+ keys: string[]
157
+ ): Exercise[] {
158
+ const map = new Map<string, Omit<Exercise, "position">>()
159
+
160
+ for (const key of keys) {
161
+ if (!key.startsWith(exercisesPrefix)) {
162
+ continue
163
+ }
164
+
165
+ const parts = key.slice(exercisesPrefix.length).split("/")
166
+ if (parts.length < 2) {
167
+ continue
168
+ }
169
+
170
+ const exerciseSlug = parts[0]
171
+ const filename = parts[parts.length - 1]
172
+ if (!exerciseSlug || !filename) {
173
+ continue
174
+ }
175
+
176
+ let exercise = map.get(exerciseSlug)
177
+ if (!exercise) {
178
+ exercise = {
179
+ title: exerciseSlug,
180
+ slug: exerciseSlug,
181
+ graded: false,
182
+ files: [],
183
+ translations: {},
184
+ }
185
+ map.set(exerciseSlug, exercise)
186
+ }
187
+
188
+ const readmeMatch = filename.match(README_FILENAME_PATTERN)
189
+ // Nested files keep their folder-relative name, as the IDE expects.
190
+ if (readmeMatch && parts.length === 2) {
191
+ exercise.translations[readmeMatch[1] || "en"] = filename
192
+ } else {
193
+ exercise.files.push({
194
+ name: parts.slice(1).join("/"),
195
+ slug: parts.slice(1).join("/"),
196
+ hidden: false,
197
+ })
198
+ }
199
+ }
200
+
201
+ return [...map.values()]
202
+ .sort((a, b) => naturalCompare(a.slug, b.slug))
203
+ .map((exercise, index) => ({
204
+ ...exercise,
205
+ files: sortExerciseFiles(exercise.files),
206
+ position: index,
207
+ }))
208
+ }
209
+
210
+ /**
211
+ * Language the course is really written in.
212
+ *
213
+ * Same rule as `workList.inferCourseBaseLanguage`: a declared language is
214
+ * trusted only when some exercise actually has a README in it, otherwise the
215
+ * most frequent translation wins. Defaulting to "en" would label a Spanish
216
+ * course English and anchor every later description to a translation.
217
+ *
218
+ * A bilingual course ties on count — most of the legacy ones are — so the tie is
219
+ * broken on the file names rather than arbitrarily: `getReadmeExtension` gives
220
+ * the *original* README no language suffix and suffixes every translation, so
221
+ * the language mapped to a bare `README.md` is the one the course was written
222
+ * in. This matters beyond the title: the descriptions backfill generates from
223
+ * `courseInfo.language` and would otherwise work from a translation.
224
+ */
225
+ export function inferPrimaryLanguage(
226
+ exercises: Exercise[],
227
+ declared?: string
228
+ ): string {
229
+ const counts = new Map<string, number>()
230
+ const unsuffixed = new Set<string>()
231
+ for (const exercise of exercises) {
232
+ for (const [lang, filename] of Object.entries(
233
+ exercise.translations || {}
234
+ )) {
235
+ counts.set(lang, (counts.get(lang) || 0) + 1)
236
+ if (/^readme\.md$/i.test(filename)) {
237
+ unsuffixed.add(lang)
238
+ }
239
+ }
240
+ }
241
+
242
+ if (counts.size === 0) {
243
+ return declared || "en"
244
+ }
245
+
246
+ const normalized = declared?.trim().toLowerCase()
247
+ if (normalized) {
248
+ for (const lang of counts.keys()) {
249
+ if (lang.toLowerCase() === normalized) {
250
+ return lang
251
+ }
252
+ }
253
+ }
254
+
255
+ const ranked = [...counts.entries()].sort((a, b) => {
256
+ if (a[1] !== b[1]) {
257
+ return b[1] - a[1]
258
+ }
259
+
260
+ const aBase = unsuffixed.has(a[0]) ? 0 : 1
261
+ const bBase = unsuffixed.has(b[0]) ? 0 : 1
262
+ return aBase === bBase ? a[0].localeCompare(b[0]) : aBase - bBase
263
+ })
264
+
265
+ return ranked[0][0]
266
+ }
267
+
268
+ /**
269
+ * Lesson type, inferred — the one field of a synthesized syllabus that is a
270
+ * guess rather than a reading. Callers should treat it as such.
271
+ */
272
+ export function inferLessonType(input: {
273
+ graded: boolean;
274
+ files: Array<{ name: string }>;
275
+ readmeBody: string;
276
+ }): LessonType {
277
+ const quizOptions = input.readmeBody.match(QUIZ_OPTION_PATTERN) || []
278
+ if (quizOptions.length >= 2) {
279
+ return "QUIZ"
280
+ }
281
+
282
+ if (input.graded) {
283
+ return "CODE"
284
+ }
285
+
286
+ const hasCodeFile = input.files.some(file => {
287
+ const extension = file.name.split(".").pop()?.toLowerCase() || ""
288
+ return CODE_FILE_EXTENSIONS.has(extension)
289
+ })
290
+
291
+ return hasCodeFile ? "CODE" : "READ"
292
+ }
293
+
294
+ export type BuildSyllabusInput = {
295
+ slug: string;
296
+ learnJson: Record<string, unknown> | null;
297
+ exercises: Exercise[];
298
+ readmes: ReadmeIndex;
299
+ primaryLanguage: string;
300
+ };
301
+
302
+ function pickLocalized(
303
+ value: unknown,
304
+ primaryLanguage: string
305
+ ): string | undefined {
306
+ if (typeof value === "string") {
307
+ return value
308
+ }
309
+
310
+ if (!value || typeof value !== "object") {
311
+ return undefined
312
+ }
313
+
314
+ const map = value as Record<string, string>
315
+ const candidate = map[primaryLanguage] || Object.values(map)[0]
316
+ return typeof candidate === "string" ? candidate : undefined
317
+ }
318
+
319
+ function buildCourseInfo(input: BuildSyllabusInput): Syllabus["courseInfo"] {
320
+ const learnJson = input.learnJson || {}
321
+ const difficulty = learnJson.difficulty
322
+ const duration = learnJson.duration
323
+
324
+ return {
325
+ title: pickLocalized(learnJson.title, input.primaryLanguage) || input.slug,
326
+ description:
327
+ pickLocalized(learnJson.description, input.primaryLanguage) || "",
328
+ duration: typeof duration === "number" ? duration : 0,
329
+ difficulty: (typeof difficulty === "string" ?
330
+ difficulty :
331
+ "beginner") as TDifficulty,
332
+ language: input.primaryLanguage,
333
+ technologies: Array.isArray(learnJson.technologies) ?
334
+ (learnJson.technologies as string[]) :
335
+ [],
336
+ slug: input.slug,
337
+ // Fields the creator wizard owns. A repaired package was never authored
338
+ // there, so they carry the "nothing pending" value rather than a guess.
339
+ hasContentIndex: false,
340
+ contentIndex: "",
341
+ isCompleted: true,
342
+ variables: [],
343
+ currentStep: "",
344
+ purpose: "",
345
+ }
346
+ }
347
+
348
+ export type BuildSyllabusResult = {
349
+ syllabus: Syllabus;
350
+ stats: Omit<RepairStats, "exercises" | "languages" | "primaryLanguage">;
351
+ };
352
+
353
+ /**
354
+ * A syllabus equivalent to what the creator would have written, derived from the
355
+ * exercise folders and their READMEs.
356
+ *
357
+ * `uid` is set to the exercise folder slug on purpose. `findSyllabusLesson`
358
+ * matches on `slugify(id + "-" + title)` *or* `uid`, and only the second is
359
+ * guaranteed to hold once a title comes from an H1 the folder name never had —
360
+ * it is also what lets the S3 and GCS repairs, run independently, still line up
361
+ * for the descriptions mirror.
362
+ */
363
+ export function buildSyllabusFromSources(
364
+ input: BuildSyllabusInput
365
+ ): BuildSyllabusResult {
366
+ const now = Date.now()
367
+ const lessonTypes: Record<LessonType, number> = { READ: 0, CODE: 0, QUIZ: 0 }
368
+ let titlesFromReadme = 0
369
+ let titlesFromFolderName = 0
370
+
371
+ const lessons: Lesson[] = [...input.exercises]
372
+ .sort((a, b) => a.position - b.position)
373
+ .map(exercise => {
374
+ const readmesByLang = input.readmes[exercise.slug] || {}
375
+ const primaryReadme = readmesByLang[input.primaryLanguage] ||
376
+ Object.values(readmesByLang)[0] || { title: null, body: "" }
377
+
378
+ const title = primaryReadme.title || humanizeFolderName(exercise.slug)
379
+ if (primaryReadme.title) {
380
+ titlesFromReadme += 1
381
+ } else {
382
+ titlesFromFolderName += 1
383
+ }
384
+
385
+ const type = inferLessonType({
386
+ graded: exercise.graded,
387
+ files: exercise.files,
388
+ readmeBody: primaryReadme.body,
389
+ })
390
+ lessonTypes[type] += 1
391
+
392
+ const translations: NonNullable<Lesson["translations"]> = {}
393
+ for (const lang of Object.keys(exercise.translations || {})) {
394
+ // Same shape `syllabusSync` repairs a missing slot with. Deliberately
395
+ // left without a fingerprint or prompt version so that
396
+ // `workList.needsGeneration` returns true and the descriptions backfill
397
+ // picks the course up on its next run.
398
+ translations[lang] = {
399
+ completionId: 0,
400
+ startedAt: now,
401
+ completedAt: now,
402
+ }
403
+ }
404
+
405
+ return {
406
+ id: deriveLessonId(exercise.slug),
407
+ uid: exercise.slug,
408
+ title,
409
+ type,
410
+ // `Lesson.description` is the generation topic, not the step
411
+ // description shown to students — that one lives per language in
412
+ // `translations[lang].description`, which the backfill fills in.
413
+ description: title,
414
+ duration: 2,
415
+ generated: true,
416
+ status: "DONE" as const,
417
+ translations,
418
+ }
419
+ })
420
+
421
+ return {
422
+ syllabus: {
423
+ lessons,
424
+ courseInfo: buildCourseInfo(input),
425
+ },
426
+ stats: { lessonTypes, titlesFromReadme, titlesFromFolderName },
427
+ }
428
+ }
429
+
430
+ /** `sidebar[exerciseSlug][lang] = title`, from the same READMEs. */
431
+ export function buildSidebarFromReadmes(
432
+ exercises: Exercise[],
433
+ readmes: ReadmeIndex
434
+ ): Record<string, Record<string, string>> {
435
+ const sidebar: Record<string, Record<string, string>> = {}
436
+
437
+ for (const exercise of exercises) {
438
+ const readmesByLang = readmes[exercise.slug] || {}
439
+ const entry: Record<string, string> = {}
440
+
441
+ for (const lang of Object.keys(exercise.translations || {})) {
442
+ entry[lang] =
443
+ readmesByLang[lang]?.title || humanizeFolderName(exercise.slug)
444
+ }
445
+
446
+ if (Object.keys(entry).length > 0) {
447
+ sidebar[exercise.slug] = entry
448
+ }
449
+ }
450
+
451
+ return sidebar
452
+ }
453
+
454
+ async function readReadmes(
455
+ storage: RepairStorage,
456
+ slug: string,
457
+ exercises: Exercise[]
458
+ ): Promise<ReadmeIndex> {
459
+ const index: ReadmeIndex = {}
460
+ const prefix = storage.layout.exercisesPrefix(slug)
461
+
462
+ await Promise.all(
463
+ exercises.flatMap(exercise =>
464
+ Object.entries(exercise.translations || {}).map(
465
+ async ([lang, filename]) => {
466
+ const content = await storage.readText(
467
+ `${prefix}${exercise.slug}/${filename}`
468
+ )
469
+ if (content === null) {
470
+ return
471
+ }
472
+
473
+ const { title } = parseReadmeContent(content)
474
+ if (!index[exercise.slug]) {
475
+ index[exercise.slug] = {}
476
+ }
477
+
478
+ index[exercise.slug][lang] = { title: title || null, body: content }
479
+ }
480
+ )
481
+ )
482
+ )
483
+
484
+ return index
485
+ }
486
+
487
+ /** First key that resolves, with the parsed value. */
488
+ async function readFirst<T>(
489
+ storage: RepairStorage,
490
+ keys: string[]
491
+ ): Promise<{ value: T; key: string } | null> {
492
+ for (const key of keys) {
493
+ // eslint-disable-next-line no-await-in-loop -- later keys are fallbacks
494
+ const value = await storage.readJson<T>(key)
495
+ if (value) {
496
+ return { value, key }
497
+ }
498
+ }
499
+
500
+ return null
501
+ }
502
+
503
+ function initialFiles(files: RepairFile[]): Record<RepairFile, FileResult> {
504
+ const requested = new Set(files)
505
+ const base: FileOutcome = "not-requested"
506
+ return {
507
+ config: { outcome: requested.has("config") ? "present" : base },
508
+ syllabus: { outcome: requested.has("syllabus") ? "present" : base },
509
+ sidebar: { outcome: requested.has("sidebar") ? "present" : base },
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Repair one package in one bucket. Returns what it did (or would do, under
515
+ * `dryRun`) without throwing: a batch reports failures per slug.
516
+ */
517
+ export async function repairPackage(
518
+ storage: RepairStorage,
519
+ slug: string,
520
+ options: RepairOptions
521
+ ): Promise<RepairResult> {
522
+ const started = Date.now()
523
+ const { layout } = storage
524
+ const wants = new Set(options.files)
525
+ const files = initialFiles(options.files)
526
+
527
+ const result = (
528
+ status: RepairResult["status"],
529
+ extra: Partial<RepairResult> = {}
530
+ ): RepairResult => ({
531
+ slug,
532
+ target: storage.target,
533
+ status,
534
+ durationMs: Date.now() - started,
535
+ files,
536
+ ...extra,
537
+ })
538
+
539
+ try {
540
+ const [learnJson, existingConfig, existingSyllabus, existingSidebar] =
541
+ await Promise.all([
542
+ storage.readJson<Record<string, unknown>>(layout.learnJsonKey(slug)),
543
+ readFirst<ConfigJsonShape>(storage, layout.configKeys(slug)),
544
+ storage.readJson<Syllabus>(layout.syllabusKey(slug)),
545
+ readFirst<Record<string, Record<string, string>>>(
546
+ storage,
547
+ layout.sidebarKeys(slug)
548
+ ),
549
+ ])
550
+
551
+ const configuredExercises = Array.isArray(existingConfig?.value.exercises) ?
552
+ (existingConfig!.value.exercises as Exercise[]) :
553
+ []
554
+
555
+ const needsConfig = wants.has("config") && configuredExercises.length === 0
556
+ const needsSyllabus = wants.has("syllabus") && !existingSyllabus
557
+ const needsSidebar = wants.has("sidebar") && !existingSidebar
558
+
559
+ if (wants.has("config") && !needsConfig) {
560
+ files.config = { outcome: "present", key: existingConfig!.key }
561
+ }
562
+
563
+ if (wants.has("syllabus") && !needsSyllabus) {
564
+ files.syllabus = { outcome: "present", key: layout.syllabusKey(slug) }
565
+ }
566
+
567
+ if (wants.has("sidebar") && !needsSidebar) {
568
+ files.sidebar = { outcome: "present", key: existingSidebar!.key }
569
+ }
570
+
571
+ // Nothing to build: stop before the listing and the READMEs, which is what
572
+ // makes a catalogue-wide pass over healthy packages cheap.
573
+ if (!needsConfig && !needsSyllabus && !needsSidebar) {
574
+ return result("unchanged")
575
+ }
576
+
577
+ const useConfigExercises =
578
+ layout.exercisesFromConfig && configuredExercises.length > 0
579
+
580
+ const derivedExercises = useConfigExercises ?
581
+ [] :
582
+ buildExercisesFromKeys(
583
+ layout.exercisesPrefix(slug),
584
+ await storage.listKeys(layout.exercisesPrefix(slug))
585
+ )
586
+
587
+ // Even where the listing is the authority on the exercise list, `graded` is
588
+ // worth keeping: the listing cannot express it (`buildConfig` hardcodes
589
+ // false) and it is a signal for typing a lesson CODE.
590
+ const gradedBySlug = new Map(
591
+ configuredExercises.map(exercise => [exercise.slug, exercise.graded])
592
+ )
593
+
594
+ const exercises = useConfigExercises ?
595
+ configuredExercises :
596
+ derivedExercises.map(exercise => ({
597
+ ...exercise,
598
+ graded: gradedBySlug.get(exercise.slug) ?? exercise.graded,
599
+ }))
600
+
601
+ if (exercises.length === 0) {
602
+ const anythingAtAll = Boolean(
603
+ learnJson || existingConfig || existingSyllabus || existingSidebar
604
+ )
605
+
606
+ if (anythingAtAll) {
607
+ return result("failed", {
608
+ error: `No exercises found for "${slug}" in ${storage.target}: neither its config.json nor its exercises/ listing yields any`,
609
+ })
610
+ }
611
+
612
+ for (const file of options.files) {
613
+ files[file] = {
614
+ outcome: "impossible",
615
+ reason: `the package is not in ${storage.target}`,
616
+ }
617
+ }
618
+
619
+ return result("absent")
620
+ }
621
+
622
+ const readmes = await readReadmes(storage, slug, exercises)
623
+ const primaryLanguage = inferPrimaryLanguage(
624
+ exercises,
625
+ (learnJson?.language as string | undefined) ||
626
+ existingSyllabus?.courseInfo?.language
627
+ )
628
+
629
+ let wrote = false
630
+
631
+ // --- config.json ----------------------------------------------------
632
+ if (needsConfig) {
633
+ // `config` is not decoration: the publish route destructures
634
+ // `{ config, exercises }` and reads `config.title` right after, so a
635
+ // config carrying only exercises would break the next publication.
636
+ const configField = existingConfig?.value.config || learnJson
637
+
638
+ if (configField) {
639
+ const key = existingConfig?.key || layout.configKeys(slug)[0]
640
+ const merged: ConfigJsonShape = {
641
+ ...existingConfig?.value,
642
+ config: configField,
643
+ exercises: derivedExercises,
644
+ }
645
+
646
+ files.config = {
647
+ outcome: existingConfig ? "merged" : "created",
648
+ key,
649
+ reason: existingConfig ?
650
+ "existing config.json had no exercises; other fields preserved" :
651
+ "no config.json in the package",
652
+ }
653
+ if (!options.dryRun) {
654
+ await storage.writeJson(key, merged)
655
+ }
656
+
657
+ wrote = true
658
+ } else {
659
+ files.config = {
660
+ outcome: "impossible",
661
+ reason: "no learn.json and no config.config to build it from",
662
+ }
663
+ }
664
+ }
665
+
666
+ // --- initialSyllabus.json -------------------------------------------
667
+ let syllabusStats: BuildSyllabusResult["stats"] | undefined
668
+
669
+ if (needsSyllabus) {
670
+ const built = buildSyllabusFromSources({
671
+ slug,
672
+ learnJson,
673
+ exercises,
674
+ readmes,
675
+ primaryLanguage,
676
+ })
677
+ syllabusStats = built.stats
678
+
679
+ files.syllabus = { outcome: "created", key: layout.syllabusKey(slug) }
680
+ if (!options.dryRun) {
681
+ await storage.writeJson(layout.syllabusKey(slug), built.syllabus)
682
+ }
683
+
684
+ wrote = true
685
+ }
686
+
687
+ // --- sidebar.json ----------------------------------------------------
688
+ if (needsSidebar) {
689
+ const sidebar = buildSidebarFromReadmes(exercises, readmes)
690
+ if (Object.keys(sidebar).length === 0) {
691
+ files.sidebar = {
692
+ outcome: "impossible",
693
+ reason: "no README titles to build it from",
694
+ }
695
+ } else {
696
+ const key = layout.sidebarKeys(slug)[0]
697
+ files.sidebar = { outcome: "created", key }
698
+ if (!options.dryRun) {
699
+ await storage.writeJson(key, sidebar)
700
+ }
701
+
702
+ wrote = true
703
+ }
704
+ }
705
+
706
+ const languages = [
707
+ ...new Set(
708
+ exercises.flatMap(exercise =>
709
+ Object.keys(exercise.translations || {})
710
+ )
711
+ ),
712
+ ].sort()
713
+
714
+ return result(wrote ? "repaired" : "unchanged", {
715
+ stats: {
716
+ exercises: exercises.length,
717
+ languages,
718
+ primaryLanguage,
719
+ lessonTypes: syllabusStats?.lessonTypes || {
720
+ READ: 0,
721
+ CODE: 0,
722
+ QUIZ: 0,
723
+ },
724
+ titlesFromReadme: syllabusStats?.titlesFromReadme || 0,
725
+ titlesFromFolderName: syllabusStats?.titlesFromFolderName || 0,
726
+ },
727
+ })
728
+ } catch (error) {
729
+ return result("failed", { error: (error as Error).message })
730
+ }
731
+ }