@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,25 @@
1
+ // A bundled image at build time, in Node: whether it has no size of its own. Not part of index.ts, which the browser
2
+ // may import.
3
+ import { readFileSync } from "node:fs";
4
+
5
+ const TYPES: Readonly<Record<string, string>> = {
6
+ svg: "image/svg+xml",
7
+ png: "image/png",
8
+ jpg: "image/jpeg",
9
+ webp: "image/webp",
10
+ };
11
+
12
+ /** The media type of a bundled image, by its file name. */
13
+ export const imageType = (name: string): string =>
14
+ TYPES[name.slice(name.lastIndexOf(".") + 1)] ?? "application/octet-stream";
15
+
16
+ /**
17
+ * Whether the SVG file `file` has no size of its own: its root has neither a width nor a height (only a viewBox, or
18
+ * percentages). In a box as wide as its content, as the image of a labelled figure is, the browser draws such an image
19
+ * with zero width. Another kind of image has a size.
20
+ */
21
+ export function isSizeless(file: string): boolean {
22
+ if (!file.endsWith(".svg")) return false;
23
+ const root = /<svg\b[^>]*>/i.exec(readFileSync(file, "utf8"))?.[0] ?? "";
24
+ return !/\s(?:width|height)\s*=\s*["']?\s*\d*\.?\d+\s*(?:px|pt|pc|mm|cm|in|em|ex|rem)?\s*["'\s/>]/i.test(root);
25
+ }
@@ -0,0 +1,13 @@
1
+ // The blocks that show content (@lernapps/app-templates/blocks): see block.ts, validate.ts and render.ts.
2
+ export * from "./block.ts";
3
+ export { highlight, type CodeLanguage } from "./code.ts";
4
+ export { bar, hundredGrid } from "./figures.ts";
5
+ export { renderCredit, renderMaterial, type MaterialContext } from "./render.ts";
6
+ export {
7
+ checkMaterial,
8
+ MATERIAL_DEFINITIONS,
9
+ type AppChecks,
10
+ type BlockChecks,
11
+ type BlockLink,
12
+ type BlockProblem,
13
+ } from "./validate.ts";
@@ -0,0 +1,4 @@
1
+ {
2
+ "credit": "Quelle: {source}. Lizenz: {licence}.",
3
+ "code": "Code in {language}"
4
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "credit": "Source: {source}. Licence: {licence}.",
3
+ "code": "Code in {language}"
4
+ }
@@ -0,0 +1,134 @@
1
+ // Renders blocks that show content at build time into HTML: prose, a formula (MathML), a figure (an inline SVG drawn
2
+ // from data, or a bundled image), a labelled figure (a bundled image with markers and, unless it is left out, a legend), a table, a
3
+ // quotation and highlighted code, each with its caption and, for an image or a quotation, its credit. The page that
4
+ // shows them says where a bundled image is from the page and how a link to one of its pages resolves; the look is
5
+ // blocks.css.
6
+ import { escape } from "../html.ts";
7
+ import { inLanguage, localeOf, textsIn, type Language, type Text } from "../languages.ts";
8
+ import { renderFormula } from "../text/formula.ts";
9
+ import { renderInline, renderMarkdown } from "../text/markdown.ts";
10
+ import { isUrl } from "../text/check.ts";
11
+ import { markerOf, type Credit, type MaterialBlock } from "./block.ts";
12
+ import { highlight } from "./code.ts";
13
+ import { bar, hundredGrid } from "./figures.ts";
14
+ import de from "./messages/de.json" with { type: "json" };
15
+ import en from "./messages/en.json" with { type: "json" };
16
+
17
+ export const BLOCK_MESSAGES: Readonly<Record<Language, typeof de>> = { de, en };
18
+
19
+ /** What rendering blocks needs from the page that shows them. */
20
+ export interface MaterialContext {
21
+ /** The language of the page. */
22
+ lang: string;
23
+ /** The address of the bundled image `image` (images/<name>) from the page. */
24
+ href: (image: string) => string;
25
+ /** Where a link to a page of the app leads; an https address is left as it is. */
26
+ resolve?: (href: string) => string;
27
+ /**
28
+ * Whether the bundled image has no size of its own: an SVG whose root has neither a width nor a height (only a
29
+ * viewBox, or percentages). In a box as wide as its content, as the image of a labelled figure is, the browser
30
+ * draws such an image with zero width.
31
+ */
32
+ sizeless?: (image: string) => boolean;
33
+ }
34
+
35
+ const external = (url: string) => `<a href="${escape(url)}" rel="noopener">${escape(url)}</a>`;
36
+
37
+ /** The credit of an image or a quotation: its source, as a link when it is a URL, and its licence. */
38
+ export function renderCredit(item: Credit<Text>, lang: string): string {
39
+ if (item.source === undefined || item.licence === undefined) return "";
40
+ const given = inLanguage(item.source, lang);
41
+ const source = isUrl(given) ? external(given) : escape(given);
42
+ const text = textsIn(BLOCK_MESSAGES, lang)
43
+ .credit.replace("{licence}", () => escape(item.licence ?? ""))
44
+ .replace("{source}", () => source);
45
+ return ` <span class="block-credit">${text}</span>`;
46
+ }
47
+
48
+ function renderBlock(block: MaterialBlock<Text>, context: MaterialContext): string {
49
+ const { lang } = context;
50
+ const resolve = context.resolve ?? ((href: string) => href);
51
+ const plain = (text: Text) => escape(inLanguage(text, lang));
52
+ const markdown = (text: Text) => renderMarkdown(inLanguage(text, lang), resolve);
53
+ const inline = (text: Text) => renderInline(inLanguage(text, lang), resolve);
54
+ switch (block.type) {
55
+ case "text":
56
+ return `<div class="block-text">${markdown(block.text)}</div>`;
57
+ case "formula":
58
+ return `<div class="block-formula">${renderFormula(inLanguage(block.formula, lang), true)}</div>`;
59
+ case "figure": {
60
+ const alt = inLanguage(block.alt, lang);
61
+ let picture = "";
62
+ if (block.draw?.kind === "hundred-grid") picture = hundredGrid(block.draw.filled, alt);
63
+ else if (block.draw?.kind === "bar") picture = bar(block.draw.part, block.draw.whole, alt, localeOf(lang));
64
+ else if (block.image !== undefined)
65
+ picture = `<img src="${escape(context.href(block.image))}" alt="${escape(alt)}" loading="lazy" decoding="async">`;
66
+ return `<figure class="block-figure">${picture}<figcaption>${markdown(block.caption)}${renderCredit(block, lang)}</figcaption></figure>`;
67
+ }
68
+ case "labelled-figure": {
69
+ const markers = block.labels
70
+ .map(
71
+ (label, index) =>
72
+ `<span class="block-marker" style="left: ${label.x}%; top: ${label.y}%" aria-hidden="true">${escape(markerOf(label, index))}</span>`,
73
+ )
74
+ .join("");
75
+ // a legend names the markers the image shows: numbered by the list as long as no label names its own marker
76
+ const named = block.labels.some((label) => label.marker !== undefined);
77
+ const item = (label: (typeof block.labels)[number], index: number) =>
78
+ named
79
+ ? `<li><b>${escape(markerOf(label, index))}</b> ${plain(label.text)}</li>`
80
+ : `<li>${plain(label.text)}</li>`;
81
+ const legend =
82
+ block.legend === false
83
+ ? ""
84
+ : `<ol class="block-legend${named ? " block-legend-named" : ""}">${block.labels.map(item).join("")}</ol>`;
85
+ const caption = block.caption === undefined ? "" : markdown(block.caption);
86
+ const fill = context.sizeless?.(block.image) === true ? " block-labelled-fill" : "";
87
+ return [
88
+ `<figure class="block-figure block-labelled">`,
89
+ `<div class="block-labelled-image${fill}"><img src="${escape(context.href(block.image))}" alt="${plain(block.alt)}" loading="lazy" decoding="async">${markers}</div>`,
90
+ legend,
91
+ `<figcaption>${caption}${renderCredit(block, lang)}</figcaption>`,
92
+ `</figure>`,
93
+ ].join("");
94
+ }
95
+ case "table": {
96
+ const caption = plain(block.caption);
97
+ const head =
98
+ block.header === undefined
99
+ ? ""
100
+ : `<thead><tr>${block.header.map((cell) => `<th scope="col">${inline(cell)}</th>`).join("")}</tr></thead>`;
101
+ const rows = block.rows
102
+ .map(
103
+ (row) =>
104
+ `<tr>${row
105
+ .map((cell, index) =>
106
+ index === 0 && block.rowHeaders === true
107
+ ? `<th scope="row">${inline(cell)}</th>`
108
+ : `<td>${inline(cell)}</td>`,
109
+ )
110
+ .join("")}</tr>`,
111
+ )
112
+ .join("");
113
+ return `<div class="block-table" role="region" aria-label="${caption}" tabindex="0"><table><caption>${caption}</caption>${head}<tbody>${rows}</tbody></table></div>`;
114
+ }
115
+ case "quote": {
116
+ const { author, work, year } = block.origin;
117
+ const origin = [
118
+ plain(author),
119
+ ...(work === undefined ? [] : [`<cite>${plain(work)}</cite>`]),
120
+ ...(year === undefined ? [] : [typeof year === "number" ? String(year) : plain(year)]),
121
+ ].join(", ");
122
+ return `<figure class="block-quote"><blockquote>${markdown(block.text)}</blockquote><figcaption>— ${origin}.${renderCredit(block, lang)}</figcaption></figure>`;
123
+ }
124
+ case "code": {
125
+ const label = textsIn(BLOCK_MESSAGES, lang).code.replace("{language}", () => block.language);
126
+ const caption = block.caption === undefined ? "" : `<figcaption>${plain(block.caption)}</figcaption>`;
127
+ return `<figure class="block-code"><pre role="region" aria-label="${escape(label)}" tabindex="0"><code class="hljs language-${block.language}">${highlight(block.code, block.language)}</code></pre>${caption}</figure>`;
128
+ }
129
+ }
130
+ }
131
+
132
+ /** The blocks as HTML, one after the other. */
133
+ export const renderMaterial = (blocks: readonly MaterialBlock<Text>[], context: MaterialContext): string =>
134
+ blocks.map((block) => renderBlock(block, context)).join("");
@@ -0,0 +1,165 @@
1
+ // What the schema cannot say about blocks that show content, at build time: a figure is drawn or an image, not both
2
+ // and not neither; a bundled image exists; every text is complete in the declared languages and holds what its kind
3
+ // allows (plain text without raw HTML, restricted Markdown, a formula, a name, the source of a credit). The place of a
4
+ // problem is a path below the blocks: [2, "caption", "en"] for the English caption of the third block.
5
+ import { inLanguage, type Text } from "../languages.ts";
6
+ import { checkText, type TextFinding, type TextKind } from "../text/check.ts";
7
+ import { markerOf, type MaterialBlock } from "./block.ts";
8
+
9
+ /** The definition of each type of block in the schema of the blocks (blocks.v1.schema.json). */
10
+ export const MATERIAL_DEFINITIONS: Readonly<Record<MaterialBlock["type"], string>> = {
11
+ text: "textBlock",
12
+ formula: "formula",
13
+ figure: "figure",
14
+ "labelled-figure": "labelledFigure",
15
+ table: "table",
16
+ quote: "quote",
17
+ code: "code",
18
+ };
19
+
20
+ /** Whether `text` names `marker`: as a word of its own, so that the marker A is not found in "Grundriss". */
21
+ const names = (text: string, marker: string): boolean =>
22
+ new RegExp(`(?<![\\p{L}\\p{N}])${marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\p{L}\\p{N}])`, "u").test(text);
23
+
24
+ /** A problem in blocks: where in them (a path), in which language, in Markdown on which line below the first, and what. */
25
+ export interface BlockProblem {
26
+ path: (string | number)[];
27
+ lang?: string;
28
+ line?: number;
29
+ message: string;
30
+ }
31
+
32
+ /** A link to a page of the app in a text of a block. */
33
+ export interface BlockLink {
34
+ path: (string | number)[];
35
+ lang: string;
36
+ href: string;
37
+ }
38
+
39
+ /** What the checks need to know about the app. */
40
+ export interface BlockChecks {
41
+ /** The languages of the app, German first; ["de"] while German is the only one. */
42
+ languages: readonly string[];
43
+ /** Where the app declares its languages, for the message about a language it does not declare. */
44
+ declaredIn?: string;
45
+ /** Whether the bundled image `image` (images/<name>) is a file in src/images/ of the app. */
46
+ imageExists(image: string): boolean;
47
+ /**
48
+ * What to do with a link to a page of the app in a caption or a text: the page that holds the blocks resolves it
49
+ * later. Without it such a link is a problem, as an app without pages (a quiz) has none.
50
+ */
51
+ onLink?(link: BlockLink): void;
52
+ }
53
+
54
+ /** What the checks need to know about the app beyond its languages. */
55
+ export type AppChecks = Pick<BlockChecks, "imageExists" | "onLink">;
56
+
57
+ /** The problems of `blocks`. */
58
+ export function checkMaterial(blocks: readonly MaterialBlock<Text>[], checks: BlockChecks): BlockProblem[] {
59
+ const problems: BlockProblem[] = [];
60
+ const text = (at: (string | number)[], value: Text | undefined, kind: TextKind) => {
61
+ if (value === undefined) return;
62
+ const { findings, links } = checkText(value, kind, checks.languages, checks.declaredIn);
63
+ for (const finding of findings as TextFinding[]) problems.push({ ...finding, path: [...at, ...finding.path] });
64
+ for (const link of links) {
65
+ const place = [...at, ...link.path];
66
+ if (checks.onLink !== undefined) checks.onLink({ ...link, path: place });
67
+ else {
68
+ problems.push({
69
+ path: place,
70
+ lang: link.lang,
71
+ message: `${link.href} is a link to a page of the app; here there are no pages: link an https address`,
72
+ });
73
+ }
74
+ }
75
+ };
76
+ blocks.forEach((block, index) => {
77
+ const at: (string | number)[] = [index];
78
+ switch (block.type) {
79
+ case "text":
80
+ text([...at, "text"], block.text, "markdown");
81
+ break;
82
+ case "formula":
83
+ text([...at, "formula"], block.formula, "latex");
84
+ break;
85
+ case "figure":
86
+ case "labelled-figure": {
87
+ if (block.type === "figure" && (block.draw === undefined) === (block.image === undefined)) {
88
+ problems.push({
89
+ path: at,
90
+ message:
91
+ block.draw === undefined
92
+ ? "draw or image is missing; a figure is drawn from data (draw) or a bundled image (image)"
93
+ : "draw and image: a figure is either drawn from data or a bundled image",
94
+ });
95
+ }
96
+ if (block.image !== undefined && !checks.imageExists(block.image)) {
97
+ problems.push({
98
+ path: [...at, "image"],
99
+ message: `${block.image} is missing: put the file into src/images/`,
100
+ });
101
+ }
102
+ text([...at, "alt"], block.alt, "plain");
103
+ text([...at, "caption"], block.caption, "markdown");
104
+ if (block.type === "labelled-figure") {
105
+ const seen = new Set<string>();
106
+ block.labels.forEach((label, number) => {
107
+ text([...at, "labels", number, "text"], label.text, "plain");
108
+ const marker = markerOf(label, number);
109
+ if (seen.has(marker)) {
110
+ problems.push({
111
+ path: [...at, "labels", number, "marker"],
112
+ message: `${marker} is used twice; give each label its own marker`,
113
+ });
114
+ }
115
+ seen.add(marker);
116
+ });
117
+ // without a legend, alt is the only text that tells which part a marker stands for
118
+ if (block.legend === false) {
119
+ for (const lang of checks.languages) {
120
+ const alt = inLanguage(block.alt, lang);
121
+ block.labels.forEach((label, number) => {
122
+ const marker = markerOf(label, number);
123
+ if (!names(alt, marker)) {
124
+ problems.push({
125
+ path: [...at, "alt"],
126
+ lang,
127
+ message: `alt does not name the marker ${marker}; with legend: false, alt names every marker with its part`,
128
+ });
129
+ }
130
+ });
131
+ }
132
+ }
133
+ }
134
+ text([...at, "source"], block.source, "credit");
135
+ break;
136
+ }
137
+ case "table": {
138
+ text([...at, "caption"], block.caption, "plain");
139
+ block.header?.forEach((cell, number) => text([...at, "header", number], cell, "markdown"));
140
+ const columns = block.header?.length ?? block.rows[0]?.length ?? 0;
141
+ block.rows.forEach((row, line) => {
142
+ if (row.length !== columns) {
143
+ problems.push({
144
+ path: [...at, "rows", line],
145
+ message: `${row.length} cells, but the table has ${columns} columns; give each row a cell per column`,
146
+ });
147
+ }
148
+ row.forEach((_cell, number) => text([...at, "rows", line, number], row[number], "markdown"));
149
+ });
150
+ break;
151
+ }
152
+ case "quote":
153
+ text([...at, "text"], block.text, "markdown");
154
+ text([...at, "origin", "author"], block.origin.author, "name");
155
+ text([...at, "origin", "work"], block.origin.work, "name");
156
+ if (typeof block.origin.year !== "number") text([...at, "origin", "year"], block.origin.year, "plain");
157
+ text([...at, "source"], block.source, "credit");
158
+ break;
159
+ case "code":
160
+ text([...at, "caption"], block.caption, "plain");
161
+ break;
162
+ }
163
+ });
164
+ return problems;
165
+ }
@@ -0,0 +1,197 @@
1
+ // The content of an app as YAML files, for every archetype whose content is YAML (the explainer, the interactive
2
+ // archetype): reading a file with its line positions, collecting the problems with their place in it (one line each:
3
+ // the file, its line and the path of the value), turning the errors of a JSON Schema into problems an author can act
4
+ // on, and checking the texts of the content (languages, raw HTML, restricted Markdown, formulas).
5
+ import { readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import type { ErrorObject } from "ajv/dist/2020.js";
8
+ import { isNode, LineCounter, parseDocument, type Document } from "yaml";
9
+ import type { Text } from "./languages.ts";
10
+ import { checkText, type TextKind } from "./text/check.ts";
11
+
12
+ export type Path = readonly (string | number)[];
13
+
14
+ /** A YAML file of the content, parsed, with its line positions. */
15
+ export interface YamlFile {
16
+ file: string;
17
+ doc: Document;
18
+ lines: LineCounter;
19
+ value: unknown;
20
+ }
21
+
22
+ /** `["blocks", 0, "text"]` as `blocks[0].text`. */
23
+ export function pathText(path: Path): string {
24
+ return path.map((part, index) => (typeof part === "number" ? `[${part}]` : index === 0 ? part : `.${part}`)).join("");
25
+ }
26
+
27
+ export const get = (value: unknown, path: Path): unknown =>
28
+ path.reduce<unknown>(
29
+ (at, part) => (typeof at === "object" && at !== null ? (at as Record<string, unknown>)[part] : undefined),
30
+ value,
31
+ );
32
+ export const isObject = (value: unknown): value is Record<string, unknown> =>
33
+ typeof value === "object" && value !== null && !Array.isArray(value);
34
+
35
+ /** Collects the problems, one line each, without repeating one. */
36
+ export class Problems {
37
+ readonly lines: string[] = [];
38
+ add(line: string): void {
39
+ if (!this.lines.includes(line)) this.lines.push(line);
40
+ }
41
+ /** A problem at `path` in `yaml` (in `lang`), `offset` lines below the line of the value there. */
42
+ at(yaml: YamlFile, path: Path, message: string, lang?: string, offset = 0): void {
43
+ const line = lineOf(yaml, path, offset);
44
+ // the text of one language is at <path>.<lang>: named as <path> (<lang>)
45
+ const shown = lang !== undefined && path.at(-1) === lang ? path.slice(0, -1) : path;
46
+ const where = shown.length === 0 ? "" : ` ${pathText(shown)}${lang === undefined ? "" : ` (${lang})`}:`;
47
+ this.add(`${yaml.file}${line === undefined ? "" : `:${line}`}:${where} ${message}`);
48
+ }
49
+ file(file: string, message: string): void {
50
+ this.add(`${file}: ${message}`);
51
+ }
52
+ }
53
+
54
+ /** The line of the value at `path`, or of the nearest value around it; a block scalar's text starts a line lower. */
55
+ export function lineOf(yaml: YamlFile, path: Path, offset: number): number | undefined {
56
+ for (let length = path.length; length >= 0; length--) {
57
+ const node = yaml.doc.getIn(path.slice(0, length), true);
58
+ if (!isNode(node) || node.range === undefined || node.range === null) continue;
59
+ const block = "type" in node && (node.type === "BLOCK_LITERAL" || node.type === "BLOCK_FOLDED");
60
+ const start = yaml.lines.linePos(node.range[0]).line;
61
+ return start + (length === path.length ? offset + (block && offset > 0 ? 1 : block ? 1 : 0) : 0);
62
+ }
63
+ return undefined;
64
+ }
65
+
66
+ /** Reads a YAML file of the content (`file` from the app's root); undefined, with its problems, when it is not valid YAML. */
67
+ export function readYaml(root: string, file: string, problems: Problems): YamlFile | undefined {
68
+ const lines = new LineCounter();
69
+ const doc = parseDocument(readFileSync(join(root, file), "utf8"), { lineCounter: lines, prettyErrors: false });
70
+ if (doc.errors.length > 0) {
71
+ for (const error of doc.errors) {
72
+ const line = lines.linePos(error.pos[0]).line;
73
+ problems.add(`${file}:${line}: not valid YAML: ${error.message.split("\n")[0] ?? ""}`);
74
+ }
75
+ return undefined;
76
+ }
77
+ return { file, doc, lines, value: doc.toJS() as unknown };
78
+ }
79
+
80
+ /**
81
+ * What an archetype says about an error of its schema in its own words: the message, at the error's place or at `path`;
82
+ * null to say nothing (another error says it); undefined to leave it to the general messages.
83
+ */
84
+ export type DescribeError = (
85
+ error: ErrorObject,
86
+ path: Path,
87
+ value: unknown,
88
+ ) => { message: string; path?: Path; lang?: string } | null | undefined;
89
+
90
+ /** The schema's errors as problems: one per cause, the branches of texts and blocks folded. */
91
+ export function schemaProblems(
92
+ yaml: YamlFile,
93
+ errors: readonly ErrorObject[],
94
+ problems: Problems,
95
+ describe?: DescribeError,
96
+ ): void {
97
+ const pathOf = (pointer: string): Path =>
98
+ pointer
99
+ .split("/")
100
+ .slice(1)
101
+ .map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"))
102
+ .map((part) => (/^\d+$/.test(part) ? Number(part) : part));
103
+ const texts = errors.filter((error) => error.keyword === "anyOf").map((error) => error.instancePath);
104
+ const underText = (error: ErrorObject) =>
105
+ texts.some((text) => error.instancePath === text || error.instancePath.startsWith(`${text}/`));
106
+ for (const error of errors) {
107
+ const path = pathOf(error.instancePath);
108
+ const value = get(yaml.value, path);
109
+ const described = describe?.(error, path, value);
110
+ if (described !== undefined) {
111
+ if (described !== null) problems.at(yaml, described.path ?? path, described.message, described.lang);
112
+ continue;
113
+ }
114
+ switch (error.keyword) {
115
+ case "if":
116
+ break;
117
+ case "anyOf": {
118
+ if (typeof value === "string") problems.at(yaml, path, "must not be empty");
119
+ else if (isObject(value)) {
120
+ for (const [lang, text] of Object.entries(value)) {
121
+ if (typeof text !== "string") problems.at(yaml, path, "must be a text", lang);
122
+ else if (text.trim() === "") problems.at(yaml, path, "must not be empty", lang);
123
+ else if (!/^[a-z]{2}$/.test(lang)) {
124
+ problems.at(yaml, path, `${lang} is not a language code; use two letters, e.g. de, en`);
125
+ }
126
+ }
127
+ } else problems.at(yaml, path, "must be a text, or a map of texts by language: { de: …, en: … }");
128
+ break;
129
+ }
130
+ case "required":
131
+ case "dependentRequired":
132
+ problems.at(yaml, path, `${String(error.params["missingProperty"])} is missing`);
133
+ break;
134
+ case "additionalProperties":
135
+ problems.at(yaml, path, `${String(error.params["additionalProperty"])} is not allowed here`);
136
+ break;
137
+ case "unevaluatedProperties":
138
+ problems.at(yaml, path, `${String(error.params["unevaluatedProperty"])} is not allowed here`);
139
+ break;
140
+ case "enum":
141
+ problems.at(yaml, path, `must be one of ${(error.params["allowedValues"] as unknown[]).join(", ")}`);
142
+ break;
143
+ case "pattern":
144
+ if (underText(error)) break;
145
+ if (error.schemaPath.includes("/id/") || error.schemaPath.startsWith("#/$defs/id")) {
146
+ problems.at(yaml, path, `use lower-case letters, digits and hyphens, e.g. "prozentwert"`);
147
+ } else if (String(error.params["pattern"]) === "\\S") problems.at(yaml, path, "must not be empty");
148
+ else problems.at(yaml, path, `must match ${String(error.params["pattern"])}`);
149
+ break;
150
+ case "type":
151
+ case "minLength":
152
+ if (!underText(error)) problems.at(yaml, path, error.message ?? "is not valid");
153
+ break;
154
+ case "minItems":
155
+ problems.at(yaml, path, "must not be empty");
156
+ break;
157
+ default:
158
+ if (!underText(error)) problems.at(yaml, path, error.message ?? "is not valid");
159
+ }
160
+ }
161
+ }
162
+
163
+ /** Where a link inside a text was written, to check its target once every page is known. */
164
+ export interface TextLink {
165
+ yaml: YamlFile;
166
+ path: Path;
167
+ lang: string;
168
+ offset: number;
169
+ href: string;
170
+ }
171
+
172
+ /** The checks of the texts: languages, raw HTML, Markdown, formulas. */
173
+ export class Texts {
174
+ readonly links: TextLink[] = [];
175
+ readonly languages: readonly string[];
176
+ private readonly problems: Problems;
177
+ constructor(languages: readonly string[], problems: Problems) {
178
+ this.languages = languages;
179
+ this.problems = problems;
180
+ }
181
+
182
+ /**
183
+ * The text at `path` of `yaml`: a plain text, restricted Markdown, a formula, a name (a person, a work), or the source
184
+ * of a credit (a URL or a citation); a formula, a name or a URL given as a string is the same in every language, a
185
+ * citation is a plain text.
186
+ */
187
+ check(yaml: YamlFile, path: Path, kind: TextKind): void {
188
+ const value = get(yaml.value, path);
189
+ if (value === undefined) return;
190
+ const { findings, links } = checkText(value as Text, kind, this.languages);
191
+ for (const finding of findings) {
192
+ this.problems.at(yaml, [...path, ...finding.path], finding.message, finding.lang, finding.line);
193
+ }
194
+ for (const link of links)
195
+ this.links.push({ yaml, path: [...path, ...link.path], lang: link.lang, offset: 0, href: link.href });
196
+ }
197
+ }
@@ -0,0 +1,20 @@
1
+ // The examples of an exercise (`examples: { accepted, rejected }`): test cases for the build, never shown to learners.
2
+ // The build runs each through the same check the page uses: the quiz's checker for its question types
3
+ // (../quiz/validate.ts), the comparator of a generated exercise (../explainer/generated.ts), the exported check of an
4
+ // exercise component (../explainer/components.ts). An accepted example the check rejects, or a rejected one it
5
+ // accepts, fails the build with this message.
6
+
7
+ /**
8
+ * What to do about an example the check judges otherwise than it is listed: `right`, whether the check accepts it;
9
+ * `check`, what judges it; `fix`, what to change so that the check agrees.
10
+ */
11
+ export function verdict(
12
+ example: string,
13
+ right: boolean,
14
+ check = "the check",
15
+ fix = "change the exercise so that its check",
16
+ ): string {
17
+ return right
18
+ ? `${check} accepts ${example}, but it is listed as rejected; if the task's text rules it out, ${fix} rejects it, else move it to accepted`
19
+ : `${check} rejects ${example}, but it is listed as accepted; if the task's text allows it, ${fix} accepts it, else move it to rejected`;
20
+ }
@@ -0,0 +1,10 @@
1
+ // Escaping a learner text for HTML, once for every archetype: `escape` for text and attribute values, `escapeText`
2
+ // for text only (inside <code>, where a quotation mark is no markup).
3
+
4
+ /** `text` for HTML text or a double-quoted attribute value. */
5
+ export const escape = (text: string): string =>
6
+ text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
7
+
8
+ /** `text` for HTML text, quotation marks as they are. */
9
+ export const escapeText = (text: string): string =>
10
+ text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");