@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,52 @@
1
+ import { CourseStorage } from "../ports/courseStorage";
2
+ import { PackageRegistry } from "../ports/packageRegistry";
3
+ import { PackageFiles } from "./validatePackage";
4
+ import { SkippedLesson } from "./types";
5
+ export type IngestInput = {
6
+ files: PackageFiles;
7
+ /** Slug the caller asked for; derived from the title when absent. */
8
+ requestedSlug?: string;
9
+ /** Whether the caller accepted overwriting a course they already own. */
10
+ overwrite: boolean;
11
+ };
12
+ export type IngestDeps = {
13
+ storage: CourseStorage;
14
+ /** Registry already scoped to the calling user's token. */
15
+ registry: PackageRegistry;
16
+ /** Organization every ingested package must end up under. */
17
+ organization: string;
18
+ now?: number;
19
+ };
20
+ export type IngestReport = {
21
+ slug: string;
22
+ packageId: number;
23
+ organization: string;
24
+ lessons: number;
25
+ /** Planned lessons the package held no content for. */
26
+ skipped: SkippedLesson[];
27
+ written: number;
28
+ deleted: number;
29
+ /**
30
+ * True when the slug was already registered but its bucket was empty, so this
31
+ * run completed an earlier attempt instead of overwriting anything.
32
+ */
33
+ recoveredEmptyPackage: boolean;
34
+ };
35
+ /**
36
+ * Ingests a finished course package: registers it and writes it to the bucket.
37
+ *
38
+ * The order is validate → resolve slug → register → write → prune, and it is
39
+ * not arbitrary. Validation and slug resolution are free and catch most bad
40
+ * requests; registration is the first thing that can conflict, and doing it
41
+ * before any write means a rejected course leaves nothing behind.
42
+ *
43
+ * Failures throw `IngestError` rather than being collected into the report. A
44
+ * partial ingest has no useful middle state to report — unlike publishing,
45
+ * nothing here runs detached after the response, so there is nothing for a
46
+ * later sweep to resume. Re-running is the recovery path, and the empty-package
47
+ * case above is what keeps that from being blocked.
48
+ * @param input - The extracted package and the caller's choices.
49
+ * @param deps - Storage, registry and expected organization.
50
+ * @returns What was written.
51
+ */
52
+ export declare function ingestCoursePackage(input: IngestInput, deps: IngestDeps): Promise<IngestReport>;
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ingestCoursePackage = ingestCoursePackage;
4
+ const console_1 = require("../../../utils/console");
5
+ const packageManifestBackfill_1 = require("../../../utils/s3/packageManifestBackfill");
6
+ const errors_1 = require("../errors");
7
+ const buildIngestConfig_1 = require("./buildIngestConfig");
8
+ const buildInitialSyllabus_1 = require("./buildInitialSyllabus");
9
+ const buildSidebar_1 = require("./buildSidebar");
10
+ const coursePaths_1 = require("./coursePaths");
11
+ const normalizeLearnJson_1 = require("./normalizeLearnJson");
12
+ const pairLessons_1 = require("./pairLessons");
13
+ const planWrites_1 = require("./planWrites");
14
+ const resolveSlug_1 = require("./resolveSlug");
15
+ const validatePackage_1 = require("./validatePackage");
16
+ /** Enough parallelism to hide the round trip without opening a socket per file. */
17
+ const WRITE_CONCURRENCY = 10;
18
+ const parseJson = (raw, path) => {
19
+ try {
20
+ return JSON.parse(raw);
21
+ }
22
+ catch (error) {
23
+ throw new errors_1.IngestError("INVALID_PACKAGE", `${path} is not valid JSON: ${error.message}`, error);
24
+ }
25
+ };
26
+ /**
27
+ * Reads the GitHub connection a previous config may hold.
28
+ *
29
+ * A user can link a course to a repository from the creator, and that link
30
+ * lives in `config.github`. Re-ingesting rewrites `config.json` wholesale, so
31
+ * without carrying this across, an overwrite would quietly disconnect a
32
+ * repository nobody asked to disconnect. `GET /config` guards the same field
33
+ * the same way.
34
+ * @param storage - Course storage.
35
+ * @param slug - Course slug.
36
+ * @returns The stored github block, or undefined.
37
+ */
38
+ async function readGithubConnection(storage, slug) {
39
+ var _a;
40
+ const raw = await storage.read((0, coursePaths_1.configPath)(slug));
41
+ if (!raw) {
42
+ return undefined;
43
+ }
44
+ try {
45
+ const existing = JSON.parse(raw);
46
+ return (_a = existing === null || existing === void 0 ? void 0 : existing.config) === null || _a === void 0 ? void 0 : _a.github;
47
+ }
48
+ catch (_b) {
49
+ // A config we cannot parse has nothing to preserve; the fresh one wins.
50
+ return undefined;
51
+ }
52
+ }
53
+ /**
54
+ * Turns the configured organization into the id everything else speaks.
55
+ *
56
+ * Configuration names an organization the way a person would, but a package
57
+ * reports its organization as a numeric id: the field is a plain ForeignKey on
58
+ * a ModelSerializer, so it serializes to the primary key, and no endpoint ever
59
+ * returns a slug. Comparing the two directly is how a correctly configured
60
+ * ingest ends up refused with "belongs to organization 105, not learnpack-labs".
61
+ *
62
+ * Accepts an id as-is, and otherwise matches case-insensitively against the
63
+ * names of the organizations the caller belongs to. A miss lists them, since
64
+ * nothing else in the flow would ever show the reader an id to use.
65
+ * @param configured - Value of INGEST_ORGANIZATION.
66
+ * @param registry - Used to list the caller's organizations.
67
+ * @returns The organization id.
68
+ * @throws IngestError `ORGANIZATION_MISMATCH` listing the available options.
69
+ */
70
+ async function resolveOrganizationId(configured, registry) {
71
+ const asId = Number(configured);
72
+ if (Number.isInteger(asId) && asId > 0) {
73
+ return asId;
74
+ }
75
+ const organizations = await registry.listOrganizations();
76
+ const wanted = configured.trim().toLowerCase();
77
+ const match = organizations.find(org => (org.name || "").trim().toLowerCase() === wanted);
78
+ if (match) {
79
+ return match.id;
80
+ }
81
+ const available = organizations
82
+ .map(org => `${org.id} (${org.name})`)
83
+ .join(", ");
84
+ throw new errors_1.IngestError("ORGANIZATION_MISMATCH", available ?
85
+ `INGEST_ORGANIZATION is "${configured}", which matches none of your organizations: ${available}. Use one of those ids or its exact name.` :
86
+ `INGEST_ORGANIZATION is "${configured}", but this token belongs to no organization`);
87
+ }
88
+ /**
89
+ * Settles the package registration for a slug the caller may already own.
90
+ *
91
+ * Registering before writing is deliberate: it is the cheap check that catches
92
+ * a slug collision or a wrong organization while the bucket is still untouched.
93
+ * @param slug - Resolved slug.
94
+ * @param learnJson - Normalized learn.json, stored as the package config.
95
+ * @param deps - Registry and expected organization.
96
+ * @param overwrite - Whether the caller accepted overwriting their own course.
97
+ * @param storage - Used to tell an abandoned registration from a real course.
98
+ * @returns The package and whether this run is completing an earlier attempt.
99
+ */
100
+ async function registerPackage(slug, learnJson, deps, overwrite, storage) {
101
+ const available = await deps.registry.isSlugAvailable(slug);
102
+ if (available) {
103
+ return {
104
+ pkg: await deps.registry.createPackage(slug, learnJson),
105
+ recovered: false,
106
+ };
107
+ }
108
+ const existing = await deps.registry.getPackageBySlug(slug);
109
+ if (!existing) {
110
+ // Taken, and the registry will not show it to us: it belongs to another
111
+ // user. No suggestion is offered here — the caller cannot fix this by
112
+ // retrying, only by choosing a different slug.
113
+ throw new errors_1.IngestError("SLUG_OWNED_BY_OTHER", `The slug "${slug}" belongs to another user`);
114
+ }
115
+ if (!overwrite) {
116
+ const contents = await storage.list((0, coursePaths_1.coursePrefix)(slug));
117
+ // A registered slug with an empty bucket is the fingerprint of a run that
118
+ // registered and then failed before writing. Refusing it would strand the
119
+ // user behind a 409 for a course that, as far as they can tell, does not
120
+ // exist.
121
+ if (contents.length === 0) {
122
+ console_1.default.info(`[ingest] "${slug}" was registered but never written; completing that attempt`);
123
+ return { pkg: existing, recovered: true };
124
+ }
125
+ const suggestions = await (0, resolveSlug_1.suggestSlugs)(slug, deps.registry);
126
+ throw new errors_1.IngestError("SLUG_TAKEN", `You already have a course at "${slug}". Re-run with overwrite to replace it, or use one of: ${suggestions.join(", ")}`);
127
+ }
128
+ return { pkg: existing, recovered: false };
129
+ }
130
+ /**
131
+ * Ingests a finished course package: registers it and writes it to the bucket.
132
+ *
133
+ * The order is validate → resolve slug → register → write → prune, and it is
134
+ * not arbitrary. Validation and slug resolution are free and catch most bad
135
+ * requests; registration is the first thing that can conflict, and doing it
136
+ * before any write means a rejected course leaves nothing behind.
137
+ *
138
+ * Failures throw `IngestError` rather than being collected into the report. A
139
+ * partial ingest has no useful middle state to report — unlike publishing,
140
+ * nothing here runs detached after the response, so there is nothing for a
141
+ * later sweep to resume. Re-running is the recovery path, and the empty-package
142
+ * case above is what keeps that from being blocked.
143
+ * @param input - The extracted package and the caller's choices.
144
+ * @param deps - Storage, registry and expected organization.
145
+ * @returns What was written.
146
+ */
147
+ async function ingestCoursePackage(input, deps) {
148
+ (0, validatePackage_1.validatePackage)(input.files);
149
+ const rawLearnJson = parseJson(input.files[validatePackage_1.LEARN_JSON_PATH], validatePackage_1.LEARN_JSON_PATH);
150
+ const plan = parseJson(input.files[validatePackage_1.LESSON_PLAN_PATH], validatePackage_1.LESSON_PLAN_PATH);
151
+ const slug = (0, resolveSlug_1.resolveSlug)({
152
+ requestedSlug: input.requestedSlug,
153
+ courseTitle: plan.course_title,
154
+ });
155
+ const learnJson = (0, normalizeLearnJson_1.normalizeLearnJson)({ learnJson: rawLearnJson, slug });
156
+ const { pkg, recovered } = await registerPackage(slug, learnJson, deps, input.overwrite, deps.storage);
157
+ const organizationId = await resolveOrganizationId(deps.organization, deps.registry);
158
+ if (pkg.organization === null) {
159
+ await deps.registry.assignOrganization(pkg.id, organizationId);
160
+ }
161
+ else if (pkg.organization !== organizationId) {
162
+ // There is no way to detach an organization through the API, so landing in
163
+ // the wrong one is only fixable by hand. Better to stop before writing.
164
+ throw new errors_1.IngestError("ORGANIZATION_MISMATCH", `"${slug}" belongs to organization ${pkg.organization}, not ${organizationId} (${deps.organization})`);
165
+ }
166
+ const { slugs, languagesBySlug } = (0, validatePackage_1.readExercises)(input.files);
167
+ const { lessons, skipped } = (0, pairLessons_1.pairLessons)(plan, slugs, languagesBySlug);
168
+ const language = (plan.language || "en").trim().toLowerCase() || "en";
169
+ const syllabus = (0, buildInitialSyllabus_1.buildInitialSyllabus)({
170
+ slug,
171
+ language,
172
+ lessons,
173
+ learnJson,
174
+ now: deps.now,
175
+ });
176
+ const sidebar = (0, buildSidebar_1.buildSidebar)(lessons);
177
+ const github = input.overwrite ?
178
+ await readGithubConnection(deps.storage, slug) :
179
+ undefined;
180
+ const projected = (0, buildIngestConfig_1.buildIngestConfig)({ lessons, learnJson });
181
+ const config = github ? Object.assign(Object.assign({}, projected), { config: Object.assign(Object.assign({}, projected.config), { github }) }) :
182
+ projected;
183
+ const writes = (0, planWrites_1.planWrites)({
184
+ slug,
185
+ files: input.files,
186
+ lessons,
187
+ learnJson,
188
+ syllabus,
189
+ sidebar,
190
+ config,
191
+ });
192
+ let deleted = 0;
193
+ if (input.overwrite) {
194
+ const existing = await deps.storage.list((0, coursePaths_1.coursePrefix)(slug));
195
+ const stale = (0, planWrites_1.planDeletions)(existing, new Set(writes.keys()), slug);
196
+ await deps.storage.deleteMany(stale);
197
+ deleted = stale.length;
198
+ }
199
+ // Written in parallel, with a cap. A course is around fifty small objects —
200
+ // one README per lesson plus the generation artifacts — and one round trip at
201
+ // a time turned a few seconds of work into most of a minute, long enough for
202
+ // the uploading client to give up while the server was still writing.
203
+ await (0, packageManifestBackfill_1.mapWithConcurrency)([...writes.entries()], WRITE_CONCURRENCY, ([path, contents]) => deps.storage.write(path, contents));
204
+ if (skipped.length > 0) {
205
+ console_1.default.warning(`[ingest] "${slug}": ${skipped.length} planned lesson(s) had no content and were left out`);
206
+ }
207
+ return {
208
+ slug,
209
+ packageId: pkg.id,
210
+ organization: deps.organization,
211
+ lessons: lessons.length,
212
+ skipped,
213
+ written: writes.size,
214
+ deleted,
215
+ recoveredEmptyPackage: recovered,
216
+ };
217
+ }
@@ -0,0 +1,25 @@
1
+ export type NormalizeLearnJsonInput = {
2
+ /** The `learn.json` shipped inside the package. */
3
+ learnJson: Record<string, unknown>;
4
+ /** The slug the ingest resolved, which wins over whatever the package says. */
5
+ slug: string;
6
+ };
7
+ /**
8
+ * Fills in the fields the generator's exporter omits.
9
+ *
10
+ * The generator writes a `learn.json` good enough to run locally, but three
11
+ * fields only matter once the course lives in the bucket, and each fails
12
+ * quietly when missing:
13
+ *
14
+ * - `telemetry.batch` — without it student telemetry is never sent.
15
+ * - `preview` — publish falls back to the generic LearnPack logo.
16
+ * - `technologies` — the organization package listing filters on it.
17
+ *
18
+ * The slug is overwritten rather than trusted: it is resolved against the
19
+ * registry before anything is written, and the copy inside the package predates
20
+ * that. Leaving them to disagree puts the bucket path and `learn.json` out of
21
+ * sync, which `change-slug` would then fail to reconcile.
22
+ * @param input - The package's learn.json and the resolved slug.
23
+ * @returns A learn.json ready to be written to the bucket.
24
+ */
25
+ export declare function normalizeLearnJson(input: NormalizeLearnJsonInput): Record<string, unknown>;
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeLearnJson = normalizeLearnJson;
4
+ const api_1 = require("../../../utils/api");
5
+ /**
6
+ * Fills in the fields the generator's exporter omits.
7
+ *
8
+ * The generator writes a `learn.json` good enough to run locally, but three
9
+ * fields only matter once the course lives in the bucket, and each fails
10
+ * quietly when missing:
11
+ *
12
+ * - `telemetry.batch` — without it student telemetry is never sent.
13
+ * - `preview` — publish falls back to the generic LearnPack logo.
14
+ * - `technologies` — the organization package listing filters on it.
15
+ *
16
+ * The slug is overwritten rather than trusted: it is resolved against the
17
+ * registry before anything is written, and the copy inside the package predates
18
+ * that. Leaving them to disagree puts the bucket path and `learn.json` out of
19
+ * sync, which `change-slug` would then fail to reconcile.
20
+ * @param input - The package's learn.json and the resolved slug.
21
+ * @returns A learn.json ready to be written to the bucket.
22
+ */
23
+ function normalizeLearnJson(input) {
24
+ const { learnJson, slug } = input;
25
+ const telemetry = learnJson.telemetry || {};
26
+ return Object.assign(Object.assign({}, learnJson), { slug, technologies: Array.isArray(learnJson.technologies) ?
27
+ learnJson.technologies :
28
+ [], telemetry: Object.assign(Object.assign({}, telemetry), { batch: telemetry.batch || api_1.BREATHECODE_TELEMETRY_URL }), preview: learnJson.preview || `https://${slug}.learn-pack.com/preview.png` });
29
+ }
@@ -0,0 +1,29 @@
1
+ import { IngestLesson, LessonPlan, SkippedLesson } from "./types";
2
+ /**
3
+ * The exercise folder a lesson id owns, e.g. "01.1" for "01.1-split-a-class".
4
+ *
5
+ * Parsed by splitting rather than by a two-digit regex: the generator's own id
6
+ * parser accepts "1.0", so a stricter pattern here would reject a plan the
7
+ * generator considers valid.
8
+ * @param exerciseSlug - Folder name inside `exercises/`.
9
+ * @returns The leading id, or the whole slug when there is no hyphen.
10
+ */
11
+ export declare function lessonIdFromSlug(exerciseSlug: string): string;
12
+ export type PairLessonsResult = {
13
+ lessons: IngestLesson[];
14
+ skipped: SkippedLesson[];
15
+ };
16
+ /**
17
+ * Matches planned lessons to the exercise folders that actually hold content.
18
+ *
19
+ * A plan lists every lesson that was *planned*; module-batched generation can
20
+ * stop early, leaving later modules without files. Pairing against the folders
21
+ * present is what makes a partial course ingest as the eight lessons it has
22
+ * rather than the twelve it intended — and the leftovers are reported instead
23
+ * of dropped, so nobody discovers the gap by scrolling the IDE.
24
+ * @param plan - The generator's lesson plan.
25
+ * @param exerciseSlugs - Folder names found under `exercises/` in the package.
26
+ * @param languagesBySlug - Language codes with a README, per exercise slug.
27
+ * @returns Paired lessons in plan order, plus the entries that had no content.
28
+ */
29
+ export declare function pairLessons(plan: LessonPlan, exerciseSlugs: string[], languagesBySlug: Record<string, string[]>): PairLessonsResult;
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.lessonIdFromSlug = lessonIdFromSlug;
4
+ exports.pairLessons = pairLessons;
5
+ const console_1 = require("../../../utils/console");
6
+ const VALID_LESSON_TYPES = new Set(["READ", "CODE", "QUIZ"]);
7
+ /**
8
+ * The exercise folder a lesson id owns, e.g. "01.1" for "01.1-split-a-class".
9
+ *
10
+ * Parsed by splitting rather than by a two-digit regex: the generator's own id
11
+ * parser accepts "1.0", so a stricter pattern here would reject a plan the
12
+ * generator considers valid.
13
+ * @param exerciseSlug - Folder name inside `exercises/`.
14
+ * @returns The leading id, or the whole slug when there is no hyphen.
15
+ */
16
+ function lessonIdFromSlug(exerciseSlug) {
17
+ const [head] = exerciseSlug.split("-");
18
+ return head;
19
+ }
20
+ /**
21
+ * Reads the lesson type, falling back rather than failing.
22
+ *
23
+ * The generator declares this as a Pydantic Literal, so a successfully
24
+ * generated course cannot carry anything else — but the package is consumed
25
+ * long after the fact, and a course that renders every lesson as READ beats one
26
+ * that refuses to ingest.
27
+ * @param type - Raw value from the plan.
28
+ * @param lessonId - Used to make the warning actionable.
29
+ * @returns A valid lesson type.
30
+ */
31
+ function coerceLessonType(type, lessonId) {
32
+ if (type && VALID_LESSON_TYPES.has(type)) {
33
+ return type;
34
+ }
35
+ if (type) {
36
+ console_1.default.warning(`[ingest] Lesson ${lessonId} has unknown type "${type}"; treating it as READ`);
37
+ }
38
+ return "READ";
39
+ }
40
+ /**
41
+ * The generation topic for a lesson.
42
+ *
43
+ * `lesson_focus` is absent in packages from the pre-ADR-0001 schema, and this
44
+ * value is not what students read — the student-facing description lives per
45
+ * language in `translations[lang].description`, which the descriptions backfill
46
+ * fills in later. The title is a fine stand-in.
47
+ * @param lesson - Planned lesson.
48
+ * @returns The focus, or the title.
49
+ */
50
+ function describeLesson(lesson) {
51
+ const focus = (lesson.lesson_focus || "").trim();
52
+ return focus || lesson.title;
53
+ }
54
+ /**
55
+ * Matches planned lessons to the exercise folders that actually hold content.
56
+ *
57
+ * A plan lists every lesson that was *planned*; module-batched generation can
58
+ * stop early, leaving later modules without files. Pairing against the folders
59
+ * present is what makes a partial course ingest as the eight lessons it has
60
+ * rather than the twelve it intended — and the leftovers are reported instead
61
+ * of dropped, so nobody discovers the gap by scrolling the IDE.
62
+ * @param plan - The generator's lesson plan.
63
+ * @param exerciseSlugs - Folder names found under `exercises/` in the package.
64
+ * @param languagesBySlug - Language codes with a README, per exercise slug.
65
+ * @returns Paired lessons in plan order, plus the entries that had no content.
66
+ */
67
+ function pairLessons(plan, exerciseSlugs, languagesBySlug) {
68
+ const slugsById = new Map();
69
+ for (const slug of exerciseSlugs) {
70
+ const id = lessonIdFromSlug(slug);
71
+ // First folder wins: a duplicate id means the package is malformed, and
72
+ // picking arbitrarily is no worse than picking the last one.
73
+ if (!slugsById.has(id)) {
74
+ slugsById.set(id, slug);
75
+ }
76
+ }
77
+ const lessons = [];
78
+ const skipped = [];
79
+ for (const planned of plan.lessons || []) {
80
+ const exerciseSlug = slugsById.get(planned.id);
81
+ if (!exerciseSlug) {
82
+ skipped.push({
83
+ id: planned.id,
84
+ title: planned.title,
85
+ reason: "no-content",
86
+ });
87
+ continue;
88
+ }
89
+ lessons.push({
90
+ id: planned.id,
91
+ exerciseSlug,
92
+ title: planned.title,
93
+ type: coerceLessonType(planned.type, planned.id),
94
+ durationMinutes: typeof planned.duration_minutes === "number" ?
95
+ planned.duration_minutes :
96
+ 0,
97
+ description: describeLesson(planned),
98
+ languages: languagesBySlug[exerciseSlug] || [],
99
+ });
100
+ }
101
+ return { lessons, skipped };
102
+ }
@@ -0,0 +1,44 @@
1
+ import { PackageFiles } from "./validatePackage";
2
+ import { IngestLesson } from "./types";
3
+ export type PlanWritesInput = {
4
+ slug: string;
5
+ files: PackageFiles;
6
+ lessons: IngestLesson[];
7
+ learnJson: Record<string, unknown>;
8
+ syllabus: unknown;
9
+ sidebar: unknown;
10
+ config: unknown;
11
+ };
12
+ /**
13
+ * Lays out every object the ingest will write, keyed by its bucket path.
14
+ *
15
+ * Returned rather than written so the orchestrator can compare the plan against
16
+ * what is already in the bucket before touching anything — which is what makes
17
+ * pruning stale lessons possible, and what lets a test assert the whole tree
18
+ * without a storage double.
19
+ *
20
+ * Content files ride through verbatim; only the four derived documents are
21
+ * generated. Note `.learn/config.json` from the package is *not* among them:
22
+ * it is a projection of the other files, and the copy the generator ships
23
+ * carries localhost URLs and a stale exercise list.
24
+ * @param input - Slug, package files and the derived documents.
25
+ * @returns Bucket path → contents.
26
+ */
27
+ export declare function planWrites(input: PlanWritesInput): Map<string, string>;
28
+ /**
29
+ * Objects the course still holds that this ingest is not going to rewrite.
30
+ *
31
+ * Scoped to the prefixes the ingest owns. Everything else under the course
32
+ * belongs to somebody else — preview images the user uploaded,
33
+ * `package-manifest.json`, the publish journal — and deleting those would
34
+ * destroy work the ingest never created.
35
+ *
36
+ * Pruning matters because `buildConfig` derives the exercise list from whatever
37
+ * files exist: a regenerated course with fewer lessons would otherwise keep
38
+ * listing the old folders as lessons that open empty.
39
+ * @param existing - Every object path currently under the course.
40
+ * @param planned - Paths this ingest is about to write.
41
+ * @param slug - Course slug, for scoping the prunable prefixes.
42
+ * @returns Paths safe to delete.
43
+ */
44
+ export declare function planDeletions(existing: string[], planned: Set<string>, slug: string): string[];
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.planWrites = planWrites;
4
+ exports.planDeletions = planDeletions;
5
+ const coursePaths_1 = require("./coursePaths");
6
+ /** Package paths copied through untouched, keyed by their prefix. */
7
+ const PASSTHROUGH_PREFIXES = [".learn/artifacts/", "exercises/"];
8
+ const normalize = (path) => path.replace(/\\/g, "/");
9
+ const stringify = (value) => JSON.stringify(value, null, 2);
10
+ /**
11
+ * Lays out every object the ingest will write, keyed by its bucket path.
12
+ *
13
+ * Returned rather than written so the orchestrator can compare the plan against
14
+ * what is already in the bucket before touching anything — which is what makes
15
+ * pruning stale lessons possible, and what lets a test assert the whole tree
16
+ * without a storage double.
17
+ *
18
+ * Content files ride through verbatim; only the four derived documents are
19
+ * generated. Note `.learn/config.json` from the package is *not* among them:
20
+ * it is a projection of the other files, and the copy the generator ships
21
+ * carries localhost URLs and a stale exercise list.
22
+ * @param input - Slug, package files and the derived documents.
23
+ * @returns Bucket path → contents.
24
+ */
25
+ function planWrites(input) {
26
+ const writes = new Map();
27
+ const prefix = (0, coursePaths_1.coursePrefix)(input.slug);
28
+ for (const [rawPath, contents] of Object.entries(input.files)) {
29
+ const path = normalize(rawPath);
30
+ if (PASSTHROUGH_PREFIXES.some(known => path.startsWith(known))) {
31
+ writes.set(`${prefix}${path}`, contents);
32
+ }
33
+ }
34
+ writes.set((0, coursePaths_1.learnJsonPath)(input.slug), stringify(input.learnJson));
35
+ writes.set((0, coursePaths_1.syllabusPath)(input.slug), stringify(input.syllabus));
36
+ writes.set((0, coursePaths_1.sidebarPath)(input.slug), stringify(input.sidebar));
37
+ writes.set((0, coursePaths_1.configPath)(input.slug), stringify(input.config));
38
+ return writes;
39
+ }
40
+ /**
41
+ * Objects the course still holds that this ingest is not going to rewrite.
42
+ *
43
+ * Scoped to the prefixes the ingest owns. Everything else under the course
44
+ * belongs to somebody else — preview images the user uploaded,
45
+ * `package-manifest.json`, the publish journal — and deleting those would
46
+ * destroy work the ingest never created.
47
+ *
48
+ * Pruning matters because `buildConfig` derives the exercise list from whatever
49
+ * files exist: a regenerated course with fewer lessons would otherwise keep
50
+ * listing the old folders as lessons that open empty.
51
+ * @param existing - Every object path currently under the course.
52
+ * @param planned - Paths this ingest is about to write.
53
+ * @param slug - Course slug, for scoping the prunable prefixes.
54
+ * @returns Paths safe to delete.
55
+ */
56
+ function planDeletions(existing, planned, slug) {
57
+ const prunable = (0, coursePaths_1.prunablePrefixes)(slug);
58
+ return existing.filter(path => !planned.has(path) && prunable.some(prefix => path.startsWith(prefix)));
59
+ }
@@ -0,0 +1,53 @@
1
+ import { PackageRegistry } from "../ports/packageRegistry";
2
+ /**
3
+ * Room to spare under Rigobot's 100-character SlugField, so a suffixed
4
+ * suggestion still fits.
5
+ */
6
+ export declare const MAX_SLUG_LENGTH = 80;
7
+ /**
8
+ * The strictest of the slug rules in play, on purpose.
9
+ *
10
+ * This repo's own `slugify` keeps dots, and two things break when one survives:
11
+ * Rigobot stores `package_slug` in a Django SlugField whose validator rejects
12
+ * them (it persists anyway, since the view never calls full_clean), and the
13
+ * published course is served at `{slug}.learn-pack.com`, where a dot adds a
14
+ * subdomain level the wildcard certificate does not cover.
15
+ * @param raw - Candidate slug or title.
16
+ * @returns Lowercase, hyphen-separated, alphanumeric-only slug.
17
+ */
18
+ export declare function normalizeSlug(raw: string): string;
19
+ /**
20
+ * Proposes alternatives for a slug that is taken.
21
+ *
22
+ * Suggested rather than applied: the slug is the public URL and, in practice,
23
+ * permanent — the rename endpoint does not update Rigobot's copy. Auto-suffixing
24
+ * is how a `solid-principles-7` ends up shipped. This mirrors the creator's own
25
+ * publish dialog, which checks availability and lets the human choose.
26
+ * @param base - The slug that was taken.
27
+ * @param registry - Used to check each candidate.
28
+ * @returns Up to three available slugs.
29
+ */
30
+ export declare function suggestSlugs(base: string, registry: PackageRegistry): Promise<string[]>;
31
+ export type ResolveSlugInput = {
32
+ /** Slug the caller asked for, if any. */
33
+ requestedSlug?: string;
34
+ /** Course title, used to derive a slug when none was requested. */
35
+ courseTitle?: string;
36
+ };
37
+ /**
38
+ * Settles which slug the course will live under.
39
+ *
40
+ * An explicit slug is validated rather than repaired: silently reshaping what
41
+ * somebody typed hands them a different URL than the one they asked for. A
42
+ * derived slug is only a suggestion, which is why the caller is expected to
43
+ * show it.
44
+ *
45
+ * There is no shared default. An earlier version of the exporter fell back to
46
+ * "LearnPack Course" for an untitled course, which slugifies to
47
+ * `learnpack-course` for *every* such course — and since slugs are globally
48
+ * unique, the second one collides with the first.
49
+ * @param input - Requested slug and/or course title.
50
+ * @returns The slug to use.
51
+ * @throws IngestError `INVALID_SLUG` when nothing usable was given.
52
+ */
53
+ export declare function resolveSlug(input: ResolveSlugInput): string;