@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
@@ -10,8 +10,8 @@
10
10
  />
11
11
 
12
12
  <title>Learnpack Creator: Craft tutorials in seconds!</title>
13
- <script type="module" crossorigin src="/creator/assets/index-DnthLsvb.js"></script>
14
- <link rel="stylesheet" crossorigin href="/creator/assets/index-CjddKHB_.css">
13
+ <script type="module" crossorigin src="/creator/assets/index-D6pmbMe9.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/creator/assets/index-zrPponAn.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -3,7 +3,7 @@ export interface SyncNotification {
3
3
  sourceLanguage: string; // Language code from which sync will occur
4
4
  createdAt: number; // Creation timestamp
5
5
  updatedAt: number; // Last update timestamp
6
- status: "pending" | "processing" | "completed" | "error";
6
+ status: "pending" | "processing" | "completed" | "partial" | "error";
7
7
  processingLastUpdate?: number; // Last update timestamp during processing
8
8
  error?: {
9
9
  message: string;
@@ -0,0 +1,70 @@
1
+ import { Bucket } from "@google-cloud/storage"
2
+ import { CourseStorage } from "../ports/courseStorage"
3
+ import { IngestError } from "../errors"
4
+ import { uploadFileToBucket } from "../../../utils/coursePackage/bucketIo"
5
+
6
+ /**
7
+ * GCS-backed course storage.
8
+ *
9
+ * Writes go through `uploadFileToBucket` rather than `bucket.file().save()`
10
+ * directly: that helper sanitizes README newlines, and a second write path to
11
+ * the same bucket that skipped it would store content the translation step
12
+ * later chokes on.
13
+ * @param bucket - Bucket holding course drafts.
14
+ * @returns A CourseStorage backed by that bucket.
15
+ */
16
+ export function createGcsCourseStorage(bucket: Bucket): CourseStorage {
17
+ return {
18
+ async write(path, content) {
19
+ try {
20
+ await uploadFileToBucket(bucket, content, path)
21
+ } catch (error) {
22
+ throw new IngestError(
23
+ "STORAGE_FAILURE",
24
+ `Could not write ${path}`,
25
+ error
26
+ )
27
+ }
28
+ },
29
+
30
+ async read(path) {
31
+ try {
32
+ const [buf] = await bucket.file(path).download()
33
+ return buf.toString()
34
+ } catch {
35
+ // A missing object and an unreadable one are indistinguishable here
36
+ // without inspecting the GCS error code; callers treat both as absent.
37
+ return null
38
+ }
39
+ },
40
+
41
+ async list(prefix) {
42
+ try {
43
+ const [files] = await bucket.getFiles({ prefix })
44
+ return files.map(file => file.name)
45
+ } catch (error) {
46
+ throw new IngestError(
47
+ "STORAGE_FAILURE",
48
+ `Could not list objects under ${prefix}`,
49
+ error
50
+ )
51
+ }
52
+ },
53
+
54
+ async deleteMany(paths) {
55
+ if (paths.length === 0) {
56
+ return
57
+ }
58
+
59
+ try {
60
+ await Promise.all(paths.map(path => bucket.file(path).delete()))
61
+ } catch (error) {
62
+ throw new IngestError(
63
+ "STORAGE_FAILURE",
64
+ `Could not delete ${paths.length} object(s)`,
65
+ error
66
+ )
67
+ }
68
+ },
69
+ }
70
+ }
@@ -0,0 +1,65 @@
1
+ import axios from "axios"
2
+ import { RIGOBOT_HOST } from "../../../utils/api"
3
+ import { Identity, RequestAuthenticator } from "../ports/requestAuthenticator"
4
+ import { IngestError } from "../errors"
5
+
6
+ const TOKEN_HEADER = "x-rigo-token"
7
+
8
+ const readHeader = (
9
+ headers: Record<string, string | string[] | undefined>,
10
+ name: string
11
+ ): string => {
12
+ const raw = headers[name] ?? headers[name.toLowerCase()]
13
+ const value = Array.isArray(raw) ? raw[0] : raw
14
+ return (value || "").trim()
15
+ }
16
+
17
+ /**
18
+ * Authenticates a request by its Rigobot user token.
19
+ *
20
+ * Most of `serve.ts` reads this header without checking it, which turns a stale
21
+ * token into an opaque failure several calls deeper. One round trip up front
22
+ * costs little and names the problem where it happened.
23
+ *
24
+ * Note this validates rather than reusing `isValidRigoToken` from
25
+ * `utils/rigoActions`: that one calls Node's global fetch even though the
26
+ * package declares support for Node 14, and propagating that is not worth the
27
+ * five lines it would save.
28
+ * @returns A RequestAuthenticator backed by Rigobot's token endpoint.
29
+ */
30
+ export function createRigoUserTokenAuth(): RequestAuthenticator {
31
+ return {
32
+ async authenticate(headers): Promise<Identity> {
33
+ const rigoToken = readHeader(headers, TOKEN_HEADER)
34
+
35
+ if (!rigoToken) {
36
+ throw new IngestError(
37
+ "INVALID_TOKEN",
38
+ `Missing ${TOKEN_HEADER} header`
39
+ )
40
+ }
41
+
42
+ try {
43
+ await axios.get(
44
+ `${RIGOBOT_HOST}/v1/auth/token/${encodeURIComponent(rigoToken)}`
45
+ )
46
+ } catch (error) {
47
+ if (axios.isAxiosError(error) && error.response) {
48
+ throw new IngestError(
49
+ "INVALID_TOKEN",
50
+ "Rigobot rejected the token",
51
+ error
52
+ )
53
+ }
54
+
55
+ throw new IngestError(
56
+ "REGISTRY_UNAVAILABLE",
57
+ "Could not reach Rigobot to validate the token",
58
+ error
59
+ )
60
+ }
61
+
62
+ return { rigoToken }
63
+ },
64
+ }
65
+ }
@@ -0,0 +1,161 @@
1
+ import axios from "axios"
2
+ import api, { RIGOBOT_HOST } from "../../../utils/api"
3
+ import {
4
+ PackageRegistry,
5
+ RigoOrganization,
6
+ RigoPackage,
7
+ } from "../ports/packageRegistry"
8
+ import { IngestError } from "../errors"
9
+
10
+ /** Rigobot rejects a duplicate slug with a 400 whose body names the conflict. */
11
+ const SLUG_CONFLICT_PATTERN = /already (taken|in use)/i
12
+
13
+ const authHeaders = (rigoToken: string) => ({
14
+ // Tokens read from a .env file routinely carry a trailing newline, which
15
+ // makes the header invalid in a way the server reports as a plain 401.
16
+ Authorization: `Token ${rigoToken.replace(/[\n\r]/g, "").trim()}`,
17
+ })
18
+
19
+ const toRigoPackage = (data: any): RigoPackage => ({
20
+ id: data.id,
21
+ package_slug: data.package_slug,
22
+ organization: data.organization ?? null,
23
+ status: data.status,
24
+ })
25
+
26
+ /**
27
+ * Turns an axios failure into a typed ingest failure.
28
+ * @param error - Whatever the request rejected with.
29
+ * @param context - Short description of the attempted operation.
30
+ * @returns Never — always throws.
31
+ */
32
+ function rethrowAsIngestError(error: unknown, context: string): never {
33
+ if (axios.isAxiosError(error)) {
34
+ const status = error.response?.status
35
+ if (status === 401 || status === 403) {
36
+ throw new IngestError(
37
+ "INVALID_TOKEN",
38
+ `Rigobot rejected the token while ${context}`,
39
+ error
40
+ )
41
+ }
42
+ }
43
+
44
+ throw new IngestError(
45
+ "REGISTRY_UNAVAILABLE",
46
+ `Rigobot request failed while ${context}`,
47
+ error
48
+ )
49
+ }
50
+
51
+ /**
52
+ * Rigobot-backed package registry.
53
+ * @param rigoToken - Rigobot user token owning the packages.
54
+ * @returns A PackageRegistry talking to RIGOBOT_HOST.
55
+ */
56
+ export function createRigobotPackageRegistry(
57
+ rigoToken: string
58
+ ): PackageRegistry {
59
+ return {
60
+ async isSlugAvailable(slug) {
61
+ try {
62
+ const response = await axios.get<{ available?: boolean }>(
63
+ `${RIGOBOT_HOST}/v1/learnpack/check-slug-availability`,
64
+ { params: { slug } }
65
+ )
66
+ return response.data.available === true
67
+ } catch (error) {
68
+ rethrowAsIngestError(error, `checking availability of "${slug}"`)
69
+ }
70
+ },
71
+
72
+ async createPackage(slug, config) {
73
+ try {
74
+ const data = await api.createRigoPackage(rigoToken, slug, config)
75
+ return toRigoPackage(data)
76
+ } catch (error) {
77
+ if (axios.isAxiosError(error) && error.response?.status === 400) {
78
+ const detail = String(error.response.data?.error ?? "")
79
+ if (SLUG_CONFLICT_PATTERN.test(detail)) {
80
+ throw new IngestError(
81
+ "SLUG_TAKEN",
82
+ `The slug "${slug}" is already registered`,
83
+ error
84
+ )
85
+ }
86
+ }
87
+
88
+ rethrowAsIngestError(error, `creating the package "${slug}"`)
89
+ }
90
+ },
91
+
92
+ async getPackageBySlug(slug) {
93
+ try {
94
+ const response = await axios.get(
95
+ `${RIGOBOT_HOST}/v1/learnpack/package/${encodeURIComponent(slug)}/`,
96
+ { headers: authHeaders(rigoToken) }
97
+ )
98
+ return toRigoPackage(response.data)
99
+ } catch (error) {
100
+ // Rigobot answers 404 for "no such package" and 403 for one owned by
101
+ // somebody else. Both mean "not yours to read"; the caller decides what
102
+ // that implies, so neither is an error here.
103
+ if (
104
+ axios.isAxiosError(error) &&
105
+ (error.response?.status === 404 || error.response?.status === 403)
106
+ ) {
107
+ return null
108
+ }
109
+
110
+ rethrowAsIngestError(error, `reading the package "${slug}"`)
111
+ }
112
+ },
113
+
114
+ async listOrganizations() {
115
+ try {
116
+ const response = await axios.get(
117
+ `${RIGOBOT_HOST}/v1/auth/me/organization`,
118
+ { headers: authHeaders(rigoToken) }
119
+ )
120
+ // Paginated endpoints answer with `results`; unpaginated ones with a
121
+ // bare array.
122
+ const rows = Array.isArray(response.data) ?
123
+ response.data :
124
+ response.data?.results ?? []
125
+ return rows.map(
126
+ (row: any): RigoOrganization => ({ id: row.id, name: row.name })
127
+ )
128
+ } catch (error) {
129
+ rethrowAsIngestError(error, "listing your organizations")
130
+ }
131
+ },
132
+
133
+ async assignOrganization(packageId, organizationId) {
134
+ try {
135
+ await axios.put(
136
+ `${RIGOBOT_HOST}/v1/learnpack/organization/package/${packageId}`,
137
+ { organization: organizationId },
138
+ { headers: authHeaders(rigoToken) }
139
+ )
140
+ } catch (error) {
141
+ // Rigobot only accepts this while the organization is still null, and
142
+ // answers 400 once one is set. There is no detach endpoint, so this is
143
+ // terminal rather than retryable.
144
+ if (axios.isAxiosError(error) && error.response?.status === 400) {
145
+ throw new IngestError(
146
+ "ORGANIZATION_MISMATCH",
147
+ `Package ${packageId} could not be assigned to organization ${organizationId}: ${
148
+ error.response.data?.error ?? "it already belongs to one"
149
+ }`,
150
+ error
151
+ )
152
+ }
153
+
154
+ rethrowAsIngestError(
155
+ error,
156
+ `assigning package ${packageId} to organization ${organizationId}`
157
+ )
158
+ }
159
+ },
160
+ }
161
+ }
@@ -0,0 +1,51 @@
1
+ import { ConfigResponse, Exercise } from "../../../utils/configBuilder"
2
+ import { getReadmeExtension } from "../../../utils/creatorUtilities"
3
+ import { IngestLesson } from "./types"
4
+
5
+ export type BuildIngestConfigInput = {
6
+ lessons: IngestLesson[];
7
+ /** The package's learn.json, already normalized. */
8
+ learnJson: Record<string, unknown>;
9
+ };
10
+
11
+ /**
12
+ * Derives `.learn/config.json` without going back to the bucket.
13
+ *
14
+ * `buildConfig` (`utils/configBuilder.ts:47`) produces the same shape, but it
15
+ * reads the bucket for exactly two things — the object listing and learn.json —
16
+ * and the ingest already holds both in memory by the time it gets here. Writing
17
+ * the course and then reading it back to describe it would be a round trip that
18
+ * answers a question we already know.
19
+ *
20
+ * The file has to exist because `POST /actions/publish/:slug` reads it as its
21
+ * first step and fails outright when it is missing; without this, publishing a
22
+ * course nobody had opened in the creator would 500. `GET /config` regenerates
23
+ * it from the bucket on every call, so this version only has to carry publish
24
+ * through: its title, description and duration, plus each exercise's slug and
25
+ * translations. `exercise.files` is never read anywhere in the publish flow,
26
+ * which is why an empty list is honest rather than lazy.
27
+ * @param input - Paired lessons and the normalized learn.json.
28
+ * @returns The `{ config, exercises }` pair the bucket stores.
29
+ */
30
+ export function buildIngestConfig(
31
+ input: BuildIngestConfigInput
32
+ ): ConfigResponse {
33
+ const exercises: Exercise[] = input.lessons.map((lesson, position) => {
34
+ const translations: Record<string, string> = {}
35
+
36
+ for (const lang of lesson.languages) {
37
+ translations[lang] = `README${getReadmeExtension(lang)}`
38
+ }
39
+
40
+ return {
41
+ title: lesson.title,
42
+ slug: lesson.exerciseSlug,
43
+ graded: false,
44
+ files: [],
45
+ translations,
46
+ position,
47
+ }
48
+ })
49
+
50
+ return { config: { ...input.learnJson }, exercises }
51
+ }
@@ -0,0 +1,122 @@
1
+ import { Lesson, Syllabus, TDifficulty } from "../../../models/creator"
2
+ import { IngestLesson } from "./types"
3
+
4
+ export type BuildSyllabusInput = {
5
+ slug: string;
6
+ language: string;
7
+ lessons: IngestLesson[];
8
+ /** The package's `learn.json`, already normalized. */
9
+ learnJson: Record<string, unknown>;
10
+ /** Injectable clock, so tests can assert on timestamps. */
11
+ now?: number;
12
+ };
13
+
14
+ const pickLocalized = (value: unknown, language: string): string => {
15
+ if (typeof value === "string") {
16
+ return value
17
+ }
18
+
19
+ if (!value || typeof value !== "object") {
20
+ return ""
21
+ }
22
+
23
+ const map = value as Record<string, string>
24
+ const candidate = map[language] || Object.values(map)[0]
25
+ return typeof candidate === "string" ? candidate : ""
26
+ }
27
+
28
+ /**
29
+ * Builds the `courseInfo` half of the syllabus.
30
+ *
31
+ * The six wizard-owned fields carry their "nothing pending" values rather than
32
+ * a guess: an ingested course was never authored in the creator, so there is no
33
+ * step to resume and no content index to honour. Same reasoning, and same
34
+ * values, as `buildCourseInfo` in `utils/repair/legacyPackageRepair.ts`.
35
+ * @param input - Course slug, language and learn.json.
36
+ * @returns The courseInfo object.
37
+ */
38
+ function buildCourseInfo(input: BuildSyllabusInput): Syllabus["courseInfo"] {
39
+ const { learnJson, language, slug } = input
40
+ const difficulty = learnJson.difficulty
41
+ const duration = learnJson.duration
42
+
43
+ return {
44
+ title: pickLocalized(learnJson.title, language) || slug,
45
+ description: pickLocalized(learnJson.description, language),
46
+ duration: typeof duration === "number" ? duration : 0,
47
+ difficulty: (typeof difficulty === "string" ?
48
+ difficulty :
49
+ "beginner") as TDifficulty,
50
+ language,
51
+ technologies: Array.isArray(learnJson.technologies) ?
52
+ (learnJson.technologies as string[]) :
53
+ [],
54
+ slug,
55
+ hasContentIndex: false,
56
+ contentIndex: "",
57
+ isCompleted: true,
58
+ variables: [],
59
+ currentStep: "",
60
+ purpose: "",
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Builds the `initialSyllabus.json` for a course whose content already exists.
66
+ *
67
+ * The sibling of `buildSyllabusFromSources`
68
+ * (`utils/repair/legacyPackageRepair.ts:363`), which solves the same problem
69
+ * for packages that predate the creator. That one has to infer titles from an
70
+ * H1 and types from a heuristic; here the generator's plan already knows both,
71
+ * so the inference is skipped and the real values are used. The invariants
72
+ * below are shared between the two — change one and check the other.
73
+ * @param input - Paired lessons plus course metadata.
74
+ * @returns A syllabus the creator treats as a finished course.
75
+ */
76
+ export function buildInitialSyllabus(input: BuildSyllabusInput): Syllabus {
77
+ const now = input.now ?? Date.now()
78
+
79
+ const lessons: Lesson[] = input.lessons.map(lesson => {
80
+ const translations: NonNullable<Lesson["translations"]> = {}
81
+
82
+ for (const lang of lesson.languages) {
83
+ // Deliberately without `sourceContentHash` or `descriptionPromptVersion`:
84
+ // their absence is what makes `workList.needsGeneration` return true, so
85
+ // the descriptions backfill picks this course up on its next run.
86
+ //
87
+ // `completedAt` must be truthy — the IDE reads a `startedAt` without one
88
+ // as a translation still in flight and shows a spinner on a finished
89
+ // course. `completionId` is only ever written, never read back to query
90
+ // Rigobot, so zero is safe.
91
+ translations[lang] = {
92
+ completionId: 0,
93
+ startedAt: now,
94
+ completedAt: now,
95
+ }
96
+ }
97
+
98
+ return {
99
+ id: lesson.id,
100
+ // The exercise folder slug, not a random id: it is the lookup key the
101
+ // rest of the server matches on, and it travels inside webhook URLs.
102
+ uid: lesson.exerciseSlug,
103
+ title: lesson.title,
104
+ type: lesson.type,
105
+ description: lesson.description,
106
+ duration: lesson.durationMinutes,
107
+ // This pair is what tells the whole UI the course is finished: without
108
+ // `generated`, the publish button reports unreviewed lessons; without
109
+ // `status`, Rigobot's agent believes it may still generate content.
110
+ generated: true,
111
+ status: "DONE" as const,
112
+ // Must exist even when empty — `syllabusHash` in serve.ts calls
113
+ // Object.entries on it with no optional chaining, on nearly every request.
114
+ translations,
115
+ }
116
+ })
117
+
118
+ return {
119
+ lessons,
120
+ courseInfo: buildCourseInfo(input),
121
+ }
122
+ }
@@ -0,0 +1,32 @@
1
+ import { TSidebar } from "../../../utils/sidebarGenerator"
2
+ import { IngestLesson } from "./types"
3
+
4
+ /**
5
+ * Builds `sidebar.json`: `{ [exerciseSlug]: { [lang]: title } }`.
6
+ *
7
+ * Unlike `createInitialSidebar` (`utils/coursePackage/sidebar.ts`), which files
8
+ * each entry under its own slug as a placeholder, an ingested course already
9
+ * has real titles — the creator flow only uses slugs because at creation time
10
+ * no lesson has been written yet.
11
+ * @param lessons - Paired lessons, in the order they should appear.
12
+ * @returns The sidebar, keyed by exercise folder slug.
13
+ */
14
+ export function buildSidebar(lessons: IngestLesson[]): TSidebar {
15
+ const sidebar: TSidebar = {}
16
+
17
+ for (const lesson of lessons) {
18
+ const entry: Record<string, string> = {}
19
+
20
+ for (const lang of lesson.languages) {
21
+ entry[lang] = lesson.title
22
+ }
23
+
24
+ // A lesson with no README in any language would key an empty object, which
25
+ // reads to the IDE as a lesson with no titles at all.
26
+ if (Object.keys(entry).length > 0) {
27
+ sidebar[lesson.exerciseSlug] = entry
28
+ }
29
+ }
30
+
31
+ return sidebar
32
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Where each part of an ingested course lives in the bucket.
3
+ *
4
+ * Deliberately a copy of `gcsLayout` (`utils/repair/repairStorage.ts:93`)
5
+ * rather than an import: that module pulls in `@google-cloud/storage` and the
6
+ * AWS SDK at its top, and the ingest core must stay free of infrastructure.
7
+ *
8
+ * The two agreeing is load-bearing, though — the repair pass writes to the same
9
+ * bucket, so a disagreement about where a file belongs would have it "fix" a
10
+ * course the ingest had just written correctly. A test asserts the two match;
11
+ * that is what keeps this copy honest.
12
+ */
13
+
14
+ export const COURSES_PREFIX = "courses"
15
+
16
+ // Everything under one course.
17
+ export const coursePrefix = (slug: string): string =>
18
+ `${COURSES_PREFIX}/${slug}/`
19
+
20
+ export const learnJsonPath = (slug: string): string =>
21
+ `${COURSES_PREFIX}/${slug}/learn.json`
22
+
23
+ export const configPath = (slug: string): string =>
24
+ `${COURSES_PREFIX}/${slug}/.learn/config.json`
25
+
26
+ export const syllabusPath = (slug: string): string =>
27
+ `${COURSES_PREFIX}/${slug}/.learn/initialSyllabus.json`
28
+
29
+ export const sidebarPath = (slug: string): string =>
30
+ `${COURSES_PREFIX}/${slug}/.learn/sidebar.json`
31
+
32
+ export const exercisesPrefix = (slug: string): string =>
33
+ `${COURSES_PREFIX}/${slug}/exercises/`
34
+
35
+ // Generation artifacts kept alongside the course. Same path the streaming
36
+ // persistence design reserves for them, so the generator can later write here
37
+ // directly without a migration.
38
+ export const artifactsPrefix = (slug: string): string =>
39
+ `${COURSES_PREFIX}/${slug}/.learn/artifacts/`
40
+
41
+ export const exerciseFilePath = (
42
+ slug: string,
43
+ exerciseSlug: string,
44
+ fileName: string
45
+ ): string => `${exercisesPrefix(slug)}${exerciseSlug}/${fileName}`
46
+
47
+ /**
48
+ * The prefixes the ingest owns and may prune on overwrite.
49
+ *
50
+ * Everything else under the course belongs to somebody else: preview images the
51
+ * user uploaded, `package-manifest.json`, the publish journal. Deleting outside
52
+ * these two would destroy work the ingest never created.
53
+ * @param slug - Course slug.
54
+ * @returns Prefixes safe to prune.
55
+ */
56
+ export const prunablePrefixes = (slug: string): string[] => [
57
+ exercisesPrefix(slug),
58
+ artifactsPrefix(slug),
59
+ ]