@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,89 @@
1
+ // runtime/shared/languages.ts
2
+ /**
3
+ * The languages the runtime has texts for, German first. Every registry of the runtime's own texts has one entry per
4
+ * language (its type says so), so adding a language is complete when the compiler is.
5
+ */
6
+ const LANGUAGES = ["de", "en"];
7
+ const isLanguage = (lang) => LANGUAGES.includes(lang);
8
+ const LOCALES = {
9
+ de: "de-DE",
10
+ en: "en-GB"
11
+ };
12
+ /** The locale for numbers and dates in `lang`; German's for a language the runtime has no texts for. */
13
+ const localeOf = (lang) => LOCALES[isLanguage(lang) ? lang : "de"];
14
+ /** The entry of `registry` for `lang`; German's for a language the runtime has no texts for. */
15
+ const textsIn = (registry, lang) => registry[isLanguage(lang) ? lang : "de"];
16
+ /** The text in `lang`; a plain string is the same in every language. */
17
+ function inLanguage(text, lang) {
18
+ return typeof text === "string" ? text : text[lang] ?? text["de"] ?? "";
19
+ }
20
+ /**
21
+ * The text in each of the app's `languages` that it has, and the problems of its languages: a translation missing,
22
+ * a language not declared (in `declaredIn`, the file with the app's languages). A plain string is German, and complete
23
+ * only while German is the only language; with `neutral` (a formula, a code), a plain string is the same in every
24
+ * language.
25
+ */
26
+ function textLanguages(value, languages, neutral = false, declaredIn = "src/app.yaml") {
27
+ if (typeof value === "string") {
28
+ if (neutral) return {
29
+ texts: languages.map((lang) => ({
30
+ lang,
31
+ text: value,
32
+ keyed: false
33
+ })),
34
+ problems: []
35
+ };
36
+ const map = `{ ${languages.map((code) => `${code}: …`).join(", ")} }`;
37
+ return {
38
+ texts: [{
39
+ lang: "de",
40
+ text: value,
41
+ keyed: false
42
+ }],
43
+ problems: languages.slice(1).map((lang) => ({
44
+ lang,
45
+ message: `the translation is missing; write the text as a map: ${map}`
46
+ }))
47
+ };
48
+ }
49
+ const problems = [...Object.keys(value).filter((lang) => !languages.includes(lang)).map((lang) => ({
50
+ lang,
51
+ message: `${lang} is not a language of the app; declare it in languages in ${declaredIn}, or remove it`
52
+ })), ...languages.filter((lang) => !(lang in value)).map((lang) => ({
53
+ lang,
54
+ message: "the translation is missing"
55
+ }))];
56
+ return {
57
+ texts: languages.filter((lang) => lang in value).map((lang) => ({
58
+ lang,
59
+ text: value[lang] ?? "",
60
+ keyed: true
61
+ })),
62
+ problems
63
+ };
64
+ }
65
+ /** What is wrong with an app's list of languages: German first, each once, each one the runtime has texts for. */
66
+ function languageListProblems(languages, supported) {
67
+ const problems = [];
68
+ if (languages[0] !== "de") problems.push({
69
+ lang: languages[0] ?? "",
70
+ message: "German comes first: languages: [de, …]"
71
+ });
72
+ languages.forEach((lang, index) => {
73
+ if (languages.indexOf(lang) !== index) problems.push({
74
+ lang,
75
+ message: `${lang} is listed twice`
76
+ });
77
+ else if (!supported.includes(lang)) problems.push({
78
+ lang,
79
+ message: `the runtime has no texts of its own in ${lang} yet; it has ${supported.join(", ")}`
80
+ });
81
+ });
82
+ return problems;
83
+ }
84
+ // runtime/shared/html.ts
85
+ /** `text` for HTML text or a double-quoted attribute value. */
86
+ const escape = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
87
+ /** `text` for HTML text, quotation marks as they are. */
88
+ const escapeText = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
89
+ export { isLanguage as a, textLanguages as c, inLanguage as i, textsIn as l, escapeText as n, languageListProblems as o, LANGUAGES as r, localeOf as s, escape as t };
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from "node:fs";
2
+ // runtime/shared/blocks/image.ts
3
+ const TYPES = {
4
+ svg: "image/svg+xml",
5
+ png: "image/png",
6
+ jpg: "image/jpeg",
7
+ webp: "image/webp"
8
+ };
9
+ /** The media type of a bundled image, by its file name. */
10
+ const imageType = (name) => TYPES[name.slice(name.lastIndexOf(".") + 1)] ?? "application/octet-stream";
11
+ /**
12
+ * Whether the SVG file `file` has no size of its own: its root has neither a width nor a height (only a viewBox, or
13
+ * percentages). In a box as wide as its content, as the image of a labelled figure is, the browser draws such an image
14
+ * with zero width. Another kind of image has a size.
15
+ */
16
+ function isSizeless(file) {
17
+ if (!file.endsWith(".svg")) return false;
18
+ const root = /<svg\b[^>]*>/i.exec(readFileSync(file, "utf8"))?.[0] ?? "";
19
+ return !/\s(?:width|height)\s*=\s*["']?\s*\d*\.?\d+\s*(?:px|pt|pc|mm|cm|in|em|ex|rem)?\s*["'\s/>]/i.test(root);
20
+ }
21
+ export { isSizeless as n, imageType as t };
@@ -0,0 +1,140 @@
1
+ import { i as inLanguage } from "./html-mffkHGHh.mjs";
2
+ import { r as languageRoot } from "./pages-9RaaomoO.mjs";
3
+ import { i as doingText, n as STATE_PROPERTY, o as inputsOf, s as mismatch, t as CLOCK_PROPERTY } from "./contract-PONpxJtd.mjs";
4
+ import { i as message, o as loadInteractive, t as APP_PAGE } from "./render-BL6fV_NI.mjs";
5
+ import { expect, test } from "playwright/test";
6
+ // runtime/interactive/e2e.ts
7
+ /** The page's language, its alternates and a switch to each other language, the archetype marker. */
8
+ async function expectFrame(page, lang, languages) {
9
+ await expect(page.locator("html")).toHaveAttribute("lang", lang);
10
+ await expect(page.locator("meta[name=\"lernapps-archetype\"]")).toHaveAttribute("content", "interactive");
11
+ for (const other of languages) {
12
+ await expect(page.locator(`link[rel="alternate"][hreflang="${other}"]`)).toHaveCount(1);
13
+ if (other === lang) continue;
14
+ const link = page.locator(`.page-languages a[hreflang="${other}"]`);
15
+ await expect(link).toBeVisible();
16
+ const response = await page.request.get(await link.evaluate((a) => a.href));
17
+ expect(response.status(), `the switch to ${other}`).toBe(200);
18
+ }
19
+ }
20
+ /** Collects the page's console errors and uncaught exceptions. */
21
+ function consoleErrors(page) {
22
+ const errors = [];
23
+ page.on("console", (entry) => {
24
+ if (entry.type() === "error") errors.push(entry.text());
25
+ });
26
+ page.on("pageerror", (error) => errors.push(error.message));
27
+ return errors;
28
+ }
29
+ /** Emits `input` (an Input) as input from the app's root component in `host`, as the component itself does. */
30
+ function emitInput(host, input) {
31
+ const root = (host._vnode?.component)?.subTree?.component;
32
+ if (root === void 0) throw new Error("the app's root component is not mounted");
33
+ root.emit("input", input);
34
+ }
35
+ /** Steps `count` ticks on the tests' clock of the page, the global `property`. */
36
+ function stepTicks({ count, property }) {
37
+ Reflect.get(globalThis, property).tick(count);
38
+ }
39
+ /**
40
+ * Presses `key` as the learner does. Playwright knows only the keys of a US keyboard by name; any other character
41
+ * (ä, ö, ü, ß, é) goes as the keydown event the runtime listens to, with that key.
42
+ */
43
+ async function pressKey(page, key) {
44
+ if (key.length === 1 && key > "~") await page.evaluate((pressed) => {
45
+ document.dispatchEvent(new KeyboardEvent("keydown", {
46
+ key: pressed,
47
+ bubbles: true,
48
+ cancelable: true
49
+ }));
50
+ }, key);
51
+ else await page.keyboard.press(key);
52
+ }
53
+ /** The interactive app's tests, for the app in `root`. */
54
+ function interactiveTests(root = ".") {
55
+ const { app, playthroughs } = loadInteractive(root);
56
+ const routes = app.routes ?? [];
57
+ test.describe("interactive", () => {
58
+ test.describe.configure({ mode: "parallel" });
59
+ test.use({ reducedMotion: "reduce" });
60
+ test.describe("without JavaScript", () => {
61
+ test.use({ javaScriptEnabled: false });
62
+ for (const lang of app.languages) {
63
+ const start = languageRoot(lang);
64
+ const text = (value) => inLanguage(value, lang).replace(/\s+/g, " ").trim();
65
+ test(`${start || "./"}: the start page says what the app does, how to play and what one learns`, async ({ page }) => {
66
+ await page.goto(`./${start}`);
67
+ await expectFrame(page, lang, app.languages);
68
+ const main = page.locator("main");
69
+ await expect(main.locator("h1")).toHaveText(text(app.title));
70
+ await expect(main.locator(".interactive-intro")).toBeVisible();
71
+ await expect(main.locator(".interactive-noscript"), "the note that the app needs JavaScript").toBeVisible();
72
+ const play = main.locator("a.interactive-start");
73
+ await expect(play).toBeVisible();
74
+ const response = await page.request.get(await play.evaluate((a) => a.href));
75
+ expect(response.status(), "the link into the app").toBe(200);
76
+ for (const route of routes) {
77
+ const link = main.locator(`a[href$="${APP_PAGE}#${route.id}"]`);
78
+ await expect(link, `the link to the route ${route.id}`).toHaveText(text(route.title));
79
+ }
80
+ await expect(main.getByRole("heading", {
81
+ level: 2,
82
+ name: message(lang, "howto"),
83
+ exact: true
84
+ })).toBeVisible();
85
+ for (const competency of app.competencies) await expect(main.locator(".interactive-competencies li", { hasText: text(competency) })).toBeVisible();
86
+ for (const section of app.sections ?? []) await expect(main.locator(`h2[id="${section.id}"]`), `the section ${section.id}`).toHaveText(text(section.title));
87
+ expect(await page.locator("script").count(), "the start page loads no script").toBe(0);
88
+ });
89
+ test(`${start}${APP_PAGE}: the app page says that the app needs JavaScript and has each route's anchor`, async ({ page }) => {
90
+ await page.goto(`./${start}${APP_PAGE}`);
91
+ await expectFrame(page, lang, app.languages);
92
+ const main = page.locator("main");
93
+ await expect(main.locator("h1"), "one h1: the title").toHaveText(text(app.title));
94
+ await expect(page.locator(".interactive-title-slot"), "the slot of the heading line").toBeHidden();
95
+ const note = main.locator(".interactive-noscript");
96
+ await expect(note, "the note that the app needs JavaScript").toBeVisible();
97
+ const back = await note.locator("a").evaluate((a) => a.href);
98
+ expect(back, "the note links the start page").toBe(new URL("../", page.url()).href);
99
+ for (const route of routes) await expect(main.locator(`[id="${route.id}"]`), `the anchor of the route ${route.id}`).toHaveText(text(route.title));
100
+ });
101
+ }
102
+ });
103
+ test.describe("with JavaScript", () => {
104
+ test(`${APP_PAGE}: the app mounts without a console error`, async ({ page }) => {
105
+ const errors = consoleErrors(page);
106
+ await page.goto(`./${APP_PAGE}`);
107
+ await expect(page.locator(".interactive-app")).toHaveAttribute("data-mounted", "");
108
+ await expect(page.locator(".interactive-app")).not.toBeEmpty();
109
+ expect(errors, "no console error").toEqual([]);
110
+ });
111
+ for (const { yaml, playthrough } of playthroughs) {
112
+ const route = playthrough.route ?? routes[0]?.id;
113
+ test(`${yaml.file}: played with its inputs and ticks`, async ({ page }) => {
114
+ const errors = consoleErrors(page);
115
+ await page.addInitScript((property) => Reflect.set(globalThis, property, {}), CLOCK_PROPERTY);
116
+ await page.goto(`./${APP_PAGE}?seed=${playthrough.seed}${route === void 0 ? "" : `#${route}`}`);
117
+ const host = page.locator(".interactive-app");
118
+ await expect(host).toHaveAttribute("data-mounted", "");
119
+ const live = page.locator("body > [aria-live=\"polite\"]");
120
+ for (const [index, step] of playthrough.steps.entries()) {
121
+ for (const input of inputsOf(step)) if (input.kind === "key") await pressKey(page, input.key);
122
+ else await host.evaluate(emitInput, input);
123
+ if (step.ticks !== void 0) await page.evaluate(stepTicks, {
124
+ count: step.ticks,
125
+ property: CLOCK_PROPERTY
126
+ });
127
+ const where = `${yaml.file}: steps[${index}] after ${doingText(step).join(", ")}`;
128
+ if (step.state !== void 0) {
129
+ const state = await host.evaluate((element, property) => element[property], STATE_PROPERTY);
130
+ expect(mismatch(state, step.state), `${where}: the state`).toBeUndefined();
131
+ }
132
+ if (step.announce !== void 0) await expect(live, `${where}: the announcement`).toHaveText(step.announce);
133
+ }
134
+ expect(errors, "no console error").toEqual([]);
135
+ });
136
+ }
137
+ });
138
+ });
139
+ }
140
+ export { interactiveTests };
@@ -0,0 +1,242 @@
1
+ import { c as withLanguage, i as notFoundPage, n as fromDepth } from "./pages-9RaaomoO.mjs";
2
+ import { c as sourceScope, n as codeProblems } from "./vue-code-BkJjW_I_.mjs";
3
+ import { o as pathText, t as Problems } from "./content-cYBPYg_J.mjs";
4
+ import { n as vueAlias, r as withServer, t as VUE_OPTIONS } from "./vue-DO1m6q1R.mjs";
5
+ import { a as inputText, i as doingText, o as inputsOf, s as mismatch } from "./contract-PONpxJtd.mjs";
6
+ import { a as InteractiveError, n as pages, o as loadInteractive, r as renderPage } from "./render-BL6fV_NI.mjs";
7
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import vue from "@vitejs/plugin-vue";
10
+ // runtime/interactive/plugin.ts
11
+ const MARKER = "<!-- interactive -->";
12
+ /** Where the core is, from the app's root. */
13
+ const CORE = "src/core/index.ts";
14
+ const CONTRACT = "the core exports start(seed, route) and step(state, input), and tick(state, dt) when the app moves on its own (interactive-core-contract)";
15
+ /** The shell with the page in it; the start page without the shell's scripts. */
16
+ function fill(shell, rendered) {
17
+ if (!shell.includes(MARKER)) throw new Error(`index.html has no ${MARKER}: put it in <body>, where the pages go`);
18
+ let html = withLanguage(shell, rendered.page.lang).replace(/\s*<title>[\s\S]*?<\/title>/, "").replace("</head>", ` ${rendered.head}\n </head>`);
19
+ if (rendered.page.kind === "start") html = html.replace(/\s*<script type="module"[^>]*><\/script>/g, "").replace(/\s*<link rel="modulepreload"[^>]*>/g, "");
20
+ return fromDepth(html, rendered.page.path).replace(MARKER, () => rendered.body);
21
+ }
22
+ /** The single-file components of the app, from its root: every .vue file in src/. */
23
+ function vueFiles(root, dir = "src") {
24
+ if (!existsSync(join(root, dir))) return [];
25
+ return readdirSync(join(root, dir)).sort().flatMap((name) => {
26
+ const file = `${dir}/${name}`;
27
+ if (statSync(join(root, file)).isDirectory()) return name === "node_modules" ? [] : vueFiles(root, file);
28
+ return name.endsWith(".vue") ? [file] : [];
29
+ });
30
+ }
31
+ const reason = (error) => (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
32
+ /** Plays `playthrough` from `route`: the state after each step, or where it throws. */
33
+ function play(core, playthrough, route) {
34
+ const states = [];
35
+ let state;
36
+ try {
37
+ state = core.start(playthrough.seed, route);
38
+ } catch (error) {
39
+ return {
40
+ states,
41
+ error: {
42
+ path: ["seed"],
43
+ message: `start(${playthrough.seed}, ${JSON.stringify(route)}) throws: ${reason(error)} (interactive-core-contract)`
44
+ }
45
+ };
46
+ }
47
+ for (const [index, step] of playthrough.steps.entries()) {
48
+ for (const [at, input] of inputsOf(step).entries()) try {
49
+ state = core.step(state, input);
50
+ } catch (error) {
51
+ const message = `step(state, ${inputText(input)}) throws: ${reason(error)} (interactive-core-contract)`;
52
+ return {
53
+ states,
54
+ error: {
55
+ path: [
56
+ "steps",
57
+ index,
58
+ step.keys === void 0 ? "inputs" : "keys",
59
+ at
60
+ ],
61
+ message
62
+ }
63
+ };
64
+ }
65
+ for (let at = 0; at < (step.ticks ?? 0); at++) try {
66
+ state = core.tick?.(state, 20) ?? state;
67
+ } catch (error) {
68
+ const message = `tick(state, 20) throws at tick ${at + 1}: ${reason(error)} (interactive-core-contract)`;
69
+ return {
70
+ states,
71
+ error: {
72
+ path: [
73
+ "steps",
74
+ index,
75
+ "ticks"
76
+ ],
77
+ message
78
+ }
79
+ };
80
+ }
81
+ states.push(state);
82
+ }
83
+ return { states };
84
+ }
85
+ const valueAt = (value, path) => path.reduce((at, part) => typeof at === "object" && at !== null ? at[part] : void 0, value);
86
+ /** The problems of the core: its exports, the start of every route, and every playthrough. */
87
+ async function coreProblems(root, content, load) {
88
+ if (!existsSync(join(root, "src/core/index.ts"))) return [`${CORE} is missing; ${CONTRACT}`];
89
+ let exports;
90
+ try {
91
+ exports = await load(`/${CORE}`);
92
+ } catch (error) {
93
+ return [`${CORE}: cannot be loaded: ${reason(error)}`];
94
+ }
95
+ const lacking = ["start", "step"].filter((name) => typeof exports[name] !== "function");
96
+ if (exports["tick"] !== void 0 && typeof exports["tick"] !== "function") lacking.push("tick as a function");
97
+ if (lacking.length > 0) return [`${CORE}: exports no ${lacking.join(", ")}; ${CONTRACT}`];
98
+ const core = exports;
99
+ const problems = new Problems();
100
+ const routes = content.app.routes ?? [];
101
+ const failed = /* @__PURE__ */ new Set();
102
+ routes.forEach((route, index) => {
103
+ try {
104
+ core.start(1, route.id);
105
+ } catch (error) {
106
+ failed.add(route.id);
107
+ problems.at(content.appYaml, [
108
+ "routes",
109
+ index,
110
+ "id"
111
+ ], `start(1, ${JSON.stringify(route.id)}) throws: ${reason(error)}; the core starts every route (interactive-core-contract)`);
112
+ }
113
+ });
114
+ for (const { yaml, playthrough } of content.playthroughs) {
115
+ const route = playthrough.route ?? routes[0]?.id ?? "";
116
+ if (failed.has(route)) continue;
117
+ const ticking = playthrough.steps.findIndex((step) => step.ticks !== void 0);
118
+ if (core.tick === void 0 && ticking >= 0) {
119
+ problems.at(yaml, [
120
+ "steps",
121
+ ticking,
122
+ "ticks"
123
+ ], `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)`);
124
+ continue;
125
+ }
126
+ const first = play(core, playthrough, route);
127
+ if (first.error !== void 0) {
128
+ problems.at(yaml, first.error.path, first.error.message);
129
+ continue;
130
+ }
131
+ const second = play(core, playthrough, route);
132
+ const differs = first.states.findIndex((state, index) => JSON.stringify(state) !== JSON.stringify(second.states[index]));
133
+ if (differs >= 0 || second.error !== void 0) {
134
+ problems.at(yaml, ["steps", Math.max(differs, 0)], "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)");
135
+ break;
136
+ }
137
+ for (const [index, step] of playthrough.steps.entries()) {
138
+ if (step.state === void 0) continue;
139
+ const found = mismatch(first.states[index], step.state);
140
+ if (found === void 0) continue;
141
+ const done = playthrough.steps.slice(0, index + 1).flatMap(doingText).join(", ");
142
+ const where = found.path.length === 0 ? "the state" : pathText(found.path);
143
+ problems.at(yaml, [
144
+ "steps",
145
+ index,
146
+ "state",
147
+ ...found.path
148
+ ], `${where} is ${JSON.stringify(found.actual) ?? "missing"} after ${done}, the playthrough expects ${JSON.stringify(valueAt(step.state, found.path))} (interactive-playthroughs-pass)`);
149
+ break;
150
+ }
151
+ }
152
+ return problems.lines;
153
+ }
154
+ /** The build step of an interactive app: validates src/, checks its components and its core, writes every page. */
155
+ function interactive() {
156
+ let root = process.cwd();
157
+ let command = "build";
158
+ let content;
159
+ const load = () => content ??= loadInteractive(root);
160
+ return [
161
+ vue(VUE_OPTIONS),
162
+ {
163
+ name: "lernapps-interactive-vue-alias",
164
+ config: () => ({ resolve: { alias: vueAlias() } })
165
+ },
166
+ {
167
+ name: "lernapps-interactive",
168
+ enforce: "post",
169
+ configResolved(config) {
170
+ root = config.root;
171
+ command = config.command;
172
+ },
173
+ async buildStart() {
174
+ content = void 0;
175
+ let loaded;
176
+ try {
177
+ loaded = load();
178
+ } catch (error) {
179
+ this.error(error instanceof Error ? error.message : String(error));
180
+ }
181
+ if (command !== "build") return;
182
+ const problems = codeProblems(root, vueFiles(root), sourceScope(root));
183
+ if (problems.length === 0) problems.push(...await withServer(root, (loader) => coreProblems(root, loaded, loader)));
184
+ if (problems.length > 0) this.error(new InteractiveError(problems).message);
185
+ },
186
+ generateBundle(_options, bundle) {
187
+ const index = bundle["index.html"];
188
+ if (index === void 0 || index.type !== "asset") return;
189
+ const shell = String(index.source);
190
+ const app = load().app;
191
+ for (const page of pages(app)) {
192
+ const html = fill(shell, renderPage(app, page));
193
+ if (page.path === "") index.source = html;
194
+ else this.emitFile({
195
+ type: "asset",
196
+ fileName: `${page.path}index.html`,
197
+ source: html
198
+ });
199
+ }
200
+ this.emitFile({
201
+ type: "asset",
202
+ fileName: "404.html",
203
+ source: notFoundPage()
204
+ });
205
+ },
206
+ configureServer(server) {
207
+ server.watcher.on("all", (_event, file) => {
208
+ if (file.endsWith(".yaml") && file.startsWith(join(root, "src"))) {
209
+ content = void 0;
210
+ server.ws.send({ type: "full-reload" });
211
+ }
212
+ });
213
+ server.middlewares.use((request, response, next) => {
214
+ const path = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname).replace(/^\//, "");
215
+ let app;
216
+ try {
217
+ app = load().app;
218
+ } catch (error) {
219
+ response.statusCode = 500;
220
+ response.setHeader("content-type", "text/plain; charset=utf-8");
221
+ response.end(error instanceof Error ? error.message : String(error));
222
+ return;
223
+ }
224
+ const page = pages(app).find((candidate) => candidate.path === path);
225
+ if (page === void 0) return next();
226
+ const shell = readFileSync(join(root, "index.html"), "utf8");
227
+ server.transformIndexHtml(`/${path}`, fill(shell, {
228
+ ...renderPage(app, page),
229
+ page: {
230
+ ...page,
231
+ path: ""
232
+ }
233
+ })).then((html) => {
234
+ response.setHeader("content-type", "text/html; charset=utf-8");
235
+ response.end(html);
236
+ }).catch(next);
237
+ });
238
+ }
239
+ }
240
+ ];
241
+ }
242
+ export { CORE, fill, interactive };
@@ -0,0 +1,75 @@
1
+ import { n as STATE_PROPERTY, r as TICK, t as CLOCK_PROPERTY } from "./contract-PONpxJtd.mjs";
2
+ import { createApp, h, shallowRef } from "vue";
3
+ // runtime/interactive/interactive.ts
4
+ /** Where a key belongs to the page's own controls, not to the app. */
5
+ const CONTROLS = "a[href], button, input, select, textarea, summary, [contenteditable]";
6
+ /**
7
+ * Starts the app on the app page: its state from `core`, shown by the component `root`, and in the heading line the
8
+ * component `options.title` if there is one. Nothing on another page.
9
+ */
10
+ function startInteractive(core, root, options = {}) {
11
+ const host = document.querySelector(".interactive-app");
12
+ if (host === null) return;
13
+ const routes = JSON.parse(host.dataset["routes"] ?? "[]");
14
+ const seedParam = Number(new URL(location.href).searchParams.get("seed"));
15
+ const seed = Number.isInteger(seedParam) && seedParam > 0 ? seedParam : 1 + Math.floor(Math.random() * 2 ** 31 - 1);
16
+ const routeOf = () => {
17
+ const id = decodeURIComponent(location.hash.slice(1));
18
+ return routes.includes(id) ? id : routes[0] ?? "";
19
+ };
20
+ let route = routeOf();
21
+ const state = shallowRef(core.start(seed, route));
22
+ const set = (next) => {
23
+ state.value = next;
24
+ Object.assign(host, { [STATE_PROPERTY]: next });
25
+ };
26
+ set(state.value);
27
+ const markRoute = () => {
28
+ for (const link of document.querySelectorAll(".interactive-routes a")) if (link.hash === `#${route}`) link.setAttribute("aria-current", "true");
29
+ else link.removeAttribute("aria-current");
30
+ };
31
+ markRoute();
32
+ /** Gives `given` to the core; whether it changed the state. */
33
+ const input = (given) => {
34
+ const next = core.step(state.value, given);
35
+ if (next === state.value) return false;
36
+ set(next);
37
+ return true;
38
+ };
39
+ document.addEventListener("keydown", (event) => {
40
+ if (event.ctrlKey || event.altKey || event.metaKey || event.isComposing) return;
41
+ if (event.target instanceof Element && event.target.closest(CONTROLS) !== null) return;
42
+ if (input({
43
+ kind: "key",
44
+ key: event.key
45
+ })) event.preventDefault();
46
+ });
47
+ addEventListener("hashchange", () => {
48
+ if (routeOf() === route) return;
49
+ route = routeOf();
50
+ set(core.start(seed, route));
51
+ markRoute();
52
+ });
53
+ const tick = () => set(core.tick?.(state.value, 20) ?? state.value);
54
+ const clock = Reflect.get(globalThis, CLOCK_PROPERTY);
55
+ if (typeof clock === "object" && clock !== null) Object.assign(clock, { tick: (count) => {
56
+ for (let at = 0; at < count; at++) tick();
57
+ } });
58
+ else if (core.tick !== void 0) setInterval(tick, 20);
59
+ const lang = document.documentElement.lang || "de";
60
+ const mount = (component, into) => {
61
+ createApp({ render: () => h(component, {
62
+ state: state.value,
63
+ lang,
64
+ onInput: input
65
+ }) }).mount(into);
66
+ };
67
+ const slot = document.querySelector(".interactive-title-slot");
68
+ if (options.title !== void 0 && slot !== null) {
69
+ mount(options.title, slot);
70
+ slot.hidden = false;
71
+ }
72
+ mount(root, host);
73
+ host.dataset["mounted"] = "";
74
+ }
75
+ export { TICK, startInteractive };