@lernapps/app-templates 0.0.0-stage → 0.1.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.
- package/LICENSE +22 -0
- package/README.md +195 -2
- package/dist/appname-Bh23cUuz.mjs +12 -0
- package/dist/assessment-By2GGTJC.mjs +1696 -0
- package/dist/block-BK_ohsH1.mjs +82 -0
- package/dist/blocks-BmRrD7UY.mjs +149 -0
- package/dist/blocks.mjs +4 -0
- package/dist/check-BkEl-m-F.mjs +254 -0
- package/dist/content-cYBPYg_J.mjs +140 -0
- package/dist/contract-PONpxJtd.mjs +52 -0
- package/dist/e2e-BiMI-pCL.mjs +89 -0
- package/dist/engine-BqF2Vffn.mjs +136 -0
- package/dist/explainer-e2e.mjs +1297 -0
- package/dist/explainer-islands.mjs +197 -0
- package/dist/explainer-plugin.mjs +2131 -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-C-0hUaSC.mjs +16947 -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-BKc8JYtC.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-BJey0bwl.mjs +540 -0
- package/dist/render-BL6fV_NI.mjs +507 -0
- package/dist/render-DpRO_RKm.mjs +146 -0
- package/dist/result-_eQcamoz.mjs +180 -0
- package/dist/tasks-DADUYy52.mjs +74 -0
- package/dist/validate-COaZso7u.mjs +1072 -0
- package/dist/validate-CxtdeQ5S.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 +144 -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 +446 -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 +1474 -0
- package/runtime/explainer/exercises.ts +324 -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 +299 -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 +430 -0
- package/runtime/shared/questions/question.v1.schema.json +342 -0
- package/runtime/shared/questions/questions.css +192 -0
- package/runtime/shared/questions/render.ts +247 -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/text.v1.schema.json +34 -0
- package/runtime/shared/vue-code.ts +395 -0
- package/runtime/shared/vue.ts +38 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { c as textLanguages, o as languageListProblems, r as LANGUAGES, t as escape } from "./html-mffkHGHh.mjs";
|
|
2
|
+
import { r as materialImages } from "./block-BK_ohsH1.mjs";
|
|
3
|
+
import "./blocks-BmRrD7UY.mjs";
|
|
4
|
+
import { a as duplicates, d as readable, f as schemaMessages, o as inQuestion, p as validateQuestion, u as questionSchemas } from "./validate-COaZso7u.mjs";
|
|
5
|
+
import { n as quizLanguages, t as quizIn } from "./quiz-BKc8JYtC.mjs";
|
|
6
|
+
import { t as appName } from "./appname-Bh23cUuz.mjs";
|
|
7
|
+
import { n as isSizeless, t as imageType } from "./image-CspJVfyQ.mjs";
|
|
8
|
+
import { c as withLanguage, i as notFoundPage, n as fromDepth, r as languageRoot } from "./pages-9RaaomoO.mjs";
|
|
9
|
+
import { r as renderQuiz } from "./render-DpRO_RKm.mjs";
|
|
10
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
// runtime/quiz/quiz.v1.schema.json
|
|
14
|
+
var quiz_v1_schema_default = {
|
|
15
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
16
|
+
$id: "urn:lernapps:schema:quiz.v1",
|
|
17
|
+
title: "Question bank of a lernapps quiz",
|
|
18
|
+
description: "The questions of a quiz built from the archetype quiz (runtime @lernapps/app-templates), in src/quiz.json: a quiz about things to know. Each option carries short background knowledge, each question links to further reading. The build validates the bank against this schema and fails with a message naming each problem. Every text is for learners: German, plain language, in your own words. A quiz in several languages declares them in languages, German first, and writes every text for learners as a map of texts by language ({ \"de\": …, \"en\": … }) with each of them, and the address of a link as one for every language or as a map by language; the build then writes a page per language.",
|
|
19
|
+
type: "object",
|
|
20
|
+
additionalProperties: false,
|
|
21
|
+
required: [
|
|
22
|
+
"title",
|
|
23
|
+
"intro",
|
|
24
|
+
"questions"
|
|
25
|
+
],
|
|
26
|
+
properties: {
|
|
27
|
+
"$schema": {
|
|
28
|
+
"description": "This schema, for the editor.",
|
|
29
|
+
"type": "string"
|
|
30
|
+
},
|
|
31
|
+
"languages": {
|
|
32
|
+
"description": "The languages of the quiz, German first: [\"de\", \"en\"]. The build writes a page per language: German at the root, every other below /<language>/, with a language switch. Each language needs every text for learners as a map of texts by language; without languages, the quiz is German only and its texts are strings.",
|
|
33
|
+
"type": "array",
|
|
34
|
+
"minItems": 1,
|
|
35
|
+
"items": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"pattern": "^[a-z]{2}$"
|
|
38
|
+
},
|
|
39
|
+
"default": ["de"]
|
|
40
|
+
},
|
|
41
|
+
"title": {
|
|
42
|
+
"description": "The title of the quiz: the page's title and its h1 heading.",
|
|
43
|
+
"$ref": "urn:lernapps:schema:text.v1#/$defs/text"
|
|
44
|
+
},
|
|
45
|
+
"intro": {
|
|
46
|
+
"description": "One or two sentences under the heading: what the quiz is about and whom it is for.",
|
|
47
|
+
"$ref": "urn:lernapps:schema:text.v1#/$defs/text"
|
|
48
|
+
},
|
|
49
|
+
"rememberLastResult": {
|
|
50
|
+
"description": "Keep the last result on the learner's device (localStorage, through the preset's storage) and show it at the end. Off by default: then the quiz stores nothing.",
|
|
51
|
+
"type": "boolean",
|
|
52
|
+
"default": false
|
|
53
|
+
},
|
|
54
|
+
"questions": {
|
|
55
|
+
"description": "The questions, in the order of the page without JavaScript. With JavaScript, the order is shuffled by the seed in the address (?seed=<number>).",
|
|
56
|
+
"type": "array",
|
|
57
|
+
"minItems": 1,
|
|
58
|
+
"items": { "$ref": "urn:lernapps:schema:question.v1#/$defs/question" }
|
|
59
|
+
},
|
|
60
|
+
"sources": {
|
|
61
|
+
"description": "Where the facts of the quiz come from, listed at the end of the page.",
|
|
62
|
+
"type": "array",
|
|
63
|
+
"minItems": 1,
|
|
64
|
+
"items": { "$ref": "#/$defs/source" }
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
$defs: { "source": {
|
|
68
|
+
"type": "object",
|
|
69
|
+
"additionalProperties": false,
|
|
70
|
+
"required": ["title"],
|
|
71
|
+
"properties": {
|
|
72
|
+
"title": {
|
|
73
|
+
"description": "The source, e.g. the article or book.",
|
|
74
|
+
"$ref": "urn:lernapps:schema:text.v1#/$defs/text"
|
|
75
|
+
},
|
|
76
|
+
"url": {
|
|
77
|
+
"description": "Its address, if it is on the web; https only.",
|
|
78
|
+
"type": "string",
|
|
79
|
+
"pattern": "^https://[^\\s]+$"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} }
|
|
83
|
+
};
|
|
84
|
+
// runtime/quiz/validate.ts
|
|
85
|
+
const SCHEMA_ID = quiz_v1_schema_default.$id;
|
|
86
|
+
/** Where an app finds the schema of its question bank, for the message that names it. */
|
|
87
|
+
const SCHEMA_FILE = "node_modules/@lernapps/app-templates/runtime/quiz/quiz.v1.schema.json";
|
|
88
|
+
const RULE_URL = "https://lernapps.net/tooling/rules/#quiz-bank-valid";
|
|
89
|
+
const ajv = new Ajv2020({
|
|
90
|
+
allErrors: true,
|
|
91
|
+
strict: true
|
|
92
|
+
});
|
|
93
|
+
for (const each of questionSchemas) ajv.addSchema(each);
|
|
94
|
+
ajv.addSchema(quiz_v1_schema_default);
|
|
95
|
+
function compiled(ref) {
|
|
96
|
+
const validate = ajv.getSchema(ref);
|
|
97
|
+
if (validate === void 0) throw new Error(`the quiz's schema has no ${ref}`);
|
|
98
|
+
return validate;
|
|
99
|
+
}
|
|
100
|
+
const validateBank = compiled(SCHEMA_ID);
|
|
101
|
+
/** `/questions/2/options/1/text` as `questions[2].options[1].text`, with the id of the question. */
|
|
102
|
+
function inBank(path, value) {
|
|
103
|
+
const parts = path.split("/").slice(1);
|
|
104
|
+
const [first, index] = parts;
|
|
105
|
+
if (first === "questions" && index !== void 0 && /^\d+$/.test(index)) {
|
|
106
|
+
const questions = typeof value === "object" && value !== null && "questions" in value ? value.questions : [];
|
|
107
|
+
return inQuestion(`questions[${index}]`, parts.slice(2), Array.isArray(questions) ? questions[Number(index)] : void 0);
|
|
108
|
+
}
|
|
109
|
+
const at = readable(parts);
|
|
110
|
+
return at === "" ? "the bank" : at.slice(1);
|
|
111
|
+
}
|
|
112
|
+
/** Where the quiz declares its languages, for the message about a language it does not declare. */
|
|
113
|
+
const DECLARED_IN = "src/quiz.json";
|
|
114
|
+
/** The problems of the bank's own texts (title, intro, sources) in its languages. */
|
|
115
|
+
function bankTextProblems(quiz, languages) {
|
|
116
|
+
return [
|
|
117
|
+
["title", quiz.title],
|
|
118
|
+
["intro", quiz.intro],
|
|
119
|
+
...(quiz.sources ?? []).map((source, index) => [`sources[${index}].title`, source.title])
|
|
120
|
+
].flatMap(([at, text]) => textLanguages(text, languages, false, DECLARED_IN).problems.map((problem) => `${at} (${problem.lang}): ${problem.message}`));
|
|
121
|
+
}
|
|
122
|
+
/** The problems of a question bank; none when it is valid. */
|
|
123
|
+
function problems(value, app) {
|
|
124
|
+
if (!validateBank(value)) return schemaMessages(validateBank.errors ?? [], (path) => inBank(path, value), value);
|
|
125
|
+
const languages = quizLanguages(value);
|
|
126
|
+
const listed = languageListProblems(languages, LANGUAGES).map((problem) => `languages[${Math.max(languages.lastIndexOf(problem.lang), 0)}]: ${problem.message}`);
|
|
127
|
+
if (listed.length > 0) return listed;
|
|
128
|
+
const ids = value.questions.map((question) => question.id);
|
|
129
|
+
return [
|
|
130
|
+
...duplicates(ids).map((id) => `questions: the id "${id}" appears twice; each question needs its own id`),
|
|
131
|
+
...bankTextProblems(value, languages),
|
|
132
|
+
...value.questions.flatMap((question, index) => validateQuestion(question, `questions[${index}]`, languages, DECLARED_IN, app))
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
/** Reads a question bank from JSON text, in all its languages; throws an Error whose message lists every problem. */
|
|
136
|
+
function readBank(text, file, app) {
|
|
137
|
+
let value;
|
|
138
|
+
try {
|
|
139
|
+
value = JSON.parse(text);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
throw new Error(`${file} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
142
|
+
}
|
|
143
|
+
const found = problems(value, app);
|
|
144
|
+
if (found.length > 0) throw new Error([
|
|
145
|
+
`${file} is not a valid question bank (${RULE_URL}):`,
|
|
146
|
+
...found.map((problem) => ` - ${problem}`),
|
|
147
|
+
`Fix the question bank as its schema says: ${SCHEMA_FILE}`
|
|
148
|
+
].join("\n"));
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
// runtime/quiz/plugin.ts
|
|
152
|
+
/** Where the question bank is, relative to the app's root. */
|
|
153
|
+
const BANK = "src/quiz.json";
|
|
154
|
+
const MARKER = "<!-- quiz -->";
|
|
155
|
+
/** The page in `lang`: the shell with the quiz in that language, its title, description and alternates. */
|
|
156
|
+
function fill(html, { quiz, storage, images }, lang) {
|
|
157
|
+
if (!html.includes(MARKER)) throw new Error(`index.html has no ${MARKER}: put it in <body>, where the quiz goes`);
|
|
158
|
+
const data = {
|
|
159
|
+
quiz: quizIn(quiz, lang),
|
|
160
|
+
...storage === void 0 ? {} : { storage },
|
|
161
|
+
...lang === "de" ? {} : { lang }
|
|
162
|
+
};
|
|
163
|
+
const { title, description, alternates, body } = renderQuiz(data, {
|
|
164
|
+
languages: quizLanguages(quiz),
|
|
165
|
+
sizeless: (image) => {
|
|
166
|
+
const file = images.get(image);
|
|
167
|
+
return file !== void 0 && isSizeless(file);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
const head = [
|
|
171
|
+
`<title>${escape(title)}</title>`,
|
|
172
|
+
`<meta name="description" content="${escape(description)}" />`,
|
|
173
|
+
...alternates
|
|
174
|
+
].join("\n ");
|
|
175
|
+
const withHead = /<title>[\s\S]*?<\/title>/.test(html) ? html.replace(/<title>[\s\S]*?<\/title>/, head) : html.replace("</head>", ` ${head}\n </head>`);
|
|
176
|
+
return (lang === "de" ? withHead : withLanguage(withHead, lang)).replace(MARKER, () => body);
|
|
177
|
+
}
|
|
178
|
+
/** The language of a page the dev server serves: the first part of its address, if the quiz has that language. */
|
|
179
|
+
function languageOf(url, languages) {
|
|
180
|
+
const first = /^\/([a-z]{2})\//.exec(url ?? "")?.[1];
|
|
181
|
+
return first !== void 0 && first !== "de" && languages.includes(first) ? first : "de";
|
|
182
|
+
}
|
|
183
|
+
/** The build step of the quiz: validates the question bank and renders it into index.html, a page per language. */
|
|
184
|
+
function quiz() {
|
|
185
|
+
let root = process.cwd();
|
|
186
|
+
let bank;
|
|
187
|
+
/** The built index.html before the quiz is in it, for the pages in the other languages. */
|
|
188
|
+
let shell;
|
|
189
|
+
const load = () => {
|
|
190
|
+
const file = join(root, BANK);
|
|
191
|
+
let text;
|
|
192
|
+
try {
|
|
193
|
+
text = readFileSync(file, "utf8");
|
|
194
|
+
} catch {
|
|
195
|
+
throw new Error(`${BANK} is missing: the quiz's questions belong there (schema quiz.v1.schema.json)`);
|
|
196
|
+
}
|
|
197
|
+
const quiz = readBank(text, BANK, { imageExists: (image) => existsSync(join(root, "src", image)) });
|
|
198
|
+
const images = new Map(quiz.questions.flatMap((question) => materialImages(question.material ?? [])).map((image) => [image, join(root, "src", image)]));
|
|
199
|
+
return quiz.rememberLastResult === true ? {
|
|
200
|
+
quiz,
|
|
201
|
+
storage: appName(root, "quiz"),
|
|
202
|
+
images
|
|
203
|
+
} : {
|
|
204
|
+
quiz,
|
|
205
|
+
images
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
return {
|
|
209
|
+
name: "lernapps-quiz",
|
|
210
|
+
enforce: "post",
|
|
211
|
+
configResolved(config) {
|
|
212
|
+
root = config.root;
|
|
213
|
+
},
|
|
214
|
+
buildStart() {
|
|
215
|
+
this.addWatchFile(join(root, BANK));
|
|
216
|
+
try {
|
|
217
|
+
bank = load();
|
|
218
|
+
} catch (error) {
|
|
219
|
+
this.error(error instanceof Error ? error.message : String(error));
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
handleHotUpdate({ file, server }) {
|
|
223
|
+
if (file === join(root, "src/quiz.json")) {
|
|
224
|
+
bank = void 0;
|
|
225
|
+
server.ws.send({ type: "full-reload" });
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
configureServer(server) {
|
|
229
|
+
server.middlewares.use((request, response, next) => {
|
|
230
|
+
const path = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname).replace(/^\//, "");
|
|
231
|
+
if (!path.startsWith("images/")) return next();
|
|
232
|
+
const file = (bank ??= load()).images.get(path);
|
|
233
|
+
if (file === void 0) return next();
|
|
234
|
+
response.setHeader("content-type", imageType(path));
|
|
235
|
+
response.end(readFileSync(file));
|
|
236
|
+
});
|
|
237
|
+
},
|
|
238
|
+
transformIndexHtml(html, context) {
|
|
239
|
+
bank ??= load();
|
|
240
|
+
shell = html;
|
|
241
|
+
return fill(html, bank, languageOf(context.originalUrl, quizLanguages(bank.quiz)));
|
|
242
|
+
},
|
|
243
|
+
generateBundle() {
|
|
244
|
+
if (bank === void 0 || shell === void 0) return;
|
|
245
|
+
this.emitFile({
|
|
246
|
+
type: "asset",
|
|
247
|
+
fileName: "404.html",
|
|
248
|
+
source: notFoundPage()
|
|
249
|
+
});
|
|
250
|
+
for (const [image, file] of bank.images) this.emitFile({
|
|
251
|
+
type: "asset",
|
|
252
|
+
fileName: image,
|
|
253
|
+
source: readFileSync(file)
|
|
254
|
+
});
|
|
255
|
+
for (const lang of quizLanguages(bank.quiz).slice(1)) {
|
|
256
|
+
const path = languageRoot(lang);
|
|
257
|
+
this.emitFile({
|
|
258
|
+
type: "asset",
|
|
259
|
+
fileName: `${path}index.html`,
|
|
260
|
+
source: fill(fromDepth(shell, path), bank, lang)
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
export { BANK, quiz };
|
package/dist/quiz.mjs
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { S as shuffled, g as questionId, u as isCorrect } from "./render-BJey0bwl.mjs";
|
|
2
|
+
import { n as startQuestion } from "./engine-BqF2Vffn.mjs";
|
|
3
|
+
import { a as de_default, i as en_default, r as solutionId } from "./quiz-BKc8JYtC.mjs";
|
|
4
|
+
import { translator } from "@lernapps/tooling/i18n";
|
|
5
|
+
import { announce, moveFocus } from "@lernapps/tooling/a11y";
|
|
6
|
+
import { createStorage } from "@lernapps/tooling/storage";
|
|
7
|
+
// runtime/quiz/engine.ts
|
|
8
|
+
/** The quiz's texts, by the page's language. */
|
|
9
|
+
const BY_LANGUAGE = {
|
|
10
|
+
de: de_default,
|
|
11
|
+
en: en_default
|
|
12
|
+
};
|
|
13
|
+
const SEED = "seed";
|
|
14
|
+
const COUNT = "n";
|
|
15
|
+
const isLastResult = (value) => typeof value === "object" && value !== null && "score" in value && "total" in value && typeof value.score === "number" && typeof value.total === "number";
|
|
16
|
+
/** The seed in the address; a new one, written into the address, when there is none. */
|
|
17
|
+
function seedOf(location) {
|
|
18
|
+
const url = new URL(location.href);
|
|
19
|
+
const given = Number(url.searchParams.get(SEED));
|
|
20
|
+
if (Number.isInteger(given) && given > 0) return given;
|
|
21
|
+
const seed = 1 + Math.floor(Math.random() * 999999);
|
|
22
|
+
url.searchParams.set(SEED, String(seed));
|
|
23
|
+
history.replaceState(history.state, "", url);
|
|
24
|
+
return seed;
|
|
25
|
+
}
|
|
26
|
+
/** The number of questions in the address (?n=<number>), if it is one from 1 to `total`. */
|
|
27
|
+
function countOf(location, total) {
|
|
28
|
+
const given = Number(new URL(location.href).searchParams.get(COUNT));
|
|
29
|
+
return Number.isInteger(given) && given >= 1 && given <= total ? given : void 0;
|
|
30
|
+
}
|
|
31
|
+
/** The share of right answers from which the result rains confetti; all right gives fireworks instead. */
|
|
32
|
+
const CONFETTI_FROM = .8;
|
|
33
|
+
/**
|
|
34
|
+
* Celebrates a result on a layer over the page that takes no clicks and is hidden for those who prefer less motion
|
|
35
|
+
* (quiz.css): confetti from 80% right, fireworks for all right. Positions, delays and colours are fixed by index, so
|
|
36
|
+
* the same result always looks the same.
|
|
37
|
+
*/
|
|
38
|
+
function celebrate(root, right, total) {
|
|
39
|
+
if (total === 0 || right / total < CONFETTI_FROM) return void 0;
|
|
40
|
+
const layer = root.createElement("div");
|
|
41
|
+
layer.setAttribute("aria-hidden", "true");
|
|
42
|
+
const add = (parent, tag, style) => {
|
|
43
|
+
const element = root.createElement(tag);
|
|
44
|
+
for (const [name, value] of Object.entries(style)) element.style.setProperty(name, value);
|
|
45
|
+
parent.append(element);
|
|
46
|
+
return element;
|
|
47
|
+
};
|
|
48
|
+
if (right === total) {
|
|
49
|
+
layer.className = "quiz-celebration quiz-fireworks";
|
|
50
|
+
for (const burst of [
|
|
51
|
+
{
|
|
52
|
+
left: "24%",
|
|
53
|
+
top: "26%",
|
|
54
|
+
delay: "0.3s",
|
|
55
|
+
hue: 20
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
left: "76%",
|
|
59
|
+
top: "22%",
|
|
60
|
+
delay: "0.9s",
|
|
61
|
+
hue: 200
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
left: "50%",
|
|
65
|
+
top: "40%",
|
|
66
|
+
delay: "1.5s",
|
|
67
|
+
hue: 320
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
left: "18%",
|
|
71
|
+
top: "58%",
|
|
72
|
+
delay: "2.1s",
|
|
73
|
+
hue: 140
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
left: "82%",
|
|
77
|
+
top: "56%",
|
|
78
|
+
delay: "2.7s",
|
|
79
|
+
hue: 50
|
|
80
|
+
}
|
|
81
|
+
]) {
|
|
82
|
+
const element = add(layer, "span", {
|
|
83
|
+
left: burst.left,
|
|
84
|
+
top: burst.top,
|
|
85
|
+
"--hue": String(burst.hue)
|
|
86
|
+
});
|
|
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
|
+
/** Starts the quiz on the page the build rendered; does nothing on a page without one. */
|
|
111
|
+
function startQuiz(root = document) {
|
|
112
|
+
const dataElement = root.getElementById("quiz-data");
|
|
113
|
+
const main = root.getElementById("quiz");
|
|
114
|
+
if (!dataElement || !main) return;
|
|
115
|
+
const { quiz, storage: storageName, lang = "de" } = JSON.parse(dataElement.textContent ?? "{}");
|
|
116
|
+
const t = translator(BY_LANGUAGE[lang] ?? de_default);
|
|
117
|
+
const storage = storageName === void 0 ? void 0 : createStorage(storageName);
|
|
118
|
+
const seed = seedOf(root.location);
|
|
119
|
+
const total = quiz.questions.length;
|
|
120
|
+
const shuffledQuestions = shuffled(total, seed).flatMap((index) => quiz.questions[index] ?? []);
|
|
121
|
+
const hashId = decodeURIComponent(root.location.hash.slice(1));
|
|
122
|
+
const list = main.querySelector(".quiz-questions");
|
|
123
|
+
const progress = main.querySelector(".quiz-progress");
|
|
124
|
+
const result = main.querySelector(".quiz-result");
|
|
125
|
+
const solutions = main.querySelector(".quiz-solutions");
|
|
126
|
+
if (!list || !progress || !result || !solutions) return;
|
|
127
|
+
const heading = main.querySelector(".quiz-heading");
|
|
128
|
+
const form = main.querySelector(".quiz-start");
|
|
129
|
+
/** Runs the quiz with the first `count` questions in the order of the seed. */
|
|
130
|
+
const run = (count) => {
|
|
131
|
+
const order = shuffledQuestions.slice(0, count);
|
|
132
|
+
const linked = shuffledQuestions.find((question) => questionId(question) === hashId);
|
|
133
|
+
if (linked && !order.includes(linked)) order[0] = linked;
|
|
134
|
+
const answers = /* @__PURE__ */ new Map();
|
|
135
|
+
main.classList.add("quiz-running");
|
|
136
|
+
solutions.hidden = true;
|
|
137
|
+
progress.hidden = false;
|
|
138
|
+
const items = /* @__PURE__ */ new Map();
|
|
139
|
+
for (const question of order) {
|
|
140
|
+
const item = root.getElementById(questionId(question));
|
|
141
|
+
if (!item) continue;
|
|
142
|
+
items.set(question.id, item);
|
|
143
|
+
list.append(item);
|
|
144
|
+
startQuestion(item, question, {
|
|
145
|
+
seed,
|
|
146
|
+
lang,
|
|
147
|
+
onAnswer: (answer) => answers.set(question.id, answer)
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
for (const question of quiz.questions.filter((candidate) => !order.includes(candidate))) {
|
|
151
|
+
root.getElementById(questionId(question))?.remove();
|
|
152
|
+
root.getElementById(solutionId(question))?.remove();
|
|
153
|
+
}
|
|
154
|
+
let celebration;
|
|
155
|
+
const show = (index, focus) => {
|
|
156
|
+
celebration?.remove();
|
|
157
|
+
order.forEach((question, at) => {
|
|
158
|
+
const item = items.get(question.id);
|
|
159
|
+
if (item) item.hidden = at !== index;
|
|
160
|
+
});
|
|
161
|
+
result.hidden = true;
|
|
162
|
+
solutions.hidden = true;
|
|
163
|
+
progress.textContent = t("progress", {
|
|
164
|
+
current: index + 1,
|
|
165
|
+
total: order.length
|
|
166
|
+
});
|
|
167
|
+
const legend = items.get(order[index]?.id ?? "")?.querySelector("legend");
|
|
168
|
+
if (focus && legend) moveFocus(legend);
|
|
169
|
+
if (focus) progress.scrollIntoView({ block: "start" });
|
|
170
|
+
};
|
|
171
|
+
const finish = () => {
|
|
172
|
+
for (const item of items.values()) item.hidden = true;
|
|
173
|
+
progress.hidden = true;
|
|
174
|
+
const right = order.filter((question) => {
|
|
175
|
+
const answer = answers.get(question.id);
|
|
176
|
+
return answer !== void 0 && isCorrect(question, answer);
|
|
177
|
+
}).length;
|
|
178
|
+
const scoreText = main.querySelector(".quiz-score");
|
|
179
|
+
if (scoreText) scoreText.textContent = t("score", {
|
|
180
|
+
score: right,
|
|
181
|
+
total: order.length
|
|
182
|
+
});
|
|
183
|
+
const last = storage?.load("last-result", isLastResult);
|
|
184
|
+
const lastText = main.querySelector(".quiz-last");
|
|
185
|
+
if (lastText && last) {
|
|
186
|
+
lastText.textContent = t("last", {
|
|
187
|
+
score: last.score,
|
|
188
|
+
total: last.total
|
|
189
|
+
});
|
|
190
|
+
lastText.hidden = false;
|
|
191
|
+
}
|
|
192
|
+
storage?.save("last-result", {
|
|
193
|
+
score: right,
|
|
194
|
+
total: order.length
|
|
195
|
+
});
|
|
196
|
+
const restart = main.querySelector(".quiz-restart");
|
|
197
|
+
if (restart) restart.href = `?${SEED}=${1 + seed * 7919 % 999999}&${COUNT}=${order.length}`;
|
|
198
|
+
result.hidden = false;
|
|
199
|
+
solutions.hidden = false;
|
|
200
|
+
celebration?.remove();
|
|
201
|
+
celebration = celebrate(root, right, order.length);
|
|
202
|
+
moveFocus(result);
|
|
203
|
+
announce(scoreText?.textContent ?? "");
|
|
204
|
+
};
|
|
205
|
+
order.forEach((question, index) => {
|
|
206
|
+
const next = items.get(question.id)?.querySelector(".question-next");
|
|
207
|
+
if (!next) return;
|
|
208
|
+
const isLast = index === order.length - 1;
|
|
209
|
+
if (isLast) next.textContent = t("toResult");
|
|
210
|
+
next.addEventListener("click", () => {
|
|
211
|
+
if (isLast) finish();
|
|
212
|
+
else show(index + 1, true);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
const fromAddress = (focus) => {
|
|
216
|
+
const hashId = decodeURIComponent(root.location.hash.slice(1));
|
|
217
|
+
const index = order.findIndex((question) => questionId(question) === hashId);
|
|
218
|
+
show(index >= 0 ? index : 0, focus || index >= 0);
|
|
219
|
+
};
|
|
220
|
+
globalThis.addEventListener("hashchange", () => fromAddress(true));
|
|
221
|
+
fromAddress(false);
|
|
222
|
+
};
|
|
223
|
+
const given = countOf(root.location, total);
|
|
224
|
+
if (given !== void 0 || hashId !== "" || !form || total < 2) {
|
|
225
|
+
run(given ?? total);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const input = form.querySelector("input");
|
|
229
|
+
if (!input) return run(total);
|
|
230
|
+
list.hidden = true;
|
|
231
|
+
solutions.hidden = true;
|
|
232
|
+
if (heading) heading.hidden = true;
|
|
233
|
+
form.hidden = false;
|
|
234
|
+
form.addEventListener("submit", (event) => {
|
|
235
|
+
event.preventDefault();
|
|
236
|
+
const count = Math.min(total, Math.max(1, Math.floor(Number(input.value)) || total));
|
|
237
|
+
const url = new URL(root.location.href);
|
|
238
|
+
url.searchParams.set(COUNT, String(count));
|
|
239
|
+
history.replaceState(history.state, "", url);
|
|
240
|
+
form.hidden = true;
|
|
241
|
+
list.hidden = false;
|
|
242
|
+
if (heading) heading.hidden = false;
|
|
243
|
+
run(count);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
export { startQuiz };
|