@lernapps/app-templates 0.2.0 → 0.3.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 (58) hide show
  1. package/README.md +5 -11
  2. package/dist/{assessment-Dy359O1O.mjs → assessment-DaKsXUdj.mjs} +204 -16
  3. package/dist/{blocks-CxtCPAa5.mjs → blocks-D4SSGKuL.mjs} +2 -2
  4. package/dist/blocks.mjs +2 -2
  5. package/dist/{check-CteIFN96.mjs → check-BxZOZPic.mjs} +58 -4
  6. package/dist/{content-DO6oEJxj.mjs → content-CAL0Nz1a.mjs} +1 -1
  7. package/dist/explainer-e2e.mjs +5 -5
  8. package/dist/explainer-plugin.mjs +57 -13
  9. package/dist/explainer.mjs +2 -2
  10. package/dist/interactive-e2e.mjs +1 -1
  11. package/dist/interactive-plugin.mjs +2 -2
  12. package/dist/{load-RosvaPZf.mjs → load--ovXqbNE.mjs} +286 -33
  13. package/dist/questions.mjs +1 -1
  14. package/dist/quiz-e2e.mjs +1 -1
  15. package/dist/quiz-plugin.mjs +3 -3
  16. package/dist/{render-D5kwkNt1.mjs → render-D3LsJY04.mjs} +2 -2
  17. package/dist/{render-DqHOJyHK.mjs → render-mYtzN1DV.mjs} +1 -1
  18. package/dist/{result-D_mmCVwN.mjs → result-DphIWHDF.mjs} +1 -1
  19. package/dist/{validate-Bxy9NfB8.mjs → validate-CfFykN0M.mjs} +1 -1
  20. package/dist/{validate-BDwL7Su3.mjs → validate-j3GjVhNF.mjs} +2 -2
  21. package/explainer/README.md +7 -3
  22. package/explainer/docs/arc42/05-explainer-building-blocks.arc42.md +26 -8
  23. package/explainer/docs/arc42/09-explainer-decisions.arc42.md +42 -0
  24. package/explainer/package.json +1 -1
  25. package/explainer/src/app.yaml +3 -1
  26. package/explainer/src/prozent/chapter.yaml +1 -1
  27. package/explainer/src/prozent/grundwert.yaml +2 -1
  28. package/explainer/src/prozent/prozentsatz.yaml +2 -1
  29. package/explainer/src/prozent/prozentwert.yaml +2 -1
  30. package/interactive/package.json +1 -1
  31. package/package.json +3 -4
  32. package/quiz/package.json +1 -1
  33. package/runtime/explainer/assessment.ts +4 -2
  34. package/runtime/explainer/check-page.ts +6 -2
  35. package/runtime/explainer/content.ts +20 -1
  36. package/runtime/explainer/exercises.ts +71 -6
  37. package/runtime/explainer/explainer.css +21 -0
  38. package/runtime/explainer/explainer.v1.schema.json +2 -2
  39. package/runtime/explainer/explainer.v2.schema.json +152 -0
  40. package/runtime/explainer/expression.ts +127 -1
  41. package/runtime/explainer/generated/texts.ts +98 -9
  42. package/runtime/explainer/generated/types.ts +8 -0
  43. package/runtime/explainer/load.ts +70 -16
  44. package/runtime/explainer/messages/de.json +5 -1
  45. package/runtime/explainer/messages/en.json +5 -1
  46. package/runtime/explainer/outline.ts +1 -1
  47. package/runtime/explainer/page-list.ts +38 -0
  48. package/runtime/explainer/plugin.ts +13 -4
  49. package/runtime/explainer/render.ts +26 -2
  50. package/runtime/explainer/schema.ts +92 -9
  51. package/runtime/explainer/tutorpage.ts +9 -1
  52. package/runtime/shared/blocks/blocks.v1.schema.json +1 -1
  53. package/runtime/shared/text/formula.ts +61 -1
  54. package/scaffold/explainer/src/app.yaml +5 -1
  55. package/scaffold/explainer/src/thema/chapter.yaml +1 -1
  56. package/scaffold/explainer/src/thema/einstieg.yaml +1 -1
  57. package/scaffold/interactive/src/core/index.ts +4 -2
  58. package/scaffold/interactive/src/data/count.json +1 -0
@@ -124,7 +124,8 @@ implements: concept-archetype-extension
124
124
 
125
125
  Built. The plugin `explainer()` reads and validates the content, runs the component rules and the property test of
126
126
  each generated exercise and exercise component over fixed seeds, and only then writes every page in each language,
127
- the tutor pages where the tutor is on, and the 404 page; `vp dev` serves the same pages.
127
+ the tutor pages where the tutor is on, and the 404 page; for an app of format 2 also the list of its pages for the
128
+ catalog (`if-page-list`); `vp dev` serves the same pages.
128
129
 
129
130
  ```arc42
130
131
  :::building-block
@@ -137,6 +138,22 @@ requires: if-archetype-content, if-component-rules, if-exercise-logic, if-tutor-
137
138
  :::
138
139
  ```
