@lernapps/app-templates 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +142 -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,238 @@
1
+ // The result line that "Zurück" copies on a page a tutor opened (explainer.ts). The app sends nothing and reports
2
+ // nothing back: the tutor learns only what the learner writes into the chat, so the button puts the line on the
3
+ // clipboard for the learner to paste there. It names the page, the exercise and its task number, how far the learner
4
+ // got with it ("richtig im 2. Versuch", "Lösung angesehen", "noch nicht gelöst (1 Versuch, Eingabe 3/20)"), the count
5
+ // of the session on this page, and the absolute address of the page's tutor page with the position (position.ts),
6
+ // which holds the task (tutorpage.ts): a chat assistant fetches only addresses its user wrote into the conversation,
7
+ // so the learner brings it. The solution is never in the line, as the learner reads it while pasting. Pure and
8
+ // without Node: the client module calls it.
9
+ import { rightEntries, type Answer, type Question } from "../shared/questions/question.ts";
10
+ import { message } from "./messages.ts";
11
+
12
+ /**
13
+ * How far the learner got with one task: the tries, the try that was right, the solution viewed, the last wrong input
14
+ * with the message the app showed for it, word for word, and whether the learner opened the hint.
15
+ */
16
+ export interface TaskState {
17
+ attempts: number;
18
+ rightAt?: number;
19
+ revealed: boolean;
20
+ input?: string;
21
+ message?: string;
22
+ hint?: boolean;
23
+ /** For an open task not all right: how many of its points the learner ticked, of how many, and the missed ones (P1). */
24
+ points?: { ticked: number; total: number; missed: string };
25
+ }
26
+
27
+ /** The count of the session: the tasks answered right, of all the learner answered or looked up. */
28
+ export interface Session {
29
+ right: number;
30
+ total: number;
31
+ }
32
+
33
+ const key = (id: string, n: number | undefined) => (n === undefined ? id : `${id}=${n}`);
34
+
35
+ /**
36
+ * What the learner did on this page, by task (an exercise and its task number), kept in memory only: the page stores
37
+ * nothing, so the session is this visit of the page, which a tutor's link opens in a tab of its own.
38
+ */
39
+ export class Progress {
40
+ readonly #tasks = new Map<string, TaskState>();
41
+ readonly #listeners: ((id: string) => void)[] = [];
42
+ #last: string | undefined;
43
+
44
+ /** Calls `listener` with the exercise's id whenever the state of one of its tasks changes. */
45
+ listen(listener: (id: string) => void): void {
46
+ this.#listeners.push(listener);
47
+ }
48
+
49
+ #set(id: string, n: number | undefined, state: TaskState): void {
50
+ this.#tasks.set(key(id, n), state);
51
+ for (const listener of this.#listeners) listener(id);
52
+ }
53
+
54
+ /**
55
+ * A complete answer to the task number `n` of the exercise `id` (none for an exercise without numbers): the input as
56
+ * text, and the message the app showed for a wrong one, word for word.
57
+ */
58
+ answered(id: string, n: number | undefined, right: boolean, input?: string, message?: string): void {
59
+ this.#last = id;
60
+ const state = this.state(id, n);
61
+ // once the task is right or its solution shown, nothing after counts
62
+ if (state.rightAt !== undefined || state.revealed) return;
63
+ const attempts = state.attempts + 1;
64
+ const { input: _input, message: _message, ...rest } = state;
65
+ this.#set(id, n, {
66
+ ...rest,
67
+ attempts,
68
+ ...(right ? { rightAt: attempts } : {}),
69
+ ...(right || input === undefined ? {} : { input }),
70
+ ...(right || message === undefined || message === "" ? {} : { message }),
71
+ });
72
+ }
73
+
74
+ /**
75
+ * The open task `id` as the learner compared it: `ticked` of `total` points, the `missed` ones named (P2, P4), the
76
+ * answer as text (or that it is a photo). `attempt`: a new comparing, which counts as a try; a changed tick changes
77
+ * the same try. Its model answer shows once compared.
78
+ */
79
+ compared(id: string, ticked: number, total: number, missed: string, input: string, attempt = true): void {
80
+ this.#last = id;
81
+ const state = this.state(id, undefined);
82
+ const attempts = attempt ? state.attempts + 1 : Math.max(state.attempts, 1);
83
+ const { points: _points, input: _input, rightAt: _rightAt, ...rest } = state;
84
+ this.#set(id, undefined, {
85
+ ...rest,
86
+ attempts,
87
+ revealed: true,
88
+ ...(ticked === total ? { rightAt: attempts } : { points: { ticked, total, missed }, input }),
89
+ });
90
+ }
91
+
92
+ /** The learner opened the hint of the task. */
93
+ hinted(id: string, n: number | undefined): void {
94
+ const state = this.state(id, n);
95
+ if (state.hint === true) return;
96
+ this.#set(id, n, { ...state, hint: true });
97
+ }
98
+
99
+ /** The solution of the task shows ("Lösung"; also after the right answer, which then stays right). */
100
+ revealed(id: string, n: number | undefined): void {
101
+ this.#last = id;
102
+ const state = this.state(id, n);
103
+ if (state.rightAt !== undefined) return;
104
+ this.#set(id, n, { ...state, revealed: true });
105
+ }
106
+
107
+ /** How far the learner got with the task. */
108
+ state(id: string, n: number | undefined): TaskState {
109
+ return this.#tasks.get(key(id, n)) ?? { attempts: 0, revealed: false };
110
+ }
111
+
112
+ /** The exercise the learner answered last, if any. */
113
+ get last(): string | undefined {
114
+ return this.#last;
115
+ }
116
+
117
+ /** Each task the learner answered or looked up in this session, in the order they first did. */
118
+ tasks(): { id: string; number?: number; state: TaskState }[] {
119
+ return [...this.#tasks.entries()]
120
+ .filter(([, state]) => state.attempts > 0 || state.revealed)
121
+ .map(([key, state]) => {
122
+ const [id = "", n] = key.split("=");
123
+ return { id, ...(n === undefined ? {} : { number: Number(n) }), state };
124
+ });
125
+ }
126
+
127
+ /** The count of the session. */
128
+ session(): Session {
129
+ // a hint alone does not count: the learner neither answered nor looked up the task
130
+ const states = [...this.#tasks.values()].filter((state) => state.attempts > 0 || state.revealed);
131
+ return { right: states.filter((state) => state.rightAt !== undefined).length, total: states.length };
132
+ }
133
+ }
134
+
135
+ /** What one result line says. */
136
+ export interface ResultLine {
137
+ /** The page's title. */
138
+ page: string;
139
+ /** The exercise's id. */
140
+ exercise: string;
141
+ /** The task number, for an exercise with numbers. */
142
+ number?: number;
143
+ state: TaskState;
144
+ session: Session;
145
+ /** The page's tutor page with the position, absolute. */
146
+ tutor: string;
147
+ }
148
+
149
+ /** How far the learner got with a task, in `lang`: "richtig im 2. Versuch", "noch nicht gelöst (1 Versuch, …)". */
150
+ function statusOf(state: TaskState, lang: string): string {
151
+ if (state.rightAt !== undefined) return message(lang, "resultRight", { n: String(state.rightAt) });
152
+ if (state.points !== undefined) {
153
+ const partly = message(lang, "resultPartly", {
154
+ n: String(state.points.ticked),
155
+ total: String(state.points.total),
156
+ missed: state.points.missed,
157
+ });
158
+ return state.input === undefined ? partly : `${partly}, ${message(lang, "resultAnswer", { input: state.input })}`;
159
+ }
160
+ const status = message(lang, state.revealed ? "resultSolution" : "resultOpen");
161
+ if (state.attempts === 0) return status;
162
+ const tries =
163
+ state.attempts === 1 ? message(lang, "resultTry") : message(lang, "resultTries", { n: String(state.attempts) });
164
+ const input = state.input === undefined ? "" : `, ${message(lang, "resultInput", { input: state.input })}`;
165
+ return `${status} (${tries}${input})`;
166
+ }
167
+
168
+ /**
169
+ * What "Zurück zum Tutor" at the top or the end of a page copies, in `lang`: the page's tutor page with the page's
170
+ * task numbers, then a line for each task the learner answered or looked up in this session (the exercise, its task
171
+ * number, how far the learner got, the last wrong input and the app's message for it, word for word), then the count of
172
+ * the session; a tutor loads the tutor page and coaches on exactly these tasks.
173
+ */
174
+ export function sessionLines(
175
+ tutor: string,
176
+ tasks: readonly { id: string; number?: number; state: TaskState }[],
177
+ session: Session,
178
+ lang: string,
179
+ ): string {
180
+ if (tasks.length === 0) return message(lang, "sessionNone", { url: tutor });
181
+ return [
182
+ message(lang, "sessionIntro", { url: tutor }),
183
+ ...tasks.map((task) => {
184
+ const said =
185
+ task.state.rightAt === undefined && task.state.message !== undefined
186
+ ? ` ${message(lang, "stuckMessage", { message: task.state.message.replace(/\s+/g, " ").trim() })}`
187
+ : "";
188
+ return `- ${message(lang, "sessionTask", {
189
+ exercise: task.id,
190
+ number: task.number === undefined ? "" : message(lang, "resultNumber", { n: String(task.number) }),
191
+ status: statusOf(task.state, lang),
192
+ })}${said}`;
193
+ }),
194
+ message(lang, "resultSession", { right: String(session.right), total: String(session.total) }),
195
+ ].join("\n");
196
+ }
197
+
198
+ /** The result line, two lines in `lang`: the result, and the page's tutor page. */
199
+ export function resultLine(line: ResultLine, lang: string): string {
200
+ const first = message(lang, "resultLine", {
201
+ page: line.page,
202
+ exercise: line.exercise,
203
+ number: line.number === undefined ? "" : message(lang, "resultNumber", { n: String(line.number) }),
204
+ status: statusOf(line.state, lang),
205
+ });
206
+ const session =
207
+ line.session.total === 0
208
+ ? ""
209
+ : ` ${message(lang, "resultSession", { right: String(line.session.right), total: String(line.session.total) })}`;
210
+ return [`${first}${session}`, `${message(lang, "resultTutor")} ${line.tutor}`].join("\n");
211
+ }
212
+
213
+ const oneLine = (text: string) => text.replace(/\s+/g, " ").trim();
214
+
215
+ /** The learner's answer to a question as text, in `lang`: the options chosen, the number typed, the order given. */
216
+ export function answerShown(question: Question, answer: Answer, lang: string): string {
217
+ const indices = typeof answer === "number" ? [answer] : Array.isArray(answer) ? (answer as readonly number[]) : [];
218
+ switch (question.type) {
219
+ case "single-choice":
220
+ case "multiple-choice":
221
+ return indices.map((index) => oneLine(question.options[index]?.text ?? "?")).join("; ");
222
+ case "true-false":
223
+ return message(lang, answer === true ? "resultTrue" : "resultFalse");
224
+ case "number":
225
+ return oneLine(String(answer));
226
+ case "ordering":
227
+ return indices
228
+ .map((place, item) => ({ place, item }))
229
+ .filter(({ place }) => place >= 0)
230
+ .sort((a, b) => a.place - b.place)
231
+ .map(({ item }) => oneLine(question.items[item] ?? "?"))
232
+ .join(" → ");
233
+ case "matching":
234
+ return question.pairs
235
+ .map((pair, index) => `${oneLine(pair.left)} → ${oneLine(rightEntries(question)[indices[index] ?? -1] ?? "?")}`)
236
+ .join("; ");
237
+ }
238
+ }
@@ -0,0 +1,119 @@
1
+ // The pages an explainer's build writes, per language, and how they link: the start page, a page per chapter (none
2
+ // when the app has one chapter, which the navigation then hides), the content pages in the order of the lists, the
3
+ // glossary when the content defines terms, and the page of each self-assessment (assessment.ts), linked only from
4
+ // the start page or its chapter's page. German is at the root, every other language below /<language>/;
5
+ // within a language tree the addresses are the same: <chapter>/<page>/, glossar/.
6
+ import {
7
+ allBlocks,
8
+ inLanguage,
9
+ termId,
10
+ type BlockOf,
11
+ type Chapter,
12
+ type Page,
13
+ type Site,
14
+ type Text,
15
+ } from "./content.ts";
16
+ import { languageRoot } from "../shared/pages.ts";
17
+ import { message } from "./messages.ts";
18
+ import { assessments, type Assessment } from "./assessment.ts";
19
+
20
+ export interface Route {
21
+ kind: "start" | "chapter" | "page" | "glossary" | "assessment";
22
+ lang: string;
23
+ /** Where the page is, from the app's root: "", "prozent/", "en/prozent/prozentwert/", "glossar/". */
24
+ path: string;
25
+ /** The same page's address within any language tree: "", "prozent/prozentwert/". */
26
+ local: string;
27
+ /** Its title, the h1. */
28
+ title: string;
29
+ chapter?: Chapter;
30
+ page?: Page;
31
+ /** The self-assessment of the page of kind assessment. */
32
+ assessment?: Assessment;
33
+ }
34
+
35
+ /** Whether the app's one chapter is hidden in the navigation. */
36
+ export const hiddenChapter = (site: Site): boolean => site.chapters.length === 1;
37
+
38
+ /** The content pages in order, over all chapters. */
39
+ export const contentPages = (site: Site): Page[] => site.chapters.flatMap((chapter) => chapter.pages);
40
+
41
+ /** A definition of the content, where it is. */
42
+ export interface Term {
43
+ id: string;
44
+ term: Text;
45
+ explanation: Text;
46
+ page: Page;
47
+ }
48
+
49
+ export { allBlocks };
50
+
51
+ /** The terms the content defines, in the order of the pages. */
52
+ export function terms(site: Site): Term[] {
53
+ return contentPages(site).flatMap((page) =>
54
+ [...allBlocks(page.blocks)]
55
+ .filter((block): block is BlockOf<"definition"> => block.type === "definition")
56
+ .map((block) => ({ id: termId(block.term), term: block.term, explanation: block.explanation, page })),
57
+ );
58
+ }
59
+
60
+ /** Every page of the app, in every language. */
61
+ export function routes(site: Site): Route[] {
62
+ const hidden = hiddenChapter(site);
63
+ const glossary = terms(site).length > 0;
64
+ const all = assessments(site);
65
+ return site.app.languages.flatMap((lang) => {
66
+ const root = languageRoot(lang);
67
+ const list: Route[] = [{ kind: "start", lang, path: root, local: "", title: inLanguage(site.app.title, lang) }];
68
+ const assessmentRoute = (assessment: Assessment): Route => ({
69
+ kind: "assessment",
70
+ lang,
71
+ path: root + assessment.local,
72
+ local: assessment.local,
73
+ title: message(lang, "assessmentTitle"),
74
+ ...(assessment.chapter === undefined ? {} : { chapter: assessment.chapter }),
75
+ assessment,
76
+ });
77
+ for (const assessment of all.filter((candidate) => candidate.chapter === undefined)) {
78
+ list.push(assessmentRoute(assessment));
79
+ }
80
+ for (const chapter of site.chapters) {
81
+ if (!hidden) {
82
+ const local = `${chapter.id}/`;
83
+ list.push({
84
+ kind: "chapter",
85
+ lang,
86
+ path: root + local,
87
+ local,
88
+ title: inLanguage(chapter.title, lang),
89
+ chapter,
90
+ });
91
+ for (const assessment of all.filter((candidate) => candidate.chapter === chapter)) {
92
+ list.push(assessmentRoute(assessment));
93
+ }
94
+ }
95
+ for (const page of chapter.pages) {
96
+ const local = `${chapter.id}/${page.id}/`;
97
+ list.push({
98
+ kind: "page",
99
+ lang,
100
+ path: root + local,
101
+ local,
102
+ title: inLanguage(page.title, lang),
103
+ chapter,
104
+ page,
105
+ });
106
+ }
107
+ }
108
+ if (glossary) {
109
+ list.push({
110
+ kind: "glossary",
111
+ lang,
112
+ path: `${root}glossar/`,
113
+ local: "glossar/",
114
+ title: message(lang, "glossary"),
115
+ });
116
+ }
117
+ return list;
118
+ });
119
+ }
@@ -0,0 +1,140 @@
1
+ // The explainer's content schema (explainer.v1.schema.json) at build time: the validators of its files (the app, a
2
+ // chapter, a page, a data file), and the explainer's own words for the errors of the schema (what a quote or a bundled
3
+ // image lacks, which fields a block type has, which an exercise of a question type); the general messages are
4
+ // shared/content.ts's.
5
+ import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js";
6
+ import schema from "./explainer.v1.schema.json" with { type: "json" };
7
+ import questionSchema from "../shared/questions/question.v1.schema.json" with { type: "json" };
8
+ import { knownProperties, schemasById } from "../shared/schema.ts";
9
+ import { addressProblem, QUESTION_BRANCH, questionSchemas } from "../shared/questions/validate.ts";
10
+ import { isObject, schemaProblems, type DescribeError, type Problems, type YamlFile } from "../shared/content.ts";
11
+
12
+ export const SCHEMA_ID = schema.$id;
13
+ /** Where an app finds the schema of its content, for the message that names it. */
14
+ export const SCHEMA_FILE = "node_modules/@lernapps/app-templates/runtime/explainer/explainer.v1.schema.json";
15
+
16
+ // union types ("type": ["string", "object"]) keep the messages of the comparator and the lists plain
17
+ const ajv = new Ajv2020({ allErrors: true, strict: true, allowUnionTypes: true });
18
+ for (const each of questionSchemas) ajv.addSchema(each);
19
+ ajv.addSchema(schema);
20
+ const validator = (name: string): ValidateFunction => {
21
+ const validate = ajv.getSchema(`${SCHEMA_ID}#/$defs/${name}`);
22
+ if (validate === undefined) throw new Error(`the schema has no $defs/${name}`);
23
+ return validate;
24
+ };
25
+ export const validators = {
26
+ app: validator("app"),
27
+ chapter: validator("chapter"),
28
+ page: validator("page"),
29
+ data: validator("data"),
30
+ event: validator("event"),
31
+ };
32
+
33
+ const schemas = schemasById(...questionSchemas, schema);
34
+
35
+ const BLOCK_DEFINITIONS: Readonly<Record<string, string>> = {
36
+ text: "textBlock",
37
+ formula: "formula",
38
+ figure: "figure",
39
+ "labelled-figure": "labelledFigure",
40
+ table: "table",
41
+ timeline: "timeline",
42
+ definition: "definition",
43
+ quote: "quote",
44
+ code: "code",
45
+ video: "video",
46
+ example: "box",
47
+ note: "box",
48
+ section: "section",
49
+ generated: "generated",
50
+ "open-task": "openTask",
51
+ "vue-component": "vueComponent",
52
+ };
53
+
54
+ /** The fields of an exercise of a question type: those of every question and those of its type. */
55
+ const questionFields = (type: string): Set<string> => {
56
+ const own = knownProperties(schemas, questionSchema.$id, "question", { branches: false });
57
+ const branch = knownProperties(schemas, questionSchema.$id, QUESTION_BRANCH[type] ?? "");
58
+ return new Set([...own, ...branch]);
59
+ };
60
+
61
+ /** What `required` names as missing, with why it is needed. */
62
+ function missing(property: string, object: unknown): string {
63
+ const type = isObject(object) ? object["type"] : undefined;
64
+ if (property === "origin") return "origin is missing; a quote names who said or wrote it, where and when";
65
+ if ((property === "licence" || property === "source") && type === "quote") {
66
+ return `${property} is missing; a quote names its source and its licence`;
67
+ }
68
+ if (property === "licence" || property === "source") {
69
+ return `${property} is missing; a bundled image names its source and its licence`;
70
+ }
71
+ return `${property} is missing`;
72
+ }
73
+
74
+ /** The explainer's own words for the errors of its schema: what a block or an exercise lacks, or may not have. */
75
+ const describe: DescribeError = (error, path, value) => {
76
+ switch (error.keyword) {
77
+ case "anyOf": {
78
+ // the address of a link of an exercise of the question types: an https address, or a map of them by language
79
+ if (path.at(-1) !== "url") return undefined;
80
+ const { lang, message } = addressProblem(value);
81
+ return { message, ...(lang === undefined ? {} : { lang }) };
82
+ }
83
+ case "pattern":
84
+ // an exercise of a self-assessment: by its page and id
85
+ if (path.at(-2) === "exercises" && path[0] === "selfAssessment") {
86
+ return { message: `${String(value)} is no exercise's address; write /<chapter>/<page>/#<exercise>` };
87
+ }
88
+ // a video's id: an address pasted whole is the likely mistake
89
+ if (path.at(-1) !== "youtube") return undefined;
90
+ return {
91
+ message: `${String(value)} is no YouTube id; give the 11 characters after watch?v= in the video's address, e.g. rkLmLaFHodo`,
92
+ };
93
+ case "required":
94
+ case "dependentRequired":
95
+ return { message: missing(String(error.params["missingProperty"]), value) };
96
+ case "unevaluatedProperties": {
97
+ const property = String(error.params["unevaluatedProperty"]);
98
+ const type = isObject(value) ? value["type"] : undefined;
99
+ const kind = isObject(value) ? value["kind"] : undefined;
100
+ const definition =
101
+ path.at(-1) === "draw"
102
+ ? "drawing"
103
+ : typeof type === "string"
104
+ ? BLOCK_DEFINITIONS[type]
105
+ : typeof kind === "string"
106
+ ? "drawing"
107
+ : undefined;
108
+ if (typeof type === "string" && type in QUESTION_BRANCH) {
109
+ return questionFields(type).has(property)
110
+ ? null
111
+ : { message: `${property} is not a field of a ${type} exercise` };
112
+ }
113
+ if (
114
+ definition === undefined ||
115
+ property === "type" ||
116
+ knownProperties(schemas, schema.$id, definition).has(property)
117
+ )
118
+ return null;
119
+ const what = path.at(-1) === "draw" ? `a ${String(kind)} drawing` : `a ${String(type)} block`;
120
+ return { message: `${property} is not a field of ${what}` };
121
+ }
122
+ case "enum":
123
+ if (path.at(-1) !== "type") return undefined;
124
+ return {
125
+ path: path.slice(0, -1),
126
+ message: `${String(value)} is not a block type; use one of ${(error.params["allowedValues"] as string[]).join(", ")}`,
127
+ };
128
+ }
129
+ return undefined;
130
+ };
131
+
132
+ export function validate(yaml: YamlFile, kind: keyof typeof validators, problems: Problems): boolean {
133
+ const check = validators[kind];
134
+ if (check(yaml.value)) return true;
135
+ const before = problems.lines.length;
136
+ schemaProblems(yaml, check.errors ?? [], problems, describe);
137
+ // every error folded away: the file is still invalid
138
+ if (problems.lines.length === before) problems.at(yaml, [], "does not match the schema");
139
+ return false;
140
+ }
@@ -0,0 +1,87 @@
1
+ // The scope of an explainer, for the tutor: the whole learning path in the creator's own words, from the material, as
2
+ // continuous prose. Each content page says what it teaches (`scope` in its YAML), a chapter may say what it is about as
3
+ // a whole, and src/app.yaml says what the app is about and what does not belong to the scope (`scope: { text, out }`;
4
+ // what belongs to it is what the pages say); with the app's, every page has its own (load.ts checks it). The build
5
+ // writes, in each language tree, a scope beside each content page, <chapter>/<page>/scope/: the page's text and what
6
+ // does not belong to the scope, with a link to its chapter's; beside each chapter, <chapter>/scope/: its text and its pages', with a link to
7
+ // the app's; and the whole at scope/. Each as its Markdown (index.md) and a page that no menu links (index.html,
8
+ // noindex). Each tutor page links its page's scope (../scope/), so that a tutor judges an open answer by the material,
9
+ // not by general knowledge, and goes up to the chapter or the app when a point's source is on another page. The
10
+ // learner never sees it.
11
+ import { renderMarkdown } from "../shared/text/markdown.ts";
12
+ import { escape } from "../shared/html.ts";
13
+ import { inLanguage, type Site, type Text } from "./content.ts";
14
+
15
+ /** The folder of a scope, below the app's root, a chapter's folder or a page's folder in each language tree. */
16
+ export const SCOPE_PAGE = "scope/";
17
+
18
+ /** The words of a scope by language: its closing part and the links one level up. */
19
+ const WORDS = {
20
+ de: {
21
+ out: "Gehört nicht dazu",
22
+ chapter: "Alle Seiten des Kapitels: [Scope des Kapitels](../../scope/)",
23
+ app: "Die ganze App: [Scope der App](../../scope/)",
24
+ },
25
+ en: {
26
+ out: "Out of scope",
27
+ chapter: "All pages of the chapter: [scope of the chapter](../../scope/)",
28
+ app: "The whole app: [scope of the app](../../scope/)",
29
+ },
30
+ } as const;
31
+
32
+ /**
33
+ * The scopes the build writes for `site` in `lang`, by their folder within the language tree: "" the whole,
34
+ * "<chapter>/" a chapter's with its pages, "<chapter>/<page>/" a page's; each but the whole with a link one level up.
35
+ * None when the app has no scope.
36
+ */
37
+ export function scopeFiles(site: Site, lang: string): Map<string, string> {
38
+ const files = new Map<string, string>();
39
+ const { scope } = site.app;
40
+ if (scope === undefined) return files;
41
+ const words = lang === "de" ? WORDS.de : WORDS.en;
42
+ const own = (text: Text | undefined) => (text === undefined ? "" : inLanguage(text, lang).trim());
43
+ const closing = [`## ${words.out}`, scope.out.map((item) => `- ${own(item)}`).join("\n")];
44
+ const join = (...parts: string[]) => `${parts.filter((part) => part !== "").join("\n\n")}\n`;
45
+ const whole: string[] = [`# ${own(site.app.title)}`, own(scope.text)];
46
+ for (const chapter of site.chapters) {
47
+ const pages = chapter.pages.map((page) => ({ page, title: own(page.title), text: own(page.scope) }));
48
+ whole.push(`## ${own(chapter.title)}`, own(chapter.scope));
49
+ for (const { title, text } of pages) whole.push(`### ${title}`, text);
50
+ files.set(
51
+ `${chapter.id}/`,
52
+ join(
53
+ `# ${own(chapter.title)}`,
54
+ own(chapter.scope),
55
+ ...pages.flatMap(({ title, text }) => [`## ${title}`, text]),
56
+ ...closing,
57
+ words.app,
58
+ ),
59
+ );
60
+ for (const { page, title, text } of pages) {
61
+ files.set(`${chapter.id}/${page.id}/`, join(`# ${title}`, text, ...closing, words.chapter));
62
+ }
63
+ }
64
+ files.set("", join(...whole, ...closing));
65
+ return files;
66
+ }
67
+
68
+ /** The page of the scope in `folder` (as scopeFiles names it) in `lang`: its Markdown rendered, for no menu and no search engine. */
69
+ export function scopeDocument(markdown: string, title: string, lang: string, folder = ""): string {
70
+ // a link of the scope to a page leads within the language tree, from <folder>scope/
71
+ const depth = folder.split("/").filter((segment) => segment !== "").length + 1;
72
+ const body = renderMarkdown(markdown, (href) =>
73
+ href.startsWith("/") ? `${"../".repeat(depth)}${href.slice(1)}` : href,
74
+ );
75
+ return [
76
+ "<!doctype html>",
77
+ `<html lang="${escape(lang)}">`,
78
+ "<head>",
79
+ '<meta charset="utf-8">',
80
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
81
+ '<meta name="robots" content="noindex">',
82
+ `<title>${escape(title)}</title>`,
83
+ "</head>",
84
+ `<body><main>${body}</main></body>`,
85
+ "</html>",
86
+ ].join("\n");
87
+ }
@@ -0,0 +1,57 @@
1
+ // "Hilfe" ("Warum?" after a wrong answer), way B to the tutor (explainer.ts): the learner is stuck at one task. The app
2
+ // sends nothing and reports nothing back, so what the tutor needs to help with exactly this task goes into lines the
3
+ // learner sends with the click, in the learner's voice: the exercise and its task number, the last wrong input with
4
+ // the tries, the message the app showed for it word for word (so that the tutor starts from what the learner was
5
+ // told, and does not repeat it), the hint or the solution opened, and the page's tutor page with the position
6
+ // (position.ts, taskQuery), which holds the task and the guide (tutorpage.ts). Without a chat (B1) one sentence names
7
+ // the tutor page and the task, as the prompt of a new chat; on a page a tutor opened (via=tutor, B2) the learner
8
+ // pastes the lines into the running chat, the tutor page last. Pure and without Node: the client module calls it.
9
+ import { message } from "./messages.ts";
10
+ import type { TaskState } from "./result.ts";
11
+
12
+ /** What the lines of one task say. */
13
+ export interface Stuck {
14
+ /** The exercise's id. */
15
+ exercise: string;
16
+ /** The task number, for an exercise with numbers. */
17
+ number?: number;
18
+ state: TaskState;
19
+ /** The page's tutor page with the position, absolute. */
20
+ tutor: string;
21
+ }
22
+
23
+ /** The exercise and its task number, in `lang`: "prozentwert-ueben, Nr. 27". */
24
+ const taskOf = (stuck: Stuck, lang: string) => ({
25
+ exercise: stuck.exercise,
26
+ number: stuck.number === undefined ? "" : message(lang, "resultNumber", { n: String(stuck.number) }),
27
+ });
28
+
29
+ /** What the learner did and was told, in `lang`. */
30
+ function stateLines(state: TaskState, lang: string): string[] {
31
+ const tries =
32
+ state.attempts === 1 ? message(lang, "resultTry") : message(lang, "resultTries", { n: String(state.attempts) });
33
+ const lines: string[] = [];
34
+ if (state.rightAt !== undefined) lines.push(message(lang, "stuckRight", { n: String(state.rightAt) }));
35
+ else if (state.input !== undefined) lines.push(message(lang, "stuckInput", { input: state.input, tries }));
36
+ else if (state.attempts > 0) lines.push(message(lang, "stuckTries", { tries }));
37
+ if (state.rightAt === undefined && state.message !== undefined) {
38
+ lines.push(message(lang, "stuckMessage", { message: state.message.replace(/\s+/g, " ").trim() }));
39
+ }
40
+ if (state.hint === true) lines.push(message(lang, "stuckHint"));
41
+ if (state.revealed) lines.push(message(lang, "stuckSolution"));
42
+ return lines;
43
+ }
44
+
45
+ /** The lines for a running chat (B2), in `lang`: the task, what the learner did and was told, the tutor page. */
46
+ export const stuckLines = (stuck: Stuck, lang: string): string =>
47
+ [
48
+ message(lang, "stuckTask", taskOf(stuck, lang)),
49
+ ...stateLines(stuck.state, lang),
50
+ `${message(lang, "resultTutor")} ${stuck.tutor}`,
51
+ ].join("\n");
52
+
53
+ /** The prompt of a new chat (B1), in `lang`: the tutor page and the task in one sentence, then the lines. */
54
+ export const stuckPrompt = (stuck: Stuck, lang: string): string =>
55
+ [message(lang, "stuckAsk", { url: stuck.tutor, ...taskOf(stuck, lang) }), ...stateLines(stuck.state, lang)].join(
56
+ "\n",
57
+ );