@lernapps/app-templates 0.1.0 → 0.2.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 (55) hide show
  1. package/README.md +2 -2
  2. package/dist/{assessment-By2GGTJC.mjs → assessment-Dy359O1O.mjs} +1 -1
  3. package/dist/{blocks-BmRrD7UY.mjs → blocks-CxtCPAa5.mjs} +2 -2
  4. package/dist/blocks.mjs +2 -2
  5. package/dist/{check-BkEl-m-F.mjs → check-CteIFN96.mjs} +1 -1
  6. package/dist/{content-cYBPYg_J.mjs → content-DO6oEJxj.mjs} +1 -1
  7. package/dist/{e2e-BiMI-pCL.mjs → e2e-CU0n_unV.mjs} +1 -1
  8. package/dist/{engine-BqF2Vffn.mjs → engine-CArxqizW.mjs} +1 -1
  9. package/dist/explainer-e2e.mjs +54 -19
  10. package/dist/explainer-plugin.mjs +6 -5
  11. package/dist/explainer.mjs +4 -4
  12. package/dist/interactive-e2e.mjs +1 -1
  13. package/dist/interactive-plugin.mjs +2 -2
  14. package/dist/{load-C-0hUaSC.mjs → load-RosvaPZf.mjs} +37 -8
  15. package/dist/questions-e2e.mjs +1 -1
  16. package/dist/questions-engine.mjs +2 -2
  17. package/dist/questions.mjs +2 -2
  18. package/dist/{quiz-BKc8JYtC.mjs → quiz-DnW-xtCh.mjs} +1 -1
  19. package/dist/quiz-e2e.mjs +52 -28
  20. package/dist/quiz-plugin.mjs +4 -4
  21. package/dist/quiz.mjs +3 -3
  22. package/dist/{render-BL6fV_NI.mjs → render-D5kwkNt1.mjs} +2 -2
  23. package/dist/{render-DpRO_RKm.mjs → render-DqHOJyHK.mjs} +3 -3
  24. package/dist/{render-BJey0bwl.mjs → render-GS1Htlu8.mjs} +9 -4
  25. package/dist/{result-_eQcamoz.mjs → result-D_mmCVwN.mjs} +2 -2
  26. package/dist/{validate-COaZso7u.mjs → validate-BDwL7Su3.mjs} +6 -2
  27. package/dist/{validate-CxtdeQ5S.mjs → validate-Bxy9NfB8.mjs} +1 -1
  28. package/docs/arc42/09-decisions.arc42.md +24 -0
  29. package/explainer/docs/arc42/05-explainer-building-blocks.arc42.md +7 -3
  30. package/explainer/e2e/runtime.e2e.ts +6 -0
  31. package/explainer/package.json +2 -1
  32. package/interactive/package.json +2 -1
  33. package/package.json +38 -2
  34. package/quiz/docs/arc42/05-quiz-building-blocks.arc42.md +7 -3
  35. package/quiz/e2e/runtime.e2e.ts +5 -0
  36. package/quiz/package.json +2 -1
  37. package/runtime/explainer/assessment-page.ts +2 -0
  38. package/runtime/explainer/e2e.ts +204 -149
  39. package/runtime/explainer/exercises.ts +2 -0
  40. package/runtime/explainer/outline.ts +8 -1
  41. package/runtime/quiz/e2e.ts +172 -135
  42. package/runtime/shared/questions/question.ts +3 -0
  43. package/runtime/shared/questions/question.v1.schema.json +4 -0
  44. package/runtime/shared/questions/render.ts +14 -6
  45. package/runtime/shared/text/markdown.ts +1 -1
  46. package/runtime/shared/text/speech.ts +39 -0
  47. package/scaffold/explainer/src/app.yaml +5 -0
  48. package/scaffold/explainer/src/thema/chapter.yaml +4 -0
  49. package/scaffold/explainer/src/thema/einstieg.yaml +28 -0
  50. package/scaffold/interactive/src/app.yaml +8 -0
  51. package/scaffold/interactive/src/core/index.ts +23 -0
  52. package/scaffold/interactive/src/messages/de.json +6 -0
  53. package/scaffold/interactive/src/playthroughs/zaehlen.yaml +12 -0
  54. package/scaffold/interactive/src/ui/App.vue +27 -0
  55. package/scaffold/quiz/src/quiz.json +31 -0
