@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,72 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:lernapps:schema:quiz.v1",
4
+ "title": "Question bank of a lernapps quiz",
5
+ "description": "The questions of a quiz built from the archetype quiz (runtime @lernapps/app-templates), in src/quiz.json: a quiz about things to know. Each option carries short background knowledge, each question links to further reading. The build validates the bank against this schema and fails with a message naming each problem. Every text is for learners: German, plain language, in your own words. A quiz in several languages declares them in languages, German first, and writes every text for learners as a map of texts by language ({ \"de\": …, \"en\": … }) with each of them, and the address of a link as one for every language or as a map by language; the build then writes a page per language.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["title", "intro", "questions"],
9
+ "properties": {
10
+ "$schema": {
11
+ "description": "This schema, for the editor.",
12
+ "type": "string"
13
+ },
14
+ "languages": {
15
+ "description": "The languages of the quiz, German first: [\"de\", \"en\"]. The build writes a page per language: German at the root, every other below /<language>/, with a language switch. Each language needs every text for learners as a map of texts by language; without languages, the quiz is German only and its texts are strings.",
16
+ "type": "array",
17
+ "minItems": 1,
18
+ "items": {
19
+ "type": "string",
20
+ "pattern": "^[a-z]{2}$"
21
+ },
22
+ "default": ["de"]
23
+ },
24
+ "title": {
25
+ "description": "The title of the quiz: the page's title and its h1 heading.",
26
+ "$ref": "urn:lernapps:schema:text.v1#/$defs/text"
27
+ },
28
+ "intro": {
29
+ "description": "One or two sentences under the heading: what the quiz is about and whom it is for.",
30
+ "$ref": "urn:lernapps:schema:text.v1#/$defs/text"
31
+ },
32
+ "rememberLastResult": {
33
+ "description": "Keep the last result on the learner's device (localStorage, through the preset's storage) and show it at the end. Off by default: then the quiz stores nothing.",
34
+ "type": "boolean",
35
+ "default": false
36
+ },
37
+ "questions": {
38
+ "description": "The questions, in the order of the page without JavaScript. With JavaScript, the order is shuffled by the seed in the address (?seed=<number>).",
39
+ "type": "array",
40
+ "minItems": 1,
41
+ "items": {
42
+ "$ref": "urn:lernapps:schema:question.v1#/$defs/question"
43
+ }
44
+ },
45
+ "sources": {
46
+ "description": "Where the facts of the quiz come from, listed at the end of the page.",
47
+ "type": "array",
48
+ "minItems": 1,
49
+ "items": {
50
+ "$ref": "#/$defs/source"
51
+ }
52
+ }
53
+ },
54
+ "$defs": {
55
+ "source": {
56
+ "type": "object",
57
+ "additionalProperties": false,
58
+ "required": ["title"],
59
+ "properties": {
60
+ "title": {
61
+ "description": "The source, e.g. the article or book.",
62
+ "$ref": "urn:lernapps:schema:text.v1#/$defs/text"
63
+ },
64
+ "url": {
65
+ "description": "Its address, if it is on the web; https only.",
66
+ "type": "string",
67
+ "pattern": "^https://[^\\s]+$"
68
+ }
69
+ }
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,173 @@
1
+ // Renders the quiz into the page at build time: every question (shared/questions/render.ts) with its controls and,
2
+ // hidden until it is answered, the background of every option, the explanation and further reading; the solutions
3
+ // with the same at the end; the sources; and the data the engine needs. Without JavaScript the page reads as a
4
+ // worksheet with its answer key; with JavaScript the engine (engine.ts) turns it into a quiz, one question at a time.
5
+ // The engine and the end-to-end tests rely on the ids, classes and data attributes written here. A quiz in several
6
+ // languages renders a page per language (renderQuiz with the page's `lang` in its data and the quiz's languages): its
7
+ // texts, the quiz's own texts in that language, and a language switch of plain links before the quiz; German is at the
8
+ // root, every other language below /<language>/. Links to other sites are plain links with rel="noopener", as in the
9
+ // site frame: nothing is loaded from another server before a click.
10
+ import { translator } from "@lernapps/tooling/i18n";
11
+ import de from "./messages/de.json" with { type: "json" };
12
+ import en from "./messages/en.json" with { type: "json" };
13
+ import { renderMaterial, type MaterialContext } from "../shared/blocks/index.ts";
14
+ import { alternateLinks, languageRoot, pageFooter, pageHeader, relative } from "../shared/pages.ts";
15
+ import { localeOf, textsIn, type Language } from "../shared/languages.ts";
16
+ import { QUESTION_MESSAGES } from "../shared/questions/render.ts";
17
+ import { questionId, type Question } from "../shared/questions/question.ts";
18
+ import { solutionId, type Quiz, type QuizData } from "./quiz.ts";
19
+ import { external, renderQuestion, renderSolution } from "../shared/questions/render.ts";
20
+ import { escape } from "../shared/html.ts";
21
+
22
+ /** The quiz's texts for learners: those of its page, and those of its questions. */
23
+ export type QuizMessages = typeof de & (typeof QUESTION_MESSAGES)["de"];
24
+ /** The quiz's texts for learners in each language it has them: a quiz may declare only these languages. */
25
+ export const QUIZ_MESSAGES: Readonly<Record<Language, QuizMessages>> = {
26
+ de: { ...QUESTION_MESSAGES.de, ...de },
27
+ en: { ...QUESTION_MESSAGES.en, ...en },
28
+ };
29
+
30
+ /** The quiz's texts in `lang`; German for a language it does not have. */
31
+ export const messagesIn = (lang: string): QuizMessages => textsIn(QUIZ_MESSAGES, lang);
32
+
33
+ /** What rendering in one language needs: its texts and its numbers. */
34
+ interface Words {
35
+ t: (key: keyof QuizMessages, params?: Readonly<Record<string, string | number>>) => string;
36
+ }
37
+ const languages = new Map<string, Words>();
38
+ function wordsIn(lang = "de"): Words {
39
+ let found = languages.get(lang);
40
+ if (found === undefined) {
41
+ found = { t: translator(messagesIn(lang), localeOf(lang)) };
42
+ languages.set(lang, found);
43
+ }
44
+ return found;
45
+ }
46
+
47
+ function solutionItem(question: Question, lang: string): string {
48
+ const { t } = wordsIn(lang);
49
+ return [
50
+ `<li class="quiz-solution" id="${solutionId(question)}">`,
51
+ `<h3>${escape(question.text)}</h3>`,
52
+ renderSolution(question, { lang }),
53
+ `<p><a href="#${questionId(question)}">${t("toQuestion")}</a></p>`,
54
+ `</li>`,
55
+ ].join("\n");
56
+ }
57
+
58
+ function renderSources(quiz: Quiz, { t }: Words): string[] {
59
+ if (quiz.sources === undefined) return [];
60
+ return [
61
+ `<section class="quiz-sources" id="quellen">`,
62
+ `<h2>${t("sources")}</h2>`,
63
+ `<p>${t("sourcesIntro")}</p>`,
64
+ `<ul>`,
65
+ ...quiz.sources.map(
66
+ (source) =>
67
+ `<li>${source.url === undefined ? escape(source.title) : external({ title: source.title, url: source.url })}</li>`,
68
+ ),
69
+ `</ul>`,
70
+ `</section>`,
71
+ ];
72
+ }
73
+
74
+ /** The pages of a quiz: its languages, German first. */
75
+ export interface QuizPages {
76
+ languages?: readonly string[];
77
+ /** Whether a bundled image has no size of its own (see MaterialContext); none is without one. */
78
+ sizeless?: (image: string) => boolean;
79
+ }
80
+
81
+ /** One page of the quiz: its title and description, its alternates in its languages for <head>, and its body. */
82
+ export interface RenderedQuiz {
83
+ title: string;
84
+ description: string;
85
+ /** The page in each of the quiz's languages; none while it has one. */
86
+ alternates: string[];
87
+ body: string;
88
+ }
89
+
90
+ /** The form with which the learner chooses how many questions to answer; the engine shows it (none for one question). */
91
+ function renderStart(total: number, t: Words["t"]): string {
92
+ if (total < 2) return "";
93
+ return [
94
+ `<form class="quiz-start" hidden>`,
95
+ `<p><label for="quiz-count">${t("countLabel")}</label> `,
96
+ `<input id="quiz-count" type="number" inputmode="numeric" min="1" max="${total}" value="${total}" required> `,
97
+ `<span>${t("countOf", { total })}</span></p>`,
98
+ `<p><button type="submit">${t("start")}</button></p>`,
99
+ `</form>`,
100
+ ].join("");
101
+ }
102
+
103
+ /**
104
+ * The page of the quiz in its data's language (German without one): its title and description, its alternates, and
105
+ * the quiz itself for the page's body, after a language switch when the quiz has several `languages`.
106
+ */
107
+ export function renderQuiz(data: QuizData, { languages = ["de"], sizeless }: QuizPages = {}): RenderedQuiz {
108
+ const { quiz } = data;
109
+ const lang = data.lang ?? "de";
110
+ const words = wordsIn(lang);
111
+ const { t } = words;
112
+ const where = { path: languageRoot(lang), local: "", languages };
113
+ // the engine needs the questions to check the answers, not what they show
114
+ const forEngine = {
115
+ ...data,
116
+ quiz: {
117
+ ...data.quiz,
118
+ questions: data.quiz.questions.map(({ material: _material, ...question }) => question as Question),
119
+ },
120
+ };
121
+ const json = JSON.stringify(forEngine).replace(/</g, "\\u003c");
122
+ const material: MaterialContext = {
123
+ lang,
124
+ href: (image) => relative(where.path, image),
125
+ ...(sizeless === undefined ? {} : { sizeless }),
126
+ };
127
+ const body = [
128
+ pageHeader({
129
+ ...where,
130
+ lang,
131
+ label: escape(t("languages")),
132
+ name: (other) => escape(messagesIn(other).language),
133
+ }),
134
+ `<main class="quiz" id="quiz">`,
135
+ `<h1>${escape(quiz.title)}</h1>`,
136
+ `<p class="quiz-intro">${escape(quiz.intro)}</p>`,
137
+ `<p class="quiz-howto">${t("howto")}</p>`,
138
+ `<noscript><p>${t("noscript")}</p></noscript>`,
139
+ `<h2 class="quiz-heading">${t("questions")}</h2>`,
140
+ renderStart(quiz.questions.length, t),
141
+ `<p class="quiz-progress" hidden></p>`,
142
+ `<ol class="quiz-questions">`,
143
+ ...quiz.questions.map((question) =>
144
+ renderQuestion(question, {
145
+ element: "li",
146
+ next: true,
147
+ lang,
148
+ ...(question.material === undefined ? {} : { materialHtml: renderMaterial(question.material, material) }),
149
+ }),
150
+ ),
151
+ `</ol>`,
152
+ `<section class="quiz-result" id="ergebnis" hidden tabindex="-1">`,
153
+ `<h2>${t("result")}</h2>`,
154
+ `<p class="quiz-score"></p>`,
155
+ `<p class="quiz-last" hidden></p>`,
156
+ `<p><a class="quiz-restart" href="./">${t("restart")}</a></p>`,
157
+ `</section>`,
158
+ `<section class="quiz-solutions" id="loesungen">`,
159
+ `<h2>${t("solutions")}</h2>`,
160
+ `<ol>`,
161
+ ...quiz.questions.map((question) => solutionItem(question, lang)),
162
+ `</ol>`,
163
+ `</section>`,
164
+ ...renderSources(quiz, words),
165
+ `</main>`,
166
+ pageFooter({ footer: t("footer"), imprint: t("imprint"), privacy: t("privacy") }),
167
+ `<script type="application/json" id="quiz-data">${json}</script>`,
168
+ ]
169
+ .filter((part) => part !== "")
170
+ .join("\n");
171
+ const alternates = languages.length < 2 ? [] : alternateLinks(where);
172
+ return { title: quiz.title, description: quiz.intro, alternates, body };
173
+ }
@@ -0,0 +1,108 @@
1
+ // Validates a question bank at build time: against its JSON Schema (quiz.v1.schema.json), then what a schema
2
+ // cannot say (unique ids, ...), then each of its questions with the validation of one question
3
+ // (shared/questions/validate.ts). Each problem names the question and what to change, for the assistant that wrote the bank. A bank in several
4
+ // languages declares them (languages, German first): each of its learner texts must then be a map with each of them
5
+ // and no other, with the messages of the explainer's language check.
6
+ import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js";
7
+ import type { AppChecks } from "../shared/blocks/index.ts";
8
+ import { LANGUAGES, languageListProblems, textLanguages, type Text } from "../shared/languages.ts";
9
+ import {
10
+ duplicates,
11
+ inQuestion,
12
+ questionSchemas,
13
+ readable,
14
+ schemaMessages,
15
+ validateQuestion,
16
+ } from "../shared/questions/validate.ts";
17
+ import schema from "./quiz.v1.schema.json" with { type: "json" };
18
+ import { quizIn, quizLanguages, type Quiz } from "./quiz.ts";
19
+
20
+ export const SCHEMA_ID = schema.$id;
21
+ /** Where an app finds the schema of its question bank, for the message that names it. */
22
+ export const SCHEMA_FILE = "node_modules/@lernapps/app-templates/runtime/quiz/quiz.v1.schema.json";
23
+ export const RULE_URL = "https://lernapps.net/tooling/rules/#quiz-bank-valid";
24
+
25
+ const ajv = new Ajv2020({ allErrors: true, strict: true });
26
+ for (const each of questionSchemas) ajv.addSchema(each);
27
+ ajv.addSchema(schema);
28
+ function compiled<T>(ref: string): ValidateFunction<T> {
29
+ const validate = ajv.getSchema<T>(ref);
30
+ if (validate === undefined) throw new Error(`the quiz's schema has no ${ref}`);
31
+ return validate;
32
+ }
33
+ const validateBank = compiled<Quiz<Text>>(SCHEMA_ID);
34
+
35
+ /** `/questions/2/options/1/text` as `questions[2].options[1].text`, with the id of the question. */
36
+ function inBank(path: string, value: unknown): string {
37
+ const parts = path.split("/").slice(1);
38
+ const [first, index] = parts;
39
+ if (first === "questions" && index !== undefined && /^\d+$/.test(index)) {
40
+ const questions = typeof value === "object" && value !== null && "questions" in value ? value.questions : [];
41
+ return inQuestion(
42
+ `questions[${index}]`,
43
+ parts.slice(2),
44
+ Array.isArray(questions) ? questions[Number(index)] : undefined,
45
+ );
46
+ }
47
+ const at = readable(parts);
48
+ return at === "" ? "the bank" : at.slice(1);
49
+ }
50
+
51
+ /** Where the quiz declares its languages, for the message about a language it does not declare. */
52
+ const DECLARED_IN = "src/quiz.json";
53
+
54
+ /** The problems of the bank's own texts (title, intro, sources) in its languages. */
55
+ function bankTextProblems(quiz: Quiz<Text>, languages: readonly string[]): string[] {
56
+ const texts: [string, Text][] = [
57
+ ["title", quiz.title],
58
+ ["intro", quiz.intro],
59
+ ...(quiz.sources ?? []).map((source, index): [string, Text] => [`sources[${index}].title`, source.title]),
60
+ ];
61
+ return texts.flatMap(([at, text]) =>
62
+ textLanguages(text, languages, false, DECLARED_IN).problems.map(
63
+ (problem) => `${at} (${problem.lang}): ${problem.message}`,
64
+ ),
65
+ );
66
+ }
67
+
68
+ /** The problems of a question bank; none when it is valid. */
69
+ export function problems(value: unknown, app?: AppChecks): string[] {
70
+ if (!validateBank(value)) return schemaMessages(validateBank.errors ?? [], (path) => inBank(path, value), value);
71
+ const languages = quizLanguages(value);
72
+ const listed = languageListProblems(languages, LANGUAGES).map(
73
+ (problem) => `languages[${Math.max(languages.lastIndexOf(problem.lang), 0)}]: ${problem.message}`,
74
+ );
75
+ if (listed.length > 0) return listed;
76
+ const ids = value.questions.map((question) => question.id);
77
+ return [
78
+ ...duplicates(ids).map((id) => `questions: the id "${id}" appears twice; each question needs its own id`),
79
+ ...bankTextProblems(value, languages),
80
+ ...value.questions.flatMap((question, index) =>
81
+ validateQuestion(question, `questions[${index}]`, languages, DECLARED_IN, app),
82
+ ),
83
+ ];
84
+ }
85
+
86
+ /** Reads a question bank from JSON text, in all its languages; throws an Error whose message lists every problem. */
87
+ export function readBank(text: string, file: string, app?: AppChecks): Quiz<Text> {
88
+ let value: unknown;
89
+ try {
90
+ value = JSON.parse(text);
91
+ } catch (error) {
92
+ throw new Error(`${file} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
93
+ }
94
+ const found = problems(value, app);
95
+ if (found.length > 0) {
96
+ throw new Error(
97
+ [
98
+ `${file} is not a valid question bank (${RULE_URL}):`,
99
+ ...found.map((problem) => ` - ${problem}`),
100
+ `Fix the question bank as its schema says: ${SCHEMA_FILE}`,
101
+ ].join("\n"),
102
+ );
103
+ }
104
+ return value as Quiz<Text>;
105
+ }
106
+
107
+ /** Reads a question bank from JSON text, in German; throws an Error whose message lists every problem. */
108
+ export const parseBank = (text: string, file: string, app?: AppChecks): Quiz => quizIn(readBank(text, file, app), "de");
@@ -0,0 +1,16 @@
1
+ // The app's name from its package.json: the prefix of what it stores on the device (@lernapps/tooling/storage).
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ /** The name in the package.json of the app in `root`, or `fallback` when there is none. */
6
+ export function appName(root: string, fallback: string): string {
7
+ try {
8
+ const manifest: unknown = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
9
+ if (typeof manifest === "object" && manifest !== null && "name" in manifest && typeof manifest.name === "string") {
10
+ return manifest.name;
11
+ }
12
+ } catch {
13
+ // no package.json: the fallback
14
+ }
15
+ return fallback;
16
+ }
@@ -0,0 +1,162 @@
1
+ // The blocks that show content, for every archetype that shows it with its questions or on its pages (the quiz, the
2
+ // explainer): a text in restricted Markdown, a formula, a figure (drawn from data, or a bundled image), a labelled
3
+ // figure (a bundled image with labels), a table, a quotation and program code. Their types, the blocks in one
4
+ // language, and the images they use. The checks are in validate.ts, the rendering in render.ts. A bundled image is a
5
+ // file in src/images/ of the app, named by its path from src/: images/<name>.svg (or .png, .jpg, .webp).
6
+ import { inLanguage, type Text } from "../languages.ts";
7
+ import type { CodeLanguage } from "./code.ts";
8
+
9
+ /** Where a bundled image or a quote comes from: a URL, the same in every language, or a citation, and its licence. */
10
+ export interface Credit<T extends Text = string> {
11
+ source?: T;
12
+ licence?: string;
13
+ }
14
+
15
+ export type Drawing = { kind: "hundred-grid"; filled: number } | { kind: "bar"; part: number; whole: number };
16
+
17
+ export interface TextBlock<T extends Text = string> {
18
+ type: "text";
19
+ text: T;
20
+ }
21
+
22
+ export interface FormulaBlock<T extends Text = string> {
23
+ type: "formula";
24
+ formula: T;
25
+ }
26
+
27
+ export type Figure<T extends Text = string> = {
28
+ type: "figure";
29
+ draw?: Drawing;
30
+ image?: string;
31
+ alt: T;
32
+ caption: T;
33
+ } & Credit<T>;
34
+
35
+ export type LabelledFigure<T extends Text = string> = {
36
+ type: "labelled-figure";
37
+ image: string;
38
+ alt: T;
39
+ caption?: T;
40
+ /** Whether the figure lists the texts of the labels next to the image; without it, alt names every marker. */
41
+ legend?: boolean;
42
+ labels: readonly { marker?: string; x: number; y: number; text: T }[];
43
+ } & Credit<T>;
44
+
45
+ /** The marker of the label at `index`: the one it names, else its number. */
46
+ export const markerOf = (label: { marker?: string }, index: number): string => label.marker ?? String(index + 1);
47
+
48
+ export interface TableBlock<T extends Text = string> {
49
+ type: "table";
50
+ caption: T;
51
+ header?: readonly T[];
52
+ rowHeaders?: boolean;
53
+ rows: readonly (readonly T[])[];
54
+ }
55
+
56
+ export type QuoteBlock<T extends Text = string> = {
57
+ type: "quote";
58
+ text: T;
59
+ origin: { author: T; work?: T; year?: number | T };
60
+ } & Credit<T>;
61
+
62
+ export interface CodeBlock<T extends Text = string> {
63
+ type: "code";
64
+ language: CodeLanguage;
65
+ code: string;
66
+ caption?: T;
67
+ }
68
+
69
+ /** A block that shows content. */
70
+ export type MaterialBlock<T extends Text = string> =
71
+ | TextBlock<T>
72
+ | FormulaBlock<T>
73
+ | Figure<T>
74
+ | LabelledFigure<T>
75
+ | TableBlock<T>
76
+ | QuoteBlock<T>
77
+ | CodeBlock<T>;
78
+
79
+ /** The types of the blocks that show content. */
80
+ export const MATERIAL_TYPES: readonly MaterialBlock["type"][] = [
81
+ "text",
82
+ "formula",
83
+ "figure",
84
+ "labelled-figure",
85
+ "table",
86
+ "quote",
87
+ "code",
88
+ ];
89
+
90
+ /** The blocks with each text in `lang`. */
91
+ export function materialIn(blocks: readonly MaterialBlock<Text>[], lang: string): MaterialBlock[] {
92
+ const t = (text: Text) => inLanguage(text, lang);
93
+ const credit = (block: Credit<Text>): Credit => ({
94
+ ...(block.source === undefined ? {} : { source: t(block.source) }),
95
+ ...(block.licence === undefined ? {} : { licence: block.licence }),
96
+ });
97
+ return blocks.map((block): MaterialBlock => {
98
+ switch (block.type) {
99
+ case "text":
100
+ return { type: "text", text: t(block.text) };
101
+ case "formula":
102
+ return { type: "formula", formula: t(block.formula) };
103
+ case "figure":
104
+ return {
105
+ type: "figure",
106
+ ...(block.draw === undefined ? {} : { draw: block.draw }),
107
+ ...(block.image === undefined ? {} : { image: block.image }),
108
+ alt: t(block.alt),
109
+ caption: t(block.caption),
110
+ ...credit(block),
111
+ };
112
+ case "labelled-figure":
113
+ return {
114
+ type: "labelled-figure",
115
+ image: block.image,
116
+ alt: t(block.alt),
117
+ ...(block.caption === undefined ? {} : { caption: t(block.caption) }),
118
+ ...(block.legend === undefined ? {} : { legend: block.legend }),
119
+ labels: block.labels.map((label) => ({
120
+ ...(label.marker === undefined ? {} : { marker: label.marker }),
121
+ x: label.x,
122
+ y: label.y,
123
+ text: t(label.text),
124
+ })),
125
+ ...credit(block),
126
+ };
127
+ case "table":
128
+ return {
129
+ type: "table",
130
+ caption: t(block.caption),
131
+ ...(block.header === undefined ? {} : { header: block.header.map(t) }),
132
+ ...(block.rowHeaders === undefined ? {} : { rowHeaders: block.rowHeaders }),
133
+ rows: block.rows.map((row) => row.map(t)),
134
+ };
135
+ case "quote":
136
+ return {
137
+ type: "quote",
138
+ text: t(block.text),
139
+ origin: {
140
+ author: t(block.origin.author),
141
+ ...(block.origin.work === undefined ? {} : { work: t(block.origin.work) }),
142
+ ...(block.origin.year === undefined
143
+ ? {}
144
+ : { year: typeof block.origin.year === "number" ? block.origin.year : t(block.origin.year) }),
145
+ },
146
+ ...credit(block),
147
+ };
148
+ case "code":
149
+ return {
150
+ type: "code",
151
+ language: block.language,
152
+ code: block.code,
153
+ ...(block.caption === undefined ? {} : { caption: t(block.caption) }),
154
+ };
155
+ }
156
+ });
157
+ }
158
+
159
+ /** The bundled images `blocks` use, each once, as paths from src/. */
160
+ export const materialImages = (blocks: readonly MaterialBlock<Text>[]): string[] => [
161
+ ...new Set(blocks.flatMap((block) => ("image" in block && block.image !== undefined ? [block.image] : []))),
162
+ ];