@lernapps/app-templates 0.2.1 → 0.3.1

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 (58) 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 +36 -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/e2e.ts +40 -0
  37. package/runtime/explainer/exercises.ts +71 -6
  38. package/runtime/explainer/explainer.css +21 -16
  39. package/runtime/explainer/explainer.v1.schema.json +2 -2
  40. package/runtime/explainer/explainer.v2.schema.json +152 -0
  41. package/runtime/explainer/expression.ts +127 -1
  42. package/runtime/explainer/generated/texts.ts +98 -9
  43. package/runtime/explainer/generated/types.ts +8 -0
  44. package/runtime/explainer/load.ts +70 -16
  45. package/runtime/explainer/messages/de.json +5 -1
  46. package/runtime/explainer/messages/en.json +5 -1
  47. package/runtime/explainer/outline.ts +1 -1
  48. package/runtime/explainer/page-list.ts +38 -0
  49. package/runtime/explainer/plugin.ts +13 -4
  50. package/runtime/explainer/render.ts +26 -2
  51. package/runtime/explainer/schema.ts +92 -9
  52. package/runtime/explainer/tutorpage.ts +9 -1
  53. package/runtime/shared/blocks/blocks.css +18 -0
  54. package/runtime/shared/blocks/blocks.v1.schema.json +1 -1
  55. package/runtime/shared/text/formula.ts +61 -1
  56. package/scaffold/explainer/src/app.yaml +5 -1
  57. package/scaffold/explainer/src/thema/chapter.yaml +1 -1
  58. package/scaffold/explainer/src/thema/einstieg.yaml +1 -1
@@ -0,0 +1,152 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:lernapps:schema:explainer.v2",
4
+ "title": "Content of a lernapps explainer, format 2",
5
+ "description": "Format 2 of an explainer's content, as YAML in src/: src/app.yaml, src/<chapter>/chapter.yaml, src/<chapter>/<page>.yaml and src/data/<name>.yaml. It is format 1 (explainer.v1.schema.json) plus the app's place on the learning map: format: 2 and strand in src/app.yaml, and on each page the competencies it practises, or recap on a page that repeats a competency from elsewhere. An app.yaml without format is format 1.",
6
+ "allOf": [
7
+ {
8
+ "if": {
9
+ "type": "object",
10
+ "required": ["chapters"],
11
+ "properties": {
12
+ "chapters": {}
13
+ }
14
+ },
15
+ "then": {
16
+ "$ref": "#/$defs/app"
17
+ }
18
+ },
19
+ {
20
+ "if": {
21
+ "type": "object",
22
+ "required": ["pages"],
23
+ "properties": {
24
+ "pages": {}
25
+ }
26
+ },
27
+ "then": {
28
+ "$ref": "#/$defs/chapter"
29
+ }
30
+ },
31
+ {
32
+ "if": {
33
+ "type": "object",
34
+ "required": ["blocks"],
35
+ "properties": {
36
+ "blocks": {}
37
+ }
38
+ },
39
+ "then": {
40
+ "$ref": "#/$defs/page"
41
+ }
42
+ },
43
+ {
44
+ "if": {
45
+ "type": "array"
46
+ },
47
+ "then": {
48
+ "$ref": "#/$defs/data"
49
+ }
50
+ }
51
+ ],
52
+ "$defs": {
53
+ "id": {
54
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/id"
55
+ },
56
+ "strand": {
57
+ "description": "The one strand (Strang) of the learning map the app belongs to, <subject>/<strand>, e.g. mathematik/stochastik; or none if it belongs to none (then it is not on the map).",
58
+ "type": "string",
59
+ "pattern": "^(none|[a-z0-9]+(-[a-z0-9]+)*/[a-z0-9]+(-[a-z0-9]+)*)$"
60
+ },
61
+ "competency": {
62
+ "description": "A competency of the learning map, <subject>/<competency>, e.g. physik/gewichtskraft-berechnen.",
63
+ "type": "string",
64
+ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*/[a-z0-9]+(-[a-z0-9]+)*$"
65
+ },
66
+ "app": {
67
+ "description": "src/app.yaml in format 2: format: 2 and strand, everything else as in format 1.",
68
+ "type": "object",
69
+ "additionalProperties": false,
70
+ "required": ["format", "strand", "title", "intro", "languages", "chapters"],
71
+ "properties": {
72
+ "format": {
73
+ "description": "Always 2. Without it, the content is format 1 (explainer.v1.schema.json).",
74
+ "const": 2
75
+ },
76
+ "strand": {
77
+ "$ref": "#/$defs/strand"
78
+ },
79
+ "title": {
80
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/app/properties/title"
81
+ },
82
+ "intro": {
83
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/app/properties/intro"
84
+ },
85
+ "languages": {
86
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/app/properties/languages"
87
+ },
88
+ "chapters": {
89
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/app/properties/chapters"
90
+ },
91
+ "sources": {
92
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/app/properties/sources"
93
+ },
94
+ "tutor": {
95
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/app/properties/tutor"
96
+ },
97
+ "scope": {
98
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/app/properties/scope"
99
+ }
100
+ }
101
+ },
102
+ "chapter": {
103
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/chapter"
104
+ },
105
+ "page": {
106
+ "description": "src/<chapter>/<page>.yaml in format 2: a page as in format 1, plus competencies, or recap on a recap page.",
107
+ "type": "object",
108
+ "additionalProperties": false,
109
+ "required": ["title", "blocks"],
110
+ "properties": {
111
+ "title": {
112
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/page/properties/title"
113
+ },
114
+ "description": {
115
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/page/properties/description"
116
+ },
117
+ "competencies": {
118
+ "description": "The competencies (Bausteine) the page practises, ids of the subject of the app's strand; as a rule one. Required when the app has a strand; leave it out with strand: none and on a recap page. The catalog checks the ids when it lists the app.",
119
+ "type": "array",
120
+ "minItems": 1,
121
+ "uniqueItems": true,
122
+ "items": {
123
+ "$ref": "#/$defs/id"
124
+ }
125
+ },
126
+ "recap": {
127
+ "description": "Makes the page a recap page: it repeats a competency that the app's competencies require but the app does not practise, e.g. physik/gewichtskraft-berechnen. Use it instead of competencies, also with strand: none. A recap page has descriptive blocks only, no exercise; the link to the competency on the learning map is added for you.",
128
+ "$ref": "#/$defs/competency"
129
+ },
130
+ "blocks": {
131
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/page/properties/blocks"
132
+ },
133
+ "scope": {
134
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/page/properties/scope"
135
+ }
136
+ },
137
+ "not": {
138
+ "required": ["competencies", "recap"],
139
+ "properties": {
140
+ "competencies": {},
141
+ "recap": {}
142
+ }
143
+ }
144
+ },
145
+ "data": {
146
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/data"
147
+ },
148
+ "event": {
149
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/event"
150
+ }
151
+ }
152
+ }
@@ -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