@lernapps/app-templates 0.2.1 → 0.3.1

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 +36 -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/e2e.ts +40 -0
  37. package/runtime/explainer/exercises.ts +71 -6
  38. package/runtime/explainer/explainer.css +21 -16
  39. package/runtime/explainer/explainer.v1.schema.json +2 -2
  40. package/runtime/explainer/explainer.v2.schema.json +152 -0
  41. package/runtime/explainer/expression.ts +127 -1
  42. package/runtime/explainer/generated/texts.ts +98 -9
  43. package/runtime/explainer/generated/types.ts +8 -0
  44. package/runtime/explainer/load.ts +70 -16
  45. package/runtime/explainer/messages/de.json +5 -1
  46. package/runtime/explainer/messages/en.json +5 -1
  47. package/runtime/explainer/outline.ts +1 -1
  48. package/runtime/explainer/page-list.ts +38 -0
  49. package/runtime/explainer/plugin.ts +13 -4
  50. package/runtime/explainer/render.ts +26 -2
  51. package/runtime/explainer/schema.ts +92 -9
  52. package/runtime/explainer/tutorpage.ts +9 -1
  53. package/runtime/shared/blocks/blocks.css +18 -0
  54. package/runtime/shared/blocks/blocks.v1.schema.json +1 -1
  55. package/runtime/shared/text/formula.ts +61 -1
  56. package/scaffold/explainer/src/app.yaml +5 -1
  57. package/scaffold/explainer/src/thema/chapter.yaml +1 -1
  58. package/scaffold/explainer/src/thema/einstieg.yaml +1 -1
@@ -1,9 +1,12 @@
1
- // Reads an explainer's content from src/ at build time and validates it: each file against its part of the schema
2
- // (explainer.v1.schema.json), then what a schema cannot say. The order lists are complete and unique, the depth is
1
+ // Reads an explainer's content from src/ at build time and validates it: each file against its part of the schema of
2
+ // its format (explainer.v1.schema.json without `format` in src/app.yaml, explainer.v2.schema.json with `format: 2`),
3
+ // then what a schema cannot say. In format 2, every page names its competencies when the app has a strand (or is a
4
+ // recap page, recap: <subject>/<competency>), and none when its strand is none; a recap page has no exercise, and
5
+ // every other page at least one. The order lists are complete and unique, the depth is
3
6
  // app, chapter, page and two section levels, ids are unique per level, every text is complete in each declared
4
7
  // language and in no other, texts hold restricted Markdown without raw HTML or headings, formulas are in the command
5
8
  // list, bundled images and quotes name source and licence, links to pages of the app find their page and section,
6
- // every page has an exercise, a question of the question types passes the validation of a question in every declared language,
9
+ // every page but a recap page has an exercise, a question of the question types passes the validation of a question in every declared language,
7
10
  // a generated exercise declares what it uses and passes its property test over fixed seeds (generated.ts), the check
8
11
  // of each exercise judges its examples as they are listed, a vue-component block names a component of src/components/
9
12
  // that passes its checks (components.ts), its props match the component's prop types, it has its nojs text (an exercise
