@lernapps/app-templates 0.0.1 → 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.
- package/LICENSE +22 -0
- package/README.md +195 -2
- package/dist/appname-Bh23cUuz.mjs +12 -0
- package/dist/assessment-Dy359O1O.mjs +1696 -0
- package/dist/block-BK_ohsH1.mjs +82 -0
- package/dist/blocks-CxtCPAa5.mjs +149 -0
- package/dist/blocks.mjs +4 -0
- package/dist/check-CteIFN96.mjs +254 -0
- package/dist/content-DO6oEJxj.mjs +140 -0
- package/dist/contract-PONpxJtd.mjs +52 -0
- package/dist/e2e-CU0n_unV.mjs +89 -0
- package/dist/engine-CArxqizW.mjs +136 -0
- package/dist/explainer-e2e.mjs +1311 -0
- package/dist/explainer-islands.mjs +197 -0
- package/dist/explainer-plugin.mjs +2132 -0
- package/dist/explainer.mjs +1246 -0
- package/dist/html-mffkHGHh.mjs +89 -0
- package/dist/image-CspJVfyQ.mjs +21 -0
- package/dist/interactive-e2e.mjs +140 -0
- package/dist/interactive-plugin.mjs +242 -0
- package/dist/interactive.mjs +75 -0
- package/dist/load-RosvaPZf.mjs +16976 -0
- package/dist/needs-BzEahJ6X.mjs +97 -0
- package/dist/pages-9RaaomoO.mjs +81 -0
- package/dist/questions-e2e.mjs +1 -0
- package/dist/questions-engine.mjs +3 -0
- package/dist/questions.mjs +6 -0
- package/dist/quiz-DnW-xtCh.mjs +70 -0
- package/dist/quiz-e2e.mjs +174 -0
- package/dist/quiz-plugin.mjs +266 -0
- package/dist/quiz.mjs +246 -0
- package/dist/render-D5kwkNt1.mjs +507 -0
- package/dist/render-DqHOJyHK.mjs +146 -0
- package/dist/render-GS1Htlu8.mjs +545 -0
- package/dist/result-D_mmCVwN.mjs +180 -0
- package/dist/tasks-DADUYy52.mjs +74 -0
- package/dist/validate-BDwL7Su3.mjs +1076 -0
- package/dist/validate-Bxy9NfB8.mjs +146 -0
- package/dist/vue-DO1m6q1R.mjs +40 -0
- package/dist/vue-code-BkJjW_I_.mjs +320 -0
- package/docs/arc42/01-introduction-and-goals.arc42.md +82 -0
- package/docs/arc42/02-constraints.arc42.md +228 -0
- package/docs/arc42/03-system-context.arc42.md +118 -0
- package/docs/arc42/04-solution-strategy.arc42.md +47 -0
- package/docs/arc42/05-building-blocks.arc42.md +307 -0
- package/docs/arc42/06-runtime-view.arc42.md +98 -0
- package/docs/arc42/07-deployment-view.arc42.md +160 -0
- package/docs/arc42/08-concepts.arc42.md +123 -0
- package/docs/arc42/09-decisions.arc42.md +246 -0
- package/docs/arc42/10-quality-requirements.arc42.md +155 -0
- package/docs/arc42/11-risks.arc42.md +113 -0
- package/docs/arc42/12-glossary.arc42.md +162 -0
- package/docs/arc42/architecture-evidence.md +395 -0
- package/explainer/.arc42ignore +3 -0
- package/explainer/README.md +54 -0
- package/explainer/_gitignore +5 -0
- package/explainer/docs/arc42/03-explainer-system-context.arc42.md +35 -0
- package/explainer/docs/arc42/05-explainer-building-blocks.arc42.md +659 -0
- package/explainer/docs/arc42/06-explainer-runtime-view.arc42.md +105 -0
- package/explainer/docs/arc42/07-explainer-deployment-view.arc42.md +34 -0
- package/explainer/docs/arc42/08-explainer-concepts.arc42.md +17 -0
- package/explainer/docs/arc42/09-explainer-decisions.arc42.md +443 -0
- package/explainer/docs/arc42/10-explainer-quality-requirements.arc42.md +54 -0
- package/explainer/docs/arc42/11-explainer-risks.arc42.md +17 -0
- package/explainer/docs/arc42/12-explainer-glossary.arc42.md +185 -0
- package/explainer/e2e/explainer.e2e.ts +3 -0
- package/explainer/index.html +11 -0
- package/explainer/package.json +18 -0
- package/explainer/playwright.config.ts +1 -0
- package/explainer/src/app.yaml +20 -0
- package/explainer/src/components/ProzentFeld.test.ts +30 -0
- package/explainer/src/components/ProzentFeld.ts +121 -0
- package/explainer/src/components/ProzentFeld.vue +84 -0
- package/explainer/src/images/hunderterfeld-klammern.svg +105 -0
- package/explainer/src/main.ts +4 -0
- package/explainer/src/prozent/chapter.yaml +18 -0
- package/explainer/src/prozent/grundwert.yaml +115 -0
- package/explainer/src/prozent/prozentsatz.yaml +99 -0
- package/explainer/src/prozent/prozentwert.yaml +119 -0
- package/explainer/tsconfig.json +1 -0
- package/explainer/vite.config.ts +4 -0
- package/interactive/.arc42ignore +5 -0
- package/interactive/README.md +28 -0
- package/interactive/_gitignore +4 -0
- package/interactive/docs/arc42/05-interactive-building-blocks.arc42.md +518 -0
- package/interactive/docs/arc42/06-interactive-runtime-view.arc42.md +58 -0
- package/interactive/docs/arc42/07-interactive-deployment-view.arc42.md +35 -0
- package/interactive/docs/arc42/08-interactive-concepts.arc42.md +52 -0
- package/interactive/docs/arc42/09-interactive-decisions.arc42.md +130 -0
- package/interactive/docs/arc42/10-interactive-quality-requirements.arc42.md +45 -0
- package/interactive/docs/arc42/11-interactive-risks.arc42.md +20 -0
- package/interactive/docs/arc42/12-interactive-glossary.arc42.md +80 -0
- package/interactive/e2e/interactive.e2e.ts +3 -0
- package/interactive/index.html +11 -0
- package/interactive/package.json +18 -0
- package/interactive/playwright.config.ts +1 -0
- package/interactive/src/app.yaml +28 -0
- package/interactive/src/core/index.ts +101 -0
- package/interactive/src/data/lessons.json +4 -0
- package/interactive/src/env.d.ts +6 -0
- package/interactive/src/main.ts +6 -0
- package/interactive/src/messages/de.json +12 -0
- package/interactive/src/playthroughs/grundreihe.yaml +33 -0
- package/interactive/src/playthroughs/obere-reihe.yaml +14 -0
- package/interactive/src/ui/App.vue +85 -0
- package/interactive/tsconfig.json +1 -0
- package/interactive/vite.config.ts +4 -0
- package/package.json +143 -4
- package/quiz/README.md +31 -0
- package/quiz/_gitignore +5 -0
- package/quiz/docs/arc42/05-quiz-building-blocks.arc42.md +283 -0
- package/quiz/docs/arc42/06-quiz-runtime-view.arc42.md +21 -0
- package/quiz/docs/arc42/07-quiz-deployment-view.arc42.md +33 -0
- package/quiz/docs/arc42/09-quiz-decisions.arc42.md +36 -0
- package/quiz/docs/arc42/10-quiz-quality-requirements.arc42.md +53 -0
- package/quiz/docs/arc42/11-quiz-risks.arc42.md +19 -0
- package/quiz/docs/arc42/12-quiz-glossary.arc42.md +27 -0
- package/quiz/e2e/quiz.e2e.ts +3 -0
- package/quiz/index.html +11 -0
- package/quiz/package.json +18 -0
- package/quiz/playwright.config.ts +1 -0
- package/quiz/src/images/berge-ordnen.jpg +0 -0
- package/quiz/src/images/everest-hoehe.jpg +0 -0
- package/quiz/src/images/galapagos.jpg +0 -0
- package/quiz/src/images/grand-canyon.jpg +0 -0
- package/quiz/src/images/totes-meer.jpg +0 -0
- package/quiz/src/images/uluru.jpg +0 -0
- package/quiz/src/images/wasserfaelle-laender.jpg +0 -0
- package/quiz/src/main.ts +4 -0
- package/quiz/src/quiz.json +312 -0
- package/quiz/tsconfig.json +1 -0
- package/quiz/vite.config.ts +4 -0
- package/runtime/explainer/assessment-client.ts +478 -0
- package/runtime/explainer/assessment-page.ts +448 -0
- package/runtime/explainer/assessment.ts +393 -0
- package/runtime/explainer/check-page.ts +520 -0
- package/runtime/explainer/components/exercise.ts +409 -0
- package/runtime/explainer/components/props.ts +230 -0
- package/runtime/explainer/components/read.ts +428 -0
- package/runtime/explainer/content.ts +277 -0
- package/runtime/explainer/e2e.ts +1488 -0
- package/runtime/explainer/exercises.ts +326 -0
- package/runtime/explainer/explainer.css +1283 -0
- package/runtime/explainer/explainer.ts +838 -0
- package/runtime/explainer/explainer.v1.schema.json +1165 -0
- package/runtime/explainer/expression.ts +407 -0
- package/runtime/explainer/fraction.ts +107 -0
- package/runtime/explainer/generated/comparator.ts +31 -0
- package/runtime/explainer/generated/question.ts +165 -0
- package/runtime/explainer/generated/soundness.ts +280 -0
- package/runtime/explainer/generated/texts.ts +257 -0
- package/runtime/explainer/generated/types.ts +103 -0
- package/runtime/explainer/generated/values.ts +136 -0
- package/runtime/explainer/islands.ts +281 -0
- package/runtime/explainer/load.ts +457 -0
- package/runtime/explainer/messages/de.json +200 -0
- package/runtime/explainer/messages/en.json +200 -0
- package/runtime/explainer/messages.ts +13 -0
- package/runtime/explainer/needs.ts +122 -0
- package/runtime/explainer/open-task.ts +235 -0
- package/runtime/explainer/outline.ts +306 -0
- package/runtime/explainer/plugin.ts +488 -0
- package/runtime/explainer/position.ts +62 -0
- package/runtime/explainer/prompt.ts +75 -0
- package/runtime/explainer/render.ts +686 -0
- package/runtime/explainer/result.ts +238 -0
- package/runtime/explainer/routes.ts +119 -0
- package/runtime/explainer/schema.ts +140 -0
- package/runtime/explainer/scope.ts +87 -0
- package/runtime/explainer/stuck.ts +57 -0
- package/runtime/explainer/tasks.ts +109 -0
- package/runtime/explainer/tutor.ts +469 -0
- package/runtime/explainer/tutorpage.ts +669 -0
- package/runtime/explainer/video.ts +123 -0
- package/runtime/interactive/contract.ts +121 -0
- package/runtime/interactive/e2e.ts +190 -0
- package/runtime/interactive/interactive.css +82 -0
- package/runtime/interactive/interactive.ts +115 -0
- package/runtime/interactive/interactive.v1.schema.json +146 -0
- package/runtime/interactive/load.ts +188 -0
- package/runtime/interactive/messages/de.json +14 -0
- package/runtime/interactive/messages/en.json +14 -0
- package/runtime/interactive/messages.ts +11 -0
- package/runtime/interactive/playthrough.v1.schema.json +143 -0
- package/runtime/interactive/plugin.ts +266 -0
- package/runtime/interactive/render.ts +126 -0
- package/runtime/quiz/e2e.ts +233 -0
- package/runtime/quiz/engine.ts +242 -0
- package/runtime/quiz/messages/de.json +23 -0
- package/runtime/quiz/messages/en.json +23 -0
- package/runtime/quiz/plugin.ts +150 -0
- package/runtime/quiz/quiz.css +154 -0
- package/runtime/quiz/quiz.ts +56 -0
- package/runtime/quiz/quiz.v1.schema.json +72 -0
- package/runtime/quiz/render.ts +173 -0
- package/runtime/quiz/validate.ts +108 -0
- package/runtime/shared/appname.ts +16 -0
- package/runtime/shared/blocks/block.ts +162 -0
- package/runtime/shared/blocks/blocks.css +190 -0
- package/runtime/shared/blocks/blocks.v1.schema.json +432 -0
- package/runtime/shared/blocks/code.ts +26 -0
- package/runtime/shared/blocks/figures.ts +49 -0
- package/runtime/shared/blocks/image.ts +25 -0
- package/runtime/shared/blocks/index.ts +13 -0
- package/runtime/shared/blocks/messages/de.json +4 -0
- package/runtime/shared/blocks/messages/en.json +4 -0
- package/runtime/shared/blocks/render.ts +134 -0
- package/runtime/shared/blocks/validate.ts +165 -0
- package/runtime/shared/content.ts +197 -0
- package/runtime/shared/examples.ts +20 -0
- package/runtime/shared/html.ts +10 -0
- package/runtime/shared/languages.ts +94 -0
- package/runtime/shared/page.css +101 -0
- package/runtime/shared/pages.ts +114 -0
- package/runtime/shared/questions/e2e.ts +115 -0
- package/runtime/shared/questions/engine.ts +209 -0
- package/runtime/shared/questions/index.ts +39 -0
- package/runtime/shared/questions/messages/de.json +26 -0
- package/runtime/shared/questions/messages/en.json +26 -0
- package/runtime/shared/questions/question.ts +433 -0
- package/runtime/shared/questions/question.v1.schema.json +346 -0
- package/runtime/shared/questions/questions.css +192 -0
- package/runtime/shared/questions/render.ts +255 -0
- package/runtime/shared/questions/validate.ts +518 -0
- package/runtime/shared/schema.ts +44 -0
- package/runtime/shared/text/check.ts +68 -0
- package/runtime/shared/text/formula.ts +69 -0
- package/runtime/shared/text/markdown.ts +185 -0
- package/runtime/shared/text/speech.ts +39 -0
- package/runtime/shared/text/text.v1.schema.json +34 -0
- package/runtime/shared/vue-code.ts +395 -0
- package/runtime/shared/vue.ts +38 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Learner texts in several languages, shared by the archetypes' runtimes (the explainer, the quiz): a text is a string while German is the app's only language, else a map of texts
|
|
2
|
+
// by language, { de: …, en: … }, complete in every declared language and in no other. German is always first.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The languages the runtime has texts for, German first. Every registry of the runtime's own texts has one entry per
|
|
6
|
+
* language (its type says so), so adding a language is complete when the compiler is.
|
|
7
|
+
*/
|
|
8
|
+
export const LANGUAGES = ["de", "en"] as const;
|
|
9
|
+
export type Language = (typeof LANGUAGES)[number];
|
|
10
|
+
|
|
11
|
+
export const isLanguage = (lang: string): lang is Language => (LANGUAGES as readonly string[]).includes(lang);
|
|
12
|
+
|
|
13
|
+
const LOCALES = { de: "de-DE", en: "en-GB" } as const satisfies Readonly<Record<Language, string>>;
|
|
14
|
+
/** The locale for numbers and dates in `lang`; German's for a language the runtime has no texts for. */
|
|
15
|
+
export const localeOf = (lang: string): string => LOCALES[isLanguage(lang) ? lang : "de"];
|
|
16
|
+
|
|
17
|
+
/** The entry of `registry` for `lang`; German's for a language the runtime has no texts for. */
|
|
18
|
+
export const textsIn = <T>(registry: Readonly<Record<Language, T>>, lang: string): T =>
|
|
19
|
+
registry[isLanguage(lang) ? lang : "de"];
|
|
20
|
+
|
|
21
|
+
/** A learner text: a string while German is the only language, else a map of texts by language. */
|
|
22
|
+
export type Text = string | Readonly<Record<string, string>>;
|
|
23
|
+
|
|
24
|
+
/** A problem with the languages of one text, in `lang`. */
|
|
25
|
+
export interface LanguageProblem {
|
|
26
|
+
lang: string;
|
|
27
|
+
message: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The text in one language; `keyed` when it comes from a map (its place is then <path>.<lang>). */
|
|
31
|
+
export interface TextIn {
|
|
32
|
+
lang: string;
|
|
33
|
+
text: string;
|
|
34
|
+
keyed: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The text in `lang`; a plain string is the same in every language. */
|
|
38
|
+
export function inLanguage(text: Text, lang: string): string {
|
|
39
|
+
return typeof text === "string" ? text : (text[lang] ?? text["de"] ?? "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The text in each of the app's `languages` that it has, and the problems of its languages: a translation missing,
|
|
44
|
+
* a language not declared (in `declaredIn`, the file with the app's languages). A plain string is German, and complete
|
|
45
|
+
* only while German is the only language; with `neutral` (a formula, a code), a plain string is the same in every
|
|
46
|
+
* language.
|
|
47
|
+
*/
|
|
48
|
+
export function textLanguages(
|
|
49
|
+
value: Text,
|
|
50
|
+
languages: readonly string[],
|
|
51
|
+
neutral = false,
|
|
52
|
+
declaredIn = "src/app.yaml",
|
|
53
|
+
): { texts: TextIn[]; problems: LanguageProblem[] } {
|
|
54
|
+
if (typeof value === "string") {
|
|
55
|
+
if (neutral) return { texts: languages.map((lang) => ({ lang, text: value, keyed: false })), problems: [] };
|
|
56
|
+
const map = `{ ${languages.map((code) => `${code}: …`).join(", ")} }`;
|
|
57
|
+
return {
|
|
58
|
+
texts: [{ lang: "de", text: value, keyed: false }],
|
|
59
|
+
problems: languages
|
|
60
|
+
.slice(1)
|
|
61
|
+
.map((lang) => ({ lang, message: `the translation is missing; write the text as a map: ${map}` })),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const problems: LanguageProblem[] = [
|
|
65
|
+
...Object.keys(value)
|
|
66
|
+
.filter((lang) => !languages.includes(lang))
|
|
67
|
+
.map((lang) => ({
|
|
68
|
+
lang,
|
|
69
|
+
message: `${lang} is not a language of the app; declare it in languages in ${declaredIn}, or remove it`,
|
|
70
|
+
})),
|
|
71
|
+
...languages.filter((lang) => !(lang in value)).map((lang) => ({ lang, message: "the translation is missing" })),
|
|
72
|
+
];
|
|
73
|
+
return {
|
|
74
|
+
texts: languages.filter((lang) => lang in value).map((lang) => ({ lang, text: value[lang] ?? "", keyed: true })),
|
|
75
|
+
problems,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What is wrong with an app's list of languages: German first, each once, each one the runtime has texts for. */
|
|
80
|
+
export function languageListProblems(languages: readonly string[], supported: readonly string[]): LanguageProblem[] {
|
|
81
|
+
const problems: LanguageProblem[] = [];
|
|
82
|
+
if (languages[0] !== "de")
|
|
83
|
+
problems.push({ lang: languages[0] ?? "", message: "German comes first: languages: [de, …]" });
|
|
84
|
+
languages.forEach((lang, index) => {
|
|
85
|
+
if (languages.indexOf(lang) !== index) problems.push({ lang, message: `${lang} is listed twice` });
|
|
86
|
+
else if (!supported.includes(lang)) {
|
|
87
|
+
problems.push({
|
|
88
|
+
lang,
|
|
89
|
+
message: `the runtime has no texts of its own in ${lang} yet; it has ${supported.join(", ")}`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
return problems;
|
|
94
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/* The frame of every page of an app (the stylesheets of the archetypes import it): the colours, plain and readable
|
|
2
|
+
at 360 px with contrast for WCAG 2.1 AA in the light and the dark scheme, the reset, the body, links, the header with
|
|
3
|
+
the language switch and the footer. An app that needs a wider frame sets --page-max-width in its own :root rule. */
|
|
4
|
+
:root {
|
|
5
|
+
--page-text: #1a1a1a;
|
|
6
|
+
--page-muted: #4a4a4a;
|
|
7
|
+
--page-background: #ffffff;
|
|
8
|
+
--page-surface: #f4f5f7;
|
|
9
|
+
--page-border: #8a8f98;
|
|
10
|
+
--page-accent: #1d4ed8;
|
|
11
|
+
--page-on-accent: #ffffff;
|
|
12
|
+
--page-right: #14532d;
|
|
13
|
+
--page-right-background: #dcfce7;
|
|
14
|
+
--page-wrong: #7f1d1d;
|
|
15
|
+
--page-wrong-background: #fee2e2;
|
|
16
|
+
color-scheme: light dark;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@media (prefers-color-scheme: dark) {
|
|
20
|
+
:root {
|
|
21
|
+
--page-text: #ececec;
|
|
22
|
+
--page-muted: #b8bcc4;
|
|
23
|
+
--page-background: #121316;
|
|
24
|
+
--page-surface: #1d1f24;
|
|
25
|
+
--page-border: #7d838d;
|
|
26
|
+
--page-accent: #8ab4ff;
|
|
27
|
+
--page-on-accent: #0b1020;
|
|
28
|
+
--page-right: #bbf7d0;
|
|
29
|
+
--page-right-background: #143522;
|
|
30
|
+
--page-wrong: #fecaca;
|
|
31
|
+
--page-wrong-background: #3f1515;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
*,
|
|
36
|
+
*::before,
|
|
37
|
+
*::after {
|
|
38
|
+
box-sizing: border-box;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
[hidden] {
|
|
42
|
+
display: none !important;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
body {
|
|
46
|
+
margin: 0;
|
|
47
|
+
background: var(--page-background);
|
|
48
|
+
color: var(--page-text);
|
|
49
|
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
50
|
+
font-size: 1.0625rem;
|
|
51
|
+
line-height: 1.55;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
a {
|
|
55
|
+
color: var(--page-accent);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
:focus-visible {
|
|
59
|
+
outline: 3px solid var(--page-accent);
|
|
60
|
+
outline-offset: 2px;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.page-header,
|
|
64
|
+
.page-footer {
|
|
65
|
+
max-width: var(--page-max-width, 44rem);
|
|
66
|
+
margin: 0 auto;
|
|
67
|
+
padding: 0 1rem;
|
|
68
|
+
overflow-wrap: break-word;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* the language switch of an app in several languages: a link to the page in each language, the current one marked */
|
|
72
|
+
.page-header {
|
|
73
|
+
display: flex;
|
|
74
|
+
flex-wrap: wrap;
|
|
75
|
+
gap: 0.25rem 1rem;
|
|
76
|
+
align-items: baseline;
|
|
77
|
+
justify-content: flex-end;
|
|
78
|
+
padding-top: 0.75rem;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.page-languages ul {
|
|
82
|
+
display: flex;
|
|
83
|
+
flex-wrap: wrap;
|
|
84
|
+
gap: 0.25rem 0.75rem;
|
|
85
|
+
margin: 0;
|
|
86
|
+
padding: 0;
|
|
87
|
+
list-style: none;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.page-languages a[aria-current="page"] {
|
|
91
|
+
color: var(--page-text);
|
|
92
|
+
font-weight: 600;
|
|
93
|
+
text-decoration: none;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.page-footer {
|
|
97
|
+
margin-top: 2rem;
|
|
98
|
+
padding-bottom: 1.5rem;
|
|
99
|
+
color: var(--page-muted);
|
|
100
|
+
font-size: 0.9375rem;
|
|
101
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// The page trees of an app in several languages, shared by the archetypes' build steps (the explainer, the quiz):
|
|
2
|
+
// German at the root, every other language below /<language>/, the same address within each tree. Each page has its
|
|
3
|
+
// language on <html>, an alternate link to itself in every language, and a language switch of plain links, so it
|
|
4
|
+
// works without JavaScript. Every URL is relative, so the app works under any path.
|
|
5
|
+
|
|
6
|
+
/** The root of a language's tree, from the app's root: "" for German, "en/" for English. */
|
|
7
|
+
export const languageRoot = (lang: string): string => (lang === "de" ? "" : `${lang}/`);
|
|
8
|
+
|
|
9
|
+
/** A relative URL from the page at `from` to `to` (both from the app's root). */
|
|
10
|
+
export function relative(from: string, to: string): string {
|
|
11
|
+
const up = "../".repeat(from.split("/").filter((part) => part !== "").length);
|
|
12
|
+
const url = `${up}${to}`;
|
|
13
|
+
return url === "" ? "./" : url.startsWith("#") ? `./${url}` : url;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The built shell's relative URLs (./assets/…, built by Vite with base "./"), from a page at `path`. */
|
|
17
|
+
export function fromDepth(html: string, path: string): string {
|
|
18
|
+
const up = "../".repeat(path.split("/").filter((part) => part !== "").length);
|
|
19
|
+
return html.replace(/((?:src|href)=")\.\/(?!\/)/g, `$1${up === "" ? "./" : up}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The page with `lang` as the language of its <html>, in place of the one it had. */
|
|
23
|
+
export const withLanguage = (html: string, lang: string): string =>
|
|
24
|
+
html.replace(/<html([^>]*?)\slang="[^"]*"/, "<html$1").replace("<html", `<html lang="${lang}"`);
|
|
25
|
+
|
|
26
|
+
/** Where a page is in its language tree, and the languages it exists in. */
|
|
27
|
+
export interface PageInLanguages {
|
|
28
|
+
/** Where the page is, from the app's root: "", "en/", "en/prozent/prozentwert/". */
|
|
29
|
+
path: string;
|
|
30
|
+
/** The same page's address within any language tree: "", "prozent/prozentwert/". */
|
|
31
|
+
local: string;
|
|
32
|
+
/** The app's languages, German first. */
|
|
33
|
+
languages: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** An alternate link to the page in each of its languages, for <head>. */
|
|
37
|
+
export const alternateLinks = ({ path, local, languages }: PageInLanguages): string[] =>
|
|
38
|
+
languages.map(
|
|
39
|
+
(lang) => `<link rel="alternate" hreflang="${lang}" href="${relative(path, languageRoot(lang) + local)}" />`,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The header of a page in `lang`: the language switch, a link to the page in each language, named in its own
|
|
44
|
+
* language, the current one marked; nothing while the app has one language. `label` and `name` give HTML, already
|
|
45
|
+
* escaped.
|
|
46
|
+
*/
|
|
47
|
+
export function pageHeader(
|
|
48
|
+
page: PageInLanguages & { lang: string; label: string; name: (lang: string) => string },
|
|
49
|
+
): string {
|
|
50
|
+
const { path, local, languages, lang: current, label, name } = page;
|
|
51
|
+
if (languages.length < 2) return "";
|
|
52
|
+
const links = languages.map(
|
|
53
|
+
(lang) =>
|
|
54
|
+
`<li><a href="${relative(path, languageRoot(lang) + local)}" hreflang="${lang}" lang="${lang}"${lang === current ? ' aria-current="page"' : ""}>${name(lang)}</a></li>`,
|
|
55
|
+
);
|
|
56
|
+
return `<header class="page-header"><nav class="page-languages" aria-label="${label}"><ul>${links.join("")}</ul></nav></header>`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The footer of a page: what the app is, and the links to the imprint and the privacy statement; texts as HTML, already escaped. */
|
|
60
|
+
export const pageFooter = (words: { footer: string; imprint: string; privacy: string }): string =>
|
|
61
|
+
`<footer class="page-footer"><p>${words.footer} · <a href="https://lernapps.net/imprint/">${words.imprint}</a> · <a href="https://lernapps.net/privacy/">${words.privacy}</a></p></footer>`;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The page for every address the app does not have, 404.html at the bundle's root: GitHub Pages serves it for any
|
|
65
|
+
* missing path below the site, at that path, so the page cannot know its own depth, and relative links in it would
|
|
66
|
+
* point below the missing path. Its script finds the app's root instead: from the missing path's folder upwards, the
|
|
67
|
+
* first folder whose 404.html exists (one HEAD request each, to the same server) is the root of the bundle that
|
|
68
|
+
* answers there: the app at /<repo>/, a preview at /<repo>/pr-preview/pr-<n>/ (which has its own 404.html), or / when
|
|
69
|
+
* served locally. It then moves on to that root's start page and points the link there. Without JavaScript the link
|
|
70
|
+
* leads to the start page of the site. German, plain language, not indexed, nothing loaded from another server.
|
|
71
|
+
*/
|
|
72
|
+
export function notFoundPage(): string {
|
|
73
|
+
const script = `(async () => {
|
|
74
|
+
const link = document.getElementById("start");
|
|
75
|
+
let folder = location.pathname.slice(0, location.pathname.lastIndexOf("/") + 1);
|
|
76
|
+
for (;;) {
|
|
77
|
+
try {
|
|
78
|
+
const found = await fetch(folder + "404.html", { method: "HEAD", cache: "no-store" });
|
|
79
|
+
if (found.ok) break;
|
|
80
|
+
} catch {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (folder === "/") return;
|
|
84
|
+
folder = folder.slice(0, folder.lastIndexOf("/", folder.length - 2) + 1);
|
|
85
|
+
}
|
|
86
|
+
link.href = folder;
|
|
87
|
+
link.textContent = "Zur Startseite der App";
|
|
88
|
+
if (folder === location.pathname) return;
|
|
89
|
+
document.getElementById("weiter").hidden = false;
|
|
90
|
+
location.replace(folder);
|
|
91
|
+
})();`;
|
|
92
|
+
return [
|
|
93
|
+
`<!doctype html>`,
|
|
94
|
+
`<html lang="de">`,
|
|
95
|
+
` <head>`,
|
|
96
|
+
` <meta charset="utf-8" />`,
|
|
97
|
+
` <meta name="viewport" content="width=device-width, initial-scale=1" />`,
|
|
98
|
+
` <meta name="robots" content="noindex" />`,
|
|
99
|
+
` <title>Seite nicht gefunden</title>`,
|
|
100
|
+
` <style>body { margin: 2rem auto; max-width: 40rem; padding: 0 1rem; font-family: system-ui, sans-serif; line-height: 1.5; color: #1a1a1a; background: #ffffff; } a { color: #1d4ed8; }</style>`,
|
|
101
|
+
` </head>`,
|
|
102
|
+
` <body>`,
|
|
103
|
+
` <main>`,
|
|
104
|
+
` <h1>Seite nicht gefunden</h1>`,
|
|
105
|
+
` <p>Diese Seite gibt es nicht (mehr).</p>`,
|
|
106
|
+
` <p id="weiter" hidden>Du wirst zur Startseite der App weitergeleitet.</p>`,
|
|
107
|
+
` <p><a id="start" href="/">Zur Startseite</a></p>`,
|
|
108
|
+
` </main>`,
|
|
109
|
+
` <script>${script}</script>`,
|
|
110
|
+
` </body>`,
|
|
111
|
+
`</html>`,
|
|
112
|
+
``,
|
|
113
|
+
].join("\n");
|
|
114
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The end-to-end helpers for a question on any page with a question that renderQuestion
|
|
2
|
+
// (@lernapps/app-templates/questions) rendered and startQuestion made live: answerQuestion gives an answer through
|
|
3
|
+
// the question's controls, as a learner does; expectBackground and expectFeedbackInView check what the learner then
|
|
4
|
+
// sees. Run by the end-to-end tests of the archetypes that have questions.
|
|
5
|
+
import { expect, type Locator, type Page } from "playwright/test";
|
|
6
|
+
import { textsIn } from "../languages.ts";
|
|
7
|
+
import { QUESTION_MESSAGES } from "./render.ts";
|
|
8
|
+
import { questionId, type Answer, type Question } from "./question.ts";
|
|
9
|
+
|
|
10
|
+
/** The question's own texts in `lang`. */
|
|
11
|
+
const messagesOf = (lang: string) => textsIn(QUESTION_MESSAGES, lang);
|
|
12
|
+
|
|
13
|
+
export const question = (page: Page, item: Question) => page.locator(`#${questionId(item)}`);
|
|
14
|
+
|
|
15
|
+
/** Gives `answer` to the question through its controls, as a learner does, on a page in `lang`. */
|
|
16
|
+
export async function answerQuestion(page: Page, item: Question, answer: Answer, lang = "de"): Promise<void> {
|
|
17
|
+
await giveAnswer(page, item, answer);
|
|
18
|
+
await question(page, item)
|
|
19
|
+
.getByRole("button", { name: messagesOf(lang).check })
|
|
20
|
+
.click();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Puts `answer` into the controls of the question, as a learner does, without checking it. */
|
|
24
|
+
export async function giveAnswer(page: Page, item: Question, answer: Answer): Promise<void> {
|
|
25
|
+
const form = question(page, item);
|
|
26
|
+
switch (item.type) {
|
|
27
|
+
case "single-choice":
|
|
28
|
+
case "multiple-choice":
|
|
29
|
+
case "true-false": {
|
|
30
|
+
const values = typeof answer === "object" ? answer.map(String) : [String(answer)];
|
|
31
|
+
for (const value of values) await form.locator(`input[value="${value}"]`).check();
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
case "number":
|
|
35
|
+
await form.locator("input").fill(String(answer));
|
|
36
|
+
break;
|
|
37
|
+
case "ordering":
|
|
38
|
+
case "matching": {
|
|
39
|
+
if (typeof answer !== "object") throw new Error(`${item.id}: an ${item.type} answer is a list`);
|
|
40
|
+
const key = item.type === "ordering" ? "item" : "left";
|
|
41
|
+
for (const [index, value] of answer.entries()) {
|
|
42
|
+
await form.locator(`select[data-${key}="${index}"]`).selectOption(String(value));
|
|
43
|
+
}
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What the learner learns about a question shows in `where`: each option's background, the explanation, the links. */
|
|
50
|
+
export async function expectBackground(where: Locator, item: Question): Promise<void> {
|
|
51
|
+
if (item.type === "single-choice" || item.type === "multiple-choice") {
|
|
52
|
+
for (const option of item.options) {
|
|
53
|
+
await expect(where.getByText(option.info, { exact: true })).toBeVisible();
|
|
54
|
+
if (option.link) await expect(where.locator(`a[href="${option.link.url}"]`).first()).toBeVisible();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
await expect(where.getByText(item.explanation, { exact: true })).toBeVisible();
|
|
58
|
+
for (const link of item.links) {
|
|
59
|
+
const anchor = where.locator(`a[href="${link.url}"]`).first();
|
|
60
|
+
await expect(anchor).toBeVisible();
|
|
61
|
+
await expect(anchor).toHaveAttribute("rel", "noopener");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** After an answer, the learner sees the feedback without scrolling back; it has the focus, the next button follows. */
|
|
66
|
+
export async function expectFeedbackInView(page: Page, item: Question): Promise<void> {
|
|
67
|
+
const feedback = question(page, item).locator(".question-feedback");
|
|
68
|
+
const top = await feedback.evaluate((element) => element.getBoundingClientRect().top);
|
|
69
|
+
const height = await page.evaluate(() => innerHeight);
|
|
70
|
+
expect(top, "the top of the feedback is in the viewport").toBeGreaterThanOrEqual(0);
|
|
71
|
+
expect(top, "the top of the feedback is in the viewport").toBeLessThan(height);
|
|
72
|
+
await expect(feedback).toBeFocused();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What a question shows besides its text (its material) is on the page, between the text and the answers; images load. */
|
|
76
|
+
export async function expectMaterial(page: Page, item: Question, { loaded = true } = {}): Promise<void> {
|
|
77
|
+
const blocks = item.material ?? [];
|
|
78
|
+
const area = question(page, item).locator(".question-material");
|
|
79
|
+
if (blocks.length === 0) {
|
|
80
|
+
await expect(area).toHaveCount(0);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
await expect(area).toBeVisible();
|
|
84
|
+
const count = (type: string) => blocks.filter((block) => block.type === type).length;
|
|
85
|
+
for (const [type, selector] of [
|
|
86
|
+
["text", ".block-text"],
|
|
87
|
+
["formula", ".block-formula"],
|
|
88
|
+
["figure", ".block-figure:not(.block-labelled)"],
|
|
89
|
+
["labelled-figure", ".block-labelled"],
|
|
90
|
+
["table", ".block-table"],
|
|
91
|
+
["quote", ".block-quote"],
|
|
92
|
+
["code", ".block-code"],
|
|
93
|
+
] as const) {
|
|
94
|
+
await expect(area.locator(selector), `${item.id}: the ${type} blocks`).toHaveCount(count(type));
|
|
95
|
+
}
|
|
96
|
+
const withCredit = blocks.filter((block) => "licence" in block && block.licence !== undefined).length;
|
|
97
|
+
await expect(area.locator(".block-credit")).toHaveCount(withCredit);
|
|
98
|
+
const images = blocks.filter((block) => "image" in block && block.image !== undefined);
|
|
99
|
+
const pictures = area.locator("img");
|
|
100
|
+
await expect(pictures).toHaveCount(images.length);
|
|
101
|
+
for (const [index, block] of images.entries()) {
|
|
102
|
+
const picture = pictures.nth(index);
|
|
103
|
+
await expect(picture).toHaveAttribute(
|
|
104
|
+
"alt",
|
|
105
|
+
block.type === "figure" || block.type === "labelled-figure" ? block.alt : "",
|
|
106
|
+
);
|
|
107
|
+
if (!loaded) continue;
|
|
108
|
+
// the file is found from this page and drawn
|
|
109
|
+
await expect
|
|
110
|
+
.poll(() => picture.evaluate((element: HTMLImageElement) => element.complete && element.naturalWidth > 0), {
|
|
111
|
+
message: `${item.id}: image ${index + 1} loads`,
|
|
112
|
+
})
|
|
113
|
+
.toBe(true);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// One question in the browser, for every archetype that has questions (@lernapps/app-templates/questions): the page
|
|
2
|
+
// has rendered it (render.ts); startQuestion makes it live. A complete answer gets its feedback, right or wrong, with
|
|
3
|
+
// the most specific reason of a wrong answer; then the solution shows: the background of every option with the right
|
|
4
|
+
// ones and the learner's choice marked, the explanation and further reading. A question can also be tried again
|
|
5
|
+
// (retry), as an exercise is. The question's own texts are in the page's language (the option `lang`, German by
|
|
6
|
+
// default). The browser imports only this module of the questions (@lernapps/app-templates/questions/engine), which
|
|
7
|
+
// has no build-time code: its types and the id of a question. Used on the element that renderQuestion rendered:
|
|
8
|
+
//
|
|
9
|
+
// startQuestion(element, question);
|
|
10
|
+
import { moveFocus } from "@lernapps/tooling/a11y";
|
|
11
|
+
import { translator } from "@lernapps/tooling/i18n";
|
|
12
|
+
import { textsIn } from "../languages.ts";
|
|
13
|
+
import { QUESTION_MESSAGES } from "./render.ts";
|
|
14
|
+
import {
|
|
15
|
+
hash,
|
|
16
|
+
isComplete,
|
|
17
|
+
isCorrect,
|
|
18
|
+
missesRight,
|
|
19
|
+
shuffled,
|
|
20
|
+
wrongReasons,
|
|
21
|
+
type Answer,
|
|
22
|
+
type Question,
|
|
23
|
+
} from "./question.ts";
|
|
24
|
+
|
|
25
|
+
/** The learner's answer as the form holds it; undefined when nothing is given yet. */
|
|
26
|
+
export function readAnswer(question: Question, form: HTMLFormElement): Answer | undefined {
|
|
27
|
+
const checked = [...form.querySelectorAll<HTMLInputElement>("input:checked")].map((input) => input.value);
|
|
28
|
+
switch (question.type) {
|
|
29
|
+
case "single-choice":
|
|
30
|
+
return checked[0] === undefined ? undefined : Number(checked[0]);
|
|
31
|
+
case "multiple-choice":
|
|
32
|
+
return checked.map(Number);
|
|
33
|
+
case "true-false":
|
|
34
|
+
return checked[0] === undefined ? undefined : checked[0] === "true";
|
|
35
|
+
case "number":
|
|
36
|
+
return form.querySelector<HTMLInputElement>("input")?.value ?? "";
|
|
37
|
+
case "ordering":
|
|
38
|
+
case "matching": {
|
|
39
|
+
const key = question.type === "ordering" ? "item" : "left";
|
|
40
|
+
const answer: number[] = [];
|
|
41
|
+
for (const select of form.querySelectorAll<HTMLSelectElement>(`select[data-${key}]`)) {
|
|
42
|
+
answer[Number(select.dataset[key])] = select.value === "" ? -1 : Number(select.value);
|
|
43
|
+
}
|
|
44
|
+
return answer;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The indices of the options the learner chose, for the question types with options. */
|
|
50
|
+
function chosenOptions(question: Question, answer: Answer): number[] {
|
|
51
|
+
if (question.type !== "single-choice" && question.type !== "multiple-choice") return [];
|
|
52
|
+
return typeof answer === "number" ? [answer] : typeof answer === "object" ? [...answer] : [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { questionId, type Answer, type Question } from "./question.ts";
|
|
56
|
+
|
|
57
|
+
/** What a page that runs one question may set. */
|
|
58
|
+
export interface StartQuestionOptions {
|
|
59
|
+
/** Shows the options of a choice in an order given by this seed and the question; as rendered without one. */
|
|
60
|
+
seed?: number;
|
|
61
|
+
/** The page's language, for the feedback (German by default; the question is already in it). */
|
|
62
|
+
lang?: string;
|
|
63
|
+
/** What went wrong in a wrong answer, e.g. a typical mistake; shown under the feedback before any other reason. */
|
|
64
|
+
diagnose?: (answer: Answer) => string | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* A wrong answer may be tried again, as an exercise does: the controls stay open, the feedback says why the answer
|
|
67
|
+
* is wrong and offers "Lösung zeigen"; the solution shows only after the right answer or that button. Without it,
|
|
68
|
+
* as in a quiz round, every complete answer locks the question and shows its solution at once.
|
|
69
|
+
*/
|
|
70
|
+
retry?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Called on each complete answer, right or wrong; once in a quiz round, where the answer locks the question. `said`
|
|
73
|
+
* is what the feedback says about a wrong answer, word for word as the question's texts have it (its formulas as
|
|
74
|
+
* written): the reasons, each with its option, without the verdict and the offer to try again; "" for a right one.
|
|
75
|
+
*/
|
|
76
|
+
onAnswer?: (answer: Answer, right: boolean, said: string) => void;
|
|
77
|
+
/** Called once, when the solution shows. */
|
|
78
|
+
onReveal?: () => void;
|
|
79
|
+
/** How a text of the question that the feedback quotes becomes HTML (its formulas); plain text without it. */
|
|
80
|
+
format?: (text: string) => string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Makes one rendered question (`item`, the element of renderQuestion) live: the button to check it shows; a complete
|
|
85
|
+
* answer gets its feedback, with a wrong one the most specific reason the question gives (the diagnosis, the
|
|
86
|
+
* background of each wrong option chosen, a number's typical mistake, else its hint). Then the solution shows: the
|
|
87
|
+
* background of every option with the right ones and the learner's choice marked, the explanation and further
|
|
88
|
+
* reading; the controls are locked, the button to the next question shows if there is one, and the focus moves to the
|
|
89
|
+
* feedback. With `retry`, a wrong answer keeps the controls open and offers "Lösung zeigen" instead. An incomplete
|
|
90
|
+
* answer is asked to be completed.
|
|
91
|
+
*/
|
|
92
|
+
export function startQuestion(item: HTMLElement, question: Question, options: StartQuestionOptions = {}): void {
|
|
93
|
+
const t = translator(textsIn(QUESTION_MESSAGES, options.lang ?? "de"));
|
|
94
|
+
const form = item.querySelector("form");
|
|
95
|
+
const feedback = item.querySelector<HTMLElement>(".question-feedback");
|
|
96
|
+
const check = item.querySelector<HTMLButtonElement>(".question-check");
|
|
97
|
+
const next = item.querySelector<HTMLButtonElement>(".question-next");
|
|
98
|
+
if (!form || !feedback || !check) return;
|
|
99
|
+
const doc = item.ownerDocument;
|
|
100
|
+
// the options of a choice in an order of their own, from the seed and the question
|
|
101
|
+
const fieldset = item.querySelector("fieldset");
|
|
102
|
+
if (
|
|
103
|
+
options.seed !== undefined &&
|
|
104
|
+
fieldset &&
|
|
105
|
+
(question.type === "single-choice" || question.type === "multiple-choice")
|
|
106
|
+
) {
|
|
107
|
+
const labels = [...fieldset.querySelectorAll(".question-option")];
|
|
108
|
+
for (const index of shuffled(labels.length, options.seed ^ hash(question.id))) {
|
|
109
|
+
const label = labels[index];
|
|
110
|
+
if (label) fieldset.append(label);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const actions = item.querySelector<HTMLElement>(".question-actions");
|
|
114
|
+
if (actions) actions.hidden = false;
|
|
115
|
+
// "Lösung zeigen", after a wrong answer of a question that may be tried again
|
|
116
|
+
let solve: HTMLButtonElement | undefined;
|
|
117
|
+
if (options.retry === true) {
|
|
118
|
+
solve = doc.createElement("button");
|
|
119
|
+
solve.type = "button";
|
|
120
|
+
solve.className = "question-solve";
|
|
121
|
+
solve.textContent = t("showSolution");
|
|
122
|
+
solve.hidden = true;
|
|
123
|
+
check.after(" ", solve);
|
|
124
|
+
}
|
|
125
|
+
// what the feedback says about the answer, for onAnswer
|
|
126
|
+
let said: string[] = [];
|
|
127
|
+
const line = (text: string, option?: string) => {
|
|
128
|
+
said.push(option === undefined ? text : `${option}: ${text}`);
|
|
129
|
+
const why = doc.createElement("p");
|
|
130
|
+
why.className = "question-diagnosis";
|
|
131
|
+
if (option !== undefined) {
|
|
132
|
+
const name = doc.createElement("strong");
|
|
133
|
+
if (options.format) name.innerHTML = `${options.format(option)}:`;
|
|
134
|
+
else name.textContent = `${option}:`;
|
|
135
|
+
why.append(name, " ");
|
|
136
|
+
}
|
|
137
|
+
if (options.format) why.insertAdjacentHTML("beforeend", options.format(text));
|
|
138
|
+
else why.append(text);
|
|
139
|
+
feedback.append(why);
|
|
140
|
+
};
|
|
141
|
+
let answered = false;
|
|
142
|
+
let last: Answer | undefined;
|
|
143
|
+
|
|
144
|
+
/** The solution: every option's background, the right ones marked, the learner's last choice marked too. */
|
|
145
|
+
const reveal = () => {
|
|
146
|
+
answered = true;
|
|
147
|
+
const solution = item.querySelector<HTMLElement>(".question-reveal");
|
|
148
|
+
for (const index of last === undefined ? [] : chosenOptions(question, last)) {
|
|
149
|
+
const option = solution?.querySelector<HTMLElement>(`[data-option="${index}"]`);
|
|
150
|
+
if (!option) continue;
|
|
151
|
+
option.classList.add("question-info-chosen");
|
|
152
|
+
const badge = doc.createElement("span");
|
|
153
|
+
badge.className = "question-badge question-badge-chosen";
|
|
154
|
+
badge.textContent = t("yourChoice");
|
|
155
|
+
option.querySelector(".question-info-head")?.append(" ", badge);
|
|
156
|
+
}
|
|
157
|
+
if (solution) solution.hidden = false;
|
|
158
|
+
for (const control of form.querySelectorAll<HTMLInputElement | HTMLSelectElement>("input, select")) {
|
|
159
|
+
control.disabled = true;
|
|
160
|
+
}
|
|
161
|
+
check.hidden = true;
|
|
162
|
+
if (solve) solve.hidden = true;
|
|
163
|
+
if (next) next.hidden = false;
|
|
164
|
+
options.onReveal?.();
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
form.addEventListener("submit", (event) => {
|
|
168
|
+
event.preventDefault();
|
|
169
|
+
if (answered) return;
|
|
170
|
+
const answer = readAnswer(question, form);
|
|
171
|
+
if (!isComplete(question, answer)) {
|
|
172
|
+
feedback.textContent = t("incomplete");
|
|
173
|
+
feedback.className = "question-feedback";
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
last = answer;
|
|
177
|
+
said = [];
|
|
178
|
+
const right = isCorrect(question, answer);
|
|
179
|
+
feedback.className = `question-feedback ${right ? "question-right" : "question-wrong"}`;
|
|
180
|
+
feedback.textContent = t(right ? "right" : "wrong");
|
|
181
|
+
if (!right) {
|
|
182
|
+
// the most specific reason: the diagnosis, else what the question says about this answer; the background of a
|
|
183
|
+
// chosen option only where the solution does not show it right away
|
|
184
|
+
const diagnosis = options.diagnose?.(answer);
|
|
185
|
+
const reasons =
|
|
186
|
+
diagnosis !== undefined
|
|
187
|
+
? [{ text: diagnosis }]
|
|
188
|
+
: wrongReasons(question, answer).filter((reason) => options.retry === true || reason.option === undefined);
|
|
189
|
+
for (const reason of reasons) line(reason.text, reason.option);
|
|
190
|
+
const missing = missesRight(question, answer);
|
|
191
|
+
if (diagnosis === undefined && missing) line(t("missingRight"));
|
|
192
|
+
if (options.retry === true && reasons.length === 0 && !missing) line(t("lookAgain"));
|
|
193
|
+
}
|
|
194
|
+
const told = said.join(" ");
|
|
195
|
+
if (!right && options.retry === true) line(t("tryAgain"));
|
|
196
|
+
options.onAnswer?.(answer, right, told);
|
|
197
|
+
if (right || options.retry !== true) reveal();
|
|
198
|
+
else if (solve) solve.hidden = false;
|
|
199
|
+
// the focus on the feedback, not on the button below the background: the feedback stays in view, a screen
|
|
200
|
+
// reader reads it, and the next tab stops are the links of the background, then the button
|
|
201
|
+
moveFocus(feedback);
|
|
202
|
+
});
|
|
203
|
+
solve?.addEventListener("click", () => {
|
|
204
|
+
if (answered) return;
|
|
205
|
+
reveal();
|
|
206
|
+
// the focus on the solution, which shows below the feedback in place of the button
|
|
207
|
+
moveFocus(item.querySelector<HTMLElement>(".question-reveal") ?? feedback);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The questions of a lernapps app, for every archetype that has them (@lernapps/app-templates/questions), at build
|
|
2
|
+
// time and in Node: the six types of question and their checker, the schema of a question for a `$ref` from another
|
|
3
|
+
// schema, the validation of one question, the rendering of its controls and of its solution without JavaScript, and
|
|
4
|
+
// the messages. In the browser, startQuestion (@lernapps/app-templates/questions/engine) makes a rendered question
|
|
5
|
+
// live; the end-to-end helpers are in @lernapps/app-templates/questions/e2e. A page embeds a question as
|
|
6
|
+
//
|
|
7
|
+
// renderQuestion(question) +
|
|
8
|
+
// `<details><summary>${messages.solution}</summary>${renderSolution(question)}</details>`
|
|
9
|
+
//
|
|
10
|
+
// and fails its build when validateQuestion(question, "<where it is>") finds problems. A page in several languages
|
|
11
|
+
// writes each learner text of a question as a map, { de: …, en: … }: validateQuestion(question, at, languages) checks
|
|
12
|
+
// that each declared language is there (German always), questionIn(question, lang) gives the question in the page's
|
|
13
|
+
// language for the checker, and renderQuestion and renderSolution take the page's `lang` for their own texts.
|
|
14
|
+
import de from "./messages/de.json" with { type: "json" };
|
|
15
|
+
|
|
16
|
+
export * from "./question.ts";
|
|
17
|
+
export {
|
|
18
|
+
answerText,
|
|
19
|
+
QUESTION_MESSAGES,
|
|
20
|
+
questionMessagesIn,
|
|
21
|
+
renderQuestion,
|
|
22
|
+
renderSolution,
|
|
23
|
+
type QuestionMessages,
|
|
24
|
+
type RenderQuestionOptions,
|
|
25
|
+
type RenderSolutionOptions,
|
|
26
|
+
} from "./render.ts";
|
|
27
|
+
export {
|
|
28
|
+
QUESTION_REF,
|
|
29
|
+
QUESTION_SCHEMA_ID,
|
|
30
|
+
questionDetails,
|
|
31
|
+
questionSchema,
|
|
32
|
+
questionSchemas,
|
|
33
|
+
questionSchemaProblems,
|
|
34
|
+
validateQuestion,
|
|
35
|
+
type QuestionProblem,
|
|
36
|
+
} from "./validate.ts";
|
|
37
|
+
|
|
38
|
+
/** The texts of a question for learners in German, by their key (messages/de.json); questionMessagesIn(lang) for another. */
|
|
39
|
+
export const messages = de;
|