@lernapps/app-templates 0.2.1 → 0.3.0

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 (56) hide show
  1. package/README.md +5 -11
  2. package/dist/{assessment-Dy359O1O.mjs → assessment-DaKsXUdj.mjs} +204 -16
  3. package/dist/{blocks-CxtCPAa5.mjs → blocks-D4SSGKuL.mjs} +2 -2
  4. package/dist/blocks.mjs +2 -2
  5. package/dist/{check-CteIFN96.mjs → check-BxZOZPic.mjs} +58 -4
  6. package/dist/{content-DO6oEJxj.mjs → content-CAL0Nz1a.mjs} +1 -1
  7. package/dist/explainer-e2e.mjs +5 -5
  8. package/dist/explainer-plugin.mjs +57 -13
  9. package/dist/explainer.mjs +2 -2
  10. package/dist/interactive-e2e.mjs +1 -1
  11. package/dist/interactive-plugin.mjs +2 -2
  12. package/dist/{load-RosvaPZf.mjs → load--ovXqbNE.mjs} +286 -33
  13. package/dist/questions.mjs +1 -1
  14. package/dist/quiz-e2e.mjs +1 -1
  15. package/dist/quiz-plugin.mjs +3 -3
  16. package/dist/{render-D5kwkNt1.mjs → render-D3LsJY04.mjs} +2 -2
  17. package/dist/{render-DqHOJyHK.mjs → render-mYtzN1DV.mjs} +1 -1
  18. package/dist/{result-D_mmCVwN.mjs → result-DphIWHDF.mjs} +1 -1
  19. package/dist/{validate-Bxy9NfB8.mjs → validate-CfFykN0M.mjs} +1 -1
  20. package/dist/{validate-BDwL7Su3.mjs → validate-j3GjVhNF.mjs} +2 -2
  21. package/explainer/README.md +7 -3
  22. package/explainer/docs/arc42/05-explainer-building-blocks.arc42.md +26 -8
  23. package/explainer/docs/arc42/09-explainer-decisions.arc42.md +42 -0
  24. package/explainer/package.json +1 -1
  25. package/explainer/src/app.yaml +3 -1
  26. package/explainer/src/prozent/chapter.yaml +1 -1
  27. package/explainer/src/prozent/grundwert.yaml +2 -1
  28. package/explainer/src/prozent/prozentsatz.yaml +2 -1
  29. package/explainer/src/prozent/prozentwert.yaml +2 -1
  30. package/interactive/package.json +1 -1
  31. package/package.json +3 -4
  32. package/quiz/package.json +1 -1
  33. package/runtime/explainer/assessment.ts +4 -2
  34. package/runtime/explainer/check-page.ts +6 -2
  35. package/runtime/explainer/content.ts +20 -1
  36. package/runtime/explainer/exercises.ts +71 -6
  37. package/runtime/explainer/explainer.css +21 -0
  38. package/runtime/explainer/explainer.v1.schema.json +2 -2
  39. package/runtime/explainer/explainer.v2.schema.json +152 -0
  40. package/runtime/explainer/expression.ts +127 -1
  41. package/runtime/explainer/generated/texts.ts +98 -9
  42. package/runtime/explainer/generated/types.ts +8 -0
  43. package/runtime/explainer/load.ts +70 -16
  44. package/runtime/explainer/messages/de.json +5 -1
  45. package/runtime/explainer/messages/en.json +5 -1
  46. package/runtime/explainer/outline.ts +1 -1
  47. package/runtime/explainer/page-list.ts +38 -0
  48. package/runtime/explainer/plugin.ts +13 -4
  49. package/runtime/explainer/render.ts +26 -2
  50. package/runtime/explainer/schema.ts +92 -9
  51. package/runtime/explainer/tutorpage.ts +9 -1
  52. package/runtime/shared/blocks/blocks.v1.schema.json +1 -1
  53. package/runtime/shared/text/formula.ts +61 -1
  54. package/scaffold/explainer/src/app.yaml +5 -1
  55. package/scaffold/explainer/src/thema/chapter.yaml +1 -1
  56. package/scaffold/explainer/src/thema/einstieg.yaml +1 -1
@@ -4,7 +4,10 @@
4
4
  // - arithmetic on exact fractions: + - * /, parentheses, unary minus; numbers as written (0.1 is 1/10);
5
5
  // - comparisons == != < <= > >=, and, or, not; true, false; strings in quotes;
6
6
  // - record fields (e.year), list index (xs[0]), list literals ([1, 2, 3]);
7
- // - the functions round(x, n), decimals(x), map(xs, field), sortBy(xs, field), reverse(xs).
7
+ // - the functions round(x, n), decimals(x), map(xs, field), sortBy(xs, field), reverse(xs);
8
+ // - the functions sqrt(x), pow(x, n), sin(x), cos(x), tan(x) and asin(x), acos(x), atan(x), with angles in degrees as
9
+ // in school (sin(30) is 1/2). A result is exact where it is rational (sqrt(9/4), pow(2, 10), sin(30), cos(60),
10
+ // tan(45), asin(1/2)); otherwise it is the value to 12 decimal places, which an exercise rounds with round(x, n).
8
11
  // Errors are ExpressionErrors whose message says what to change.
