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