@lernapps/app-templates 0.1.0 → 0.1.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 (39) hide show
  1. package/dist/{assessment-By2GGTJC.mjs → assessment-Dy359O1O.mjs} +1 -1
  2. package/dist/{blocks-BmRrD7UY.mjs → blocks-CxtCPAa5.mjs} +2 -2
  3. package/dist/blocks.mjs +2 -2
  4. package/dist/{check-BkEl-m-F.mjs → check-CteIFN96.mjs} +1 -1
  5. package/dist/{content-cYBPYg_J.mjs → content-DO6oEJxj.mjs} +1 -1
  6. package/dist/{e2e-BiMI-pCL.mjs → e2e-CU0n_unV.mjs} +1 -1
  7. package/dist/{engine-BqF2Vffn.mjs → engine-CArxqizW.mjs} +1 -1
  8. package/dist/explainer-e2e.mjs +27 -13
  9. package/dist/explainer-plugin.mjs +6 -5
  10. package/dist/explainer.mjs +4 -4
  11. package/dist/interactive-e2e.mjs +1 -1
  12. package/dist/interactive-plugin.mjs +2 -2
  13. package/dist/{load-C-0hUaSC.mjs → load-RosvaPZf.mjs} +37 -8
  14. package/dist/questions-e2e.mjs +1 -1
  15. package/dist/questions-engine.mjs +2 -2
  16. package/dist/questions.mjs +2 -2
  17. package/dist/{quiz-BKc8JYtC.mjs → quiz-DnW-xtCh.mjs} +1 -1
  18. package/dist/quiz-e2e.mjs +4 -4
  19. package/dist/quiz-plugin.mjs +4 -4
  20. package/dist/quiz.mjs +3 -3
  21. package/dist/{render-BL6fV_NI.mjs → render-D5kwkNt1.mjs} +2 -2
  22. package/dist/{render-DpRO_RKm.mjs → render-DqHOJyHK.mjs} +3 -3
  23. package/dist/{render-BJey0bwl.mjs → render-GS1Htlu8.mjs} +9 -4
  24. package/dist/{result-_eQcamoz.mjs → result-D_mmCVwN.mjs} +2 -2
  25. package/dist/{validate-COaZso7u.mjs → validate-BDwL7Su3.mjs} +6 -2
  26. package/dist/{validate-CxtdeQ5S.mjs → validate-Bxy9NfB8.mjs} +1 -1
  27. package/explainer/package.json +1 -1
  28. package/interactive/package.json +1 -1
  29. package/package.json +2 -1
  30. package/quiz/package.json +1 -1
  31. package/runtime/explainer/assessment-page.ts +2 -0
  32. package/runtime/explainer/e2e.ts +39 -25
  33. package/runtime/explainer/exercises.ts +2 -0
  34. package/runtime/explainer/outline.ts +8 -1
  35. package/runtime/shared/questions/question.ts +3 -0
  36. package/runtime/shared/questions/question.v1.schema.json +4 -0
  37. package/runtime/shared/questions/render.ts +14 -6
  38. package/runtime/shared/text/markdown.ts +1 -1
  39. package/runtime/shared/text/speech.ts +39 -0
@@ -1,5 +1,5 @@
1
1
  import { i as inLanguage, l as textsIn, s as localeOf, t as escape } from "./html-mffkHGHh.mjs";
2
- import { S as shuffled, c as hash, m as parseNumber, u as isCorrect, y as random } from "./render-BJey0bwl.mjs";
2
+ import { S as shuffled, c as hash, m as parseNumber, u as isCorrect, y as random } from "./render-GS1Htlu8.mjs";
3
3
  import { createStorage } from "@lernapps/tooling/storage";
4
4
  // runtime/explainer/fraction.ts
5
5
  const abs = (n) => n < 0n ? -n : n;
@@ -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 { l as renderFormula, n as isUrl, o as renderInline, s as renderMarkdown } from "./check-BkEl-m-F.mjs";
4
- import "./validate-CxtdeQ5S.mjs";
3
+ import { c as renderMarkdown, n as isUrl, s as renderInline, u as renderFormula } from "./check-CteIFN96.mjs";
4
+ import "./validate-Bxy9NfB8.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-BmRrD7UY.mjs";
3
- import { n as checkMaterial, t as MATERIAL_DEFINITIONS } from "./validate-CxtdeQ5S.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";
4
4
  export { MATERIAL_DEFINITIONS, MATERIAL_TYPES, bar, checkMaterial, highlight, hundredGrid, markerOf, materialImages, materialIn, renderCredit, renderMaterial };
@@ -251,4 +251,4 @@ function checkText(value, kind, languages, declaredIn) {
251
251
  links
252
252
  };
253
253
  }
254
- export { plain as a, renderWithFormulas as c, inspect as i, renderFormula as l, isUrl as n, renderInline as o, formulaProblems as r, renderMarkdown as s, checkText as t };
254
+ export { inspect as a, renderMarkdown as c, formulaProblems as i, renderWithFormulas as l, isUrl as n, plain as o, FORMULA as r, renderInline as s, checkText as t, renderFormula as u };
@@ -1,4 +1,4 @@
1
- import { t as checkText } from "./check-BkEl-m-F.mjs";
1
+ import { t as checkText } from "./check-CteIFN96.mjs";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { LineCounter, isNode, parseDocument } from "yaml";
@@ -1,5 +1,5 @@
1
1
  import { l as textsIn } from "./html-mffkHGHh.mjs";
2
- import { g as questionId, t as QUESTION_MESSAGES } from "./render-BJey0bwl.mjs";
2
+ import { g as questionId, t as QUESTION_MESSAGES } from "./render-GS1Htlu8.mjs";
3
3
  import { expect } from "playwright/test";
4
4
  // runtime/shared/questions/e2e.ts
5
5
  /** The question's own texts in `lang`. */
@@ -1,5 +1,5 @@
1
1
  import { l as textsIn } from "./html-mffkHGHh.mjs";
2
- import { S as shuffled, c as hash, f as missesRight, l as isComplete, t as QUESTION_MESSAGES, u as isCorrect, w as wrongReasons } from "./render-BJey0bwl.mjs";
2
+ import { S as shuffled, c as hash, f as missesRight, l as isComplete, t as QUESTION_MESSAGES, u as isCorrect, w as wrongReasons } from "./render-GS1Htlu8.mjs";
3
3
  import { translator } from "@lernapps/tooling/i18n";
4
4
  import { moveFocus } from "@lernapps/tooling/a11y";
5
5
  // runtime/shared/questions/engine.ts
@@ -1,13 +1,18 @@
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-C-0hUaSC.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-RosvaPZf.mjs";
2
2
  import { i as inLanguage } from "./html-mffkHGHh.mjs";
3
- import { i as inspect, s as renderMarkdown } from "./check-BkEl-m-F.mjs";
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-BJey0bwl.mjs";
5
- import { a as giveAnswer, i as expectMaterial, r as expectFeedbackInView, t as answerQuestion } from "./e2e-BiMI-pCL.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-By2GGTJC.mjs";
7
- import { n as answerShown } from "./result-_eQcamoz.mjs";
3
+ import { a as inspect, c as renderMarkdown, l as renderWithFormulas } from "./check-CteIFN96.mjs";
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
+ 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";
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
11
+ /**
12
+ * A learner text of a question as the page shows it, as text: its formulas $…$ rendered (a formula has no space the
13
+ * tests could rely on in a browser's text, see squash in e2e.ts), the rest as written.
14
+ */
15
+ const shownText = (text) => plainText(renderWithFormulas(text));
11
16
  /** The headings, sections, formulas and figures of `blocks` in `lang`, under a heading of level `level`. */
12
17
  function walk(site, blocks, lang, level, anchor, into) {
13
18
  const text = (value) => inLanguage(value, lang);
@@ -142,7 +147,7 @@ function walk(site, blocks, lang, level, anchor, into) {
142
147
  id: block.id,
143
148
  type: block.type,
144
149
  question,
145
- answers: question.type === "single-choice" || question.type === "multiple-choice" ? question.options.filter((option) => option.correct).map((option) => option.text) : [plainText(answerText(question, lang))]
150
+ answers: question.type === "single-choice" || question.type === "multiple-choice" ? question.options.filter((option) => option.correct).map((option) => shownText(option.text)) : [plainText(answerText(question, lang))]
146
151
  });
