@lernapps/app-templates 0.0.1 → 0.1.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/LICENSE +22 -0
- package/README.md +195 -2
- package/dist/appname-Bh23cUuz.mjs +12 -0
- package/dist/assessment-By2GGTJC.mjs +1696 -0
- package/dist/block-BK_ohsH1.mjs +82 -0
- package/dist/blocks-BmRrD7UY.mjs +149 -0
- package/dist/blocks.mjs +4 -0
- package/dist/check-BkEl-m-F.mjs +254 -0
- package/dist/content-cYBPYg_J.mjs +140 -0
- package/dist/contract-PONpxJtd.mjs +52 -0
- package/dist/e2e-BiMI-pCL.mjs +89 -0
- package/dist/engine-BqF2Vffn.mjs +136 -0
- package/dist/explainer-e2e.mjs +1297 -0
- package/dist/explainer-islands.mjs +197 -0
- package/dist/explainer-plugin.mjs +2131 -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-C-0hUaSC.mjs +16947 -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-BKc8JYtC.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-BJey0bwl.mjs +540 -0
- package/dist/render-BL6fV_NI.mjs +507 -0
- package/dist/render-DpRO_RKm.mjs +146 -0
- package/dist/result-_eQcamoz.mjs +180 -0
- package/dist/tasks-DADUYy52.mjs +74 -0
- package/dist/validate-COaZso7u.mjs +1072 -0
- package/dist/validate-CxtdeQ5S.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 +142 -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 +446 -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 +1474 -0
- package/runtime/explainer/exercises.ts +324 -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 +299 -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 +430 -0
- package/runtime/shared/questions/question.v1.schema.json +342 -0
- package/runtime/shared/questions/questions.css +192 -0
- package/runtime/shared/questions/render.ts +247 -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/text.v1.schema.json +34 -0
- package/runtime/shared/vue-code.ts +395 -0
- package/runtime/shared/vue.ts +38 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// The logic of an exercise component: that it exports what an exercise needs (describedProblems), the exercise with
|
|
2
|
+
// its inputs and outputs for a tutor when it declares it (exerciseProblems), its property test over fixed seeds (propertyProblems) and its
|
|
3
|
+
// examples (exampleProblems). An exercise component emits answer, declares the
|
|
4
|
+
// prop seed: number and shows the task generate(seed, props); the build runs its logic module outside the browser,
|
|
5
|
+
// and the islands call it on "Prüfen" (check, reason, show).
|
|
6
|
+
import { textLanguages } from "../../shared/languages.ts";
|
|
7
|
+
import { verdict } from "../../shared/examples.ts";
|
|
8
|
+
import {
|
|
9
|
+
type ComponentAbout,
|
|
10
|
+
type ComponentDescription,
|
|
11
|
+
type ComponentExamples,
|
|
12
|
+
type ComponentExercise,
|
|
13
|
+
type DescribedTask,
|
|
14
|
+
} from "../content.ts";
|
|
15
|
+
import { inspect } from "../../shared/text/markdown.ts";
|
|
16
|
+
import { distinctSeeds, MAX_TASKS, SEED_TRIES } from "../tasks.ts";
|
|
17
|
+
import { isText } from "./props.ts";
|
|
18
|
+
|
|
19
|
+
/** How many seeds the property test of an exercise component's logic draws, for each block that uses it. */
|
|
20
|
+
export const COMPONENT_SEEDS = 50;
|
|
21
|
+
|
|
22
|
+
/** The exports of an exercise component's logic module, for a message. */
|
|
23
|
+
export const CONTRACT =
|
|
24
|
+
"generate(seed, props), check(task, answer), solution(task), wrong(task), reason(task, answer, props, lang), describe(task, props, lang) and about";
|
|
25
|
+
|
|
26
|
+
export interface ExerciseLogic {
|
|
27
|
+
generate(seed: number, props: unknown): unknown;
|
|
28
|
+
check(task: unknown, answer: unknown): unknown;
|
|
29
|
+
solution(task: unknown): unknown;
|
|
30
|
+
wrong(task: unknown): unknown;
|
|
31
|
+
/** The app's message for a wrong answer, in `lang`; undefined (or "") for none. */
|
|
32
|
+
reason?(task: unknown, answer: unknown, props: unknown, lang: string): unknown;
|
|
33
|
+
describe?(task: unknown, props: unknown, lang: string): unknown;
|
|
34
|
+
about?: unknown;
|
|
35
|
+
// for a tutor: the exercise with its inputs and outputs, all four or none (exerciseProblems)
|
|
36
|
+
template?(props: unknown, lang: string): unknown;
|
|
37
|
+
params?(task: unknown, props: unknown, lang: string): unknown;
|
|
38
|
+
mistakes?(task: unknown, props: unknown): unknown;
|
|
39
|
+
show?(answer: unknown, task: unknown, props: unknown, lang: string): unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The exports of an exercise component's logic module for a tutor's table of tasks, all four or none. */
|
|
43
|
+
const TABLE = ["template", "params", "mistakes", "show"] as const;
|
|
44
|
+
|
|
45
|
+
/** The exports of TABLE, for a message. */
|
|
46
|
+
export const TABLE_CONTRACT =
|
|
47
|
+
"template(props, lang), params(task, props, lang), mistakes(task, props) and show(answer, task, props, lang)";
|
|
48
|
+
|
|
49
|
+
const ABOUT: readonly (keyof ComponentAbout)[] = ["task", "answer", "correct", "mistakes"];
|
|
50
|
+
|
|
51
|
+
/** The problems of one text of about, at `path` (about.task, about.mistakes[0]). */
|
|
52
|
+
function aboutTextProblems(value: unknown, path: string, languages: readonly string[]): string[] {
|
|
53
|
+
if (!isText(value) || Object.values(typeof value === "string" ? [value] : value).some((text) => text.trim() === "")) {
|
|
54
|
+
return [
|
|
55
|
+
`${path} is ${JSON.stringify(value) ?? "missing"}; give a text, or a map of texts by language: { de: …, en: … }`,
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
return textLanguages(value, languages).problems.map(({ lang, message }) => `${path} (${lang}): ${message}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What an exercise component's logic says about itself, for each block: about (what the learner does, the answer's
|
|
63
|
+
* format, what counts as right, the typical mistakes; texts in each of `languages`), its task numbers (the seeds of
|
|
64
|
+
* its distinct tasks, generate(seed, props) compared as JSON with the props of the first language, up to `max` of
|
|
65
|
+
* `tries` seeds; distinctSeeds), and describe(task, props, lang) for the task of each number in each language, with
|
|
66
|
+
* the block's props in that language (`propsIn`): the task and its solution, each restricted Markdown that is not
|
|
67
|
+
* empty. The description, or the problems (the first of describe).
|
|
68
|
+
*/
|
|
69
|
+
export function describedProblems(
|
|
70
|
+
logic: ExerciseLogic,
|
|
71
|
+
propsIn: (lang: string) => unknown,
|
|
72
|
+
languages: readonly string[],
|
|
73
|
+
max = MAX_TASKS,
|
|
74
|
+
tries = SEED_TRIES,
|
|
75
|
+
): { description?: ComponentDescription; problems: string[] } {
|
|
76
|
+
const problems: string[] = [];
|
|
77
|
+
const about = logic.about;
|
|
78
|
+
if (typeof about !== "object" || about === null || Array.isArray(about)) {
|
|
79
|
+
problems.push(
|
|
80
|
+
`about is ${JSON.stringify(about) ?? "missing"}; export const about = { task, answer, correct, mistakes }: what the learner does, the answer's format, what counts as right, and the typical mistakes, each a text (a map by language when the app has more than one)`,
|
|
81
|
+
);
|
|
82
|
+
} else {
|
|
83
|
+
const fields = about as Record<string, unknown>;
|
|
84
|
+
for (const key of Object.keys(fields)) {
|
|
85
|
+
if (!(ABOUT as readonly string[]).includes(key))
|
|
86
|
+
problems.push(`about has no field ${key}; its fields are ${ABOUT.join(", ")}`);
|
|
87
|
+
}
|
|
88
|
+
for (const key of ABOUT) {
|
|
89
|
+
const value = fields[key];
|
|
90
|
+
if (key !== "mistakes") problems.push(...aboutTextProblems(value, `about.${key}`, languages));
|
|
91
|
+
else if (!Array.isArray(value)) {
|
|
92
|
+
problems.push(
|
|
93
|
+
`about.mistakes is ${JSON.stringify(value) ?? "missing"}; give a list of the typical mistakes, each a text`,
|
|
94
|
+
);
|
|
95
|
+
} else
|
|
96
|
+
value.forEach((text, index) =>
|
|
97
|
+
problems.push(...aboutTextProblems(text, `about.mistakes[${index}]`, languages)),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (typeof logic.describe !== "function") {
|
|
102
|
+
problems.push("describe is no function; export function describe(task, props, lang) giving { task, solution }");
|
|
103
|
+
return { problems };
|
|
104
|
+
}
|
|
105
|
+
const first = propsIn(languages[0] ?? "de");
|
|
106
|
+
const seeds = distinctSeeds(
|
|
107
|
+
(seed) => {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.stringify(logic.generate(seed, first));
|
|
110
|
+
} catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
max,
|
|
115
|
+
tries,
|
|
116
|
+
);
|
|
117
|
+
const tasks: Record<string, DescribedTask[]> = {};
|
|
118
|
+
for (const lang of languages) {
|
|
119
|
+
const props = propsIn(lang);
|
|
120
|
+
const list: DescribedTask[] = [];
|
|
121
|
+
for (const [index, seed] of seeds.entries()) {
|
|
122
|
+
const call = `describe(task, props, "${lang}")`;
|
|
123
|
+
let described: unknown;
|
|
124
|
+
try {
|
|
125
|
+
described = logic.describe(logic.generate(seed, props), props, lang);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
problems.push(`for seed ${seed} ${call} throws: ${error instanceof Error ? error.message : String(error)}`);
|
|
128
|
+
return { problems };
|
|
129
|
+
}
|
|
130
|
+
const { task, solution } = (typeof described === "object" && described !== null ? described : {}) as Record<
|
|
131
|
+
string,
|
|
132
|
+
unknown
|
|
133
|
+
>;
|
|
134
|
+
if (typeof task !== "string" || task.trim() === "" || typeof solution !== "string" || solution.trim() === "") {
|
|
135
|
+
problems.push(
|
|
136
|
+
`for seed ${seed} ${call} is ${JSON.stringify(described) ?? "undefined"}; describe returns { task, solution }: the task as the learner sees it and its solution, each a text in the page's language`,
|
|
137
|
+
);
|
|
138
|
+
return { problems };
|
|
139
|
+
}
|
|
140
|
+
for (const [part, text] of [
|
|
141
|
+
["task", task],
|
|
142
|
+
["solution", solution],
|
|
143
|
+
] as const) {
|
|
144
|
+
const [problem] = inspect(text).problems;
|
|
145
|
+
if (problem !== undefined) {
|
|
146
|
+
problems.push(`for seed ${seed} the ${part} of ${call}: ${problem.message}`);
|
|
147
|
+
return { problems };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
list.push({ number: index + 1, seed, task, solution });
|
|
151
|
+
}
|
|
152
|
+
tasks[lang] = list;
|
|
153
|
+
}
|
|
154
|
+
if (problems.length > 0) return { problems };
|
|
155
|
+
const declared = exerciseProblems(logic, propsIn, languages, seeds, (about as ComponentAbout).mistakes.length);
|
|
156
|
+
if (declared.problems.length > 0) return { problems: declared.problems };
|
|
157
|
+
return {
|
|
158
|
+
description: {
|
|
159
|
+
about: about as ComponentAbout,
|
|
160
|
+
seeds,
|
|
161
|
+
tasks,
|
|
162
|
+
...(declared.exercise ? { exercise: declared.exercise } : {}),
|
|
163
|
+
},
|
|
164
|
+
problems,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A list of names, for a message: "a", "a and b", "a, b and c". */
|
|
169
|
+
const names = (list: readonly string[]) =>
|
|
170
|
+
list.length < 2 ? (list[0] ?? "") : `${list.slice(0, -1).join(", ")} and ${list.at(-1) ?? ""}`;
|
|
171
|
+
|
|
172
|
+
/** A value of params as text in `lang`: a number with the decimal comma in German. */
|
|
173
|
+
const valueText = (value: number | string, lang: string) =>
|
|
174
|
+
typeof value === "number" && lang === "de" ? String(value).replace(".", ",") : String(value).trim();
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* An exercise component as a tutor reads it, in each of `languages`, when its logic exports template, params,
|
|
178
|
+
* mistakes and show (none of them: no exercise, no problem): the task once, template(props, lang), with {name}
|
|
179
|
+
* for each input; for the task of each of `seeds`, params(task, props, lang), its inputs by name, the same names for
|
|
180
|
+
* every task, each a number or a text that is not empty, and each placeholder one of them; show of the solution,
|
|
181
|
+
* which check accepts (propertyProblems); and show of the answer of each typical mistake, mistakes(task, props), one
|
|
182
|
+
* for each of the `count` texts of about.mistakes, in order, each undefined or an answer that check rejects. The
|
|
183
|
+
* exercise in each language, or the problems (the first).
|
|
184
|
+
*/
|
|
185
|
+
export function exerciseProblems(
|
|
186
|
+
logic: ExerciseLogic,
|
|
187
|
+
propsIn: (lang: string) => unknown,
|
|
188
|
+
languages: readonly string[],
|
|
189
|
+
seeds: readonly number[],
|
|
190
|
+
count: number,
|
|
191
|
+
): { exercise?: Record<string, ComponentExercise>; problems: string[] } {
|
|
192
|
+
const given = TABLE.filter((name) => typeof logic[name] === "function");
|
|
193
|
+
if (given.length === 0) return { problems: [] };
|
|
194
|
+
if (given.length < TABLE.length) {
|
|
195
|
+
const missing = TABLE.filter((name) => !given.includes(name));
|
|
196
|
+
return {
|
|
197
|
+
problems: [
|
|
198
|
+
`exports ${names(given)} but not ${names(missing)}; for a tutor's table of tasks export all four: ${TABLE_CONTRACT}`,
|
|
199
|
+
],
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const { template, params, mistakes, show } = logic as Required<ExerciseLogic>;
|
|
203
|
+
const fail = (problem: string) => ({ problems: [problem] });
|
|
204
|
+
const exercise: Record<string, ComponentExercise> = {};
|
|
205
|
+
for (const lang of languages) {
|
|
206
|
+
const props = propsIn(lang);
|
|
207
|
+
const call = (name: string) => `${name}(task, props, "${lang}")`;
|
|
208
|
+
const run = <T>(seed: number | undefined, what: string, act: () => T): T | { problems: string[] } => {
|
|
209
|
+
try {
|
|
210
|
+
return act();
|
|
211
|
+
} catch (error) {
|
|
212
|
+
const at = seed === undefined ? "" : `for seed ${seed} `;
|
|
213
|
+
return fail(`${at}${what} throws: ${error instanceof Error ? error.message : String(error)}`);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
const failed = (value: unknown): value is { problems: string[] } =>
|
|
217
|
+
typeof value === "object" && value !== null && "problems" in value && Array.isArray(value.problems);
|
|
218
|
+
const text = run(undefined, `template(props, "${lang}")`, () => template(props, lang));
|
|
219
|
+
if (failed(text)) return text;
|
|
220
|
+
if (typeof text !== "string" || text.trim() === "") {
|
|
221
|
+
return fail(
|
|
222
|
+
`template(props, "${lang}") is ${JSON.stringify(text) ?? "undefined"}; template gives the task as a learner reads it, with {name} for each input of params`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
let inputs: string[] | undefined;
|
|
226
|
+
let first = 0;
|
|
227
|
+
const numbered: ComponentExercise["tasks"][number][] = [];
|
|
228
|
+
for (const seed of seeds) {
|
|
229
|
+
const task = logic.generate(seed, props);
|
|
230
|
+
const values = run(seed, call("params"), () => params(task, props, lang));
|
|
231
|
+
if (failed(values)) return values;
|
|
232
|
+
if (typeof values !== "object" || values === null || Array.isArray(values)) {
|
|
233
|
+
return fail(
|
|
234
|
+
`for seed ${seed} ${call("params")} is ${JSON.stringify(values) ?? "undefined"}; params gives the inputs of the task by name, { G: 340, p: 25 }`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const keys = Object.keys(values);
|
|
238
|
+
if (inputs === undefined) {
|
|
239
|
+
inputs = keys;
|
|
240
|
+
first = seed;
|
|
241
|
+
} else if (keys.join("\u0000") !== inputs.join("\u0000")) {
|
|
242
|
+
return fail(
|
|
243
|
+
`for seed ${seed} ${call("params")} gives the names ${keys.join(", ")}, and for seed ${first} ${inputs.join(", ")}; give the same names for every task`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
const shown: string[] = [];
|
|
247
|
+
for (const [name, value] of Object.entries(values as Record<string, unknown>)) {
|
|
248
|
+
if (
|
|
249
|
+
!((typeof value === "number" && Number.isFinite(value)) || (typeof value === "string" && value.trim() !== ""))
|
|
250
|
+
) {
|
|
251
|
+
return fail(
|
|
252
|
+
`for seed ${seed} ${call("params")}.${name} is ${JSON.stringify(value) ?? "undefined"}; give each input as a number or a text that is not empty`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
shown.push(valueText(value, lang));
|
|
256
|
+
}
|
|
257
|
+
const asText = (answer: unknown, what: string): string | { problems: string[] } => {
|
|
258
|
+
const result = run(seed, `show(${what}, task, props, "${lang}")`, () => show(answer, task, props, lang));
|
|
259
|
+
if (failed(result)) return result;
|
|
260
|
+
if (typeof result !== "string" || result.trim() === "") {
|
|
261
|
+
return fail(
|
|
262
|
+
`for seed ${seed} show(${what}, task, props, "${lang}") is ${JSON.stringify(result) ?? "undefined"}; show gives an answer as text, as the learner gives it`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return result.trim();
|
|
266
|
+
};
|
|
267
|
+
const solution = asText(logic.solution(task), "solution(task)");
|
|
268
|
+
if (failed(solution)) return solution;
|
|
269
|
+
const answers = run(seed, "mistakes(task, props)", () => mistakes(task, props));
|
|
270
|
+
if (failed(answers)) return answers;
|
|
271
|
+
if (!Array.isArray(answers) || answers.length !== count) {
|
|
272
|
+
const length = Array.isArray(answers)
|
|
273
|
+
? `${answers.length} answer${answers.length === 1 ? "" : "s"}`
|
|
274
|
+
: (JSON.stringify(answers) ?? "undefined");
|
|
275
|
+
return fail(
|
|
276
|
+
`for seed ${seed} mistakes(task, props) gives ${length}; give one for each of the ${count} typical mistakes of about.mistakes, in order, undefined for one without a single answer`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
const wrong: string[] = [];
|
|
280
|
+
for (const [index, answer] of (answers as unknown[]).entries()) {
|
|
281
|
+
if (answer === undefined) {
|
|
282
|
+
wrong.push("");
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const what = `mistakes(task, props)[${index}]`;
|
|
286
|
+
const result = run(seed, `check(task, ${what})`, () => logic.check(task, answer));
|
|
287
|
+
if (failed(result)) return result;
|
|
288
|
+
if (result !== false) {
|
|
289
|
+
return fail(
|
|
290
|
+
`for seed ${seed} check(task, ${what}) is ${String(result)}; the answer of a typical mistake must be one that check rejects`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
const shownAnswer = asText(answer, what);
|
|
294
|
+
if (failed(shownAnswer)) return shownAnswer;
|
|
295
|
+
wrong.push(shownAnswer);
|
|
296
|
+
}
|
|
297
|
+
numbered.push({ inputs: shown, solution, mistakes: wrong });
|
|
298
|
+
}
|
|
299
|
+
const unknown = [...text.matchAll(/\{([^{}\s]+)\}/g)]
|
|
300
|
+
.map((match) => match[1] ?? "")
|
|
301
|
+
.find((name) => !(inputs ?? []).includes(name));
|
|
302
|
+
if (unknown !== undefined) {
|
|
303
|
+
return fail(
|
|
304
|
+
`template(props, "${lang}") has the placeholder {${unknown}}, which ${call("params")} does not give; its names are ${(inputs ?? []).join(", ")}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
exercise[lang] = { template: text.replace(/\s+/g, " ").trim(), inputs: inputs ?? [], tasks: numbered };
|
|
308
|
+
}
|
|
309
|
+
return { exercise, problems: [] };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The property test of an exercise component's logic with the props of a block, over the seeds 1 to `seeds`: the
|
|
314
|
+
* task is the same for the same seed, the solution passes, the wrong answer fails, reason gives a text or none for
|
|
315
|
+
* it, and nothing throws. The first problem found, or none.
|
|
316
|
+
*/
|
|
317
|
+
export function propertyProblems(logic: ExerciseLogic, props: unknown, seeds = COMPONENT_SEEDS, lang = "de"): string[] {
|
|
318
|
+
const run = <T>(seed: number, call: string, act: () => T): T | string => {
|
|
319
|
+
try {
|
|
320
|
+
return act();
|
|
321
|
+
} catch (error) {
|
|
322
|
+
return `\u0000for seed ${seed} ${call} throws: ${error instanceof Error ? error.message : String(error)}`;
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
const failed = (value: unknown): value is string => typeof value === "string" && value.startsWith("\u0000");
|
|
326
|
+
for (let seed = 1; seed <= seeds; seed++) {
|
|
327
|
+
const task = run(seed, "generate(seed, props)", () => logic.generate(seed, props));
|
|
328
|
+
if (failed(task)) return [task.slice(1)];
|
|
329
|
+
const again = run(seed, "generate(seed, props)", () => logic.generate(seed, props));
|
|
330
|
+
if (failed(again)) return [again.slice(1)];
|
|
331
|
+
if (JSON.stringify(task) !== JSON.stringify(again)) {
|
|
332
|
+
return [
|
|
333
|
+
`for seed ${seed} generate(seed, props) gives another task each time; it must give the same task for the same seed`,
|
|
334
|
+
];
|
|
335
|
+
}
|
|
336
|
+
for (const [answer, want] of [
|
|
337
|
+
["solution", true],
|
|
338
|
+
["wrong", false],
|
|
339
|
+
] as const) {
|
|
340
|
+
const call = `check(task, ${answer}(task))`;
|
|
341
|
+
const result = run(seed, call, () => logic.check(task, logic[answer](task)));
|
|
342
|
+
if (failed(result)) return [result.slice(1)];
|
|
343
|
+
if (typeof result !== "boolean")
|
|
344
|
+
return [`for seed ${seed} ${call} is ${String(result)}; check returns true or false`];
|
|
345
|
+
if (result !== want) {
|
|
346
|
+
return [
|
|
347
|
+
`for seed ${seed} ${call} is ${String(result)}; ${answer}(task) must give an answer that check ${want ? "accepts" : "rejects"}`,
|
|
348
|
+
];
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (logic.reason !== undefined) {
|
|
352
|
+
const call = `reason(task, wrong(task), props, "${lang}")`;
|
|
353
|
+
const message = run(seed, call, () => logic.reason?.(task, logic.wrong(task), props, lang));
|
|
354
|
+
if (failed(message)) return [message.slice(1)];
|
|
355
|
+
if (message !== undefined && typeof message !== "string") {
|
|
356
|
+
return [
|
|
357
|
+
`for seed ${seed} ${call} is ${JSON.stringify(message) ?? "a function"}; reason gives the app's message for a wrong answer as a text, or undefined for none`,
|
|
358
|
+
];
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Whether the check of an exercise component's logic, with the props of a block, judges each of the block's examples
|
|
367
|
+
* as it is listed: an accepted answer passes, a rejected one fails, for the task of its seed, or of every seed from 1
|
|
368
|
+
* to `seeds` when it gives none. One problem per example, at the first seed that judges it otherwise, with the
|
|
369
|
+
* example's kind and index.
|
|
370
|
+
*/
|
|
371
|
+
export function exampleProblems(
|
|
372
|
+
logic: ExerciseLogic,
|
|
373
|
+
props: unknown,
|
|
374
|
+
examples: ComponentExamples | undefined,
|
|
375
|
+
seeds = COMPONENT_SEEDS,
|
|
376
|
+
): { kind: "accepted" | "rejected"; index: number; message: string }[] {
|
|
377
|
+
const problems: { kind: "accepted" | "rejected"; index: number; message: string }[] = [];
|
|
378
|
+
for (const kind of ["accepted", "rejected"] as const) {
|
|
379
|
+
(examples?.[kind] ?? []).forEach((example, index) => {
|
|
380
|
+
const shown = JSON.stringify(example.answer);
|
|
381
|
+
for (const seed of example.seed === undefined ? Array.from({ length: seeds }, (_, i) => i + 1) : [example.seed]) {
|
|
382
|
+
let result: unknown;
|
|
383
|
+
try {
|
|
384
|
+
result = logic.check(logic.generate(seed, props), example.answer);
|
|
385
|
+
} catch (error) {
|
|
386
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
387
|
+
problems.push({ kind, index, message: `for seed ${seed} check(task, ${shown}) throws: ${reason}` });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (typeof result !== "boolean") {
|
|
391
|
+
problems.push({
|
|
392
|
+
kind,
|
|
393
|
+
index,
|
|
394
|
+
message: `for seed ${seed} check(task, ${shown}) is ${String(result)}; check returns true or false`,
|
|
395
|
+
});
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (result === (kind === "accepted")) continue;
|
|
399
|
+
problems.push({
|
|
400
|
+
kind,
|
|
401
|
+
index,
|
|
402
|
+
message: `for seed ${seed} ${verdict(shown, result, "check(task, answer)", "fix check so that it")}`,
|
|
403
|
+
});
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
return problems;
|
|
409
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// The props of a vue-component block against the prop types of its component: what is wrong with a value at a path
|
|
2
|
+
// below props, the paths of the learner texts among them, and the props in the page's language. A list prop may be
|
|
3
|
+
// given as { data: <name> }, the records of src/data/<name>.yaml (withData).
|
|
4
|
+
import { inLanguage, type Text } from "../../shared/languages.ts";
|
|
5
|
+
import { type ComponentInfo, type Field, type PropType } from "../content.ts";
|
|
6
|
+
|
|
7
|
+
export interface PropProblem {
|
|
8
|
+
path: readonly (string | number)[];
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const isText = (value: unknown): value is Text =>
|
|
13
|
+
typeof value === "string" ||
|
|
14
|
+
(typeof value === "object" &&
|
|
15
|
+
value !== null &&
|
|
16
|
+
!Array.isArray(value) &&
|
|
17
|
+
Object.keys(value).length > 0 &&
|
|
18
|
+
Object.values(value).every((text) => typeof text === "string"));
|
|
19
|
+
|
|
20
|
+
/** What a value of `type` is, for a message. */
|
|
21
|
+
function expected(type: PropType): string {
|
|
22
|
+
switch (type.kind) {
|
|
23
|
+
case "string":
|
|
24
|
+
return "a text";
|
|
25
|
+
case "number":
|
|
26
|
+
return "a number";
|
|
27
|
+
case "boolean":
|
|
28
|
+
return "true or false";
|
|
29
|
+
case "unknown":
|
|
30
|
+
return "a value";
|
|
31
|
+
case "literal":
|
|
32
|
+
return `one of ${type.values.map((value) => JSON.stringify(value)).join(", ")}`;
|
|
33
|
+
case "array":
|
|
34
|
+
return "a list";
|
|
35
|
+
case "object":
|
|
36
|
+
return `fields ${Object.keys(type.fields).join(", ")}`;
|
|
37
|
+
case "union":
|
|
38
|
+
return type.of.map(expected).join(" or ");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Whether `value` is a `type`. */
|
|
43
|
+
function matches(value: unknown, type: PropType): boolean {
|
|
44
|
+
return valueProblems(value, type, [], "", "").length === 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function valueProblems(
|
|
48
|
+
value: unknown,
|
|
49
|
+
type: PropType,
|
|
50
|
+
path: readonly (string | number)[],
|
|
51
|
+
component: string,
|
|
52
|
+
declaration: string,
|
|
53
|
+
): PropProblem[] {
|
|
54
|
+
const wrong = (): PropProblem[] => [
|
|
55
|
+
{ path, message: `${component} declares ${declaration}; give ${expected(type)} here (vue-component-props-valid)` },
|
|
56
|
+
];
|
|
57
|
+
switch (type.kind) {
|
|
58
|
+
case "unknown":
|
|
59
|
+
return [];
|
|
60
|
+
case "string":
|
|
61
|
+
return isText(value) ? [] : wrong();
|
|
62
|
+
case "number":
|
|
63
|
+
return typeof value === "number" ? [] : wrong();
|
|
64
|
+
case "boolean":
|
|
65
|
+
return typeof value === "boolean" ? [] : wrong();
|
|
66
|
+
case "literal":
|
|
67
|
+
return type.values.includes(value as string) ? [] : wrong();
|
|
68
|
+
case "array":
|
|
69
|
+
return Array.isArray(value)
|
|
70
|
+
? value.flatMap((item, index) => valueProblems(item, type.of, [...path, index], component, declaration))
|
|
71
|
+
: wrong();
|
|
72
|
+
case "union":
|
|
73
|
+
return type.of.some((member) => matches(value, member)) ? [] : wrong();
|
|
74
|
+
case "object":
|
|
75
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return wrong();
|
|
76
|
+
return fieldProblems(value as Record<string, unknown>, type.fields, path, component, declaration);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fieldProblems(
|
|
81
|
+
value: Readonly<Record<string, unknown>>,
|
|
82
|
+
fields: Readonly<Record<string, Field>>,
|
|
83
|
+
path: readonly (string | number)[],
|
|
84
|
+
component: string,
|
|
85
|
+
declaration?: string,
|
|
86
|
+
): PropProblem[] {
|
|
87
|
+
const problems: PropProblem[] = [];
|
|
88
|
+
const what = path.length === 0 ? "prop" : "field";
|
|
89
|
+
for (const key of Object.keys(value)) {
|
|
90
|
+
if (!(key in fields)) {
|
|
91
|
+
problems.push({
|
|
92
|
+
path: [...path, key],
|
|
93
|
+
message: `${component} has no ${what} ${key}${declaration === undefined ? "" : ` in ${declaration}`}; ${Object.keys(fields).length === 0 ? "it has none" : `its ${what}s are ${Object.keys(fields).join(", ")}`} (vue-component-props-valid)`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
98
|
+
if (!(key in value)) {
|
|
99
|
+
if (!field.optional) {
|
|
100
|
+
problems.push({
|
|
101
|
+
path,
|
|
102
|
+
message: `${key} is missing; ${component} declares ${field.source} (vue-component-props-valid)`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
problems.push(...valueProblems(value[key], field.type, [...path, key], component, declaration ?? field.source));
|
|
108
|
+
}
|
|
109
|
+
return problems;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `value`, a `type`, with each { data: <name> } where a list is declared replaced by the records of src/data/<name>.yaml,
|
|
114
|
+
* cut to the fields of the declared item type; a file that does not exist is a problem at the reference.
|
|
115
|
+
*/
|
|
116
|
+
function dataIn(
|
|
117
|
+
value: unknown,
|
|
118
|
+
type: PropType,
|
|
119
|
+
files: Readonly<Record<string, readonly object[]>>,
|
|
120
|
+
path: readonly (string | number)[],
|
|
121
|
+
problems: PropProblem[],
|
|
122
|
+
): unknown {
|
|
123
|
+
if (type.kind === "array" && Array.isArray(value)) {
|
|
124
|
+
return value.map((item, index) => dataIn(item, type.of, files, [...path, index], problems));
|
|
125
|
+
}
|
|
126
|
+
if (type.kind === "array" && typeof value === "object" && value !== null) {
|
|
127
|
+
const keys = Object.keys(value);
|
|
128
|
+
const name = (value as { data?: unknown }).data;
|
|
129
|
+
if (keys.length !== 1 || typeof name !== "string") return value;
|
|
130
|
+
const records = files[name];
|
|
131
|
+
if (records === undefined) {
|
|
132
|
+
problems.push({ path, message: `src/data/${name}.yaml does not exist (vue-component-props-valid)` });
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
const item = type.of;
|
|
136
|
+
return item.kind !== "object"
|
|
137
|
+
? records
|
|
138
|
+
: records.map((record) =>
|
|
139
|
+
Object.fromEntries(
|
|
140
|
+
Object.keys(item.fields).flatMap((key) =>
|
|
141
|
+
key in record ? [[key, (record as Record<string, unknown>)[key]]] : [],
|
|
142
|
+
),
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (type.kind === "object" && typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
147
|
+
return Object.fromEntries(
|
|
148
|
+
Object.entries(value).map(([key, item]) => {
|
|
149
|
+
const field = type.fields[key];
|
|
150
|
+
return [key, field === undefined ? item : dataIn(item, field.type, files, [...path, key], problems)];
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A block's `props` with the data references replaced by their records, and the problems of the references. */
|
|
158
|
+
export function withData(
|
|
159
|
+
props: unknown,
|
|
160
|
+
component: ComponentInfo,
|
|
161
|
+
files: Readonly<Record<string, readonly object[]>>,
|
|
162
|
+
): { props: unknown; problems: PropProblem[] } {
|
|
163
|
+
const problems: PropProblem[] = [];
|
|
164
|
+
const resolved = dataIn(props, { kind: "object", fields: component.props }, files, [], problems);
|
|
165
|
+
return { props: resolved, problems };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The problems of a block's `props` against the props of `component`. */
|
|
169
|
+
export function propsProblems(props: unknown, component: ComponentInfo): PropProblem[] {
|
|
170
|
+
if (props !== undefined && (typeof props !== "object" || props === null || Array.isArray(props))) {
|
|
171
|
+
return [{ path: [], message: `the props of ${component.name} are a map of its prop names to values` }];
|
|
172
|
+
}
|
|
173
|
+
return fieldProblems((props ?? {}) as Record<string, unknown>, component.props, [], component.name);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The paths of the learner texts in `value`, a `type`: each string prop, whose languages the build checks. */
|
|
177
|
+
export function textPaths(
|
|
178
|
+
value: unknown,
|
|
179
|
+
type: PropType,
|
|
180
|
+
path: readonly (string | number)[] = [],
|
|
181
|
+
): (string | number)[][] {
|
|
182
|
+
switch (type.kind) {
|
|
183
|
+
case "string":
|
|
184
|
+
return [[...path]];
|
|
185
|
+
case "array":
|
|
186
|
+
return Array.isArray(value) ? value.flatMap((item, index) => textPaths(item, type.of, [...path, index])) : [];
|
|
187
|
+
case "object":
|
|
188
|
+
return typeof value === "object" && value !== null
|
|
189
|
+
? Object.entries(type.fields).flatMap(([key, field]) =>
|
|
190
|
+
key in value ? textPaths((value as Record<string, unknown>)[key], field.type, [...path, key]) : [],
|
|
191
|
+
)
|
|
192
|
+
: [];
|
|
193
|
+
case "union": {
|
|
194
|
+
const member = type.of.find((candidate) => matches(value, candidate));
|
|
195
|
+
return member === undefined ? [] : textPaths(value, member, path);
|
|
196
|
+
}
|
|
197
|
+
default:
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** `value`, a `type`, with each learner text in `lang`. */
|
|
203
|
+
function localize(value: unknown, type: PropType, lang: string): unknown {
|
|
204
|
+
switch (type.kind) {
|
|
205
|
+
case "string":
|
|
206
|
+
return inLanguage(value as Text, lang);
|
|
207
|
+
case "array":
|
|
208
|
+
return (value as unknown[]).map((item) => localize(item, type.of, lang));
|
|
209
|
+
case "object":
|
|
210
|
+
return Object.fromEntries(
|
|
211
|
+
Object.entries(value as Record<string, unknown>).map(([key, item]) => {
|
|
212
|
+
const field = type.fields[key];
|
|
213
|
+
return [key, field === undefined ? item : localize(item, field.type, lang)];
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
case "union": {
|
|
217
|
+
const member = type.of.find((candidate) => matches(value, candidate));
|
|
218
|
+
return member === undefined ? value : localize(value, member, lang);
|
|
219
|
+
}
|
|
220
|
+
default:
|
|
221
|
+
return value;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A block's props as the component gets them on a page in `lang`. */
|
|
226
|
+
export function propsIn(props: unknown, component: ComponentInfo, lang: string): Record<string, unknown> {
|
|
227
|
+
return localize(props ?? {}, { kind: "object", fields: component.props }, lang) as Record<string, unknown>;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The logic module of an exercise component; describe and about, which readComponents requires, for describedProblems. */
|