139
140
 
141
+ #### Page list for the catalog
142
+
143
+ The build provides the catalog with the app's place on the learning map: its strand and its pages with their
144
+ competencies. The catalog consumes it to place the app and checks the ids, which the app cannot know. Only format 2
145
+ apps have one.
146
+
147
+ ```arc42
148
+ :::interface
149
+ id: if-page-list
150
+ title: Page list lernapps-pages.json (format 2)
151
+ provider: bb-build
152
+ protocol: JSON file over HTTPS, beside index.html
153
+ path: runtime/explainer/page-list.ts
154
+ :::
155
+ ```
156
+
140
157
  #### Build plugin interface
141
158
 
142
159
  The app's vite.config.ts adds `explainer()` to the tooling preset.
@@ -153,10 +170,10 @@ path: runtime/explainer/plugin.ts
153
170
 
154
171
  ### Content validation
155
172
 
156
- Built. Validates every content file against its part of the schema, then what a schema cannot say (complete order
157
- lists, depth, unique ids, complete languages, restricted Markdown, formulas in the command list, sources and
158
- licences, links to pages and sections, an exercise on every page, components and their props), one line per problem
159
- with file and line.
173
+ Built. Validates every content file against its part of the schema of its format, then what a schema cannot say
174
+ (complete order lists, depth, unique ids, complete languages, restricted Markdown, formulas in the command list,
175
+ sources and licences, links to pages and sections, the exercises a page needs, components and their props, and in
176
+ format 2 the competencies of the pages), one line per problem with file and line.
160
177
 
161
178
  ```arc42
162
179
  :::building-block
@@ -567,15 +584,16 @@ path: explainer/src/app.yaml
567
584
  ### Content interface
568
585
 
569
586
  The explainer's content: build, validation, rendering, generated exercises, tutor pages and tests read it only through
570
- its schema.
587
+ its schema: format 2 for the template and new apps, which adds the app's place on the learning map to format 1; format
588
+ 1 stays valid (`dec-explainer-format-2`).
571
589
 
572
590
  ```arc42
573
591
  :::interface
574
592
  id: if-archetype-content
575
- title: Content YAML (explainer.v1 schema)
593
+ title: Content YAML (explainer.v1 and v2 schemas)
576
594
  provider: bb-explainer-content
577
595
  protocol: YAML, JSON Schema
578
- path: runtime/explainer/explainer.v1.schema.json
596
+ path: runtime/explainer/explainer.v2.schema.json
579
597
  :::
580
598
  ```
581
599
 
@@ -28,6 +28,11 @@ only adds. `lernapps check` opens every explainer page a second time with JavaSc
28
28
  inside a component or a live exercise. The one exception is the optional self-assessment (`dec-self-assessment`),
29
29
  which needs a run in the browser and is no content of its own.
30
30
 
31
+ Formulas, too, are set at build; the browser has no formula renderer. Trade-off for a generated exercise: the build
32
+ sets a chemical formula of a drawn value (`\ce{{{r.gl}}}`) once for every value of its list. That makes the page
33
+ larger and allows only values from a list, and only as the whole formula. Rejected: Temml in the client, which would
34
+ make every page with an exercise larger.
35
+
31
36
  ```arc42
32
37
  :::decision
33
38
  id: dec-explainer-complete-without-js
@@ -441,3 +446,40 @@ date: 2026-10-28
441
446
  addresses: qg-understandable, qg-privacy, con-no-request-before-click
442
447
  :::
