@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.
- 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 +5 -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/exercises.ts +71 -6
- package/runtime/explainer/explainer.css +21 -0
- 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.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
|
@@ -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
|
|
|
@@ -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
|
|
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 {
|
|
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: ${
|
|
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
|
-
|
|
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 (
|
|
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
|
+
}
|