@@ -1,9 +1,14 @@
1
- // The end-to-end tests of every quiz (@lernapps/app-templates/quiz/e2e), run by `lernapps check --pre-push` on the built
2
- // app with the app's own question bank. An app's e2e/quiz.e2e.ts is
1
+ // The end-to-end tests of the quiz (@lernapps/app-templates/quiz/e2e), run by `lernapps check --pre-push` on the built
2
+ // app with its own question bank, in two parts. quizTests() tests the app's content, and every app runs it: its
3
+ // e2e/quiz.e2e.ts, part of the scaffold, is
3
4
  //
4
5
  // import { quizTests } from "@lernapps/app-templates/quiz/e2e";
5
6
  // quizTests();
6
7
  //
8
+ // Each question's link opens it with its text and what it shows, its images loaded; without JavaScript every question
9
+ // and its solution can be read; each other language has its page, its switch, and what a question shows there.
10
+ // quizRuntimeTests() tests the runtime, the same in every app; only this repo runs it, on the template and its
11
+ // fixtures (quiz/e2e/runtime.e2e.ts, not in the scaffold). What follows is what the two parts check together.
7
12
  // For each question: its deep link opens it, the right answer counts and a wrong one does not; after either, the
8
13
  // background of every option, the explanation and the links for further reading show, the learner's choice marked.
9
14
  // The feedback then has the focus and its top is in view, and the button to the next question comes after it.
@@ -37,8 +42,29 @@ const de = QUIZ_MESSAGES.de;
37
42
  /** The quiz's own texts in `lang`. */
38
43
  const messagesOf = messagesIn;
39
44
 