443
448
  ```
449
+
450
+ ## Format 2 Places the App on the Learning Map
451
+
452
+ The catalog's learning map shows which apps practise which competencies. The app owns where it belongs: it names one
453
+ strand (or `none`, so that none forgets), and each page the competencies it practises. The build hands this to the
454
+ catalog (`if-page-list`); the catalog owns the subjects' ids and checks them. The fields are required, so this is a
455
+ new format; format 1 stays valid for apps that do not move, and they are not on the map. Rejected: strand and
456
+ competencies in the catalog entry (the pages would be listed twice); optional fields in format 1 (an app could forget
457
+ them).
458
+
459
+ ```arc42
460
+ :::decision
461
+ id: dec-explainer-format-2
462
+ title: Explainer format 2 names the app's strand and each page's competencies, and the build lists the pages
463
+ status: accepted
464
+ date: 2026-11-03
465
+ addresses: qg-checkable, con-runtime-unchanged
466
+ :::
467
+ ```
468
+
469
+ ## A Recap Page Repeats a Competency the App Does Not Practise
470
+
471
+ A competency may require one the app does not practise; its learners may need it again. A recap page repeats it with
472
+ descriptive blocks only: practising stays the job of the apps the map lists for it. The runtime, not the author, links
473
+ the competency on the map, so the link cannot be missing; the catalog checks that the competency exists. Rejected:
474
+ exercises on a recap page (the app would practise a competency the map does not show for it); a link the author writes
475
+ (it could be missing or wrong).
476
+
477
+ ```arc42
478
+ :::decision
479
+ id: dec-explainer-recap-page
480
+ title: A recap page repeats a competency of another strand without exercises and links it on the learning map
481
+ status: accepted
482
+ date: 2026-11-03
483
+ addresses: qg-understandable, qg-checkable, con-no-request-before-click
484
+ :::
485
+ ```
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@lernapps/app-templates": "file:..",
12
- "@lernapps/tooling": "^0.1.2",
12
+ "@lernapps/tooling": "0.3.1",
13
13
  "vite-plus": "1.4.0"
14
14
  },
15
15
  "overrides": {
@@ -1,4 +1,6 @@
1
- # yaml-language-server: $schema=../node_modules/@lernapps/app-templates/runtime/explainer/explainer.v1.schema.json
1
+ # yaml-language-server: $schema=../node_modules/@lernapps/app-templates/runtime/explainer/explainer.v2.schema.json
2
+ format: 2
3
+ strand: mathematik/arithmetik
2
4
  title: Prozentrechnung
3
5
  intro: |
4
6
  Prozentwert, Prozentsatz und Grundwert: Was sie bedeuten, wie du sie ausrechnest, und je ein Beispiel dazu.
@@ -1,4 +1,4 @@
1
- # yaml-language-server: $schema=../../node_modules/@lernapps/app-templates/runtime/explainer/explainer.v1.schema.json
1
+ # yaml-language-server: $schema=../../node_modules/@lernapps/app-templates/runtime/explainer/explainer.v2.schema.json
2
2
  title: Prozent
3
3
  summary: Mit Prozenten vergleichst du Anteile, egal wie groß das Ganze ist.
4
4
  pages: [prozentwert, prozentsatz, grundwert]
@@ -1,6 +1,7 @@
1
- # yaml-language-server: $schema=../../node_modules/@lernapps/app-templates/runtime/explainer/explainer.v1.schema.json
1
+ # yaml-language-server: $schema=../../node_modules/@lernapps/app-templates/runtime/explainer/explainer.v2.schema.json
2
2
  title: Grundwert
3
3
  description: "Den Grundwert berechnen: G = W · 100 : p, mit einem Beispiel und dem Hunderterfeld."
4
+ competencies: [prozent-grundwert]
4
5
  scope: |
5
6
  Gesucht ist das Ganze, wenn nur ein Teil und sein Prozentsatz bekannt sind, etwa der alte Preis, wenn 30 € gespart
6
7
  sind und das 12 % waren. Man rechnet $G = \frac{W \cdot 100}{p}$: $W$ geteilt durch $p$ ist 1 %, mal 100 das Ganze.
@@ -1,6 +1,7 @@
1
- # yaml-language-server: $schema=../../node_modules/@lernapps/app-templates/runtime/explainer/explainer.v1.schema.json
1
+ # yaml-language-server: $schema=../../node_modules/@lernapps/app-templates/runtime/explainer/explainer.v2.schema.json
2
2
  title: Prozentsatz
3
3
  description: "Den Prozentsatz berechnen: p = W : G · 100, mit einem Beispiel und dem Prozentstreifen."
4
+ competencies: [prozent-prozentsatz]
4
5
  scope: |
5
6
  Gesucht ist, welcher Anteil der Teil vom Ganzen ist, etwa „18 von 24 Schülern“. Man rechnet
6
7
  $p = \frac{W}{G} \cdot 100$: Teil durch Ganzes gibt den Anteil, mal 100 die Prozent. Erst der Prozentsatz macht
@@ -1,6 +1,7 @@
1
- # yaml-language-server: $schema=../../node_modules/@lernapps/app-templates/runtime/explainer/explainer.v1.schema.json
1
+ # yaml-language-server: $schema=../../node_modules/@lernapps/app-templates/runtime/explainer/explainer.v2.schema.json
2
2
  title: Prozentwert
3
3
  description: "Den Prozentwert berechnen: W = G · p : 100, mit einem Beispiel und dem Hunderterfeld."
4
+ competencies: [prozent-prozentwert]
4
5
  scope: |
5
6
  Gesucht ist der Teil des Ganzen, etwa wie viel Euro man bei 20 % Rabatt auf 80 € spart. Man rechnet
6
7
  $W = \frac{G \cdot p}{100}$: erst 1 % des Grundwerts ($G$ geteilt durch 100), dann mal $p$. Der Prozentwert hat die
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@lernapps/app-templates": "file:..",
12
- "@lernapps/tooling": "^0.1.2",
12
+ "@lernapps/tooling": "0.3.1",
13
13
  "vite-plus": "1.4.0"
14
14
  },
15
15
  "overrides": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lernapps/app-templates",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "The templates of the archetypes of lernapps.net apps, and the runtime of each archetype that apps depend on (the quiz: engine, build step, styles, end-to-end tests, the schema of the question bank, the questions it shares with the explainer are in ./questions; the explainer: build step, client module with its exercises, the islands of its Vue components, styles, end-to-end tests, the schema of its content; the interactive archetype: build step, the start page and the app page around the app's own Vue components and core, its playthroughs, styles, end-to-end tests, the schemas of its start page and its playthroughs)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -68,6 +68,7 @@
68
68
  },
69
69
  "./explainer/style.css": "./runtime/explainer/explainer.css",
70
70
  "./explainer/explainer.v1.schema.json": "./runtime/explainer/explainer.v1.schema.json",
71
+ "./explainer/explainer.v2.schema.json": "./runtime/explainer/explainer.v2.schema.json",
71
72
  "./interactive": {
72
73
  "types": "./runtime/interactive/interactive.ts",
73
74
  "default": "./dist/interactive.mjs"
@@ -91,9 +92,7 @@
91
92
  "scripts": {
92
93
  "prepare": "vp pack && vp config --no-agent",
93
94
  "test": "vp test",
94
- "check:vue": "vue-tsc --noEmit -p tsconfig.vue.json",
95
- "prepack": "node scripts/pack-dotfiles.ts stage",
96
- "postpack": "node scripts/pack-dotfiles.ts clean"
95
+ "check:vue": "vue-tsc --noEmit -p tsconfig.vue.json"
97
96
  },
98
97
  "dependencies": {
99
98
  "@vitejs/plugin-vue": "6.0.10",
package/quiz/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@lernapps/app-templates": "file:..",
12
- "@lernapps/tooling": "^0.1.2",
12
+ "@lernapps/tooling": "0.3.1",
13
13
  "vite-plus": "1.4.0"
14
14
  },
15
15
  "overrides": {
@@ -121,13 +121,15 @@ export function assessments(site: Site): Assessment[] {
121
121
  // the app's: the pages of every chapter's, in the order of the chapters
122
122
  const steps = site.chapters.flatMap((chapter) => chapter.selfAssessment?.pages ?? []);
123
123
  if (steps.length === 0) return [];
124
- const all = site.chapters.flatMap((chapter) => chapter.pages);
124
+ // a recap page has no exercise: the self-assessment leaves it out
125
+ const asked = (pages: readonly Page[]) => pages.filter((page) => page.recap === undefined);
126
+ const all = asked(site.chapters.flatMap((chapter) => chapter.pages));
125
127
  // a chapter's of its own only where the app shows its chapters: with one chapter, the app's is the chapter's
126
128
  const own = site.chapters.length === 1 ? [] : site.chapters;
127
129
  return [
128
130
  of({ pages: steps }, all),
129
131
  ...own.flatMap((chapter) =>
130
- chapter.selfAssessment === undefined ? [] : [of(chapter.selfAssessment, chapter.pages, chapter)],
132
+ chapter.selfAssessment === undefined ? [] : [of(chapter.selfAssessment, asked(chapter.pages), chapter)],
131
133
  ),
132
134
  ];
133
135
  }
@@ -20,7 +20,7 @@ import {
20
20
  type Text,
21
21
  } from "./content.ts";
22
22
  import { ExpressionError, parse, variables } from "./expression.ts";
23
- import { dataOf, templatesOf } from "./exercises.ts";
23
+ import { ChemError, dataOf, templatesOf } from "./exercises.ts";
24
24
  import { exampleProblems, soundness } from "./generated/soundness.ts";
25
25
  import { templateProblems, unusedVariables } from "./generated/texts.ts";
26
26
  import { type Generated, type Templates } from "./generated/types.ts";
@@ -501,8 +501,12 @@ function checkGenerated(check: PageCheck, block: Generated, at: Path): void {
501
501
  for (const [name, file] of check.dataFiles) records[name] = file.value as DataRecord[];
502
502
  let templates: Templates;
503
503
  try {
504
- templates = templatesOf(block, texts.languages);
504
+ templates = templatesOf(block, texts.languages, dataOf(block, records));
505
505
  } catch (error) {
506
+ if (error instanceof ChemError) {
507
+ problems.at(yaml, at, error.message);
508
+ return;
509
+ }
506
510
  const reason = (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
507
511
  problems.at(
508
512
  yaml,
@@ -1,4 +1,5 @@
1
- // The content of an explainer, as the YAML files in src/ give it (explainer.v1.schema.json) and the build reads it
1
+ // The content of an explainer, as the YAML files in src/ give it (explainer.v1.schema.json, or in format 2
2
+ // explainer.v2.schema.json) and the build reads it
2
3
  // (load.ts): the app, its chapters in order, their pages in order, the data files, the bundled images and the
3
4
  // components (src/components/, components.ts) with the blocks that use them.
4
5
 
@@ -43,6 +44,10 @@ export interface Source {
43
44
  }
44
45
 
45
46
  export interface App {
47
+ /** The format of the content: 2, or none for format 1. */
48
+ format?: 2;
49
+ /** Format 2: the strand of the learning map the app stays in, <subject>/<strand>, or none. */
50
+ strand?: string;
46
51
  title: Text;
47
52
  intro: Text;
48
53
  languages: readonly string[];
@@ -102,6 +107,14 @@ export interface Page {
102
107
  file: string;
103
108
  title: Text;
104
109
  description?: Text;
110
+ /** Format 2: the ids of the competencies of the learning map that the page practises. */
111
+ competencies?: readonly string[];
112
+ /**
113
+ * Format 2: makes the page a recap page, which repeats this competency (one the app does not practise), <subject>/<competency>,
114
+ * instead of practising competencies of its own: no exercise, a link to the competency on the learning map, left
115
+ * out of the self-assessment.
116
+ */
117
+ recap?: string;
105
118
  /** What the page teaches, for the tutor's scope (scope.ts). */
106
119
  scope?: Text;
107
120
  blocks: readonly Block[];
@@ -253,6 +266,12 @@ export function* allBlocks(blocks: readonly Block[]): Generator<Block> {
253
266
  }
254
267
  }
255
268
 
269
+ /** The page of a competency on the learning map of lernapps.net, from <subject>/<competency> (a recap page's recap). */
270
+ export function mapUrl(recap: string): string {
271
+ const [subject = "", competency = ""] = recap.split("/");
272
+ return `https://lernapps.net/apps/map/${subject}/c/${competency}/`;
273
+ }
274
+
256
275
  /** Whether a block is an exercise. */
