@lernapps/app-templates 0.0.1 → 0.1.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/LICENSE +22 -0
- package/README.md +195 -2
- package/dist/appname-Bh23cUuz.mjs +12 -0
- package/dist/assessment-Dy359O1O.mjs +1696 -0
- package/dist/block-BK_ohsH1.mjs +82 -0
- package/dist/blocks-CxtCPAa5.mjs +149 -0
- package/dist/blocks.mjs +4 -0
- package/dist/check-CteIFN96.mjs +254 -0
- package/dist/content-DO6oEJxj.mjs +140 -0
- package/dist/contract-PONpxJtd.mjs +52 -0
- package/dist/e2e-CU0n_unV.mjs +89 -0
- package/dist/engine-CArxqizW.mjs +136 -0
- package/dist/explainer-e2e.mjs +1311 -0
- package/dist/explainer-islands.mjs +197 -0
- package/dist/explainer-plugin.mjs +2132 -0
- package/dist/explainer.mjs +1246 -0
- package/dist/html-mffkHGHh.mjs +89 -0
- package/dist/image-CspJVfyQ.mjs +21 -0
- package/dist/interactive-e2e.mjs +140 -0
- package/dist/interactive-plugin.mjs +242 -0
- package/dist/interactive.mjs +75 -0
- package/dist/load-RosvaPZf.mjs +16976 -0
- package/dist/needs-BzEahJ6X.mjs +97 -0
- package/dist/pages-9RaaomoO.mjs +81 -0
- package/dist/questions-e2e.mjs +1 -0
- package/dist/questions-engine.mjs +3 -0
- package/dist/questions.mjs +6 -0
- package/dist/quiz-DnW-xtCh.mjs +70 -0
- package/dist/quiz-e2e.mjs +174 -0
- package/dist/quiz-plugin.mjs +266 -0
- package/dist/quiz.mjs +246 -0
- package/dist/render-D5kwkNt1.mjs +507 -0
- package/dist/render-DqHOJyHK.mjs +146 -0
- package/dist/render-GS1Htlu8.mjs +545 -0
- package/dist/result-D_mmCVwN.mjs +180 -0
- package/dist/tasks-DADUYy52.mjs +74 -0
- package/dist/validate-BDwL7Su3.mjs +1076 -0
- package/dist/validate-Bxy9NfB8.mjs +146 -0
- package/dist/vue-DO1m6q1R.mjs +40 -0
- package/dist/vue-code-BkJjW_I_.mjs +320 -0
- package/docs/arc42/01-introduction-and-goals.arc42.md +82 -0
- package/docs/arc42/02-constraints.arc42.md +228 -0
- package/docs/arc42/03-system-context.arc42.md +118 -0
- package/docs/arc42/04-solution-strategy.arc42.md +47 -0
- package/docs/arc42/05-building-blocks.arc42.md +307 -0
- package/docs/arc42/06-runtime-view.arc42.md +98 -0
- package/docs/arc42/07-deployment-view.arc42.md +160 -0
- package/docs/arc42/08-concepts.arc42.md +123 -0
- package/docs/arc42/09-decisions.arc42.md +246 -0
- package/docs/arc42/10-quality-requirements.arc42.md +155 -0
- package/docs/arc42/11-risks.arc42.md +113 -0
- package/docs/arc42/12-glossary.arc42.md +162 -0
- package/docs/arc42/architecture-evidence.md +395 -0
- package/explainer/.arc42ignore +3 -0
- package/explainer/README.md +54 -0
- package/explainer/_gitignore +5 -0
- package/explainer/docs/arc42/03-explainer-system-context.arc42.md +35 -0
- package/explainer/docs/arc42/05-explainer-building-blocks.arc42.md +659 -0
- package/explainer/docs/arc42/06-explainer-runtime-view.arc42.md +105 -0
- package/explainer/docs/arc42/07-explainer-deployment-view.arc42.md +34 -0
- package/explainer/docs/arc42/08-explainer-concepts.arc42.md +17 -0
- package/explainer/docs/arc42/09-explainer-decisions.arc42.md +443 -0
- package/explainer/docs/arc42/10-explainer-quality-requirements.arc42.md +54 -0
- package/explainer/docs/arc42/11-explainer-risks.arc42.md +17 -0
- package/explainer/docs/arc42/12-explainer-glossary.arc42.md +185 -0
- package/explainer/e2e/explainer.e2e.ts +3 -0
- package/explainer/index.html +11 -0
- package/explainer/package.json +18 -0
- package/explainer/playwright.config.ts +1 -0
- package/explainer/src/app.yaml +20 -0
- package/explainer/src/components/ProzentFeld.test.ts +30 -0
- package/explainer/src/components/ProzentFeld.ts +121 -0
- package/explainer/src/components/ProzentFeld.vue +84 -0
- package/explainer/src/images/hunderterfeld-klammern.svg +105 -0
- package/explainer/src/main.ts +4 -0
- package/explainer/src/prozent/chapter.yaml +18 -0
- package/explainer/src/prozent/grundwert.yaml +115 -0
- package/explainer/src/prozent/prozentsatz.yaml +99 -0
- package/explainer/src/prozent/prozentwert.yaml +119 -0
- package/explainer/tsconfig.json +1 -0
- package/explainer/vite.config.ts +4 -0
- package/interactive/.arc42ignore +5 -0
- package/interactive/README.md +28 -0
- package/interactive/_gitignore +4 -0
- package/interactive/docs/arc42/05-interactive-building-blocks.arc42.md +518 -0
- package/interactive/docs/arc42/06-interactive-runtime-view.arc42.md +58 -0
- package/interactive/docs/arc42/07-interactive-deployment-view.arc42.md +35 -0
- package/interactive/docs/arc42/08-interactive-concepts.arc42.md +52 -0
- package/interactive/docs/arc42/09-interactive-decisions.arc42.md +130 -0
- package/interactive/docs/arc42/10-interactive-quality-requirements.arc42.md +45 -0
- package/interactive/docs/arc42/11-interactive-risks.arc42.md +20 -0
- package/interactive/docs/arc42/12-interactive-glossary.arc42.md +80 -0
- package/interactive/e2e/interactive.e2e.ts +3 -0
- package/interactive/index.html +11 -0
- package/interactive/package.json +18 -0
- package/interactive/playwright.config.ts +1 -0
- package/interactive/src/app.yaml +28 -0
- package/interactive/src/core/index.ts +101 -0
- package/interactive/src/data/lessons.json +4 -0
- package/interactive/src/env.d.ts +6 -0
- package/interactive/src/main.ts +6 -0
- package/interactive/src/messages/de.json +12 -0
- package/interactive/src/playthroughs/grundreihe.yaml +33 -0
- package/interactive/src/playthroughs/obere-reihe.yaml +14 -0
- package/interactive/src/ui/App.vue +85 -0
- package/interactive/tsconfig.json +1 -0
- package/interactive/vite.config.ts +4 -0
- package/package.json +143 -4
- package/quiz/README.md +31 -0
- package/quiz/_gitignore +5 -0
- package/quiz/docs/arc42/05-quiz-building-blocks.arc42.md +283 -0
- package/quiz/docs/arc42/06-quiz-runtime-view.arc42.md +21 -0
- package/quiz/docs/arc42/07-quiz-deployment-view.arc42.md +33 -0
- package/quiz/docs/arc42/09-quiz-decisions.arc42.md +36 -0
- package/quiz/docs/arc42/10-quiz-quality-requirements.arc42.md +53 -0
- package/quiz/docs/arc42/11-quiz-risks.arc42.md +19 -0
- package/quiz/docs/arc42/12-quiz-glossary.arc42.md +27 -0
- package/quiz/e2e/quiz.e2e.ts +3 -0
- package/quiz/index.html +11 -0
- package/quiz/package.json +18 -0
- package/quiz/playwright.config.ts +1 -0
- package/quiz/src/images/berge-ordnen.jpg +0 -0
- package/quiz/src/images/everest-hoehe.jpg +0 -0
- package/quiz/src/images/galapagos.jpg +0 -0
- package/quiz/src/images/grand-canyon.jpg +0 -0
- package/quiz/src/images/totes-meer.jpg +0 -0
- package/quiz/src/images/uluru.jpg +0 -0
- package/quiz/src/images/wasserfaelle-laender.jpg +0 -0
- package/quiz/src/main.ts +4 -0
- package/quiz/src/quiz.json +312 -0
- package/quiz/tsconfig.json +1 -0
- package/quiz/vite.config.ts +4 -0
- package/runtime/explainer/assessment-client.ts +478 -0
- package/runtime/explainer/assessment-page.ts +448 -0
- package/runtime/explainer/assessment.ts +393 -0
- package/runtime/explainer/check-page.ts +520 -0
- package/runtime/explainer/components/exercise.ts +409 -0
- package/runtime/explainer/components/props.ts +230 -0
- package/runtime/explainer/components/read.ts +428 -0
- package/runtime/explainer/content.ts +277 -0
- package/runtime/explainer/e2e.ts +1488 -0
- package/runtime/explainer/exercises.ts +326 -0
- package/runtime/explainer/explainer.css +1283 -0
- package/runtime/explainer/explainer.ts +838 -0
- package/runtime/explainer/explainer.v1.schema.json +1165 -0
- package/runtime/explainer/expression.ts +407 -0
- package/runtime/explainer/fraction.ts +107 -0
- package/runtime/explainer/generated/comparator.ts +31 -0
- package/runtime/explainer/generated/question.ts +165 -0
- package/runtime/explainer/generated/soundness.ts +280 -0
- package/runtime/explainer/generated/texts.ts +257 -0
- package/runtime/explainer/generated/types.ts +103 -0
- package/runtime/explainer/generated/values.ts +136 -0
- package/runtime/explainer/islands.ts +281 -0
- package/runtime/explainer/load.ts +457 -0
- package/runtime/explainer/messages/de.json +200 -0
- package/runtime/explainer/messages/en.json +200 -0
- package/runtime/explainer/messages.ts +13 -0
- package/runtime/explainer/needs.ts +122 -0
- package/runtime/explainer/open-task.ts +235 -0
- package/runtime/explainer/outline.ts +306 -0
- package/runtime/explainer/plugin.ts +488 -0
- package/runtime/explainer/position.ts +62 -0
- package/runtime/explainer/prompt.ts +75 -0
- package/runtime/explainer/render.ts +686 -0
- package/runtime/explainer/result.ts +238 -0
- package/runtime/explainer/routes.ts +119 -0
- package/runtime/explainer/schema.ts +140 -0
- package/runtime/explainer/scope.ts +87 -0
- package/runtime/explainer/stuck.ts +57 -0
- package/runtime/explainer/tasks.ts +109 -0
- package/runtime/explainer/tutor.ts +469 -0
- package/runtime/explainer/tutorpage.ts +669 -0
- package/runtime/explainer/video.ts +123 -0
- package/runtime/interactive/contract.ts +121 -0
- package/runtime/interactive/e2e.ts +190 -0
- package/runtime/interactive/interactive.css +82 -0
- package/runtime/interactive/interactive.ts +115 -0
- package/runtime/interactive/interactive.v1.schema.json +146 -0
- package/runtime/interactive/load.ts +188 -0
- package/runtime/interactive/messages/de.json +14 -0
- package/runtime/interactive/messages/en.json +14 -0
- package/runtime/interactive/messages.ts +11 -0
- package/runtime/interactive/playthrough.v1.schema.json +143 -0
- package/runtime/interactive/plugin.ts +266 -0
- package/runtime/interactive/render.ts +126 -0
- package/runtime/quiz/e2e.ts +233 -0
- package/runtime/quiz/engine.ts +242 -0
- package/runtime/quiz/messages/de.json +23 -0
- package/runtime/quiz/messages/en.json +23 -0
- package/runtime/quiz/plugin.ts +150 -0
- package/runtime/quiz/quiz.css +154 -0
- package/runtime/quiz/quiz.ts +56 -0
- package/runtime/quiz/quiz.v1.schema.json +72 -0
- package/runtime/quiz/render.ts +173 -0
- package/runtime/quiz/validate.ts +108 -0
- package/runtime/shared/appname.ts +16 -0
- package/runtime/shared/blocks/block.ts +162 -0
- package/runtime/shared/blocks/blocks.css +190 -0
- package/runtime/shared/blocks/blocks.v1.schema.json +432 -0
- package/runtime/shared/blocks/code.ts +26 -0
- package/runtime/shared/blocks/figures.ts +49 -0
- package/runtime/shared/blocks/image.ts +25 -0
- package/runtime/shared/blocks/index.ts +13 -0
- package/runtime/shared/blocks/messages/de.json +4 -0
- package/runtime/shared/blocks/messages/en.json +4 -0
- package/runtime/shared/blocks/render.ts +134 -0
- package/runtime/shared/blocks/validate.ts +165 -0
- package/runtime/shared/content.ts +197 -0
- package/runtime/shared/examples.ts +20 -0
- package/runtime/shared/html.ts +10 -0
- package/runtime/shared/languages.ts +94 -0
- package/runtime/shared/page.css +101 -0
- package/runtime/shared/pages.ts +114 -0
- package/runtime/shared/questions/e2e.ts +115 -0
- package/runtime/shared/questions/engine.ts +209 -0
- package/runtime/shared/questions/index.ts +39 -0
- package/runtime/shared/questions/messages/de.json +26 -0
- package/runtime/shared/questions/messages/en.json +26 -0
- package/runtime/shared/questions/question.ts +433 -0
- package/runtime/shared/questions/question.v1.schema.json +346 -0
- package/runtime/shared/questions/questions.css +192 -0
- package/runtime/shared/questions/render.ts +255 -0
- package/runtime/shared/questions/validate.ts +518 -0
- package/runtime/shared/schema.ts +44 -0
- package/runtime/shared/text/check.ts +68 -0
- package/runtime/shared/text/formula.ts +69 -0
- package/runtime/shared/text/markdown.ts +185 -0
- package/runtime/shared/text/speech.ts +39 -0
- package/runtime/shared/text/text.v1.schema.json +34 -0
- package/runtime/shared/vue-code.ts +395 -0
- package/runtime/shared/vue.ts +38 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// The texts of a task: its template filled in with the values (the placeholders ⟦name⟧ the build leaves), the numbers
|
|
2
|
+
// as a locale writes them, the checks of the placeholders, the steps and the hint.
|
|
3
|
+
import { evaluate, ExpressionError, Localized, parse, type Value, variables } from "../expression.ts";
|
|
4
|
+
import { Fraction } from "../fraction.ts";
|
|
5
|
+
import { inLanguage, localeOf, type Text } from "../../shared/languages.ts";
|
|
6
|
+
import { escape } from "../../shared/html.ts";
|
|
7
|
+
import { type Generated, type LanguageTemplates, type Task, type Templates } from "./types.ts";
|
|
8
|
+
import { kindOf } from "./comparator.ts";
|
|
9
|
+
|
|
10
|
+
const PATH = String.raw`[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*`;
|
|
11
|
+
/** A placeholder: two braces around a name or a name's field, nothing else inside. */
|
|
12
|
+
const PLACEHOLDER = new RegExp(String.raw`\{\{(${PATH})\}\}`, "g");
|
|
13
|
+
/** What looks meant as a placeholder but is not one: one brace in prose, or spaces inside two braces. */
|
|
14
|
+
const ONE_BRACE = new RegExp(String.raw`(?<!\{)\{(${PATH})\}(?!\})`, "g");
|
|
15
|
+
/** Anything between two braces, as written: a placeholder, or what only looks like one ({{opts[0].x}}, {{}}, {{1x}}). */
|
|
16
|
+
const BRACED = /\{\{([^{}]*)\}\}/g;
|
|
17
|
+
const PATH_ONLY = new RegExp(String.raw`^${PATH}$`);
|
|
18
|
+
const SPACED = new RegExp(String.raw`\{\{\s*(${PATH})\s*\}\}`, "g");
|
|
19
|
+
/** A formula ($…$) or a run of prose, in a template. */
|
|
20
|
+
const SEGMENT = /\$((?:\\.|[^\\$\n])+?)\$|([^$]+)/g;
|
|
21
|
+
|
|
22
|
+
const formats = new Map<string, Intl.NumberFormat>();
|
|
23
|
+
/** A number as the page shows it: up to six decimals, no grouping (a learner would type 1500, not 1.500). */
|
|
24
|
+
export function formatNumber(value: Fraction, lang: string, decimals?: number): string {
|
|
25
|
+
const key = `${lang}:${decimals ?? ""}`;
|
|
26
|
+
let format = formats.get(key);
|
|
27
|
+
if (format === undefined) {
|
|
28
|
+
format = new Intl.NumberFormat(localeOf(lang), {
|
|
29
|
+
maximumFractionDigits: decimals ?? 6,
|
|
30
|
+
...(decimals === undefined ? {} : { minimumFractionDigits: decimals }),
|
|
31
|
+
useGrouping: false,
|
|
32
|
+
});
|
|
33
|
+
formats.set(key, format);
|
|
34
|
+
}
|
|
35
|
+
return format.format(value.toNumber());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A value as the learner reads it, in `lang`. */
|
|
39
|
+
export function label(value: Value, lang: string): string {
|
|
40
|
+
if (value instanceof Fraction) return formatNumber(value, lang);
|
|
41
|
+
if (value instanceof Localized) return value.in(lang);
|
|
42
|
+
if (typeof value === "string") return value;
|
|
43
|
+
if (typeof value === "boolean") return String(value);
|
|
44
|
+
if (Array.isArray(value)) return (value as readonly Value[]).map((item) => label(item, lang)).join(", ");
|
|
45
|
+
throw new ExpressionError("a record cannot be shown; name one of its fields, e.g. {{e.name}}");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The answer as the learner reads it: a sum of money with two decimals when it has any. */
|
|
49
|
+
export function answerLabel(spec: Generated, task: Task, lang: string): string {
|
|
50
|
+
const kind = kindOf(spec.answer.compare);
|
|
51
|
+
if (kind.kind === "number" && task.answer instanceof Fraction) {
|
|
52
|
+
return formatNumber(task.answer, lang, kind.money && !task.answer.isInteger() ? 2 : undefined);
|
|
53
|
+
}
|
|
54
|
+
return label(task.answer, lang);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A template's placeholders as tokens: {{G}} becomes ⟦G⟧, inside a formula ($…$) {\text{⟦G⟧}}. */
|
|
58
|
+
export function tokenize(text: string): string {
|
|
59
|
+
return text.replace(SEGMENT, (whole, math: string | undefined, prose: string | undefined) =>
|
|
60
|
+
math !== undefined
|
|
61
|
+
? `$${math.replace(PLACEHOLDER, (_placeholder, path: string) => `{\\text{⟦${path}⟧}}`)}$`
|
|
62
|
+
: (prose ?? whole).replace(PLACEHOLDER, (_placeholder, path: string) => `⟦${path}⟧`),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The placeholders of a template, by their path (G, e.name, answer). */
|
|
67
|
+
export function placeholdersIn(text: string): string[] {
|
|
68
|
+
return [...text.matchAll(PLACEHOLDER)].map((match) => match[1] ?? "");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The problems of the placeholders of one template: unknown roots, one brace in prose, spaces inside, and double
|
|
73
|
+
* braces around anything but a name or a name's field (an index, a number, nothing). A literal {{ in a formula is
|
|
74
|
+
* written { { }.
|
|
75
|
+
*/
|
|
76
|
+
export function placeholderProblems(text: string, known: ReadonlySet<string>): string[] {
|
|
77
|
+
const problems: string[] = [];
|
|
78
|
+
const list = [...known].map((name) => `{{${name}}}`).join(", ");
|
|
79
|
+
for (const path of placeholdersIn(text)) {
|
|
80
|
+
const root = path.split(".")[0] ?? "";
|
|
81
|
+
if (!known.has(root)) {
|
|
82
|
+
problems.push(
|
|
83
|
+
`{{${path}}} is an unknown placeholder: ${root} is not declared in given; declare it, or use one of ${list}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const match of text.matchAll(SPACED)) {
|
|
88
|
+
if (match[0] !== `{{${match[1]}}}`) {
|
|
89
|
+
problems.push(`${match[0]} has spaces inside; a placeholder is written {{${match[1]}}}, without spaces`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const match of text.matchAll(BRACED)) {
|
|
93
|
+
const inner = match[1] ?? "";
|
|
94
|
+
if (PATH_ONLY.test(inner) || PATH_ONLY.test(inner.trim())) continue; // a placeholder, or reported as spaced
|
|
95
|
+
problems.push(
|
|
96
|
+
`${match[0]} is no placeholder: a placeholder is a name or a name's field ({{e.brauch}}); to show one record, pick it on its own (given: { e: { pick: 1, from: { data: feste } } }) and write {{e.brauch}}; a literal {{ in a formula is written { {`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
for (const match of text.matchAll(SEGMENT)) {
|
|
100
|
+
if (match[2] === undefined) continue;
|
|
101
|
+
for (const one of match[2].matchAll(ONE_BRACE)) {
|
|
102
|
+
const root = (one[1] ?? "").split(".")[0] ?? "";
|
|
103
|
+
if (known.has(root)) {
|
|
104
|
+
problems.push(`{${one[1]}} has one pair of braces; a placeholder is written with two: {{${one[1]}}}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return problems;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One template of an exercise as written, in one language: where it is, and its text. */
|
|
112
|
+
export interface Written {
|
|
113
|
+
/** The path in the exercise; for a text by language it ends with the language. */
|
|
114
|
+
path: (string | number)[];
|
|
115
|
+
lang?: string;
|
|
116
|
+
text: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The templates of an exercise as written: the task, the steps, the hint and the messages. */
|
|
120
|
+
export function writtenTemplates(spec: Generated): Written[] {
|
|
121
|
+
const written: Written[] = [];
|
|
122
|
+
const add = (path: (string | number)[], text: Text | undefined) => {
|
|
123
|
+
if (text === undefined) return;
|
|
124
|
+
if (typeof text === "string") written.push({ path, text });
|
|
125
|
+
else for (const [lang, each] of Object.entries(text)) written.push({ path: [...path, lang], lang, text: each });
|
|
126
|
+
};
|
|
127
|
+
add(["task", "text"], spec.task.text);
|
|
128
|
+
(spec.steps ?? []).forEach((step, index) => add(["steps", index], step));
|
|
129
|
+
add(["hint"], spec.hint);
|
|
130
|
+
(spec.mistakes ?? []).forEach((mistake, index) => add(["mistakes", index, "message"], mistake.message));
|
|
131
|
+
return written;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The problems of the placeholders of an exercise's templates, each at its path. */
|
|
135
|
+
export function templateProblems(spec: Generated): { path: (string | number)[]; lang?: string; message: string }[] {
|
|
136
|
+
const declared = [...Object.keys(spec.given), "answer"];
|
|
137
|
+
return writtenTemplates(spec).flatMap(({ path, lang, text }) => {
|
|
138
|
+
const known = new Set(declared);
|
|
139
|
+
return placeholderProblems(text, known).map((message) => ({
|
|
140
|
+
path,
|
|
141
|
+
...(lang === undefined ? {} : { lang }),
|
|
142
|
+
message,
|
|
143
|
+
}));
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The variables of `given` that the exercise never uses: in no placeholder of its templates, and in none of the
|
|
149
|
+
* expressions of the answer (with the options of a choice) and the mistakes. `where` does not
|
|
150
|
+
* count: a value drawn only to be filtered is never seen.
|
|
151
|
+
*/
|
|
152
|
+
export function unusedVariables(spec: Generated): string[] {
|
|
153
|
+
const used = new Set<string>();
|
|
154
|
+
for (const { text } of writtenTemplates(spec))
|
|
155
|
+
for (const path of placeholdersIn(text)) used.add(path.split(".")[0] ?? "");
|
|
156
|
+
const expressions = [spec.answer.value, ...(spec.mistakes ?? []).map((mistake) => mistake.value)];
|
|
157
|
+
const compare = spec.answer.compare;
|
|
158
|
+
if (typeof compare === "object") {
|
|
159
|
+
const options = (compare as Record<string, unknown>)["choice"];
|
|
160
|
+
if (typeof options === "string") expressions.push(options);
|
|
161
|
+
}
|
|
162
|
+
for (const source of expressions) {
|
|
163
|
+
try {
|
|
164
|
+
variables(parse(source), used);
|
|
165
|
+
} catch {
|
|
166
|
+
// an expression that does not parse is reported on its own
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return Object.keys(spec.given).filter((name) => !used.has(name));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The value of a placeholder's path in a task, or undefined when it has none. */
|
|
173
|
+
export function placeholderValue(
|
|
174
|
+
spec: Generated,
|
|
175
|
+
task: Task,
|
|
176
|
+
path: string,
|
|
177
|
+
lang: string,
|
|
178
|
+
): { text: string; number?: Fraction } | undefined {
|
|
179
|
+
if (path === "answer") {
|
|
180
|
+
return { text: answerLabel(spec, task, lang), ...(task.answer instanceof Fraction ? { number: task.answer } : {}) };
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const value = evaluate(parse(path), task.values);
|
|
184
|
+
return { text: label(value, lang), ...(value instanceof Fraction ? { number: value } : {}) };
|
|
185
|
+
} catch {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A template with the values of a task: HTML (escaped, numbers in formulas as <mn>) or plain text. */
|
|
191
|
+
export function fill(
|
|
192
|
+
template: string,
|
|
193
|
+
spec: Generated,
|
|
194
|
+
task: Task,
|
|
195
|
+
lang: string,
|
|
196
|
+
mode: "html" | "text" = "html",
|
|
197
|
+
): string {
|
|
198
|
+
const withMath =
|
|
199
|
+
mode === "html"
|
|
200
|
+
? template.replace(/<mtext>⟦([^⟧]+)⟧<\/mtext>/g, (_whole, path: string) => {
|
|
201
|
+
const value = placeholderValue(spec, task, path, lang);
|
|
202
|
+
if (value === undefined) return `<mtext>{{${escape(path)}}}</mtext>`;
|
|
203
|
+
if (value.number === undefined) return `<mtext>${escape(value.text)}</mtext>`;
|
|
204
|
+
const negative = value.number.compare(Fraction.of(0)) < 0;
|
|
205
|
+
const digits = escape(negative ? value.text.replace(/^[-−]/, "") : value.text);
|
|
206
|
+
return negative ? `<mo>−</mo><mn>${digits}</mn>` : `<mn>${digits}</mn>`;
|
|
207
|
+
})
|
|
208
|
+
: template;
|
|
209
|
+
return withMath.replace(/⟦([^⟧]+)⟧/g, (_whole, path: string) => {
|
|
210
|
+
const value = placeholderValue(spec, task, path, lang);
|
|
211
|
+
if (value === undefined) return `{{${path}}}`;
|
|
212
|
+
return mode === "html" ? escape(value.text) : value.text;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A template as its author wrote it (restricted Markdown, formulas as $…$) with each placeholder replaced by its value
|
|
218
|
+
* as the learner reads it, in `lang`.
|
|
219
|
+
*/
|
|
220
|
+
export function fillSource(text: Text, spec: Generated, task: Task, lang: string): string {
|
|
221
|
+
return inLanguage(text, lang).replace(
|
|
222
|
+
PLACEHOLDER,
|
|
223
|
+
(whole, path: string) => placeholderValue(spec, task, path, lang)?.text ?? whole,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export const templatesIn = (templates: Templates, lang: string): LanguageTemplates =>
|
|
228
|
+
templates[lang] ?? templates["de"] ?? { task: "", steps: [], messages: [] };
|
|
229
|
+
|
|
230
|
+
/** The task's text as HTML, in `lang`. */
|
|
231
|
+
export function taskText(spec: Generated, task: Task, lang: string, templates: Templates): string {
|
|
232
|
+
return fill(templatesIn(templates, lang).task, spec, task, lang);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The steps of the solution as HTML, in `lang`. */
|
|
236
|
+
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));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The hint as HTML, in `lang`, if the exercise has one. */
|
|
241
|
+
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);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The text of HTML, as a learner reads it: tags left out, entities resolved, white space folded. */
|
|
247
|
+
export function plainText(html: string): string {
|
|
248
|
+
return html
|
|
249
|
+
.replace(/<[^>]*>/g, "")
|
|
250
|
+
.replace(/</g, "<")
|
|
251
|
+
.replace(/>/g, ">")
|
|
252
|
+
.replace(/"/g, '"')
|
|
253
|
+
.replace(/'/g, "'")
|
|
254
|
+
.replace(/&/g, "&")
|
|
255
|
+
.replace(/\s+/g, " ")
|
|
256
|
+
.trim();
|
|
257
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Generated exercises (`generated`), the explainer's native notation for exercises drawn from a seed, in the style of
|
|
2
|
+
// property-based testing: arbitraries (`given`), preconditions (`where`), a task template,
|
|
3
|
+
// an answer with a comparator, typical mistakes with their messages, a hint and the steps, and examples: answers
|
|
4
|
+
// the build runs through the check of every seed's task (exampleProblems), never shown to learners. The same code runs at build
|
|
5
|
+
// time (the worked tasks without JavaScript, the property test over fixed seeds) and in the browser (a live task for
|
|
6
|
+
// any seed, "Neue Aufgabe"), so both agree. The comparator maps the answer onto the answer kinds of a question: number,
|
|
7
|
+
// choice, order, set and pairs become the number, single-choice, ordering, multiple-choice and matching
|
|
8
|
+
// questions, so controls, checker and feedback come from the shared questions.
|
|
9
|
+
//
|
|
10
|
+
// Templates are rendered by the build (restricted Markdown, formulas as MathML) with each placeholder {{name}} as a
|
|
11
|
+
// token ⟦name⟧ (inside a formula \text{⟦name⟧}); fill() puts in the values of a task, so the browser needs no Markdown
|
|
12
|
+
// or formula renderer. No Node and no DOM here.
|
|
13
|
+
//
|
|
14
|
+
// A placeholder is always written with two braces and nothing else inside: {{G}}, {{e.name}}, {{answer}}, in prose
|
|
15
|
+
// and in formulas alike. One brace is never a placeholder: in prose {G} is an error, in a formula it is a LaTeX group,
|
|
16
|
+
// so a bare letter (G, {G}, \frac{W}{G}) is always the letter. The one LaTeX form this takes away, a double group
|
|
17
|
+
// around a lone name ({{G}}), means the same as the letter alone, so nothing is lost; any other group ({{G}^2},
|
|
18
|
+
// {{G} + 1}) stays a group. To fill in a value where the formula needs a group, keep the group: \frac{{{W}}}{{{G}}}.
|
|
19
|
+
// Two braces with spaces inside ({{ G }}) are an error everywhere, as they were most likely meant as a placeholder.
|
|
20
|
+
|
|
21
|
+
// This module: the types of a generated exercise, its task and its data.
|
|
22
|
+
import { type Value } from "../expression.ts";
|
|
23
|
+
import { type Text } from "../../shared/languages.ts";
|
|
24
|
+
|
|
25
|
+
/** A literal of a list in the content: a number, a string, or a text by language. */
|
|
26
|
+
export type Literal = number | string | Readonly<Record<string, string>>;
|
|
27
|
+
/** A list of literals, or the records of a data file src/data/<name>.yaml. */
|
|
28
|
+
export type Source = readonly Literal[] | { readonly data: string };
|
|
29
|
+
|
|
30
|
+
/** One arbitrary of `given`: exactly one of int, decimal, oneOf, pick (with from). */
|
|
31
|
+
export interface Arbitrary {
|
|
32
|
+
readonly int?: readonly [number, number];
|
|
33
|
+
readonly decimal?: readonly [number, number];
|
|
34
|
+
readonly step?: number;
|
|
35
|
+
readonly oneOf?: readonly Literal[];
|
|
36
|
+
readonly pick?: number;
|
|
37
|
+
readonly from?: Source;
|
|
38
|
+
readonly distinct?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface NumberCompare {
|
|
42
|
+
readonly format?: "integer" | "decimal" | "money" | "percent";
|
|
43
|
+
readonly decimals?: number;
|
|
44
|
+
readonly tolerance?: number;
|
|
45
|
+
readonly unit?: Text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type Compare =
|
|
49
|
+
| "number"
|
|
50
|
+
| "order"
|
|
51
|
+
| "pairs"
|
|
52
|
+
| { readonly number: NumberCompare }
|
|
53
|
+
| { readonly choice: readonly Literal[] | string }
|
|
54
|
+
| { readonly pairs: { readonly left: string; readonly right: string } };
|
|
55
|
+
|
|
56
|
+
export interface Generated {
|
|
57
|
+
readonly type: "generated";
|
|
58
|
+
readonly id: string;
|
|
59
|
+
readonly given: Readonly<Record<string, Arbitrary>>;
|
|
60
|
+
readonly where?: readonly string[];
|
|
61
|
+
readonly task: { readonly text: Text };
|
|
62
|
+
readonly answer: { readonly value: string; readonly compare: Compare };
|
|
63
|
+
readonly mistakes?: readonly { readonly value: string; readonly message: Text }[];
|
|
64
|
+
readonly hint?: Text;
|
|
65
|
+
readonly steps?: readonly Text[];
|
|
66
|
+
/** The sentence above the live task; the page's own when absent. */
|
|
67
|
+
readonly practice?: Text;
|
|
68
|
+
/**
|
|
69
|
+
* Test cases for the build, never shown to learners: answers as expressions of given and answer, which the check
|
|
70
|
+
* of every seed's task must accept or reject (exampleProblems).
|
|
71
|
+
*/
|
|
72
|
+
readonly examples?: { readonly accepted?: readonly string[]; readonly rejected?: readonly string[] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A value of a data record: a number, a truth value, a text (a string, or a map by language), or, for what a component
|
|
77
|
+
* shows, a list or a record of values.
|
|
78
|
+
*/
|
|
79
|
+
export type DataValue = number | boolean | Text | readonly DataValue[] | { readonly [field: string]: DataValue };
|
|
80
|
+
/** A record of a data file: its fields by name. */
|
|
81
|
+
export type DataRecord = Readonly<Record<string, DataValue>>;
|
|
82
|
+
/** The records of the data files, by name. */
|
|
83
|
+
export type Data = Readonly<Record<string, readonly DataRecord[]>>;
|
|
84
|
+
|
|
85
|
+
/** The templates of an exercise in one language, rendered by the build, with tokens for the placeholders. */
|
|
86
|
+
export interface LanguageTemplates {
|
|
87
|
+
/** The task, inline HTML (no paragraph). */
|
|
88
|
+
task: string;
|
|
89
|
+
steps: string[];
|
|
90
|
+
hint?: string | undefined;
|
|
91
|
+
/** The mistakes' messages, plain text. */
|
|
92
|
+
messages: string[];
|
|
93
|
+
}
|
|
94
|
+
export type Templates = Readonly<Record<string, LanguageTemplates>>;
|
|
95
|
+
|
|
96
|
+
/** A task drawn from a seed: the values of `given`, the answer, the mistakes, the options of a choice or set. */
|
|
97
|
+
export interface Task {
|
|
98
|
+
seed: number;
|
|
99
|
+
values: Record<string, Value>;
|
|
100
|
+
answer: Value;
|
|
101
|
+
mistakes: Value[];
|
|
102
|
+
options?: Value[];
|
|
103
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The values of a generated exercise: the arbitraries (`given`) drawn from a seed, the preconditions (`where`), the
|
|
2
|
+
// compiled expressions, and the task of a seed.
|
|
3
|
+
import { evaluate, type Expression, ExpressionError, Localized, parse, type Rec, type Value } from "../expression.ts";
|
|
4
|
+
import { Fraction } from "../fraction.ts";
|
|
5
|
+
import { hash, random, shuffled } from "../../shared/questions/question.ts";
|
|
6
|
+
import {
|
|
7
|
+
type Arbitrary,
|
|
8
|
+
type Data,
|
|
9
|
+
type DataRecord,
|
|
10
|
+
type DataValue,
|
|
11
|
+
type Generated,
|
|
12
|
+
type Literal,
|
|
13
|
+
type Source,
|
|
14
|
+
type Task,
|
|
15
|
+
} from "./types.ts";
|
|
16
|
+
import { kindOf } from "./comparator.ts";
|
|
17
|
+
|
|
18
|
+
/** How many draws a seed may take until `where` holds. */
|
|
19
|
+
export const ATTEMPTS = 100;
|
|
20
|
+
|
|
21
|
+
const literal = (value: Literal): Value =>
|
|
22
|
+
typeof value === "number" ? Fraction.from(value) : typeof value === "string" ? value : new Localized(value);
|
|
23
|
+
|
|
24
|
+
/** A value of a data record: a map of strings is a text by language, any other map a record. */
|
|
25
|
+
function dataValue(value: DataValue): Value {
|
|
26
|
+
if (typeof value === "number") return Fraction.from(value);
|
|
27
|
+
if (typeof value === "boolean" || typeof value === "string") return value;
|
|
28
|
+
if (Array.isArray(value)) return value.map(dataValue);
|
|
29
|
+
const fields = Object.entries(value as Record<string, DataValue>);
|
|
30
|
+
return fields.every(([, field]) => typeof field === "string")
|
|
31
|
+
? new Localized(value as Readonly<Record<string, string>>)
|
|
32
|
+
: record(value as DataRecord);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function record(source: DataRecord): Rec {
|
|
36
|
+
return Object.fromEntries(Object.entries(source).map(([key, value]) => [key, dataValue(value)]));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function items(source: Source, data: Data): Value[] {
|
|
40
|
+
if (Array.isArray(source)) return (source as readonly Literal[]).map(literal);
|
|
41
|
+
const name = (source as { data: string }).data;
|
|
42
|
+
const records = data[name];
|
|
43
|
+
if (records === undefined) throw new ExpressionError(`src/data/${name}.yaml does not exist`);
|
|
44
|
+
return records.map(record);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The value of one arbitrary, drawn with `next`. */
|
|
48
|
+
export function draw(arbitrary: Arbitrary, next: () => number, data: Data): Value {
|
|
49
|
+
const range = arbitrary.int ?? arbitrary.decimal;
|
|
50
|
+
if (range !== undefined) {
|
|
51
|
+
const [min, max] = range;
|
|
52
|
+
const step = Fraction.from(arbitrary.step ?? 1);
|
|
53
|
+
const low = Fraction.from(min);
|
|
54
|
+
const count = Math.floor(Fraction.from(max).sub(low).div(step).toNumber() + 1e-9) + 1;
|
|
55
|
+
return low.add(step.mul(Fraction.of(Math.floor(next() * count))));
|
|
56
|
+
}
|
|
57
|
+
if (arbitrary.oneOf !== undefined) {
|
|
58
|
+
const list = arbitrary.oneOf;
|
|
59
|
+
return literal(list[Math.floor(next() * list.length)] as Literal);
|
|
60
|
+
}
|
|
61
|
+
const list = items(arbitrary.from ?? [], data);
|
|
62
|
+
const count = arbitrary.pick ?? 1;
|
|
63
|
+
const picked: Value[] = [];
|
|
64
|
+
if (arbitrary.distinct === true) {
|
|
65
|
+
const order = shuffled(list.length, Math.floor(next() * 2 ** 31));
|
|
66
|
+
for (const index of order.slice(0, count)) picked.push(list[index] as Value);
|
|
67
|
+
} else {
|
|
68
|
+
for (let i = 0; i < count; i++) picked.push(list[Math.floor(next() * list.length)] as Value);
|
|
69
|
+
}
|
|
70
|
+
return count === 1 && arbitrary.pick === 1 ? (picked[0] as Value) : picked;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The parsed expressions of an exercise, once per exercise. */
|
|
74
|
+
interface Compiled {
|
|
75
|
+
where: Expression[];
|
|
76
|
+
answer: Expression;
|
|
77
|
+
mistakes: Expression[];
|
|
78
|
+
options?: Expression;
|
|
79
|
+
}
|
|
80
|
+
const compiledCache = new WeakMap<Generated, Compiled>();
|
|
81
|
+
export function compiled(spec: Generated): Compiled {
|
|
82
|
+
let found = compiledCache.get(spec);
|
|
83
|
+
if (found === undefined) {
|
|
84
|
+
const kind = kindOf(spec.answer.compare);
|
|
85
|
+
found = {
|
|
86
|
+
where: (spec.where ?? []).map((source) => parse(source)),
|
|
87
|
+
answer: parse(spec.answer.value),
|
|
88
|
+
mistakes: (spec.mistakes ?? []).map((mistake) => parse(mistake.value)),
|
|
89
|
+
...(kind.kind === "choice" && typeof kind.options === "string" ? { options: parse(kind.options) } : {}),
|
|
90
|
+
};
|
|
91
|
+
compiledCache.set(spec, found);
|
|
92
|
+
}
|
|
93
|
+
return found;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The values of `given` for a seed and an attempt. */
|
|
97
|
+
export function values(spec: Generated, seed: number, attempt: number, data: Data): Record<string, Value> {
|
|
98
|
+
const next = random(hash(`${spec.id}#${seed}#${attempt}`));
|
|
99
|
+
const drawn: Record<string, Value> = {};
|
|
100
|
+
for (const [name, arbitrary] of Object.entries(spec.given)) drawn[name] = draw(arbitrary, next, data);
|
|
101
|
+
return drawn;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Whether every precondition holds for these values. */
|
|
105
|
+
export function holds(spec: Generated, env: Record<string, Value>): boolean {
|
|
106
|
+
return compiled(spec).where.every((condition, index) => {
|
|
107
|
+
const value = evaluate(condition, env);
|
|
108
|
+
if (typeof value !== "boolean") {
|
|
109
|
+
throw new ExpressionError(`where[${index}] needs true or false, not a value`);
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Thrown when no draw of a seed passes `where`. */
|
|
116
|
+
export class StarvedError extends ExpressionError {}
|
|
117
|
+
|
|
118
|
+
/** The task of `seed`: the first draw whose values pass `where`, with the answer, the mistakes and the options. */
|
|
119
|
+
export function generate(spec: Generated, seed: number, data: Data): Task {
|
|
120
|
+
const code = compiled(spec);
|
|
121
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
122
|
+
const env = values(spec, seed, attempt, data);
|
|
123
|
+
if (!holds(spec, env)) continue;
|
|
124
|
+
const answer = evaluate(code.answer, env);
|
|
125
|
+
const task: Task = { seed, values: env, answer, mistakes: code.mistakes.map((mistake) => evaluate(mistake, env)) };
|
|
126
|
+
const kind = kindOf(spec.answer.compare);
|
|
127
|
+
if (kind.kind === "choice") {
|
|
128
|
+
const options =
|
|
129
|
+
code.options === undefined ? (kind.options as readonly Literal[]).map(literal) : evaluate(code.options, env);
|
|
130
|
+
if (!Array.isArray(options)) throw new ExpressionError(`the options of choice are not a list`);
|
|
131
|
+
task.options = [...(options as Value[])];
|
|
132
|
+
}
|
|
133
|
+
return task;
|
|
134
|
+
}
|
|
135
|
+
throw new StarvedError(`where holds in none of ${ATTEMPTS} draws for seed ${seed}`);
|
|
136
|
+
}
|