@learnpack/learnpack 5.0.363 → 5.0.365
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.
- package/lib/commands/serve.js +482 -286
- package/lib/models/creator.d.ts +1 -0
- package/lib/utils/continueGeneration.d.ts +65 -0
- package/lib/utils/continueGeneration.js +82 -0
- package/lib/utils/generationRetry.d.ts +48 -0
- package/lib/utils/generationRetry.js +81 -0
- package/lib/utils/interactivity/componentsGuide.d.ts +50 -0
- package/lib/utils/interactivity/componentsGuide.js +193 -0
- package/lib/utils/interactivity/interactivityPlan.d.ts +20 -0
- package/lib/utils/interactivity/interactivityPlan.js +33 -0
- package/lib/utils/interactivity/structuredComponents.d.ts +34 -0
- package/lib/utils/interactivity/structuredComponents.js +792 -0
- package/lib/utils/rigoActions.d.ts +5 -2
- package/lib/utils/rigoActions.js +5 -3
- package/package.json +1 -1
- package/src/commands/serve.ts +720 -408
- package/src/models/creator.ts +87 -84
- package/src/ui/_app/app.js +130 -72
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/continueGeneration.ts +108 -0
- package/src/utils/generationRetry.ts +100 -0
- package/src/utils/interactivity/componentsGuide.ts +294 -0
- package/src/utils/interactivity/interactivityPlan.ts +57 -0
- package/src/utils/interactivity/structuredComponents.ts +1004 -0
- package/src/utils/rigoActions.ts +8 -3
|
@@ -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, "&")
|
|
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: ``, 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;
|