257
276
  export const isExercise = (block: Block): block is Exercise =>
258
277
  block.type === "generated" ||
@@ -10,8 +10,10 @@ import { renderMaterial, type MaterialContext } from "../shared/blocks/index.ts"
10
10
  import { answerText, questionMessagesIn, renderQuestion, renderSolution } from "../shared/questions/render.ts";
11
11
  import { inLanguage, type Text } from "../shared/languages.ts";
12
12
  import type { DataRecord, Exercise, OpenTask, QuestionBlock } from "./content.ts";
13
- import { hintOf, stepsOf, taskText, tokenize } from "./generated/texts.ts";
14
- import { generate } from "./generated/values.ts";
13
+ import { chemPlaceholdersIn, hintOf, label, stepsOf, taskText, tokenize, writtenTemplates } from "./generated/texts.ts";
14
+ import { generate, items } from "./generated/values.ts";
15
+ import { evaluate, parse, type Value } from "./expression.ts";
16
+ import { renderFormula } from "../shared/text/formula.ts";
15
17
  import { questionFor, taskKey } from "./generated/question.ts";
16
18
  import { WORKED_TASKS } from "./tasks.ts";
17
19
  import { type Data, type Generated, type LanguageTemplates, type Templates } from "./generated/types.ts";
@@ -57,20 +59,83 @@ export interface OpenTaskData {
57
59
  hint?: string;
58
60
  }
