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