147
152
  }
148
153
  }
@@ -279,6 +284,11 @@ const BAND_WORDS = {
279
284
  read: "assessmentRead",
280
285
  unasked: "assessmentUnasked"
281
286
  };
287
+ /**
288
+ * `text` without space. A browser's text of a formula has space between its parts ("2 7" for 2⁷) where the content's
289
+ * has none, so texts with formulas are compared without it (in the order of their characters, which is what counts).
290
+ */
291
+ const squash = (text) => text.replace(/\s+/g, "");
282
292
  /** The task number of the deep link the tests open a generated exercise with. */
283
293
  const SEED = 7;
284
294
  /**
@@ -290,7 +300,7 @@ async function expectExercises(page, item) {
290
300
  const main = page.locator("main");
291
301
  const expected = item.exercises.map((exercise) => exercise.kind === "question" ? {
292
302
  id: exercise.id,
293
- texts: [exercise.question.text],
303
+ texts: [shownText(exercise.question.text)],
294
304
  answers: [exercise.answers]
295
305
  } : exercise.kind === "open" ? {
296
306
  id: exercise.id,
@@ -301,7 +311,7 @@ async function expectExercises(page, item) {
301
311
  texts: exercise.worked.map((worked) => worked.task),
302
312
  answers: exercise.worked.map((worked) => [worked.answer])
303
313
  });
304
- const read = () => main.locator(".explainer-exercise").evaluateAll((elements) => elements.map((element) => {
314
+ const read = async () => (await main.locator(".explainer-exercise").evaluateAll((elements) => elements.map((element) => {
305
315
  const fold = (text) => (text ?? "").replace(/\s+/g, " ").trim();
306
316
  const own = element.classList.contains("explainer-question") ? [...element.querySelectorAll("legend")] : element.classList.contains("explainer-open-task") ? [...element.querySelectorAll(":scope > .explainer-open-task-text")] : [...element.querySelectorAll(".explainer-worked-task > .explainer-task")];
307
317
  const solutions = [...element.querySelectorAll("details.explainer-solution")];
@@ -310,6 +320,10 @@ async function expectExercises(page, item) {
310
320
  texts: own.map((text) => fold(text.textContent)),
311
321
  shown: solutions.map((details) => details.open ? fold(details.innerText) : "")
312
322
  };
323
+ }))).map((item) => ({
324
+ ...item,
325
+ texts: item.texts.map(squash),
326
+ shown: item.shown.map(squash)
313
327
  }));
314
328
  const before = await read();
315
329
  expect(before.map(({ id, texts }) => ({
@@ -317,12 +331,12 @@ async function expectExercises(page, item) {
317
331
  texts
318
332
  })), `${item.url}: each exercise with its text`).toEqual(expected.map(({ id, texts }) => ({
319
333
  id,
320
- texts
334
+ texts: texts.map(squash)
321
335
  })));
322
336
  await main.locator(".explainer-exercise details.explainer-solution > summary").evaluateAll((summaries) => summaries.forEach((summary) => summary.click()));
323
337
  const after = await read();
324
338
  for (const [index, exercise] of expected.entries()) exercise.answers.forEach((answers, at) => {
325
- for (const answer of answers) expect(after[index]?.shown[at] ?? "", `${item.url}: ${exercise.id}, Lösung ${at + 1} shows ${answer}`).toContain(answer);
339
+ for (const answer of answers) expect(after[index]?.shown[at] ?? "", `${item.url}: ${exercise.id}, Lösung ${at + 1} shows ${answer}`).toContain(squash(answer));
326
340
  });
327
341
  }
328
342
  /** Without JavaScript: each component's nojs text is visible, not empty and as in the content; its island is hidden. */
@@ -535,7 +549,7 @@ const feedback = (page, id) => page.locator(`[id="${id}"] .question-feedback`);
535
549
  function reasonFor(question, answer) {
536
550
  const quiz = questionMessagesIn("de");
537
551
  const [first] = wrongReasons(question, answer);
538
- if (first !== void 0) return first.option === void 0 ? first.text : `${first.option}: ${first.text}`;
552
+ if (first !== void 0) return first.option === void 0 ? shownText(first.text) : `${shownText(first.option)}: ${shownText(first.text)}`;
539
553
  return missesRight(question, answer) ? quiz.missingRight : quiz.lookAgain;
540
554
  }
