@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
package/README.md CHANGED
@@ -21,7 +21,7 @@ feature: assistants read it before they choose an archetype.
21
21
  | Folder | Archetype | What the app supplies |
22
22
  |---|---|---|
23
23
  | [`quiz/`](quiz/) | a quiz about things to know: questions with options, feedback on every option, score and solutions, in one or more languages (a page per language) | the question bank `src/quiz.json` ([schema](runtime/quiz/quiz.v1.schema.json)) |
24
- | [`explainer/`](explainer/) | an explainer: chapters of pages with sections, formulas, figures, tables, timelines, definitions with a glossary, quotes, code and YouTube videos behind two clicks, and an exercise on every page (the quiz's question types, and `generated` exercises drawn from a seed and property-tested at build), readable without JavaScript, in one or more languages; an optional self-assessment of the app or a chapter, checked as a whole; Vue components for what no block can express | the content as YAML in `src/`: `app.yaml`, a folder per chapter with `chapter.yaml` and its pages, `data/` and `images/`, and `components/` ([schema](runtime/explainer/explainer.v1.schema.json)) |
24
+ | [`explainer/`](explainer/) | an explainer: chapters of pages with sections, formulas, figures, tables, timelines, definitions with a glossary, quotes, code and YouTube videos behind two clicks, and an exercise on every page but a recap page (the quiz's question types, and `generated` exercises drawn from a seed and property-tested at build), readable without JavaScript, in one or more languages; an optional self-assessment of the app or a chapter, checked as a whole; Vue components for what no block can express | the content as YAML in `src/`: `app.yaml`, a folder per chapter with `chapter.yaml` and its pages, `data/` and `images/`, and `components/` ([schema](runtime/explainer/explainer.v2.schema.json); format 1: [schema](runtime/explainer/explainer.v1.schema.json)) |
25
25
  | [`interactive/`](interactive/) | an interactive app whose doing is the content (a game, a trainer): a static start page, readable without JavaScript, and an app page with the app's own Vue components, its routes as `app/#<id>`; the build plays its playthroughs against its core | `src/app.yaml` ([schema](runtime/interactive/interactive.v1.schema.json)), the core `src/core/index.ts` (`start(seed, route)`, `step(state, input)` with a key or an action of its root component, optional `tick(state, dt)`), its components in `src/ui/`, data in `src/data/`, and `src/playthroughs/*.yaml` ([schema](runtime/interactive/playthrough.v1.schema.json)) |
26
26
 
27
27
  - [`runtime/`](runtime/): the runtime of each archetype, published to npm as `@lernapps/app-templates` (with the template folders, complete example apps, and the scaffold of each archetype: `lernapps create` of `@lernapps/tooling` copies the paths `lernapps.scaffold.<archetype>` in [`package.json`](package.json) lists and the placeholder in [`scaffold/<archetype>/`](scaffold/), and leaves the example in the installed package; [`scripts/scaffold.ts`](scripts/scaffold.ts) writes the same for the CI job `scaffold`); its exports
@@ -29,14 +29,8 @@ feature: assistants read it before they choose an archetype.
29
29
  one by one, for other archetypes that embed them as exercises: `@lernapps/app-templates/quiz/question` (schema,
30
30
  validation, rendering, checker, messages; a question's texts may be maps by language), `startQuestion` of `@lernapps/app-templates/quiz` and the helpers of
31
31
  `@lernapps/app-templates/quiz/e2e`; [`test/fixtures/question-page/`](test/fixtures/question-page/) is a page built so.
32
- - The placeholders of the explainer's `generated` exercises are always two braces around a declared name or its
33
- field, nothing else inside: `{{G}}`, `{{e.name}}`, `{{answer}}`, in prose and in formulas alike
34
- (`$\frac{{{W}}}{{{G}}} \cdot 100 = {{answer}}$`). One brace is never a placeholder, so in a formula `G`, `{G}` and
35
- `\frac{W}{G}` are letters; the only LaTeX form this reserves, a double group around a lone name, means the same as
36
- the name alone. The build fails on a placeholder whose name `given` does not declare, on one brace or spaces where a
37
- placeholder was meant in prose (`{G}`, `{{ G }}`), on a variable of `given` that nothing uses (no placeholder, and
38
- no expression of the figure, answer or mistakes; `where` does not count), and on a placeholder left after filling in
39
- ([`generated.ts`](runtime/explainer/generated.ts)).
32
+ - How to write the explainer's `generated` exercises, with their placeholders, is in its
33
+ [schema](runtime/explainer/explainer.v1.schema.json) (`$defs/generated`).
40
34
  - An explainer's components are Vue single-file components in `src/components/<Name>.vue`, each with
41
35
  `<script setup lang="ts">`, typed props and its test `<Name>.test.ts`, used by a `vue-component` block with its
42
36
  `props` and a `nojs` text. The build renders each with Vue's server renderer into its island, `hidden` until it
@@ -188,8 +182,8 @@ positive list, `TEMPLATE_PATHS`: a path on no list runs the root checks only, an
188
182
  change scope and runs all of them for any code change, catches the rest; when it catches what the hook let through, add
189
183
  the path to the list. The fixture jobs run only in CI. The job `check` reports the overall result and is the check to
190
184
  require. A change reaches new apps once it is released ([`release.yml`](.github/workflows/release.yml): after a merge to `main`,
191
- semantic-release takes the version from the conventional commits, tags `v<version>` and publishes to npm with provenance) and lernapps/tooling depends on that version; Renovate moves existing apps. The package leaves out
192
- each template's `.gitignore`, which npm never ships: `prepack` stages it as `_gitignore` ([`scripts/pack-dotfiles.ts`](scripts/pack-dotfiles.ts)), and `lernapps create` writes it back.
185
+ semantic-release takes the version from the conventional commits, tags `v<version>` and publishes to npm with provenance) and lernapps/tooling depends on that version; Renovate moves existing apps. Each template folder has
186
+ its `.gitignore` also as `_gitignore`, because npm never ships a `.gitignore`; change both together.
193
187
 
194
188
  ## License
195
189
 
@@ -123,7 +123,15 @@ const FUNCTIONS = {
123
123
  arity: 2,
124
124
  field: 1
125
125
  },
126
- reverse: { arity: 1 }
126
+ reverse: { arity: 1 },
127
+ sqrt: { arity: 1 },
128
+ pow: { arity: 2 },
129
+ sin: { arity: 1 },
130
+ cos: { arity: 1 },
131
+ tan: { arity: 1 },
132
+ asin: { arity: 1 },
133
+ acos: { arity: 1 },
134
+ atan: { arity: 1 }
127
135
  };
128
136
  const KEYWORDS = /* @__PURE__ */ new Set([
129
137
  "and",
@@ -444,6 +452,98 @@ function integer(value, what) {
444
452
  if (!value.isInteger()) throw new ExpressionError(`${what} needs a whole number`);
445
453
  return Number(value.n);
446
454
  }
455
+ /** A real number that is no fraction, as the fraction of its first APPROXIMATION_PLACES decimals. */
456
+ function approximate(value, what) {
457
+ if (!Number.isFinite(value)) throw new ExpressionError(`${what} has no result here`);
458
+ return Fraction.from(Number(value.toFixed(12)));
459
+ }
460
+ /** The square root of a whole number ≥ 0, if it is whole. */
461
+ function wholeRoot(n) {
462
+ if (n < 2n) return n;
463
+ let x = BigInt(Math.floor(Math.sqrt(Number(n))));
464
+ while (x * x > n) x--;
465
+ while ((x + 1n) * (x + 1n) <= n) x++;
466
+ return x * x === n ? x : void 0;
467
+ }
468
+ function sqrt(x) {
469
+ if (x.n < 0n) throw new ExpressionError(`sqrt needs a number ≥ 0, not ${x.toNumber()}`);
470
+ const n = wholeRoot(x.n);
471
+ const d = wholeRoot(x.d);
472
+ return n !== void 0 && d !== void 0 ? Fraction.of(n, d) : approximate(Math.sqrt(x.toNumber()), "sqrt");
473
+ }
474
+ /** The largest exponent of pow: beyond it, a number has more digits than any exercise shows. */
475
+ const MAX_EXPONENT = 1e3;
476
+ function pow(x, n) {
477
+ if (n.isInteger()) {
478
+ const e = Number(n.n);
479
+ if (Math.abs(e) > MAX_EXPONENT) throw new ExpressionError(`pow takes an exponent from -${MAX_EXPONENT} to ${MAX_EXPONENT}`);
480
+ if (x.n === 0n && e < 0) throw new ExpressionError("pow(0, n) needs n ≥ 0: division by zero");
481
+ const power = Fraction.of(x.n ** BigInt(Math.abs(e)), x.d ** BigInt(Math.abs(e)));
482
+ return e < 0 ? Fraction.of(1).div(power) : power;
483
+ }
484
+ if (x.n < 0n) throw new ExpressionError("pow of a negative number needs a whole exponent");
485
+ return approximate(x.toNumber() ** n.toNumber(), "pow");
486
+ }
487
+ /** The angle `x` in degrees, brought into 0 ≤ x < 360 exactly. */
488
+ function turn(x) {
489
+ const full = Fraction.of(360);
490
+ const quotient = x.div(full);
491
+ let floor = quotient.n / quotient.d;
492
+ if (quotient.n < 0n && quotient.n % quotient.d !== 0n) floor -= 1n;
493
+ return x.sub(full.mul(Fraction.of(floor)));
494
+ }
495
+ /** The sine of the angles 0°, 30°, 90°, … whose sine is rational, in degrees. */
496
+ const EXACT_SINE = /* @__PURE__ */ new Map([
497
+ [0, Fraction.of(0)],
498
+ [30, Fraction.of(1, 2)],
499
+ [90, Fraction.of(1)],
500
+ [150, Fraction.of(1, 2)],
501
+ [180, Fraction.of(0)],
502
+ [210, Fraction.of(-1, 2)],
503
+ [270, Fraction.of(-1)],
504
+ [330, Fraction.of(-1, 2)]
505
+ ]);
506
+ const RADIANS = Math.PI / 180;
507
+ function sine(degrees) {
508
+ const angle = turn(degrees);
509
+ return (angle.isInteger() ? EXACT_SINE.get(Number(angle.n)) : void 0) ?? approximate(Math.sin(angle.toNumber() * RADIANS), "sin");
510
+ }
511
+ const cosine = (degrees) => sine(degrees.add(Fraction.of(90)));
512
+ function tangent(degrees) {
513
+ const angle = turn(degrees);
514
+ if (cosine(angle).n === 0n) throw new ExpressionError(`tan(${degrees.toNumber()}) has no value: the cosine of the angle is 0`);
515
+ if (angle.isInteger() && [
516
+ 45,
517
+ 135,
518
+ 225,
519
+ 315
520
+ ].includes(Number(angle.n))) return Fraction.of(Number(angle.n) % 180 === 45 ? 1 : -1);
521
+ const sin = sine(angle);
522
+ return sin.n === 0n ? sin : approximate(Math.tan(angle.toNumber() * RADIANS), "tan");
523
+ }
524
+ /** The inverse of sin, cos or tan in degrees: exact for the values of the angles of EXACT_SINE and of 45°. */
525
+ function inverse(fn, x) {
526
+ if (fn !== "atan" && (x.compare(Fraction.of(-1)) < 0 || x.compare(Fraction.of(1)) > 0)) throw new ExpressionError(`${fn} needs a number from -1 to 1, not ${x.toNumber()}`);
527
+ const known = (fn === "asin" ? {
528
+ "-1": -90,
529
+ "-1/2": -30,
530
+ "0": 0,
531
+ "1/2": 30,
532
+ "1": 90
533
+ } : fn === "acos" ? {
534
+ "-1": 180,
535
+ "-1/2": 120,
536
+ "0": 90,
537
+ "1/2": 60,
538
+ "1": 0
539
+ } : {
540
+ "-1": -45,
541
+ "0": 0,
542
+ "1": 45
543
+ })[x.toString()];
544
+ if (known !== void 0) return Fraction.of(known);
545
+ return approximate((fn === "asin" ? Math.asin(x.toNumber()) : fn === "acos" ? Math.acos(x.toNumber()) : Math.atan(x.toNumber())) / RADIANS, fn);
546
+ }
447
547
  function call(fn, args, env) {
448
548
  const value = (index) => evaluate(args[index], env);
449
549
  const field = () => args[1].name;
@@ -459,6 +559,14 @@ function call(fn, args, env) {
459
559
  return [...asList(value(0), "sortBy")].sort((a, b) => order(fieldOf(a, name), fieldOf(b, name), "sortBy"));
460
560
  }
461
561
  case "reverse": return [...asList(value(0), "reverse")].reverse();
562
+ case "sqrt": return sqrt(asNumber(value(0), "sqrt"));
563
+ case "pow": return pow(asNumber(value(0), "pow"), asNumber(value(1), "pow"));
564
+ case "sin": return sine(asNumber(value(0), "sin"));
565
+ case "cos": return cosine(asNumber(value(0), "cos"));
566
+ case "tan": return tangent(asNumber(value(0), "tan"));
567
+ case "asin":
568
+ case "acos":
569
+ case "atan": return inverse(fn, asNumber(value(0), fn));
462
570
  default: throw new ExpressionError(`${fn} is not a function of the expression language`);
463
571
  }
464
572
  }
@@ -553,6 +661,10 @@ const PATH_ONLY = new RegExp(String.raw`^${PATH}$`);
553
661
  const SPACED = new RegExp(String.raw`\{\{\s*(${PATH})\s*\}\}`, "g");
554
662
  /** A formula ($…$) or a run of prose, in a template. */
555
663
  const SEGMENT = /\$((?:\\.|[^\\$\n])+?)\$|([^$]+)/g;
664
+ /** A chemical formula whose whole argument is one placeholder: \ce{{{e.gl}}}. */
665
+ const CHEM = new RegExp(String.raw`\\ce\{\{\{(${PATH})\}\}\}`, "g");
666
+ /** The start of the token of a placeholder that is the whole argument of \ce: ⟦ce:e.gl⟧. */
667
+ const CHEM_TOKEN = "ce:";
556
668
  const formats = /* @__PURE__ */ new Map();
557
669
  /** A number as the page shows it: up to six decimals, no grouping (a learner would type 1500, not 1.500). */
558
670
  function formatNumber(value, lang, decimals) {
@@ -583,9 +695,61 @@ function answerLabel(spec, task, lang) {
583
695
  if (kind.kind === "number" && task.answer instanceof Fraction) return formatNumber(task.answer, lang, kind.money && !task.answer.isInteger() ? 2 : void 0);
584
696
  return label(task.answer, lang);
585
697
  }
586
- /** A template's placeholders as tokens: {{G}} becomes ⟦G⟧, inside a formula ($…$) {\text{⟦G⟧}}. */
698
+ /**
699
+ * A template's placeholders as tokens: {{G}} becomes ⟦G⟧, inside a formula ($…$) {\text{⟦G⟧}}; a chemical formula
700
+ * whose whole argument is a placeholder, \ce{{{e.gl}}}, becomes {\text{⟦ce:e.gl⟧}}, which fill() replaces by the
701
+ * formula the build set for the task's value (LanguageTemplates.chem).
702
+ */
587
703
  function tokenize(text) {
588
- return text.replace(SEGMENT, (whole, math, prose) => math !== void 0 ? `$${math.replace(PLACEHOLDER, (_placeholder, path) => `{\\text{⟦${path}⟧}}`)}$` : (prose ?? whole).replace(PLACEHOLDER, (_placeholder, path) => `⟦${path}⟧`));
704
+ return text.replace(SEGMENT, (whole, math, prose) => math !== void 0 ? `$${math.replace(CHEM, (_chem, path) => `{\\text{⟦${CHEM_TOKEN}${path}⟧}}`).replace(PLACEHOLDER, (_placeholder, path) => `{\\text{⟦${path}⟧}}`)}$` : (prose ?? whole).replace(PLACEHOLDER, (_placeholder, path) => `⟦${path}⟧`));
705
+ }
706
+ /** The formulas of a template ($…$), as written. */
707
+ const formulasIn = (text) => [...text.matchAll(SEGMENT)].flatMap((match) => match[1] === void 0 ? [] : [match[1]]);
708
+ /** The placeholders that are the whole argument of a \ce in a formula of a template (\ce{{{e.gl}}}), by their path. */
709
+ function chemPlaceholdersIn(text) {
710
+ return formulasIn(text).flatMap((math) => [...math.matchAll(CHEM)].map((match) => match[1] ?? ""));
711
+ }
712
+ /** The argument of each \ce and \pu of a formula, with its command; braces balanced. */
713
+ function mhchemArguments(math) {
714
+ const found = [];
715
+ for (const match of math.matchAll(/\\(ce|pu)\s*\{/g)) {
716
+ const start = (match.index ?? 0) + match[0].length;
717
+ let depth = 1;
718
+ for (let at = start; at < math.length; at++) if (math[at] === "{") depth++;
719
+ else if (math[at] === "}" && --depth === 0) {
720
+ found.push({
721
+ command: `\\${match[1] ?? ""}`,
722
+ argument: math.slice(start, at)
723
+ });
724
+ break;
725
+ }
726
+ }
727
+ return found;
728
+ }
729
+ const WHOLE_PLACEHOLDER = new RegExp(String.raw`^\{\{${PATH}\}\}$`);
730
+ /**
731
+ * The problems of the placeholders in \ce and \pu of one template. A placeholder in \ce is its whole argument,
732
+ * \ce{{{e.gl}}}, drawn from a finite list (oneOf, or pick: 1 from a list or a data file), so that the build can set
733
+ * the formula of every value; \pu takes none.
734
+ */
735
+ function chemProblems(text, given) {
736
+ const problems = [];
737
+ for (const math of formulasIn(text)) for (const { command, argument } of mhchemArguments(math)) {
738
+ if (placeholdersIn(argument).length === 0) continue;
739
+ if (command === "\\pu") problems.push(`\\pu{${argument}} has a placeholder inside; \\pu takes none: write the number before it, e.g. {{m}}\\,\\pu{g}`);
740
+ else if (!WHOLE_PLACEHOLDER.test(argument)) problems.push(`\\ce{${argument}} has a placeholder among other text; in \\ce a placeholder is the whole argument, \\ce{{{e.gl}}}: put the whole formula into one field of the data (gl: "2H2 + O2 -> 2H2O")`);
741
+ }
742
+ for (const path of chemPlaceholdersIn(text)) {
743
+ const root = path.split(".")[0] ?? "";
744
+ if (root === "answer") {
745
+ problems.push("\\ce{{{answer}}} is not possible: the build sets only the formulas of values drawn from a list; show the formula of a value of given");
746
+ continue;
747
+ }
748
+ const arbitrary = given[root];
749
+ if (arbitrary === void 0) continue;
750
+ if (arbitrary.oneOf === void 0 && !(arbitrary.from !== void 0 && arbitrary.pick === 1)) problems.push(`\\ce{{{${path}}}} needs one value from a list: draw ${root} with oneOf, or with pick: 1 from a list or a data file, so that the build can set the formula of each value`);
751
+ }
752
+ return problems;
589
753
  }
590
754
  /** The placeholders of a template, by their path (G, e.name, answer). */
591
755
  function placeholdersIn(text) {
@@ -647,7 +811,7 @@ function writtenTemplates(spec) {
647
811
  function templateProblems(spec) {
648
812
  const declared = [...Object.keys(spec.given), "answer"];
649
813
  return writtenTemplates(spec).flatMap(({ path, lang, text }) => {
650
- return placeholderProblems(text, new Set(declared)).map((message) => ({
814
+ return [...placeholderProblems(text, new Set(declared)), ...chemProblems(text, spec.given)].map((message) => ({
651
815
  path,
652
816
  ...lang === void 0 ? {} : { lang },
653
817
  message
@@ -690,15 +854,23 @@ function placeholderValue(spec, task, path, lang) {
690
854
  }
691
855
  }
692
856
  /** A template with the values of a task: HTML (escaped, numbers in formulas as <mn>) or plain text. */
693
- function fill(template, spec, task, lang, mode = "html") {
694
- return (mode === "html" ? template.replace(/<mtext>⟦([^⟧]+)⟧<\/mtext>/g, (_whole, path) => {
857
+ function fill(template, spec, task, lang, mode = "html", chem = {}) {
858
+ return (mode === "html" ? template.replace(/<mtext>⟦([^⟧]+)⟧<\/mtext>/g, (_whole, token) => {
859
+ if (token.startsWith(CHEM_TOKEN)) {
860
+ const path = token.slice(3);
861
+ const value = placeholderValue(spec, task, path, lang);
862
+ if (value === void 0) return `<mtext>{{${escape(path)}}}</mtext>`;
863
+ return chem[value.text] ?? `<mtext>${escape(value.text)}</mtext>`;
864
+ }
865
+ const path = token;
695
866
  const value = placeholderValue(spec, task, path, lang);
696
867
  if (value === void 0) return `<mtext>{{${escape(path)}}}</mtext>`;
697
868
  if (value.number === void 0) return `<mtext>${escape(value.text)}</mtext>`;
698
869
  const negative = value.number.compare(Fraction.of(0)) < 0;
699
870
  const digits = escape(negative ? value.text.replace(/^[-−]/, "") : value.text);
700
871
  return negative ? `<mo>−</mo><mn>${digits}</mn>` : `<mn>${digits}</mn>`;
701
- }) : template).replace(/⟦([^⟧]+)⟧/g, (_whole, path) => {
872
+ }) : template).replace(/⟦([^⟧]+)⟧/g, (_whole, token) => {
873
+ const path = token.startsWith(CHEM_TOKEN) ? token.slice(3) : token;
702
874
  const value = placeholderValue(spec, task, path, lang);
703
875
  if (value === void 0) return `{{${path}}}`;
704
876
  return mode === "html" ? escape(value.text) : value.text;
@@ -711,16 +883,18 @@ const templatesIn = (templates, lang) => templates[lang] ?? templates["de"] ?? {
711
883
  };
712
884
  /** The task's text as HTML, in `lang`. */
713
885
  function taskText(spec, task, lang, templates) {
714
- return fill(templatesIn(templates, lang).task, spec, task, lang);
886
+ const { task: text, chem } = templatesIn(templates, lang);
887
+ return fill(text, spec, task, lang, "html", chem);
715
888
  }
716
889
  /** The steps of the solution as HTML, in `lang`. */
717
890
  function stepsOf(spec, task, lang, templates) {
718
- return templatesIn(templates, lang).steps.map((step) => fill(step, spec, task, lang));
891
+ const { steps, chem } = templatesIn(templates, lang);
892
+ return steps.map((step) => fill(step, spec, task, lang, "html", chem));
719
893
  }
720
894
  /** The hint as HTML, in `lang`, if the exercise has one. */
721
895
  function hintOf(spec, task, lang, templates) {
722
- const hint = templatesIn(templates, lang).hint;
723
- return hint === void 0 ? void 0 : fill(hint, spec, task, lang);
896
+ const { hint, chem } = templatesIn(templates, lang);
897
+ return hint === void 0 ? void 0 : fill(hint, spec, task, lang, "html", chem);
724
898
  }
725
899
  /** The text of HTML, as a learner reads it: tags left out, entities resolved, white space folded. */
726
900
  function plainText(html) {
@@ -1153,7 +1327,11 @@ const MESSAGES = {
1153
1327
  openTaskLocked: "Die Lösung öffnet sich nach deiner Antwort.",
1154
1328
  resultPartly: "teilweise richtig ({n} von {total} Punkten, es fehlen {missed})",
1155
1329
  resultPhoto: "Antwort als Foto",
1156
- resultAnswer: "Antwort „{input}“"
1330
+ resultAnswer: "Antwort „{input}“",
1331
+ recap: "Wiederholung",
1332
+ recapText: "Diese Seite wiederholt etwas, das du schon können solltest. Üben kannst du es in anderen Apps: {link}",
1333
+ recapLink: "Baustein auf der Lernlandkarte von lernapps.net",
1334
+ recapNote: "Wiederholung"
1157
1335
  },
1158
1336
  en: {
1159
1337
  language: "English",
@@ -1353,7 +1531,11 @@ const MESSAGES = {
1353
1531
  openTaskLocked: "The solution opens after your answer.",
1354
1532
  resultPartly: "partly right ({n} of {total} points, missing {missed})",
1355
1533
  resultPhoto: "answer as a photo",
1356
- resultAnswer: "answer “{input}”"
1534
+ resultAnswer: "answer “{input}”",
1535
+ recap: "Recap",
1536
+ recapText: "This page goes over something you should already know. You can practise it in other apps: {link}",
1537
+ recapLink: "building block on the learning map of lernapps.net",
1538
+ recapNote: "recap"
1357
1539
  }
1358
1540
  };
1359
1541
  /** The text of `key` in `lang`, with its placeholders filled. */
@@ -1461,6 +1643,11 @@ function* allBlocks(blocks) {
1461
1643
  if (block.type === "section" || block.type === "example" || block.type === "note") yield* allBlocks(block.blocks);
1462
1644
  }
1463
1645
  }
1646
+ /** The page of a competency on the learning map of lernapps.net, from <subject>/<competency> (a recap page's recap). */
1647
+ function mapUrl(recap) {
1648
+ const [subject = "", competency = ""] = recap.split("/");
1649
+ return `https://lernapps.net/apps/map/${subject}/c/${competency}/`;
1650
+ }
1464
1651
  /** Whether a block is an exercise. */
1465
1652
  const isExercise = (block) => block.type === "generated" || block.type === "open-task" || [
1466
1653
  "single-choice",
@@ -1532,9 +1719,10 @@ function assessments(site) {
1532
1719
  };
1533
1720
  const steps = site.chapters.flatMap((chapter) => chapter.selfAssessment?.pages ?? []);
1534
1721
  if (steps.length === 0) return [];
1535
- const all = site.chapters.flatMap((chapter) => chapter.pages);
1722
+ const asked = (pages) => pages.filter((page) => page.recap === void 0);
1723
+ const all = asked(site.chapters.flatMap((chapter) => chapter.pages));
1536
1724
  const own = site.chapters.length === 1 ? [] : site.chapters;
1537
- return [of({ pages: steps }, all), ...own.flatMap((chapter) => chapter.selfAssessment === void 0 ? [] : [of(chapter.selfAssessment, chapter.pages, chapter)])];
1725
+ return [of({ pages: steps }, all), ...own.flatMap((chapter) => chapter.selfAssessment === void 0 ? [] : [of(chapter.selfAssessment, asked(chapter.pages), chapter)])];
1538
1726
  }
1539
1727
  /** The self-assessment of the app (no chapter) or of the chapter `chapter`, if it has one. */
1540
1728
  const assessmentOf = (site, chapter) => assessments(site).find((assessment) => assessment.chapter?.id === chapter);
@@ -1693,4 +1881,4 @@ function linkLines(root, chapters, whole, lang) {
1693
1881
  }
1694
1882
  /** The title of a content page in `lang`, on one line. */
1695
1883
  const titleOf = (page, lang) => oneLine(inLanguage(page.title, lang));
1696
- export { parse as $, answerFor as A, label as B, watchAddress as C, generate as D, compiled as E, questionFor as F, taskText as G, placeholdersIn as H, taskKey as I, tokenize as J, templateProblems as K, answerLabel as L, exampleGiving as M, mistakeOf as N, holds as O, pairValues as P, evaluate as Q, fill as R, videoLine as S, StarvedError as T, plainText as U, placeholderValue as V, stepsOf as W, kindOf as X, unusedVariables as Y, ExpressionError as Z, termId as _, assessments as a, renderVideo as b, isStoredResult as c, parseRef as d, same as et, resultQuery as f, isExercise as g, allBlocks as h, assessmentPrompt as i, diagnose as j, values as k, linkLines as l, titleOf as m, BAND_MESSAGES as n, Fraction as nt, drawsTasks as o, stepHint as p, templatesIn as q, assessmentOf as r, exerciseOf as s, ASSESSMENT as t, variables as tt, pageResults as u, timelineEvents as v, message as w, startVideos as x, embedAddress as y, hintOf as z };
1884
+ export { writtenTemplates as $, items as A, chemPlaceholdersIn as B, videoLine as C, compiled as D, StarvedError as E, mistakeOf as F, placeholdersIn as G, hintOf as H, pairValues as I, taskText as J, plainText as K, questionFor as L, answerFor as M, diagnose as N, generate as O, exampleGiving as P, unusedVariables as Q, taskKey as R, startVideos as S, message as T, label as U, fill as V, placeholderValue as W, templatesIn as X, templateProblems as Y, tokenize as Z, mapUrl as _, assessments as a, variables as at, embedAddress as b, isStoredResult as c, parseRef as d, kindOf as et, resultQuery as f, isExercise as g, allBlocks as h, assessmentPrompt as i, same as it, values as j, holds as k, linkLines as l, titleOf as m, BAND_MESSAGES as n, evaluate as nt, drawsTasks as o, Fraction as ot, stepHint as p, stepsOf as q, assessmentOf as r, parse as rt, exerciseOf as s, ASSESSMENT as t, ExpressionError as tt, pageResults as u, termId as v, watchAddress as w, renderVideo as x, timelineEvents as y, answerLabel as z };
@@ -1,7 +1,7 @@
1
1
  import { i as inLanguage, l as textsIn, n as escapeText, s as localeOf, t as escape } from "./html-mffkHGHh.mjs";
2
2
  import { n as markerOf } from "./block-BK_ohsH1.mjs";
3
- import { c as renderMarkdown, n as isUrl, s as renderInline, u as renderFormula } from "./check-CteIFN96.mjs";
4
- import "./validate-Bxy9NfB8.mjs";
3
+ import { c as renderMarkdown, n as isUrl, s as renderInline, u as renderFormula } from "./check-BxZOZPic.mjs";
4
+ import "./validate-CfFykN0M.mjs";
5
5
  import hljs from "highlight.js/lib/core";
6
6
  import bash from "highlight.js/lib/languages/bash";
7
7
  import c from "highlight.js/lib/languages/c";
package/dist/blocks.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  import { i as materialIn, n as markerOf, r as materialImages, t as MATERIAL_TYPES } from "./block-BK_ohsH1.mjs";
2
- import { a as highlight, i as hundredGrid, n as renderMaterial, r as bar, t as renderCredit } from "./blocks-CxtCPAa5.mjs";
3
- import { n as checkMaterial, t as MATERIAL_DEFINITIONS } from "./validate-Bxy9NfB8.mjs";
2
+ import { a as highlight, i as hundredGrid, n as renderMaterial, r as bar, t as renderCredit } from "./blocks-D4SSGKuL.mjs";
3
+ import { n as checkMaterial, t as MATERIAL_DEFINITIONS } from "./validate-CfFykN0M.mjs";
4
4
  export { MATERIAL_DEFINITIONS, MATERIAL_TYPES, bar, checkMaterial, highlight, hundredGrid, markerOf, materialImages, materialIn, renderCredit, renderMaterial };
@@ -4,7 +4,8 @@ import { Marked } from "marked";
4
4
  // runtime/shared/text/formula.ts
5
5
  globalThis.temml = temml;
6
6
  await import("temml/contrib/mhchem/mhchem.js");
7
- const allowed = /* @__PURE__ */ new Set([
7
+ /** The commands a formula may use (explainer.v1.schema.json, $defs/latex), besides ^, _, braces and parentheses. */
8
+ const COMMANDS = [
8
9
  "\\frac",
9
10
  "\\cdot",
10
11
  "\\times",
@@ -21,8 +22,61 @@ const allowed = /* @__PURE__ */ new Set([
21
22
  "\\{",
22
23
  "\\}",
23
24
  "\\ce",
24
- "\\pu"
25
- ]);
25
+ "\\pu",
26
+ "\\ ",
27
+ "\\quad",
28
+ "\\qquad",
29
+ "\\Rightarrow",
30
+ "\\Leftrightarrow",
31
+ "\\left",
32
+ "\\right",
33
+ "\\sin",
34
+ "\\cos",
35
+ "\\tan",
36
+ "\\arcsin",
37
+ "\\arccos",
38
+ "\\arctan",
39
+ "\\circ",
40
+ ...[
41
+ "alpha",
42
+ "beta",
43
+ "gamma",
44
+ "delta",
45
+ "varepsilon",
46
+ "epsilon",
47
+ "zeta",
48
+ "eta",
49
+ "theta",
50
+ "vartheta",
51
+ "iota",
52
+ "kappa",
53
+ "lambda",
54
+ "mu",
55
+ "nu",
56
+ "xi",
57
+ "pi",
58
+ "rho",
59
+ "sigma",
60
+ "tau",
61
+ "upsilon",
62
+ "varphi",
63
+ "phi",
64
+ "chi",
65
+ "psi",
66
+ "omega",
67
+ "Gamma",
68
+ "Delta",
69
+ "Theta",
70
+ "Lambda",
71
+ "Xi",
72
+ "Pi",
73
+ "Sigma",
74
+ "Phi",
75
+ "Psi",
76
+ "Omega"
77
+ ].map((letter) => `\\${letter}`)
78
+ ];
79
+ const allowed = new Set(COMMANDS);
26
80
  /** The commands of `tex` outside the arguments of \ce and \pu, which mhchem reads in its own notation. */
27
81
  function commands(tex) {
28
82
  const found = [];
@@ -42,7 +96,7 @@ function commands(tex) {
42
96
  /** What is wrong with the formula `tex`, or undefined when it is in the command list and parses. */
43
97
  function formulaProblem(tex) {
44
98
  const unknown = commands(tex).find((command) => !allowed.has(command));
45
- if (unknown !== void 0) return `${unknown} is not in the formula command list; write the formula with the commands the schema lists ($defs/latex)`;
99
+ if (unknown !== void 0) return `${unknown} is not in the formula command list; use only these commands, besides ^, _, braces and parentheses: ${COMMANDS.map((command) => command === "\\ " ? "\\ (a space)" : command).join(", ")}`;
46
100
  try {
47
101
  temml.renderToString(tex, { throwOnError: true });
48
102
  return;
@@ -1,4 +1,4 @@
1
- import { t as checkText } from "./check-CteIFN96.mjs";
1
+ import { t as checkText } from "./check-BxZOZPic.mjs";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { LineCounter, isNode, parseDocument } from "yaml";
@@ -1,10 +1,10 @@
1
- import { a as routes, d as templatesOf, f as workedTasks, l as renderExercise, n as loadSite, s as dataOf, u as taskSeeds } from "./load-RosvaPZf.mjs";
1
+ import { a as routes, d as templatesOf, f as workedTasks, l as renderExercise, n as loadSite, s as dataOf, u as taskSeeds } from "./load--ovXqbNE.mjs";
2
2
  import { i as inLanguage } from "./html-mffkHGHh.mjs";
3
- import { a as inspect, c as renderMarkdown, l as renderWithFormulas } from "./check-CteIFN96.mjs";
3
+ import { a as inspect, c as renderMarkdown, l as renderWithFormulas } from "./check-BxZOZPic.mjs";
4
4
  import { C as wrongAnswer, _ as questionIn, b as rightAnswer, f as missesRight, g as questionId, i as questionMessagesIn, n as answerText, w as wrongReasons } from "./render-GS1Htlu8.mjs";
5
5
  import { a as giveAnswer, i as expectMaterial, r as expectFeedbackInView, t as answerQuestion } from "./e2e-CU0n_unV.mjs";
6
- import { A as answerFor, C as watchAddress, D as generate, F as questionFor, G as taskText, I as taskKey, R as fill, U as plainText, X as kindOf, a as assessments, j as diagnose, m as titleOf, v as timelineEvents, w as message, y as embedAddress } from "./assessment-Dy359O1O.mjs";
7
- import { n as answerShown } from "./result-D_mmCVwN.mjs";
6
+ import { J as taskText, K as plainText, L as questionFor, M as answerFor, N as diagnose, O as generate, R as taskKey, T as message, V as fill, a as assessments, b as embedAddress, et as kindOf, m as titleOf, w as watchAddress, y as timelineEvents } from "./assessment-DaKsXUdj.mjs";
7
+ import { n as answerShown } from "./result-DphIWHDF.mjs";
8
8
  import { n as ASSESSMENT_TASK_EVENT, p as taskNumber, u as nextTaskNumber } from "./tasks-DADUYy52.mjs";
9
9
  import { expect, test } from "playwright/test";
10
10
  // runtime/explainer/outline.ts
@@ -94,7 +94,7 @@ function walk(site, blocks, lang, level, anchor, into) {
94
94
  }
95
95
  case "generated": {
96
96
  const data = dataOf(block, site.data);
97
- const templates = templatesOf(block, [lang]);
97
+ const templates = templatesOf(block, [lang], data);
98
98
  const worked = workedTasks(block, data, templates, lang);
99
99
  for (const item of worked) for (const html of [
100
100
  item.task,