40
- /** The quiz's tests, for the question bank in `bank` (relative to the app's root). */
45
+ /**
46
+ * The tests of a quiz's content, for the question bank in `bank` (relative to the app's root): what every app runs
47
+ * (e2e/quiz.e2e.ts). Each question's link opens it with what it shows; without JavaScript every question and its
48
+ * solution can be read; each other language has its page. What the runtime does the same in every app is
49
+ * quizRuntimeTests.
50
+ */
41
51
  export function quizTests(bank = "src/quiz.json"): void {
52
+ quizSuite(bank, "content");
53
+ }
54
+
55
+ /**
56
+ * The tests of the quiz's runtime on the question bank in `bank`: the right answer counts and a wrong one does not, with
57
+ * the feedback in view; the seed and the order; a run through every question to the full score; how many questions
58
+ * the learner chooses; what is stored. The template folder and the fixtures of lernapps/app-templates run them
59
+ * (e2e/runtime.e2e.ts); an app does not, its scaffold has no such file: they test the runtime, which is the same in
60
+ * every app, and their time grows with the bank (lernapps/app-templates#125).
61
+ */
62
+ export function quizRuntimeTests(bank = "src/quiz.json"): void {
63
+ quizSuite(bank, "runtime");
64
+ }
65
+
66
+ function quizSuite(bank: string, part: "content" | "runtime"): void {
67
+ const content = part === "content";
42
68
  const all = JSON.parse(readFileSync(bank, "utf8")) as Quiz<Text>;
43
69
  const languages = quizLanguages(all);
44
70
  const quiz = quizIn(all, "de");
@@ -48,94 +74,115 @@ export function quizTests(bank = "src/quiz.json"): void {
48
74
  // answer: with motion, every click would wait for a scroll to settle, the run through a quiz grow with its size.
49
75
  test.use({ reducedMotion: "reduce" });
50
76
 
51
- for (const item of quiz.questions) {
52
- test(`${item.id} (${item.type}): its link opens it and the right answer counts`, async ({ page }) => {
53
- await page.goto(`./#${questionId(item)}`);
54
- await expect(question(page, item)).toBeVisible();
55
- await expect(page.locator(".question:visible")).toHaveCount(1);
56
- await answerQuestion(page, item, rightAnswer(item));
57
- await expect(question(page, item).locator(".question-feedback")).toContainText(de.right);
58
- await expectFeedbackInView(page, item);
59
- // from the feedback, the keyboard reaches the next question (past the links of the background)
60
- const next = question(page, item).locator(".question-next");
61
- for (let tab = 0; tab < 30 && !(await next.evaluate((button) => button === document.activeElement)); tab++) {
62
- await page.keyboard.press("Tab");
63
- }
64
- await expect(next).toBeFocused();
65
- const reveal = question(page, item).locator(".question-reveal");
66
- await expectBackground(reveal, item);
67
- if (item.type === "single-choice" || item.type === "multiple-choice") {
68
- const right = item.options.filter((option) => option.correct).length;
69
- await expect(reveal.locator(".question-badge-right")).toHaveCount(right);
70
- await expect(reveal.locator(".question-badge-right").first()).toHaveText(de.rightOption);
71
- } else {
72
- await expect(reveal).toContainText(de.rightAnswer);
73
- }
77
+ if (content) {
78
+ for (const item of quiz.questions) {
79
+ test(`${item.id} (${item.type}): its link opens it, with what it shows`, async ({ page }) => {
80
+ await page.goto(`./#${questionId(item)}`);
81
+ await expect(question(page, item)).toBeVisible();
82
+ await expect(page.locator(".question:visible")).toHaveCount(1);
83
+ await expect(question(page, item).locator("legend")).toHaveText(item.text);
84
+ if (item.material !== undefined) await expectMaterial(page, item);
85
+ });
86
+ }
87
+
88
+ test.describe("without JavaScript", () => {
89
+ test.use({ javaScriptEnabled: false });
90
+
91
+ test("every question and its solution can be read", async ({ page }) => {
92
+ await page.goto("./");
93
+ for (const item of quiz.questions) {
94
+ await expect(question(page, item).locator("legend")).toHaveText(item.text);
95
+ await expectMaterial(page, item, { loaded: false });
96
+ await expectBackground(page.locator(`#loesung-${item.id}`), item);
97
+ }
98
+ });
74
99
  });
100
+ }
101
+
102
+ if (!content)
103
+ for (const item of quiz.questions) {
104
+ test(`${item.id} (${item.type}): its link opens it and the right answer counts`, async ({ page }) => {
105
+ await page.goto(`./#${questionId(item)}`);
106
+ await expect(question(page, item)).toBeVisible();
107
+ await expect(page.locator(".question:visible")).toHaveCount(1);
108
+ await answerQuestion(page, item, rightAnswer(item));
109
+ await expect(question(page, item).locator(".question-feedback")).toContainText(de.right);
110
+ await expectFeedbackInView(page, item);
111
+ // from the feedback, the keyboard reaches the next question (past the links of the background)
112
+ const next = question(page, item).locator(".question-next");
113
+ for (let tab = 0; tab < 30 && !(await next.evaluate((button) => button === document.activeElement)); tab++) {
114
+ await page.keyboard.press("Tab");
115
+ }
116
+ await expect(next).toBeFocused();
117
+ const reveal = question(page, item).locator(".question-reveal");
118
+ await expectBackground(reveal, item);
119
+ if (item.type === "single-choice" || item.type === "multiple-choice") {
120
+ const right = item.options.filter((option) => option.correct).length;
121
+ await expect(reveal.locator(".question-badge-right")).toHaveCount(right);
122
+ await expect(reveal.locator(".question-badge-right").first()).toHaveText(de.rightOption);
123
+ } else {
124
+ await expect(reveal).toContainText(de.rightAnswer);
125
+ }
126
+ });
75
127
 
76
- if (item.material !== undefined) {
77
- test(`${item.id} (${item.type}): what it shows is there, and its images load`, async ({ page }) => {
128
+ test(`${item.id} (${item.type}): a wrong answer does not count`, async ({ page }) => {
78
129
  await page.goto(`./#${questionId(item)}`);
79
- await expectMaterial(page, item);
130
+ await answerQuestion(page, item, wrongAnswer(item));
131
+ await expect(question(page, item).locator(".question-feedback")).toContainText(de.wrong);
132
+ await expectFeedbackInView(page, item);
133
+ await expectBackground(question(page, item).locator(".question-reveal"), item);
134
+ if (item.type === "single-choice" || item.type === "multiple-choice") {
135
+ await expect(question(page, item).locator(".question-badge-chosen").first()).toHaveText(de.yourChoice);
136
+ }
80
137
  });
81
138
  }
82
139
 
83
- test(`${item.id} (${item.type}): a wrong answer does not count`, async ({ page }) => {
84
- await page.goto(`./#${questionId(item)}`);
85
- await answerQuestion(page, item, wrongAnswer(item));
86
- await expect(question(page, item).locator(".question-feedback")).toContainText(de.wrong);
87
- await expectFeedbackInView(page, item);
88
- await expectBackground(question(page, item).locator(".question-reveal"), item);
89
- if (item.type === "single-choice" || item.type === "multiple-choice") {
90
- await expect(question(page, item).locator(".question-badge-chosen").first()).toHaveText(de.yourChoice);
140
+ if (!content)
141
+ test("the seed in the address gives the order; without one, the address gets one", async ({ page }) => {
142
+ const everything = `&n=${quiz.questions.length}`;
143
+ const order = async (address: string) => {
144
+ await page.goto(address);
145
+ return page.locator(".question").evaluateAll((items) => items.map((item) => item.id).join());
146
+ };
147
+ const first = await order(`./?seed=11${everything}`);
148
+ expect(await order(`./?seed=11${everything}`)).toBe(first);
149
+ if (quiz.questions.length > 2) {
150
+ const others = [];
151
+ for (const seed of [12, 13, 14, 15, 16]) others.push(await order(`./?seed=${seed}${everything}`));
152
+ expect(others.some((other) => other !== first)).toBe(true);
91
153
  }
154
+ await page.goto("./");
155
+ await expect.poll(() => new URL(page.url()).searchParams.get("seed")).toMatch(/^\d+$/);
92
156
  });
93
- }
94
157
 
95
- test("the seed in the address gives the order; without one, the address gets one", async ({ page }) => {
96
- const everything = `&n=${quiz.questions.length}`;
97
- const order = async (address: string) => {
98
- await page.goto(address);
99
- return page.locator(".question").evaluateAll((items) => items.map((item) => item.id).join());
100
- };
101
- const first = await order(`./?seed=11${everything}`);
102
- expect(await order(`./?seed=11${everything}`)).toBe(first);
103
- if (quiz.questions.length > 2) {
104
- const others = [];
105
- for (const seed of [12, 13, 14, 15, 16]) others.push(await order(`./?seed=${seed}${everything}`));
106
- expect(others.some((other) => other !== first)).toBe(true);
107
- }
108
- await page.goto("./");
109
- await expect.poll(() => new URL(page.url()).searchParams.get("seed")).toMatch(/^\d+$/);
110
- });
111
-
112
- test("all right answers give the full score, then the solutions show", async ({ page }) => {
113
- await page.goto("./");
114
- if (quiz.questions.length > 1) await page.locator(".quiz-start button").click();
115
- expect(await page.evaluate(() => Object.keys(localStorage))).toEqual([]);
116
- for (let step = 0; step < quiz.questions.length; step++) {
117
- const id = await page.locator(".question:visible").getAttribute("data-id");
118
- const item = quiz.questions.find((candidate) => candidate.id === id);
119
- if (item === undefined) throw new Error(`no visible question at step ${step + 1}`);
120
- await answerQuestion(page, item, rightAnswer(item));
121
- await question(page, item).locator(".question-next").click();
122
- }
123
- const total = String(quiz.questions.length);
124
- await expect(page.locator(".quiz-score")).toHaveText(
125
- de.score.replace("{score}", total).replace("{total}", total),
126
- );
127
- await expect(page.locator("#loesungen")).toBeVisible();
128
- // only the last result, and only when the bank asks for it, under the app's own prefix
129
- const stored = await page.evaluate(() => Object.keys(localStorage));
130
- if (quiz.rememberLastResult === true) {
131
- expect(stored).toHaveLength(1);
132
- expect(stored[0]).toMatch(/^lernapps:.+:last-result$/);
133
- } else {
134
- expect(stored).toEqual([]);
135
- }
136
- });
158
+ if (!content)
159
+ test("all right answers give the full score, then the solutions show", async ({ page }) => {
160
+ await page.goto("./");
161
+ if (quiz.questions.length > 1) await page.locator(".quiz-start button").click();
162
+ expect(await page.evaluate(() => Object.keys(localStorage))).toEqual([]);
163
+ for (let step = 0; step < quiz.questions.length; step++) {
164
+ const id = await page.locator(".question:visible").getAttribute("data-id");
165
+ const item = quiz.questions.find((candidate) => candidate.id === id);
166
+ if (item === undefined) throw new Error(`no visible question at step ${step + 1}`);
167
+ await answerQuestion(page, item, rightAnswer(item));
168
+ await question(page, item).locator(".question-next").click();
169
+ }
170
+ const total = String(quiz.questions.length);
171
+ await expect(page.locator(".quiz-score")).toHaveText(
172
+ de.score.replace("{score}", total).replace("{total}", total),
173
+ );
174
+ await expect(page.locator("#loesungen")).toBeVisible();
175
+ // only the last result, and only when the bank asks for it, under the app's own prefix
176
+ const stored = await page.evaluate(() => Object.keys(localStorage));
177
+ if (quiz.rememberLastResult === true) {
178
+ expect(stored).toHaveLength(1);
179
+ expect(stored[0]).toMatch(/^lernapps:.+:last-result$/);
180
+ } else {
181
+ expect(stored).toEqual([]);
182
+ }
183
+ });
137
184
 
138
- if (quiz.questions.length > 1) {
185
+ if (!content && quiz.questions.length > 1) {
139
186
  test("the learner chooses how many questions to answer; the address keeps the choice", async ({ page }) => {
140
187
  await page.goto("./?seed=11");
141
188
  await expect(page.locator(".quiz-start")).toBeVisible();
@@ -163,70 +210,60 @@ export function quizTests(bank = "src/quiz.json"): void {
163
210
  });
164
211
  }
165
212
 
166
- test.describe("without JavaScript", () => {
167
- test.use({ javaScriptEnabled: false });
168
-
169
- test("every question and its solution can be read", async ({ page }) => {
170
- await page.goto("./");
171
- for (const item of quiz.questions) {
172
- await expect(question(page, item).locator("legend")).toHaveText(item.text);
173
- await expectMaterial(page, item, { loaded: false });
174
- await expectBackground(page.locator(`#loesung-${item.id}`), item);
175
- }
176
- });
177
- });
178
-
179
213
  for (const lang of languages.slice(1)) {
180
214
  const local = quizIn(all, lang);
181
215
  const words = messagesOf(lang);
182
216
  const address = `./${lang}/`;
183
217
 
184
218
  test.describe(`in ${lang}`, () => {
185
- test.describe("without JavaScript", () => {
186
- test.use({ javaScriptEnabled: false });
187
-
188
- test(`the page is in ${lang}, every question and its solution can be read, the switch leads to every language`, async ({
189
- page,
190
- }) => {
191
- await page.goto(address);
192
- await expect(page.locator("html")).toHaveAttribute("lang", lang);
193
- await expect(page.locator("h1")).toHaveText(local.title);
194
- await expect(page.locator("#loesungen h2")).toHaveText(words.solutions);
195
- for (const item of local.questions) {
196
- await expect(question(page, item).locator("legend")).toHaveText(item.text);
197
- await expectMaterial(page, item, { loaded: false });
198
- await expectBackground(page.locator(`#loesung-${item.id}`), item);
199
- }
200
- for (const other of languages) {
201
- await expect(page.locator(`link[rel="alternate"][hreflang="${other}"]`)).toHaveCount(1);
202
- }
203
- for (const other of languages.filter((candidate) => candidate !== lang)) {
219
+ if (content)
220
+ test.describe("without JavaScript", () => {
221
+ test.use({ javaScriptEnabled: false });
222
+
223
+ test(`the page is in ${lang}, every question and its solution can be read, the switch leads to every language`, async ({
224
+ page,
225
+ }) => {
204
226
  await page.goto(address);
205
- await page.locator(`.page-languages a[hreflang="${other}"]`).click();
206
- await expect(page.locator("html")).toHaveAttribute("lang", other);
207
- await expect(page.locator("h1")).toHaveText(quizIn(all, other).title);
208
- await expect(page.locator('.page-languages a[aria-current="page"]')).toHaveAttribute("hreflang", other);
209
- }
227
+ await expect(page.locator("html")).toHaveAttribute("lang", lang);
228
+ await expect(page.locator("h1")).toHaveText(local.title);
229
+ await expect(page.locator("#loesungen h2")).toHaveText(words.solutions);
230
+ for (const item of local.questions) {
231
+ await expect(question(page, item).locator("legend")).toHaveText(item.text);
232
+ await expectMaterial(page, item, { loaded: false });
233
+ await expectBackground(page.locator(`#loesung-${item.id}`), item);
234
+ }
235
+ for (const other of languages) {
236
+ await expect(page.locator(`link[rel="alternate"][hreflang="${other}"]`)).toHaveCount(1);
237
+ }
238
+ for (const other of languages.filter((candidate) => candidate !== lang)) {
239
+ await page.goto(address);
240
+ await page.locator(`.page-languages a[hreflang="${other}"]`).click();
241
+ await expect(page.locator("html")).toHaveAttribute("lang", other);
242
+ await expect(page.locator("h1")).toHaveText(quizIn(all, other).title);
243
+ await expect(page.locator('.page-languages a[aria-current="page"]')).toHaveAttribute("hreflang", other);
244
+ }
245
+ });
210
246
  });
211
- });
212
247
 
213
- test(`what a question shows, in ${lang}`, async ({ page }) => {
214
- const item = local.questions.find((candidate) => candidate.material !== undefined);
215
- test.skip(item === undefined, "no question shows content");
216
- if (item === undefined) return;
217
- await page.goto(`${address}#${questionId(item)}`);
218
- await expectMaterial(page, item);
219
- });
248
+ if (content)
249
+ test(`what a question shows, in ${lang}`, async ({ page }) => {
250
+ const item = local.questions.find((candidate) => candidate.material !== undefined);
251
+ test.skip(item === undefined, "no question shows content");
252
+ if (item === undefined) return;
253
+ await page.goto(`${address}#${questionId(item)}`);
254
+ await expectMaterial(page, item);
255
+ });
220
256
 
221
- test(`one question, answered in ${lang}`, async ({ page }) => {
222
- const item = local.questions[0];
223
- if (item === undefined) throw new Error("the quiz has no questions");
224
- await page.goto(`${address}#${questionId(item)}`);
225
- await expect(question(page, item)).toBeVisible();
226
- await answerQuestion(page, item, rightAnswer(item), lang);
227
- await expect(question(page, item).locator(".question-feedback")).toContainText(words.right);
228
- await expectBackground(question(page, item).locator(".question-reveal"), item);
229
- });
257
+ if (!content)
258
+ test(`one question, answered in ${lang}`, async ({ page }) => {
259
+ const item = local.questions[0];
260
+ if (item === undefined) throw new Error("the quiz has no questions");
261
+ await page.goto(`${address}#${questionId(item)}`);
262
+ await expect(question(page, item)).toBeVisible();
263
+ await answerQuestion(page, item, rightAnswer(item), lang);
264
+ await expect(question(page, item).locator(".question-feedback")).toContainText(words.right);
265
+ await expectBackground(question(page, item).locator(".question-reveal"), item);
266
+ });
230
267
  });
231
268
  }
232
269
  });
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ # yaml-language-server: $schema=../node_modules/@lernapps/app-templates/runtime/explainer/explainer.v1.schema.json
2
+ title: Platzhalter
3
+ intro: Dieses Kapitel hält den Platz frei, bis der geplante Inhalt da ist.
4
+ languages: [de]
5
+ chapters: [thema]
@@ -0,0 +1,4 @@
1
+ # yaml-language-server: $schema=../../node_modules/@lernapps/app-templates/runtime/explainer/explainer.v1.schema.json
2
+ title: Thema
3
+ summary: Ersetze dieses Kapitel durch das erste geplante.
4
+ pages: [einstieg]
@@ -0,0 +1,28 @@
1
+ # yaml-language-server: $schema=../../node_modules/@lernapps/app-templates/runtime/explainer/explainer.v1.schema.json
2
+ title: Einstieg
3
+ description: Eine Seite, die den Platz frei hält.
4
+ blocks:
5
+ - type: section
6
+ id: worum-es-geht
7
+ title: Worum es geht
8
+ blocks:
9
+ - type: text
10
+ text: Ersetze diese Seite durch die erste geplante.
11
+ - type: section
12
+ id: uebung
13
+ title: Übung
14
+ blocks:
15
+ - type: single-choice
16
+ id: platzhalter
17
+ text: Welche Antwort ist richtig?
18
+ explanation: Diese Übung ist ein Platzhalter. Ersetze sie durch die erste geplante.
19
+ links:
20
+ - title: lernapps.net
21
+ url: https://lernapps.net/
22
+ options:
23
+ - text: Diese
24
+ correct: true
25
+ info: "Richtig: Diese Antwort ist als richtig markiert."
26
+ - text: Diese nicht
27
+ correct: false
28
+ info: "Falsch: Diese Antwort ist als falsch markiert."
@@ -0,0 +1,8 @@
1
+ # yaml-language-server: $schema=../node_modules/@lernapps/app-templates/runtime/interactive/interactive.v1.schema.json
2
+ title: Platzhalter
3
+ description: Ein Knopf, der bis drei zählt. Er hält den Platz frei, bis die geplante App da ist.
4
+ howto: |
5
+ - Drücke „Weiter“, bis du bei drei bist.
6
+ competencies:
7
+ - Ich kann bis drei zählen.
8
+ languages: [de]
@@ -0,0 +1,23 @@
1
+ // The app's logic, a placeholder: a count from 0 to 3, one step for each "weiter" (the button of the component).
2
+ // Pure TypeScript without DOM; the runtime keeps the state and calls start and step
3
+ // (@lernapps/app-templates/interactive). Replace it with the planned core.
4
+ import type { Input } from "@lernapps/app-templates/interactive";
5
+
6
+ export interface State {
7
+ seed: number;
8
+ count: number;
9
+ done: boolean;
10
+ }
11
+
12
+ /** How far the count goes. */
13
+ export const GOAL = 3;
14
+
15
+ export function start(seed: number): State {
16
+ return { seed, count: 0, done: false };
17
+ }
18
+
19
+ export function step(state: State, input: Input): State {
20
+ if (state.done || input.kind !== "action" || input.name !== "weiter") return state;
21
+ const count = state.count + 1;
22
+ return { ...state, count, done: count >= GOAL };
23
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "app": "Zähler",
3
+ "count": "{count} von {goal}",
4
+ "next": "Weiter",
5
+ "done": "Fertig: bis drei gezählt."
6
+ }
@@ -0,0 +1,12 @@
1
+ # yaml-language-server: $schema=../../node_modules/@lernapps/app-templates/runtime/interactive/playthrough.v1.schema.json
2
+ # Three times "weiter": the count reaches three and the app is done; a fourth press changes nothing.
3
+ seed: 1
4
+ steps:
5
+ - inputs: [{ kind: action, name: weiter }]
6
+ state: { count: 1, done: false }
7
+ announce: 1 von 3
8
+ - inputs: [{ kind: action, name: weiter }, { kind: action, name: weiter }]
9
+ state: { count: 3, done: true }
10
+ announce: "Fertig: bis drei gezählt."
11
+ - inputs: [{ kind: action, name: weiter }]
12
+ state: { count: 3, done: true }
@@ -0,0 +1,27 @@
1
+ <script setup lang="ts">
2
+ // A placeholder: the count and a button that emits the action "weiter". Replace it with the planned component.
3
+ import { watch } from "vue";
4
+ import { announce } from "@lernapps/tooling/a11y";
5
+ import { translator } from "@lernapps/tooling/i18n";
6
+ import type { Input } from "@lernapps/app-templates/interactive";
7
+ import { GOAL, type State } from "../core/index.ts";
8
+ import de from "../messages/de.json";
9
+
10
+ const props = defineProps<{ state: State; lang: string }>();
11
+ const emit = defineEmits<{ input: [input: Input] }>();
12
+ const t = translator(de);
13
+
14
+ watch(
15
+ () => props.state.count,
16
+ (count) => announce(props.state.done ? t("done") : t("count", { count, goal: GOAL })),
17
+ );
18
+ </script>
19
+
20
+ <template>
21
+ <section :aria-label="t('app')">
22
+ <p>{{ state.done ? t("done") : t("count", { count: state.count, goal: GOAL }) }}</p>
23
+ <button v-if="!state.done" type="button" @click="emit('input', { kind: 'action', name: 'weiter' })">
24
+ {{ t("next") }}
25
+ </button>
26
+ </section>
27
+ </template>