@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,242 @@
|
|
|
1
|
+
// The quiz engine in the browser (@lernapps/app-templates/quiz). The build has rendered every question into the page
|
|
2
|
+
// (render.ts); the engine turns that page into a quiz: one question at a time in an order given by the seed in the
|
|
3
|
+
// address (?seed=<number>), as many as the learner chooses at the start (?n=<number>, all by default); after each answer, right or wrong, the background of every option with the right ones
|
|
4
|
+
// and the learner's choice marked, the explanation and further reading; the score and the solutions at the end, with confetti from 80% right and fireworks for all right. A deep
|
|
5
|
+
// link (#frage-<id>) opens its question. The quiz's own texts are in the page's language (its data's lang, German
|
|
6
|
+
// without one). Nothing is stored, except the last result on the device when the bank asks for it
|
|
7
|
+
// (rememberLastResult), through the storage of @lernapps/tooling. An app's src/main.ts is
|
|
8
|
+
//
|
|
9
|
+
// import "@lernapps/app-templates/quiz/style.css";
|
|
10
|
+
// import { startQuiz } from "@lernapps/app-templates/quiz";
|
|
11
|
+
// startQuiz();
|
|
12
|
+
//
|
|
13
|
+
// One question on its own (startQuestion, shared/questions/engine.ts): the quiz runs each of its questions so.
|
|
14
|
+
import { announce, moveFocus } from "@lernapps/tooling/a11y";
|
|
15
|
+
import { translator } from "@lernapps/tooling/i18n";
|
|
16
|
+
import { createStorage } from "@lernapps/tooling/storage";
|
|
17
|
+
import de from "./messages/de.json" with { type: "json" };
|
|
18
|
+
import en from "./messages/en.json" with { type: "json" };
|
|
19
|
+
import { startQuestion } from "../shared/questions/engine.ts";
|
|
20
|
+
import { isCorrect, questionId, shuffled, type Answer } from "../shared/questions/question.ts";
|
|
21
|
+
import { solutionId, type QuizData } from "./quiz.ts";
|
|
22
|
+
|
|
23
|
+
/** The quiz's texts, by the page's language. */
|
|
24
|
+
const BY_LANGUAGE: Readonly<Record<string, typeof de>> = { de, en };
|
|
25
|
+
const SEED = "seed";
|
|
26
|
+
const COUNT = "n";
|
|
27
|
+
|
|
28
|
+
interface LastResult {
|
|
29
|
+
score: number;
|
|
30
|
+
total: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const isLastResult = (value: unknown): value is LastResult =>
|
|
34
|
+
typeof value === "object" &&
|
|
35
|
+
value !== null &&
|
|
36
|
+
"score" in value &&
|
|
37
|
+
"total" in value &&
|
|
38
|
+
typeof value.score === "number" &&
|
|
39
|
+
typeof value.total === "number";
|
|
40
|
+
|
|
41
|
+
/** The seed in the address; a new one, written into the address, when there is none. */
|
|
42
|
+
function seedOf(location: Location): number {
|
|
43
|
+
const url = new URL(location.href);
|
|
44
|
+
const given = Number(url.searchParams.get(SEED));
|
|
45
|
+
if (Number.isInteger(given) && given > 0) return given;
|
|
46
|
+
const seed = 1 + Math.floor(Math.random() * 999_999);
|
|
47
|
+
url.searchParams.set(SEED, String(seed));
|
|
48
|
+
history.replaceState(history.state, "", url);
|
|
49
|
+
return seed;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The number of questions in the address (?n=<number>), if it is one from 1 to `total`. */
|
|
53
|
+
function countOf(location: Location, total: number): number | undefined {
|
|
54
|
+
const given = Number(new URL(location.href).searchParams.get(COUNT));
|
|
55
|
+
return Number.isInteger(given) && given >= 1 && given <= total ? given : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The share of right answers from which the result rains confetti; all right gives fireworks instead. */
|
|
59
|
+
const CONFETTI_FROM = 0.8;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Celebrates a result on a layer over the page that takes no clicks and is hidden for those who prefer less motion
|
|
63
|
+
* (quiz.css): confetti from 80% right, fireworks for all right. Positions, delays and colours are fixed by index, so
|
|
64
|
+
* the same result always looks the same.
|
|
65
|
+
*/
|
|
66
|
+
function celebrate(root: Document, right: number, total: number): HTMLElement | undefined {
|
|
67
|
+
if (total === 0 || right / total < CONFETTI_FROM) return undefined;
|
|
68
|
+
const layer = root.createElement("div");
|
|
69
|
+
layer.setAttribute("aria-hidden", "true");
|
|
70
|
+
const add = (parent: HTMLElement, tag: string, style: Record<string, string>): HTMLElement => {
|
|
71
|
+
const element = root.createElement(tag);
|
|
72
|
+
for (const [name, value] of Object.entries(style)) element.style.setProperty(name, value);
|
|
73
|
+
parent.append(element);
|
|
74
|
+
return element;
|
|
75
|
+
};
|
|
76
|
+
if (right === total) {
|
|
77
|
+
layer.className = "quiz-celebration quiz-fireworks";
|
|
78
|
+
const bursts = [
|
|
79
|
+
{ left: "24%", top: "26%", delay: "0.3s", hue: 20 },
|
|
80
|
+
{ left: "76%", top: "22%", delay: "0.9s", hue: 200 },
|
|
81
|
+
{ left: "50%", top: "40%", delay: "1.5s", hue: 320 },
|
|
82
|
+
{ left: "18%", top: "58%", delay: "2.1s", hue: 140 },
|
|
83
|
+
{ left: "82%", top: "56%", delay: "2.7s", hue: 50 },
|
|
84
|
+
];
|
|
85
|
+
for (const burst of bursts) {
|
|
86
|
+
const element = add(layer, "span", { left: burst.left, top: burst.top, "--hue": String(burst.hue) });
|
|
87
|
+
element.className = "quiz-burst";
|
|
88
|
+
for (let index = 0; index < 28; index++) {
|
|
89
|
+
const spark = add(element, "i", {
|
|
90
|
+
"--angle": `${index * (360 / 28)}deg`,
|
|
91
|
+
"--far": index % 2 === 0 ? "1" : "0.6",
|
|
92
|
+
});
|
|
93
|
+
spark.style.animationDelay = burst.delay;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
layer.className = "quiz-celebration quiz-confetti";
|
|
98
|
+
for (let index = 0; index < 28; index++) {
|
|
99
|
+
const piece = add(layer, "span", {
|
|
100
|
+
left: `${(index * 37) % 100}%`,
|
|
101
|
+
"--hue": String((index * 47) % 360),
|
|
102
|
+
"--turn": `${(index % 2 === 0 ? 1 : -1) * (180 + ((index * 29) % 360))}deg`,
|
|
103
|
+
});
|
|
104
|
+
piece.style.animationDelay = `${((index * 53) % 100) / 100}s`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
root.body.append(layer);
|
|
108
|
+
return layer;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Starts the quiz on the page the build rendered; does nothing on a page without one. */
|
|
112
|
+
export function startQuiz(root: Document = document): void {
|
|
113
|
+
const dataElement = root.getElementById("quiz-data");
|
|
114
|
+
const main = root.getElementById("quiz");
|
|
115
|
+
if (!dataElement || !main) return;
|
|
116
|
+
const { quiz, storage: storageName, lang = "de" } = JSON.parse(dataElement.textContent ?? "{}") as QuizData;
|
|
117
|
+
const t = translator(BY_LANGUAGE[lang] ?? de);
|
|
118
|
+
const storage = storageName === undefined ? undefined : createStorage(storageName);
|
|
119
|
+
const seed = seedOf(root.location);
|
|
120
|
+
const total = quiz.questions.length;
|
|
121
|
+
const shuffledQuestions = shuffled(total, seed).flatMap((index) => quiz.questions[index] ?? []);
|
|
122
|
+
const hashId = decodeURIComponent(root.location.hash.slice(1));
|
|
123
|
+
const list = main.querySelector<HTMLElement>(".quiz-questions");
|
|
124
|
+
const progress = main.querySelector<HTMLElement>(".quiz-progress");
|
|
125
|
+
const result = main.querySelector<HTMLElement>(".quiz-result");
|
|
126
|
+
const solutions = main.querySelector<HTMLElement>(".quiz-solutions");
|
|
127
|
+
if (!list || !progress || !result || !solutions) return;
|
|
128
|
+
const heading = main.querySelector<HTMLElement>(".quiz-heading");
|
|
129
|
+
const form = main.querySelector<HTMLFormElement>(".quiz-start");
|
|
130
|
+
|
|
131
|
+
/** Runs the quiz with the first `count` questions in the order of the seed. */
|
|
132
|
+
const run = (count: number) => {
|
|
133
|
+
const order = shuffledQuestions.slice(0, count);
|
|
134
|
+
// a link to a question that this run does not ask puts that question first, so every question keeps its link
|
|
135
|
+
const linked = shuffledQuestions.find((question) => questionId(question) === hashId);
|
|
136
|
+
if (linked && !order.includes(linked)) order[0] = linked;
|
|
137
|
+
const answers = new Map<string, Answer>();
|
|
138
|
+
|
|
139
|
+
main.classList.add("quiz-running");
|
|
140
|
+
solutions.hidden = true;
|
|
141
|
+
progress.hidden = false;
|
|
142
|
+
const items = new Map<string, HTMLElement>();
|
|
143
|
+
for (const question of order) {
|
|
144
|
+
const item = root.getElementById(questionId(question));
|
|
145
|
+
if (!item) continue;
|
|
146
|
+
items.set(question.id, item);
|
|
147
|
+
list.append(item); // in the order of the seed
|
|
148
|
+
startQuestion(item, question, { seed, lang, onAnswer: (answer) => answers.set(question.id, answer) });
|
|
149
|
+
}
|
|
150
|
+
// the questions this run does not ask: neither the question nor its solution
|
|
151
|
+
for (const question of quiz.questions.filter((candidate) => !order.includes(candidate))) {
|
|
152
|
+
root.getElementById(questionId(question))?.remove();
|
|
153
|
+
root.getElementById(solutionId(question))?.remove();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let celebration: HTMLElement | undefined;
|
|
157
|
+
const show = (index: number, focus: boolean) => {
|
|
158
|
+
celebration?.remove();
|
|
159
|
+
order.forEach((question, at) => {
|
|
160
|
+
const item = items.get(question.id);
|
|
161
|
+
if (item) item.hidden = at !== index;
|
|
162
|
+
});
|
|
163
|
+
result.hidden = true;
|
|
164
|
+
solutions.hidden = true;
|
|
165
|
+
progress.textContent = t("progress", { current: index + 1, total: order.length });
|
|
166
|
+
const legend = items.get(order[index]?.id ?? "")?.querySelector<HTMLElement>("legend");
|
|
167
|
+
if (focus && legend) moveFocus(legend);
|
|
168
|
+
// the previous question was longer than this one, so the page shrank and the browser left the view at the top:
|
|
169
|
+
// the heading "Frage N" comes into view, after the focus, which scrolls on its own
|
|
170
|
+
if (focus) progress.scrollIntoView({ block: "start" });
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const finish = () => {
|
|
174
|
+
for (const item of items.values()) item.hidden = true;
|
|
175
|
+
progress.hidden = true;
|
|
176
|
+
const right = order.filter((question) => {
|
|
177
|
+
const answer = answers.get(question.id);
|
|
178
|
+
return answer !== undefined && isCorrect(question, answer);
|
|
179
|
+
}).length;
|
|
180
|
+
const scoreText = main.querySelector(".quiz-score");
|
|
181
|
+
if (scoreText) scoreText.textContent = t("score", { score: right, total: order.length });
|
|
182
|
+
const last = storage?.load("last-result", isLastResult);
|
|
183
|
+
const lastText = main.querySelector<HTMLElement>(".quiz-last");
|
|
184
|
+
if (lastText && last) {
|
|
185
|
+
lastText.textContent = t("last", { score: last.score, total: last.total });
|
|
186
|
+
lastText.hidden = false;
|
|
187
|
+
}
|
|
188
|
+
storage?.save("last-result", { score: right, total: order.length } satisfies LastResult);
|
|
189
|
+
const restart = main.querySelector<HTMLAnchorElement>(".quiz-restart");
|
|
190
|
+
if (restart) restart.href = `?${SEED}=${1 + ((seed * 7919) % 999_999)}&${COUNT}=${order.length}`;
|
|
191
|
+
result.hidden = false;
|
|
192
|
+
solutions.hidden = false;
|
|
193
|
+
celebration?.remove();
|
|
194
|
+
celebration = celebrate(root, right, order.length);
|
|
195
|
+
moveFocus(result);
|
|
196
|
+
announce(scoreText?.textContent ?? "");
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
order.forEach((question, index) => {
|
|
200
|
+
const next = items.get(question.id)?.querySelector<HTMLButtonElement>(".question-next");
|
|
201
|
+
if (!next) return;
|
|
202
|
+
const isLast = index === order.length - 1;
|
|
203
|
+
if (isLast) next.textContent = t("toResult");
|
|
204
|
+
next.addEventListener("click", () => {
|
|
205
|
+
if (isLast) finish();
|
|
206
|
+
else show(index + 1, true);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const fromAddress = (focus: boolean) => {
|
|
211
|
+
const hashId = decodeURIComponent(root.location.hash.slice(1));
|
|
212
|
+
const index = order.findIndex((question) => questionId(question) === hashId);
|
|
213
|
+
show(index >= 0 ? index : 0, focus || index >= 0);
|
|
214
|
+
};
|
|
215
|
+
globalThis.addEventListener("hashchange", () => fromAddress(true));
|
|
216
|
+
fromAddress(false);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const given = countOf(root.location, total);
|
|
220
|
+
// the learner chooses how many questions to answer, unless the address already says (or links to a question)
|
|
221
|
+
if (given !== undefined || hashId !== "" || !form || total < 2) {
|
|
222
|
+
run(given ?? total);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const input = form.querySelector<HTMLInputElement>("input");
|
|
226
|
+
if (!input) return run(total);
|
|
227
|
+
list.hidden = true;
|
|
228
|
+
solutions.hidden = true;
|
|
229
|
+
if (heading) heading.hidden = true;
|
|
230
|
+
form.hidden = false;
|
|
231
|
+
form.addEventListener("submit", (event) => {
|
|
232
|
+
event.preventDefault();
|
|
233
|
+
const count = Math.min(total, Math.max(1, Math.floor(Number(input.value)) || total));
|
|
234
|
+
const url = new URL(root.location.href);
|
|
235
|
+
url.searchParams.set(COUNT, String(count));
|
|
236
|
+
history.replaceState(history.state, "", url);
|
|
237
|
+
form.hidden = true;
|
|
238
|
+
list.hidden = false;
|
|
239
|
+
if (heading) heading.hidden = false;
|
|
240
|
+
run(count);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"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.",
|
|
3
|
+
"noscript": "Ohne JavaScript kannst du alle Fragen lesen. Die Lösungen mit mehr Wissen zu jeder Antwort stehen am Ende der Seite.",
|
|
4
|
+
"questions": "Fragen",
|
|
5
|
+
"countLabel": "Wie viele Fragen möchtest du beantworten?",
|
|
6
|
+
"countOf": "von {total}",
|
|
7
|
+
"start": "Los geht’s",
|
|
8
|
+
"progress": "Frage {current} von {total}",
|
|
9
|
+
"toResult": "Zum Ergebnis",
|
|
10
|
+
"result": "Ergebnis",
|
|
11
|
+
"score": "Du hast {score} von {total} Fragen richtig beantwortet.",
|
|
12
|
+
"last": "Beim letzten Mal waren es {score} von {total}.",
|
|
13
|
+
"restart": "Noch einmal",
|
|
14
|
+
"solutions": "Lösungen",
|
|
15
|
+
"toQuestion": "Zur Frage",
|
|
16
|
+
"footer": "Ein Quiz auf lernapps.net",
|
|
17
|
+
"imprint": "Impressum",
|
|
18
|
+
"privacy": "Datenschutz",
|
|
19
|
+
"sources": "Quellen",
|
|
20
|
+
"sourcesIntro": "Die Fakten in diesem Quiz stammen aus diesen Quellen:",
|
|
21
|
+
"language": "Deutsch",
|
|
22
|
+
"languages": "Sprache"
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"howto": "Answer the questions one by one. After each answer you see whether it is right and learn more about every answer. At the end you see your result.",
|
|
3
|
+
"noscript": "Without JavaScript you can read all questions. The solutions, with more on every answer, are at the end of the page.",
|
|
4
|
+
"questions": "Questions",
|
|
5
|
+
"countLabel": "How many questions do you want to answer?",
|
|
6
|
+
"countOf": "of {total}",
|
|
7
|
+
"start": "Start",
|
|
8
|
+
"progress": "Question {current} of {total}",
|
|
9
|
+
"toResult": "To the result",
|
|
10
|
+
"result": "Result",
|
|
11
|
+
"score": "You answered {score} of {total} questions right.",
|
|
12
|
+
"last": "Last time it was {score} of {total}.",
|
|
13
|
+
"restart": "Again",
|
|
14
|
+
"solutions": "Solutions",
|
|
15
|
+
"toQuestion": "To the question",
|
|
16
|
+
"footer": "A quiz on lernapps.net",
|
|
17
|
+
"imprint": "Imprint",
|
|
18
|
+
"privacy": "Privacy",
|
|
19
|
+
"sources": "Sources",
|
|
20
|
+
"sourcesIntro": "The facts in this quiz come from these sources:",
|
|
21
|
+
"language": "English",
|
|
22
|
+
"languages": "Language"
|
|
23
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// The build step of a quiz (@lernapps/app-templates/quiz/plugin), a Vite plugin. An app's vite.config.ts adds it to
|
|
2
|
+
// the shared preset of @lernapps/tooling:
|
|
3
|
+
//
|
|
4
|
+
// import { lernapps } from "@lernapps/tooling/preset";
|
|
5
|
+
// import { quiz } from "@lernapps/app-templates/quiz/plugin";
|
|
6
|
+
// export default lernapps({ plugins: [quiz()] });
|
|
7
|
+
//
|
|
8
|
+
// It reads the question bank (src/quiz.json), validates it against its JSON Schema and fails the build with a
|
|
9
|
+
// message naming each problem, and renders every question into index.html where it says <!-- quiz -->, so the page
|
|
10
|
+
// reads without JavaScript. The app's src/main.ts starts the engine (@lernapps/app-templates/quiz) on that page.
|
|
11
|
+
// A bank in several languages (languages: ["de", "en"]) gives a page per language: German in index.html, every other
|
|
12
|
+
// language in <language>/index.html, the built index.html with that language's quiz and its relative asset URLs
|
|
13
|
+
// rewritten for its depth; each with its language, its alternates and a language switch. 404.html leads every address
|
|
14
|
+
// the app does not have to its start page (notFoundPage of ../shared/pages.ts). `vp dev` serves the same pages, /en/
|
|
15
|
+
// in English.
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import type { Plugin } from "vite-plus";
|
|
19
|
+
import { appName } from "../shared/appname.ts";
|
|
20
|
+
import { imageType, isSizeless } from "../shared/blocks/image.ts";
|
|
21
|
+
import { materialImages } from "../shared/blocks/index.ts";
|
|
22
|
+
import type { Text } from "../shared/languages.ts";
|
|
23
|
+
import { fromDepth, languageRoot, notFoundPage, withLanguage } from "../shared/pages.ts";
|
|
24
|
+
import { quizIn, quizLanguages, type Quiz } from "./quiz.ts";
|
|
25
|
+
import { renderQuiz } from "./render.ts";
|
|
26
|
+
import { readBank } from "./validate.ts";
|
|
27
|
+
import { escape } from "../shared/html.ts";
|
|
28
|
+
|
|
29
|
+
/** Where the question bank is, relative to the app's root. */
|
|
30
|
+
export const BANK = "src/quiz.json";
|
|
31
|
+
const MARKER = "<!-- quiz -->";
|
|
32
|
+
|
|
33
|
+
/** The bank, in all its languages, and the name of the app's storage when the quiz keeps the last result. */
|
|
34
|
+
interface Bank {
|
|
35
|
+
quiz: Quiz<Text>;
|
|
36
|
+
storage?: string;
|
|
37
|
+
/** The bundled images the questions show, by their path from src/ (images/<name>), with their files. */
|
|
38
|
+
images: ReadonlyMap<string, string>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The page in `lang`: the shell with the quiz in that language, its title, description and alternates. */
|
|
42
|
+
function fill(html: string, { quiz, storage, images }: Bank, lang: string): string {
|
|
43
|
+
if (!html.includes(MARKER)) {
|
|
44
|
+
throw new Error(`index.html has no ${MARKER}: put it in <body>, where the quiz goes`);
|
|
45
|
+
}
|
|
46
|
+
const data = {
|
|
47
|
+
quiz: quizIn(quiz, lang),
|
|
48
|
+
...(storage === undefined ? {} : { storage }),
|
|
49
|
+
...(lang === "de" ? {} : { lang }),
|
|
50
|
+
};
|
|
51
|
+
const { title, description, alternates, body } = renderQuiz(data, {
|
|
52
|
+
languages: quizLanguages(quiz),
|
|
53
|
+
sizeless: (image) => {
|
|
54
|
+
const file = images.get(image);
|
|
55
|
+
return file !== undefined && isSizeless(file);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
const head = [
|
|
59
|
+
`<title>${escape(title)}</title>`,
|
|
60
|
+
`<meta name="description" content="${escape(description)}" />`,
|
|
61
|
+
...alternates,
|
|
62
|
+
].join("\n ");
|
|
63
|
+
const withHead = /<title>[\s\S]*?<\/title>/.test(html)
|
|
64
|
+
? html.replace(/<title>[\s\S]*?<\/title>/, head)
|
|
65
|
+
: html.replace("</head>", ` ${head}\n </head>`);
|
|
66
|
+
return (lang === "de" ? withHead : withLanguage(withHead, lang)).replace(MARKER, () => body);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The language of a page the dev server serves: the first part of its address, if the quiz has that language. */
|
|
70
|
+
function languageOf(url: string | undefined, languages: readonly string[]): string {
|
|
71
|
+
const first = /^\/([a-z]{2})\//.exec(url ?? "")?.[1];
|
|
72
|
+
return first !== undefined && first !== "de" && languages.includes(first) ? first : "de";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The build step of the quiz: validates the question bank and renders it into index.html, a page per language. */
|
|
76
|
+
export function quiz(): Plugin {
|
|
77
|
+
let root = process.cwd();
|
|
78
|
+
let bank: Bank | undefined;
|
|
79
|
+
/** The built index.html before the quiz is in it, for the pages in the other languages. */
|
|
80
|
+
let shell: string | undefined;
|
|
81
|
+
const load = (): Bank => {
|
|
82
|
+
const file = join(root, BANK);
|
|
83
|
+
let text: string;
|
|
84
|
+
try {
|
|
85
|
+
text = readFileSync(file, "utf8");
|
|
86
|
+
} catch {
|
|
87
|
+
throw new Error(`${BANK} is missing: the quiz's questions belong there (schema quiz.v1.schema.json)`);
|
|
88
|
+
}
|
|
89
|
+
const quiz = readBank(text, BANK, { imageExists: (image) => existsSync(join(root, "src", image)) });
|
|
90
|
+
const images = new Map(
|
|
91
|
+
quiz.questions
|
|
92
|
+
.flatMap((question) => materialImages(question.material ?? []))
|
|
93
|
+
.map((image) => [image, join(root, "src", image)]),
|
|
94
|
+
);
|
|
95
|
+
return quiz.rememberLastResult === true ? { quiz, storage: appName(root, "quiz"), images } : { quiz, images };
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
name: "lernapps-quiz",
|
|
99
|
+
// after Vite has written the built index.html, so the pages in the other languages are made from it
|
|
100
|
+
enforce: "post",
|
|
101
|
+
configResolved(config) {
|
|
102
|
+
root = config.root;
|
|
103
|
+
},
|
|
104
|
+
buildStart() {
|
|
105
|
+
this.addWatchFile(join(root, BANK));
|
|
106
|
+
try {
|
|
107
|
+
bank = load();
|
|
108
|
+
} catch (error) {
|
|
109
|
+
this.error(error instanceof Error ? error.message : String(error));
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
handleHotUpdate({ file, server }) {
|
|
113
|
+
if (file === join(root, BANK)) {
|
|
114
|
+
bank = undefined;
|
|
115
|
+
server.ws.send({ type: "full-reload" });
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
configureServer(server) {
|
|
119
|
+
// the bundled images, before Vite's own fallback to index.html
|
|
120
|
+
server.middlewares.use((request, response, next) => {
|
|
121
|
+
const path = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname).replace(/^\//, "");
|
|
122
|
+
if (!path.startsWith("images/")) return next();
|
|
123
|
+
const file = (bank ??= load()).images.get(path);
|
|
124
|
+
if (file === undefined) return next();
|
|
125
|
+
response.setHeader("content-type", imageType(path));
|
|
126
|
+
response.end(readFileSync(file));
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
transformIndexHtml(html, context) {
|
|
130
|
+
bank ??= load();
|
|
131
|
+
shell = html;
|
|
132
|
+
return fill(html, bank, languageOf(context.originalUrl, quizLanguages(bank.quiz)));
|
|
133
|
+
},
|
|
134
|
+
generateBundle() {
|
|
135
|
+
if (bank === undefined || shell === undefined) return;
|
|
136
|
+
this.emitFile({ type: "asset", fileName: "404.html", source: notFoundPage() });
|
|
137
|
+
for (const [image, file] of bank.images)
|
|
138
|
+
this.emitFile({ type: "asset", fileName: image, source: readFileSync(file) });
|
|
139
|
+
for (const lang of quizLanguages(bank.quiz).slice(1)) {
|
|
140
|
+
const path = languageRoot(lang);
|
|
141
|
+
this.emitFile({
|
|
142
|
+
type: "asset",
|
|
143
|
+
fileName: `${path}index.html`,
|
|
144
|
+
// the shell's asset URLs for the depth of the page, before the quiz is in it (its own links are relative to it)
|
|
145
|
+
source: fill(fromDepth(shell, path), bank, lang),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/* The look of a quiz (@lernapps/app-templates/quiz/style.css): the frame and the questions are shared (page.css,
|
|
2
|
+
questions.css); this is the quiz's page: its start form, its progress, its result, its solutions and its celebration. */
|
|
3
|
+
@import "../shared/page.css";
|
|
4
|
+
@import "../shared/blocks/blocks.css";
|
|
5
|
+
@import "../shared/questions/questions.css";
|
|
6
|
+
|
|
7
|
+
.quiz {
|
|
8
|
+
max-width: var(--page-max-width, 44rem);
|
|
9
|
+
margin: 0 auto;
|
|
10
|
+
padding: 1rem;
|
|
11
|
+
overflow-wrap: break-word;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.quiz h1 {
|
|
15
|
+
font-size: 1.75rem;
|
|
16
|
+
line-height: 1.2;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.quiz-questions,
|
|
20
|
+
.quiz-solutions ol {
|
|
21
|
+
padding-left: 1.5rem;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.quiz-running .quiz-questions {
|
|
25
|
+
list-style: none;
|
|
26
|
+
padding-left: 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.quiz-start button {
|
|
30
|
+
font: inherit;
|
|
31
|
+
padding: 0.5rem 1rem;
|
|
32
|
+
border: 2px solid var(--page-accent);
|
|
33
|
+
border-radius: 0.375rem;
|
|
34
|
+
background: var(--page-accent);
|
|
35
|
+
color: var(--page-on-accent);
|
|
36
|
+
cursor: pointer;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.quiz a.quiz-restart {
|
|
40
|
+
display: inline-block;
|
|
41
|
+
padding: 0.5rem 1rem;
|
|
42
|
+
border: 2px solid var(--page-accent);
|
|
43
|
+
border-radius: 0.375rem;
|
|
44
|
+
background: var(--page-accent);
|
|
45
|
+
color: var(--page-on-accent);
|
|
46
|
+
text-decoration: none;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.quiz-progress {
|
|
50
|
+
color: var(--page-muted);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.quiz-solution h3 {
|
|
54
|
+
font-size: 1.0625rem;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
58
|
+
html {
|
|
59
|
+
scroll-behavior: smooth;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.quiz-start input {
|
|
64
|
+
font: inherit;
|
|
65
|
+
width: 4.5rem;
|
|
66
|
+
padding: 0.375rem 0.5rem;
|
|
67
|
+
border: 2px solid var(--page-border);
|
|
68
|
+
border-radius: 0.375rem;
|
|
69
|
+
background: var(--page-surface);
|
|
70
|
+
color: inherit;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* the celebration of a good result: confetti from 80% right, fireworks for all right (engine.ts) */
|
|
74
|
+
.quiz-celebration {
|
|
75
|
+
position: fixed;
|
|
76
|
+
inset: 0;
|
|
77
|
+
overflow: hidden;
|
|
78
|
+
pointer-events: none;
|
|
79
|
+
z-index: 10;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.quiz-fireworks {
|
|
83
|
+
background: rgb(6 10 28 / 0.72);
|
|
84
|
+
animation: quiz-dim 4.8s ease-in-out forwards;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@keyframes quiz-dim {
|
|
88
|
+
0% {
|
|
89
|
+
opacity: 0;
|
|
90
|
+
}
|
|
91
|
+
15%,
|
|
92
|
+
80% {
|
|
93
|
+
opacity: 1;
|
|
94
|
+
}
|
|
95
|
+
100% {
|
|
96
|
+
opacity: 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.quiz-burst {
|
|
101
|
+
position: absolute;
|
|
102
|
+
width: 0;
|
|
103
|
+
height: 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.quiz-burst i {
|
|
107
|
+
position: absolute;
|
|
108
|
+
left: -0.4rem;
|
|
109
|
+
top: -0.4rem;
|
|
110
|
+
width: 0.8rem;
|
|
111
|
+
height: 0.8rem;
|
|
112
|
+
border-radius: 50%;
|
|
113
|
+
background: hsl(var(--hue) 90% 62%);
|
|
114
|
+
box-shadow: 0 0 0.9rem 0.25rem hsl(var(--hue) 90% 62% / 0.7);
|
|
115
|
+
opacity: 0;
|
|
116
|
+
animation: quiz-spark 2.2s ease-out forwards;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@keyframes quiz-spark {
|
|
120
|
+
0% {
|
|
121
|
+
opacity: 0;
|
|
122
|
+
transform: rotate(var(--angle)) translateX(0);
|
|
123
|
+
}
|
|
124
|
+
12% {
|
|
125
|
+
opacity: 1;
|
|
126
|
+
}
|
|
127
|
+
100% {
|
|
128
|
+
opacity: 0;
|
|
129
|
+
transform: rotate(var(--angle)) translateX(calc(min(20rem, 42vw) * var(--far)));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.quiz-confetti span {
|
|
134
|
+
position: absolute;
|
|
135
|
+
top: -1.5rem;
|
|
136
|
+
width: 0.6rem;
|
|
137
|
+
height: 1rem;
|
|
138
|
+
border-radius: 2px;
|
|
139
|
+
background: hsl(var(--hue) 80% 55%);
|
|
140
|
+
animation: quiz-confetti-fall 2.8s ease-in forwards;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@keyframes quiz-confetti-fall {
|
|
144
|
+
to {
|
|
145
|
+
transform: translateY(105vh) rotate(var(--turn));
|
|
146
|
+
opacity: 0.9;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@media (prefers-reduced-motion: reduce) {
|
|
151
|
+
.quiz-celebration {
|
|
152
|
+
display: none;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The question bank of a quiz about things to know (src/quiz.json, schema quiz.v1.schema.json): the quiz around its
|
|
2
|
+
// questions, and the score. The questions themselves, and what counts as a right answer, are in
|
|
3
|
+
// ../shared/questions/question.ts. A quiz in several languages writes each learner text as a map of texts by
|
|
4
|
+
// language, { de: …, en: … } (Quiz<Text>); quizIn gives it in the page's language.
|
|
5
|
+
import { inLanguage, type Text } from "../shared/languages.ts";
|
|
6
|
+
import { isCorrect, questionIn, type Answer, type Question } from "../shared/questions/question.ts";
|
|
7
|
+
|
|
8
|
+
export interface Quiz<T extends Text = string> {
|
|
9
|
+
/** The quiz's languages, German first; German only when it has none. */
|
|
10
|
+
languages?: string[];
|
|
11
|
+
title: T;
|
|
12
|
+
intro: T;
|
|
13
|
+
rememberLastResult?: boolean;
|
|
14
|
+
questions: Question<T>[];
|
|
15
|
+
/** Where the facts come from. */
|
|
16
|
+
sources?: { title: T; url?: string }[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The quiz's languages, German first. */
|
|
20
|
+
export const quizLanguages = (quiz: Pick<Quiz<Text>, "languages">): readonly string[] => quiz.languages ?? ["de"];
|
|
21
|
+
|
|
22
|
+
/** The quiz in `lang`: each text from its map, a plain string as it is; everything else as it is. */
|
|
23
|
+
export function quizIn(quiz: Quiz<Text>, lang: string): Quiz {
|
|
24
|
+
const { sources } = quiz;
|
|
25
|
+
// the keys in their order, so a quiz in German only is as it was
|
|
26
|
+
return {
|
|
27
|
+
...(quiz as Omit<Quiz<Text>, "sources">),
|
|
28
|
+
title: inLanguage(quiz.title, lang),
|
|
29
|
+
intro: inLanguage(quiz.intro, lang),
|
|
30
|
+
questions: quiz.questions.map((question) => questionIn(question, lang)),
|
|
31
|
+
...(sources === undefined
|
|
32
|
+
? {}
|
|
33
|
+
: { sources: sources.map((source) => ({ ...source, title: inLanguage(source.title, lang) })) }),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The number of questions answered right; questions without an answer count as wrong. */
|
|
38
|
+
export function score(questions: readonly Question[], answers: ReadonlyMap<string, Answer>): number {
|
|
39
|
+
return questions.filter((question) => {
|
|
40
|
+
const answer = answers.get(question.id);
|
|
41
|
+
return answer !== undefined && isCorrect(question, answer);
|
|
42
|
+
}).length;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What the engine reads from the page (script#quiz-data). */
|
|
46
|
+
export interface QuizData {
|
|
47
|
+
/** The quiz in the page's language. */
|
|
48
|
+
quiz: Quiz;
|
|
49
|
+
/** The name of the app's storage, when the quiz keeps the last result on the device. */
|
|
50
|
+
storage?: string;
|
|
51
|
+
/** The page's language, when it is not German. */
|
|
52
|
+
lang?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The id of the element of the solution of a question, in the list of solutions. */
|
|
56
|
+
export const solutionId = (question: Pick<Question, "id">): string => `loesung-${question.id}`;
|