@learnpack/learnpack 5.0.362 → 5.0.364

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 (55) hide show
  1. package/lib/commands/serve.js +444 -279
  2. package/lib/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
  3. package/lib/creatorDist/assets/{index-CKg5uuoj.js → index-o3Cw0bsF.js} +69620 -66788
  4. package/lib/creatorDist/index.html +2 -2
  5. package/lib/models/creator.d.ts +1 -0
  6. package/lib/utils/api.d.ts +0 -9
  7. package/lib/utils/api.js +1 -65
  8. package/lib/utils/generationRetry.d.ts +48 -0
  9. package/lib/utils/generationRetry.js +81 -0
  10. package/lib/utils/interactivity/componentsGuide.d.ts +50 -0
  11. package/lib/utils/interactivity/componentsGuide.js +193 -0
  12. package/lib/utils/interactivity/interactivityPlan.d.ts +20 -0
  13. package/lib/utils/interactivity/interactivityPlan.js +33 -0
  14. package/lib/utils/interactivity/structuredComponents.d.ts +34 -0
  15. package/lib/utils/interactivity/structuredComponents.js +792 -0
  16. package/lib/utils/rigoActions.d.ts +1 -1
  17. package/lib/utils/rigoActions.js +2 -2
  18. package/package.json +1 -1
  19. package/src/commands/serve.ts +653 -397
  20. package/src/creator/package-lock.json +297 -3
  21. package/src/creator/package.json +1 -0
  22. package/src/creator/src/App.test.tsx +124 -1
  23. package/src/creator/src/App.tsx +52 -36
  24. package/src/creator/src/components/FileCard.tsx +63 -19
  25. package/src/creator/src/components/FileReadListener.test.tsx +128 -0
  26. package/src/creator/src/components/FileReadListener.tsx +75 -0
  27. package/src/creator/src/components/FileReviewModal.test.tsx +193 -0
  28. package/src/creator/src/components/FileReviewModal.tsx +128 -0
  29. package/src/creator/src/components/FileUploader.test.tsx +229 -0
  30. package/src/creator/src/components/FileUploader.tsx +212 -58
  31. package/src/creator/src/components/GenerationErrorCard.tsx +57 -0
  32. package/src/creator/src/components/MarkdownRenderer.tsx +4 -2
  33. package/src/creator/src/components/syllabus/SyllabusEditor.tsx +6 -9
  34. package/src/creator/src/index.css +45 -0
  35. package/src/creator/src/locales/en.json +27 -5
  36. package/src/creator/src/locales/es.json +27 -5
  37. package/src/creator/src/main.tsx +2 -0
  38. package/src/creator/src/utils/completionResult.test.ts +61 -0
  39. package/src/creator/src/utils/completionResult.ts +61 -2
  40. package/src/creator/src/utils/courseLanguage.test.ts +6 -0
  41. package/src/creator/src/utils/creationRequest.test.ts +53 -18
  42. package/src/creator/src/utils/creationRequest.ts +17 -12
  43. package/src/creator/src/utils/fileRead.ts +60 -0
  44. package/src/creator/src/utils/lib.ts +0 -5
  45. package/src/creator/src/utils/store.ts +0 -10
  46. package/src/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
  47. package/src/creatorDist/assets/{index-CKg5uuoj.js → index-o3Cw0bsF.js} +69620 -66788
  48. package/src/creatorDist/index.html +2 -2
  49. package/src/models/creator.ts +87 -84
  50. package/src/utils/api.ts +0 -83
  51. package/src/utils/generationRetry.ts +100 -0
  52. package/src/utils/interactivity/componentsGuide.ts +294 -0
  53. package/src/utils/interactivity/interactivityPlan.ts +57 -0
  54. package/src/utils/interactivity/structuredComponents.ts +1004 -0
  55. package/src/utils/rigoActions.ts +3 -2