9
12
  import { Fraction } from "./fraction.ts";
10
13
 
@@ -49,6 +52,14 @@ const FUNCTIONS: Readonly<Record<string, { arity: number; field?: number }>> = {
49
52
  map: { arity: 2, field: 1 },
50
53
  sortBy: { arity: 2, field: 1 },
51
54
  reverse: { arity: 1 },
55
+ sqrt: { arity: 1 },
56
+ pow: { arity: 2 },
57
+ sin: { arity: 1 },
58
+ cos: { arity: 1 },
59
+ tan: { arity: 1 },
60
+ asin: { arity: 1 },
61
+ acos: { arity: 1 },
62
+ atan: { arity: 1 },
52
63
  };
53
64
  const KEYWORDS = new Set(["and", "or", "not", "true", "false"]);
54
65
 
@@ -322,6 +333,107 @@ function integer(value: Fraction, what: string): number {
322
333
  return Number(value.n);
323
334
  }
324
335
 
336
+ /** The decimal places of a result that is not rational (sqrt(2), sin(10)): enough for any rounding of an exercise. */
337
+ export const APPROXIMATION_PLACES = 12;
338
+
339
+ /** A real number that is no fraction, as the fraction of its first APPROXIMATION_PLACES decimals. */
340
+ function approximate(value: number, what: string): Fraction {
341
+ if (!Number.isFinite(value)) throw new ExpressionError(`${what} has no result here`);
342
+ return Fraction.from(Number(value.toFixed(APPROXIMATION_PLACES)));
343
+ }
344
+
345
+ /** The square root of a whole number ≥ 0, if it is whole. */
346
+ function wholeRoot(n: bigint): bigint | undefined {
347
+ if (n < 2n) return n;
348
+ let x = BigInt(Math.floor(Math.sqrt(Number(n))));
349
+ while (x * x > n) x--;
350
+ while ((x + 1n) * (x + 1n) <= n) x++;
351
+ return x * x === n ? x : undefined;
352
+ }
353
+
354
+ function sqrt(x: Fraction): Fraction {
355
+ if (x.n < 0n) throw new ExpressionError(`sqrt needs a number ≥ 0, not ${x.toNumber()}`);
356
+ const n = wholeRoot(x.n);
357
+ const d = wholeRoot(x.d);
358
+ return n !== undefined && d !== undefined ? Fraction.of(n, d) : approximate(Math.sqrt(x.toNumber()), "sqrt");
359
+ }
360
+
361
+ /** The largest exponent of pow: beyond it, a number has more digits than any exercise shows. */
362
+ const MAX_EXPONENT = 1000;
363
+
364
+ function pow(x: Fraction, n: Fraction): Fraction {
365
+ if (n.isInteger()) {
366
+ const e = Number(n.n);
367
+ if (Math.abs(e) > MAX_EXPONENT)
368
+ throw new ExpressionError(`pow takes an exponent from -${MAX_EXPONENT} to ${MAX_EXPONENT}`);
369
+ if (x.n === 0n && e < 0) throw new ExpressionError("pow(0, n) needs n ≥ 0: division by zero");
370
+ const power = Fraction.of(x.n ** BigInt(Math.abs(e)), x.d ** BigInt(Math.abs(e)));
371
+ return e < 0 ? Fraction.of(1).div(power) : power;
372
+ }
373
+ if (x.n < 0n) throw new ExpressionError("pow of a negative number needs a whole exponent");
374
+ return approximate(x.toNumber() ** n.toNumber(), "pow");
375
+ }
376
+
377
+ /** The angle `x` in degrees, brought into 0 ≤ x < 360 exactly. */
378
+ function turn(x: Fraction): Fraction {
379
+ const full = Fraction.of(360);
380
+ const quotient = x.div(full);
381
+ // floor of the quotient
382
+ let floor = quotient.n / quotient.d;
383
+ if (quotient.n < 0n && quotient.n % quotient.d !== 0n) floor -= 1n;
384
+ return x.sub(full.mul(Fraction.of(floor)));
385
+ }
386
+
387
+ /** The sine of the angles 0°, 30°, 90°, … whose sine is rational, in degrees. */
388
+ const EXACT_SINE: ReadonlyMap<number, Fraction> = new Map([
389
+ [0, Fraction.of(0)],
390
+ [30, Fraction.of(1, 2)],
391
+ [90, Fraction.of(1)],
392
+ [150, Fraction.of(1, 2)],
393
+ [180, Fraction.of(0)],
394
+ [210, Fraction.of(-1, 2)],
395
+ [270, Fraction.of(-1)],
396
+ [330, Fraction.of(-1, 2)],
397
+ ]);
398
+ const RADIANS = Math.PI / 180;
399
+
400
+ function sine(degrees: Fraction): Fraction {
401
+ const angle = turn(degrees);
402
+ const exact = angle.isInteger() ? EXACT_SINE.get(Number(angle.n)) : undefined;
403
+ return exact ?? approximate(Math.sin(angle.toNumber() * RADIANS), "sin");
404
+ }
405
+
406
+ const cosine = (degrees: Fraction): Fraction => sine(degrees.add(Fraction.of(90)));
407
+
408
+ function tangent(degrees: Fraction): Fraction {
409
+ const angle = turn(degrees);
410
+ const cos = cosine(angle);
411
+ if (cos.n === 0n) throw new ExpressionError(`tan(${degrees.toNumber()}) has no value: the cosine of the angle is 0`);
412
+ if (angle.isInteger() && [45, 135, 225, 315].includes(Number(angle.n))) {
413
+ return Fraction.of(Number(angle.n) % 180 === 45 ? 1 : -1);
414
+ }
415
+ const sin = sine(angle);
416
+ return sin.n === 0n ? sin : approximate(Math.tan(angle.toNumber() * RADIANS), "tan");
417
+ }
418
+
419
+ /** The inverse of sin, cos or tan in degrees: exact for the values of the angles of EXACT_SINE and of 45°. */
420
+ function inverse(fn: "asin" | "acos" | "atan", x: Fraction): Fraction {
421
+ if (fn !== "atan" && (x.compare(Fraction.of(-1)) < 0 || x.compare(Fraction.of(1)) > 0)) {
422
+ throw new ExpressionError(`${fn} needs a number from -1 to 1, not ${x.toNumber()}`);
423
+ }
424
+ const exact: Readonly<Record<string, number>> =
425
+ fn === "asin"
426
+ ? { "-1": -90, "-1/2": -30, "0": 0, "1/2": 30, "1": 90 }
427
+ : fn === "acos"
428
+ ? { "-1": 180, "-1/2": 120, "0": 90, "1/2": 60, "1": 0 }
429
+ : { "-1": -45, "0": 0, "1": 45 };
430
+ const known = exact[x.toString()];
431
+ if (known !== undefined) return Fraction.of(known);
432
+ const radians =
433
+ fn === "asin" ? Math.asin(x.toNumber()) : fn === "acos" ? Math.acos(x.toNumber()) : Math.atan(x.toNumber());
434
+ return approximate(radians / RADIANS, fn);
435
+ }
436
+
325
437
  function call(fn: string, args: readonly Node[], env: Readonly<Record<string, Value>>): Value {
326
438
  const value = (index: number) => evaluate(args[index] as Node, env);
327
439
  const field = () => (args[1] as Extract<Node, { kind: "name" }>).name;
@@ -341,6 +453,20 @@ function call(fn: string, args: readonly Node[], env: Readonly<Record<string, Va
341
453
  }
342
454
  case "reverse":
343
455
  return [...asList(value(0), "reverse")].reverse();
456
+ case "sqrt":
457
+ return sqrt(asNumber(value(0), "sqrt"));
458
+ case "pow":
459
+ return pow(asNumber(value(0), "pow"), asNumber(value(1), "pow"));
460
+ case "sin":
461
+ return sine(asNumber(value(0), "sin"));
462
+ case "cos":
463
+ return cosine(asNumber(value(0), "cos"));
464
+ case "tan":
465
+ return tangent(asNumber(value(0), "tan"));
466
+ case "asin":
467
+ case "acos":
468
+ case "atan":
469
+ return inverse(fn, asNumber(value(0), fn));
344
470
  default:
345
471
  throw new ExpressionError(`${fn} is not a function of the expression language`);
346
472
  }
