@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.
- package/README.md +5 -11
- package/dist/{assessment-Dy359O1O.mjs → assessment-DaKsXUdj.mjs} +204 -16
- package/dist/{blocks-CxtCPAa5.mjs → blocks-D4SSGKuL.mjs} +2 -2
- package/dist/blocks.mjs +2 -2
- package/dist/{check-CteIFN96.mjs → check-BxZOZPic.mjs} +58 -4
- package/dist/{content-DO6oEJxj.mjs → content-CAL0Nz1a.mjs} +1 -1
- package/dist/explainer-e2e.mjs +36 -5
- package/dist/explainer-plugin.mjs +57 -13
- package/dist/explainer.mjs +2 -2
- package/dist/interactive-e2e.mjs +1 -1
- package/dist/interactive-plugin.mjs +2 -2
- package/dist/{load-RosvaPZf.mjs → load--ovXqbNE.mjs} +286 -33
- package/dist/questions.mjs +1 -1
- package/dist/quiz-e2e.mjs +1 -1
- package/dist/quiz-plugin.mjs +3 -3
- package/dist/{render-D5kwkNt1.mjs → render-D3LsJY04.mjs} +2 -2
- package/dist/{render-DqHOJyHK.mjs → render-mYtzN1DV.mjs} +1 -1
- package/dist/{result-D_mmCVwN.mjs → result-DphIWHDF.mjs} +1 -1
- package/dist/{validate-Bxy9NfB8.mjs → validate-CfFykN0M.mjs} +1 -1
- package/dist/{validate-BDwL7Su3.mjs → validate-j3GjVhNF.mjs} +2 -2
- package/explainer/README.md +7 -3
- package/explainer/docs/arc42/05-explainer-building-blocks.arc42.md +26 -8
- package/explainer/docs/arc42/09-explainer-decisions.arc42.md +42 -0
- package/explainer/package.json +1 -1
- package/explainer/src/app.yaml +3 -1
- package/explainer/src/prozent/chapter.yaml +1 -1
- package/explainer/src/prozent/grundwert.yaml +2 -1
- package/explainer/src/prozent/prozentsatz.yaml +2 -1
- package/explainer/src/prozent/prozentwert.yaml +2 -1
- package/interactive/package.json +1 -1
- package/package.json +3 -4
- package/quiz/package.json +1 -1
- package/runtime/explainer/assessment.ts +4 -2
- package/runtime/explainer/check-page.ts +6 -2
- package/runtime/explainer/content.ts +20 -1
- package/runtime/explainer/e2e.ts +40 -0
- package/runtime/explainer/exercises.ts +71 -6
- package/runtime/explainer/explainer.css +21 -16
- package/runtime/explainer/explainer.v1.schema.json +2 -2
- package/runtime/explainer/explainer.v2.schema.json +152 -0
- package/runtime/explainer/expression.ts +127 -1
- package/runtime/explainer/generated/texts.ts +98 -9
- package/runtime/explainer/generated/types.ts +8 -0
- package/runtime/explainer/load.ts +70 -16
- package/runtime/explainer/messages/de.json +5 -1
- package/runtime/explainer/messages/en.json +5 -1
- package/runtime/explainer/outline.ts +1 -1
- package/runtime/explainer/page-list.ts +38 -0
- package/runtime/explainer/plugin.ts +13 -4
- package/runtime/explainer/render.ts +26 -2
- package/runtime/explainer/schema.ts +92 -9
- package/runtime/explainer/tutorpage.ts +9 -1
- package/runtime/shared/blocks/blocks.css +18 -0
- package/runtime/shared/blocks/blocks.v1.schema.json +1 -1
- package/runtime/shared/text/formula.ts +61 -1
- package/scaffold/explainer/src/app.yaml +5 -1
- package/scaffold/explainer/src/thema/chapter.yaml +1 -1
- 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
|
-
/**
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|