541
555
  /**
@@ -545,7 +559,7 @@ function reasonFor(question, answer) {
545
559
  async function wrongThenSolution(page, exercise, question, reason) {
546
560
  const item = page.locator(`[id="${questionId(question)}"]`);
547
561
  await expect(feedback(page, questionId(question))).toContainText(questionMessagesIn("de").wrong);
548
- await expect(item.locator(".question-diagnosis").first(), `${exercise}: the reason of the wrong answer`).toHaveText(reason);
562
+ await expect.poll(async () => squash(await item.locator(".question-diagnosis").first().textContent() ?? ""), `${exercise}: the reason of the wrong answer`).toBe(squash(reason));
549
563
  await expectFeedbackInView(page, question);
550
564
  await expect(item.locator(".question-reveal"), `${exercise}: no solution after a wrong answer`).toBeHidden();
551
565
  await expect(item.locator("input, select").first(), `${exercise}: another try`).toBeEnabled();
@@ -1,12 +1,12 @@
1
- import { _ as propertyProblems, a as routes, c as exerciseData, d as templatesOf, g as exampleProblems, h as describedProblems, i as hiddenChapter, l as renderExercise, m as TABLE_CONTRACT, n as loadSite, o as terms, p as SIZE_BUDGET, r as contentPages, s as dataOf, t as ContentError, u as taskSeeds, v as propsIn } from "./load-C-0hUaSC.mjs";
1
+ import { _ as exampleProblems, a as routes, c as exerciseData, d as templatesOf, g as describedProblems, h as TABLE_CONTRACT, i as hiddenChapter, l as renderExercise, m as SIZE_BUDGET, n as loadSite, o as terms, p as formulaAlternative, r as contentPages, s as dataOf, t as ContentError, u as taskSeeds, v as propertyProblems, y as propsIn } from "./load-RosvaPZf.mjs";
2
2
  import { i as inLanguage, s as localeOf, t as escape } from "./html-mffkHGHh.mjs";
3
3
  import { n as markerOf } from "./block-BK_ohsH1.mjs";
4
- import { n as renderMaterial } from "./blocks-BmRrD7UY.mjs";
5
- import { a as plain, c as renderWithFormulas, o as renderInline, s as renderMarkdown } from "./check-BkEl-m-F.mjs";
6
- import { _ as questionIn, a as renderQuestion, i as questionMessagesIn, n as answerText } from "./render-BJey0bwl.mjs";
4
+ import { n as renderMaterial } from "./blocks-CxtCPAa5.mjs";
5
+ import { c as renderMarkdown, l as renderWithFormulas, o as plain, s as renderInline } from "./check-CteIFN96.mjs";
6
+ import { _ as questionIn, a as renderQuestion, i as questionMessagesIn, n as answerText } from "./render-GS1Htlu8.mjs";
7
7
  import { n as isSizeless, t as imageType } from "./image-CspJVfyQ.mjs";
8
8
  import { a as pageFooter, c as withLanguage, i as notFoundPage, n as fromDepth, o as pageHeader, r as languageRoot, s as relative, t as alternateLinks } from "./pages-9RaaomoO.mjs";
9
- import { B as label, D as generate, F as questionFor, H as placeholdersIn, S as videoLine, U as plainText, V as placeholderValue, X as kindOf, _ as termId, a as assessments, b as renderVideo, h as allBlocks, m as titleOf, o as drawsTasks, p as stepHint, r as assessmentOf, v as timelineEvents, w as message } from "./assessment-By2GGTJC.mjs";
9
+ import { B as label, D as generate, F as questionFor, H as placeholdersIn, S as videoLine, U as plainText, V as placeholderValue, X as kindOf, _ as termId, a as assessments, b as renderVideo, h as allBlocks, m as titleOf, o as drawsTasks, p as stepHint, r as assessmentOf, v as timelineEvents, w as message } from "./assessment-Dy359O1O.mjs";
10
10
  import { i as NEW_TASK_ICON } from "./tasks-DADUYy52.mjs";
11
11
  import { n as NEEDS, o as languageName, t as LANGUAGES } from "./needs-BzEahJ6X.mjs";
12
12
  import { n as vueAlias, r as withServer, t as VUE_OPTIONS } from "./vue-DO1m6q1R.mjs";
@@ -909,6 +909,7 @@ function itemHtml(context, data, n, component = "") {
909
909
  return `${label}${renderQuestion(question, {
910
910
  lang,
911
911
  format: (text) => rendered[text] ?? renderWithFormulas(text),
912
+ describe: (text) => formulaAlternative(text, lang),
912
913
  ...question.material === void 0 ? {} : { materialHtml: renderMaterial(question.material, context.material) }
913
914
  })}`;
914
915
  }
@@ -1,8 +1,8 @@
1
1
  import { t as escape } from "./html-mffkHGHh.mjs";
2
- import { S as shuffled, a as renderQuestion, g as questionId, l as isComplete, u as isCorrect } from "./render-BJey0bwl.mjs";
3
- import { n as startQuestion, t as readAnswer } from "./engine-BqF2Vffn.mjs";
4
- import { D as generate, F as questionFor, G as taskText, N as mistakeOf, W as stepsOf, c as isStoredResult, f as resultQuery, i as assessmentPrompt, j as diagnose, l as linkLines, n as BAND_MESSAGES, u as pageResults, w as message, x as startVideos, z as hintOf } from "./assessment-By2GGTJC.mjs";
5
- import { i as sessionLines, n as answerShown, r as resultLine, t as Progress } from "./result-_eQcamoz.mjs";
2
+ import { S as shuffled, a as renderQuestion, g as questionId, l as isComplete, u as isCorrect } from "./render-GS1Htlu8.mjs";
3
+ import { n as startQuestion, t as readAnswer } from "./engine-CArxqizW.mjs";
4
+ import { D as generate, F as questionFor, G as taskText, N as mistakeOf, W as stepsOf, c as isStoredResult, f as resultQuery, i as assessmentPrompt, j as diagnose, l as linkLines, n as BAND_MESSAGES, u as pageResults, w as message, x as startVideos, z as hintOf } from "./assessment-Dy359O1O.mjs";
5
+ import { i as sessionLines, n as answerShown, r as resultLine, t as Progress } from "./result-D_mmCVwN.mjs";
6
6
  import { c as addressOf, d as numberFromAddress, f as randomTaskNumber, i as NEW_TASK_ICON, n as ASSESSMENT_TASK_EVENT, p as taskNumber, r as ASSESSMENT_UNPACKED_EVENT, s as SHOWN_EVENT, t as ASSESSMENT_ANSWER_EVENT, u as nextTaskNumber } from "./tasks-DADUYy52.mjs";
7
7
  import { a as isNeedsChoice, c as withNeeds, r as NEEDS_KEY, s as withLanguage } from "./needs-BzEahJ6X.mjs";
8
8
  import { moveFocus } from "@lernapps/tooling/a11y";
@@ -1,7 +1,7 @@
1
1
  import { i as inLanguage } from "./html-mffkHGHh.mjs";
2
2
  import { r as languageRoot } from "./pages-9RaaomoO.mjs";
3
3
  import { i as doingText, n as STATE_PROPERTY, o as inputsOf, s as mismatch, t as CLOCK_PROPERTY } from "./contract-PONpxJtd.mjs";
4
- import { i as message, o as loadInteractive, t as APP_PAGE } from "./render-BL6fV_NI.mjs";
4
+ import { i as message, o as loadInteractive, t as APP_PAGE } from "./render-D5kwkNt1.mjs";
5
5
  import { expect, test } from "playwright/test";
6
6
  // runtime/interactive/e2e.ts
7
7
  /** The page's language, its alternates and a switch to each other language, the archetype marker. */
@@ -1,9 +1,9 @@
1
1
  import { c as withLanguage, i as notFoundPage, n as fromDepth } from "./pages-9RaaomoO.mjs";
2
2
  import { c as sourceScope, n as codeProblems } from "./vue-code-BkJjW_I_.mjs";
3
- import { o as pathText, t as Problems } from "./content-cYBPYg_J.mjs";
3
+ import { o as pathText, t as Problems } from "./content-DO6oEJxj.mjs";
4
4
  import { n as vueAlias, r as withServer, t as VUE_OPTIONS } from "./vue-DO1m6q1R.mjs";
5
5
  import { a as inputText, i as doingText, o as inputsOf, s as mismatch } from "./contract-PONpxJtd.mjs";
6
- import { a as InteractiveError, n as pages, o as loadInteractive, r as renderPage } from "./render-BL6fV_NI.mjs";
6
+ import { a as InteractiveError, n as pages, o as loadInteractive, r as renderPage } from "./render-D5kwkNt1.mjs";
7
7
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import vue from "@vitejs/plugin-vue";
@@ -1,19 +1,20 @@
1
1
  import { a as isLanguage, c as textLanguages, i as inLanguage, o as languageListProblems, r as LANGUAGES } from "./html-mffkHGHh.mjs";
2
- import { n as renderMaterial } from "./blocks-BmRrD7UY.mjs";
3
- import { a as plain, c as renderWithFormulas, i as inspect, o as renderInline, r as formulaProblems, s as renderMarkdown } from "./check-BkEl-m-F.mjs";
4
- import { n as checkMaterial } from "./validate-CxtdeQ5S.mjs";
5
- import { _ as questionIn, a as renderQuestion, b as rightAnswer, i as questionMessagesIn, n as answerText, o as renderSolution, u as isCorrect, v as questionTexts } from "./render-BJey0bwl.mjs";
6
- import { _ as verdict, g as schemasById, h as knownProperties, i as addressProblem, m as question_v1_schema_default, s as questionDetails, t as QUESTION_BRANCH, u as questionSchemas } from "./validate-COaZso7u.mjs";
2
+ import { n as renderMaterial } from "./blocks-CxtCPAa5.mjs";
3
+ import { a as inspect, c as renderMarkdown, i as formulaProblems, l as renderWithFormulas, o as plain, r as FORMULA, s as renderInline, u as renderFormula } from "./check-CteIFN96.mjs";
4
+ import { n as checkMaterial } from "./validate-Bxy9NfB8.mjs";
5
+ import { _ as questionIn, a as renderQuestion, b as rightAnswer, i as questionMessagesIn, n as answerText, o as renderSolution, u as isCorrect, v as questionTexts } from "./render-GS1Htlu8.mjs";
6
+ import { _ as verdict, g as schemasById, h as knownProperties, i as addressProblem, m as question_v1_schema_default, s as questionDetails, t as QUESTION_BRANCH, u as questionSchemas } from "./validate-BDwL7Su3.mjs";
7
7
  import { t as appName } from "./appname-Bh23cUuz.mjs";
8
8
  import { r as languageRoot } from "./pages-9RaaomoO.mjs";