@@ -18,6 +18,10 @@ const PATH_ONLY = new RegExp(String.raw`^${PATH}$`);
18
18
  const SPACED = new RegExp(String.raw`\{\{\s*(${PATH})\s*\}\}`, "g");
19
19
  /** A formula ($…$) or a run of prose, in a template. */
20
20
  const SEGMENT = /\$((?:\\.|[^\\$\n])+?)\$|([^$]+)/g;
21
+ /** A chemical formula whose whole argument is one placeholder: \ce{{{e.gl}}}. */
22
+ const CHEM = new RegExp(String.raw`\\ce\{\{\{(${PATH})\}\}\}`, "g");
23
+ /** The start of the token of a placeholder that is the whole argument of \ce: ⟦ce:e.gl⟧. */
24
+ const CHEM_TOKEN = "ce:";
21
25
 
22
26
  const formats = new Map<string, Intl.NumberFormat>();
23
27
  /** A number as the page shows it: up to six decimals, no grouping (a learner would type 1500, not 1.500). */
@@ -54,15 +58,89 @@ export function answerLabel(spec: Generated, task: Task, lang: string): string {
54
58
  return label(task.answer, lang);
55
59
  }
56
60
 
57
- /** A template's placeholders as tokens: {{G}} becomes ⟦G⟧, inside a formula ($…$) {\text{⟦G⟧}}. */
61
+ /**
62
+ * A template's placeholders as tokens: {{G}} becomes ⟦G⟧, inside a formula ($…$) {\text{⟦G⟧}}; a chemical formula
63
+ * whose whole argument is a placeholder, \ce{{{e.gl}}}, becomes {\text{⟦ce:e.gl⟧}}, which fill() replaces by the
64
+ * formula the build set for the task's value (LanguageTemplates.chem).
65
+ */
58
66
  export function tokenize(text: string): string {
59
67
  return text.replace(SEGMENT, (whole, math: string | undefined, prose: string | undefined) =>
60
68
  math !== undefined
61
- ? `$${math.replace(PLACEHOLDER, (_placeholder, path: string) => `{\\text{⟦${path}⟧}}`)}$`
69
+ ? `$${math
70
+ .replace(CHEM, (_chem, path: string) => `{\\text{⟦${CHEM_TOKEN}${path}⟧}}`)
71
+ .replace(PLACEHOLDER, (_placeholder, path: string) => `{\\text{⟦${path}⟧}}`)}$`
62
72
  : (prose ?? whole).replace(PLACEHOLDER, (_placeholder, path: string) => `⟦${path}⟧`),
63
73
  );
64
74
  }
