@lernapps/app-templates 0.2.1 → 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 (56) 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
@@ -12,7 +12,8 @@
12
12
  // depth. The bundled images go to images/, the tutor page of each content page to <chapter>/<page>/tutor/ with its
13
13
  // Markdown beside it (index.md; tutorpage.ts), and of each self-assessment to <assessment>/tutor/ (assessment-page.ts),
14
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.
15
+ // app does not have to its start page (notFoundPage of ../shared/pages.ts). An app of format 2 also gets
16
+ // lernapps-pages.json at the root, the list of its pages with their competencies for the catalog (page-list.ts). `vp dev` serves the same pages and tutor pages, rendered on each request.
16
17
  //
17
18
  // Components (src/components/<Name>.vue, components.ts): only when the app has them, the plugin compiles them with
18
19
  // @vitejs/plugin-vue and resolves vue to the copy this runtime depends on, so that the components and the islands
@@ -53,6 +54,7 @@ import {
53
54
  tutorPageDocument,
54
55
  } from "./tutorpage.ts";
55
56
  import { imageType } from "../shared/blocks/image.ts";
57
+ import { PAGE_LIST, pageList } from "./page-list.ts";
56
58
 
57
59
  const MARKER = "<!-- explainer -->";
58
60
  const ISLANDS = "\0lernapps-explainer-islands";
@@ -263,7 +265,9 @@ export function explainer(): Plugin[] {
263
265
  }
264
266
  if (command !== "build" || content.components.length === 0) return;
265
267
  const rendered = await withServer(root, (loader) => renderComponents(content, loader, true));
266
- if (rendered.problems.length > 0) this.error(new ContentError(rendered.problems).message);
268
+ if (rendered.problems.length > 0) {
269
+ this.error(new ContentError(rendered.problems, content.app.format ?? 1).message);
270
+ }
267
271
  islands = rendered.islands;
268
272
  descriptions = rendered.descriptions;
269
273
  islandsChunk = this.emitFile({ type: "chunk", id: ISLANDS, name: "islands" });
@@ -289,6 +293,11 @@ export function explainer(): Plugin[] {
289
293
  else this.emitFile({ type: "asset", fileName: `${page.route.path}index.html`, source: html });
290
294
  }
291
295
  this.emitFile({ type: "asset", fileName: "404.html", source: notFoundPage() });
296
+ // format 2: the list of the pages for the catalog
297
+ const list = pageList(content);
298
+ if (list !== undefined) {
299
+ this.emitFile({ type: "asset", fileName: PAGE_LIST, source: `${JSON.stringify(list, null, 2)}\n` });
300
+ }
292
301
  for (const [image, file] of content.images) {
293
302
  this.emitFile({ type: "asset", fileName: image, source: readFileSync(file) });
294
303
  }
@@ -404,7 +413,7 @@ export function explainer(): Plugin[] {
404
413
  );
405
414
  }
406
415
  }
407
- if (problems.length > 0) this.error(new ContentError(problems).message);
416
+ if (problems.length > 0) this.error(new ContentError(problems, content.app.format ?? 1).message);
408
417
  },