9
- import { $ as parse, A as answerFor, B as label, D as generate, E as compiled, F as questionFor, G as taskText, I as taskKey, J as tokenize, K as templateProblems, L as answerLabel, M as exampleGiving, O as holds, P as pairValues, Q as evaluate, R as fill, T as StarvedError, W as stepsOf, X as kindOf, Y as unusedVariables, Z as ExpressionError, _ as termId, a as assessments, d as parseRef, et as same, g as isExercise, h as allBlocks, k as values, nt as Fraction, o as drawsTasks, q as templatesIn, s as exerciseOf, t as ASSESSMENT, tt as variables, w as message$1, z as hintOf } from "./assessment-By2GGTJC.mjs";
9
+ import { $ as parse, A as answerFor, B as label, D as generate, E as compiled, F as questionFor, G as taskText, I as taskKey, J as tokenize, K as templateProblems, L as answerLabel, M as exampleGiving, O as holds, P as pairValues, Q as evaluate, R as fill, T as StarvedError, W as stepsOf, X as kindOf, Y as unusedVariables, Z as ExpressionError, _ as termId, a as assessments, d as parseRef, et as same, g as isExercise, h as allBlocks, k as values, nt as Fraction, o as drawsTasks, q as templatesIn, s as exerciseOf, t as ASSESSMENT, tt as variables, w as message$1, z as hintOf } from "./assessment-Dy359O1O.mjs";
10
10
  import { a as RESERVED_PARAMS, l as distinctSeeds, o as SEED_TRIES } from "./tasks-DADUYy52.mjs";
11
11
  import { a as parse$1, i as nodes, o as readSfc, r as line, s as sfc, t as codeChecker } from "./vue-code-BkJjW_I_.mjs";
12
- import { a as lineOf, c as schemaProblems, i as isObject, n as Texts, o as pathText, r as get, s as readYaml, t as Problems } from "./content-cYBPYg_J.mjs";
12
+ import { a as lineOf, c as schemaProblems, i as isObject, n as Texts, o as pathText, r as get, s as readYaml, t as Problems } from "./content-DO6oEJxj.mjs";
13
13
  import "node:module";
14
14
  import { Ajv2020 } from "ajv/dist/2020.js";
15
15
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
16
16
  import { join } from "node:path";
17
+ import untyped from "speech-rule-engine";
17
18
  // \0rolldown/runtime.js
18
19
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod, mod.exports), cb = null), mod.exports);
19
20
  // runtime/explainer/components/props.ts
@@ -14709,6 +14710,33 @@ function readComponents(root) {
14709
14710
  };
14710
14711
  }
14711
14712
  /** A problem with a block's props, at a path below props. */
14713
+ // runtime/shared/text/speech.ts
14714
+ const SRE = untyped;
14715
+ const SPEECH = {
14716
+ domain: "clearspeak",
14717
+ modality: "speech"
14718
+ };
14719
+ for (const locale of LANGUAGES) await SRE.setupEngine({
14720
+ locale,
14721
+ ...SPEECH
14722
+ });
14723
+ /** The formula `tex` as words in `lang` (German for a language the runtime has no texts for), e.g. "2 hoch 7". */
14724
+ function speakFormula(tex, lang) {
14725
+ SRE.setupEngine({
14726
+ locale: isLanguage(lang) ? lang : "de",
14727
+ ...SPEECH
14728
+ });
14729
+ return SRE.toSpeech(renderFormula(tex, false)).replace(/\s+/g, " ").trim();
14730
+ }
14731
+ /**
14732
+ * The words of a text that is only formulas, $…$ (and space); undefined for any other text, which has words of its own
14733
+ * for a name to be made of.
14734
+ */
14735
+ function formulaAlternative(text, lang) {
14736
+ const formulas = [...text.matchAll(FORMULA)];
14737
+ if (formulas.length === 0 || text.replace(FORMULA, "").trim() !== "") return void 0;
14738
+ return formulas.map((formula) => speakFormula(formula[1] ?? "", lang)).join(" ");
14739
+ }
14712
14740
  // runtime/explainer/exercises.ts
14713
14741
  /** The templates of a generated exercise in each of `languages`; links inside them through `resolve`. */