@@ -17,6 +20,7 @@ import { readComponents } from "./components/read.ts";
17
20
  import {
18
21
  isExercise,
19
22
  type App,
23
+ type Block,
20
24
  type BlockOf,
21
25
  type Chapter,
22
26
  type ComponentUse,
@@ -27,12 +31,12 @@ import {
27
31
  } from "./content.ts";
28
32
  import { allBlocks } from "./routes.ts";
29
33
  import { ASSESSMENT, drawsTasks, exerciseOf, parseRef } from "./assessment.ts";
30
- import { SCHEMA_FILE, validate } from "./schema.ts";
34
+ import { formatOf, SCHEMA_FILES, validate, type Format } from "./schema.ts";
31
35
  import { appName } from "../shared/appname.ts";
32
36
  import { isObject, Problems, readYaml, Texts, type Path, type YamlFile } from "../shared/content.ts";
33
37
  import { isLanguage, LANGUAGES, languageListProblems } from "../shared/languages.ts";
34
38
 
35
- export { SCHEMA_ID } from "./schema.ts";
39
+ export { SCHEMA_ID, SCHEMA_ID_V2, type Format } from "./schema.ts";
36
40
  export const RULE_URL = "https://lernapps.net/tooling/rules/#explainer-content-valid";
37
41
 
38
42
  /** What src/ may hold besides the chapter folders. */
@@ -45,12 +49,12 @@ const IMAGE = /^[a-z0-9]+(-[a-z0-9]+)*\.(svg|png|jpg|webp)$/;
45
49
  /** The content could not be read: one problem per line in `problems`. */
46
50
  export class ContentError extends Error {
47
51
  readonly problems: readonly string[];
48
- constructor(problems: readonly string[]) {
52
+ constructor(problems: readonly string[], format: Format = 1) {
49
53
  super(
50
54
  [
51
55
  `the explainer's content in src/ is not valid (${RULE_URL}):`,
52
56
  ...problems.map((problem) => ` - ${problem}`),
53
- `Fix the content as its schema says: ${SCHEMA_FILE}`,
57
+ `Fix the content as its schema says: ${SCHEMA_FILES[format]}`,
54
58
  ].join("\n"),
55
59
  );
56
60
  this.problems = problems;
@@ -123,6 +127,14 @@ function checkAssessment(
123
127
  problems.at(yaml, path, `${ref} names no page of the app; write /<chapter>/<page>/#<exercise>`);
124
128
  return;
125
129
  }
130
+ if (page.recap !== undefined) {
131
+ problems.at(
132
+ yaml,
133
+ path,
134
+ `${ref} is on a recap page, which has no exercise and is left out of the self-assessment; choose an exercise of a page of the app's own competencies`,
135
+ );
136
+ return;
137
+ }
126
138
  const exercise = exerciseOf(components, page, parts.exercise);
127
139
  // a generated exercise or an exercise component may be listed again: each listing draws a task of its own
128
140
  if (twice && exercise !== undefined && !drawsTasks(exercise)) {
@@ -148,11 +160,21 @@ function checkAssessment(
148
160
  });
149
161
  }
150
162
 
163
+ /** Each block of `blocks` and the blocks inside them, with its path in the page's file. */
164
+ function* blockPaths(blocks: readonly Block[], path: Path): Generator<[Block, Path]> {
165
+ for (const [index, block] of blocks.entries()) {
166
+ yield [block, [...path, index]];
167
+ if (block.type === "section" || block.type === "example" || block.type === "note") {
168
+ yield* blockPaths(block.blocks, [...path, index, "blocks"]);
169
+ }
170
+ }
171
+ }
172
+
151
173
  /** How many seeds the property test of each generated exercise draws at build time. */
152
174
  export const SEEDS = 200;
153
175
 
154
176
  /** The problems of the content in `root`/src, and the content when there are none. */
155
- export function check(root: string, seeds = SEEDS): { site?: Site; problems: string[] } {
177
+ export function check(root: string, seeds = SEEDS): { site?: Site; problems: string[]; format: Format } {
156
178
  const problems = new Problems();
157
179
  const src = join(root, "src");
158
180
  if (!existsSync(join(src, "app.yaml"))) {
@@ -160,7 +182,7 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
160
182
  "src/app.yaml",
161
183
  "is missing: the app's title, intro, languages and the order of its chapters belong there",
162
184
  );
163
- return { problems: problems.lines };
185
+ return { problems: problems.lines, format: 1 };
164
186
  }
165
187
 
166
188
  // what src/ holds
@@ -213,13 +235,14 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
213
235
 
214
236
  // the app
215
237
  const appYaml = readYaml(root, "src/app.yaml", problems);
216
- if (appYaml === undefined || !validate(appYaml, "app", problems)) return { problems: problems.lines };
238
+ const format = formatOf(appYaml?.value);
239
+ if (appYaml === undefined || !validate(appYaml, "app", problems, format)) return { problems: problems.lines, format };
217
240
  const app = appYaml.value as App;
218
241
  const languageList = languageListProblems(app.languages, LANGUAGES);
219
242
  for (const problem of languageList) {
220
243
  problems.at(appYaml, ["languages", Math.max(app.languages.lastIndexOf(problem.lang), 0)], problem.message);
221
244
  }
222
- if (languageList.length > 0) return { problems: problems.lines };
245
+ if (languageList.length > 0) return { problems: problems.lines, format };
223
246
  const texts = new Texts(app.languages, problems);
224
247
  texts.check(appYaml, ["title"], "plain");
225
248
  texts.check(appYaml, ["intro"], "markdown");
@@ -312,11 +335,30 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
312
335
  for (const pageId of pagesOk ? chapter.pages : pageIds) {
313
336
  const file = `src/${chapterId}/${pageId}.yaml`;
314
337
  const yaml = readYaml(root, file, problems);
315
- if (yaml === undefined || !validate(yaml, "page", problems)) {
338
+ if (yaml === undefined || !validate(yaml, "page", problems, format)) {
316
339
  complete = false;
317
340
  continue;
318
341
  }
319
342
  const page = yaml.value as Omit<Page, "id" | "chapter" | "file">;
343
+ // format 2: the competencies a page practises, by the app's strand
344
+ if (app.strand === "none" && page.competencies !== undefined) {
345
+ problems.at(
346
+ yaml,
347
+ ["competencies"],
348
+ "competencies is not allowed: src/app.yaml has strand: none, so the app is on no strand; remove it, or name the app's strand in src/app.yaml",
349
+ );
350
+ } else if (
351
+ app.strand !== undefined &&
352
+ app.strand !== "none" &&
353
+ page.competencies === undefined &&
354
+ page.recap === undefined
355
+ ) {
356
+ problems.at(
357
+ yaml,
358
+ [],
359
+ `competencies is missing; src/app.yaml names the strand ${app.strand}, so each page names the competencies of its subject that it practises, e.g. competencies: [laplace]; a page that only repeats a competency the app does not practise says recap: <subject>/<competency> instead`,
360
+ );
361
+ }
320
362
  texts.check(yaml, ["title"], "plain");
321
363
  texts.check(yaml, ["description"], "plain");
322
364
  const pageAnchors = new Set<string>();
@@ -341,7 +383,18 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
341
383
  ["blocks"],
342
384
  0,
343
385
  );
344
- if (![...allBlocks(page.blocks)].some(isExercise)) {
386
+ if (page.recap !== undefined) {
387
+ // a recap page repeats a competency the app does not practise: no exercise, not even a component's
388
+ for (const [block, path] of blockPaths(page.blocks, ["blocks"])) {
389
+ const component = uses.find((use) => use.block === block && use.component.exercise);
390
+ if (!isExercise(block) && component === undefined) continue;
391
+ problems.at(
392
+ yaml,
393
+ path,
394
+ `${component === undefined ? `the exercise ${block.type}` : `the exercise component ${component.component.name}`} is not allowed on a recap page: it repeats ${page.recap} with descriptive blocks only, and the apps of that competency's strand practise it; remove the exercise, or make it a page of the app's own competencies`,
395
+ );
396
+ }
397
+ } else if (![...allBlocks(page.blocks)].some(isExercise)) {
345
398
  problems.at(
346
399
  yaml,
347
400
  ["blocks"],
@@ -429,7 +482,7 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
429
482
  }
430
483
  }
431
484
 
432
- if (problems.lines.length > 0) return { problems: problems.lines };
485
+ if (problems.lines.length > 0) return { problems: problems.lines, format };
433
486
  const data: Record<string, DataRecord[]> = {};
434
487
  for (const [id, yaml] of dataFiles) data[id] = yaml.value as DataRecord[];
435
488
  return {
@@ -442,6 +495,7 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
442
495
  storage: appName(root, "explainer"),
443
496
  },
444
497
  problems: [],
498
+ format,
445
499
  };
446
500
  }
447
501
 
@@ -449,8 +503,8 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
449
503
  * The content in `root`/src, its generated exercises tested over `seeds` seeds (0: not at all, for a reader of content
450
504
  * the build has checked); throws a ContentError with every problem when it is not valid. */
451
505
  export function loadSite(root: string, seeds = SEEDS): Site {
452
- const { site, problems } = check(root, seeds);
453
- if (site === undefined) throw new ContentError(problems);
506
+ const { site, problems, format } = check(root, seeds);
507
+ if (site === undefined) throw new ContentError(problems, format);
454
508
  return site;
455
509
  }
456
510
 
@@ -196,5 +196,9 @@
196
196
  "openTaskLocked": "Die Lösung öffnet sich nach deiner Antwort.",
197
197
  "resultPartly": "teilweise richtig ({n} von {total} Punkten, es fehlen {missed})",
198
198
  "resultPhoto": "Antwort als Foto",
199
- "resultAnswer": "Antwort „{input}“"
199
+ "resultAnswer": "Antwort „{input}“",
200
+ "recap": "Wiederholung",
201
+ "recapText": "Diese Seite wiederholt etwas, das du schon können solltest. Üben kannst du es in anderen Apps: {link}",
202
+ "recapLink": "Baustein auf der Lernlandkarte von lernapps.net",
203
+ "recapNote": "Wiederholung"
200
204
  }
@@ -196,5 +196,9 @@
196
196
  "openTaskLocked": "The solution opens after your answer.",
197
197
  "resultPartly": "partly right ({n} of {total} points, missing {missed})",
198
198
  "resultPhoto": "answer as a photo",
199
- "resultAnswer": "answer “{input}”"
199
+ "resultAnswer": "answer “{input}”",
200
+ "recap": "Recap",
201
+ "recapText": "This page goes over something you should already know. You can practise it in other apps: {link}",
202
+ "recapLink": "building block on the learning map of lernapps.net",
203
+ "recapNote": "recap"
200
204
  }
@@ -196,7 +196,7 @@ function walk(
196
196
  }
197
197
  case "generated": {
198
198
  const data = dataOf(block, site.data);
199
- const templates = templatesOf(block, [lang]);
199
+ const templates = templatesOf(block, [lang], data);
200
200
  const worked = workedTasks(block, data, templates, lang);
201
201
  for (const item of worked) {
202
202
  for (const html of [item.task, item.hint ?? "", ...item.steps])
@@ -0,0 +1,38 @@
1
+ // The list of an explainer's pages for the catalog of lernapps.net (lernapps-pages.json): only an app of format 2
2
+ // (`format: 2` in src/app.yaml) has it. The build writes it at the root of the bundle, beside index.html; the catalog
3
+ // reads it instead of hand-written topics, to place the app and its pages on the learning map. It holds the version of
4
+ // the list itself (format 1), the app's strand (<subject>/<strand>, or none), and the content pages in the order of the
5
+ // app, each with its address relative to the app's root as the app links it, its title as a text (a string, or by
6
+ // language) and, unless the strand is none, the competencies it practises; a recap page names instead the competency
7
+ // it repeats (recap: <subject>/<competency>), with any strand.
8
+ import { contentPages } from "./routes.ts";
9
+ import type { Site, Text } from "./content.ts";
10
+
11
+ /** The file's name, at the root of the bundle. */
12
+ export const PAGE_LIST = "lernapps-pages.json";
13
+
14
+ export interface PageList {
15
+ /** The version of this list's own format. */
16
+ format: 1;
17
+ strand: string;
18
+ pages: { path: string; title: Text; competencies?: readonly string[]; recap?: string }[];
19
+ }
20
+
21
+ /** The list of the pages of `site`; undefined for an app of format 1, which has none. */
22
+ export function pageList(site: Site): PageList | undefined {
23
+ const { format, strand } = site.app;
24
+ if (format !== 2 || strand === undefined) return undefined;
25
+ return {
26
+ format: 1,
27
+ strand,
28
+ pages: contentPages(site).map((page) => ({
29
+ path: `${page.chapter}/${page.id}/`,
30
+ title: page.title,
31
+ ...(page.recap !== undefined
32
+ ? { recap: page.recap }
33
+ : strand === "none" || page.competencies === undefined
34
+ ? {}
35
+ : { competencies: page.competencies }),
36
+ })),
37
+ };
38
+ }
@@ -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(
@@ -30,6 +30,24 @@
30
30
  height: auto;
31
31
  }
32
32
 
33
+ /* The drawings take the page's colours, so they follow the dark mode too (the attributes in figures.ts are the fallback
34
+ for an SVG outside a page) */
35
+ .block-drawing .fig-on {
36
+ fill: var(--page-accent);
37
+ }
38
+
39
+ .block-drawing .fig-off {
40
+ fill: var(--page-background);
41
+ }
42
+
43
+ .block-drawing .fig-line {
44
+ stroke: var(--page-muted);
45
+ }
46
+
47
+ .block-drawing .fig-text {
48
+ fill: var(--page-text);
49
+ }
50
+
33
51
  .block-hundred-grid {
34
52
  width: 16rem;
35
53
  }
@@ -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": {