409
418
  configureServer(server) {
410
419
  devServer = server;
@@ -442,7 +451,7 @@ export function explainer(): Plugin[] {
442
451
  if (dev === undefined) return next();
443
452
  renderComponents(content, (id) => dev.ssrLoadModule(id), false)
444
453
  .then(({ islands: rendered, descriptions: described, problems }) => {
445
- if (problems.length > 0) throw new ContentError(problems);
454
+ if (problems.length > 0) throw new ContentError(problems, content.app.format ?? 1);
446
455
  if (forScope) {
447
456
  const markdown = path.endsWith(TUTOR_MARKDOWN);
448
457
  const lang =
@@ -2,7 +2,8 @@
2
2
  // as inline SVG or an image, tables, timelines, definitions, quotes, highlighted code, the exercises (exercises.ts:
3
3
  // each with its solution in a <details>, a generated one with three worked tasks), and the navigation (contents,
4
4
  // breadcrumbs, previous and next, the contents of a page with three or more sections, the glossary, the language
5
- // switch). Without JavaScript the page is complete; the client module only adds. A vue-component block is its island:
5
+ // switch; a recap page marked in the contents, and at its top its notice with the link to the learning map). Without
6
+ // JavaScript the page is complete; the client module only adds. A vue-component block is its island:
6
7
  // the component's markup as the build rendered it (`hidden` until it hydrates), its nojs text, its props in the page's
7
8
  // language, and for an exercise its anchor, its task number with "Neue Aufgabe", the task of number 1 as its describe
8
9
  // states it with the solution folded, and the place of its feedback. Links to other sites are plain links with rel="noopener":
@@ -13,6 +14,7 @@ import { questionMessagesIn } from "../shared/questions/render.ts";
13
14
  import { propsIn } from "./components/props.ts";
14
15
  import {
15
16
  inLanguage,
17
+ mapUrl,
16
18
  termId as termIdOf,
17
19
  timelineEvents,
18
20
  type Block,
@@ -322,7 +324,10 @@ function pageList(context: Context, pages: readonly NonNullable<Route["page"]>[]
322
324
  page.description === undefined
323
325
  ? ""
324
326
  : ` <span class="explainer-description">${context.text(page.description)}</span>`;
325
- return `<li><a href="${context.to(`${page.chapter}/${page.id}/`)}">${context.text(page.title)}</a>${description}</li>`;
327
+ // a recap page repeats a competency the app does not practise: marked, so that no one takes it for the app's own
328
+ const recap =
329
+ page.recap === undefined ? "" : ` <span class="explainer-recap-note">(${plain(context.t("recapNote"))})</span>`;
330
+ return `<li><a href="${context.to(`${page.chapter}/${page.id}/`)}">${context.text(page.title)}</a>${recap}${description}</li>`;
326
331
  })
327
332
  .join("")}</ol>`;
328
333
  }
@@ -407,6 +412,7 @@ function main(context: Context): string {
407
412
  title,
408
413
  tutorSection(context),
409
414
  `</div>`,
415
+ page.recap === undefined ? "" : recapNotice(context, page.recap),
410
416
  pageContents(context, page.blocks),
411
417
  renderBlocks(context, page.blocks, 1),
412
418
  ].join("\n");
@@ -414,6 +420,24 @@ function main(context: Context): string {
414
420
  }
415
421
  }
416
422
 
423
+ /**
424
+ * At the top of a recap page: that it is a recap, and the link to the competency's page on the learning map, where the
425
+ * apps are that practise it. The runtime writes it, so that it cannot be missing; a plain link, nothing loaded before
426
+ * a click.
427
+ */
428
+ function recapNotice(context: Context, recap: string): string {
429
+ const link = external(mapUrl(recap), context.t("recapLink"));
430
+ // the message keeps its placeholder {link} unfilled: the link goes in its place as markup
431
+ const [before = "", after = ""] = context.t("recapText").split("{link}");
432
+ const label = plain(context.t("recap"));
433
+ return [
434
+ `<aside class="explainer-recap" aria-label="${label}">`,
435
+ `<p class="explainer-recap-label">${label}</p>`,
436
+ `<p>${plain(before)}${link}${plain(after)}</p>`,
437
+ `</aside>`,
438
+ ].join("\n");
439
+ }
440
+
417
441
  /** What a part of a self-assessment needs of the page it is on (assessment-page.ts). */
418
442
  const assessmentContext = (context: Context): AssessmentContext => ({
419
443
  site: context.site,
@@ -1,25 +1,35 @@
1
- // The explainer's content schema (explainer.v1.schema.json) at build time: the validators of its files (the app, a
2
- // chapter, a page, a data file), and the explainer's own words for the errors of the schema (what a quote or a bundled
1
+ // The explainer's content schemas at build time: format 1 (explainer.v1.schema.json) and format 2
2
+ // (explainer.v2.schema.json, which adds the strand of the app, the competencies of each page and recap pages, and refers to format 1
3
+ // for everything else); the validators of their files (the app, a chapter, a page, a data file) by format, and the explainer's own words for the errors of the schema (what a quote or a bundled
3
4
  // image lacks, which fields a block type has, which an exercise of a question type); the general messages are
4
5
  // shared/content.ts's.
5
6
  import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js";
6
7
  import schema from "./explainer.v1.schema.json" with { type: "json" };
8
+ import schemaV2 from "./explainer.v2.schema.json" with { type: "json" };
7
9
  import questionSchema from "../shared/questions/question.v1.schema.json" with { type: "json" };
8
10
  import { knownProperties, schemasById } from "../shared/schema.ts";
9
11
  import { addressProblem, QUESTION_BRANCH, questionSchemas } from "../shared/questions/validate.ts";
10
12
  import { isObject, schemaProblems, type DescribeError, type Problems, type YamlFile } from "../shared/content.ts";
11
13
 
12
14
  export const SCHEMA_ID = schema.$id;
13
- /** Where an app finds the schema of its content, for the message that names it. */
14
- export const SCHEMA_FILE = "node_modules/@lernapps/app-templates/runtime/explainer/explainer.v1.schema.json";
15
+ export const SCHEMA_ID_V2 = schemaV2.$id;
16
+ /** The formats of the content: 1 without `format` in src/app.yaml, 2 with `format: 2`. */
17
+ export type Format = 1 | 2;
18
+ /** Where an app finds the schema of its content, by format, for the message that names it. */
19
+ export const SCHEMA_FILES: Readonly<Record<Format, string>> = {
20
+ 1: "node_modules/@lernapps/app-templates/runtime/explainer/explainer.v1.schema.json",
21
+ 2: "node_modules/@lernapps/app-templates/runtime/explainer/explainer.v2.schema.json",
22
+ };
23
+ export const SCHEMA_FILE = SCHEMA_FILES[1];
15
24
 
16
25
  // union types ("type": ["string", "object"]) keep the messages of the comparator and the lists plain
17
26
  const ajv = new Ajv2020({ allErrors: true, strict: true, allowUnionTypes: true });
18
27
  for (const each of questionSchemas) ajv.addSchema(each);
19
28
  ajv.addSchema(schema);
20
- const validator = (name: string): ValidateFunction => {
21
- const validate = ajv.getSchema(`${SCHEMA_ID}#/$defs/${name}`);
22
- if (validate === undefined) throw new Error(`the schema has no $defs/${name}`);
29
+ ajv.addSchema(schemaV2);
30
+ const validator = (name: string, id = SCHEMA_ID): ValidateFunction => {
31
+ const validate = ajv.getSchema(`${id}#/$defs/${name}`);
32
+ if (validate === undefined) throw new Error(`the schema ${id} has no $defs/${name}`);
23
33
  return validate;
24
34
  };
25
35
  export const validators = {
@@ -29,6 +39,20 @@ export const validators = {
29
39
  data: validator("data"),
30
40
  event: validator("event"),
31
41
  };
42
+ /** The validators of format 2: its own app and page; chapters, data files and events are those of format 1. */
43
+ const validatorsV2: typeof validators = {
44
+ ...validators,
45
+ app: validator("app", SCHEMA_ID_V2),
46
+ page: validator("page", SCHEMA_ID_V2),
47
+ };
48
+
49
+ /** The format of the app `app` (src/app.yaml as read): 2 when it names any format, which its schema then checks. */
50
+ export const formatOf = (app: unknown): Format => (isObject(app) && app["format"] !== undefined ? 2 : 1);
51
+
52
+ /** The fields of format 2, which format 1 does not know. */
53
+ const FORMAT_2_FIELDS = new Set(["format", "strand", "competencies", "recap"]);
54
+ /** The fields of format 2 on a page. */
55
+ const PAGE_FIELDS = new Set(["competencies", "recap"]);
32
56
 
33
57
  const schemas = schemasById(...questionSchemas, schema);
34
58
 
@@ -61,6 +85,9 @@ const questionFields = (type: string): Set<string> => {
61
85
  /** What `required` names as missing, with why it is needed. */
62
86
  function missing(property: string, object: unknown): string {
63
87
  const type = isObject(object) ? object["type"] : undefined;
88
+ if (property === "strand") {
89
+ return "strand is missing; name the strand of the learning map the app stays in, <subject>/<strand> (e.g. mathematik/stochastik), or none when the app is on no strand";
90
+ }
64
91
  if (property === "origin") return "origin is missing; a quote names who said or wrote it, where and when";
65
92
  if ((property === "licence" || property === "source") && type === "quote") {
66
93
  return `${property} is missing; a quote names its source and its licence`;
@@ -80,7 +107,58 @@ const describe: DescribeError = (error, path, value) => {
80
107
  const { lang, message } = addressProblem(value);
81
108
  return { message, ...(lang === undefined ? {} : { lang }) };
82
109
  }
110
+ case "not":
111
+ // a page with both: it practises competencies of the app's strand, or it repeats one the app does not practise
112
+ if (path.length !== 0 || !isObject(value) || !("recap" in value) || !("competencies" in value)) return undefined;
113
+ return {
114
+ path: ["recap"],
115
+ message:
116
+ "a page has competencies or recap, never both: a page practises competencies of the app's strand, a recap page only repeats a competency the app does not practise; remove one of them",
117
+ };
118
+ case "const":
119
+ // the format of the content: 2, or none at all for format 1
120
+ if (path.length !== 1 || path[0] !== "format") return undefined;
121
+ return { message: `${String(value)} is not a format; write format: 2, or leave format out for format 1` };
122
+ case "uniqueItems":
123
+ if (path.at(-1) !== "competencies") return undefined;
124
+ return { message: "names a competency twice; name each competency once" };
125
+ case "additionalProperties": {
126
+ // a field of format 2 in an app of format 1
127
+ const property = String(error.params["additionalProperty"]);
128
+ if (path.length !== 0 || !FORMAT_2_FIELDS.has(property) || !isObject(value)) return undefined;
129
+ if (PAGE_FIELDS.has(property) && "blocks" in value) {
130
+ return {
131
+ path: [property],
132
+ message: `${property} is a field of format 2; add format: 2 and the app's strand to src/app.yaml, or remove it`,
133
+ };
134
+ }
135
+ if (!PAGE_FIELDS.has(property) && "chapters" in value) {
136
+ return {
137
+ path: [property],
138
+ message: `${property} is a field of format 2; add format: 2 to src/app.yaml, with the app's strand`,
139
+ };
140
+ }
141
+ return undefined;
142
+ }
83
143
  case "pattern":
144
+ // the strand of the app: <subject>/<strand> or none
145
+ if (path.length === 1 && path[0] === "strand") {
146
+ return {
147
+ message: `${String(value)} is no strand; write <subject>/<strand> with the ids of the learning map, e.g. mathematik/stochastik, or none`,
148
+ };
149
+ }
150
+ // the competency a recap page repeats: <subject>/<competency>
151
+ if (path.length === 1 && path[0] === "recap") {
152
+ return {
153
+ message: `${String(value)} is no competency of the learning map; write <subject>/<competency> with the ids of the learning map, e.g. physik/gewichtskraft-berechnen`,
154
+ };
155
+ }
156
+ // a competency of a page: an id of the learning map
157
+ if (path.at(-2) === "competencies") {
158
+ return {
159
+ message: `${String(value)} is no competency id; use the competency's id in the learning map: lower-case letters, digits and hyphens, e.g. laplace`,
160
+ };
161
+ }
84
162
  // an exercise of a self-assessment: by its page and id
85
163
  if (path.at(-2) === "exercises" && path[0] === "selfAssessment") {
86
164
  return { message: `${String(value)} is no exercise's address; write /<chapter>/<page>/#<exercise>` };
@@ -129,8 +207,13 @@ const describe: DescribeError = (error, path, value) => {
129
207
  return undefined;
130
208
  };
131
209
 
132
- export function validate(yaml: YamlFile, kind: keyof typeof validators, problems: Problems): boolean {
133
- const check = validators[kind];
210
+ export function validate(
211
+ yaml: YamlFile,
212
+ kind: keyof typeof validators,
213
+ problems: Problems,
214
+ format: Format = 1,
215
+ ): boolean {
216
+ const check = (format === 2 ? validatorsV2 : validators)[kind];
134
217
  if (check(yaml.value)) return true;
135
218
  const before = problems.lines.length;
136
219
  schemaProblems(yaml, check.errors ?? [], problems, describe);
@@ -27,6 +27,7 @@ import { escape } from "../shared/html.ts";
27
27
  import { answerText } from "../shared/questions/render.ts";
28
28
  import {
29
29
  inLanguage,
30
+ mapUrl,
30
31
  type Block,
31
32
  type ComponentExercise,
32
33
  type Descriptions,
@@ -72,6 +73,8 @@ interface Words {
72
73
  needsIntro: string;
73
74
  needs: Readonly<Record<Need, string>>;
74
75
  contextTitle: string;
76
+ /** On a recap page: that it has no exercise, and where the competency is practised ({url}). */
77
+ recap: string;
75
78
  exercise: string;
76
79
  steps: string;
77
80
  link: string;
@@ -139,6 +142,8 @@ const WORDS: Readonly<Record<string, Words>> = {
139
142
  "Zeig Zahlen der Seite als Menge, z. B. „37 sind 3 Zehner und 7 Einer“, aber nie das Ergebnis einer Aufgabe. Ein Schritt pro Nachricht, nie Zeitdruck.",
140
143
  },
141
144
  contextTitle: "Kontext: die Seite „{page}“",
145
+ recap:
146
+ "Diese Seite ist eine Wiederholung: Sie wiederholt etwas, das das Kind schon können sollte, und hat keine Übung. Erklär mit dem Inhalt der Seite und frag nach, ob das Kind ihn verstanden hat. Üben kann es das in anderen Apps; welche das sind, steht hier: {url}",
142
147
  exercise: "Übung",
143
148
  steps: "Lösungsweg",
144
149
  link: "Link",
@@ -205,6 +210,8 @@ const WORDS: Readonly<Record<string, Words>> = {
205
210
  "Show numbers of the page as amounts, e.g. “37 is 3 tens and 7 ones”, but never the result of a task. One step per message, never time pressure.",
206
211
  },
207
212
  contextTitle: "Context: the page “{page}”",
213
+ recap:
214
+ "This page is a recap: it goes over something the child should already know, and has no exercise. Explain with the content of the page and ask whether the child has understood it. It can practise it in other apps; which ones, it says here: {url}",
208
215
  exercise: "Exercise",
209
216
  steps: "Steps of the solution",
210
217
  link: "Link",
@@ -341,7 +348,7 @@ export function generatedLines(spec: Generated, site: Site, lang: string, format
341
348
  lines.push(`- ${words.linkNumber}: \`${page}?${spec.id}=n&via=tutor#${spec.id}\``);
342
349
  const { paths, answer } = placeholdersOf(spec, lang);
343
350
  const data = dataOf(spec, site.data);
344
- const templates = templatesOf(spec, [lang]);
351
+ const templates = templatesOf(spec, [lang], data);
345
352
  let choices = false;
346
353
  const rows = taskSeeds(spec, data).map((seed, index) => {
347
354
  const task = generate(spec, seed, data);
@@ -569,6 +576,7 @@ function pageMarkdown(
569
576
  `## ${fill(words.contextTitle)}`,
570
577
  "",
571
578
  );
579
+ if (page.recap !== undefined) lines.push(words.recap.replace("{url}", mapUrl(page.recap)), "");
572
580
  if (page.description !== undefined) lines.push(own(page.description), "");
573
581
  lines.push(
574
582
  ...contentLines(
@@ -328,7 +328,7 @@
328
328
  "type": "object"
329
329
  },
330
330
  "latex": {
331
- "description": "One formula in the LaTeX subset of the formula command list: \\frac, \\cdot, \\times, \\div, \\%, \\text, ^, _, \\sqrt, \\approx, \\pm, \\le, \\ge, \\neq, \\, (thin space), parentheses and braces; \\ce{…} for chemical equations and \\pu{…} for units (mhchem). Written without the $ signs. A map by language only when it has \\text{…} that differs by language.",
331
+ "description": "One formula in the LaTeX subset of the formula command list: \\frac, \\cdot, \\times, \\div, \\%, \\text, ^, _, \\sqrt, \\approx, \\pm, \\le, \\ge, \\neq, \\, (thin space), \\ (space), \\quad, \\qquad, \\Rightarrow, \\Leftrightarrow, \\left( … \\right), parentheses and braces; \\sin, \\cos, \\tan, \\arcsin, \\arccos, \\arctan (or \\sin^{-1}), \\circ for degrees (30^\\circ); the Greek letters \\alpha to \\omega (also \\varepsilon, \\vartheta, \\varphi) and the capitals \\Gamma, \\Delta, \\Theta, \\Lambda, \\Xi, \\Pi, \\Sigma, \\Phi, \\Psi, \\Omega; \\ce{…} for chemical equations and \\pu{…} for units (mhchem). The build names any other command and lists these. Written without the $ signs. A map by language only when it has \\text{…} that differs by language.",
332
332
  "$ref": "urn:lernapps:schema:text.v1#/$defs/text"
333
333
  },
334
334
  "table": {
@@ -7,6 +7,46 @@ import temml from "temml";
7
7
  const mhchem: string = "temml/contrib/mhchem/mhchem.js";
8
8
  await import(mhchem);
9
9
 
10
+ /** The Greek letters a formula may use, as \\alpha: the small ones, and the capitals that differ from Latin ones. */
11
+ const GREEK = [
12
+ "alpha",
13
+ "beta",
14
+ "gamma",
15
+ "delta",
16
+ "varepsilon",
17
+ "epsilon",
18
+ "zeta",
19
+ "eta",
20
+ "theta",
21
+ "vartheta",
22
+ "iota",
23
+ "kappa",
24
+ "lambda",
25
+ "mu",
26
+ "nu",
27
+ "xi",
28
+ "pi",
29
+ "rho",
30
+ "sigma",
31
+ "tau",
32
+ "upsilon",
33
+ "varphi",
34
+ "phi",
35
+ "chi",
36
+ "psi",
37
+ "omega",
38
+ "Gamma",
39
+ "Delta",
40
+ "Theta",
41
+ "Lambda",
42
+ "Xi",
43
+ "Pi",
44
+ "Sigma",
45
+ "Phi",
46
+ "Psi",
47
+ "Omega",
48
+ ];
49
+
10
50
  /** The commands a formula may use (explainer.v1.schema.json, $defs/latex), besides ^, _, braces and parentheses. */
11
51
  export const COMMANDS: readonly string[] = [
12
52
  "\\frac",
@@ -26,6 +66,25 @@ export const COMMANDS: readonly string[] = [
26
66
  "\\}",
27
67
  "\\ce",
28
68
  "\\pu",
69
+ // spaces and arrows between the steps of a calculation
70
+ "\\ ",
71
+ "\\quad",
72
+ "\\qquad",
73
+ "\\Rightarrow",
74
+ "\\Leftrightarrow",
75
+ // parentheses that grow with their content
76
+ "\\left",
77
+ "\\right",
78
+ // trigonometry: the functions, their inverses (or \sin^{-1}) and the degree sign (^\circ)
79
+ "\\sin",
80
+ "\\cos",
81
+ "\\tan",
82
+ "\\arcsin",
83
+ "\\arccos",
84
+ "\\arctan",
85
+ "\\circ",
86
+ // Greek letters
87
+ ...GREEK.map((letter) => `\\${letter}`),
29
88
  ];
30
89
  const allowed = new Set(COMMANDS);
31
90
 
@@ -52,7 +111,8 @@ function commands(tex: string): string[] {
52
111
  export function formulaProblem(tex: string): string | undefined {
53
112
  const unknown = commands(tex).find((command) => !allowed.has(command));
54
113
  if (unknown !== undefined) {
55
- return `${unknown} is not in the formula command list; write the formula with the commands the schema lists ($defs/latex)`;
114
+ const list = COMMANDS.map((command) => (command === "\\ " ? "\\ (a space)" : command)).join(", ");
115
+ return `${unknown} is not in the formula command list; use only these commands, besides ^, _, braces and parentheses: ${list}`;
56
116
  }
57
117
  try {
58
118
  temml.renderToString(tex, { throwOnError: true });
@@ -1,4 +1,8 @@
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
+ # The strand of the learning map the app stays in, <subject>/<strand> (e.g. mathematik/stochastik); then each page
4
+ # names its competencies. none: the app is on no strand, and no page names competencies.
5
+ strand: none
2
6
  title: Platzhalter
3
7
  intro: Dieses Kapitel hält den Platz frei, bis der geplante Inhalt da ist.
4
8
  languages: [de]
@@ -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: Thema
3
3
  summary: Ersetze dieses Kapitel durch das erste geplante.
4
4
  pages: [einstieg]
@@ -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: Einstieg
3
3
  description: Eine Seite, die den Platz frei hält.
4
4
  blocks: