@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,266 @@
1
+ // The build step of an interactive app (@lernapps/app-templates/interactive/plugin), a Vite plugin. An app's
2
+ // vite.config.ts adds it to the shared preset of @lernapps/tooling:
3
+ //
4
+ // import { lernapps } from "@lernapps/tooling/preset";
5
+ // import { interactive } from "@lernapps/app-templates/interactive/plugin";
6
+ // export default lernapps({ plugins: [interactive()] });
7
+ //
8
+ // It reads src/app.yaml and the playthroughs (load.ts) and fails the build with one line per problem. Then, before
9
+ // anything is written:
10
+ // - the component rules on every single-file component in src/ (src/ui/) and the app's own TypeScript modules they
11
+ // import, with the explainer's checks on the SFC compiler's output (../explainer/components.ts): no network, no
12
+ // eval, new Function, innerHTML or v-html, imports only from vue, this runtime, @lernapps/tooling/storage, i18n and
13
+ // a11y, the app's own files in src/ and the libraries in the dependencies of its package.json (bundled; never from
14
+ // a URL), browser globals only in onMounted;
15
+ // - the core (src/core/index.ts, contract.ts), loaded in a Vite server of its own: it exports start and step (and tick,
16
+ // if any, as a function, which a playthrough with ticks needs), every route starts, and every playthrough plays:
17
+ // start(seed, route), then for each step step(state, input) for each of its inputs (its keys as key inputs) and
18
+ // tick(state, TICK) for each of its ticks, twice, giving the same states both times (deterministic by its seed),
19
+ // and after each step the state the playthrough expects. No time passes but the ticks a step names.
20
+ // index.html is Vite's only entry: Vite builds the app's client module (src/main.ts) and stylesheet from it. The built
21
+ // index.html is the shell of every page (render.ts): the start page without any script, and the app page that loads
22
+ // the client module, in each language, with the shell's relative asset URLs rewritten for its depth. An app's code has
23
+ // no size limit. 404.html leads every address the app does not have to its start page. `vp dev` serves the same
24
+ // pages, rendered on each request.
25
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ import vue from "@vitejs/plugin-vue";
28
+ import type { Plugin } from "vite-plus";
29
+ import { codeProblems, sourceScope } from "../shared/vue-code.ts";
30
+ import { pathText, Problems } from "../shared/content.ts";
31
+ import { fromDepth, notFoundPage, withLanguage } from "../shared/pages.ts";
32
+ import { type Load, VUE_OPTIONS, vueAlias, withServer } from "../shared/vue.ts";
33
+ import { doingText, inputsOf, inputText, mismatch, TICK, type Core, type Playthrough } from "./contract.ts";
34
+ import { InteractiveError, loadInteractive, type Interactive } from "./load.ts";
35
+ import { pages, renderPage, type RenderedPage } from "./render.ts";
36
+
37
+ const MARKER = "<!-- interactive -->";
38
+ /** Where the core is, from the app's root. */
39
+ export const CORE = "src/core/index.ts";
40
+
41
+ const CONTRACT =
42
+ "the core exports start(seed, route) and step(state, input), and tick(state, dt) when the app moves on its own (interactive-core-contract)";
43
+
44
+ /** The shell with the page in it; the start page without the shell's scripts. */
45
+ export function fill(shell: string, rendered: RenderedPage): string {
46
+ if (!shell.includes(MARKER)) throw new Error(`index.html has no ${MARKER}: put it in <body>, where the pages go`);
47
+ let html = withLanguage(shell, rendered.page.lang)
48
+ .replace(/\s*<title>[\s\S]*?<\/title>/, "")
49
+ .replace("</head>", ` ${rendered.head}\n </head>`);
50
+ if (rendered.page.kind === "start") {
51
+ html = html
52
+ .replace(/\s*<script type="module"[^>]*><\/script>/g, "")
53
+ .replace(/\s*<link rel="modulepreload"[^>]*>/g, "");
54
+ }
55
+ return fromDepth(html, rendered.page.path).replace(MARKER, () => rendered.body);
56
+ }
57
+
58
+ /** The single-file components of the app, from its root: every .vue file in src/. */
59
+ function vueFiles(root: string, dir = "src"): string[] {
60
+ if (!existsSync(join(root, dir))) return [];
61
+ return readdirSync(join(root, dir))
62
+ .sort()
63
+ .flatMap((name) => {
64
+ const file = `${dir}/${name}`;
65
+ if (statSync(join(root, file)).isDirectory()) return name === "node_modules" ? [] : vueFiles(root, file);
66
+ return name.endsWith(".vue") ? [file] : [];
67
+ });
68
+ }
69
+
70
+ const reason = (error: unknown) => (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
71
+
72
+ /** Plays `playthrough` from `route`: the state after each step, or where it throws. */
73
+ function play(
74
+ core: Core<unknown>,
75
+ playthrough: Playthrough,
76
+ route: string,
77
+ ): { states: unknown[]; error?: { path: (string | number)[]; message: string } } {
78
+ const states: unknown[] = [];
79
+ let state: unknown;
80
+ try {
81
+ state = core.start(playthrough.seed, route);
82
+ } catch (error) {
83
+ const message = `start(${playthrough.seed}, ${JSON.stringify(route)}) throws: ${reason(error)} (interactive-core-contract)`;
84
+ return { states, error: { path: ["seed"], message } };
85
+ }
86
+ for (const [index, step] of playthrough.steps.entries()) {
87
+ for (const [at, input] of inputsOf(step).entries()) {
88
+ try {
89
+ state = core.step(state, input);
90
+ } catch (error) {
91
+ const message = `step(state, ${inputText(input)}) throws: ${reason(error)} (interactive-core-contract)`;
92
+ return { states, error: { path: ["steps", index, step.keys === undefined ? "inputs" : "keys", at], message } };
93
+ }
94
+ }
95
+ for (let at = 0; at < (step.ticks ?? 0); at++) {
96
+ try {
97
+ state = core.tick?.(state, TICK) ?? state;
98
+ } catch (error) {
99
+ const message = `tick(state, ${TICK}) throws at tick ${at + 1}: ${reason(error)} (interactive-core-contract)`;
100
+ return { states, error: { path: ["steps", index, "ticks"], message } };
101
+ }
102
+ }
103
+ states.push(state);
104
+ }
105
+ return { states };
106
+ }
107
+
108
+ const valueAt = (value: unknown, path: readonly (string | number)[]): unknown =>
109
+ path.reduce<unknown>(
110
+ (at, part) => (typeof at === "object" && at !== null ? (at as Record<string, unknown>)[part] : undefined),
111
+ value,
112
+ );
113
+
114
+ /** The problems of the core: its exports, the start of every route, and every playthrough. */
115
+ async function coreProblems(root: string, content: Interactive, load: Load): Promise<string[]> {
116
+ if (!existsSync(join(root, CORE))) return [`${CORE} is missing; ${CONTRACT}`];
117
+ let exports: Record<string, unknown>;
118
+ try {
119
+ exports = await load(`/${CORE}`);
120
+ } catch (error) {
121
+ return [`${CORE}: cannot be loaded: ${reason(error)}`];
122
+ }
123
+ const lacking = ["start", "step"].filter((name) => typeof exports[name] !== "function");
124
+ if (exports["tick"] !== undefined && typeof exports["tick"] !== "function") lacking.push("tick as a function");
125
+ if (lacking.length > 0) return [`${CORE}: exports no ${lacking.join(", ")}; ${CONTRACT}`];
126
+ const core = exports as unknown as Core<unknown>;
127
+ const problems = new Problems();
128
+ const routes = content.app.routes ?? [];
129
+ const failed = new Set<string>();
130
+ routes.forEach((route, index) => {
131
+ try {
132
+ core.start(1, route.id);
133
+ } catch (error) {
134
+ failed.add(route.id);
135
+ problems.at(
136
+ content.appYaml,
137
+ ["routes", index, "id"],
138
+ `start(1, ${JSON.stringify(route.id)}) throws: ${reason(error)}; the core starts every route (interactive-core-contract)`,
139
+ );
140
+ }
141
+ });
142
+ for (const { yaml, playthrough } of content.playthroughs) {
143
+ const route = playthrough.route ?? routes[0]?.id ?? "";
144
+ if (failed.has(route)) continue;
145
+ const ticking = playthrough.steps.findIndex((step) => step.ticks !== undefined);
146
+ if (core.tick === undefined && ticking >= 0) {
147
+ problems.at(
148
+ yaml,
149
+ ["steps", ticking, "ticks"],
150
+ `the core exports no tick, so no time passes in the app; export tick(state, dt) from ${CORE}, or leave the ticks out (interactive-core-contract)`,
151
+ );
152
+ continue;
153
+ }
154
+ const first = play(core, playthrough, route);
155
+ if (first.error !== undefined) {
156
+ problems.at(yaml, first.error.path, first.error.message);
157
+ continue;
158
+ }
159
+ const second = play(core, playthrough, route);
160
+ const differs = first.states.findIndex(
161
+ (state, index) => JSON.stringify(state) !== JSON.stringify(second.states[index]),
162
+ );
163
+ if (differs >= 0 || second.error !== undefined) {
164
+ problems.at(
165
+ yaml,
166
+ ["steps", Math.max(differs, 0)],
167
+ "the state differs between two plays of the same steps; the core gives the same state for the same seed, route, inputs and ticks, so draw from the seed, never from Math.random or the clock (interactive-core-contract)",
168
+ );
169
+ // a property of the core: once is enough, and no state of it can be trusted
170
+ break;
171
+ }
172
+ for (const [index, step] of playthrough.steps.entries()) {
173
+ if (step.state === undefined) continue;
174
+ const found = mismatch(first.states[index], step.state);
175
+ if (found === undefined) continue;
176
+ const done = playthrough.steps
177
+ .slice(0, index + 1)
178
+ .flatMap(doingText)
179
+ .join(", ");
180
+ const where = found.path.length === 0 ? "the state" : pathText(found.path);
181
+ problems.at(
182
+ yaml,
183
+ ["steps", index, "state", ...found.path],
184
+ `${where} is ${JSON.stringify(found.actual) ?? "missing"} after ${done}, the playthrough expects ${JSON.stringify(valueAt(step.state, found.path))} (interactive-playthroughs-pass)`,
185
+ );
186
+ // the steps after it start from another state
187
+ break;
188
+ }
189
+ }
190
+ return problems.lines;
191
+ }
192
+
193
+ /** The build step of an interactive app: validates src/, checks its components and its core, writes every page. */
194
+ export function interactive(): Plugin[] {
195
+ let root = process.cwd();
196
+ let command: "build" | "serve" = "build";
197
+ let content: Interactive | undefined;
198
+ const load = () => (content ??= loadInteractive(root));
199
+ const main: Plugin = {
200
+ name: "lernapps-interactive",
201
+ enforce: "post",
202
+ configResolved(config) {
203
+ root = config.root;
204
+ command = config.command;
205
+ },
206
+ async buildStart() {
207
+ content = undefined;
208
+ let loaded: Interactive;
209
+ try {
210
+ loaded = load();
211
+ } catch (error) {
212
+ this.error(error instanceof Error ? error.message : String(error));
213
+ }
214
+ if (command !== "build") return;
215
+ const problems = codeProblems(root, vueFiles(root), sourceScope(root));
216
+ if (problems.length === 0)
217
+ problems.push(...(await withServer(root, (loader) => coreProblems(root, loaded, loader))));
218
+ if (problems.length > 0) this.error(new InteractiveError(problems).message);
219
+ },
220
+ generateBundle(_options, bundle) {
221
+ const index = bundle["index.html"];
222
+ if (index === undefined || index.type !== "asset") return;
223
+ const shell = String(index.source);
224
+ const app = load().app;
225
+ for (const page of pages(app)) {
226
+ const html = fill(shell, renderPage(app, page));
227
+ if (page.path === "") index.source = html;
228
+ else this.emitFile({ type: "asset", fileName: `${page.path}index.html`, source: html });
229
+ }
230
+ this.emitFile({ type: "asset", fileName: "404.html", source: notFoundPage() });
231
+ },
232
+ configureServer(server) {
233
+ server.watcher.on("all", (_event, file) => {
234
+ if (file.endsWith(".yaml") && file.startsWith(join(root, "src"))) {
235
+ content = undefined;
236
+ server.ws.send({ type: "full-reload" });
237
+ }
238
+ });
239
+ // before Vite's own fallback to index.html
240
+ server.middlewares.use((request, response, next) => {
241
+ const path = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname).replace(/^\//, "");
242
+ let app: Interactive["app"];
243
+ try {
244
+ app = load().app;
245
+ } catch (error) {
246
+ response.statusCode = 500;
247
+ response.setHeader("content-type", "text/plain; charset=utf-8");
248
+ response.end(error instanceof Error ? error.message : String(error));
249
+ return;
250
+ }
251
+ const page = pages(app).find((candidate) => candidate.path === path);
252
+ if (page === undefined) return next();
253
+ const shell = readFileSync(join(root, "index.html"), "utf8");
254
+ server
255
+ .transformIndexHtml(`/${path}`, fill(shell, { ...renderPage(app, page), page: { ...page, path: "" } }))
256
+ .then((html) => {
257
+ response.setHeader("content-type", "text/html; charset=utf-8");
258
+ response.end(html);
259
+ })
260
+ .catch(next);
261
+ });
262
+ },
263
+ };
264
+ const alias: Plugin = { name: "lernapps-interactive-vue-alias", config: () => ({ resolve: { alias: vueAlias() } }) };
265
+ return [vue(VUE_OPTIONS), alias, main];
266
+ }
@@ -0,0 +1,126 @@
1
+ // Renders the pages of an interactive app at build time from src/app.yaml, in each of its languages (German at the
2
+ // root, every other language below /<language>/):
3
+ // - the start page (index.html): what the app does, a link into the app, its routes, how to play, what a learner
4
+ // learns, and the background sections; complete static HTML, with a <noscript> note that the app needs JavaScript;
5
+ // - the app page (app/index.html): the heading line (the title, and the hidden slot that the app's title component is
6
+ // mounted into, interactive.ts), a <noscript> note, the routes as links to their anchors (#<id>, each the
7
+ // id of its list item, so that a topic app/#<id> of the catalog entry finds it), and the element the app's root
8
+ // component is mounted into (interactive.ts).
9
+ // Each page has its language, its alternates, the archetype marker, and the footer with imprint and privacy notice. The
10
+ // header holds the language switch of plain links and nothing else (no link with the app's name: the title is the h1,
11
+ // and the way back to the catalog is the platform's); an app with one language has no header. The end-to-end tests rely on the classes and ids written here.
12
+ import { inLanguage, type Text } from "../shared/languages.ts";
13
+ import { alternateLinks, languageRoot, pageFooter, pageHeader, relative } from "../shared/pages.ts";
14
+ import { plain, renderMarkdown } from "../shared/text/markdown.ts";
15
+ import type { App } from "./load.ts";
16
+ import { message, type Messages } from "./messages.ts";
17
+
18
+ /** The address of the app page, from a language tree's root. */
19
+ export const APP_PAGE = "app/";
20
+
21
+ /** A page of the app in one language. */
22
+ export interface Page {
23
+ kind: "start" | "app";
24
+ lang: string;
25
+ /** Where the page is, from the app's root: "", "app/", "en/", "en/app/". */
26
+ path: string;
27
+ /** The same page's address within any language tree: "" or "app/". */
28
+ local: string;
29
+ }
30
+
31
+ /** A page, rendered: what goes into the shell's head and body. */
32
+ export interface RenderedPage {
33
+ page: Page;
34
+ head: string;
35
+ body: string;
36
+ }
37
+
38
+ /** Every page of the app, in every language. */
39
+ export function pages(app: App): Page[] {
40
+ return app.languages.flatMap((lang) =>
41
+ (["start", "app"] as const).map((kind) => {
42
+ const local = kind === "start" ? "" : APP_PAGE;
43
+ return { kind, lang, path: `${languageRoot(lang)}${local}`, local };
44
+ }),
45
+ );
46
+ }
47
+
48
+ /** The ids of the routes, in order: the app page's anchors. */
49
+ export const routeIds = (app: App): string[] => (app.routes ?? []).map((route) => route.id);
50
+
51
+ /** One page of the app. */
52
+ export function renderPage(app: App, page: Page): RenderedPage {
53
+ const { lang, path } = page;
54
+ const t = (key: keyof Messages) => plain(message(lang, key));
55
+ const text = (value: Text) => plain(inLanguage(value, lang));
56
+ /** A link from this page to `to` (from the language tree's root). */
57
+ const to = (target: string) => relative(path, `${languageRoot(lang)}${target}`);
58
+ const markdown = (value: Text) =>
59
+ renderMarkdown(inLanguage(value, lang), (href) => (href.startsWith("/") ? to(href.slice(1)) : href));
60
+ const title = text(app.title);
61
+ const header = pageHeader({
62
+ ...page,
63
+ languages: app.languages,
64
+ label: t("languages"),
65
+ name: (other) => plain(message(other, "language")),
66
+ });
67
+ const footer = pageFooter({ footer: t("footer"), imprint: t("imprint"), privacy: t("privacy") });
68
+ const routes = app.routes ?? [];
69
+ let main: string;
70
+ if (page.kind === "start") {
71
+ const routeList =
72
+ routes.length === 0
73
+ ? ""
74
+ : `<section class="interactive-section"><h2>${t("routes")}</h2><ul class="interactive-route-list">${routes
75
+ .map((route) => {
76
+ const description =
77
+ route.description === undefined
78
+ ? ""
79
+ : ` <span class="interactive-description">${text(route.description)}</span>`;
80
+ return `<li><a href="${to(`${APP_PAGE}#${route.id}`)}">${text(route.title)}</a>${description}</li>`;
81
+ })
82
+ .join("")}</ul></section>`;
83
+ main = [
84
+ `<h1>${title}</h1>`,
85
+ `<div class="interactive-intro">${markdown(app.description)}</div>`,
86
+ `<noscript><p class="interactive-noscript">${t("noscript")}</p></noscript>`,
87
+ `<p class="interactive-play"><a class="interactive-start" href="${to(APP_PAGE)}">${t("play")}</a></p>`,
88
+ routeList,
89
+ `<section class="interactive-section interactive-howto"><h2>${t("howto")}</h2>${markdown(app.howto)}</section>`,
90
+ `<section class="interactive-section"><h2>${t("competencies")}</h2><ul class="interactive-competencies">${app.competencies
91
+ .map((competency) => `<li>${text(competency)}</li>`)
92
+ .join("")}</ul></section>`,
93
+ ...(app.sections ?? []).map(
94
+ (section) =>
95
+ `<section class="interactive-section"><h2 id="${section.id}">${text(section.title)}</h2>${markdown(section.text)}</section>`,
96
+ ),
97
+ ]
98
+ .filter((part) => part !== "")
99
+ .join("\n");
100
+ } else {
101
+ const nav =
102
+ routes.length === 0
103
+ ? ""
104
+ : `<nav class="interactive-routes" aria-label="${t("routes")}"><ul>${routes
105
+ .map((route) => `<li id="${route.id}"><a href="#${route.id}">${text(route.title)}</a></li>`)
106
+ .join("")}</ul></nav>`;
107
+ main = [
108
+ `<div class="interactive-title"><h1>${title}</h1><div class="interactive-title-slot" hidden></div></div>`,
109
+ `<noscript><p class="interactive-noscript">${t("noscriptApp")} <a href="${to("")}">${t("toStart")}</a></p></noscript>`,
110
+ nav,
111
+ `<div class="interactive-app" data-routes="${plain(JSON.stringify(routeIds(app)))}"></div>`,
112
+ ]
113
+ .filter((part) => part !== "")
114
+ .join("\n");
115
+ }
116
+ const body = [header, `<main class="interactive" id="inhalt">`, main, `</main>`, footer]
117
+ .filter((part) => part !== "")
118
+ .join("\n");
119
+ const head = [
120
+ `<title>${page.kind === "start" ? title : `${t("play")} – ${title}`}</title>`,
121
+ `<meta name="description" content="${plain(inLanguage(app.description, lang).replace(/\s+/g, " ").trim())}" />`,
122
+ `<meta name="lernapps-archetype" content="interactive" />`,
123
+ ...alternateLinks({ ...page, languages: app.languages }),
124
+ ].join("\n ");
125
+ return { page, head, body };
126
+ }
@@ -0,0 +1,233 @@
1
+ // The end-to-end tests of every quiz (@lernapps/app-templates/quiz/e2e), run by `lernapps check --pre-push` on the built
2
+ // app with the app's own question bank. An app's e2e/quiz.e2e.ts is
3
+ //
4
+ // import { quizTests } from "@lernapps/app-templates/quiz/e2e";
5
+ // quizTests();
6
+ //
7
+ // For each question: its deep link opens it, the right answer counts and a wrong one does not; after either, the
8
+ // background of every option, the explanation and the links for further reading show, the learner's choice marked.
9
+ // The feedback then has the focus and its top is in view, and the button to the next question comes after it.
10
+ // After the answer the reveal also says what is right: the right options marked, or the right answer for the other
11
+ // types. The seed in the address gives the order of the questions, and the address gets one when it has none. All
12
+ // right answers give the full score and show the solutions; nothing is stored on the device before, and afterwards
13
+ // only the last result when the bank asks for it. Without JavaScript every question and its solution, with the same
14
+ // background and links, can be read. Each test opens its own page; none depends on another, so they run in parallel.
15
+ // A bank in several languages runs all of this in German; for each other language, kept short: without JavaScript
16
+ // its page (/<language>/) is in that language with every question and its solution, and its switch leads to the page
17
+ // in every language; with JavaScript one question is answered, with the feedback in that language.
18
+ // The helpers answerQuestion, expectBackground and expectFeedbackInView work per question, on any page with a question
19
+ // that renderQuestion of @lernapps/app-templates/questions rendered and startQuestion made live (shared/questions/e2e.ts).
20
+ import { readFileSync } from "node:fs";
21
+ import { expect, test } from "playwright/test";
22
+ import type { Text } from "../shared/languages.ts";
23
+ import {
24
+ answerQuestion,
25
+ expectBackground,
26
+ expectFeedbackInView,
27
+ expectMaterial,
28
+ question,
29
+ } from "../shared/questions/e2e.ts";
30
+ import { questionId, rightAnswer, wrongAnswer } from "../shared/questions/question.ts";
31
+ import { quizIn, quizLanguages, type Quiz } from "./quiz.ts";
32
+ import { messagesIn, QUIZ_MESSAGES } from "./render.ts";
33
+
34
+ export { answerQuestion, expectBackground, expectFeedbackInView, expectMaterial } from "../shared/questions/e2e.ts";
35
+
36
+ const de = QUIZ_MESSAGES.de;
37
+ /** The quiz's own texts in `lang`. */
38
+ const messagesOf = messagesIn;
39
+
40
+ /** The quiz's tests, for the question bank in `bank` (relative to the app's root). */
41
+ export function quizTests(bank = "src/quiz.json"): void {
42
+ const all = JSON.parse(readFileSync(bank, "utf8")) as Quiz<Text>;
43
+ const languages = quizLanguages(all);
44
+ const quiz = quizIn(all, "de");
45
+
46
+ test.describe("quiz", () => {
47
+ // The runtime scrolls smoothly unless the learner asks for less motion (quiz.css) and moves the focus after every
48
+ // answer: with motion, every click would wait for a scroll to settle, the run through a quiz grow with its size.
49
+ test.use({ reducedMotion: "reduce" });
50
+
51
+ for (const item of quiz.questions) {
52
+ test(`${item.id} (${item.type}): its link opens it and the right answer counts`, async ({ page }) => {
53
+ await page.goto(`./#${questionId(item)}`);
54
+ await expect(question(page, item)).toBeVisible();
55
+ await expect(page.locator(".question:visible")).toHaveCount(1);
56
+ await answerQuestion(page, item, rightAnswer(item));
57
+ await expect(question(page, item).locator(".question-feedback")).toContainText(de.right);
58
+ await expectFeedbackInView(page, item);
59
+ // from the feedback, the keyboard reaches the next question (past the links of the background)
60
+ const next = question(page, item).locator(".question-next");
61
+ for (let tab = 0; tab < 30 && !(await next.evaluate((button) => button === document.activeElement)); tab++) {
62
+ await page.keyboard.press("Tab");
63
+ }
64
+ await expect(next).toBeFocused();
65
+ const reveal = question(page, item).locator(".question-reveal");
66
+ await expectBackground(reveal, item);
67
+ if (item.type === "single-choice" || item.type === "multiple-choice") {
68
+ const right = item.options.filter((option) => option.correct).length;
69
+ await expect(reveal.locator(".question-badge-right")).toHaveCount(right);
70
+ await expect(reveal.locator(".question-badge-right").first()).toHaveText(de.rightOption);
71
+ } else {
72
+ await expect(reveal).toContainText(de.rightAnswer);
73
+ }
74
+ });
75
+
76
+ if (item.material !== undefined) {
77
+ test(`${item.id} (${item.type}): what it shows is there, and its images load`, async ({ page }) => {
78
+ await page.goto(`./#${questionId(item)}`);
79
+ await expectMaterial(page, item);
80
+ });
81
+ }
82
+
83
+ test(`${item.id} (${item.type}): a wrong answer does not count`, async ({ page }) => {
84
+ await page.goto(`./#${questionId(item)}`);
85
+ await answerQuestion(page, item, wrongAnswer(item));
86
+ await expect(question(page, item).locator(".question-feedback")).toContainText(de.wrong);
87
+ await expectFeedbackInView(page, item);
88
+ await expectBackground(question(page, item).locator(".question-reveal"), item);
89
+ if (item.type === "single-choice" || item.type === "multiple-choice") {
90
+ await expect(question(page, item).locator(".question-badge-chosen").first()).toHaveText(de.yourChoice);
91
+ }
92
+ });
93
+ }
94
+
95
+ test("the seed in the address gives the order; without one, the address gets one", async ({ page }) => {
96
+ const everything = `&n=${quiz.questions.length}`;
97
+ const order = async (address: string) => {
98
+ await page.goto(address);
99
+ return page.locator(".question").evaluateAll((items) => items.map((item) => item.id).join());
100
+ };
101
+ const first = await order(`./?seed=11${everything}`);
102
+ expect(await order(`./?seed=11${everything}`)).toBe(first);
103
+ if (quiz.questions.length > 2) {
104
+ const others = [];
105
+ for (const seed of [12, 13, 14, 15, 16]) others.push(await order(`./?seed=${seed}${everything}`));
106
+ expect(others.some((other) => other !== first)).toBe(true);
107
+ }
108
+ await page.goto("./");
109
+ await expect.poll(() => new URL(page.url()).searchParams.get("seed")).toMatch(/^\d+$/);
110
+ });
111
+
112
+ test("all right answers give the full score, then the solutions show", async ({ page }) => {
113
+ await page.goto("./");
114
+ if (quiz.questions.length > 1) await page.locator(".quiz-start button").click();
115
+ expect(await page.evaluate(() => Object.keys(localStorage))).toEqual([]);
116
+ for (let step = 0; step < quiz.questions.length; step++) {
117
+ const id = await page.locator(".question:visible").getAttribute("data-id");
118
+ const item = quiz.questions.find((candidate) => candidate.id === id);
119
+ if (item === undefined) throw new Error(`no visible question at step ${step + 1}`);
120
+ await answerQuestion(page, item, rightAnswer(item));
121
+ await question(page, item).locator(".question-next").click();
122
+ }
123
+ const total = String(quiz.questions.length);
124
+ await expect(page.locator(".quiz-score")).toHaveText(
125
+ de.score.replace("{score}", total).replace("{total}", total),
126
+ );
127
+ await expect(page.locator("#loesungen")).toBeVisible();
128
+ // only the last result, and only when the bank asks for it, under the app's own prefix
129
+ const stored = await page.evaluate(() => Object.keys(localStorage));
130
+ if (quiz.rememberLastResult === true) {
131
+ expect(stored).toHaveLength(1);
132
+ expect(stored[0]).toMatch(/^lernapps:.+:last-result$/);
133
+ } else {
134
+ expect(stored).toEqual([]);
135
+ }
136
+ });
137
+
138
+ if (quiz.questions.length > 1) {
139
+ test("the learner chooses how many questions to answer; the address keeps the choice", async ({ page }) => {
140
+ await page.goto("./?seed=11");
141
+ await expect(page.locator(".quiz-start")).toBeVisible();
142
+ await expect(page.locator(".question:visible")).toHaveCount(0);
143
+ const count = page.locator("#quiz-count");
144
+ await expect(count).toHaveValue(String(quiz.questions.length));
145
+ await expect(count).toHaveAttribute("max", String(quiz.questions.length));
146
+ await count.fill("1");
147
+ await page.locator(".quiz-start button").click();
148
+ await expect(page.locator(".quiz-start")).toBeHidden();
149
+ expect(new URL(page.url()).searchParams.get("n")).toBe("1");
150
+ await expect(page.locator(".question")).toHaveCount(1);
151
+ await expect(page.locator(".quiz-solution")).toHaveCount(1);
152
+ const id = await page.locator(".question:visible").getAttribute("data-id");
153
+ const item = quiz.questions.find((candidate) => candidate.id === id);
154
+ if (item === undefined) throw new Error("no visible question");
155
+ await answerQuestion(page, item, rightAnswer(item));
156
+ await question(page, item).locator(".question-next").click();
157
+ await expect(page.locator(".quiz-score")).toHaveText(de.score.replace("{score}", "1").replace("{total}", "1"));
158
+ // the same address asks the same question, without asking again
159
+ await page.goto(page.url());
160
+ await expect(page.locator(".quiz-start")).toBeHidden();
161
+ await expect(page.locator(".question")).toHaveCount(1);
162
+ await expect(page.locator(".question")).toHaveAttribute("data-id", item.id);
163
+ });
164
+ }
165
+
166
+ test.describe("without JavaScript", () => {
167
+ test.use({ javaScriptEnabled: false });
168
+
169
+ test("every question and its solution can be read", async ({ page }) => {
170
+ await page.goto("./");
171
+ for (const item of quiz.questions) {
172
+ await expect(question(page, item).locator("legend")).toHaveText(item.text);
173
+ await expectMaterial(page, item, { loaded: false });
174
+ await expectBackground(page.locator(`#loesung-${item.id}`), item);
175
+ }
176
+ });
177
+ });
178
+
179
+ for (const lang of languages.slice(1)) {
180
+ const local = quizIn(all, lang);
181
+ const words = messagesOf(lang);
182
+ const address = `./${lang}/`;
183
+
184
+ test.describe(`in ${lang}`, () => {
185
+ test.describe("without JavaScript", () => {
186
+ test.use({ javaScriptEnabled: false });
187
+
188
+ test(`the page is in ${lang}, every question and its solution can be read, the switch leads to every language`, async ({
189
+ page,
190
+ }) => {
191
+ await page.goto(address);
192
+ await expect(page.locator("html")).toHaveAttribute("lang", lang);
193
+ await expect(page.locator("h1")).toHaveText(local.title);
194
+ await expect(page.locator("#loesungen h2")).toHaveText(words.solutions);
195
+ for (const item of local.questions) {
196
+ await expect(question(page, item).locator("legend")).toHaveText(item.text);
197
+ await expectMaterial(page, item, { loaded: false });
198
+ await expectBackground(page.locator(`#loesung-${item.id}`), item);
199
+ }
200
+ for (const other of languages) {
201
+ await expect(page.locator(`link[rel="alternate"][hreflang="${other}"]`)).toHaveCount(1);
202
+ }
203
+ for (const other of languages.filter((candidate) => candidate !== lang)) {
204
+ await page.goto(address);
205
+ await page.locator(`.page-languages a[hreflang="${other}"]`).click();
206
+ await expect(page.locator("html")).toHaveAttribute("lang", other);
207
+ await expect(page.locator("h1")).toHaveText(quizIn(all, other).title);
208
+ await expect(page.locator('.page-languages a[aria-current="page"]')).toHaveAttribute("hreflang", other);
209
+ }
210
+ });
211
+ });
212
+
213
+ test(`what a question shows, in ${lang}`, async ({ page }) => {
214
+ const item = local.questions.find((candidate) => candidate.material !== undefined);
215
+ test.skip(item === undefined, "no question shows content");
216
+ if (item === undefined) return;
217
+ await page.goto(`${address}#${questionId(item)}`);
218
+ await expectMaterial(page, item);
219
+ });
220
+
221
+ test(`one question, answered in ${lang}`, async ({ page }) => {
222
+ const item = local.questions[0];
223
+ if (item === undefined) throw new Error("the quiz has no questions");
224
+ await page.goto(`${address}#${questionId(item)}`);
225
+ await expect(question(page, item)).toBeVisible();
226
+ await answerQuestion(page, item, rightAnswer(item), lang);
227
+ await expect(question(page, item).locator(".question-feedback")).toContainText(words.right);
228
+ await expectBackground(question(page, item).locator(".question-reveal"), item);
229
+ });
230
+ });
231
+ }
232
+ });
233
+ }