14714
14742
  function templatesOf(spec, languages, resolve = (href) => href) {
@@ -14795,6 +14823,7 @@ function questionExercise(block, lang, material) {
14795
14823
  renderQuestion(question, {
14796
14824
  lang,
14797
14825
  format: renderWithFormulas,
14826
+ describe: (text) => formulaAlternative(text, lang),
14798
14827
  ...question.material === void 0 ? {} : { materialHtml: renderMaterial(question.material, material) }
14799
14828
  }),
14800
14829
  `<details class="explainer-solution"><summary>${plain(questionMessagesIn(lang).solution)}</summary>`,
@@ -16944,4 +16973,4 @@ function loadSite(root, seeds = 200) {
16944
16973
  if (site === void 0) throw new ContentError(problems);
16945
16974
  return site;
16946
16975
  }
16947
- export { propertyProblems as _, routes as a, exerciseData as c, templatesOf as d, workedTasks as f, exampleProblems$1 as g, describedProblems as h, hiddenChapter as i, renderExercise as l, TABLE_CONTRACT as m, loadSite as n, terms as o, SIZE_BUDGET as p, contentPages as r, dataOf as s, ContentError as t, taskSeeds as u, propsIn as v };
16976
+ export { exampleProblems$1 as _, routes as a, exerciseData as c, templatesOf as d, workedTasks as f, describedProblems as g, TABLE_CONTRACT as h, hiddenChapter as i, renderExercise as l, SIZE_BUDGET as m, loadSite as n, terms as o, formulaAlternative as p, contentPages as r, dataOf as s, ContentError as t, taskSeeds as u, propertyProblems as v, propsIn as y };
@@ -1 +1 @@
1
- export { t as answerQuestion, n as expectBackground, r as expectFeedbackInView, i as expectMaterial, a as giveAnswer, o as question } from "./e2e-BiMI-pCL.mjs";
1
+ export { t as answerQuestion, n as expectBackground, r as expectFeedbackInView, i as expectMaterial, a as giveAnswer, o as question } from "./e2e-CU0n_unV.mjs";
@@ -1,3 +1,3 @@
1
- import { g as questionId } from "./render-BJey0bwl.mjs";
2
- import { n as startQuestion, t as readAnswer } from "./engine-BqF2Vffn.mjs";
1
+ import { g as questionId } from "./render-GS1Htlu8.mjs";
2
+ import { n as startQuestion, t as readAnswer } from "./engine-CArxqizW.mjs";
3
3
  export { questionId, readAnswer, startQuestion };
@@ -1,5 +1,5 @@
1
- import { C as wrongAnswer, S as shuffled, T as de_default, _ as questionIn, a as renderQuestion, b as rightAnswer, c as hash, d as materialPairs, f as missesRight, g as questionId, h as questionAddresses, i as questionMessagesIn, l as isComplete, m as parseNumber, n as answerText, o as renderSolution, p as mixed, s as QUESTION_TYPES, t as QUESTION_MESSAGES, u as isCorrect, v as questionTexts, w as wrongReasons, x as rightEntries, y as random } from "./render-BJey0bwl.mjs";
2
- import { c as questionSchema, l as questionSchemaProblems, n as QUESTION_REF, p as validateQuestion, r as QUESTION_SCHEMA_ID, s as questionDetails, u as questionSchemas } from "./validate-COaZso7u.mjs";
1
+ import { C as wrongAnswer, S as shuffled, T as de_default, _ as questionIn, a as renderQuestion, b as rightAnswer, c as hash, d as materialPairs, f as missesRight, g as questionId, h as questionAddresses, i as questionMessagesIn, l as isComplete, m as parseNumber, n as answerText, o as renderSolution, p as mixed, s as QUESTION_TYPES, t as QUESTION_MESSAGES, u as isCorrect, v as questionTexts, w as wrongReasons, x as rightEntries, y as random } from "./render-GS1Htlu8.mjs";
2
+ import { c as questionSchema, l as questionSchemaProblems, n as QUESTION_REF, p as validateQuestion, r as QUESTION_SCHEMA_ID, s as questionDetails, u as questionSchemas } from "./validate-BDwL7Su3.mjs";
3
3
  // runtime/shared/questions/index.ts
4
4
  /** The texts of a question for learners in German, by their key (messages/de.json); questionMessagesIn(lang) for another. */
5
5
  const messages = de_default;
@@ -1,5 +1,5 @@
1
1
  import { i as inLanguage } from "./html-mffkHGHh.mjs";
2
- import { _ as questionIn } from "./render-BJey0bwl.mjs";
2
+ import { _ as questionIn } from "./render-GS1Htlu8.mjs";
3
3
  // runtime/quiz/messages/de.json
4
4
  var de_default = {
5
5
  howto: "Beantworte die Fragen der Reihe nach. Nach jeder Antwort siehst du, ob sie stimmt, und erfährst mehr über jede Antwort. Am Ende siehst du dein Ergebnis.",
package/dist/quiz-e2e.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { C as wrongAnswer, b as rightAnswer, g as questionId } from "./render-BJey0bwl.mjs";
2
- import { i as expectMaterial, n as expectBackground, o as question, r as expectFeedbackInView, t as answerQuestion } from "./e2e-BiMI-pCL.mjs";
3
- import { n as quizLanguages, t as quizIn } from "./quiz-BKc8JYtC.mjs";
4
- import { n as messagesIn, t as QUIZ_MESSAGES } from "./render-DpRO_RKm.mjs";
1
+ import { C as wrongAnswer, b as rightAnswer, g as questionId } from "./render-GS1Htlu8.mjs";
2
+ import { i as expectMaterial, n as expectBackground, o as question, r as expectFeedbackInView, t as answerQuestion } from "./e2e-CU0n_unV.mjs";
3
+ import { n as quizLanguages, t as quizIn } from "./quiz-DnW-xtCh.mjs";
4
+ import { n as messagesIn, t as QUIZ_MESSAGES } from "./render-DqHOJyHK.mjs";
5
5
  import { expect, test } from "playwright/test";
6
6
  import { readFileSync } from "node:fs";
7
7
  // runtime/quiz/e2e.ts
@@ -1,12 +1,12 @@
1
1
  import { c as textLanguages, o as languageListProblems, r as LANGUAGES, t as escape } from "./html-mffkHGHh.mjs";
2
2
  import { r as materialImages } from "./block-BK_ohsH1.mjs";
3
- import "./blocks-BmRrD7UY.mjs";
4
- import { a as duplicates, d as readable, f as schemaMessages, o as inQuestion, p as validateQuestion, u as questionSchemas } from "./validate-COaZso7u.mjs";
5
- import { n as quizLanguages, t as quizIn } from "./quiz-BKc8JYtC.mjs";
3
+ import "./blocks-CxtCPAa5.mjs";
4
+ import { a as duplicates, d as readable, f as schemaMessages, o as inQuestion, p as validateQuestion, u as questionSchemas } from "./validate-BDwL7Su3.mjs";
5
+ import { n as quizLanguages, t as quizIn } from "./quiz-DnW-xtCh.mjs";
6
6
  import { t as appName } from "./appname-Bh23cUuz.mjs";
7
7
  import { n as isSizeless, t as imageType } from "./image-CspJVfyQ.mjs";
8
8
  import { c as withLanguage, i as notFoundPage, n as fromDepth, r as languageRoot } from "./pages-9RaaomoO.mjs";
9
- import { r as renderQuiz } from "./render-DpRO_RKm.mjs";
9
+ import { r as renderQuiz } from "./render-DqHOJyHK.mjs";
10
10
  import { Ajv2020 } from "ajv/dist/2020.js";
11
11
  import { existsSync, readFileSync } from "node:fs";
12
12
  import { join } from "node:path";
package/dist/quiz.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { S as shuffled, g as questionId, u as isCorrect } from "./render-BJey0bwl.mjs";
2
- import { n as startQuestion } from "./engine-BqF2Vffn.mjs";
3
- import { a as de_default, i as en_default, r as solutionId } from "./quiz-BKc8JYtC.mjs";
1
+ import { S as shuffled, g as questionId, u as isCorrect } from "./render-GS1Htlu8.mjs";
2
+ import { n as startQuestion } from "./engine-CArxqizW.mjs";
3
+ import { a as de_default, i as en_default, r as solutionId } from "./quiz-DnW-xtCh.mjs";
4
4
  import { translator } from "@lernapps/tooling/i18n";
5
5
  import { announce, moveFocus } from "@lernapps/tooling/a11y";
6
6
  import { createStorage } from "@lernapps/tooling/storage";
@@ -1,7 +1,7 @@
1
1
  import { i as inLanguage, l as textsIn, o as languageListProblems, r as LANGUAGES } from "./html-mffkHGHh.mjs";
2
- import { a as plain, s as renderMarkdown } from "./check-BkEl-m-F.mjs";
2
+ import { c as renderMarkdown, o as plain } from "./check-CteIFN96.mjs";
3
3
  import { a as pageFooter, o as pageHeader, r as languageRoot, s as relative, t as alternateLinks } from "./pages-9RaaomoO.mjs";
4
- import { c as schemaProblems, n as Texts, s as readYaml, t as Problems } from "./content-cYBPYg_J.mjs";
4
+ import { c as schemaProblems, n as Texts, s as readYaml, t as Problems } from "./content-DO6oEJxj.mjs";
5
5
  import { Ajv2020 } from "ajv/dist/2020.js";
6
6
  import { existsSync, readdirSync } from "node:fs";
7
7
  import { join } from "node:path";
@@ -1,7 +1,7 @@
1
1
  import { l as textsIn, s as localeOf, t as escape } from "./html-mffkHGHh.mjs";
2
- import { n as renderMaterial } from "./blocks-BmRrD7UY.mjs";
3
- import { a as renderQuestion, g as questionId, o as renderSolution, r as external, t as QUESTION_MESSAGES } from "./render-BJey0bwl.mjs";
4
- import { a as de_default, i as en_default, r as solutionId } from "./quiz-BKc8JYtC.mjs";
2
+ import { n as renderMaterial } from "./blocks-CxtCPAa5.mjs";
3
+ import { a as renderQuestion, g as questionId, o as renderSolution, r as external, t as QUESTION_MESSAGES } from "./render-GS1Htlu8.mjs";
4
+ import { a as de_default, i as en_default, r as solutionId } from "./quiz-DnW-xtCh.mjs";
5
5
  import { a as pageFooter, o as pageHeader, r as languageRoot, s as relative, t as alternateLinks } from "./pages-9RaaomoO.mjs";
6
6
  import { translator } from "@lernapps/tooling/i18n";
7
7
  // runtime/quiz/render.ts
@@ -199,6 +199,7 @@ function questionIn(question, lang) {
199
199
  text: t(option.text),
200
200
  correct: option.correct,
201
201
  info: t(option.info),
202
+ ...option.alt === void 0 ? {} : { alt: t(option.alt) },
202
203
  ...option.link === void 0 ? {} : { link: link(option.link) }
203
204
  }))
204
205
  };
@@ -415,13 +416,17 @@ function questionWords(lang = "de") {
415
416
  }
416
417
  return found;
417
418
  }