65
75
 
76
+ /** The formulas of a template ($…$), as written. */
77
+ const formulasIn = (text: string): string[] =>
78
+ [...text.matchAll(SEGMENT)].flatMap((match) => (match[1] === undefined ? [] : [match[1]]));
79
+
80
+ /** The placeholders that are the whole argument of a \ce in a formula of a template (\ce{{{e.gl}}}), by their path. */
81
+ export function chemPlaceholdersIn(text: string): string[] {
82
+ return formulasIn(text).flatMap((math) => [...math.matchAll(CHEM)].map((match) => match[1] ?? ""));
83
+ }
84
+
85
+ /** The argument of each \ce and \pu of a formula, with its command; braces balanced. */
86
+ function mhchemArguments(math: string): { command: string; argument: string }[] {
87
+ const found: { command: string; argument: string }[] = [];
88
+ for (const match of math.matchAll(/\\(ce|pu)\s*\{/g)) {
89
+ const start = (match.index ?? 0) + match[0].length;
90
+ let depth = 1;
91
+ for (let at = start; at < math.length; at++) {
92
+ if (math[at] === "{") depth++;
93
+ else if (math[at] === "}" && --depth === 0) {
94
+ found.push({ command: `\\${match[1] ?? ""}`, argument: math.slice(start, at) });
95
+ break;
96
+ }
97
+ }
98
+ }
99
+ return found;
100
+ }
101
+
102
+ const WHOLE_PLACEHOLDER = new RegExp(String.raw`^\{\{${PATH}\}\}$`);
103
+
104
+ /**
105
+ * The problems of the placeholders in \ce and \pu of one template. A placeholder in \ce is its whole argument,
106
+ * \ce{{{e.gl}}}, drawn from a finite list (oneOf, or pick: 1 from a list or a data file), so that the build can set
107
+ * the formula of every value; \pu takes none.
108
+ */
109
+ export function chemProblems(text: string, given: Generated["given"]): string[] {
110
+ const problems: string[] = [];
111
+ for (const math of formulasIn(text)) {
112
+ for (const { command, argument } of mhchemArguments(math)) {
113
+ if (placeholdersIn(argument).length === 0) continue;
114
+ if (command === "\\pu") {
115
+ problems.push(
116
+ `\\pu{${argument}} has a placeholder inside; \\pu takes none: write the number before it, e.g. {{m}}\\,\\pu{g}`,
117
+ );
118
+ } else if (!WHOLE_PLACEHOLDER.test(argument)) {
119
+ problems.push(
120
+ `\\ce{${argument}} has a placeholder among other text; in \\ce a placeholder is the whole argument, \\ce{{{e.gl}}}: put the whole formula into one field of the data (gl: "2H2 + O2 -> 2H2O")`,
121
+ );
122
+ }
123
+ }
124
+ }
125
+ for (const path of chemPlaceholdersIn(text)) {
126
+ const root = path.split(".")[0] ?? "";
127
+ if (root === "answer") {
128
+ problems.push(
129
+ "\\ce{{{answer}}} is not possible: the build sets only the formulas of values drawn from a list; show the formula of a value of given",
130
+ );
131
+ continue;
132
+ }
133
+ const arbitrary = given[root];
134
+ if (arbitrary === undefined) continue; // reported as an unknown placeholder
135
+ if (arbitrary.oneOf === undefined && !(arbitrary.from !== undefined && arbitrary.pick === 1)) {
136
+ problems.push(
137
+ `\\ce{{{${path}}}} needs one value from a list: draw ${root} with oneOf, or with pick: 1 from a list or a data file, so that the build can set the formula of each value`,
138
+ );
139
+ }
140
+ }
141
+ return problems;
142
+ }
143
+
66
144
  /** The placeholders of a template, by their path (G, e.name, answer). */
67
145
  export function placeholdersIn(text: string): string[] {
68
146
  return [...text.matchAll(PLACEHOLDER)].map((match) => match[1] ?? "");
@@ -136,7 +214,7 @@ export function templateProblems(spec: Generated): { path: (string | number)[];
136
214
  const declared = [...Object.keys(spec.given), "answer"];
137
215
  return writtenTemplates(spec).flatMap(({ path, lang, text }) => {
138
216
  const known = new Set(declared);
139
- return placeholderProblems(text, known).map((message) => ({
217
+ return [...placeholderProblems(text, known), ...chemProblems(text, spec.given)].map((message) => ({
140
218
  path,
141
219
  ...(lang === undefined ? {} : { lang }),
142
220
  message,
@@ -194,10 +272,18 @@ export function fill(
194
272
  task: Task,
195
273
  lang: string,
196
274
  mode: "html" | "text" = "html",
275
+ chem: Readonly<Record<string, string>> = {},
197
276
  ): string {
198
277
  const withMath =
199
278
  mode === "html"
200
- ? template.replace(/<mtext>⟦([^⟧]+)⟧<\/mtext>/g, (_whole, path: string) => {
279
+ ? template.replace(/<mtext>⟦([^⟧]+)⟧<\/mtext>/g, (_whole, token: string) => {
280
+ if (token.startsWith(CHEM_TOKEN)) {
281
+ const path = token.slice(CHEM_TOKEN.length);
282
+ const value = placeholderValue(spec, task, path, lang);
283
+ if (value === undefined) return `<mtext>{{${escape(path)}}}</mtext>`;
284
+ return chem[value.text] ?? `<mtext>${escape(value.text)}</mtext>`;
285
+ }
286
+ const path = token;
201
287
  const value = placeholderValue(spec, task, path, lang);
202
288
  if (value === undefined) return `<mtext>{{${escape(path)}}}</mtext>`;
203
289
  if (value.number === undefined) return `<mtext>${escape(value.text)}</mtext>`;
@@ -206,7 +292,8 @@ export function fill(
206
292
  return negative ? `<mo>−</mo><mn>${digits}</mn>` : `<mn>${digits}</mn>`;
207
293
  })
208
294
  : template;
209
- return withMath.replace(/⟦([^⟧]+)⟧/g, (_whole, path: string) => {
295
+ return withMath.replace(/⟦([^⟧]+)⟧/g, (_whole, token: string) => {
296
+ const path = token.startsWith(CHEM_TOKEN) ? token.slice(CHEM_TOKEN.length) : token;
210
297
  const value = placeholderValue(spec, task, path, lang);
211
298
  if (value === undefined) return `{{${path}}}`;
212
299
  return mode === "html" ? escape(value.text) : value.text;
@@ -229,18 +316,20 @@ export const templatesIn = (templates: Templates, lang: string): LanguageTemplat
229
316
 
230
317
  /** The task's text as HTML, in `lang`. */
231
318
  export function taskText(spec: Generated, task: Task, lang: string, templates: Templates): string {
232
- return fill(templatesIn(templates, lang).task, spec, task, lang);
319
+ const { task: text, chem } = templatesIn(templates, lang);
320
+ return fill(text, spec, task, lang, "html", chem);
233
321
  }
234
322
 
235
323
  /** The steps of the solution as HTML, in `lang`. */
236
324
  export function stepsOf(spec: Generated, task: Task, lang: string, templates: Templates): string[] {
237
- return templatesIn(templates, lang).steps.map((step) => fill(step, spec, task, lang));
325
+ const { steps, chem } = templatesIn(templates, lang);
326
+ return steps.map((step) => fill(step, spec, task, lang, "html", chem));
238
327
  }
239
328
 
240
329
  /** The hint as HTML, in `lang`, if the exercise has one. */
241
330
  export function hintOf(spec: Generated, task: Task, lang: string, templates: Templates): string | undefined {
242
- const hint = templatesIn(templates, lang).hint;
243
- return hint === undefined ? undefined : fill(hint, spec, task, lang);
331
+ const { hint, chem } = templatesIn(templates, lang);
332
+ return hint === undefined ? undefined : fill(hint, spec, task, lang, "html", chem);
244
333
  }
245
334
 
246
335
  /** The text of HTML, as a learner reads it: tags left out, entities resolved, white space folded. */
@@ -17,6 +17,9 @@
17
17
  // around a lone name ({{G}}), means the same as the letter alone, so nothing is lost; any other group ({{G}^2},
18
18
  // {{G} + 1}) stays a group. To fill in a value where the formula needs a group, keep the group: \frac{{{W}}}{{{G}}}.
19
19
  // Two braces with spaces inside ({{ G }}) are an error everywhere, as they were most likely meant as a placeholder.
20
+ // In \ce a placeholder is the whole argument, \ce{{{e.gl}}}, drawn from a finite list (oneOf, or pick: 1 from a list
21
+ // or a data file): the build sets the formula of each value with mhchem (LanguageTemplates.chem), and fill() puts in
22
+ // the one of the task's value. A placeholder among other text in \ce, or any in \pu, is an error.
20
23
 
21
24
  // This module: the types of a generated exercise, its task and its data.
22
25
  import { type Value } from "../expression.ts";
@@ -90,6 +93,11 @@ export interface LanguageTemplates {
90
93
  hint?: string | undefined;
91
94
  /** The mistakes' messages, plain text. */
92
95
  messages: string[];
96
+ /**
97
+ * The chemical formula of each value of the placeholders in \ce{{{…}}}, as MathML without <math>, by the value as
98
+ * the learner reads it; fill() puts it in for the token ⟦ce:…⟧.
99
+ */
100
+ chem?: Readonly<Record<string, string>>;
93
101
  }
94
102
  export type Templates = Readonly<Record<string, LanguageTemplates>>;
95
103
 
@@ -1,9 +1,12 @@
1
- // Reads an explainer's content from src/ at build time and validates it: each file against its part of the schema
2
- // (explainer.v1.schema.json), then what a schema cannot say. The order lists are complete and unique, the depth is
1
+ // Reads an explainer's content from src/ at build time and validates it: each file against its part of the schema of
2
+ // its format (explainer.v1.schema.json without `format` in src/app.yaml, explainer.v2.schema.json with `format: 2`),
3
+ // then what a schema cannot say. In format 2, every page names its competencies when the app has a strand (or is a
4
+ // recap page, recap: <subject>/<competency>), and none when its strand is none; a recap page has no exercise, and
5
+ // every other page at least one. The order lists are complete and unique, the depth is
3
6
  // app, chapter, page and two section levels, ids are unique per level, every text is complete in each declared
4
7
  // language and in no other, texts hold restricted Markdown without raw HTML or headings, formulas are in the command
5
8
  // list, bundled images and quotes name source and licence, links to pages of the app find their page and section,
6
- // every page has an exercise, a question of the question types passes the validation of a question in every declared language,
9
+ // every page but a recap page has an exercise, a question of the question types passes the validation of a question in every declared language,
7
10
  // a generated exercise declares what it uses and passes its property test over fixed seeds (generated.ts), the check
8
11
  // of each exercise judges its examples as they are listed, a vue-component block names a component of src/components/
9
12
  // that passes its checks (components.ts), its props match the component's prop types, it has its nojs text (an exercise
@@ -17,6 +20,7 @@ import { readComponents } from "./components/read.ts";
17
20
  import {
18
21
  isExercise,
19
22
  type App,
23
+ type Block,
20
24
  type BlockOf,
21
25
  type Chapter,
22
26
  type ComponentUse,
@@ -27,12 +31,12 @@ import {
27
31
  } from "./content.ts";
28
32
  import { allBlocks } from "./routes.ts";
29
33
  import { ASSESSMENT, drawsTasks, exerciseOf, parseRef } from "./assessment.ts";
30
- import { SCHEMA_FILE, validate } from "./schema.ts";
34
+ import { formatOf, SCHEMA_FILES, validate, type Format } from "./schema.ts";
31
35
  import { appName } from "../shared/appname.ts";
32
36
  import { isObject, Problems, readYaml, Texts, type Path, type YamlFile } from "../shared/content.ts";
33
37
  import { isLanguage, LANGUAGES, languageListProblems } from "../shared/languages.ts";
34
38
 
35
- export { SCHEMA_ID } from "./schema.ts";
39
+ export { SCHEMA_ID, SCHEMA_ID_V2, type Format } from "./schema.ts";
36
40
  export const RULE_URL = "https://lernapps.net/tooling/rules/#explainer-content-valid";
37
41
 
38
42
  /** What src/ may hold besides the chapter folders. */
@@ -45,12 +49,12 @@ const IMAGE = /^[a-z0-9]+(-[a-z0-9]+)*\.(svg|png|jpg|webp)$/;
45
49
  /** The content could not be read: one problem per line in `problems`. */
46
50
  export class ContentError extends Error {
47
51
  readonly problems: readonly string[];
48
- constructor(problems: readonly string[]) {
52
+ constructor(problems: readonly string[], format: Format = 1) {
49
53
  super(
50
54
  [
51
55
  `the explainer's content in src/ is not valid (${RULE_URL}):`,
52
56
  ...problems.map((problem) => ` - ${problem}`),
53
- `Fix the content as its schema says: ${SCHEMA_FILE}`,
57
+ `Fix the content as its schema says: ${SCHEMA_FILES[format]}`,
54
58
  ].join("\n"),
55
59
  );
56
60
  this.problems = problems;
@@ -123,6 +127,14 @@ function checkAssessment(
123
127
  problems.at(yaml, path, `${ref} names no page of the app; write /<chapter>/<page>/#<exercise>`);
124
128
  return;
125
129
  }
130
+ if (page.recap !== undefined) {
131
+ problems.at(
132
+ yaml,
133
+ path,
134
+ `${ref} is on a recap page, which has no exercise and is left out of the self-assessment; choose an exercise of a page of the app's own competencies`,
135
+ );
136
+ return;
137
+ }
126
138
  const exercise = exerciseOf(components, page, parts.exercise);
127
139
  // a generated exercise or an exercise component may be listed again: each listing draws a task of its own
128
140
  if (twice && exercise !== undefined && !drawsTasks(exercise)) {
@@ -148,11 +160,21 @@ function checkAssessment(
148
160
  });
149
161
  }
150
162
 
163
+ /** Each block of `blocks` and the blocks inside them, with its path in the page's file. */
164
+ function* blockPaths(blocks: readonly Block[], path: Path): Generator<[Block, Path]> {
165
+ for (const [index, block] of blocks.entries()) {
166
+ yield [block, [...path, index]];
167
+ if (block.type === "section" || block.type === "example" || block.type === "note") {
168
+ yield* blockPaths(block.blocks, [...path, index, "blocks"]);
169
+ }
170
+ }
171
+ }
172
+
151
173
  /** How many seeds the property test of each generated exercise draws at build time. */
152
174
  export const SEEDS = 200;
153
175
 
154
176
  /** The problems of the content in `root`/src, and the content when there are none. */
155
- export function check(root: string, seeds = SEEDS): { site?: Site; problems: string[] } {
177
+ export function check(root: string, seeds = SEEDS): { site?: Site; problems: string[]; format: Format } {
156
178
  const problems = new Problems();
157
179
  const src = join(root, "src");
158
180
  if (!existsSync(join(src, "app.yaml"))) {
@@ -160,7 +182,7 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
160
182
  "src/app.yaml",
161
183
  "is missing: the app's title, intro, languages and the order of its chapters belong there",
162
184
  );
163
- return { problems: problems.lines };
185
+ return { problems: problems.lines, format: 1 };
164
186
  }
165
187
 
166
188
  // what src/ holds
@@ -213,13 +235,14 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
213
235
 
214
236
  // the app
215
237
  const appYaml = readYaml(root, "src/app.yaml", problems);
216
- if (appYaml === undefined || !validate(appYaml, "app", problems)) return { problems: problems.lines };
238
+ const format = formatOf(appYaml?.value);
239
+ if (appYaml === undefined || !validate(appYaml, "app", problems, format)) return { problems: problems.lines, format };
217
240
  const app = appYaml.value as App;
218
241
  const languageList = languageListProblems(app.languages, LANGUAGES);
219
242
  for (const problem of languageList) {
220
243
  problems.at(appYaml, ["languages", Math.max(app.languages.lastIndexOf(problem.lang), 0)], problem.message);
221
244
  }
222
- if (languageList.length > 0) return { problems: problems.lines };
245
+ if (languageList.length > 0) return { problems: problems.lines, format };
223
246
  const texts = new Texts(app.languages, problems);
224
247
  texts.check(appYaml, ["title"], "plain");
225
248
  texts.check(appYaml, ["intro"], "markdown");
@@ -312,11 +335,30 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
312
335
  for (const pageId of pagesOk ? chapter.pages : pageIds) {
313
336
  const file = `src/${chapterId}/${pageId}.yaml`;
314
337
  const yaml = readYaml(root, file, problems);
315
- if (yaml === undefined || !validate(yaml, "page", problems)) {
338
+ if (yaml === undefined || !validate(yaml, "page", problems, format)) {
316
339
  complete = false;
317
340
  continue;
318
341
  }
319
342
  const page = yaml.value as Omit<Page, "id" | "chapter" | "file">;
343
+ // format 2: the competencies a page practises, by the app's strand
344
+ if (app.strand === "none" && page.competencies !== undefined) {
345
+ problems.at(
346
+ yaml,
347
+ ["competencies"],
348
+ "competencies is not allowed: src/app.yaml has strand: none, so the app is on no strand; remove it, or name the app's strand in src/app.yaml",
349
+ );
350
+ } else if (
351
+ app.strand !== undefined &&
352
+ app.strand !== "none" &&
353
+ page.competencies === undefined &&
354
+ page.recap === undefined
355
+ ) {
356
+ problems.at(
357
+ yaml,
358
+ [],
359
+ `competencies is missing; src/app.yaml names the strand ${app.strand}, so each page names the competencies of its subject that it practises, e.g. competencies: [laplace]; a page that only repeats a competency the app does not practise says recap: <subject>/<competency> instead`,
360
+ );
361
+ }
320
362
  texts.check(yaml, ["title"], "plain");
321
363
  texts.check(yaml, ["description"], "plain");
322
364
  const pageAnchors = new Set<string>();
@@ -341,7 +383,18 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
341
383
  ["blocks"],
342
384
  0,
343
385
  );
344
- if (![...allBlocks(page.blocks)].some(isExercise)) {
386
+ if (page.recap !== undefined) {
387
+ // a recap page repeats a competency the app does not practise: no exercise, not even a component's
388
+ for (const [block, path] of blockPaths(page.blocks, ["blocks"])) {
389
+ const component = uses.find((use) => use.block === block && use.component.exercise);
390
+ if (!isExercise(block) && component === undefined) continue;
391
+ problems.at(
392
+ yaml,
393
+ path,
394
+ `${component === undefined ? `the exercise ${block.type}` : `the exercise component ${component.component.name}`} is not allowed on a recap page: it repeats ${page.recap} with descriptive blocks only, and the apps of that competency's strand practise it; remove the exercise, or make it a page of the app's own competencies`,
395
+ );
396
+ }
397
+ } else if (![...allBlocks(page.blocks)].some(isExercise)) {
345
398
  problems.at(
346
399
  yaml,
347
400
  ["blocks"],
@@ -429,7 +482,7 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
429
482
  }
430
483
  }
431
484
 
432
- if (problems.lines.length > 0) return { problems: problems.lines };
485
+ if (problems.lines.length > 0) return { problems: problems.lines, format };
433
486
  const data: Record<string, DataRecord[]> = {};
434
487
  for (const [id, yaml] of dataFiles) data[id] = yaml.value as DataRecord[];
435
488
  return {
@@ -442,6 +495,7 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
442
495
  storage: appName(root, "explainer"),
443
496
  },
444
497
  problems: [],
498
+ format,
445
499
  };
446
500
  }
447
501
 
@@ -449,8 +503,8 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
449
503
  * The content in `root`/src, its generated exercises tested over `seeds` seeds (0: not at all, for a reader of content
450
504
  * the build has checked); throws a ContentError with every problem when it is not valid. */
451
505
  export function loadSite(root: string, seeds = SEEDS): Site {
452
- const { site, problems } = check(root, seeds);
453
- if (site === undefined) throw new ContentError(problems);
506
+ const { site, problems, format } = check(root, seeds);
507
+ if (site === undefined) throw new ContentError(problems, format);
454
508
  return site;
455
509
  }
456
510
 
@@ -196,5 +196,9 @@
196
196
  "openTaskLocked": "Die Lösung öffnet sich nach deiner Antwort.",
197
197
  "resultPartly": "teilweise richtig ({n} von {total} Punkten, es fehlen {missed})",
198
198
  "resultPhoto": "Antwort als Foto",
199
- "resultAnswer": "Antwort „{input}“"
199
+ "resultAnswer": "Antwort „{input}“",
200
+ "recap": "Wiederholung",
201
+ "recapText": "Diese Seite wiederholt etwas, das du schon können solltest. Üben kannst du es in anderen Apps: {link}",
202
+ "recapLink": "Baustein auf der Lernlandkarte von lernapps.net",
203
+ "recapNote": "Wiederholung"
200
204
  }
@@ -196,5 +196,9 @@
196
196
  "openTaskLocked": "The solution opens after your answer.",
197
197
  "resultPartly": "partly right ({n} of {total} points, missing {missed})",
198
198
  "resultPhoto": "answer as a photo",
199
- "resultAnswer": "answer “{input}”"
199
+ "resultAnswer": "answer “{input}”",
200
+ "recap": "Recap",
201
+ "recapText": "This page goes over something you should already know. You can practise it in other apps: {link}",
202
+ "recapLink": "building block on the learning map of lernapps.net",
203
+ "recapNote": "recap"
200
204
  }
@@ -196,7 +196,7 @@ function walk(
196
196
  }
197
197
  case "generated": {
198
198
  const data = dataOf(block, site.data);
199
- const templates = templatesOf(block, [lang]);
199
+ const templates = templatesOf(block, [lang], data);
200
200
  const worked = workedTasks(block, data, templates, lang);
201
201
  for (const item of worked) {
202
202
  for (const html of [item.task, item.hint ?? "", ...item.steps])
@@ -0,0 +1,38 @@
1
+ // The list of an explainer's pages for the catalog of lernapps.net (lernapps-pages.json): only an app of format 2
2
+ // (`format: 2` in src/app.yaml) has it. The build writes it at the root of the bundle, beside index.html; the catalog
3
+ // reads it instead of hand-written topics, to place the app and its pages on the learning map. It holds the version of
4
+ // the list itself (format 1), the app's strand (<subject>/<strand>, or none), and the content pages in the order of the
5
+ // app, each with its address relative to the app's root as the app links it, its title as a text (a string, or by
6
+ // language) and, unless the strand is none, the competencies it practises; a recap page names instead the competency
7
+ // it repeats (recap: <subject>/<competency>), with any strand.
8
+ import { contentPages } from "./routes.ts";
9
+ import type { Site, Text } from "./content.ts";
10
+
11
+ /** The file's name, at the root of the bundle. */
12
+ export const PAGE_LIST = "lernapps-pages.json";
13
+
14
+ export interface PageList {
15
+ /** The version of this list's own format. */
16
+ format: 1;
17
+ strand: string;
18
+ pages: { path: string; title: Text; competencies?: readonly string[]; recap?: string }[];
19
+ }
20
+
21
+ /** The list of the pages of `site`; undefined for an app of format 1, which has none. */
22
+ export function pageList(site: Site): PageList | undefined {
23
+ const { format, strand } = site.app;
24
+ if (format !== 2 || strand === undefined) return undefined;
25
+ return {
26
+ format: 1,
27
+ strand,
28
+ pages: contentPages(site).map((page) => ({
29
+ path: `${page.chapter}/${page.id}/`,
30
+ title: page.title,
31
+ ...(page.recap !== undefined
32
+ ? { recap: page.recap }
33
+ : strand === "none" || page.competencies === undefined
34
+ ? {}
35
+ : { competencies: page.competencies }),
36
+ })),
37
+ };
38
+ }