59
61
 
60
- /** The templates of a generated exercise in each of `languages`; links inside them through `resolve`. */
62
+ /** A value of a placeholder in \ce{{{…}}} whose formula does not parse; the check reports its message as it is. */
63
+ export class ChemError extends Error {}
64
+
65
+ /**
66
+ * The chemical formula of each value of the placeholders in \ce{{{…}}} of an exercise's templates in `lang`, as
67
+ * MathML without <math>, by the value as the learner reads it: each value of the list the placeholder's root is
68
+ * drawn from (oneOf, or pick: 1 from a list or a data file), set by mhchem as \ce{value}. Throws a ChemError for a
69
+ * value that does not parse.
70
+ */
71
+ export function chemFormulas(spec: Generated, data: Data, lang: string): Record<string, string> {
72
+ const formulas: Record<string, string> = {};
73
+ const paths = new Set(
74
+ writtenTemplates(spec)
75
+ .filter((written) => written.lang === undefined || written.lang === lang)
76
+ .flatMap((written) => chemPlaceholdersIn(written.text)),
77
+ );
78
+ for (const path of paths) {
79
+ const root = path.split(".")[0] ?? "";
80
+ const arbitrary = spec.given[root];
81
+ if (arbitrary === undefined) continue; // reported by templateProblems
82
+ const source = arbitrary.oneOf ?? (arbitrary.pick === 1 ? arbitrary.from : undefined);
83
+ if (source === undefined) continue; // reported by templateProblems
84
+ let candidates: Value[];
85
+ try {
86
+ candidates = items(source, data);
87
+ } catch {
88
+ continue; // a missing data file is reported on its own
89
+ }
90
+ for (const candidate of candidates) {
91
+ let text: string;
92
+ try {
93
+ text = label(evaluate(parse(path), { [root]: candidate }), lang);
94
+ } catch {
95
+ continue; // a record without the field, reported when a task draws it
96
+ }
97
+ if (text in formulas) continue;
98
+ try {
99
+ // a value with unbalanced braces would end \ce early and leave the rest outside it
100
+ let depth = 0;
101
+ for (const char of text) {
102
+ if (char === "{") depth++;
103
+ else if (char === "}" && --depth < 0) break;
104
+ }
105
+ if (depth !== 0) throw new Error("its braces are not balanced");
106
+ const math = renderFormula(`\\ce{${text}}`, false);
107
+ formulas[text] = /^<math[^>]*>([\s\S]*)<\/math>$/.exec(math)?.[1] ?? math;
108
+ } catch (error) {
109
+ const reason = (error instanceof Error ? error.message : String(error)).split("\n")[0]?.trim() ?? "";
110
+ throw new ChemError(
111
+ `\\ce{{{${path}}}}: the value "${text}" is no chemical formula mhchem can set (${reason}); write it in mhchem's notation, e.g. 2H2 + O2 -> 2H2O`,
112
+ );
113
+ }
114
+ }
115
+ }
116
+ return formulas;
117
+ }
118
+
119
+ /**
120
+ * The templates of a generated exercise in each of `languages`, with the formulas of the values in \ce{{{…}}} drawn
121
+ * from `data`; links inside them through `resolve`.
122
+ */
61
123
  export function templatesOf(
62
124
  spec: Generated,
63
125
  languages: readonly string[],
126
+ data: Data,
64
127
  resolve: (href: string) => string = (href) => href,
65
128
  ): Templates {
66
129
  const templates: Record<string, LanguageTemplates> = {};
67
130
  for (const lang of languages) {
68
131
  const inline = (text: Text) => renderInline(tokenize(inLanguage(text, lang)), resolve);
132
+ const chem = chemFormulas(spec, data, lang);
69
133
  templates[lang] = {
70
134
  task: inline(spec.task.text),
71
135
  steps: (spec.steps ?? []).map(inline),
72
136
  hint: spec.hint === undefined ? undefined : inline(spec.hint),
73
137
  messages: (spec.mistakes ?? []).map((mistake) => tokenize(inLanguage(mistake.message, lang))),
138
+ ...(Object.keys(chem).length === 0 ? {} : { chem }),
74
139
  };
75
140
  }
76
141
  return templates;
@@ -93,7 +158,7 @@ const seedCache = new WeakMap<Generated, number[]>();
93
158
  export function taskSeeds(spec: Generated, data: Data): number[] {
94
159
  let seeds = seedCache.get(spec);
95
160
  if (seeds === undefined) {
96
- const templates = templatesOf(spec, ["de"]);
161
+ const templates = templatesOf(spec, ["de"], data);
97
162
  seeds = distinctSeeds((seed) => {
98
163
  try {
99
164
  return taskKey(spec, generate(spec, seed, data), "de", templates);
@@ -192,7 +257,7 @@ function generatedData(
192
257
  kind: "generated",
193
258
  lang,
194
259
  spec: shipped,
195
- templates: templatesOf(spec, [lang], resolve),
260
+ templates: templatesOf(spec, [lang], data, resolve),
196
261
  data,
197
262
  seeds: taskSeeds(spec, data),
198
263
  texts: {
@@ -277,8 +342,8 @@ function generatedExercise(
277
342
  all: Readonly<Record<string, readonly DataRecord[]>>,
278
343
  resolve: (href: string) => string,
279
344
  ): string {
280
- const templates = templatesOf(spec, [lang], resolve);
281
345
  const data = dataOf(spec, all);
346
+ const templates = templatesOf(spec, [lang], data, resolve);
282
347
  const solution = questionMessagesIn(lang).solution;
283
348
  const worked = workedTasks(spec, data, templates, lang).map((item) =>
284
349
  [
@@ -162,6 +162,27 @@
162
162
  font-size: 0.9375rem;
163
163
  }
164
164
 
165
+ /* a recap page: what it is, and where the competency is practised */
166
+ .explainer-recap {
167
+ margin: 1rem 0 1.5rem;
168
+ padding: 0.75rem 1rem;
169
+ border-left: 4px solid var(--page-border);
170
+ background: var(--page-surface);
171
+ }
172
+
173
+ .explainer-recap p {
174
+ margin: 0.25rem 0;
175
+ }
176
+
177
+ .explainer-recap-label {
178
+ font-weight: 600;
179
+ }
180
+
181
+ .explainer-recap-note {
182
+ color: var(--page-muted);
183
+ font-size: 0.9375rem;
184
+ }
185
+
165
186
  .explainer-glossary dt {
166
187
  margin-top: 1rem;
167
188
  font-weight: 600;
@@ -748,7 +748,7 @@
748
748
  "type": "object"
749
749
  },
750
750
  "expression": {
751
- "description": "An expression of the expression language, e.g. \"G * p / 100\": numbers (exact fractions), + - * /, parentheses, == != < <= > >=, and, or, not, strings in quotes, record fields (e.year), list index (xs[0]), lists ([1, 2]), and the functions round(x, n), decimals, map(xs, field), sortBy(xs, field), reverse. It may use the variables of given.",
751
+ "description": "An expression of the expression language, e.g. \"G * p / 100\": numbers (exact fractions), + - * /, parentheses, == != < <= > >=, and, or, not, strings in quotes, record fields (e.year), list index (xs[0]), lists ([1, 2]), and the functions round(x, n), decimals, map(xs, field), sortBy(xs, field), reverse, sqrt(x), pow(x, n), sin(x), cos(x), tan(x), asin(x), acos(x), atan(x). Angles are in degrees: sin(30) is 1/2. Round a result that is not rational (sqrt(2)) with round(x, n). It may use the variables of given.",
752
752
  "type": "string",
753
753
  "minLength": 1,
754
754
  "pattern": "\\S"
@@ -881,7 +881,7 @@
881
881
  }
882
882
  },
883
883
  "generated": {
884
- "description": "An exercise drawn from a seed, property-based: given draws the values, where filters them, task is the text with placeholders ({{G}}, {{e.name}}; {{answer}} in the steps), answer is an expression with its comparator, mistakes are typical wrong answers each with a message saying what went wrong, hint and steps help. A placeholder is always two braces around a declared name or its field, nothing else inside, in prose and in formulas alike ($\\frac{{{W}}}{{{G}}}$); an index is no field, so {{opts[0].x}} is no placeholder and the build reports it: draw the record on its own (given: { e: { pick: 1, from: { data: name } } }) and write {{e.x}}; one brace is never a placeholder, so in a formula G and {G} are the letter. The build checks that every placeholder names a variable of given (or answer), that no text has double braces around anything but a name or its field, that no prose has a placeholder with one brace or spaces inside, and that every variable of given is used in a placeholder or in an expression of the answer or mistakes; then it checks it over 200 fixed seeds: where holds in at least 1 of 10 draws; the answer is defined, in its format and passes its comparator; no mistake equals it; the task is the same each time and keeps no placeholder. Its tasks are numbered: the build walks the seeds 1, 2, 3, … and numbers only the distinct tasks, Aufgabe Nr. 1 to N (N at most 67; fewer when the exercise has fewer distinct tasks, noted in the build output). The number is what the page shows, what a learner names to a tutor, and what the address carries: ?seed=<n>#<id> opens task number n (a number above N wraps around). Without JavaScript the page shows the tasks 1, 2 and 3 as worked tasks with their solution; with JavaScript a live task of a random number, Neue Aufgabe, which moves to the next number (after N back to 1) and writes it into the address, and the deep link: a wrong answer gets the message of the mistake it matches (else a line to look again, with the hint above) and may be tried again; the solution and the steps show after the right answer or Lösung zeigen.",
884
+ "description": "An exercise drawn from a seed, property-based: given draws the values, where filters them, task is the text with placeholders ({{G}}, {{e.name}}; {{answer}} in the steps), answer is an expression with its comparator, mistakes are typical wrong answers each with a message saying what went wrong, hint and steps help. A placeholder is always two braces around a declared name or its field, nothing else inside, in prose and in formulas alike ($\\frac{{{W}}}{{{G}}}$); an index is no field, so {{opts[0].x}} is no placeholder and the build reports it: draw the record on its own (given: { e: { pick: 1, from: { data: name } } }) and write {{e.x}}; one brace is never a placeholder, so in a formula G and {G} are the letter. A chemical formula from a record is $\\ce{{{r.gl}}}$: the whole formula in one field (gl: \"2H2 + O2 -> 2H2O\"), r drawn with pick: 1 or oneOf; a placeholder inside a longer \\ce or in \\pu is not possible. The build checks that every placeholder names a variable of given (or answer), that no text has double braces around anything but a name or its field, that no prose has a placeholder with one brace or spaces inside, and that every variable of given is used in a placeholder or in an expression of the answer or mistakes; then it checks it over 200 fixed seeds: where holds in at least 1 of 10 draws; the answer is defined, in its format and passes its comparator; no mistake equals it; the task is the same each time and keeps no placeholder. Its tasks are numbered: the build walks the seeds 1, 2, 3, … and numbers only the distinct tasks, Aufgabe Nr. 1 to N (N at most 67; fewer when the exercise has fewer distinct tasks, noted in the build output). The number is what the page shows, what a learner names to a tutor, and what the address carries: ?seed=<n>#<id> opens task number n (a number above N wraps around). Without JavaScript the page shows the tasks 1, 2 and 3 as worked tasks with their solution; with JavaScript a live task of a random number, Neue Aufgabe, which moves to the next number (after N back to 1) and writes it into the address, and the deep link: a wrong answer gets the message of the mistake it matches (else a line to look again, with the hint above) and may be tried again; the solution and the steps show after the right answer or Lösung zeigen.",
885
885
  "required": ["id", "given", "task", "answer"],
886
886
  "properties": {
887
887
  "id": {
@@ -0,0 +1,152 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:lernapps:schema:explainer.v2",
4
+ "title": "Content of a lernapps explainer, format 2",
5
+ "description": "Format 2 of an explainer's content, as YAML in src/: src/app.yaml, src/<chapter>/chapter.yaml, src/<chapter>/<page>.yaml and src/data/<name>.yaml. It is format 1 (explainer.v1.schema.json) plus the app's place on the learning map: format: 2 and strand in src/app.yaml, and on each page the competencies it practises, or recap on a page that repeats a competency from elsewhere. An app.yaml without format is format 1.",
6
+ "allOf": [
7
+ {
8
+ "if": {
9
+ "type": "object",
10
+ "required": ["chapters"],
11
+ "properties": {
12
+ "chapters": {}
13
+ }
14
+ },
15
+ "then": {
16
+ "$ref": "#/$defs/app"
17
+ }
18
+ },
19
+ {
20
+ "if": {
21
+ "type": "object",
22
+ "required": ["pages"],
23
+ "properties": {
24
+ "pages": {}
25
+ }
26
+ },
27
+ "then": {
28
+ "$ref": "#/$defs/chapter"
29
+ }
30
+ },
31
+ {
32
+ "if": {
33
+ "type": "object",
34
+ "required": ["blocks"],
35
+ "properties": {
36
+ "blocks": {}
37
+ }
38
+ },
39
+ "then": {
40
+ "$ref": "#/$defs/page"
41
+ }
42
+ },
43
+ {
44
+ "if": {
45
+ "type": "array"
46
+ },
47
+ "then": {
48
+ "$ref": "#/$defs/data"
49
+ }
50
+ }
51
+ ],
52
+ "$defs": {
53
+ "id": {
54
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/id"
55
+ },
56
+ "strand": {
57
+ "description": "The one strand (Strang) of the learning map the app belongs to, <subject>/<strand>, e.g. mathematik/stochastik; or none if it belongs to none (then it is not on the map).",
58
+ "type": "string",
59
+ "pattern": "^(none|[a-z0-9]+(-[a-z0-9]+)*/[a-z0-9]+(-[a-z0-9]+)*)$"
60
+ },
61
+ "competency": {
62
+ "description": "A competency of the learning map, <subject>/<competency>, e.g. physik/gewichtskraft-berechnen.",
63
+ "type": "string",
64
+ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*/[a-z0-9]+(-[a-z0-9]+)*$"
65
+ },
66
+ "app": {
67
+ "description": "src/app.yaml in format 2: format: 2 and strand, everything else as in format 1.",
68
+ "type": "object",
69
+ "additionalProperties": false,
70
+ "required": ["format", "strand", "title", "intro", "languages", "chapters"],
71
+ "properties": {
72
+ "format": {
73
+ "description": "Always 2. Without it, the content is format 1 (explainer.v1.schema.json).",
74
+ "const": 2
75
+ },
76
+ "strand": {
77
+ "$ref": "#/$defs/strand"
78
+ },
79
+ "title": {
80
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/app/properties/title"
81
+ },
82
+ "intro": {
83
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/app/properties/intro"
84
+ },
85
+ "languages": {
86
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/app/properties/languages"
87
+ },
88
+ "chapters": {
89
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/app/properties/chapters"
90
+ },
91
+ "sources": {
92
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/app/properties/sources"
93
+ },
94
+ "tutor": {
95
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/app/properties/tutor"
96
+ },
97
+ "scope": {
98
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/app/properties/scope"
99
+ }
100
+ }
101
+ },
102
+ "chapter": {
103
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/chapter"
104
+ },
105
+ "page": {
106
+ "description": "src/<chapter>/<page>.yaml in format 2: a page as in format 1, plus competencies, or recap on a recap page.",
107
+ "type": "object",
108
+ "additionalProperties": false,
109
+ "required": ["title", "blocks"],
110
+ "properties": {
111
+ "title": {
112
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/page/properties/title"
113
+ },
114
+ "description": {
115
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/page/properties/description"
116
+ },
117
+ "competencies": {
118
+ "description": "The competencies (Bausteine) the page practises, ids of the subject of the app's strand; as a rule one. Required when the app has a strand; leave it out with strand: none and on a recap page. The catalog checks the ids when it lists the app.",
119
+ "type": "array",
120
+ "minItems": 1,
121
+ "uniqueItems": true,
122
+ "items": {
123
+ "$ref": "#/$defs/id"
124
+ }
125
+ },
126
+ "recap": {
127
+ "description": "Makes the page a recap page: it repeats a competency that the app's competencies require but the app does not practise, e.g. physik/gewichtskraft-berechnen. Use it instead of competencies, also with strand: none. A recap page has descriptive blocks only, no exercise; the link to the competency on the learning map is added for you.",
128
+ "$ref": "#/$defs/competency"
129
+ },
130
+ "blocks": {
131
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/page/properties/blocks"
132
+ },
133
+ "scope": {
134
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/page/properties/scope"
135
+ }
136
+ },
137
+ "not": {
138
+ "required": ["competencies", "recap"],
139
+ "properties": {
140
+ "competencies": {},
141
+ "recap": {}
142
+ }
143
+ }
144
+ },
145
+ "data": {
146
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/data"
147
+ },
148
+ "event": {
149
+ "$ref": "urn:lernapps:schema:explainer.v1#/$defs/event"
150
+ }
151
+ }
152
+ }