@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,488 @@
1
+ // The build step of an explainer (@lernapps/app-templates/explainer/plugin), a Vite plugin. An app's vite.config.ts
2
+ // adds it to the shared preset of @lernapps/tooling:
3
+ //
4
+ // import { lernapps } from "@lernapps/tooling/preset";
5
+ // import { explainer } from "@lernapps/app-templates/explainer/plugin";
6
+ // export default lernapps({ plugins: [explainer()] });
7
+ //
8
+ // It reads the content in src/ (load.ts) and fails the build with one line per problem. index.html is Vite's only
9
+ // entry: Vite builds the one hashed client module (src/main.ts) and stylesheet from it. The built index.html is then
10
+ // the shell of every page: each page is the shell with its language, its head and its body where it says
11
+ // <!-- explainer -->, written into its folder, with the shell's relative asset URLs (./assets/…) rewritten for its
12
+ // depth. The bundled images go to images/, the tutor page of each content page to <chapter>/<page>/tutor/ with its
13
+ // Markdown beside it (index.md; tutorpage.ts), and of each self-assessment to <assessment>/tutor/ (assessment-page.ts),
14
+ // every link of them checked, and 404.html, which leads every address the
15
+ // app does not have to its start page (notFoundPage of ../shared/pages.ts). `vp dev` serves the same pages and tutor pages, rendered on each request.
16
+ //
17
+ // Components (src/components/<Name>.vue, components.ts): only when the app has them, the plugin compiles them with
18
+ // @vitejs/plugin-vue and resolves vue to the copy this runtime depends on, so that the components and the islands
19
+ // module share one Vue. Before the pages are written, it runs the property test of each exercise component's logic
20
+ // module for every block that uses it, with the block's examples, reads what the logic describes (about, and describe
21
+ // for each task number in each language, for the no-JS text and the tutor), and renders each block's component with
22
+ // Vue's server renderer in each language, all in a Vite server of their own; a problem fails the build with its line. The islands module
23
+ // (islands.ts) is a chunk of its own with Vue, and only the pages with a component load it; each component is a chunk
24
+ // that its pages load when they hydrate it, and its own code, gzipped, must stay within the budget.
25
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
26
+ import { dirname, join, resolve } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ import { gzipSync } from "node:zlib";
29
+ import type { Component } from "vue";
30
+ import type { Plugin, ViteDevServer } from "vite-plus";
31
+ import { describedProblems, exampleProblems, propertyProblems, type ExerciseLogic } from "./components/exercise.ts";
32
+ import { propsIn } from "./components/props.ts";
33
+ import { SIZE_BUDGET } from "./components/read.ts";
34
+ import { inLanguage, type Block, type ComponentDescription, type Descriptions, type Site } from "./content.ts";
35
+ import { ContentError, loadSite } from "./load.ts";
36
+ import { dataOf, taskSeeds } from "./exercises.ts";
37
+ import { MAX_TASKS } from "./tasks.ts";
38
+ import { renderSite, type Islands, type RenderedPage } from "./render.ts";
39
+ import { fromDepth, languageRoot, notFoundPage, withLanguage } from "../shared/pages.ts";
40
+ import { type Load, VUE_OPTIONS, vueAlias, withServer } from "../shared/vue.ts";
41
+ import { allBlocks, contentPages, routes } from "./routes.ts";
42
+ import { anchorsOf, linkProblems, tutorPageOf } from "./tutor.ts";
43
+ import { assessments, stepHint } from "./assessment.ts";
44
+ import { SCOPE_PAGE, scopeDocument, scopeFiles } from "./scope.ts";
45
+ import { assessmentTutorDocument, assessmentTutorMarkdown, assessmentTutorOf } from "./assessment-page.ts";
46
+ import {
47
+ bytesOf,
48
+ type Cut,
49
+ tableHint,
50
+ TUTOR_MARKDOWN,
51
+ TUTOR_PAGE_BUDGET,
52
+ tutorPage,
53
+ tutorPageDocument,
54
+ } from "./tutorpage.ts";
55
+ import { imageType } from "../shared/blocks/image.ts";
56
+
57
+ const MARKER = "<!-- explainer -->";
58
+ const ISLANDS = "\0lernapps-explainer-islands";
59
+
60
+ /** The shell with the page in it, and `head` (the islands module) at the end of its head. */
61
+ export function fill(shell: string, page: RenderedPage, head = ""): string {
62
+ if (!shell.includes(MARKER)) throw new Error(`index.html has no ${MARKER}: put it in <body>, where the pages go`);
63
+ const html = withLanguage(shell, page.route.lang)
64
+ .replace(/\s*<title>[\s\S]*?<\/title>/, "")
65
+ .replace("</head>", ` ${page.head}${head === "" ? "" : `\n ${head}`}\n </head>`);
66
+ return fromDepth(html, page.route.path).replace(MARKER, () => page.body);
67
+ }
68
+
69
+ /** The islands module, next to this file: islands.ts in the source, explainer-islands.mjs in dist/. */
70
+ const here = fileURLToPath(import.meta.url);
71
+ const ISLANDS_RUNTIME = here.endsWith(".ts")
72
+ ? join(dirname(here), "islands.ts")
73
+ : join(dirname(here), "explainer-islands.mjs");
74
+
75
+ /** Whether the app in `root` has components. */
76
+ const hasComponents = (root: string): boolean => {
77
+ const dir = join(root, "src/components");
78
+ return existsSync(dir) && readdirSync(dir).some((file) => file.endsWith(".vue"));
79
+ };
80
+
81
+ type Hook = (this: unknown, ...args: unknown[]) => unknown;
82
+ /** @vitejs/plugin-vue, loaded only when the app has components: an app without them loads nothing of Vue. */
83
+ function vueWhenComponents(): Plugin {
84
+ let inner: Record<string, unknown> | undefined;
85
+ const delegate = (name: string): Hook =>
86
+ function (this: unknown, ...args: unknown[]): unknown {
87
+ const hook = inner?.[name] as Hook | { handler: Hook } | undefined;
88
+ if (hook === undefined) return undefined;
89
+ return (typeof hook === "function" ? hook : hook.handler).apply(this, args);
90
+ };
91
+ const hooks = [
92
+ "configResolved",
93
+ "options",
94
+ "buildStart",
95
+ "configureServer",
96
+ "handleHotUpdate",
97
+ "shouldTransformCachedModule",
98
+ "resolveId",
99
+ "load",
100
+ "transform",
101
+ ];
102
+ return {
103
+ name: "lernapps-explainer-vue",
104
+ ...Object.fromEntries(hooks.map((name) => [name, delegate(name)])),
105
+ async config(config, env) {
106
+ if (!hasComponents(resolve(config.root ?? process.cwd()))) return undefined;
107
+ const { default: vue } = await import("@vitejs/plugin-vue");
108
+ inner = vue(VUE_OPTIONS) as unknown as Record<string, unknown>;
109
+ const own = ((await delegate("config").call(this, config, env)) ?? {}) as { resolve?: object };
110
+ return { ...own, resolve: { ...own.resolve, alias: vueAlias() } };
111
+ },
112
+ } as Plugin;
113
+ }
114
+
115
+ const reason = (error: unknown) => (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
116
+
117
+ /**
118
+ * The property test of each exercise component's logic with each block's props (`test`), what its logic describes
119
+ * (about, and describe for each task number in each language), and the markup of each block's component in each
120
+ * language, rendered by Vue's server renderer, an exercise component with the task number 1; or the problems, one
121
+ * line each.
122
+ */
123
+ async function renderComponents(
124
+ site: Site,
125
+ load: Load,
126
+ test: boolean,
127
+ ): Promise<{ islands: Islands; descriptions: Descriptions; problems: string[] }> {
128
+ const problems: string[] = [];
129
+ const islands = new Map<Block, Map<string, string>>();
130
+ const descriptions = new Map<Block, ComponentDescription>();
131
+ if (site.components.length === 0) return { islands, descriptions, problems };
132
+ const { createSSRApp } = (await load("vue")) as unknown as typeof import("vue");
133
+ const { renderToString } = (await load("vue/server-renderer")) as unknown as typeof import("vue/server-renderer");
134
+ for (const { block, component, place, examples } of site.components) {
135
+ if (component.logic !== undefined) {
136
+ const logic = (await load(`/${component.file.replace(/\.vue$/, ".ts")}`)) as unknown as ExerciseLogic;
137
+ const props = propsIn(block.props, component, "de");
138
+ const contract = test ? propertyProblems(logic, props) : [];
139
+ for (const problem of contract) {
140
+ problems.push(`${place}: ${component.name}.ts: ${problem} (vue-component-exercise-contract)`);
141
+ }
142
+ if (test && contract.length === 0) {
143
+ for (const { kind, index, message } of exampleProblems(logic, props, block.examples)) {
144
+ problems.push(
145
+ `${examples[kind][index] ?? place}: ${component.name}.ts: ${message} (vue-component-exercise-contract)`,
146
+ );
147
+ }
148
+ }
149
+ if (contract.length === 0) {
150
+ const described = describedProblems(logic, (lang) => propsIn(block.props, component, lang), site.app.languages);
151
+ for (const problem of described.problems) {
152
+ problems.push(`${place}: ${component.name}.ts: ${problem} (vue-component-exercise-contract)`);
153
+ }
154
+ if (described.description !== undefined) descriptions.set(block, described.description);
155
+ }
156
+ }
157
+ const definition = (await load(`/${component.file}`))["default"] as Component;
158
+ const markup = new Map<string, string>();
159
+ for (const lang of site.app.languages) {
160
+ const app = createSSRApp(definition, {
161
+ ...propsIn(block.props, component, lang),
162
+ // the task of number 1, as the islands hydrate it
163
+ ...(component.exercise ? { seed: descriptions.get(block)?.seeds[0] ?? 1 } : {}),
164
+ ...(component.checked ? { checked: false } : {}),
165
+ });
166
+ const warnings = new Set<string>();
167
+ app.config.warnHandler = (message) => warnings.add(message);
168
+ try {
169
+ markup.set(lang, await renderToString(app));
170
+ } catch (error) {
171
+ problems.push(`${place} (${lang}): ${component.name} throws while the build renders it: ${reason(error)}`);
172
+ }
173
+ for (const warning of warnings) {
174
+ problems.push(`${place} (${lang}): Vue warns while the build renders ${component.name}: ${warning}`);
175
+ }
176
+ }
177
+ islands.set(block, markup);
178
+ }
179
+ return { islands, descriptions, problems };
180
+ }
181
+
182
+ /**
183
+ * The islands module of the app: the islands runtime, with each component the pages use and the logic module of each
184
+ * exercise component, which checks its answers in the browser, loaded on first use.
185
+ */
186
+ function islandsModule(site: Site): string {
187
+ const used = new Map(site.components.map((use) => [use.component.name, use.component]));
188
+ const loader = (name: string, file: string) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(file)}),`;
189
+ return [
190
+ `import { startIslands } from ${JSON.stringify(ISLANDS_RUNTIME)};`,
191
+ `startIslands({`,
192
+ ...[...used.keys()].map((name) => loader(name, `/src/components/${name}.vue`)),
193
+ `}, {`,
194
+ ...[...used.values()]
195
+ .filter((component) => component.exercise)
196
+ .map((component) => loader(component.name, `/src/components/${component.name}.ts`)),
197
+ `});`,
198
+ "",
199
+ ].join("\n");
200
+ }
201
+
202
+ /** A tutor page as the build writes it: its Markdown, its HTML, and its exercises with numbers left out. */
203
+ interface TutorAsset {
204
+ markdown: string;
205
+ html: string;
206
+ cut: Cut[];
207
+ }
208
+
209
+ /**
210
+ * What the build writes for a tutor beside the pages: the tutor page of each content page and of each self-assessment,
211
+ * by its folder from the app's root (with the language root), its HTML as index.html and its Markdown beside it.
212
+ */
213
+ function tutorAssets(site: Site, descriptions: Descriptions): Map<string, TutorAsset> {
214
+ const pages = new Map<string, TutorAsset>();
215
+ for (const lang of site.app.languages) {
216
+ for (const page of contentPages(site)) {
217
+ const { markdown, cut } = tutorPage(site, page, lang, descriptions);
218
+ pages.set(`${languageRoot(lang)}${tutorPageOf(page)}`, {
219
+ markdown,
220
+ html: tutorPageDocument(markdown, site, page, lang),
221
+ cut,
222
+ });
223
+ }
224
+ for (const assessment of assessments(site)) {
225
+ const markdown = assessmentTutorMarkdown(site, assessment, lang);
226
+ pages.set(`${languageRoot(lang)}${assessmentTutorOf(assessment)}`, {
227
+ markdown,
228
+ html: assessmentTutorDocument(markdown, site, lang),
229
+ cut: [],
230
+ });
231
+ }
232
+ }
233
+ return pages;
234
+ }
235
+
236
+ /** The build step of an explainer: validates the content in src/ and writes every page. */
237
+ export function explainer(): Plugin[] {
238
+ let root = process.cwd();
239
+ let command: "build" | "serve" = "build";
240
+ let site: Site | undefined;
241
+ let islands: Islands = new Map();
242
+ let descriptions: Descriptions = new Map();
243
+ let islandsChunk: string | undefined;
244
+ let devServer: ViteDevServer | undefined;
245
+ const load = () => (site ??= loadSite(root));
246
+ const main: Plugin = {
247
+ name: "lernapps-explainer",
248
+ enforce: "post",
249
+ configResolved(config) {
250
+ root = config.root;
251
+ command = config.command;
252
+ },
253
+ async buildStart() {
254
+ site = undefined;
255
+ islands = new Map();
256
+ descriptions = new Map();
257
+ islandsChunk = undefined;
258
+ let content: Site;
259
+ try {
260
+ content = load();
261
+ } catch (error) {
262
+ this.error(error instanceof Error ? error.message : String(error));
263
+ }
264
+ if (command !== "build" || content.components.length === 0) return;
265
+ const rendered = await withServer(root, (loader) => renderComponents(content, loader, true));
266
+ if (rendered.problems.length > 0) this.error(new ContentError(rendered.problems).message);
267
+ islands = rendered.islands;
268
+ descriptions = rendered.descriptions;
269
+ islandsChunk = this.emitFile({ type: "chunk", id: ISLANDS, name: "islands" });
270
+ },
271
+ resolveId(id) {
272
+ return id === ISLANDS ? ISLANDS : undefined;
273
+ },
274
+ load(id) {
275
+ return id === ISLANDS ? islandsModule(load()) : undefined;
276
+ },
277
+ generateBundle(_options, bundle) {
278
+ const index = bundle["index.html"];
279
+ if (index === undefined || index.type !== "asset") return;
280
+ const shell = String(index.source);
281
+ const content = load();
282
+ const script =
283
+ islandsChunk === undefined
284
+ ? ""
285
+ : `<script type="module" crossorigin src="./${this.getFileName(islandsChunk)}"></script>`;
286
+ for (const page of renderSite(content, islands, descriptions)) {
287
+ const html = fill(shell, page, page.islands ? script : "");
288
+ if (page.route.path === "") index.source = html;
289
+ else this.emitFile({ type: "asset", fileName: `${page.route.path}index.html`, source: html });
290
+ }
291
+ this.emitFile({ type: "asset", fileName: "404.html", source: notFoundPage() });
292
+ for (const [image, file] of content.images) {
293
+ this.emitFile({ type: "asset", fileName: image, source: readFileSync(file) });
294
+ }
295
+ const problems: string[] = [];
296
+ const tutor = tutorAssets(content, descriptions);
297
+ for (const lang of content.app.languages) {
298
+ const files = new Set([
299
+ ...routes(content)
300
+ .filter((route) => route.lang === lang)
301
+ .map((route) => route.local),
302
+ ...contentPages(content).map((page) => tutorPageOf(page)),
303
+ ...assessments(content).map((assessment) => assessmentTutorOf(assessment)),
304
+ ...[...scopeFiles(content, lang).keys()].map((folder) => `${folder}${SCOPE_PAGE}`),
305
+ ]);
306
+ const anchors = new Map(
307
+ contentPages(content).map((page) => [`${page.chapter}/${page.id}/`, anchorsOf(content, page)]),
308
+ );
309
+ // the tutor page of each content page and each self-assessment with its Markdown, each link checked against
310
+ // the pages and anchors of the language tree
311
+ for (const local of [
312
+ ...contentPages(content).map((contentPage) => tutorPageOf(contentPage)),
313
+ ...assessments(content).map((assessment) => assessmentTutorOf(assessment)),
314
+ ]) {
315
+ const page = tutor.get(`${languageRoot(lang)}${local}`);
316
+ if (page === undefined) continue;
317
+ problems.push(
318
+ ...linkProblems(local, page.markdown, files, anchors).map((problem) => `${languageRoot(lang)}${problem}`),
319
+ );
320
+ this.emitFile({ type: "asset", fileName: `${languageRoot(lang)}${local}index.html`, source: page.html });
321
+ this.emitFile({
322
+ type: "asset",
323
+ fileName: `${languageRoot(lang)}${local}${TUTOR_MARKDOWN}`,
324
+ source: page.markdown,
325
+ });
326
+ }
327
+ }
328
+ // the scopes beside the app, each chapter and each page of each language tree, for the tutor only: their
329
+ // Markdown and a page for no menu
330
+ for (const lang of content.app.languages) {
331
+ for (const [folder, markdown] of scopeFiles(content, lang)) {
332
+ const at = `${languageRoot(lang)}${folder}${SCOPE_PAGE}`;
333
+ this.emitFile({ type: "asset", fileName: `${at}${TUTOR_MARKDOWN}`, source: markdown });
334
+ this.emitFile({
335
+ type: "asset",
336
+ fileName: `${at}index.html`,
337
+ source: scopeDocument(markdown, inLanguage(content.app.title, lang), lang, folder),
338
+ });
339
+ // the whole scope beyond the budget of a tutor page: noted, no problem
340
+ if (folder === "" && bytesOf(markdown) > TUTOR_PAGE_BUDGET) {
341
+ this.info(
342
+ `lernapps explainer: the scope of the whole app (${languageRoot(lang)}${SCOPE_PAGE}) is larger than ${TUTOR_PAGE_BUDGET / 1000} kB (${(bytesOf(markdown) / 1000).toFixed(0)} kB), which a tutor may get cut off`,
343
+ );
344
+ }
345
+ }
346
+ }
347
+ // a tutor page beyond its budget: noted, no problem (the tutor may get it cut off)
348
+ const large = [...tutor].filter(([, page]) => bytesOf(page.html) > TUTOR_PAGE_BUDGET);
349
+ if (large.length > 0) {
350
+ this.info(
351
+ `lernapps explainer: ${large.length} tutor page${large.length === 1 ? " is" : "s are"} larger than ${TUTOR_PAGE_BUDGET / 1000} kB, which a tutor may get cut off: ${large.map(([file, page]) => `${file} (${(bytesOf(page.html) / 1000).toFixed(0)} kB)`).join(", ")}`,
352
+ );
353
+ }
354
+ // an exercise component whose numbers do not all fit on its tutor page: a warning, until it declares its
355
+ // table of tasks (template, params, mistakes and show of its logic module)
356
+ const cut = [...tutor].flatMap(([file, page]) => page.cut.map((one) => ({ file, ...one })));
357
+ if (cut.length > 0) {
358
+ this.warn(
359
+ `lernapps explainer: ${cut.length} exercise${cut.length === 1 ? " shows" : "s show"} only some of ${cut.length === 1 ? "its" : "their"} task numbers on the tutor page, to stay within ${TUTOR_PAGE_BUDGET / 1000} kB; export template, params, mistakes and show from its logic module for a table of all its numbers: ${cut.map((one) => `${one.file} ${one.id} (${one.shown} of ${one.count})`).join(", ")}`,
360
+ );
361
+ }
362
+ // an exercise component without its table of tasks: a hint for now
363
+ const hint = tableHint(content, descriptions);
364
+ if (hint !== undefined) this.info(hint);
365
+ // a page of a self-assessment with fewer or more exercises than it should show: a hint
366
+ const steps = stepHint(content);
367
+ if (steps !== undefined) this.info(steps);
368
+ // the exercises with fewer distinct tasks than the numbers allow: noted, no problem
369
+ const few = [
370
+ ...contentPages(content).flatMap((page) =>
371
+ [...allBlocks(page.blocks)].flatMap((block) =>
372
+ block.type === "generated"
373
+ ? [[block.id, taskSeeds(block, dataOf(block, content.data)).length] as const]
374
+ : [],
375
+ ),
376
+ ),
377
+ ...content.components.flatMap((use) => {
378
+ const seeds = descriptions.get(use.block)?.seeds;
379
+ return seeds === undefined || use.id === undefined ? [] : [[use.id, seeds.length] as const];
380
+ }),
381
+ ].filter(([, count]) => count < MAX_TASKS);
382
+ if (few.length > 0) {
383
+ this.info(
384
+ `lernapps explainer: ${few.length} exercise${few.length === 1 ? " has" : "s have"} fewer than ${MAX_TASKS} distinct tasks, numbered 1 to N: ${few.map(([id, count]) => `${id} (${count})`).join(", ")}`,
385
+ );
386
+ }
387
+ // each component's own code: its chunk and the chunks of its folder that it imports, gzipped
388
+ const chunks = Object.values(bundle).flatMap((output) => (output.type === "chunk" ? [output] : []));
389
+ const folder = join(root, "src/components");
390
+ for (const component of new Map(content.components.map((use) => [use.component.name, use.component])).values()) {
391
+ const chunk = chunks.find((candidate) => candidate.facadeModuleId === component.path);
392
+ if (chunk === undefined) continue;
393
+ const own = [
394
+ chunk.code,
395
+ ...chunk.imports
396
+ .map((file) => chunks.find((candidate) => candidate.fileName === file))
397
+ .filter((imported) => imported !== undefined && imported.moduleIds.every((id) => id.startsWith(folder)))
398
+ .map((imported) => imported?.code ?? ""),
399
+ ].join("\n");
400
+ const size = gzipSync(own).length;
401
+ if (size > SIZE_BUDGET) {
402
+ problems.push(
403
+ `${component.file}: ${(size / 1000).toFixed(1)} kB gzip of its own code, more than the budget of ${SIZE_BUDGET / 1000} kB; split it into smaller components, or ask for a built-in block (vue-component-size)`,
404
+ );
405
+ }
406
+ }
407
+ if (problems.length > 0) this.error(new ContentError(problems).message);
408
+ },
409
+ configureServer(server) {
410
+ devServer = server;
411
+ server.watcher.on("all", (_event, file) => {
412
+ if (file.startsWith(join(root, "src")) && !file.endsWith(".ts")) {
413
+ site = undefined;
414
+ server.ws.send({ type: "full-reload" });
415
+ }
416
+ });
417
+ // before Vite's own fallback to index.html
418
+ server.middlewares.use((request, response, next) => {
419
+ const url = new URL(request.url ?? "/", "http://localhost");
420
+ const path = decodeURIComponent(url.pathname).replace(/^\//, "");
421
+ if (path.startsWith("images/")) {
422
+ response.setHeader("content-type", imageType(path));
423
+ response.end(readFileSync(join(root, "src", path)));
424
+ return;
425
+ }
426
+ const forTutor = new RegExp(`(^|/)tutor/(${TUTOR_MARKDOWN.replace(".", "\\.")})?$`).test(path);
427
+ const forScope = new RegExp(`(^|/)${SCOPE_PAGE}(${TUTOR_MARKDOWN.replace(".", "\\.")})?$`).test(path);
428
+ if (!(path === "" || path.endsWith("/") || forTutor || forScope)) return next();
429
+ const fail = (error: unknown) => {
430
+ response.statusCode = 500;
431
+ response.setHeader("content-type", "text/plain; charset=utf-8");
432
+ response.end(error instanceof Error ? error.message : String(error));
433
+ };
434
+ let content: Site;
435
+ try {
436
+ content = load();
437
+ } catch (error) {
438
+ fail(error);
439
+ return;
440
+ }
441
+ const dev = devServer;
442
+ if (dev === undefined) return next();
443
+ renderComponents(content, (id) => dev.ssrLoadModule(id), false)
444
+ .then(({ islands: rendered, descriptions: described, problems }) => {
445
+ if (problems.length > 0) throw new ContentError(problems);
446
+ if (forScope) {
447
+ const markdown = path.endsWith(TUTOR_MARKDOWN);
448
+ const lang =
449
+ content.app.languages.find((one) => languageRoot(one) !== "" && path.startsWith(languageRoot(one))) ??
450
+ content.app.languages[0] ??
451
+ "de";
452
+ // the folder within the language tree whose scope this is
453
+ const folder = path
454
+ .slice(languageRoot(lang).length)
455
+ .replace(new RegExp(`${SCOPE_PAGE}(${TUTOR_MARKDOWN.replace(".", "\\.")})?$`), "");
456
+ const scope = scopeFiles(content, lang).get(folder);
457
+ if (scope === undefined) return next();
458
+ response.setHeader("content-type", `text/${markdown ? "markdown" : "html"}; charset=utf-8`);
459
+ response.end(markdown ? scope : scopeDocument(scope, inLanguage(content.app.title, lang), lang, folder));
460
+ return undefined;
461
+ }
462
+ if (forTutor) {
463
+ const markdown = path.endsWith(TUTOR_MARKDOWN);
464
+ const page = tutorAssets(content, described).get(markdown ? path.slice(0, -TUTOR_MARKDOWN.length) : path);
465
+ if (page === undefined) return next();
466
+ response.setHeader("content-type", `text/${markdown ? "markdown" : "html"}; charset=utf-8`);
467
+ response.end(markdown ? page.markdown : page.html);
468
+ return undefined;
469
+ }
470
+ const page = renderSite(content, rendered, described).find((candidate) => candidate.route.path === path);
471
+ if (page === undefined) return next();
472
+ const shell = readFileSync(join(root, "index.html"), "utf8");
473
+ const head = page.islands
474
+ ? `<script type="module" src="/@id/__x00__lernapps-explainer-islands"></script>`
475
+ : "";
476
+ return dev
477
+ .transformIndexHtml(`/${path}`, fill(shell, { ...page, route: { ...page.route, path: "" } }, head))
478
+ .then((html) => {
479
+ response.setHeader("content-type", "text/html; charset=utf-8");
480
+ response.end(html);
481
+ });
482
+ })
483
+ .catch(fail);
484
+ });
485
+ },
486
+ };
487
+ return [vueWhenComponents(), main];
488
+ }
@@ -0,0 +1,62 @@
1
+ // Where the learner is, for the two ways to the tutor (explainer.ts): as the query of the page's tutor page, which
2
+ // the learner's message names. The tutor page holds every task of the page (tutorpage.ts), and a static host serves
3
+ // it for any query: the tutor reads the query from its own prompt. It is the page's own address, the task number of
4
+ // each of its exercises (?<id>=<n>; tasks.ts). Way A, "Mit <name> lernen", is for learning the page from its start:
5
+ // the numbers alone (pageQuery), so that the tutor leads through the page before it comes to a task. Way B, "Hilfe",
6
+ // adds the exercise whose button was pressed (uebung) and its section (abschnitt; taskQuery). Pure and without Node:
7
+ // the client module calls it.
8
+ import { numberFromAddress } from "./tasks.ts";
9
+
10
+ /** What a page tells the client module about itself for the learner's position (render.ts). */
11
+ export interface PagePosition {
12
+ id: string;
13
+ /** Its exercises in order, each with the anchor of its section and whether its tasks have numbers. */
14
+ exercises: readonly { id: string; section?: string; numbered: boolean }[];
15
+ /** The anchors of its sections. */
16
+ sections: readonly string[];
17
+ }
18
+
19
+ type Exercise = PagePosition["exercises"][number];
20
+
21
+ /** The anchor of the address `href`, decoded. */
22
+ const anchorOf = (href: string): string => decodeURIComponent(new URL(href).hash.slice(1));
23
+
24
+ /**
25
+ * The exercise the learner is at on the page at `href`: the one answered last, else the one the anchor names, else the
26
+ * page's only exercise; none on a page with several and no such hint.
27
+ */
28
+ export function currentExercise(href: string, page: PagePosition, last?: string): Exercise | undefined {
29
+ const anchor = anchorOf(href);
30
+ const byId = (id: string | undefined) => page.exercises.find((exercise) => exercise.id === id);
31
+ return byId(last) ?? byId(anchor) ?? (page.exercises.length === 1 ? page.exercises[0] : undefined);
32
+ }
33
+
34
+ /**
35
+ * The query of the page's tutor page: the task number the address `href` holds of each exercise with numbers, in the
36
+ * page's order, then the exercise `exercise` and the section `section`, each if any; "" for none of them.
37
+ */
38
+ function tutorQuery(
39
+ href: string,
40
+ page: PagePosition,
41
+ exercise: Exercise | undefined,
42
+ section: string | undefined,
43
+ ): string {
44
+ const query = new URLSearchParams();
45
+ for (const candidate of page.exercises) {
46
+ const n = candidate.numbered ? numberFromAddress(href, candidate.id) : undefined;
47
+ if (n !== undefined) query.set(candidate.id, String(n));
48
+ }
49
+ if (exercise !== undefined) query.set("uebung", exercise.id);
50
+ if (section !== undefined) query.set("abschnitt", section);
51
+ const text = query.toString();
52
+ return text === "" ? "" : `?${text}`;
53
+ }
54
+
55
+ /** Way A: the task numbers the address `href` holds of the page's exercises, for learning the page. */
56
+ export const pageQuery = (href: string, page: PagePosition): string => tutorQuery(href, page, undefined, undefined);
57
+
58
+ /** Way B: the page's task numbers at `href`, and the exercise `id` with its section. */
59
+ export function taskQuery(href: string, page: PagePosition, id: string): string {
60
+ const exercise = page.exercises.find((candidate) => candidate.id === id);
61
+ return tutorQuery(href, page, exercise ?? { id, numbered: false }, exercise?.section);
62
+ }
@@ -0,0 +1,75 @@
1
+ // The start prompt of a content page and the assistants that take it, way A to the tutor ("Mit <name> lernen"; way B,
2
+ // "Hilfe" at one task, is stuck.ts). The learner switches an assistant on once (Claude, ChatGPT or Mistral Vibe, the same
3
+ // for every app), or copying the text; then every content page has a bar beside its title. Its
4
+ // button opens the assistant with the start prompt in its address: one sentence in the learner's voice that names
5
+ // the page's tutor page (tutorpage.ts) with where the learner is in its query (position.ts). A chat assistant
6
+ // fetches only addresses its user wrote into the conversation, so the prompt names the one address the tutor needs,
7
+ // and the tutor page holds all the rest, its guide among it: the prompt itself gives the tutor no rules. Mistral Vibe
8
+ // treats a message from its address as not written by the user and loads nothing from it (`paste`): there the click
9
+ // copies the start prompt and opens the chat with a short placeholder message; the learner pastes, and the prompt,
10
+ // now the learner's own message, lets the tutor load its tutor page. For copying,
11
+ // the client module adds the tutor page's Markdown after the prompt, nothing going over the network but the app's
12
+ // own static file. Without JavaScript the provider links are plain links to the assistant, and the tutor page is a
13
+ // page to read and copy from. Nothing loads before a click.
14
+ import { type Route } from "./routes.ts";
15
+ import { tutorPageOf } from "./tutor.ts";
16
+
17
+ /** Where the start prompt's address starts: replaced by the address of the language tree when the page uses it. */
18
+ export const BASE = "⟦base⟧";
19
+
20
+ /** Where the start prompt of a content page has the learner's position: replaced by the query the page builds. */
21
+ export const QUERY = "⟦query⟧";
22
+
23
+ /**
24
+ * An assistant the start page offers: its name, and its address with {prompt} where the prompt goes. An assistant that
25
+ * fetches no address from a message it got through its own address (Mistral Vibe) has `paste`: the address carries only a
26
+ * short placeholder message, and the learner pastes the copied start prompt, which then counts as written by them.
27
+ */
28
+ export interface Provider {
29
+ name: string;
30
+ url: string;
31
+ paste?: boolean;
32
+ }
33
+
34
+ /** The assistants a learner can switch on, the same for every app: each takes the prompt in its address. */
35
+ export const PROVIDERS: readonly Provider[] = [
36
+ { name: "Claude", url: "https://claude.ai/new?q={prompt}" },
37
+ { name: "ChatGPT", url: "https://chatgpt.com/?q={prompt}" },
38
+ { name: "Mistral Vibe", url: "https://chat.mistral.ai/chat?q={prompt}", paste: true },
39
+ ];
40
+
41
+ const MARK = "lernapps-prompt";
42
+ /** A provider's address without the prompt, for the link without JavaScript: its parameter with {prompt} left out. */
43
+ export function plainAddress(url: string): string {
44
+ const parsed = new URL(url.replace(/\{prompt\}/g, MARK));
45
+ const keys = Array.from(parsed.searchParams.entries()).flatMap(([key, value]) => (value.includes(MARK) ? [key] : []));
46
+ for (const key of keys) parsed.searchParams.delete(key);
47
+ return parsed.href.replace(/\?$/, "");
48
+ }
49
+
50
+ const LOAD: Readonly<Record<string, string>> = {
51
+ de: "Lade {url} und sei mein Tutor.",
52
+ en: "Load {url} and be my tutor.",
53
+ };
54
+
55
+ const PLACEHOLDER: Readonly<Record<string, string>> = {
56
+ de: "Ich brauche Hilfe bei einer Lern-Einheit. Ich schreibe Dir gleich, worum es geht.",
57
+ en: "I need help with a learning unit. I will tell you in a moment what it is about.",
58
+ };
59
+
60
+ /** The short message a `paste` assistant gets in its address, in `lang`: it holds no address to fetch. */
61
+ export const placeholderPrompt = (lang: string): string => PLACEHOLDER[lang] ?? PLACEHOLDER["de"] ?? "";
62
+
63
+ /**
64
+ * The start prompt a provider link carries from a content page, the one kind of page that starts a tutor, in its
65
+ * language: "Lade <chapter>/<page>/tutor/<QUERY> und sei mein Tutor.", the page's tutor page with
66
+ * where the learner is in its query, which the client module fills in (position.ts, pageQuery). Its address starts
67
+ * with BASE.
68
+ */
69
+ export function startPrompt(route: Pick<Route, "lang" | "page">): string {
70
+ const page = { chapter: route.page?.chapter ?? "", id: route.page?.id ?? "" };
71
+ return (LOAD[route.lang] ?? LOAD["de"] ?? "").replace("{url}", `${BASE}${tutorPageOf(page)}${QUERY}`);
72
+ }
73
+
74
+ /** The prompt with its address from `base`, the language tree's address. */
75
+ export const withBase = (prompt: string, base: string): string => prompt.split(BASE).join(base);