418
- function controls(question, { t }, format) {
419
+ function controls(question, { t }, format, describe) {
419
420
  const name = questionId(question);
420
421
  switch (question.type) {
421
422
  case "single-choice":
422
423
  case "multiple-choice": {
423
424
  const type = question.type === "single-choice" ? "radio" : "checkbox";
424
- return question.options.map((option, index) => `<label class="question-option"><input type="${type}" name="${name}" value="${index}"> <span>${format(option.text)}</span></label>`).join("\n");
425
+ return question.options.map((option, index) => {
426
+ const spoken = option.alt ?? describe?.(option.text);
427
+ const label = spoken === void 0 ? "" : ` aria-label="${escape(spoken)}"`;
428
+ return `<label class="question-option"><input type="${type}" name="${name}" value="${index}"${label}> <span>${format(option.text)}</span></label>`;
429
+ }).join("\n");
425
430
  }
426
431
  case "true-false": return [true, false].map((value) => `<label class="question-option"><input type="radio" name="${name}" value="${value}"> <span>${t(value ? "true" : "false")}</span></label>`).join("\n");
427
432
  case "number": return [
@@ -456,7 +461,7 @@ function controls(question, { t }, format) {
456
461
  * what the learner learns about it (the same as its solution). The engine's startQuestion makes it live; without
457
462
  * JavaScript the button stays hidden.
458
463
  */
459
- function renderQuestion(question, { element = "div", next = false, lang = "de", textHtml, materialHtml, explanationHtml, format = escape } = {}) {
464
+ function renderQuestion(question, { element = "div", next = false, lang = "de", textHtml, materialHtml, explanationHtml, format = escape, describe } = {}) {
460
465
  const words = questionWords(lang);
461
466
  const { t } = words;
462
467
  const id = questionId(question);
@@ -467,7 +472,7 @@ function renderQuestion(question, { element = "div", next = false, lang = "de",
467
472
  `<fieldset>`,
468
473
  `<legend class="question-text">${textHtml ?? format(question.text)}</legend>`,
469
474
  ...materialHtml === void 0 || materialHtml === "" ? [] : [`<div class="question-material">${materialHtml}</div>`],
470
- controls(question, words, format),
475
+ controls(question, words, format, describe),
471
476
  `</fieldset>`,
472
477
  `<div class="question-feedback" role="status"></div>`,
473
478
  `<div class="question-reveal" hidden>`,
@@ -1,5 +1,5 @@
1
- import { x as rightEntries } from "./render-BJey0bwl.mjs";
2
- import { w as message } from "./assessment-By2GGTJC.mjs";
1
+ import { x as rightEntries } from "./render-GS1Htlu8.mjs";
2
+ import { w as message } from "./assessment-Dy359O1O.mjs";
3
3
  // runtime/explainer/result.ts
4
4
  const key = (id, n) => n === void 0 ? id : `${id}=${n}`;
5
5
  /**
@@ -1,6 +1,6 @@
1
1
  import { c as textLanguages } from "./html-mffkHGHh.mjs";
2
- import { n as checkMaterial, t as MATERIAL_DEFINITIONS } from "./validate-CxtdeQ5S.mjs";
3
- import { _ as questionIn, h as questionAddresses, l as isComplete, m as parseNumber, u as isCorrect, v as questionTexts, x as rightEntries } from "./render-BJey0bwl.mjs";
2
+ import { n as checkMaterial, t as MATERIAL_DEFINITIONS } from "./validate-Bxy9NfB8.mjs";
3
+ import { _ as questionIn, h as questionAddresses, l as isComplete, m as parseNumber, u as isCorrect, v as questionTexts, x as rightEntries } from "./render-GS1Htlu8.mjs";
4
4
  import { Ajv2020 } from "ajv/dist/2020.js";
5
5
  // runtime/shared/examples.ts
6
6
  /**
@@ -591,6 +591,10 @@ var question_v1_schema_default = {
591
591
  "description": "One to three sentences of background on this option itself, true whether or not it is the right answer, e.g. \"Der Uluru ist ein Inselberg in der Wüste Zentralaustraliens ...\". After an answer it shows for every option. For a wrong option it is also the reason a learner who chose it reads with the feedback, so say there why it does not fit.",
592
592
  "$ref": "urn:lernapps:schema:text.v1#/$defs/text"
593
593
  },
594
+ "alt": {
595
+ "description": "Optional: the option as it is spoken, for the name of its radio button or checkbox, when its text is only a formula, e.g. \"zwei hoch sieben\" for \"$2^7$\". Without it the name is made from the formula in the page's language (\"2 hoch 7\"); write it where that reads badly, e.g. for a chemical formula. Not needed when the text has words.",
596
+ "$ref": "urn:lernapps:schema:text.v1#/$defs/text"
597
+ },
594
598
  "link": {
595
599
  "description": "Further reading on this option.",
596
600
  "$ref": "#/$defs/link"
@@ -1,6 +1,6 @@
1
1
  import { i as inLanguage } from "./html-mffkHGHh.mjs";
2
2
  import { n as markerOf } from "./block-BK_ohsH1.mjs";
3
- import { t as checkText } from "./check-BkEl-m-F.mjs";
3
+ import { t as checkText } from "./check-CteIFN96.mjs";
4
4
  // runtime/shared/blocks/validate.ts
5
5
  /** The definition of each type of block in the schema of the blocks (blocks.v1.schema.json). */
6
6
  const MATERIAL_DEFINITIONS = {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@lernapps/app-templates": "file:..",
12
- "@lernapps/tooling": "github:lernapps/tooling#7354ffe7ae7d8079ba5f714d5b402666d27df066",
12
+ "@lernapps/tooling": "^0.1.2",
13
13
  "vite-plus": "1.4.0"
14
14
  },
15
15
  "overrides": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@lernapps/app-templates": "file:..",
12
- "@lernapps/tooling": "github:lernapps/tooling#7354ffe7ae7d8079ba5f714d5b402666d27df066",
12
+ "@lernapps/tooling": "^0.1.2",
13
13
  "vite-plus": "1.4.0"
14
14
  },
15
15
  "overrides": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lernapps/app-templates",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "The templates of the archetypes of lernapps.net apps, and the runtime of each archetype that apps depend on (the quiz: engine, build step, styles, end-to-end tests, the schema of the question bank, the questions it shares with the explainer are in ./questions; the explainer: build step, client module with its exercises, the islands of its Vue components, styles, end-to-end tests, the schema of its content; the interactive archetype: build step, the start page and the app page around the app's own Vue components and core, its playthroughs, styles, end-to-end tests, the schemas of its start page and its playthroughs)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -100,6 +100,7 @@
100
100
  "highlight.js": "11.12.0",
101
101
  "marked": "18.1.2",
102
102
  "source-map-js": "1.2.2",
103
+ "speech-rule-engine": "4.1.5",
103
104
  "temml": "0.14.0",
104
105
  "vue": "3.5.44",
105
106
  "yaml": "2.9.1"
package/quiz/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@lernapps/app-templates": "file:..",
12
- "@lernapps/tooling": "github:lernapps/tooling#7354ffe7ae7d8079ba5f714d5b402666d27df066",
12
+ "@lernapps/tooling": "^0.1.2",
13
13
  "vite-plus": "1.4.0"
14
14
  },
15
15
  "overrides": {
@@ -12,6 +12,7 @@
12
12
  import { renderQuestion } from "../shared/questions/render.ts";
13
13
  import { type MaterialContext, renderMaterial } from "../shared/blocks/index.ts";
14
14
  import { plain, renderWithFormulas } from "../shared/text/markdown.ts";
15
+ import { formulaAlternative } from "../shared/text/speech.ts";
15
16
  import {
16
17
  assessments,
17
18
  drawsTasks,
@@ -117,6 +118,7 @@ function itemHtml(context: AssessmentContext, data: AssessmentItemData, n: numbe
117
118
  return `${label}${renderQuestion(question, {
118
119
  lang,
119
120
  format: (text) => rendered[text] ?? renderWithFormulas(text),
121
+ describe: (text) => formulaAlternative(text, lang),
120
122
  ...(question.material === undefined ? {} : { materialHtml: renderMaterial(question.material, context.material) }),
121
123
  })}`;
122
124
  }
@@ -67,6 +67,7 @@ import { message as messageOf } from "./messages.ts";
67
67
  import { ASSESSMENT_TASK_EVENT, nextTaskNumber, taskNumber } from "./tasks.ts";
68
68
  import {
69
69
  outline,
70
+ shownText,
70
71
  type AssessmentOutline,
71
72
  type ComponentOutline,
72
73
  type ExerciseOutline,
@@ -157,6 +158,12 @@ const BAND_WORDS = {
157
158
  unasked: "assessmentUnasked",
158
159
  } as const;
159
160
 
161
+ /**
162
+ * `text` without space. A browser's text of a formula has space between its parts ("2 7" for 2⁷) where the content's
163
+ * has none, so texts with formulas are compared without it (in the order of their characters, which is what counts).
164
+ */
165
+ const squash = (text: string): string => text.replace(/\s+/g, "");
166
+
160
167
  /** The task number of the deep link the tests open a generated exercise with. */
161
168
  const SEED = 7;
162
169
 
@@ -169,7 +176,7 @@ async function expectExercises(page: Page, item: PageOutline): Promise<void> {
169
176
  const main = page.locator("main");
170
177
  const expected = item.exercises.map((exercise) =>
171
178
  exercise.kind === "question"
172
- ? { id: exercise.id, texts: [exercise.question.text], answers: [exercise.answers] }
179
+ ? { id: exercise.id, texts: [shownText(exercise.question.text)], answers: [exercise.answers] }
173
180
  : exercise.kind === "open"
174
181
  ? { id: exercise.id, texts: [exercise.task], answers: [exercise.solution] }
175
182
  : {
@@ -178,29 +185,31 @@ async function expectExercises(page: Page, item: PageOutline): Promise<void> {
178
185
  answers: exercise.worked.map((worked) => [worked.answer]),
179
186
  },
180
187
  );
181
- const read = () =>
182
- main.locator(".explainer-exercise").evaluateAll((elements) =>
183
- elements.map((element) => {
184
- const fold = (text: string | null) => (text ?? "").replace(/\s+/g, " ").trim();
185
- const own = element.classList.contains("explainer-question")
186
- ? [...element.querySelectorAll("legend")]
187
- : element.classList.contains("explainer-open-task")
188
- ? [...element.querySelectorAll(":scope > .explainer-open-task-text")]
189
- : [...element.querySelectorAll(".explainer-worked-task > .explainer-task")];
190
- const solutions = [...element.querySelectorAll<HTMLDetailsElement>("details.explainer-solution")];
191
- return {
192
- id: element.id,
193
- texts: own.map((text) => fold(text.textContent)),
194
- // what a learner sees of each "Lösung": nothing while it is closed
195
- shown: solutions.map((details) => (details.open ? fold((details as HTMLElement).innerText) : "")),
196
- };
197
- }),
198
- );
188
+ const read = async () =>
189
+ (
190
+ await main.locator(".explainer-exercise").evaluateAll((elements) =>
191
+ elements.map((element) => {
192
+ const fold = (text: string | null) => (text ?? "").replace(/\s+/g, " ").trim();
193
+ const own = element.classList.contains("explainer-question")
194
+ ? [...element.querySelectorAll("legend")]
195
+ : element.classList.contains("explainer-open-task")
196
+ ? [...element.querySelectorAll(":scope > .explainer-open-task-text")]
197
+ : [...element.querySelectorAll(".explainer-worked-task > .explainer-task")];
198
+ const solutions = [...element.querySelectorAll<HTMLDetailsElement>("details.explainer-solution")];
199
+ return {
200
+ id: element.id,
201
+ texts: own.map((text) => fold(text.textContent)),
202
+ // what a learner sees of each "Lösung": nothing while it is closed
203
+ shown: solutions.map((details) => (details.open ? fold((details as HTMLElement).innerText) : "")),
204
+ };
205
+ }),
206
+ )
207
+ ).map((item) => ({ ...item, texts: item.texts.map(squash), shown: item.shown.map(squash) }));
199
208
  const before = await read();
200
209
  expect(
201
210
  before.map(({ id, texts }) => ({ id, texts })),
202
211
  `${item.url}: each exercise with its text`,
203
- ).toEqual(expected.map(({ id, texts }) => ({ id, texts })));
212
+ ).toEqual(expected.map(({ id, texts }) => ({ id, texts: texts.map(squash) })));
204
213
  // a click on each summary, as a learner opens a <details> (its own behaviour, no script of the page)
205
214
  await main
206
215
  .locator(".explainer-exercise details.explainer-solution > summary")
@@ -212,7 +221,7 @@ async function expectExercises(page: Page, item: PageOutline): Promise<void> {
212
221
  expect(
213
222
  after[index]?.shown[at] ?? "",
214
223
  `${item.url}: ${exercise.id}, Lösung ${at + 1} shows ${answer}`,
215
- ).toContain(answer);
224
+ ).toContain(squash(answer));
216
225
  }
217
226
  });
218
227
  }
@@ -484,7 +493,9 @@ const feedback = (page: Page, id: string) => page.locator(`[id="${id}"] .questio
484
493
  function reasonFor(question: Question, answer: Answer): string {
485
494
  const quiz = questionMessagesIn("de");
486
495
  const [first] = wrongReasons(question, answer);
487
- if (first !== undefined) return first.option === undefined ? first.text : `${first.option}: ${first.text}`;
496
+ if (first !== undefined) {
497
+ return first.option === undefined ? shownText(first.text) : `${shownText(first.option)}: ${shownText(first.text)}`;
498
+ }
488
499
  return missesRight(question, answer) ? quiz.missingRight : quiz.lookAgain;
489
500
  }
490
501
 
@@ -495,9 +506,12 @@ function reasonFor(question: Question, answer: Answer): string {
495
506
  async function wrongThenSolution(page: Page, exercise: string, question: Question, reason: string): Promise<void> {
496
507
  const item = page.locator(`[id="${questionId(question)}"]`);
497
508
  await expect(feedback(page, questionId(question))).toContainText(questionMessagesIn("de").wrong);
498
- await expect(item.locator(".question-diagnosis").first(), `${exercise}: the reason of the wrong answer`).toHaveText(
499
- reason,
500
- );
509
+ await expect
510
+ .poll(
511
+ async () => squash((await item.locator(".question-diagnosis").first().textContent()) ?? ""),
512
+ `${exercise}: the reason of the wrong answer`,
513
+ )
514
+ .toBe(squash(reason));
501
515
  await expectFeedbackInView(page, question);
502
516
  await expect(item.locator(".question-reveal"), `${exercise}: no solution after a wrong answer`).toBeHidden();
503
517
  await expect(item.locator("input, select").first(), `${exercise}: another try`).toBeEnabled();
@@ -16,6 +16,7 @@ import { questionFor, taskKey } from "./generated/question.ts";
16
16
  import { WORKED_TASKS } from "./tasks.ts";
17
17
  import { type Data, type Generated, type LanguageTemplates, type Templates } from "./generated/types.ts";
18
18
  import { plain, renderInline, renderMarkdown, renderWithFormulas } from "../shared/text/markdown.ts";
19
+ import { formulaAlternative } from "../shared/text/speech.ts";
19
20
  import { distinctSeeds } from "./tasks.ts";
20
21
  import { message } from "./messages.ts";
21
22
 
@@ -166,6 +167,7 @@ function questionExercise(block: QuestionBlock, lang: string, material: Material
166
167
  renderQuestion(question, {
167
168
  lang,
168
169
  format: renderWithFormulas,
170
+ describe: (text) => formulaAlternative(text, lang),
169
171
  ...(question.material === undefined ? {} : { materialHtml: renderMaterial(question.material, material) }),
170
172
  }),
171
173
  `<details class="explainer-solution"><summary>${plain(questionMessagesIn(lang).solution)}</summary>`,
@@ -8,6 +8,7 @@ import { answerText } from "../shared/questions/render.ts";
8
8
  import { inLanguage, timelineEvents, type Block, type ComponentExample, type Site, type Text } from "./content.ts";
9
9
  import { dataOf, renderExercise, templatesOf, workedTasks } from "./exercises.ts";
10
10
  import { kindOf } from "./generated/comparator.ts";
11
+ import { renderWithFormulas } from "../shared/text/markdown.ts";
11
12
  import { plainText } from "./generated/texts.ts";
12
13
  import { type Data, type Generated, type Templates } from "./generated/types.ts";
13
14
  import { loadSite } from "./load.ts";
@@ -48,6 +49,12 @@ export interface ComponentOutline {
48
49
  examples?: { accepted?: ComponentExample; rejected?: ComponentExample };
49
50
  }
50
51
 
52
+ /**
53
+ * A learner text of a question as the page shows it, as text: its formulas $…$ rendered (a formula has no space the
54
+ * tests could rely on in a browser's text, see squash in e2e.ts), the rest as written.
55
+ */
56
+ export const shownText = (text: string): string => plainText(renderWithFormulas(text));
57
+
51
58
  /** An exercise of a page, in the page's language: what it shows and what it accepts. */
52
59
  export type ExerciseOutline =
53
60
  | {
@@ -244,7 +251,7 @@ function walk(
244
251
  question,
245
252
  answers:
246
253
  question.type === "single-choice" || question.type === "multiple-choice"
247
- ? question.options.filter((option) => option.correct).map((option) => option.text)
254
+ ? question.options.filter((option) => option.correct).map((option) => shownText(option.text))
248
255
  : [plainText(answerText(question, lang))],
249
256
  });
250
257
  }
@@ -20,6 +20,8 @@ export interface Option<T extends Text = string> {
20
20
  text: T;
21
21
  correct: boolean;
22
22
  info: T;
23
+ /** The option spoken, as its radio or checkbox is named: for an option that is only a formula; else made from it. */
24
+ alt?: T;
23
25
  link?: Link<T>;
24
26
  }
25
27
 
@@ -209,6 +211,7 @@ export function questionIn(question: Question<Text>, lang: string): Question {
209
211
  text: t(option.text),
210
212
  correct: option.correct,
211
213
  info: t(option.info),
214
+ ...(option.alt === undefined ? {} : { alt: t(option.alt) }),
212
215
  ...(option.link === undefined ? {} : { link: link(option.link) }),
213
216
  })),
214
217
  };
@@ -190,6 +190,10 @@
190
190
  "description": "One to three sentences of background on this option itself, true whether or not it is the right answer, e.g. \"Der Uluru ist ein Inselberg in der Wüste Zentralaustraliens ...\". After an answer it shows for every option. For a wrong option it is also the reason a learner who chose it reads with the feedback, so say there why it does not fit.",
191
191
  "$ref": "urn:lernapps:schema:text.v1#/$defs/text"
192
192
  },
193
+ "alt": {
194
+ "description": "Optional: the option as it is spoken, for the name of its radio button or checkbox, when its text is only a formula, e.g. \"zwei hoch sieben\" for \"$2^7$\". Without it the name is made from the formula in the page's language (\"2 hoch 7\"); write it where that reads badly, e.g. for a chemical formula. Not needed when the text has words.",
195
+ "$ref": "urn:lernapps:schema:text.v1#/$defs/text"
196
+ },
193
197
  "link": {
194
198
  "description": "Further reading on this option.",
195
199
  "$ref": "#/$defs/link"
@@ -44,17 +44,22 @@ export function questionWords(lang = "de"): Words {
44
44
  /** How a learner text becomes HTML. */
45
45
  export type Format = (text: string) => string;
46
46
 
47
- function controls(question: Question, { t }: Words, format: Format): string {
47
+ /** A text as words for a name, where the text itself has none (a formula); undefined where it has. */
48
+ export type Describe = (text: string) => string | undefined;
49
+
50
+ function controls(question: Question, { t }: Words, format: Format, describe?: Describe): string {
48
51
  const name = questionId(question);
49
52
  switch (question.type) {
50
53
  case "single-choice":
51
54
  case "multiple-choice": {
52
55
  const type = question.type === "single-choice" ? "radio" : "checkbox";
53
56
  return question.options
54
- .map(
55
- (option, index) =>
56
- `<label class="question-option"><input type="${type}" name="${name}" value="${index}"> <span>${format(option.text)}</span></label>`,
57
- )
57
+ .map((option, index) => {
58
+ // an option that is only a formula has no text to name its control; the page says in words what it shows
59
+ const spoken = option.alt ?? describe?.(option.text);
60
+ const label = spoken === undefined ? "" : ` aria-label="${escape(spoken)}"`;
61
+ return `<label class="question-option"><input type="${type}" name="${name}" value="${index}"${label}> <span>${format(option.text)}</span></label>`;
62
+ })
58
63
  .join("\n");
59
64
  }
60
65
  case "true-false":
@@ -111,6 +116,8 @@ function controls(question: Question, { t }: Words, format: Format): string {
111
116
  export interface RenderQuestionOptions {
112
117
  /** How a learner text becomes HTML, e.g. with its formulas; escaped as plain text by default. */
113
118
  format?: (text: string) => string;
119
+ /** The words a screen reader names the control of an option by, when its text is only a formula. */
120
+ describe?: Describe;
114
121
  element?: "div" | "li";
115
122
  next?: boolean;
116
123
  lang?: string;
@@ -142,6 +149,7 @@ export function renderQuestion(
142
149
  materialHtml,
143
150
  explanationHtml,
144
151
  format = escape,
152
+ describe,
145
153
  }: RenderQuestionOptions = {},
146
154
  ): string {
147
155
  const words = questionWords(lang);
@@ -156,7 +164,7 @@ export function renderQuestion(
156
164
  ...(materialHtml === undefined || materialHtml === ""
157
165
  ? []
158
166
  : [`<div class="question-material">${materialHtml}</div>`]),
159
- controls(question, words, format),
167
+ controls(question, words, format, describe),
160
168
  `</fieldset>`,
161
169
  `<div class="question-feedback" role="status"></div>`,
162
170
  `<div class="question-reveal" hidden>`,
@@ -158,7 +158,7 @@ export function renderInline(source: string, resolve: (href: string) => string):
158
158
  /** A plain text as HTML. */
159
159
  export const plain = escape;
160
160
 
161
- const FORMULA = /\$((?:\\.|[^\\$\n])+?)\$/g;
161
+ export const FORMULA = /\$((?:\\.|[^\\$\n])+?)\$/g;
162
162
 
163
163
  /** A plain text as HTML in which only $…$ is a formula (MathML); everything else stays text, as in a quiz. */
164
164
  export function renderWithFormulas(text: string): string {
@@ -0,0 +1,39 @@
1
+ // The text alternative of a formula, for a name a screen reader or the accessibility check can read where the page has
2
+ // only MathML (an option of a choice whose text is a formula, see the label of its radio): Speech Rule Engine speaks
3
+ // the MathML of the formula in the page's language. Build time only; the engine loads its rules for every language
4
+ // of the runtime once, here, so that speaking a formula in any of them is then synchronous.
5
+ // @ts-expect-error speech-rule-engine ships no types; the part this file uses is typed below (drop this line if it does)
6
+ import untyped from "speech-rule-engine";
7
+ import { isLanguage, LANGUAGES } from "../languages.ts";
8
+ import { renderFormula } from "./formula.ts";
9
+ import { FORMULA } from "./markdown.ts";
10
+
11
+ /** The part of Speech Rule Engine that this file uses. */
12
+ interface Engine {
13
+ /** Loads the rules of the locale; once loaded, switching to it takes effect at once. */
14
+ setupEngine(setup: { locale: string; domain: string; modality: string }): Promise<void>;
15
+ /** The words of a MathML string, in the locale set up last. */
16
+ toSpeech(mathml: string): string;
17
+ }
18
+ const SRE: Engine = untyped;
19
+
20
+ const SPEECH = { domain: "clearspeak", modality: "speech" } as const;
21
+
22
+ for (const locale of LANGUAGES) await SRE.setupEngine({ locale, ...SPEECH });
23
+
24
+ /** The formula `tex` as words in `lang` (German for a language the runtime has no texts for), e.g. "2 hoch 7". */
25
+ export function speakFormula(tex: string, lang: string): string {
26
+ // the rules of every language are loaded: switching to one takes effect at once
27
+ void SRE.setupEngine({ locale: isLanguage(lang) ? lang : "de", ...SPEECH });
28
+ return SRE.toSpeech(renderFormula(tex, false)).replace(/\s+/g, " ").trim();
29
+ }
30
+
31
+ /**
32
+ * The words of a text that is only formulas, $…$ (and space); undefined for any other text, which has words of its own
33
+ * for a name to be made of.
34
+ */
35
+ export function formulaAlternative(text: string, lang: string): string | undefined {
36
+ const formulas = [...text.matchAll(FORMULA)];
37
+ if (formulas.length === 0 || text.replace(FORMULA, "").trim() !== "") return undefined;
38
+ return formulas.map((formula) => speakFormula(formula[1] ?? "", lang)).join(" ");
39
+ }