@lernapps/app-templates 0.0.0-stage → 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.
Files changed (231) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +195 -2
  3. package/dist/appname-Bh23cUuz.mjs +12 -0
  4. package/dist/assessment-By2GGTJC.mjs +1696 -0
  5. package/dist/block-BK_ohsH1.mjs +82 -0
  6. package/dist/blocks-BmRrD7UY.mjs +149 -0
  7. package/dist/blocks.mjs +4 -0
  8. package/dist/check-BkEl-m-F.mjs +254 -0
  9. package/dist/content-cYBPYg_J.mjs +140 -0
  10. package/dist/contract-PONpxJtd.mjs +52 -0
  11. package/dist/e2e-BiMI-pCL.mjs +89 -0
  12. package/dist/engine-BqF2Vffn.mjs +136 -0
  13. package/dist/explainer-e2e.mjs +1297 -0
  14. package/dist/explainer-islands.mjs +197 -0
  15. package/dist/explainer-plugin.mjs +2131 -0
  16. package/dist/explainer.mjs +1246 -0
  17. package/dist/html-mffkHGHh.mjs +89 -0
  18. package/dist/image-CspJVfyQ.mjs +21 -0
  19. package/dist/interactive-e2e.mjs +140 -0
  20. package/dist/interactive-plugin.mjs +242 -0
  21. package/dist/interactive.mjs +75 -0
  22. package/dist/load-C-0hUaSC.mjs +16947 -0
  23. package/dist/needs-BzEahJ6X.mjs +97 -0
  24. package/dist/pages-9RaaomoO.mjs +81 -0
  25. package/dist/questions-e2e.mjs +1 -0
  26. package/dist/questions-engine.mjs +3 -0
  27. package/dist/questions.mjs +6 -0
  28. package/dist/quiz-BKc8JYtC.mjs +70 -0
  29. package/dist/quiz-e2e.mjs +174 -0
  30. package/dist/quiz-plugin.mjs +266 -0
  31. package/dist/quiz.mjs +246 -0
  32. package/dist/render-BJey0bwl.mjs +540 -0
  33. package/dist/render-BL6fV_NI.mjs +507 -0
  34. package/dist/render-DpRO_RKm.mjs +146 -0
  35. package/dist/result-_eQcamoz.mjs +180 -0
  36. package/dist/tasks-DADUYy52.mjs +74 -0
  37. package/dist/validate-COaZso7u.mjs +1072 -0
  38. package/dist/validate-CxtdeQ5S.mjs +146 -0
  39. package/dist/vue-DO1m6q1R.mjs +40 -0
  40. package/dist/vue-code-BkJjW_I_.mjs +320 -0
  41. package/docs/arc42/01-introduction-and-goals.arc42.md +82 -0
  42. package/docs/arc42/02-constraints.arc42.md +228 -0
  43. package/docs/arc42/03-system-context.arc42.md +118 -0
  44. package/docs/arc42/04-solution-strategy.arc42.md +47 -0
  45. package/docs/arc42/05-building-blocks.arc42.md +307 -0
  46. package/docs/arc42/06-runtime-view.arc42.md +98 -0
  47. package/docs/arc42/07-deployment-view.arc42.md +160 -0
  48. package/docs/arc42/08-concepts.arc42.md +123 -0
  49. package/docs/arc42/09-decisions.arc42.md +246 -0
  50. package/docs/arc42/10-quality-requirements.arc42.md +155 -0
  51. package/docs/arc42/11-risks.arc42.md +113 -0
  52. package/docs/arc42/12-glossary.arc42.md +162 -0
  53. package/docs/arc42/architecture-evidence.md +395 -0
  54. package/explainer/.arc42ignore +3 -0
  55. package/explainer/README.md +54 -0
  56. package/explainer/_gitignore +5 -0
  57. package/explainer/docs/arc42/03-explainer-system-context.arc42.md +35 -0
  58. package/explainer/docs/arc42/05-explainer-building-blocks.arc42.md +659 -0
  59. package/explainer/docs/arc42/06-explainer-runtime-view.arc42.md +105 -0
  60. package/explainer/docs/arc42/07-explainer-deployment-view.arc42.md +34 -0
  61. package/explainer/docs/arc42/08-explainer-concepts.arc42.md +17 -0
  62. package/explainer/docs/arc42/09-explainer-decisions.arc42.md +443 -0
  63. package/explainer/docs/arc42/10-explainer-quality-requirements.arc42.md +54 -0
  64. package/explainer/docs/arc42/11-explainer-risks.arc42.md +17 -0
  65. package/explainer/docs/arc42/12-explainer-glossary.arc42.md +185 -0
  66. package/explainer/e2e/explainer.e2e.ts +3 -0
  67. package/explainer/index.html +11 -0
  68. package/explainer/package.json +18 -0
  69. package/explainer/playwright.config.ts +1 -0
  70. package/explainer/src/app.yaml +20 -0
  71. package/explainer/src/components/ProzentFeld.test.ts +30 -0
  72. package/explainer/src/components/ProzentFeld.ts +121 -0
  73. package/explainer/src/components/ProzentFeld.vue +84 -0
  74. package/explainer/src/images/hunderterfeld-klammern.svg +105 -0
  75. package/explainer/src/main.ts +4 -0
  76. package/explainer/src/prozent/chapter.yaml +18 -0
  77. package/explainer/src/prozent/grundwert.yaml +115 -0
  78. package/explainer/src/prozent/prozentsatz.yaml +99 -0
  79. package/explainer/src/prozent/prozentwert.yaml +119 -0
  80. package/explainer/tsconfig.json +1 -0
  81. package/explainer/vite.config.ts +4 -0
  82. package/interactive/.arc42ignore +5 -0
  83. package/interactive/README.md +28 -0
  84. package/interactive/_gitignore +4 -0
  85. package/interactive/docs/arc42/05-interactive-building-blocks.arc42.md +518 -0
  86. package/interactive/docs/arc42/06-interactive-runtime-view.arc42.md +58 -0
  87. package/interactive/docs/arc42/07-interactive-deployment-view.arc42.md +35 -0
  88. package/interactive/docs/arc42/08-interactive-concepts.arc42.md +52 -0
  89. package/interactive/docs/arc42/09-interactive-decisions.arc42.md +130 -0
  90. package/interactive/docs/arc42/10-interactive-quality-requirements.arc42.md +45 -0
  91. package/interactive/docs/arc42/11-interactive-risks.arc42.md +20 -0
  92. package/interactive/docs/arc42/12-interactive-glossary.arc42.md +80 -0
  93. package/interactive/e2e/interactive.e2e.ts +3 -0
  94. package/interactive/index.html +11 -0
  95. package/interactive/package.json +18 -0
  96. package/interactive/playwright.config.ts +1 -0
  97. package/interactive/src/app.yaml +28 -0
  98. package/interactive/src/core/index.ts +101 -0
  99. package/interactive/src/data/lessons.json +4 -0
  100. package/interactive/src/env.d.ts +6 -0
  101. package/interactive/src/main.ts +6 -0
  102. package/interactive/src/messages/de.json +12 -0
  103. package/interactive/src/playthroughs/grundreihe.yaml +33 -0
  104. package/interactive/src/playthroughs/obere-reihe.yaml +14 -0
  105. package/interactive/src/ui/App.vue +85 -0
  106. package/interactive/tsconfig.json +1 -0
  107. package/interactive/vite.config.ts +4 -0
  108. package/package.json +144 -4
  109. package/quiz/README.md +31 -0
  110. package/quiz/_gitignore +5 -0
  111. package/quiz/docs/arc42/05-quiz-building-blocks.arc42.md +283 -0
  112. package/quiz/docs/arc42/06-quiz-runtime-view.arc42.md +21 -0
  113. package/quiz/docs/arc42/07-quiz-deployment-view.arc42.md +33 -0
  114. package/quiz/docs/arc42/09-quiz-decisions.arc42.md +36 -0
  115. package/quiz/docs/arc42/10-quiz-quality-requirements.arc42.md +53 -0
  116. package/quiz/docs/arc42/11-quiz-risks.arc42.md +19 -0
  117. package/quiz/docs/arc42/12-quiz-glossary.arc42.md +27 -0
  118. package/quiz/e2e/quiz.e2e.ts +3 -0
  119. package/quiz/index.html +11 -0
  120. package/quiz/package.json +18 -0
  121. package/quiz/playwright.config.ts +1 -0
  122. package/quiz/src/images/berge-ordnen.jpg +0 -0
  123. package/quiz/src/images/everest-hoehe.jpg +0 -0
  124. package/quiz/src/images/galapagos.jpg +0 -0
  125. package/quiz/src/images/grand-canyon.jpg +0 -0
  126. package/quiz/src/images/totes-meer.jpg +0 -0
  127. package/quiz/src/images/uluru.jpg +0 -0
  128. package/quiz/src/images/wasserfaelle-laender.jpg +0 -0
  129. package/quiz/src/main.ts +4 -0
  130. package/quiz/src/quiz.json +312 -0
  131. package/quiz/tsconfig.json +1 -0
  132. package/quiz/vite.config.ts +4 -0
  133. package/runtime/explainer/assessment-client.ts +478 -0
  134. package/runtime/explainer/assessment-page.ts +446 -0
  135. package/runtime/explainer/assessment.ts +393 -0
  136. package/runtime/explainer/check-page.ts +520 -0
  137. package/runtime/explainer/components/exercise.ts +409 -0
  138. package/runtime/explainer/components/props.ts +230 -0
  139. package/runtime/explainer/components/read.ts +428 -0
  140. package/runtime/explainer/content.ts +277 -0
  141. package/runtime/explainer/e2e.ts +1474 -0
  142. package/runtime/explainer/exercises.ts +324 -0
  143. package/runtime/explainer/explainer.css +1283 -0
  144. package/runtime/explainer/explainer.ts +838 -0
  145. package/runtime/explainer/explainer.v1.schema.json +1165 -0
  146. package/runtime/explainer/expression.ts +407 -0
  147. package/runtime/explainer/fraction.ts +107 -0
  148. package/runtime/explainer/generated/comparator.ts +31 -0
  149. package/runtime/explainer/generated/question.ts +165 -0
  150. package/runtime/explainer/generated/soundness.ts +280 -0
  151. package/runtime/explainer/generated/texts.ts +257 -0
  152. package/runtime/explainer/generated/types.ts +103 -0
  153. package/runtime/explainer/generated/values.ts +136 -0
  154. package/runtime/explainer/islands.ts +281 -0
  155. package/runtime/explainer/load.ts +457 -0
  156. package/runtime/explainer/messages/de.json +200 -0
  157. package/runtime/explainer/messages/en.json +200 -0
  158. package/runtime/explainer/messages.ts +13 -0
  159. package/runtime/explainer/needs.ts +122 -0
  160. package/runtime/explainer/open-task.ts +235 -0
  161. package/runtime/explainer/outline.ts +299 -0
  162. package/runtime/explainer/plugin.ts +488 -0
  163. package/runtime/explainer/position.ts +62 -0
  164. package/runtime/explainer/prompt.ts +75 -0
  165. package/runtime/explainer/render.ts +686 -0
  166. package/runtime/explainer/result.ts +238 -0
  167. package/runtime/explainer/routes.ts +119 -0
  168. package/runtime/explainer/schema.ts +140 -0
  169. package/runtime/explainer/scope.ts +87 -0
  170. package/runtime/explainer/stuck.ts +57 -0
  171. package/runtime/explainer/tasks.ts +109 -0
  172. package/runtime/explainer/tutor.ts +469 -0
  173. package/runtime/explainer/tutorpage.ts +669 -0
  174. package/runtime/explainer/video.ts +123 -0
  175. package/runtime/interactive/contract.ts +121 -0
  176. package/runtime/interactive/e2e.ts +190 -0
  177. package/runtime/interactive/interactive.css +82 -0
  178. package/runtime/interactive/interactive.ts +115 -0
  179. package/runtime/interactive/interactive.v1.schema.json +146 -0
  180. package/runtime/interactive/load.ts +188 -0
  181. package/runtime/interactive/messages/de.json +14 -0
  182. package/runtime/interactive/messages/en.json +14 -0
  183. package/runtime/interactive/messages.ts +11 -0
  184. package/runtime/interactive/playthrough.v1.schema.json +143 -0
  185. package/runtime/interactive/plugin.ts +266 -0
  186. package/runtime/interactive/render.ts +126 -0
  187. package/runtime/quiz/e2e.ts +233 -0
  188. package/runtime/quiz/engine.ts +242 -0
  189. package/runtime/quiz/messages/de.json +23 -0
  190. package/runtime/quiz/messages/en.json +23 -0
  191. package/runtime/quiz/plugin.ts +150 -0
  192. package/runtime/quiz/quiz.css +154 -0
  193. package/runtime/quiz/quiz.ts +56 -0
  194. package/runtime/quiz/quiz.v1.schema.json +72 -0
  195. package/runtime/quiz/render.ts +173 -0
  196. package/runtime/quiz/validate.ts +108 -0
  197. package/runtime/shared/appname.ts +16 -0
  198. package/runtime/shared/blocks/block.ts +162 -0
  199. package/runtime/shared/blocks/blocks.css +190 -0
  200. package/runtime/shared/blocks/blocks.v1.schema.json +432 -0
  201. package/runtime/shared/blocks/code.ts +26 -0
  202. package/runtime/shared/blocks/figures.ts +49 -0
  203. package/runtime/shared/blocks/image.ts +25 -0
  204. package/runtime/shared/blocks/index.ts +13 -0
  205. package/runtime/shared/blocks/messages/de.json +4 -0
  206. package/runtime/shared/blocks/messages/en.json +4 -0
  207. package/runtime/shared/blocks/render.ts +134 -0
  208. package/runtime/shared/blocks/validate.ts +165 -0
  209. package/runtime/shared/content.ts +197 -0
  210. package/runtime/shared/examples.ts +20 -0
  211. package/runtime/shared/html.ts +10 -0
  212. package/runtime/shared/languages.ts +94 -0
  213. package/runtime/shared/page.css +101 -0
  214. package/runtime/shared/pages.ts +114 -0
  215. package/runtime/shared/questions/e2e.ts +115 -0
  216. package/runtime/shared/questions/engine.ts +209 -0
  217. package/runtime/shared/questions/index.ts +39 -0
  218. package/runtime/shared/questions/messages/de.json +26 -0
  219. package/runtime/shared/questions/messages/en.json +26 -0
  220. package/runtime/shared/questions/question.ts +430 -0
  221. package/runtime/shared/questions/question.v1.schema.json +342 -0
  222. package/runtime/shared/questions/questions.css +192 -0
  223. package/runtime/shared/questions/render.ts +247 -0
  224. package/runtime/shared/questions/validate.ts +518 -0
  225. package/runtime/shared/schema.ts +44 -0
  226. package/runtime/shared/text/check.ts +68 -0
  227. package/runtime/shared/text/formula.ts +69 -0
  228. package/runtime/shared/text/markdown.ts +185 -0
  229. package/runtime/shared/text/text.v1.schema.json +34 -0
  230. package/runtime/shared/vue-code.ts +395 -0
  231. package/runtime/shared/vue.ts +38 -0