@@ -0,0 +1,792 @@
1
+ "use strict";
2
+ // Turns the structured output of the interactivity template (v2) into the
3
+ // lesson markdown that the LearnPack IDE renders.
4
+ //
5
+ // The model returns the lesson text with <!-- component:ID --> placeholders and
6
+ // one list of data per component. This module validates each component, writes
7
+ // its markdown and puts it where its placeholder is. The model never writes
8
+ // component syntax, so a component whose data is invalid is dropped instead of
9
+ // reaching the README broken.
10
+ //
11
+ // Every syntax rule below comes from how the IDE parses the markdown
12
+ // (learnpack/ide: Markdowner.tsx, QuizRenderer.tsx, markdownConfig.ts).
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.isImageComponent = exports.processInteractivityResult = exports.removeCopiedSyntax = exports.seededShuffle = exports.toSlug = exports.fence = exports.encodeMetaValue = exports.stripBlockPrefix = exports.collapse = exports.DEFAULT_DECIDED_BY = exports.COMPONENT_NAMES = void 0;
15
+ const readmeSanitizer_1 = require("../readmeSanitizer");
16
+ exports.COMPONENT_NAMES = [
17
+ "multiple_choice_question",
18
+ "select_the_blank",
19
+ "ordering",
20
+ "open_text_question",
21
+ "code_challenge_proposal",
22
+ "ask_rigo_button",
23
+ "mermaid_diagram",
24
+ "technical_diagram",
25
+ "conceptual_illustration",
26
+ "realistic_photography",
27
+ ];
28
+ const IMAGE_COMPONENTS = new Set([
29
+ "technical_diagram",
30
+ "conceptual_illustration",
31
+ "realistic_photography",
32
+ ]);
33
+ const ASSESSMENT_COMPONENTS = new Set([
34
+ "multiple_choice_question",
35
+ "select_the_blank",
36
+ "ordering",
37
+ "open_text_question",
38
+ "code_challenge_proposal",
39
+ ]);
40
+ // Used when the components guide is not at hand. It matches
41
+ // docs/components_v2.yml in learnpack/ide.
42
+ exports.DEFAULT_DECIDED_BY = {
43
+ multiple_choice_question: "syllabus",
44
+ select_the_blank: "syllabus",
45
+ ordering: "syllabus",
46
+ open_text_question: "syllabus",
47
+ code_challenge_proposal: "syllabus",
48
+ ask_rigo_button: "syllabus",
49
+ mermaid_diagram: "interactivity",
50
+ technical_diagram: "interactivity",
51
+ conceptual_illustration: "interactivity",
52
+ realistic_photography: "interactivity",
53
+ };
54
+ const KNOWN_MERMAID_TYPES = [
55
+ "graph",
56
+ "flowchart",
57
+ "sequenceDiagram",
58
+ "classDiagram",
59
+ "stateDiagram",
60
+ "erDiagram",
61
+ "journey",
62
+ "gantt",
63
+ "pie",
64
+ "mindmap",
65
+ "timeline",
66
+ "%%{init",
67
+ ];
68
+ // Code block languages of LearnPack components. A block with one of these
69
+ // languages in the lesson text was copied by the model instead of returned as
70
+ // data (see Markdowner.tsx CustomCodeBlock).
71
+ const COMPONENT_FENCE_LANGUAGES = new Set([
72
+ "select_the_blank",
73
+ "select",
74
+ "ordering",
75
+ "order",
76
+ "question",
77
+ "fill_in_the_blank",
78
+ "fill",
79
+ "code_challenge_proposal",
80
+ ]);
81
+ // ---------------------------------------------------------------------------
82
+ // Text utilities
83
+ // ---------------------------------------------------------------------------
84
+ const asText = (value) => typeof value === "string" ?
85
+ value :
86
+ (value === null || value === undefined ?
87
+ "" :
88
+ String(value));
89
+ // One line of text: line breaks and repeated spaces become a single space.
90
+ const collapse = (value) => asText(value).replace(/\s+/g, " ").trim();
91
+ exports.collapse = collapse;
92
+ // Removes markdown block markers at the start of a text: headings, quotes,
93
+ // list markers, numbers and task list boxes. Used where a text must stay a
94
+ // plain paragraph, such as a question or an option.
95
+ const stripBlockPrefix = (value) => {
96
+ let text = (0, exports.collapse)(value);
97
+ let previous = "";
98
+ while (text !== previous) {
99
+ previous = text;
100
+ text = text
101
+ .replace(/^#{1,6}\s+/, "")
102
+ .replace(/^>\s*/, "")
103
+ .replace(/^[*+-]\s+/, "")
104
+ .replace(/^\d+[).]\s+/, "")
105
+ .replace(/^\[[ Xx]]\s*/, "")
106
+ .trim();
107
+ }
108
+ return text;
109
+ };
110
+ exports.stripBlockPrefix = stripBlockPrefix;
111
+ // Encodes a value for the info string of a code block, such as
112
+ // select_the_blank 1="..." or question eval="...". CommonMark decodes
113
+ // backslash escapes and entities in the info string before the IDE reads the
114
+ // value with /(\w+)="([^"]*)"/, a backtick ends the code block, and the value
115
+ // cannot span lines.
116
+ const encodeMetaValue = (value) => (0, exports.collapse)(value)
117
+ .replace(/`/g, "")
118
+ .replace(/\\/g, "\\\\")
119
+ .replace(/&/g, "&amp;")
120
+ .replace(/"/g, "'")
121
+ .trim();
122
+ exports.encodeMetaValue = encodeMetaValue;
123
+ // A code block whose fence is longer than any run of backticks in its body,
124
+ // so that no line of the body can close it.
125
+ const fence = (language, meta, body) => {
126
+ let longest = 0;
127
+ for (const run of body.match(/`+/g) || [])
128
+ longest = Math.max(longest, run.length);
129
+ const marks = "`".repeat(Math.max(3, longest + 1));
130
+ const info = meta ? `${language} ${meta}` : language;
131
+ return `${marks}${info}\n${body}\n${marks}`;
132
+ };
133
+ exports.fence = fence;
134
+ // Text used to compare options and questions: no inline formatting, no case.
135
+ const comparable = (value) => value
136
+ .replace(/[*_`~]/g, "")
137
+ .replace(/\s+/g, " ")
138
+ .trim()
139
+ .toLowerCase();
140
+ const wordCount = (value) => value.split(/\s+/).filter(Boolean).length;
141
+ const headingLine = (value) => {
142
+ const heading = (0, exports.stripBlockPrefix)(value);
143
+ return heading ? `### ${heading}\n\n` : "";
144
+ };
145
+ // Combining marks left by NFD normalization (U+0300 to U+036F).
146
+ const DIACRITICS = new RegExp(`[${String.fromCharCode(768)}-${String.fromCharCode(879)}]`, "g");
147
+ // Lowercase ASCII slug with hyphens.
148
+ const toSlug = (value) => value
149
+ .normalize("NFD")
150
+ .replace(DIACRITICS, "")
151
+ .toLowerCase()
152
+ .replace(/[^\da-z]+/g, "-")
153
+ .replace(/^-+|-+$/g, "");
154
+ exports.toSlug = toSlug;
155
+ // FNV-1a hash, used as a seed and to tell image descriptions apart.
156
+ const hash = (value) => {
157
+ let h = 2166136261;
158
+ for (let i = 0; i < value.length; i++) {
159
+ h ^= value.charCodeAt(i);
160
+ h = Math.imul(h, 16777619);
161
+ }
162
+ return h >>> 0;
163
+ };
164
+ // Deterministic shuffle: the same question always gets the same order, and
165
+ // different questions get different orders. The IDE does not shuffle multiple
166
+ // choice options, so without this the correct option would always sit where
167
+ // the model wrote it.
168
+ const seededShuffle = (items, seed) => {
169
+ let state = hash(seed) || 1;
170
+ const random = () => {
171
+ // mulberry32. `| 0` wraps the sum to 32 bits, which Math.trunc does not.
172
+ // eslint-disable-next-line unicorn/prefer-math-trunc
173
+ state = (state + 1831565813) | 0;
174
+ let t = state;
175
+ t = Math.imul(t ^ (t >>> 15), t | 1);
176
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
177
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
178
+ };
179
+ const result = [...items];
180
+ for (let i = result.length - 1; i > 0; i--) {
181
+ const j = Math.floor(random() * (i + 1));
182
+ const tmp = result[i];
183
+ result[i] = result[j];
184
+ result[j] = tmp;
185
+ }
186
+ return result;
187
+ };
188
+ exports.seededShuffle = seededShuffle;
189
+ // Separator that ends any list around a quiz. It is an HTML block, which the
190
+ // IDE does not render (react-markdown skipHtml), and without it a quiz next to
191
+ // a numbered list in the text would merge with that list and never show its
192
+ // submit button (QuizRenderer counts every item of the list as a question).
193
+ const QUIZ_SEPARATOR = "<!-- -->";
194
+ // ---------------------------------------------------------------------------
195
+ // Builders, one per component
196
+ // ---------------------------------------------------------------------------
197
+ const buildMultipleChoice = (data, state, warn) => {
198
+ const errors = [];
199
+ // Problems of single questions: the question is removed, the quiz stays.
200
+ const questionErrors = [];
201
+ const questions = Array.isArray(data === null || data === void 0 ? void 0 : data.questions) ? data.questions : [];
202
+ const blocks = [];
203
+ for (const [index, raw] of questions.entries()) {
204
+ const where = `question ${index + 1}`;
205
+ const question = (0, exports.stripBlockPrefix)(raw === null || raw === void 0 ? void 0 : raw.question);
206
+ if (!question) {
207
+ questionErrors.push(`${where} is empty`);
208
+ continue;
209
+ }
210
+ const key = comparable(question);
211
+ if (state.usedQuestions.has(key)) {
212
+ questionErrors.push(`${where} repeats another question of the step`);
213
+ continue;
214
+ }
215
+ const options = (Array.isArray(raw === null || raw === void 0 ? void 0 : raw.options) ? raw.options : []).map((option) => ({
216
+ text: (0, exports.stripBlockPrefix)(option === null || option === void 0 ? void 0 : option.text),
217
+ correct: (option === null || option === void 0 ? void 0 : option.correct) === true,
218
+ }));
219
+ if (options.some(option => !option.text)) {
220
+ questionErrors.push(`${where} has an empty option`);
221
+ continue;
222
+ }
223
+ if (options.length < 2) {
224
+ questionErrors.push(`${where} has fewer than 2 options`);
225
+ continue;
226
+ }
227
+ const correctCount = options.filter(option => option.correct).length;
228
+ if (correctCount !== 1) {
229
+ questionErrors.push(`${where} has ${correctCount} correct options instead of 1`);
230
+ continue;
231
+ }
232
+ // The IDE identifies an option by its text, so repeated options collide.
233
+ const optionKeys = options.map(option => comparable(option.text));
234
+ if (new Set(optionKeys).size !== optionKeys.length) {
235
+ questionErrors.push(`${where} has repeated options`);
236
+ continue;
237
+ }
238
+ if (options.length < 3 || options.length > 5) {
239
+ warn(`${where} has ${options.length} options (3 to 5 expected)`);
240
+ }
241
+ const correct = options.find((option) => option.correct);
242
+ if (correct &&
243
+ comparable(correct.text).length > 3 &&
244
+ key.includes(comparable(correct.text))) {
245
+ warn(`${where} contains its correct option`);
246
+ }
247
+ state.usedQuestions.add(key);
248
+ const lines = (0, exports.seededShuffle)(options, question).map((option) => ` - [${option.correct ? "x" : " "}] ${option.text}`);
249
+ blocks.push(`1. ${question}\n\n${lines.join("\n")}`);
250
+ }
251
+ if (blocks.length === 0) {
252
+ errors.push(...questionErrors, questions.length === 0 ?
253
+ "the quiz has no questions" :
254
+ "no question is valid");
255
+ return { errors };
256
+ }
257
+ // Questions are separated by a blank line so that the list is loose: in a
258
+ // tight list the question is not a paragraph and the IDE renders nothing.
259
+ const markdown = `${QUIZ_SEPARATOR}\n\n${headingLine(data === null || data === void 0 ? void 0 : data.heading)}${blocks.join("\n\n")}\n\n${QUIZ_SEPARATOR}`;
260
+ return {
261
+ markdown,
262
+ errors,
263
+ removed: questionErrors.map(error => `${error}; the question was removed`),
264
+ };
265
+ };
266
+ const buildSelectTheBlank = (data, _state, warn) => {
267
+ const errors = [];
268
+ const text = asText(data === null || data === void 0 ? void 0 : data.text)
269
+ .split(/\r?\n/)
270
+ .map(line => line.replace(/\s+$/, ""))
271
+ .join("\n")
272
+ .replace(/^\n+|\n+$/g, "");
273
+ if (!text.trim())
274
+ return { errors: ["the text is empty"] };
275
+ const blanks = Array.isArray(data === null || data === void 0 ? void 0 : data.blanks) ? data.blanks : [];
276
+ const markers = (text.match(/_(\d+)_/g) || []).map(marker => Number(marker.slice(1, -1)));
277
+ const expected = blanks.map((_, index) => index + 1);
278
+ const sortedMarkers = [...markers].sort((a, b) => a - b);
279
+ if (blanks.length === 0) {
280
+ errors.push("there are no blanks");
281
+ }
282
+ else if (sortedMarkers.join(",") !== expected.join(",")) {
283
+ errors.push(`the text has blanks [${markers.join(", ")}] but ${blanks.length} blank options were given`);
284
+ }
285
+ const metadata = [];
286
+ for (const [index, blank] of blanks.entries()) {
287
+ const where = `blank ${index + 1}`;
288
+ const correct = (0, exports.encodeMetaValue)(blank === null || blank === void 0 ? void 0 : blank.correct);
289
+ if (!correct) {
290
+ errors.push(`${where} has no correct option`);
291
+ continue;
292
+ }
293
+ const seen = new Set([comparable(correct)]);
294
+ const distractors = [];
295
+ for (const raw of Array.isArray(blank === null || blank === void 0 ? void 0 : blank.distractors) ?
296
+ blank.distractors :
297
+ []) {
298
+ const distractor = (0, exports.encodeMetaValue)(raw);
299
+ if (!distractor || seen.has(comparable(distractor)))
300
+ continue;
301
+ seen.add(comparable(distractor));
302
+ distractors.push(distractor);
303
+ }
304
+ const options = [correct, ...distractors];
305
+ if (options.some(option => option.includes("|"))) {
306
+ errors.push(`${where} has an option with the character |`);
307
+ continue;
308
+ }
309
+ if (distractors.length < 2) {
310
+ errors.push(`${where} has ${distractors.length} distractors (at least 2 needed)`);
311
+ continue;
312
+ }
313
+ if (options.some(option => wordCount(option) > 4)) {
314
+ warn(`${where} has an option longer than 4 words`);
315
+ }
316
+ // The IDE takes the first option as the correct one and shuffles them.
317
+ metadata.push(`${index + 1}="${options.join("|")}"`);
318
+ }
319
+ if (errors.length > 0)
320
+ return { errors };
321
+ if (blanks.length < 3)
322
+ warn(`only ${blanks.length} blanks (at least 3 expected)`);
323
+ const markdown = headingLine(data === null || data === void 0 ? void 0 : data.heading) +
324
+ (0, exports.fence)("select_the_blank", metadata.join(" "), text);
325
+ return { markdown, errors };
326
+ };
327
+ const buildOrdering = (data, _state, warn) => {
328
+ const errors = [];
329
+ const items = (Array.isArray(data === null || data === void 0 ? void 0 : data.items) ? data.items : []).map(exports.stripBlockPrefix);
330
+ if (items.some((item) => !item))
331
+ errors.push("an item is empty");
332
+ if (items.length < 3)
333
+ errors.push(`${items.length} items (at least 3 needed)`);
334
+ // The IDE uses each item as its key, so repeated items break the component.
335
+ const seen = new Set();
336
+ for (const item of items) {
337
+ if (seen.has(comparable(item))) {
338
+ errors.push("there are repeated items");
339
+ break;
340
+ }
341
+ seen.add(comparable(item));
342
+ }
343
+ if (errors.length > 0)
344
+ return { errors };
345
+ if (items.length > 6)
346
+ warn(`${items.length} items (at most 6 expected)`);
347
+ let title = (0, exports.encodeMetaValue)(data === null || data === void 0 ? void 0 : data.instruction);
348
+ // The IDE turns "true" and "false" into booleans and ignores them.
349
+ if (/^(true|false)$/i.test(title))
350
+ title = "";
351
+ const meta = title ? `title="${title}"` : "";
352
+ const markdown = headingLine(data === null || data === void 0 ? void 0 : data.heading) + (0, exports.fence)("ordering", meta, items.join("\n"));
353
+ return { markdown, errors };
354
+ };
355
+ const buildOpenText = (data, _state, warn) => {
356
+ const errors = [];
357
+ const question = asText(data === null || data === void 0 ? void 0 : data.question).trim();
358
+ const evaluation = (0, exports.encodeMetaValue)(data === null || data === void 0 ? void 0 : data.eval);
359
+ if (!question)
360
+ errors.push("the question is empty");
361
+ if (!evaluation)
362
+ errors.push("the eval is empty");
363
+ const examples = (Array.isArray(data === null || data === void 0 ? void 0 : data.examples) ? data.examples : [])
364
+ .map((example) => ({
365
+ text: (0, exports.collapse)(example === null || example === void 0 ? void 0 : example.text).replace(/^(?:in)?correct\s*:\s*/i, ""),
366
+ correct: (example === null || example === void 0 ? void 0 : example.correct) === true,
367
+ }))
368
+ .filter((example) => example.text);
369
+ if (errors.length > 0)
370
+ return { errors };
371
+ // Examples help the evaluator but are not required: the IDE evaluates with
372
+ // the eval, and when a question has fewer than 3 examples it asks the
373
+ // course author to add them (OpenQuestion.tsx). Dropping the question would
374
+ // lose it, so missing examples are only reported.
375
+ if (examples.length < 3 || examples.length > 5) {
376
+ warn(`${examples.length} examples (3 to 5 expected)`);
377
+ }
378
+ if (examples.length > 0 && !examples.some(example => example.correct)) {
379
+ warn("no correct example");
380
+ }
381
+ if (examples.length > 0 && !examples.some(example => !example.correct)) {
382
+ warn("no incorrect example");
383
+ }
384
+ // The learner never sees the code block, so the question goes before it.
385
+ // The IDE reads one example per line.
386
+ const body = examples
387
+ .map((example) => `${example.correct ? "CORRECT" : "INCORRECT"}: ${example.text}`)
388
+ .join("\n");
389
+ const markdown = headingLine(data === null || data === void 0 ? void 0 : data.heading) +
390
+ `${question}\n\n` +
391
+ (0, exports.fence)("question", `eval="${evaluation}"`, body);
392
+ return { markdown, errors };
393
+ };
394
+ const buildCodeChallenge = data => {
395
+ // The IDE removes these blocks with a regex that expects a fence of exactly
396
+ // three backticks, so runs of backticks are removed from the text instead of
397
+ // lengthening the fence.
398
+ const description = asText(data === null || data === void 0 ? void 0 : data.description).replace(/`{3,}/g, "").trim();
399
+ if (!description)
400
+ return { errors: ["the description is empty"] };
401
+ return {
402
+ markdown: (0, exports.fence)("code_challenge_proposal", "", description),
403
+ errors: [],
404
+ };
405
+ };
406
+ const buildAskRigo = data => {
407
+ const errors = [];
408
+ const label = (0, exports.collapse)(data === null || data === void 0 ? void 0 : data.label).replace(/[[\]]/g, "");
409
+ const question = (0, exports.collapse)(data === null || data === void 0 ? void 0 : data.question);
410
+ if (!label)
411
+ errors.push("the label is empty");
412
+ if (wordCount(question) < 3)
413
+ errors.push("the question has fewer than 3 words");
414
+ if (errors.length > 0)
415
+ return { errors };
416
+ // The IDE reads the query with URLSearchParams and turns hyphens into
417
+ // spaces, so hyphens of the question become spaces first. Encoding keeps
418
+ // accents and non-Latin scripts, and parentheses are encoded too because
419
+ // they would end the link.
420
+ const query = question
421
+ .toLowerCase()
422
+ .replace(/-/g, " ")
423
+ .trim()
424
+ .split(/\s+/)
425
+ .map(word => encodeURIComponent(word).replace(/\(/g, "%28").replace(/\)/g, "%29"))
426
+ .join("-");
427
+ return {
428
+ markdown: `[${label}](https://4geeks.com/ask?query=${query})`,
429
+ errors,
430
+ };
431
+ };
432
+ const buildImage = (data, state, warn) => {
433
+ const alt = (0, exports.collapse)(data === null || data === void 0 ? void 0 : data.alt).replace(/[[\]]/g, "");
434
+ if (!alt)
435
+ return { errors: ["the alt text is empty"] };
436
+ if (wordCount(alt) < 30)
437
+ warn(`the alt text has ${wordCount(alt)} words (at least 30 expected)`);
438
+ // Image files are shared by the whole course and the image webhook skips
439
+ // files that already exist, so the name carries the step id and a hash of
440
+ // the description: the same description keeps its file, a new one gets a
441
+ // new file.
442
+ const words = (0, exports.toSlug)(alt.split(/\s+/).slice(0, 6).join(" ")) ||
443
+ (0, exports.toSlug)(asText(data === null || data === void 0 ? void 0 : data.id)) ||
444
+ "image";
445
+ const base = `${(0, exports.toSlug)(state.lessonId) || "step"}-${words}`
446
+ .slice(0, 72)
447
+ .replace(/-+$/, "");
448
+ let name = `${base}-${hash(alt).toString(16).padStart(8, "0").slice(0, 6)}`;
449
+ let suffix = 2;
450
+ while (state.usedImageNames.has(name)) {
451
+ name = `${base}-${hash(alt)
452
+ .toString(16)
453
+ .padStart(8, "0")
454
+ .slice(0, 6)}-${suffix++}`;
455
+ }
456
+ state.usedImageNames.add(name);
457
+ return { markdown: `![${alt}](./.learn/assets/${name}.png)`, errors: [] };
458
+ };
459
+ const buildMermaid = (data, _state, warn) => {
460
+ const code = asText(data === null || data === void 0 ? void 0 : data.code)
461
+ .trim()
462
+ .replace(/^(`{3,}|~{3,})\s*mermaid\s*\n/i, "")
463
+ .replace(/\n?(`{3,}|~{3,})\s*$/, "")
464
+ .trim();
465
+ if (!code)
466
+ return { errors: ["the code is empty"] };
467
+ if (!KNOWN_MERMAID_TYPES.some(type => code.startsWith(type))) {
468
+ warn("the diagram type is not one of the usual Mermaid types");
469
+ }
470
+ return { markdown: (0, exports.fence)("mermaid", "", code), errors: [] };
471
+ };
472
+ const BUILDERS = {
473
+ multiple_choice_question: buildMultipleChoice,
474
+ select_the_blank: buildSelectTheBlank,
475
+ ordering: buildOrdering,
476
+ open_text_question: buildOpenText,
477
+ code_challenge_proposal: buildCodeChallenge,
478
+ ask_rigo_button: buildAskRigo,
479
+ mermaid_diagram: buildMermaid,
480
+ technical_diagram: buildImage,
481
+ conceptual_illustration: buildImage,
482
+ realistic_photography: buildImage,
483
+ };
484
+ // ---------------------------------------------------------------------------
485
+ // Lesson text
486
+ // ---------------------------------------------------------------------------
487
+ const PLACEHOLDER = /<!--\s*component\s*:\s*(.*?)\s*-->/gi;
488
+ const FENCE_OPEN = /^\s*(`{3,}|~{3,})\s*([^\s`]*)/;
489
+ const NESTED_TASK_ITEM = /^(?: {2,}|\t)(?:[*+-]|\d+[).])\s+\[[ Xx]]\s/;
490
+ const ASK_RIGO_LINK = /\[([^\]]*)]\(\s*https?:\/\/(?:www\.)?4geeks\.com\/ask\/?\?[^)]*\)/g;
491
+ const normalizeId = (value) => (0, exports.collapse)(value).toLowerCase();
492
+ const isFenceClose = (line, open) => {
493
+ const match = line.match(/^\s*(`{3,}|~{3,})\s*$/);
494
+ return Boolean(match && match[1][0] === open[0] && match[1].length >= open.length);
495
+ };
496
+ // Removes component markup that the model wrote in the lesson text instead of
497
+ // returning it as data. It usually copies it from the previous lesson, which
498
+ // reaches the model already rendered. Removing it keeps a broken or duplicated
499
+ // component out of the README.
500
+ const removeCopiedSyntax = (text) => {
501
+ const removed = [];
502
+ const output = [];
503
+ const lines = text.split(/\r?\n/);
504
+ let openFence = null;
505
+ let dropping = false;
506
+ let removedTaskItems = 0;
507
+ // Only task items nested under a numbered item have the shape of a quiz;
508
+ // other checklists in the text are left alone.
509
+ let underNumberedItem = false;
510
+ for (const line of lines) {
511
+ if (openFence) {
512
+ if (!dropping)
513
+ output.push(line);
514
+ if (isFenceClose(line, openFence)) {
515
+ openFence = null;
516
+ dropping = false;
517
+ }
518
+ continue;
519
+ }
520
+ const fenceMatch = line.match(FENCE_OPEN);
521
+ if (fenceMatch) {
522
+ openFence = fenceMatch[1];
523
+ const language = fenceMatch[2].split("-")[0].toLowerCase();
524
+ dropping =
525
+ COMPONENT_FENCE_LANGUAGES.has(language) ||
526
+ COMPONENT_FENCE_LANGUAGES.has(fenceMatch[2].toLowerCase());
527
+ if (dropping) {
528
+ removed.push(`a ${fenceMatch[2]} block written in the lesson text`);
529
+ }
530
+ else {
531
+ output.push(line);
532
+ }
533
+ continue;
534
+ }
535
+ // Only lines that start at column 0 change the parent; indented lines
536
+ // belong to the current item.
537
+ if (/^\d+[).]\s+/.test(line)) {
538
+ underNumberedItem = true;
539
+ }
540
+ else if (line.trim() && !/^\s/.test(line)) {
541
+ underNumberedItem = false;
542
+ }
543
+ if (underNumberedItem && NESTED_TASK_ITEM.test(line)) {
544
+ removedTaskItems++;
545
+ continue;
546
+ }
547
+ output.push(line.replace(ASK_RIGO_LINK, (_match, label) => {
548
+ removed.push("an Ask Rigo link written in the lesson text");
549
+ return label;
550
+ }));
551
+ }
552
+ if (removedTaskItems > 0) {
553
+ removed.push(`${removedTaskItems} quiz option line(s) written in the lesson text`);
554
+ }
555
+ return { text: output.join("\n"), removed };
556
+ };
557
+ exports.removeCopiedSyntax = removeCopiedSyntax;
558
+ const splitPlaceholders = (text) => {
559
+ const pieces = [];
560
+ let openFence = null;
561
+ for (const line of text.split(/\r?\n/)) {
562
+ if (openFence) {
563
+ pieces.push({ kind: "text", line });
564
+ if (isFenceClose(line, openFence))
565
+ openFence = null;
566
+ continue;
567
+ }
568
+ const fenceMatch = line.match(FENCE_OPEN);
569
+ if (fenceMatch) {
570
+ openFence = fenceMatch[1];
571
+ pieces.push({ kind: "text", line });
572
+ continue;
573
+ }
574
+ PLACEHOLDER.lastIndex = 0;
575
+ if (!PLACEHOLDER.test(line)) {
576
+ pieces.push({ kind: "text", line });
577
+ continue;
578
+ }
579
+ PLACEHOLDER.lastIndex = 0;
580
+ let last = 0;
581
+ let match;
582
+ let first = true;
583
+ while ((match = PLACEHOLDER.exec(line)) !== null) {
584
+ const before = line.slice(last, match.index);
585
+ // A placeholder alone in a list item or a quote leaves its container, so
586
+ // the component is a block of its own. When the line has other text,
587
+ // that text keeps its list or quote marker.
588
+ const withoutContainer = first ?
589
+ before.replace(/^\s*(?:>\s*)*(?:(?:[*+-]|\d+[).])\s+)?/, "") :
590
+ before;
591
+ if (withoutContainer.trim())
592
+ pieces.push({ kind: "text", line: before.replace(/\s+$/, "") });
593
+ pieces.push({ kind: "placeholder", id: normalizeId(match[1]) });
594
+ last = match.index + match[0].length;
595
+ first = false;
596
+ }
597
+ const after = line.slice(last);
598
+ if (after.trim())
599
+ pieces.push({ kind: "text", line: after.trim() });
600
+ }
601
+ return pieces;
602
+ };
603
+ // Joins text lines and component blocks, with a blank line around each block
604
+ // and never more than one blank line in a row.
605
+ const joinPieces = (parts) => {
606
+ const out = [];
607
+ const pushBlank = () => {
608
+ if (out.length > 0 && out[out.length - 1] !== "")
609
+ out.push("");
610
+ };
611
+ for (const part of parts) {
612
+ if (part.kind === "block") {
613
+ pushBlank();
614
+ out.push(...part.markdown.split("\n"), "");
615
+ }
616
+ else {
617
+ out.push(part.line);
618
+ }
619
+ }
620
+ return out.join("\n");
621
+ };
622
+ const normalizeBlankLines = (text) => {
623
+ const cleaned = text
624
+ .split("\n")
625
+ .map(line => (line.trim() === "" ? "" : line.replace(/\s+$/, "")))
626
+ .join("\n")
627
+ .replace(/\n{3,}/g, "\n\n")
628
+ .replace(/^\n+/, "")
629
+ .replace(/\n*$/, "\n");
630
+ // The IDE applies this when it reads a README; the result must not change.
631
+ return (0, readmeSanitizer_1.sanitizeReadmeNewlines)(cleaned);
632
+ };
633
+ const removePlaceholders = (text) => splitPlaceholders(text)
634
+ .filter((piece) => piece.kind === "text")
635
+ .map(piece => piece.line)
636
+ .join("\n");
637
+ // ---------------------------------------------------------------------------
638
+ // Entry point
639
+ // ---------------------------------------------------------------------------
640
+ const processUnsafe = (parsed, ctx) => {
641
+ const dropped = [];
642
+ const warnings = [];
643
+ const decidedBy = Object.assign(Object.assign({}, exports.DEFAULT_DECIDED_BY), ctx.decidedBy);
644
+ const state = {
645
+ lessonId: asText(ctx.lessonId),
646
+ usedImageNames: new Set(),
647
+ usedQuestions: new Set(),
648
+ };
649
+ // Build every component. Order: as the lists come, which is the order the
650
+ // model wrote them.
651
+ const rendered = new Map();
652
+ const unidentified = [];
653
+ for (const name of exports.COMPONENT_NAMES) {
654
+ const list = Array.isArray(parsed === null || parsed === void 0 ? void 0 : parsed[name]) ? parsed[name] : [];
655
+ for (const [index, data] of list.entries()) {
656
+ const id = normalizeId(data === null || data === void 0 ? void 0 : data.id);
657
+ const label = id || `${name} #${index + 1}`;
658
+ if (id && rendered.has(id)) {
659
+ dropped.push({
660
+ component: name,
661
+ id,
662
+ message: `${name} "${label}" repeats an id`,
663
+ });
664
+ continue;
665
+ }
666
+ const result = BUILDERS[name](data, state, message => warnings.push({
667
+ component: name,
668
+ id: label,
669
+ message: `${name} "${label}": ${message}`,
670
+ }));
671
+ if (!result.markdown || result.errors.length > 0) {
672
+ dropped.push({
673
+ component: name,
674
+ id: label,
675
+ message: `${name} "${label}" was removed: ${result.errors.join("; ")}`,
676
+ });
677
+ continue;
678
+ }
679
+ for (const message of result.removed || []) {
680
+ dropped.push({
681
+ component: name,
682
+ id: label,
683
+ message: `${name} "${label}": ${message}`,
684
+ });
685
+ }
686
+ const component = { name, id: label, markdown: result.markdown };
687
+ if (id)
688
+ rendered.set(id, component);
689
+ else
690
+ unidentified.push(component);
691
+ }
692
+ }
693
+ let content = asText(parsed === null || parsed === void 0 ? void 0 : parsed.final_content);
694
+ // Cache-busting suffix that the CLI adds to the draft (see serve.ts).
695
+ content = content.replace(/-\d{6}\s*$/, "");
696
+ const copied = (0, exports.removeCopiedSyntax)(content);
697
+ for (const message of copied.removed)
698
+ dropped.push({ message: `Removed ${message}` });
699
+ const placed = new Set();
700
+ const parts = [];
701
+ const written = [];
702
+ for (const piece of splitPlaceholders(copied.text)) {
703
+ if (piece.kind === "text") {
704
+ parts.push(piece);
705
+ continue;
706
+ }
707
+ const component = rendered.get(piece.id);
708
+ if (!component) {
709
+ // Its data was invalid (already reported) or never returned.
710
+ if (!dropped.some(issue => issue.id === piece.id)) {
711
+ warnings.push({
712
+ id: piece.id,
713
+ message: `placeholder "${piece.id}" has no component`,
714
+ });
715
+ }
716
+ continue;
717
+ }
718
+ if (placed.has(piece.id)) {
719
+ warnings.push({
720
+ id: piece.id,
721
+ message: `placeholder "${piece.id}" appears more than once`,
722
+ });
723
+ continue;
724
+ }
725
+ placed.add(piece.id);
726
+ written.push(component);
727
+ parts.push({ kind: "block", markdown: component.markdown });
728
+ }
729
+ // Components with data but no placeholder.
730
+ const unplaced = [
731
+ ...[...rendered.values()].filter(c => !placed.has(c.id)),
732
+ ...unidentified,
733
+ ];
734
+ for (const component of unplaced) {
735
+ if (decidedBy[component.name] === "syllabus" && !ctx.isFirstOrLast) {
736
+ warnings.push({
737
+ component: component.name,
738
+ id: component.id,
739
+ message: `${component.name} "${component.id}" had no placeholder and was added at the end`,
740
+ });
741
+ written.push(component);
742
+ parts.push({ kind: "block", markdown: component.markdown });
743
+ }
744
+ else {
745
+ dropped.push({
746
+ component: component.name,
747
+ id: component.id,
748
+ message: `${component.name} "${component.id}" was removed: it had no placeholder in the lesson text`,
749
+ });
750
+ }
751
+ }
752
+ const usedComponents = exports.COMPONENT_NAMES.filter(name => written.some(c => c.name === name));
753
+ const assessmentCount = written.filter(c => ASSESSMENT_COMPONENTS.has(c.name)).length;
754
+ return {
755
+ markdown: normalizeBlankLines(joinPieces(parts)),
756
+ usedComponents,
757
+ assessmentCount,
758
+ dropped,
759
+ warnings,
760
+ };
761
+ };
762
+ // Never throws: a failure here would mark the lesson as failed with no retry
763
+ // (see the interactivity webhook in serve.ts), so an internal error returns the
764
+ // lesson text without placeholders.
765
+ const processInteractivityResult = (parsed, ctx = {}) => {
766
+ try {
767
+ return processUnsafe(parsed, ctx);
768
+ }
769
+ catch (error) {
770
+ let markdown = "";
771
+ try {
772
+ markdown = normalizeBlankLines(removePlaceholders(asText(parsed === null || parsed === void 0 ? void 0 : parsed.final_content)));
773
+ }
774
+ catch (_a) {
775
+ markdown = asText(parsed === null || parsed === void 0 ? void 0 : parsed.final_content);
776
+ }
777
+ return {
778
+ markdown,
779
+ usedComponents: [],
780
+ assessmentCount: 0,
781
+ dropped: [
782
+ {
783
+ message: `Components could not be processed: ${error.message}`,
784
+ },
785
+ ],
786
+ warnings: [],
787
+ };
788
+ }
789
+ };
790
+ exports.processInteractivityResult = processInteractivityResult;
791
+ const isImageComponent = (name) => IMAGE_COMPONENTS.has(name);
792
+ exports.isImageComponent = isImageComponent;