@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,457 @@
1
+ // Reads an explainer's content from src/ at build time and validates it: each file against its part of the schema
2
+ // (explainer.v1.schema.json), then what a schema cannot say. The order lists are complete and unique, the depth is
3
+ // app, chapter, page and two section levels, ids are unique per level, every text is complete in each declared
4
+ // language and in no other, texts hold restricted Markdown without raw HTML or headings, formulas are in the command
5
+ // list, bundled images and quotes name source and licence, links to pages of the app find their page and section,
6
+ // every page has an exercise, a question of the question types passes the validation of a question in every declared language,
7
+ // a generated exercise declares what it uses and passes its property test over fixed seeds (generated.ts), the check
8
+ // of each exercise judges its examples as they are listed, a vue-component block names a component of src/components/
9
+ // that passes its checks (components.ts), its props match the component's prop types, it has its nojs text (an exercise
10
+ // component's is generated from its describe) and, for an exercise component, its examples (which the build step
11
+ // runs, plugin.ts) and an anchor once on the page (its id, or its name in kebab case), every data file is named by a timeline, a generated exercise or a component prop, and src/ holds nothing else. Each problem is one line: the file,
12
+ // its line, the place in the file, the language where it matters, and what to do.
13
+ import { existsSync, readdirSync, statSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { checkBlocks } from "./check-page.ts";
16
+ import { readComponents } from "./components/read.ts";
17
+ import {
18
+ isExercise,
19
+ type App,
20
+ type BlockOf,
21
+ type Chapter,
22
+ type ComponentUse,
23
+ type DataRecord,
24
+ type Page,
25
+ type SelfAssessment,
26
+ type Site,
27
+ } from "./content.ts";
28
+ import { allBlocks } from "./routes.ts";
29
+ import { ASSESSMENT, drawsTasks, exerciseOf, parseRef } from "./assessment.ts";
30
+ import { SCHEMA_FILE, validate } from "./schema.ts";
31
+ import { appName } from "../shared/appname.ts";
32
+ import { isObject, Problems, readYaml, Texts, type Path, type YamlFile } from "../shared/content.ts";
33
+ import { isLanguage, LANGUAGES, languageListProblems } from "../shared/languages.ts";
34
+
35
+ export { SCHEMA_ID } from "./schema.ts";
36
+ export const RULE_URL = "https://lernapps.net/tooling/rules/#explainer-content-valid";
37
+
38
+ /** What src/ may hold besides the chapter folders. */
39
+ const SRC = "src/ holds only app.yaml, the chapter folders, data/, images/, components/ and main.ts";
40
+ /** Chapter ids that would collide with other addresses: data files, images, the glossary, the scope, language trees. */
41
+ const RESERVED = new Set(["data", "images", "glossar", "components", "assets", "scope", ASSESSMENT]);
42
+ const ID = /^[a-z0-9]+(-[a-z0-9]+)*$/;
43
+ const IMAGE = /^[a-z0-9]+(-[a-z0-9]+)*\.(svg|png|jpg|webp)$/;
44
+
45
+ /** The content could not be read: one problem per line in `problems`. */
46
+ export class ContentError extends Error {
47
+ readonly problems: readonly string[];
48
+ constructor(problems: readonly string[]) {
49
+ super(
50
+ [
51
+ `the explainer's content in src/ is not valid (${RULE_URL}):`,
52
+ ...problems.map((problem) => ` - ${problem}`),
53
+ `Fix the content as its schema says: ${SCHEMA_FILE}`,
54
+ ].join("\n"),
55
+ );
56
+ this.problems = problems;
57
+ }
58
+ }
59
+
60
+ /** The files and folders in `dir`, sorted. */
61
+ const entries = (dir: string) => (existsSync(dir) ? readdirSync(dir).sort() : []);
62
+ const isDirectory = (path: string) => statSync(path).isDirectory();
63
+
64
+ /** Checks the order list `list` (at `key` of `yaml`) against the ids that exist; true when they agree. */
65
+ function checkOrder(
66
+ yaml: YamlFile,
67
+ key: string,
68
+ list: readonly string[],
69
+ existing: readonly string[],
70
+ what: { one: string; folder: string; file: (id: string) => string },
71
+ problems: Problems,
72
+ ): boolean {
73
+ let ok = true;
74
+ list.forEach((id, index) => {
75
+ if (list.indexOf(id) !== index) {
76
+ problems.at(yaml, [key, index], `${id} is listed twice; list each ${what.one} exactly once`);
77
+ ok = false;
78
+ } else if (!existing.includes(id)) {
79
+ problems.at(yaml, [key, index], `${id} is listed, but ${what.file(id)} does not exist`);
80
+ ok = false;
81
+ }
82
+ });
83
+ for (const id of existing.filter((candidate) => !list.includes(candidate))) {
84
+ problems.at(yaml, [key], `${id} is not listed; list each ${what.one} of ${what.folder} exactly once`);
85
+ ok = false;
86
+ }
87
+ return ok;
88
+ }
89
+
90
+ /**
91
+ * The problems of the self-assessment `assessment` of `yaml`: of the app (no `chapter`), or of the chapter `chapter`,
92
+ * whose exercises are all its own. Each reference names a page and an exercise of it of a question type or generated;
93
+ * a question is listed once, a generated exercise may be listed again (each listing draws a task of its own).
94
+ */
95
+ function checkAssessment(
96
+ yaml: YamlFile,
97
+ assessment: SelfAssessment,
98
+ chapters: readonly Chapter[],
99
+ chapter: string | undefined,
100
+ components: Site["components"],
101
+ problems: Problems,
102
+ ): void {
103
+ const seen = new Set<string>();
104
+ assessment.pages.forEach((step, index) => {
105
+ step.exercises.forEach((ref, at) => {
106
+ const path = ["selfAssessment", "pages", index, "exercises", at];
107
+ const parts = parseRef(ref);
108
+ if (parts === undefined) return;
109
+ const twice = seen.has(ref);
110
+ seen.add(ref);
111
+ if (chapter !== undefined && parts.chapter !== chapter) {
112
+ problems.at(
113
+ yaml,
114
+ path,
115
+ `${ref} is not an exercise of the chapter ${chapter}; a chapter's self-assessment asks only its own pages; the app's is every chapter's together`,
116
+ );
117
+ return;
118
+ }
119
+ const page = chapters
120
+ .find((candidate) => candidate.id === parts.chapter)
121
+ ?.pages.find((candidate) => candidate.id === parts.page);
122
+ if (page === undefined) {
123
+ problems.at(yaml, path, `${ref} names no page of the app; write /<chapter>/<page>/#<exercise>`);
124
+ return;
125
+ }
126
+ const exercise = exerciseOf(components, page, parts.exercise);
127
+ // a generated exercise or an exercise component may be listed again: each listing draws a task of its own
128
+ if (twice && exercise !== undefined && !drawsTasks(exercise)) {
129
+ problems.at(
130
+ yaml,
131
+ path,
132
+ `${ref} is listed twice, and a question shows the same task each time; list it once (a generated exercise or an exercise component may be listed again: each listing draws a task of its own)`,
133
+ );
134
+ return;
135
+ }
136
+ if (exercise !== undefined) return;
137
+ const component = components.some(
138
+ (use) => use.id === parts.exercise && [...allBlocks(page.blocks)].some((block) => block === use.block),
139
+ );
140
+ problems.at(
141
+ yaml,
142
+ path,
143
+ component
144
+ ? `${ref} is a component that checks no answer; choose an exercise: generated, a question type, or an exercise component`
145
+ : `${ref} names no exercise of ${page.file}; use the id of one of its exercises: generated, a question type, or an exercise component`,
146
+ );
147
+ });
148
+ });
149
+ }
150
+
151
+ /** How many seeds the property test of each generated exercise draws at build time. */
152
+ export const SEEDS = 200;
153
+
154
+ /** The problems of the content in `root`/src, and the content when there are none. */
155
+ export function check(root: string, seeds = SEEDS): { site?: Site; problems: string[] } {
156
+ const problems = new Problems();
157
+ const src = join(root, "src");
158
+ if (!existsSync(join(src, "app.yaml"))) {
159
+ problems.file(
160
+ "src/app.yaml",
161
+ "is missing: the app's title, intro, languages and the order of its chapters belong there",
162
+ );
163
+ return { problems: problems.lines };
164
+ }
165
+
166
+ // what src/ holds
167
+ const chapterIds: string[] = [];
168
+ const dataFiles = new Map<string, YamlFile>();
169
+ for (const name of entries(src)) {
170
+ const path = join(src, name);
171
+ if (name === "app.yaml" || name === "main.ts") continue;
172
+ if (name === "components" && isDirectory(path)) continue;
173
+ if (!isDirectory(path)) {
174
+ problems.file(`src/${name}`, `not part of an explainer; ${SRC}`);
175
+ } else if (name === "images") {
176
+ for (const image of entries(path)) {
177
+ if (isDirectory(join(path, image)) || !IMAGE.test(image)) {
178
+ problems.file(
179
+ `src/images/${image}`,
180
+ "not an image of an explainer; src/images/ holds image files <name>.svg, .png, .jpg or .webp, with a lower-case name",
181
+ );
182
+ }
183
+ }
184
+ } else if (name === "data") {
185
+ for (const file of entries(path)) {
186
+ const id = file.replace(/\.yaml$/, "");
187
+ if (isDirectory(join(path, file)) || !file.endsWith(".yaml") || !ID.test(id)) {
188
+ problems.file(
189
+ `src/data/${file}`,
190
+ "not a data file; src/data/ holds data files <name>.yaml, with a lower-case name",
191
+ );
192
+ continue;
193
+ }
194
+ const yaml = readYaml(root, `src/data/${file}`, problems);
195
+ if (yaml !== undefined && validate(yaml, "data", problems)) dataFiles.set(id, yaml);
196
+ }
197
+ } else if (!ID.test(name)) {
198
+ problems.file(
199
+ `src/${name}/`,
200
+ `the folder's name is the chapter's id: use lower-case letters, digits and hyphens`,
201
+ );
202
+ } else if (RESERVED.has(name) || /^[a-z]{2}$/.test(name)) {
203
+ problems.file(
204
+ `src/${name}/`,
205
+ `${name} is reserved for the app's addresses (data, images, glossar, components, assets, ${ASSESSMENT} and two-letter language codes); rename the chapter`,
206
+ );
207
+ } else chapterIds.push(name);
208
+ }
209
+
210
+ // the components, read with Vue's SFC compiler only when there are any
211
+ const components = existsSync(join(src, "components")) ? readComponents(root) : undefined;
212
+ for (const problem of components?.problems ?? []) problems.add(problem);
213
+
214
+ // the app
215
+ const appYaml = readYaml(root, "src/app.yaml", problems);
216
+ if (appYaml === undefined || !validate(appYaml, "app", problems)) return { problems: problems.lines };
217
+ const app = appYaml.value as App;
218
+ const languageList = languageListProblems(app.languages, LANGUAGES);
219
+ for (const problem of languageList) {
220
+ problems.at(appYaml, ["languages", Math.max(app.languages.lastIndexOf(problem.lang), 0)], problem.message);
221
+ }
222
+ if (languageList.length > 0) return { problems: problems.lines };
223
+ const texts = new Texts(app.languages, problems);
224
+ texts.check(appYaml, ["title"], "plain");
225
+ texts.check(appYaml, ["intro"], "markdown");
226
+ texts.check(appYaml, ["tutor", "rules"], "markdown");
227
+ app.sources?.forEach((_source, index) => texts.check(appYaml, ["sources", index, "title"], "plain"));
228
+ // a text by language anywhere in a record, also in the lists and records of a record: a map of strings with a
229
+ // language among its keys; a record whose fields are all texts ({ name: Geige, familie: Streicher }) is no text
230
+ const checkDataTexts = (yaml: YamlFile, path: (string | number)[], value: unknown): void => {
231
+ if (Array.isArray(value)) value.forEach((item, index) => checkDataTexts(yaml, [...path, index], item));
232
+ else if (isObject(value)) {
233
+ const fields = Object.entries(value);
234
+ if (fields.every(([, field]) => typeof field === "string") && fields.some(([key]) => isLanguage(key))) {
235
+ texts.check(yaml, path, "plain");
236
+ } else for (const [field, item] of fields) checkDataTexts(yaml, [...path, field], item);
237
+ }
238
+ };
239
+ for (const yaml of dataFiles.values()) {
240
+ (yaml.value as DataRecord[]).forEach((record, index) => checkDataTexts(yaml, [index], record));
241
+ }
242
+ const chaptersOk = checkOrder(
243
+ appYaml,
244
+ "chapters",
245
+ app.chapters,
246
+ chapterIds,
247
+ { one: "chapter", folder: "src/", file: (id) => `src/${id}/` },
248
+ problems,
249
+ );
250
+
251
+ // the chapters and their pages
252
+ const terms = new Map<string, string>();
253
+ const images = new Map<string, string>();
254
+ const anchors = new Map<string, Set<string>>();
255
+ const uses: ComponentUse[] = [];
256
+ const dataUsed = new Set<string>();
257
+ // false once a chapter or page could not be read: what it names is unknown, so no data file counts as unused
258
+ let complete = true;
259
+ const chapters: Chapter[] = [];
260
+ const chapterFiles = new Map<string, YamlFile>();
261
+ for (const chapterId of chaptersOk ? app.chapters : chapterIds) {
262
+ const folder = join(src, chapterId);
263
+ const pageIds: string[] = [];
264
+ for (const name of entries(folder)) {
265
+ const file = `src/${chapterId}/${name}`;
266
+ if (name === "chapter.yaml") continue;
267
+ if (isDirectory(join(folder, name))) {
268
+ problems.file(
269
+ `${file}/`,
270
+ "a folder inside a chapter; an explainer goes app, chapter, page, and two section levels inside a page: split the app",
271
+ );
272
+ } else if (!name.endsWith(".yaml")) {
273
+ problems.file(
274
+ file,
275
+ `not part of an explainer; a chapter folder holds only chapter.yaml and its pages <page>.yaml`,
276
+ );
277
+ } else if (!ID.test(name.slice(0, -".yaml".length))) {
278
+ problems.file(file, `the file's name is the page's id: use lower-case letters, digits and hyphens`);
279
+ } else if (name === `${ASSESSMENT}.yaml`) {
280
+ problems.file(file, `${ASSESSMENT} is reserved for the chapter's self-assessment; rename the page`);
281
+ } else {
282
+ const pageId = name.slice(0, -".yaml".length);
283
+ pageIds.push(pageId);
284
+ }
285
+ }
286
+ if (!existsSync(join(folder, "chapter.yaml"))) {
287
+ problems.file(
288
+ `src/${chapterId}/chapter.yaml`,
289
+ "is missing: a chapter folder needs its chapter.yaml with its title and the order of its pages",
290
+ );
291
+ complete = false;
292
+ continue;
293
+ }
294
+ const chapterYaml = readYaml(root, `src/${chapterId}/chapter.yaml`, problems);
295
+ if (chapterYaml === undefined || !validate(chapterYaml, "chapter", problems)) {
296
+ complete = false;
297
+ continue;
298
+ }
299
+ const chapter = chapterYaml.value as Omit<Chapter, "id" | "pages"> & { pages: string[] };
300
+ chapterFiles.set(chapterId, chapterYaml);
301
+ texts.check(chapterYaml, ["title"], "plain");
302
+ texts.check(chapterYaml, ["summary"], "markdown");
303
+ const pagesOk = checkOrder(
304
+ chapterYaml,
305
+ "pages",
306
+ chapter.pages,
307
+ pageIds,
308
+ { one: "page", folder: `src/${chapterId}/`, file: (id) => `src/${chapterId}/${id}.yaml` },
309
+ problems,
310
+ );
311
+ const pages: Page[] = [];
312
+ for (const pageId of pagesOk ? chapter.pages : pageIds) {
313
+ const file = `src/${chapterId}/${pageId}.yaml`;
314
+ const yaml = readYaml(root, file, problems);
315
+ if (yaml === undefined || !validate(yaml, "page", problems)) {
316
+ complete = false;
317
+ continue;
318
+ }
319
+ const page = yaml.value as Omit<Page, "id" | "chapter" | "file">;
320
+ texts.check(yaml, ["title"], "plain");
321
+ texts.check(yaml, ["description"], "plain");
322
+ const pageAnchors = new Set<string>();
323
+ const exercises = new Map<string, Path>();
324
+ checkBlocks(
325
+ {
326
+ yaml,
327
+ texts,
328
+ problems,
329
+ root,
330
+ dataFiles,
331
+ terms,
332
+ images,
333
+ anchors: pageAnchors,
334
+ exercises,
335
+ seeds,
336
+ components,
337
+ uses,
338
+ dataUsed,
339
+ },
340
+ page.blocks,
341
+ ["blocks"],
342
+ 0,
343
+ );
344
+ if (![...allBlocks(page.blocks)].some(isExercise)) {
345
+ problems.at(
346
+ yaml,
347
+ ["blocks"],
348
+ "the page has no exercise; give it a section with an exercise: one of the question types, or generated",
349
+ );
350
+ }
351
+ for (const [id, path] of exercises) {
352
+ if (pageAnchors.has(id)) {
353
+ problems.at(
354
+ yaml,
355
+ [...path, "id"],
356
+ `${id} is also the anchor of a section of this page; give the exercise its own id`,
357
+ );
358
+ }
359
+ pageAnchors.add(id);
360
+ }
361
+ anchors.set(`/${chapterId}/${pageId}/`, pageAnchors);
362
+ pages.push({ ...page, id: pageId, chapter: chapterId, file });
363
+ }
364
+ chapters.push({
365
+ id: chapterId,
366
+ title: chapter.title,
367
+ ...(chapter.summary === undefined ? {} : { summary: chapter.summary }),
368
+ ...(chapter.selfAssessment === undefined ? {} : { selfAssessment: chapter.selfAssessment }),
369
+ ...(chapter.scope === undefined ? {} : { scope: chapter.scope }),
370
+ pages,
371
+ });
372
+ }
373
+
374
+ // the self-assessments: each exercise found, checkable, in its scope and listed once
375
+ if (complete) {
376
+ for (const chapter of chapters) {
377
+ const yaml = chapterFiles.get(chapter.id);
378
+ if (chapter.selfAssessment === undefined || yaml === undefined) continue;
379
+ checkAssessment(yaml, chapter.selfAssessment, chapters, chapter.id, uses, problems);
380
+ }
381
+ }
382
+
383
+ // a data file that nothing names is dead: the author believes its content is in the app
384
+ if (complete) {
385
+ for (const id of dataFiles.keys()) {
386
+ if (dataUsed.has(id)) continue;
387
+ problems.file(
388
+ `src/data/${id}.yaml`,
389
+ `never used; no timeline, generated exercise or component prop names { data: ${id} }. Use it, or remove it (explainer-unused-data)`,
390
+ );
391
+ }
392
+ }
393
+
394
+ // links to pages of the app
395
+ const hidden = app.chapters.length === 1;
396
+ for (const link of texts.links) {
397
+ const [path = "", hash] = link.href.split("#");
398
+ const pageAnchors = anchors.get(path);
399
+ const generated =
400
+ path === "/" ||
401
+ (path === "/glossar/" && terms.size > 0) ||
402
+ (!hidden && app.chapters.some((id) => path === `/${id}/`));
403
+ if (pageAnchors === undefined && !generated) {
404
+ problems.at(
405
+ link.yaml,
406
+ link.path,
407
+ `the link ${link.href} leads to no page of the app; write /<chapter>/<page>/, or /<chapter>/<page>/#<section>`,
408
+ link.lang,
409
+ );
410
+ } else if (hash !== undefined && hash !== "" && (pageAnchors === undefined || !pageAnchors.has(hash))) {
411
+ problems.at(link.yaml, link.path, `the link ${link.href} leads to no section of its page`, link.lang);
412
+ }
413
+ }
414
+
415
+ // the scope for the tutor: with the app's, every page has its own; a page's without the app's would be lost
416
+ for (const chapter of chapters) {
417
+ for (const page of chapter.pages) {
418
+ if (app.scope !== undefined && page.scope === undefined) {
419
+ problems.file(
420
+ page.file,
421
+ "has no scope; src/app.yaml has one, so each page says in scope: what it teaches, in your own words from the material",
422
+ );
423
+ } else if (app.scope === undefined && page.scope !== undefined) {
424
+ problems.file(
425
+ page.file,
426
+ "has a scope, but src/app.yaml has none; add scope: { out: [...] } to src/app.yaml, what does not belong to the scope",
427
+ );
428
+ }
429
+ }
430
+ }
431
+
432
+ if (problems.lines.length > 0) return { problems: problems.lines };
433
+ const data: Record<string, DataRecord[]> = {};
434
+ for (const [id, yaml] of dataFiles) data[id] = yaml.value as DataRecord[];
435
+ return {
436
+ site: {
437
+ app,
438
+ chapters,
439
+ data,
440
+ images,
441
+ components: uses,
442
+ storage: appName(root, "explainer"),
443
+ },
444
+ problems: [],
445
+ };
446
+ }
447
+
448
+ /**
449
+ * The content in `root`/src, its generated exercises tested over `seeds` seeds (0: not at all, for a reader of content
450
+ * the build has checked); throws a ContentError with every problem when it is not valid. */
451
+ export function loadSite(root: string, seeds = SEEDS): Site {
452
+ const { site, problems } = check(root, seeds);
453
+ if (site === undefined) throw new ContentError(problems);
454
+ return site;
455
+ }
456
+
457
+ export type { BlockOf };
@@ -0,0 +1,200 @@
1
+ {
2
+ "language": "Deutsch",
3
+ "languages": "Sprache",
4
+ "start": "Start",
5
+ "breadcrumbs": "Du bist hier",
6
+ "contents": "Inhalt",
7
+ "onThisPage": "Auf dieser Seite",
8
+ "pages": "Seiten",
9
+ "chapters": "Kapitel",
10
+ "pager": "Weiterblättern",
11
+ "previous": "Zurück: {title}",
12
+ "next": "Weiter: {title}",
13
+ "glossary": "Glossar",
14
+ "glossaryIntro": "Die Begriffe dieser App, von A bis Z, jeder mit der Seite, die ihn erklärt.",
15
+ "inGlossary": "im Glossar",
16
+ "explainedOn": "Erklärt auf der Seite {page}",
17
+ "sources": "Quellen",
18
+ "sourcesIntro": "Die Fakten in dieser App stammen aus diesen Quellen:",
19
+ "beforeChrist": "{year} v. Chr.",
20
+ "code": "Code in {language}",
21
+ "footer": "Eine Erklär-App auf lernapps.net",
22
+ "imprint": "Impressum",
23
+ "privacy": "Datenschutz",
24
+ "workedTasks": "Drei Beispielaufgaben, jede mit ihrer Lösung:",
25
+ "practice": "Übe selbst, mit immer neuen Aufgaben:",
26
+ "newTask": "Neue Aufgabe",
27
+ "hint": "Tipp",
28
+ "textDescription": "Beschreibung als Text",
29
+ "taskNumber": "Aufgabe Nr. {n}",
30
+ "backToTutor": "Zurück zum Tutor",
31
+ "backToTutorOpen": "Wechsel zurück zum Chat mit {name}.",
32
+ "backToTutorCopied": "Wechsel zurück zu {name} und füg es dort ein. Diesen Tab kannst du offen lassen.",
33
+ "backToTutorFailed": "Kopieren ging nicht. Kopier die markierten Zeilen, wechsel zurück zu {name} und füg sie dort ein.",
34
+ "resultLabel": "Ergebniszeile für deinen Tutor",
35
+ "resultLine": "{page}, {exercise}{number}: {status}.",
36
+ "resultNumber": ", Nr. {n}",
37
+ "resultRight": "richtig im {n}. Versuch",
38
+ "resultSolution": "Lösung angesehen",
39
+ "resultOpen": "noch nicht gelöst",
40
+ "resultTry": "1 Versuch",
41
+ "resultTries": "{n} Versuche",
42
+ "resultInput": "Eingabe {input}",
43
+ "resultSession": "Sitzung: {right} von {total} richtig.",
44
+ "resultTutor": "Tutor-Seite:",
45
+ "resultTrue": "stimmt",
46
+ "resultFalse": "stimmt nicht",
47
+ "stuckAsk": "Lade {url} und hilf mir bei Übung {exercise}{number}. Ich komme nicht weiter.",
48
+ "stuckTask": "Übung {exercise}{number}:",
49
+ "stuckInput": "Ich habe {input} eingegeben ({tries}).",
50
+ "stuckTries": "Bisher {tries}.",
51
+ "stuckRight": "Ich habe sie im {n}. Versuch richtig gelöst.",
52
+ "stuckMessage": "Die App sagt: „{message}“",
53
+ "stuckHint": "Ich habe den Tipp geöffnet.",
54
+ "stuckSolution": "Ich habe mir die Lösung angesehen.",
55
+ "stuckPrivacy": "„Warum?“ gibt {name} mit deinem Klick die Aufgabe, deine Eingabe und die Meldung der App.",
56
+ "stuckPrivacyCopy": "Dein Klick kopiert die Aufgabe, deine Eingabe und die Meldung der App für den Chat mit deinem Tutor; die App schickt nichts weg.",
57
+ "stuckCopied": "Wechsel zurück zu {name} und füg die Zeilen dort ein.",
58
+ "stuckFailed": "Kopieren ging nicht. Kopier die markierten Zeilen, wechsel zurück zu {name} und füg sie dort ein.",
59
+ "stuckLabel": "Zeilen für deinen Tutor",
60
+ "tutorTitle": "Mit einem KI-Tutor lernen",
61
+ "tutorAbout": "Ein KI-Tutor ist ein KI-Assistent deiner Wahl, der dich beim Lernen mit dieser App begleitet. Er erklärt dir den Stoff mit den Beispielen der Seiten, gibt dir Aufgaben aus der App und hilft dir, wenn du nicht weiterkommst. Gelöst wird in der App: Sie prüft deine Antworten.",
62
+ "tutorAboutStart": "Wähl hier deinen Assistenten. Dann steht er auf jeder Seite unter dem Titel bereit, und jede Übung hat den Knopf „Hilfe“. Du kannst ihn auch auf einer Seite unter dem Titel einschalten.",
63
+ "tutorAboutPrivacy": "Deine Wahl bleibt auf diesem Gerät; erst dein Klick öffnet den Assistenten. Frag deine Eltern oder Lehrkraft, ob du ihn nutzen darfst.",
64
+ "tutorAccount": "Du nutzt dein eigenes Konto beim Anbieter. Der Assistent ist kein Angebot von lernapps.net: lernapps liefert nur Inhalte und Übungen.",
65
+ "tutorScope": "Die Wahl gilt für alle Seiten, nur auf diesem Gerät.",
66
+ "tutorChoose": "Dein KI-Assistent",
67
+ "tutorNone": "Keiner",
68
+ "needsTitle": "Was dir beim Lernen hilft",
69
+ "needsOpen": "Einstellungen",
70
+ "needsDone": "Fertig",
71
+ "needsAbout": "Hier kannst du deinem Tutor sagen, wie er mit dir sprechen soll. Kreuz an, was dir hilft; du kannst mehreres wählen.",
72
+ "needKurzeSchritte": "Kurze Schritte",
73
+ "needKurzeSchritteAbout": "Der Tutor erklärt in kleinen Schritten, immer nur eine Sache auf einmal. Du weißt immer, wie weit es noch ist.",
74
+ "needWenigLesen": "Wenig lesen",
75
+ "needWenigLesenAbout": "Der Tutor schreibt sehr kurz und einfach und erklärt jedes Fachwort. Tipp: Lass dir seine Antworten vorlesen und sprich deine Antwort ein.",
76
+ "needMehrTiefe": "Mehr Tiefe",
77
+ "needMehrTiefeAbout": "Der Tutor überspringt, was du schon kannst, und stellt dir knifflige Warum- und Was-wäre-wenn-Fragen.",
78
+ "needAndereMuttersprache": "Andere Muttersprache",
79
+ "needAndereMutterspracheAbout": "Der Tutor erklärt auf Deutsch in einfachen Sätzen und übersetzt neue Fachwörter in deine Sprache. Du darfst auch in deiner Sprache schreiben.",
80
+ "needKlarUndWoertlich": "Klar und wörtlich",
81
+ "needKlarUndWoertlichAbout": "Der Tutor schreibt genau das, was er meint: ohne Witze, versteckte Bedeutungen oder Redewendungen. Er sagt dir immer, wo ihr gerade seid.",
82
+ "needRechnenAnschaulich": "Rechnen anschaulich",
83
+ "needRechnenAnschaulichAbout": "Der Tutor zeigt dir, wie viel eine Zahl ist, und erklärt in kleinen Schritten. Du rechnest selbst und hast so viel Zeit, wie du brauchst.",
84
+ "needsLanguage": "Deine Muttersprache",
85
+ "needsLanguageNone": "Bitte wählen",
86
+ "needsScopeTitle": "Das gilt",
87
+ "needsScopeAll": "für alle Erklär-Apps auf lernapps.net",
88
+ "needsScopeApp": "nur für diese App",
89
+ "needsPrivacy": "Deine Wahl bleibt auf diesem Gerät. Der Tutor bekommt nur die Namen der Einstellungen, die du ankreuzt, und erst, wenn du ihn öffnest.",
90
+ "tutorOther": "Ein anderer: Text kopieren",
91
+ "tutorOn": "Einschalten",
92
+ "tutorSomeone": "deinen KI-Tutor",
93
+ "help": "Hilfe",
94
+ "helpWhy": "Warum?",
95
+ "helpBack": "Zurück",
96
+ "helpLabel": "{name} um Hilfe bitten",
97
+ "helpWhyLabel": "{name} fragen, warum",
98
+ "helpBackLabel": "Zurück zu {name}",
99
+ "tutorCopy": "Text kopieren",
100
+ "tutorOpenWith": "Mit {name} öffnen",
101
+ "tutorGo": "Mit {name} lernen",
102
+ "tutorWay": "Assistent",
103
+ "tutorPageIntro": "Ein KI-Tutor erklärt dir diese Seite und hilft dir bei den Übungen. Wähl deinen Assistenten:",
104
+ "tutorPageLink": "Ansehen, was dein Tutor zu dieser Seite bekommt",
105
+ "tutorPasteHint": "{name} lädt keine Webseiten, wenn der Link aus einem Knopf kommt. Darum kopieren wir den Satz mit dem Link für dich. Ein Klick auf „Mit {name} lernen“ öffnet {name} mit einer kurzen Nachricht. Füg danach den kopierten Satz ein (Strg+V, am Mac ⌘+V) und schick ihn ab.",
106
+ "tutorDone": "✓ Kopiert – jetzt in {name} einfügen",
107
+ "tutorCopied": "Der Prompt ist kopiert. Füg ihn in den Chat deines KI-Assistenten ein.",
108
+ "tutorCopyFailed": "Kopieren ging nicht. Versuch es noch einmal.",
109
+ "exerciseLabel": "Übung",
110
+ "videoChannel": "{channel} · YouTube",
111
+ "videoNote": "Beim Abspielen gehen Daten an YouTube/Google, darunter deine IP-Adresse.",
112
+ "videoPlay": "Video abspielen",
113
+ "videoAlways": "Videos immer direkt laden",
114
+ "assessmentTitle": "Selbsteinschätzung",
115
+ "assessmentOfferTitle": "Was kannst du schon?",
116
+ "assessmentOfferApp": "Freiwillig: Bevor du anfängst, kannst du {count} Aufgaben aus dieser App lösen. Am Ende siehst du für jede Seite, ob du sie dir genauer ansehen solltest.",
117
+ "assessmentOfferChapter": "Freiwillig: Bevor du anfängst, kannst du {count} Aufgaben aus diesem Kapitel lösen. Am Ende siehst du für jede Seite, ob du sie dir genauer ansehen solltest.",
118
+ "assessmentOfferStart": "Selbsteinschätzung starten",
119
+ "assessmentOfferLast": "Dein letztes Ergebnis ansehen",
120
+ "assessmentIntro": "Mit dieser Selbsteinschätzung bekommst du mit {count} Aufgaben einen Überblick, was du schon kannst.",
121
+ "assessmentAllAsked": "Sie fragt jede Seite ab.",
122
+ "assessmentLeftOut": "{n} Seiten fragt sie nicht ab. Es lohnt sich also, sie einmal anzuschauen:",
123
+ "assessmentLeftOutOne": "Eine Seite fragt sie nicht ab. Es lohnt sich also, sie einmal anzuschauen:",
124
+ "assessmentHow": "Die App sagt dir nicht bei jeder Aufgabe, ob sie stimmt. Erst am Ende siehst du für jede abgefragte Seite, ob du sie schon kannst. Was du leer lässt, zählt als nicht gekonnt.",
125
+ "assessmentPrivacy": "Dein Ergebnis bleibt nur auf diesem Gerät. Ein neuer Start ersetzt es.",
126
+ "assessmentBegin": "Los geht's",
127
+ "assessmentStep": "Seite {n} von {count}",
128
+ "assessmentTask": "Aufgabe {n}",
129
+ "assessmentBack": "Zurück",
130
+ "assessmentNext": "Weiter",
131
+ "assessmentFinish": "Auswerten",
132
+ "assessmentResultTitle": "Dein Ergebnis",
133
+ "assessmentScore": "Du hast {right} von {total} Aufgaben richtig gelöst.",
134
+ "assessmentScorePartly": "Du hast {right} von {total} Aufgaben richtig gelöst und {partly} teilweise.",
135
+ "assessmentAdvice": "Fang mit den Seiten an, die du genau lesen solltest.",
136
+ "assessmentAllKnown": "Die abgefragten Seiten kannst du wahrscheinlich schon. Üb trotzdem auf den Seiten, damit es sicher sitzt.",
137
+ "assessmentKnown": "Kannst du wahrscheinlich schon",
138
+ "assessmentPartly": "Schau sie dir noch einmal an",
139
+ "assessmentRead": "Lies sie genau",
140
+ "assessmentUnasked": "Nicht abgefragt",
141
+ "assessmentPageScore": "{right} von {total} richtig",
142
+ "assessmentPageScorePartly": "{right} von {total} richtig, {partly} teilweise",
143
+ "assessmentRestart": "Neu starten",
144
+ "assessmentDelete": "Ergebnis löschen",
145
+ "assessmentDeleted": "Das Ergebnis ist gelöscht.",
146
+ "assessmentTutorTitle": "Mit einem KI-Tutor weiterlernen",
147
+ "assessmentTutorIntro": "Ein KI-Tutor kann mit deinem Ergebnis die Seiten mit dir durchgehen, die noch nicht sitzen. Dein Klick gibt ihm das Ergebnis und deine Eingaben; sonst schickt die App nichts weg.",
148
+ "assessmentTutorGo": "Mit {name} besprechen",
149
+ "assessmentTutorPage": "Ansehen, was dein Tutor bekommt",
150
+ "assessmentNojs": "Die Selbsteinschätzung braucht JavaScript.",
151
+ "assessmentAsk": "Lade {url} und sei mein Tutor. Ich habe die Selbsteinschätzung gemacht. Mein Ergebnis je Seite:",
152
+ "assessmentLine": "{key} ({page}, Übung {exercise}{number}): {state}.",
153
+ "assessmentRight": "richtig",
154
+ "assessmentPartlyRight": "teilweise richtig",
155
+ "assessmentWrong": "falsch",
156
+ "assessmentOpen": "nicht beantwortet",
157
+ "assessmentInput": "Eingabe: {input}",
158
+ "assessmentMissed": "Nicht erreicht: {points}.",
159
+ "assessmentPageLine": "„{page}“: {band} ({right} von {total} richtig).",
160
+ "assessmentPageLinePartly": "„{page}“: {band} ({right} von {total} richtig, {partly} teilweise).",
161
+ "assessmentPageUnasked": "„{page}“: nicht abgefragt.",
162
+ "assessmentTasks": "Meine Aufgaben:",
163
+ "assessmentBackIntro": "Drück „Zurück zum Tutor“. Das kopiert dein Ergebnis. Füg es im Chat mit deinem Tutor ein.",
164
+ "assessmentBackLine": "Ich habe die Selbsteinschätzung gemacht. Mein Ergebnis je Seite:",
165
+ "assessmentTutorRun": "Diese Runde ist für deinen Tutor: Die App speichert nichts davon.",
166
+ "sessionIntro": "Ich habe auf {url} geübt:",
167
+ "sessionNone": "Ich habe auf {url} noch keine Aufgabe gelöst.",
168
+ "sessionTask": "{exercise}{number}: {status}.",
169
+ "assessmentNextRound": "Link für eine neue Runde: {url}",
170
+ "assessmentLinks": "Links für meinen Tutor, genau diese nehmen:",
171
+ "assessmentLinkChapter": "Kapitel „{chapter}“, Selbsteinschätzung: {url}",
172
+ "assessmentLinkChapterNone": "Kapitel „{chapter}“ (keine Selbsteinschätzung):",
173
+ "assessmentLinkPage": "„{page}“: {url}",
174
+ "assessmentLinkWhole": "Selbsteinschätzung der ganzen App: {url}",
175
+ "backToTutorDone": "✓ Kopiert",
176
+ "tutorSomeoneTo": "deinem KI-Tutor",
177
+ "openTaskLabel": "Offene Aufgabe",
178
+ "openTaskPoints": "Eine gute Antwort enthält:",
179
+ "openTaskForm": "Form",
180
+ "openTaskSource": "Nachlesen",
181
+ "openTaskChoice": "Deine Lösung",
182
+ "openTaskType": "Hier tippen",
183
+ "openTaskPaper": "Auf Papier, dann Foto",
184
+ "openTaskAnswer": "Deine Antwort",
185
+ "openTaskPaperHow": "Schreib deine Antwort auf ein Blatt. Dann fotografiere es.",
186
+ "openTaskPhoto": "Blatt fotografieren",
187
+ "openTaskPhotoAgain": "Neues Foto",
188
+ "openTaskPhotoAlt": "Foto deines Blatts",
189
+ "openTaskPhotoLocal": "Das Foto bleibt auf diesem Gerät: Die App speichert und sendet es nicht.",
190
+ "openTaskCompare": "Vergleichen",
191
+ "openTaskWriteFirst": "Schreib zuerst deine Antwort. Dann kannst du vergleichen.",
192
+ "openTaskPhotoFirst": "Fotografiere zuerst dein Blatt. Dann kannst du vergleichen.",
193
+ "openTaskCheck": "Was steht in deiner Antwort?",
194
+ "openTaskCheckHow": "Hake ab, was du wirklich geschrieben hast.",
195
+ "openTaskChange": "Antwort ändern",
196
+ "openTaskLocked": "Die Lösung öffnet sich nach deiner Antwort.",
197
+ "resultPartly": "teilweise richtig ({n} von {total} Punkten, es fehlen {missed})",
198
+ "resultPhoto": "Antwort als Foto",
199
+ "resultAnswer": "Antwort „{input}“"
200
+ }