@@ -0,0 +1,518 @@
1
+ // Validates one question at build time, for every archetype that has questions: against its JSON Schema
2
+ // (question.v1.schema.json), then what a schema cannot say (one correct option in a single choice, unique options,
3
+ // ...), and that the check judges each of the question's examples as it is listed: accepted or rejected. Each problem
4
+ // names the question and what to change, for the assistant that wrote it. validateQuestion works on one question in
5
+ // any place, e.g. `questions[2]` in a quiz's bank or `blocks[3]` on an explainer's page.
6
+ // A question in several languages declares them (languages, German first): each of its learner texts must then be a
7
+ // map with each of them and no other, with the messages of the language check; so must a link's address that is a map
8
+ // by language (a single address is the same in every language).
9
+ import { Ajv2020, type ErrorObject } from "ajv/dist/2020.js";
10
+ import { verdict } from "../examples.ts";
11
+ import { checkMaterial, MATERIAL_DEFINITIONS, type AppChecks } from "../blocks/validate.ts";
12
+ import { knownProperties, schemasById } from "../schema.ts";
13
+ import { textLanguages } from "../languages.ts";
14
+ import type { Text } from "../languages.ts";
15
+ import blocksSchema from "../blocks/blocks.v1.schema.json" with { type: "json" };
16
+ import textSchema from "../text/text.v1.schema.json" with { type: "json" };
17
+ import schema from "./question.v1.schema.json" with { type: "json" };
18
+ import {
19
+ isComplete,
20
+ isCorrect,
21
+ parseNumber,
22
+ questionAddresses,
23
+ questionIn,
24
+ questionTexts,
25
+ rightEntries,
26
+ type Answer,
27
+ type Examples,
28
+ type Matching,
29
+ type Question,
30
+ } from "./question.ts";
31
+
32
+ /** The id of the schema of a question, to refer to it from another schema (with `questionSchemas` added to its validator). */
33
+ export const QUESTION_SCHEMA_ID = schema.$id;
34
+ /** The reference to one question in the schema of a question, for a `$ref` from another schema. */
35
+ export const QUESTION_REF = `${QUESTION_SCHEMA_ID}#/$defs/question`;
36
+ /** The schema of a question (question.v1.schema.json), to add to another schema's validator. */
37
+ export const questionSchema = schema;
38
+ /** The schemas a question refers to, and its own: every validator of a schema that refers to a question adds them. */
39
+ export const questionSchemas = [textSchema, blocksSchema, schema] as const;
40
+ const schemas = schemasById(...questionSchemas);
41
+
42
+ /** The definition of the fields of each type of question in its schema. */
43
+ export const QUESTION_BRANCH: Readonly<Record<string, string>> = {
44
+ "single-choice": "choice",
45
+ "multiple-choice": "choice",
46
+ "true-false": "trueFalse",
47
+ number: "number",
48
+ ordering: "ordering",
49
+ matching: "matching",
50
+ };
51
+
52
+ const ajv = new Ajv2020({ allErrors: true, strict: true });
53
+ for (const each of questionSchemas) ajv.addSchema(each);
54
+ const validateOne = (() => {
55
+ const validate = ajv.getSchema<Question>(QUESTION_REF);
56
+ if (validate === undefined) throw new Error(`the question's schema has no ${QUESTION_REF}`);
57
+ return validate;
58
+ })();
59
+
60
+ /** `["options", "1", "text"]` as `.options[1].text`. */
61
+ export const readable = (parts: readonly string[]) =>
62
+ parts.map((part) => (/^\d+$/.test(part) ? `[${part}]` : `.${part}`)).join("");
63
+
64
+ /** Where something in a question is: the question at `at`, the path below it, and the question's id if it has one. */
65
+ export function inQuestion(at: string, parts: readonly string[], question: unknown): string {
66
+ const id =
67
+ typeof question === "object" && question !== null && "id" in question && typeof question.id === "string"
68
+ ? question.id
69
+ : undefined;
70
+ return id === undefined ? `${at}${readable(parts)}` : `${at}${readable(parts)} (question "${id}")`;
71
+ }
72
+
73
+ /** `/questions/2/options/1/text` as `questions[2].options[1].text`, with the id of the question. */
74
+
75
+ function valueAt(root: unknown, pointer: string): unknown {
76
+ return pointer
77
+ .split("/")
78
+ .slice(1)
79
+ .reduce<unknown>(
80
+ (at, part) => (typeof at === "object" && at !== null ? (at as Record<string, unknown>)[part] : undefined),
81
+ root,
82
+ );
83
+ }
84
+
85
+ /** A learner text that is neither a text nor a map of texts. */
86
+ function textMessage(value: unknown, at: string): string {
87
+ if (typeof value === "string") return `${at}: must not be empty`;
88
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
89
+ const bad = Object.entries(value).find(
90
+ ([lang, text]) => !/^[a-z]{2}$/.test(lang) || typeof text !== "string" || text.trim() === "",
91
+ );
92
+ if (bad !== undefined) {
93
+ return /^[a-z]{2}$/.test(bad[0])
94
+ ? `${at} (${bad[0]}): must not be empty`
95
+ : `${at}: ${bad[0]} is not a language code; use two letters, e.g. de, en`;
96
+ }
97
+ }
98
+ return `${at}: must be a text, or a map of texts by language: { de: …, en: … }`;
99
+ }
100
+
101
+ /**
102
+ * What is wrong with a link's address that is neither an https address nor a map of them by language: in which
103
+ * language, when it is one of a map. Also for the explainer, which embeds the quiz's question types.
104
+ */
105
+ export function addressProblem(value: unknown): { lang?: string; message: string } {
106
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
107
+ for (const [lang, address] of Object.entries(value)) {
108
+ if (!/^[a-z]{2}$/.test(lang)) return { message: `${lang} is not a language code; use two letters, e.g. de, en` };
109
+ if (typeof address !== "string" || !/^https:\/\/\S+$/.test(address)) {
110
+ return { lang, message: "must be an https address" };
111
+ }
112
+ }
113
+ }
114
+ return {
115
+ message: "must be an https address, or a map of https addresses by language: { de: https://…, en: https://… }",
116
+ };
117
+ }
118
+
119
+ function schemaMessage(error: ErrorObject, at: string): string | undefined {
120
+ switch (error.keyword) {
121
+ // reported by the branch of the question's type, which says more
122
+ case "if":
123
+ return undefined;
124
+ case "required":
125
+ return `${at}: ${String(error.params["missingProperty"])} is missing`;
126
+ case "additionalProperties":
127
+ return `${at}: ${String(error.params["additionalProperty"])} is not allowed here`;
128
+ case "unevaluatedProperties":
129
+ return `${at}: ${String(error.params["unevaluatedProperty"])} is not allowed ${/\/material\/\d+$/.test(error.instancePath) ? "in this type of block" : "for this type of question"}`;
130
+ case "enum":
131
+ return `${at}: must be one of ${(error.params["allowedValues"] as unknown[]).join(", ")}`;
132
+ case "pattern":
133
+ return error.instancePath.endsWith("/id")
134
+ ? `${at}: use lower-case letters, digits and hyphens, e.g. "brueche-kuerzen"`
135
+ : `${at}: must not be empty`;
136
+ default:
137
+ return `${at}: ${error.message ?? "is invalid"}`;
138
+ }
139
+ }
140
+
141
+ /** The messages of the schema's errors, each at the place `where` names for its path. */
142
+ export function schemaMessages(
143
+ errors: readonly ErrorObject[],
144
+ where: (path: string) => string,
145
+ root: unknown,
146
+ ): string[] {
147
+ // a learner text or an address (each also a map by language): one message, not one per branch
148
+ const texts = new Set(errors.filter((error) => error.keyword === "anyOf").map((error) => error.instancePath));
149
+ const underText = (path: string) => [...texts].some((text) => path === text || path.startsWith(`${text}/`));
150
+ // a question of an unknown type: say that, not every property its type would not allow
151
+ const unknownType = new Set(
152
+ errors
153
+ .filter((error) => error.keyword === "enum" && error.instancePath.endsWith("/type"))
154
+ .map((error) => error.instancePath.slice(0, -"/type".length)),
155
+ );
156
+ // a property its type knows, with an error elsewhere in its branch: that error says what is wrong
157
+ const isKnown = (error: ErrorObject): boolean => {
158
+ if (error.keyword !== "unevaluatedProperties") return false;
159
+ const value = valueAt(root, error.instancePath);
160
+ const type = typeof value === "object" && value !== null && "type" in value ? String(value.type) : "";
161
+ const property = String(error.params["unevaluatedProperty"]);
162
+ const material = /\/material\/\d+$/.test(error.instancePath)
163
+ ? MATERIAL_DEFINITIONS[type as keyof typeof MATERIAL_DEFINITIONS]
164
+ : undefined;
165
+ if (material !== undefined) return knownProperties(schemas, blocksSchema.$id, material).has(property);
166
+ const branch = QUESTION_BRANCH[type];
167
+ return (
168
+ branch !== undefined &&
169
+ (knownProperties(schemas, schema.$id, "question", { branches: false }).has(property) ||
170
+ knownProperties(schemas, schema.$id, branch).has(property))
171
+ );
172
+ };
173
+ return errors
174
+ .filter(
175
+ (error) =>
176
+ !(error.keyword === "unevaluatedProperties" && (unknownType.has(error.instancePath) || isKnown(error))),
177
+ )
178
+ .filter((error) => error.keyword === "anyOf" || !underText(error.instancePath))
179
+ .flatMap((error) => {
180
+ if (error.keyword !== "anyOf") return schemaMessage(error, where(error.instancePath)) ?? [];
181
+ const value = valueAt(root, error.instancePath);
182
+ if (!error.instancePath.endsWith("/url")) return textMessage(value, where(error.instancePath));
183
+ const { lang, message } = addressProblem(value);
184
+ return `${where(error.instancePath)}${lang === undefined ? "" : ` (${lang})`}: ${message}`;
185
+ });
186
+ }
187
+
188
+ export function duplicates(values: readonly string[]): string[] {
189
+ return [...new Set(values.filter((value, index) => values.indexOf(value) !== index))];
190
+ }
191
+
192
+ /** A problem of one question: where in it (its path; [] for the whole question), in which language, and what. */
193
+ export interface QuestionProblem {
194
+ path: (string | number)[];
195
+ lang?: string;
196
+ /** In Markdown, the line below its first. */
197
+ line?: number;
198
+ message: string;
199
+ }
200
+
201
+ /** Where the pairs of a matching question come from: written, or taken from the labelled figure of its material. */
202
+ function pairsSourceProblems(question: Matching<Text>): QuestionProblem[] {
203
+ const written = (question.pairs as unknown) !== undefined;
204
+ if (question.pairsFrom === undefined) {
205
+ return written
206
+ ? []
207
+ : [
208
+ {
209
+ path: ["pairs"],
210
+ message:
211
+ "pairs is missing; write the pairs, or take them from the labelled figure of the material with pairsFrom: material",
212
+ },
213
+ ];
214
+ }
215
+ if (written) {
216
+ return [
217
+ {
218
+ path: ["pairsFrom"],
219
+ message: "pairs and pairsFrom: write the pairs, or take them from the material, not both",
220
+ },
221
+ ];
222
+ }
223
+ const index = question.material?.findIndex((block) => block.type === "labelled-figure") ?? -1;
224
+ const figure = question.material?.[index];
225
+ if (figure?.type !== "labelled-figure") {
226
+ return [{ path: ["pairsFrom"], message: "the material has no labelled figure; add one, or write the pairs" }];
227
+ }
228
+ const problems: QuestionProblem[] = [];
229
+ if (figure.legend !== false) {
230
+ problems.push({
231
+ path: ["material", index, "legend"],
232
+ message:
233
+ "the legend of the figure lists the answers before the question is answered; write legend: false, the solution shows them",
234
+ });
235
+ }
236
+ if (figure.labels.length < 2 || figure.labels.length > 8) {
237
+ problems.push({
238
+ path: ["pairsFrom"],
239
+ message: `the figure has ${figure.labels.length} labels; a matching question takes 2 to 8 pairs`,
240
+ });
241
+ }
242
+ return problems;
243
+ }
244
+
245
+ /** What the schema cannot say about one question, its texts in German. */
246
+ function questionProblems(question: Question): QuestionProblem[] {
247
+ const problems: QuestionProblem[] = [];
248
+ const twice = (what: string, path: string, values: readonly string[]) => {
249
+ for (const value of duplicates(values))
250
+ problems.push({ path: [path], message: `the ${what} "${value}" appears twice` });
251
+ };
252
+ switch (question.type) {
253
+ case "single-choice": {
254
+ const correct = question.options.filter((option) => option.correct).length;
255
+ if (correct !== 1) {
256
+ problems.push({
257
+ path: ["options"],
258
+ message: `a single-choice question needs exactly one correct option; it has ${correct}. Mark one option "correct": true, or make it "multiple-choice"`,
259
+ });
260
+ }
261
+ twice(
262
+ "option",
263
+ "options",
264
+ question.options.map((option) => option.text),
265
+ );
266
+ break;
267
+ }
268
+ case "multiple-choice":
269
+ if (!question.options.some((option) => option.correct)) {
270
+ problems.push({
271
+ path: ["options"],
272
+ message: "a multiple-choice question needs at least one correct option; it has none",
273
+ });
274
+ }
275
+ twice(
276
+ "option",
277
+ "options",
278
+ question.options.map((option) => option.text),
279
+ );
280
+ break;
281
+ case "ordering":
282
+ twice("item", "items", question.items);
283
+ break;
284
+ case "matching":
285
+ twice(
286
+ "left entry",
287
+ "pairs",
288
+ question.pairs.map((pair) => pair.left),
289
+ );
290
+ twice("right entry", "pairs", rightEntries(question));
291
+ break;
292
+ case "number":
293
+ question.mistakes?.forEach((mistake, index) => {
294
+ if (Math.abs(mistake.answer - question.answer) <= question.tolerance + 1e-9) {
295
+ problems.push({
296
+ path: ["mistakes", index, "answer"],
297
+ message: `the mistake ${mistake.answer} counts as right: it is within the tolerance of the answer ${question.answer}; give the wrong number the mistake leads to`,
298
+ });
299
+ }
300
+ });
301
+ for (const value of duplicates((question.mistakes ?? []).map((mistake) => String(mistake.answer)))) {
302
+ problems.push({
303
+ path: ["mistakes"],
304
+ message: `the mistake ${value} appears twice; list each wrong number once`,
305
+ });
306
+ }
307
+ break;
308
+ case "true-false":
309
+ break;
310
+ }
311
+ return problems;
312
+ }
313
+
314
+ /** An example's answer, or what is wrong with it. */
315
+ type ExampleAnswer = { answer: Answer } | { problem: string };
316
+
317
+ /** The index of an option, item or entry given as its index from 0 or as its text in German; or what is wrong. */
318
+ function indexOf(value: unknown, texts: readonly string[], what: string): number | string {
319
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value < texts.length) return value;
320
+ if (typeof value === "string" && texts.includes(value)) return texts.indexOf(value);
321
+ const given = typeof value === "string" ? `"${value}"` : JSON.stringify(value);
322
+ return `${given} is not ${what}; give its index from 0 to ${texts.length - 1}, or its text as written (in German)`;
323
+ }
324
+
325
+ /** Indices of `values`, each in `texts`; or the first problem. */
326
+ function indicesOf(values: readonly unknown[], texts: readonly string[], what: string): number[] | string {
327
+ const found = values.map((value) => indexOf(value, texts, what));
328
+ const problem = found.find((value) => typeof value === "string");
329
+ return typeof problem === "string" ? problem : (found as number[]);
330
+ }
331
+
332
+ /**
333
+ * An example as the checker receives a learner's answer to `question` (in German): single choice an option, multiple
334
+ * choice a list of options, true/false a boolean, number a number or the text a learner types, ordering the items in
335
+ * the learner's order, matching a list of pairs [left, right]; an option, item or entry by its index from 0 or its
336
+ * text. A complete answer, as the page only checks one.
337
+ */
338
+ function exampleAnswer(question: Question, example: unknown): ExampleAnswer {
339
+ const expect = (what: string) => ({ problem: `give ${what}` });
340
+ let answer: Answer;
341
+ switch (question.type) {
342
+ case "single-choice": {
343
+ const index = indexOf(
344
+ example,
345
+ question.options.map((option) => option.text),
346
+ "an option",
347
+ );
348
+ if (typeof index === "string") return { problem: index };
349
+ answer = index;
350
+ break;
351
+ }
352
+ case "multiple-choice": {
353
+ if (!Array.isArray(example)) return expect("the chosen options as a list: [0, 2]");
354
+ const indices = indicesOf(
355
+ example,
356
+ question.options.map((option) => option.text),
357
+ "an option",
358
+ );
359
+ if (typeof indices === "string") return { problem: indices };
360
+ answer = [...new Set(indices)].sort((a, b) => a - b);
361
+ break;
362
+ }
363
+ case "true-false":
364
+ if (typeof example !== "boolean") return expect("true or false");
365
+ answer = example;
366
+ break;
367
+ case "number":
368
+ if (typeof example === "number") answer = String(example);
369
+ else if (typeof example === "string" && parseNumber(example, question.unit) !== undefined) answer = example;
370
+ else return expect('a number, or the text a learner types, e.g. "12,5"');
371
+ break;
372
+ case "ordering": {
373
+ if (!Array.isArray(example)) return expect("the items in the learner's order as a list");
374
+ const order = indicesOf(example, question.items, "an item");
375
+ if (typeof order === "string") return { problem: order };
376
+ if (new Set(order).size !== question.items.length || order.length !== question.items.length) {
377
+ return expect(`each of the ${question.items.length} items once, in the learner's order`);
378
+ }
379
+ // for each item (in the right order), the place the learner gave it
380
+ answer = question.items.map((_item, index) => order.indexOf(index));
381
+ break;
382
+ }
383
+ case "matching": {
384
+ const pairs = Array.isArray(example) ? (example as unknown[]) : [];
385
+ if (!Array.isArray(example) || pairs.some((pair) => !Array.isArray(pair) || pair.length !== 2)) {
386
+ return expect("a list of pairs [left, right]");
387
+ }
388
+ const lefts = indicesOf(
389
+ pairs.map((pair) => (pair as unknown[])[0]),
390
+ question.pairs.map((pair) => pair.left),
391
+ "a left entry",
392
+ );
393
+ if (typeof lefts === "string") return { problem: lefts };
394
+ const rights = indicesOf(
395
+ pairs.map((pair) => (pair as unknown[])[1]),
396
+ rightEntries(question),
397
+ "a right entry",
398
+ );
399
+ if (typeof rights === "string") return { problem: rights };
400
+ if (new Set(lefts).size !== question.pairs.length || lefts.length !== question.pairs.length) {
401
+ return expect(`one pair for each of the ${question.pairs.length} left entries`);
402
+ }
403
+ // for each left entry, the pair whose right entry the learner chose
404
+ answer = question.pairs.map((_pair, index) => rights[lefts.indexOf(index)] ?? -1);
405
+ break;
406
+ }
407
+ }
408
+ return isComplete(question, answer) ? { answer } : expect("a complete answer: the page checks only those");
409
+ }
410
+
411
+ /**
412
+ * Whether each example of `question` (in German) gets the verdict it is listed with from the same check the page
413
+ * uses: an accepted one passes, a rejected one fails. Each problem at the example's place.
414
+ */
415
+ export function exampleProblems(question: Question, examples: Examples | undefined): QuestionProblem[] {
416
+ const problems: QuestionProblem[] = [];
417
+ for (const kind of ["accepted", "rejected"] as const) {
418
+ (examples?.[kind] ?? []).forEach((example, index) => {
419
+ const path = ["examples", kind, index];
420
+ const given = exampleAnswer(question, example);
421
+ if ("problem" in given) {
422
+ problems.push({ path, message: given.problem });
423
+ return;
424
+ }
425
+ const right = isCorrect(question, given.answer);
426
+ if (right === (kind === "accepted")) return;
427
+ problems.push({
428
+ path,
429
+ message: verdict(
430
+ JSON.stringify(example),
431
+ right,
432
+ "the check",
433
+ "change the question (e.g. another type, a tolerance) so that its check",
434
+ ),
435
+ });
436
+ });
437
+ }
438
+ return problems;
439
+ }
440
+
441
+ /**
442
+ * The problems of a question that matches the schema, with their place in it: its texts complete in each of
443
+ * `languages` and in no other (German first, with the messages of the explainer's language check), so its addresses
444
+ * given as maps, then what the schema cannot say. A plain string is German, complete only while German is the only
445
+ * language; a plain address is the same in every language.
446
+ */
447
+ export function questionDetails(
448
+ question: Question<Text>,
449
+ languages: readonly string[] = ["de"],
450
+ declaredIn?: string,
451
+ app?: AppChecks,
452
+ ): QuestionProblem[] {
453
+ const problems: QuestionProblem[] = [];
454
+ if (question.material !== undefined) {
455
+ const found = checkMaterial(question.material, {
456
+ languages,
457
+ ...(declaredIn === undefined ? {} : { declaredIn }),
458
+ // without the app's files, no image exists
459
+ imageExists: app?.imageExists ?? (() => false),
460
+ ...(app?.onLink === undefined ? {} : { onLink: app.onLink }),
461
+ });
462
+ for (const problem of found) problems.push({ ...problem, path: ["material", ...problem.path] });
463
+ }
464
+ if (question.type === "matching") {
465
+ const source = pairsSourceProblems(question);
466
+ if (source.length > 0) return [...problems, ...source];
467
+ }
468
+ for (const { path, text } of questionTexts(question)) {
469
+ for (const problem of textLanguages(text, languages, false, declaredIn).problems) {
470
+ problems.push({ path, lang: problem.lang, message: problem.message });
471
+ }
472
+ }
473
+ for (const { path, text } of questionAddresses(question)) {
474
+ for (const problem of textLanguages(text, languages, true, declaredIn).problems) {
475
+ problems.push({ path, lang: problem.lang, message: problem.message });
476
+ }
477
+ }
478
+ const german = questionIn(question, "de");
479
+ // the problems of pairs taken from the material are not in a place of the question's own
480
+ const own = questionProblems(german).map((problem) =>
481
+ question.type === "matching" && question.pairsFrom !== undefined && problem.path[0] === "pairs"
482
+ ? { ...problem, path: ["pairsFrom"] }
483
+ : problem,
484
+ );
485
+ return [...problems, ...own, ...exampleProblems(german, question.examples)];
486
+ }
487
+
488
+ /** Whether `value` matches the schema of one question; its errors as messages at `at` otherwise. */
489
+ export function questionSchemaProblems(value: unknown, at: string): string[] {
490
+ if (validateOne(value)) return [];
491
+ return schemaMessages(validateOne.errors ?? [], (path) => inQuestion(at, path.split("/").slice(1), value), value);
492
+ }
493
+
494
+ /**
495
+ * The problems of one question; none when it is valid. `at` is where the question is, e.g. `questions[2]` in a bank
496
+ * or `blocks[3]` on a page; each problem starts with it, the path below it and the question's id. `languages` are the
497
+ * declared languages of the page, declared in `declaredIn` (src/app.yaml by default).
498
+ */
499
+ export function validateQuestion(
500
+ value: unknown,
501
+ at: string,
502
+ languages: readonly string[] = ["de"],
503
+ declaredIn?: string,
504
+ app?: AppChecks,
505
+ ): string[] {
506
+ const schemaProblems = questionSchemaProblems(value, at);
507
+ if (schemaProblems.length > 0) return schemaProblems;
508
+ const question = value as Question<Text>;
509
+ // an example is one of many: named by its place
510
+ return questionDetails(question, languages, declaredIn, app).map((problem) => {
511
+ // the path of a text in a language ends in the language; the message names it once
512
+ const path = problem.path.at(-1) === problem.lang ? problem.path.slice(0, -1) : problem.path;
513
+ const named = problem.lang === undefined && problem.path[0] !== "examples" && problem.path[0] !== "material";
514
+ return named
515
+ ? `${at} (question "${question.id}"): ${problem.message}`
516
+ : `${at}${readable(path.map(String))}${problem.lang === undefined ? "" : ` (${problem.lang})`} (question "${question.id}"): ${problem.message}`;
517
+ });
518
+ }
@@ -0,0 +1,44 @@
1
+ // Reading the JSON Schemas of the content for the messages about their errors, at build time: which properties a
2
+ // definition knows, with those of its allOf, its references (within a schema, or into another by its $id) and its
3
+ // branches. A property a definition knows is not "not allowed" when the schema reports an error elsewhere in it.
4
+
5
+ type Definition = { properties?: Record<string, unknown>; allOf?: unknown[]; $ref?: string; then?: unknown };
6
+
7
+ /** The schemas by their $id, each with its definitions. */
8
+ export type Schemas = Readonly<Record<string, { $defs?: unknown }>>;
9
+
10
+ /** The schemas the given ones are made of, by their $id. */
11
+ export const schemasById = (...schemas: readonly { $id: string; $defs?: unknown }[]): Schemas =>
12
+ Object.fromEntries(schemas.map((schema) => [schema.$id, schema]));
13
+
14
+ /**
15
+ * The properties of the definition `name` of the schema `id`, and of everything it refers to; with `branches` false
16
+ * not those of the branches of its allOf (the fields that only one type of a question has).
17
+ */
18
+ export function knownProperties(
19
+ schemas: Schemas,
20
+ id: string,
21
+ name: string,
22
+ { branches = true }: { branches?: boolean } = {},
23
+ ): Set<string> {
24
+ const into = new Set<string>();
25
+ const visit = (definition: unknown, scope: string): void => {
26
+ if (typeof definition !== "object" || definition === null) return;
27
+ const def = definition as Definition;
28
+ for (const key of Object.keys(def.properties ?? {})) into.add(key);
29
+ if (typeof def.$ref === "string") {
30
+ const [target = "", fragment = ""] = def.$ref.split("#");
31
+ const there = target === "" ? scope : target;
32
+ const found = (schemas[there]?.$defs as Record<string, unknown> | undefined)?.[fragment.replace("/$defs/", "")];
33
+ visit(found, there);
34
+ }
35
+ for (const part of def.allOf ?? []) {
36
+ const branch = typeof part === "object" && part !== null && "then" in part;
37
+ if (branch && !branches) continue;
38
+ visit(part, scope);
39
+ if (branch) visit((part as Definition).then, scope);
40
+ }
41
+ };
42
+ visit((schemas[id]?.$defs as Record<string, unknown> | undefined)?.[name], id);
43
+ return into;
44
+ }
@@ -0,0 +1,68 @@
1
+ // The checks of one learner text at build time, for the content of every archetype: its languages (a text is a string
2
+ // while German is the only language, else a map complete in each declared language and in no other), and, by what the
3
+ // text is, its content: plain text without raw HTML, restricted Markdown, a formula, a name, the source of a credit.
4
+ // The place of a finding is a path below the text: [] for the text, [lang] for its translation in a map.
5
+ import { textLanguages, type Text } from "../languages.ts";
6
+ import { formulaProblem } from "./formula.ts";
7
+ import { htmlTag, inspect } from "./markdown.ts";
8
+
9
+ /** What a text is: a plain text, restricted Markdown, a formula, a name (a person, a work), the source of a credit. */
10
+ export type TextKind = "plain" | "markdown" | "latex" | "name" | "credit";
11
+
12
+ /** A URL as the source of a credit: the same in every language. */
13
+ export const isUrl = (text: string): boolean => /^https?:\/\/\S+$/.test(text);
14
+
15
+ /** A problem in a text: where (below it), in which language, and in Markdown on which line below its first. */
16
+ export interface TextFinding {
17
+ path: (string | number)[];
18
+ lang?: string;
19
+ line?: number;
20
+ message: string;
21
+ }
22
+
23
+ /** A link to a page of the app in a text, which the page that holds the text resolves. */
24
+ export interface TextLink {
25
+ path: (string | number)[];
26
+ lang: string;
27
+ href: string;
28
+ }
29
+
30
+ /** The problems of the text `value` of the given kind in `languages`, declared in `declaredIn`, and its links. */
31
+ export function checkText(
32
+ value: Text,
33
+ kind: TextKind,
34
+ languages: readonly string[],
35
+ declaredIn?: string,
36
+ ): { findings: TextFinding[]; links: TextLink[] } {
37
+ const findings: TextFinding[] = [];
38
+ const links: TextLink[] = [];
39
+ const neutral =
40
+ kind === "latex" || kind === "name" || (kind === "credit" && typeof value === "string" && isUrl(value));
41
+ const { texts, problems } = textLanguages(value, languages, neutral, declaredIn);
42
+ for (const problem of problems) findings.push({ path: [], lang: problem.lang, message: problem.message });
43
+ for (const { lang, text, keyed } of texts) {
44
+ const at = keyed ? [lang] : [];
45
+ if (kind === "latex") {
46
+ const problem = formulaProblem(text);
47
+ if (problem !== undefined) findings.push({ path: at, ...(keyed ? { lang } : {}), message: problem });
48
+ continue;
49
+ }
50
+ if (kind === "plain" || kind === "name" || kind === "credit") {
51
+ const tag = htmlTag(text);
52
+ if (tag !== undefined) {
53
+ findings.push({
54
+ path: at,
55
+ lang,
56
+ message: `raw HTML (<${tag}>) is not allowed; write restricted Markdown: **bold**, *italics*, lists, links`,
57
+ });
58
+ }
59
+ continue;
60
+ }
61
+ const found = inspect(text);
62
+ for (const problem of found.problems) {
63
+ findings.push({ path: at, lang, line: problem.line, message: problem.message });
64
+ }
65
+ for (const href of found.links.filter((link) => link.startsWith("/"))) links.push({ path: at, lang, href });
66
+ }
67
+ return { findings, links };
68
+ }