@lernapps/app-templates 0.2.1 → 0.3.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/README.md +5 -11
- package/dist/{assessment-Dy359O1O.mjs → assessment-DaKsXUdj.mjs} +204 -16
- package/dist/{blocks-CxtCPAa5.mjs → blocks-D4SSGKuL.mjs} +2 -2
- package/dist/blocks.mjs +2 -2
- package/dist/{check-CteIFN96.mjs → check-BxZOZPic.mjs} +58 -4
- package/dist/{content-DO6oEJxj.mjs → content-CAL0Nz1a.mjs} +1 -1
- package/dist/explainer-e2e.mjs +36 -5
- package/dist/explainer-plugin.mjs +57 -13
- package/dist/explainer.mjs +2 -2
- package/dist/interactive-e2e.mjs +1 -1
- package/dist/interactive-plugin.mjs +2 -2
- package/dist/{load-RosvaPZf.mjs → load--ovXqbNE.mjs} +286 -33
- package/dist/questions.mjs +1 -1
- package/dist/quiz-e2e.mjs +1 -1
- package/dist/quiz-plugin.mjs +3 -3
- package/dist/{render-D5kwkNt1.mjs → render-D3LsJY04.mjs} +2 -2
- package/dist/{render-DqHOJyHK.mjs → render-mYtzN1DV.mjs} +1 -1
- package/dist/{result-D_mmCVwN.mjs → result-DphIWHDF.mjs} +1 -1
- package/dist/{validate-Bxy9NfB8.mjs → validate-CfFykN0M.mjs} +1 -1
- package/dist/{validate-BDwL7Su3.mjs → validate-j3GjVhNF.mjs} +2 -2
- package/explainer/README.md +7 -3
- package/explainer/docs/arc42/05-explainer-building-blocks.arc42.md +26 -8
- package/explainer/docs/arc42/09-explainer-decisions.arc42.md +42 -0
- package/explainer/package.json +1 -1
- package/explainer/src/app.yaml +3 -1
- package/explainer/src/prozent/chapter.yaml +1 -1
- package/explainer/src/prozent/grundwert.yaml +2 -1
- package/explainer/src/prozent/prozentsatz.yaml +2 -1
- package/explainer/src/prozent/prozentwert.yaml +2 -1
- package/interactive/package.json +1 -1
- package/package.json +3 -4
- package/quiz/package.json +1 -1
- package/runtime/explainer/assessment.ts +4 -2
- package/runtime/explainer/check-page.ts +6 -2
- package/runtime/explainer/content.ts +20 -1
- package/runtime/explainer/e2e.ts +40 -0
- package/runtime/explainer/exercises.ts +71 -6
- package/runtime/explainer/explainer.css +21 -16
- package/runtime/explainer/explainer.v1.schema.json +2 -2
- package/runtime/explainer/explainer.v2.schema.json +152 -0
- package/runtime/explainer/expression.ts +127 -1
- package/runtime/explainer/generated/texts.ts +98 -9
- package/runtime/explainer/generated/types.ts +8 -0
- package/runtime/explainer/load.ts +70 -16
- package/runtime/explainer/messages/de.json +5 -1
- package/runtime/explainer/messages/en.json +5 -1
- package/runtime/explainer/outline.ts +1 -1
- package/runtime/explainer/page-list.ts +38 -0
- package/runtime/explainer/plugin.ts +13 -4
- package/runtime/explainer/render.ts +26 -2
- package/runtime/explainer/schema.ts +92 -9
- package/runtime/explainer/tutorpage.ts +9 -1
- package/runtime/shared/blocks/blocks.css +18 -0
- package/runtime/shared/blocks/blocks.v1.schema.json +1 -1
- package/runtime/shared/text/formula.ts +61 -1
- package/scaffold/explainer/src/app.yaml +5 -1
- package/scaffold/explainer/src/thema/chapter.yaml +1 -1
- package/scaffold/explainer/src/thema/einstieg.yaml +1 -1
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
// Reads an explainer's content from src/ at build time and validates it: each file against its part of the schema
|
|
2
|
-
// (explainer.v1.schema.json
|
|
1
|
+
// Reads an explainer's content from src/ at build time and validates it: each file against its part of the schema of
|
|
2
|
+
// its format (explainer.v1.schema.json without `format` in src/app.yaml, explainer.v2.schema.json with `format: 2`),
|
|
3
|
+
// then what a schema cannot say. In format 2, every page names its competencies when the app has a strand (or is a
|
|
4
|
+
// recap page, recap: <subject>/<competency>), and none when its strand is none; a recap page has no exercise, and
|
|
5
|
+
// every other page at least one. The order lists are complete and unique, the depth is
|
|
3
6
|
// app, chapter, page and two section levels, ids are unique per level, every text is complete in each declared
|
|
4
7
|
// language and in no other, texts hold restricted Markdown without raw HTML or headings, formulas are in the command
|
|
5
8
|
// list, bundled images and quotes name source and licence, links to pages of the app find their page and section,
|
|
6
|
-
// every page has an exercise, a question of the question types passes the validation of a question in every declared language,
|
|
9
|
+
// every page but a recap page has an exercise, a question of the question types passes the validation of a question in every declared language,
|
|
7
10
|
// a generated exercise declares what it uses and passes its property test over fixed seeds (generated.ts), the check
|
|
8
11
|
// of each exercise judges its examples as they are listed, a vue-component block names a component of src/components/
|
|
9
12
|
// that passes its checks (components.ts), its props match the component's prop types, it has its nojs text (an exercise
|
|
@@ -17,6 +20,7 @@ import { readComponents } from "./components/read.ts";
|
|
|
17
20
|
import {
|
|
18
21
|
isExercise,
|
|
19
22
|
type App,
|
|
23
|
+
type Block,
|
|
20
24
|
type BlockOf,
|
|
21
25
|
type Chapter,
|
|
22
26
|
type ComponentUse,
|
|
@@ -27,12 +31,12 @@ import {
|
|
|
27
31
|
} from "./content.ts";
|
|
28
32
|
import { allBlocks } from "./routes.ts";
|
|
29
33
|
import { ASSESSMENT, drawsTasks, exerciseOf, parseRef } from "./assessment.ts";
|
|
30
|
-
import {
|
|
34
|
+
import { formatOf, SCHEMA_FILES, validate, type Format } from "./schema.ts";
|
|
31
35
|
import { appName } from "../shared/appname.ts";
|
|
32
36
|
import { isObject, Problems, readYaml, Texts, type Path, type YamlFile } from "../shared/content.ts";
|
|
33
37
|
import { isLanguage, LANGUAGES, languageListProblems } from "../shared/languages.ts";
|
|
34
38
|
|
|
35
|
-
export { SCHEMA_ID } from "./schema.ts";
|
|
39
|
+
export { SCHEMA_ID, SCHEMA_ID_V2, type Format } from "./schema.ts";
|
|
36
40
|
export const RULE_URL = "https://lernapps.net/tooling/rules/#explainer-content-valid";
|
|
37
41
|
|
|
38
42
|
/** What src/ may hold besides the chapter folders. */
|
|
@@ -45,12 +49,12 @@ const IMAGE = /^[a-z0-9]+(-[a-z0-9]+)*\.(svg|png|jpg|webp)$/;
|
|
|
45
49
|
/** The content could not be read: one problem per line in `problems`. */
|
|
46
50
|
export class ContentError extends Error {
|
|
47
51
|
readonly problems: readonly string[];
|
|
48
|
-
constructor(problems: readonly string[]) {
|
|
52
|
+
constructor(problems: readonly string[], format: Format = 1) {
|
|
49
53
|
super(
|
|
50
54
|
[
|
|
51
55
|
`the explainer's content in src/ is not valid (${RULE_URL}):`,
|
|
52
56
|
...problems.map((problem) => ` - ${problem}`),
|
|
53
|
-
`Fix the content as its schema says: ${
|
|
57
|
+
`Fix the content as its schema says: ${SCHEMA_FILES[format]}`,
|
|
54
58
|
].join("\n"),
|
|
55
59
|
);
|
|
56
60
|
this.problems = problems;
|
|
@@ -123,6 +127,14 @@ function checkAssessment(
|
|
|
123
127
|
problems.at(yaml, path, `${ref} names no page of the app; write /<chapter>/<page>/#<exercise>`);
|
|
124
128
|
return;
|
|
125
129
|
}
|
|
130
|
+
if (page.recap !== undefined) {
|
|
131
|
+
problems.at(
|
|
132
|
+
yaml,
|
|
133
|
+
path,
|
|
134
|
+
`${ref} is on a recap page, which has no exercise and is left out of the self-assessment; choose an exercise of a page of the app's own competencies`,
|
|
135
|
+
);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
126
138
|
const exercise = exerciseOf(components, page, parts.exercise);
|
|
127
139
|
// a generated exercise or an exercise component may be listed again: each listing draws a task of its own
|
|
128
140
|
if (twice && exercise !== undefined && !drawsTasks(exercise)) {
|
|
@@ -148,11 +160,21 @@ function checkAssessment(
|
|
|
148
160
|
});
|
|
149
161
|
}
|
|
150
162
|
|
|
163
|
+
/** Each block of `blocks` and the blocks inside them, with its path in the page's file. */
|
|
164
|
+
function* blockPaths(blocks: readonly Block[], path: Path): Generator<[Block, Path]> {
|
|
165
|
+
for (const [index, block] of blocks.entries()) {
|
|
166
|
+
yield [block, [...path, index]];
|
|
167
|
+
if (block.type === "section" || block.type === "example" || block.type === "note") {
|
|
168
|
+
yield* blockPaths(block.blocks, [...path, index, "blocks"]);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
151
173
|
/** How many seeds the property test of each generated exercise draws at build time. */
|
|
152
174
|
export const SEEDS = 200;
|
|
153
175
|
|
|
154
176
|
/** The problems of the content in `root`/src, and the content when there are none. */
|
|
155
|
-
export function check(root: string, seeds = SEEDS): { site?: Site; problems: string[] } {
|
|
177
|
+
export function check(root: string, seeds = SEEDS): { site?: Site; problems: string[]; format: Format } {
|
|
156
178
|
const problems = new Problems();
|
|
157
179
|
const src = join(root, "src");
|
|
158
180
|
if (!existsSync(join(src, "app.yaml"))) {
|
|
@@ -160,7 +182,7 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
|
|
|
160
182
|
"src/app.yaml",
|
|
161
183
|
"is missing: the app's title, intro, languages and the order of its chapters belong there",
|
|
162
184
|
);
|
|
163
|
-
return { problems: problems.lines };
|
|
185
|
+
return { problems: problems.lines, format: 1 };
|
|
164
186
|
}
|
|
165
187
|
|
|
166
188
|
// what src/ holds
|
|
@@ -213,13 +235,14 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
|
|
|
213
235
|
|
|
214
236
|
// the app
|
|
215
237
|
const appYaml = readYaml(root, "src/app.yaml", problems);
|
|
216
|
-
|
|
238
|
+
const format = formatOf(appYaml?.value);
|
|
239
|
+
if (appYaml === undefined || !validate(appYaml, "app", problems, format)) return { problems: problems.lines, format };
|
|
217
240
|
const app = appYaml.value as App;
|
|
218
241
|
const languageList = languageListProblems(app.languages, LANGUAGES);
|
|
219
242
|
for (const problem of languageList) {
|
|
220
243
|
problems.at(appYaml, ["languages", Math.max(app.languages.lastIndexOf(problem.lang), 0)], problem.message);
|
|
221
244
|
}
|
|
222
|
-
if (languageList.length > 0) return { problems: problems.lines };
|
|
245
|
+
if (languageList.length > 0) return { problems: problems.lines, format };
|
|
223
246
|
const texts = new Texts(app.languages, problems);
|
|
224
247
|
texts.check(appYaml, ["title"], "plain");
|
|
225
248
|
texts.check(appYaml, ["intro"], "markdown");
|
|
@@ -312,11 +335,30 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
|
|
|
312
335
|
for (const pageId of pagesOk ? chapter.pages : pageIds) {
|
|
313
336
|
const file = `src/${chapterId}/${pageId}.yaml`;
|
|
314
337
|
const yaml = readYaml(root, file, problems);
|
|
315
|
-
if (yaml === undefined || !validate(yaml, "page", problems)) {
|
|
338
|
+
if (yaml === undefined || !validate(yaml, "page", problems, format)) {
|
|
316
339
|
complete = false;
|
|
317
340
|
continue;
|
|
318
341
|
}
|
|
319
342
|
const page = yaml.value as Omit<Page, "id" | "chapter" | "file">;
|
|
343
|
+
// format 2: the competencies a page practises, by the app's strand
|
|
344
|
+
if (app.strand === "none" && page.competencies !== undefined) {
|
|
345
|
+
problems.at(
|
|
346
|
+
yaml,
|
|
347
|
+
["competencies"],
|
|
348
|
+
"competencies is not allowed: src/app.yaml has strand: none, so the app is on no strand; remove it, or name the app's strand in src/app.yaml",
|
|
349
|
+
);
|
|
350
|
+
} else if (
|
|
351
|
+
app.strand !== undefined &&
|
|
352
|
+
app.strand !== "none" &&
|
|
353
|
+
page.competencies === undefined &&
|
|
354
|
+
page.recap === undefined
|
|
355
|
+
) {
|
|
356
|
+
problems.at(
|
|
357
|
+
yaml,
|
|
358
|
+
[],
|
|
359
|
+
`competencies is missing; src/app.yaml names the strand ${app.strand}, so each page names the competencies of its subject that it practises, e.g. competencies: [laplace]; a page that only repeats a competency the app does not practise says recap: <subject>/<competency> instead`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
320
362
|
texts.check(yaml, ["title"], "plain");
|
|
321
363
|
texts.check(yaml, ["description"], "plain");
|
|
322
364
|
const pageAnchors = new Set<string>();
|
|
@@ -341,7 +383,18 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
|
|
|
341
383
|
["blocks"],
|
|
342
384
|
0,
|
|
343
385
|
);
|
|
344
|
-
if (
|
|
386
|
+
if (page.recap !== undefined) {
|
|
387
|
+
// a recap page repeats a competency the app does not practise: no exercise, not even a component's
|
|
388
|
+
for (const [block, path] of blockPaths(page.blocks, ["blocks"])) {
|
|
389
|
+
const component = uses.find((use) => use.block === block && use.component.exercise);
|
|
390
|
+
if (!isExercise(block) && component === undefined) continue;
|
|
391
|
+
problems.at(
|
|
392
|
+
yaml,
|
|
393
|
+
path,
|
|
394
|
+
`${component === undefined ? `the exercise ${block.type}` : `the exercise component ${component.component.name}`} is not allowed on a recap page: it repeats ${page.recap} with descriptive blocks only, and the apps of that competency's strand practise it; remove the exercise, or make it a page of the app's own competencies`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
} else if (![...allBlocks(page.blocks)].some(isExercise)) {
|
|
345
398
|
problems.at(
|
|
346
399
|
yaml,
|
|
347
400
|
["blocks"],
|
|
@@ -429,7 +482,7 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
|
|
|
429
482
|
}
|
|
430
483
|
}
|
|
431
484
|
|
|
432
|
-
if (problems.lines.length > 0) return { problems: problems.lines };
|
|
485
|
+
if (problems.lines.length > 0) return { problems: problems.lines, format };
|
|
433
486
|
const data: Record<string, DataRecord[]> = {};
|
|
434
487
|
for (const [id, yaml] of dataFiles) data[id] = yaml.value as DataRecord[];
|
|
435
488
|
return {
|
|
@@ -442,6 +495,7 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
|
|
|
442
495
|
storage: appName(root, "explainer"),
|
|
443
496
|
},
|
|
444
497
|
problems: [],
|
|
498
|
+
format,
|
|
445
499
|
};
|
|
446
500
|
}
|
|
447
501
|
|
|
@@ -449,8 +503,8 @@ export function check(root: string, seeds = SEEDS): { site?: Site; problems: str
|
|
|
449
503
|
* The content in `root`/src, its generated exercises tested over `seeds` seeds (0: not at all, for a reader of content
|
|
450
504
|
* the build has checked); throws a ContentError with every problem when it is not valid. */
|
|
451
505
|
export function loadSite(root: string, seeds = SEEDS): Site {
|
|
452
|
-
const { site, problems } = check(root, seeds);
|
|
453
|
-
if (site === undefined) throw new ContentError(problems);
|
|
506
|
+
const { site, problems, format } = check(root, seeds);
|
|
507
|
+
if (site === undefined) throw new ContentError(problems, format);
|
|
454
508
|
return site;
|
|
455
509
|
}
|
|
456
510
|
|
|
@@ -196,5 +196,9 @@
|
|
|
196
196
|
"openTaskLocked": "Die Lösung öffnet sich nach deiner Antwort.",
|
|
197
197
|
"resultPartly": "teilweise richtig ({n} von {total} Punkten, es fehlen {missed})",
|
|
198
198
|
"resultPhoto": "Antwort als Foto",
|
|
199
|
-
"resultAnswer": "Antwort „{input}“"
|
|
199
|
+
"resultAnswer": "Antwort „{input}“",
|
|
200
|
+
"recap": "Wiederholung",
|
|
201
|
+
"recapText": "Diese Seite wiederholt etwas, das du schon können solltest. Üben kannst du es in anderen Apps: {link}",
|
|
202
|
+
"recapLink": "Baustein auf der Lernlandkarte von lernapps.net",
|
|
203
|
+
"recapNote": "Wiederholung"
|
|
200
204
|
}
|
|
@@ -196,5 +196,9 @@
|
|
|
196
196
|
"openTaskLocked": "The solution opens after your answer.",
|
|
197
197
|
"resultPartly": "partly right ({n} of {total} points, missing {missed})",
|
|
198
198
|
"resultPhoto": "answer as a photo",
|
|
199
|
-
"resultAnswer": "answer “{input}”"
|
|
199
|
+
"resultAnswer": "answer “{input}”",
|
|
200
|
+
"recap": "Recap",
|
|
201
|
+
"recapText": "This page goes over something you should already know. You can practise it in other apps: {link}",
|
|
202
|
+
"recapLink": "building block on the learning map of lernapps.net",
|
|
203
|
+
"recapNote": "recap"
|
|
200
204
|
}
|
|
@@ -196,7 +196,7 @@ function walk(
|
|
|
196
196
|
}
|
|
197
197
|
case "generated": {
|
|
198
198
|
const data = dataOf(block, site.data);
|
|
199
|
-
const templates = templatesOf(block, [lang]);
|
|
199
|
+
const templates = templatesOf(block, [lang], data);
|
|
200
200
|
const worked = workedTasks(block, data, templates, lang);
|
|
201
201
|
for (const item of worked) {
|
|
202
202
|
for (const html of [item.task, item.hint ?? "", ...item.steps])
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The list of an explainer's pages for the catalog of lernapps.net (lernapps-pages.json): only an app of format 2
|
|
2
|
+
// (`format: 2` in src/app.yaml) has it. The build writes it at the root of the bundle, beside index.html; the catalog
|
|
3
|
+
// reads it instead of hand-written topics, to place the app and its pages on the learning map. It holds the version of
|
|
4
|
+
// the list itself (format 1), the app's strand (<subject>/<strand>, or none), and the content pages in the order of the
|
|
5
|
+
// app, each with its address relative to the app's root as the app links it, its title as a text (a string, or by
|
|
6
|
+
// language) and, unless the strand is none, the competencies it practises; a recap page names instead the competency
|
|
7
|
+
// it repeats (recap: <subject>/<competency>), with any strand.
|
|
8
|
+
import { contentPages } from "./routes.ts";
|
|
9
|
+
import type { Site, Text } from "./content.ts";
|
|
10
|
+
|
|
11
|
+
/** The file's name, at the root of the bundle. */
|
|
12
|
+
export const PAGE_LIST = "lernapps-pages.json";
|
|
13
|
+
|
|
14
|
+
export interface PageList {
|
|
15
|
+
/** The version of this list's own format. */
|
|
16
|
+
format: 1;
|
|
17
|
+
strand: string;
|
|
18
|
+
pages: { path: string; title: Text; competencies?: readonly string[]; recap?: string }[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The list of the pages of `site`; undefined for an app of format 1, which has none. */
|
|
22
|
+
export function pageList(site: Site): PageList | undefined {
|
|
23
|
+
const { format, strand } = site.app;
|
|
24
|
+
if (format !== 2 || strand === undefined) return undefined;
|
|
25
|
+
return {
|
|
26
|
+
format: 1,
|
|
27
|
+
strand,
|
|
28
|
+
pages: contentPages(site).map((page) => ({
|
|
29
|
+
path: `${page.chapter}/${page.id}/`,
|
|
30
|
+
title: page.title,
|
|
31
|
+
...(page.recap !== undefined
|
|
32
|
+
? { recap: page.recap }
|
|
33
|
+
: strand === "none" || page.competencies === undefined
|
|
34
|
+
? {}
|
|
35
|
+
: { competencies: page.competencies }),
|
|
36
|
+
})),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
// depth. The bundled images go to images/, the tutor page of each content page to <chapter>/<page>/tutor/ with its
|
|
13
13
|
// Markdown beside it (index.md; tutorpage.ts), and of each self-assessment to <assessment>/tutor/ (assessment-page.ts),
|
|
14
14
|
// every link of them checked, and 404.html, which leads every address the
|
|
15
|
-
// app does not have to its start page (notFoundPage of ../shared/pages.ts).
|
|
15
|
+
// app does not have to its start page (notFoundPage of ../shared/pages.ts). An app of format 2 also gets
|
|
16
|
+
// lernapps-pages.json at the root, the list of its pages with their competencies for the catalog (page-list.ts). `vp dev` serves the same pages and tutor pages, rendered on each request.
|
|
16
17
|
//
|
|
17
18
|
// Components (src/components/<Name>.vue, components.ts): only when the app has them, the plugin compiles them with
|
|
18
19
|
// @vitejs/plugin-vue and resolves vue to the copy this runtime depends on, so that the components and the islands
|
|
@@ -53,6 +54,7 @@ import {
|
|
|
53
54
|
tutorPageDocument,
|
|
54
55
|
} from "./tutorpage.ts";
|
|
55
56
|
import { imageType } from "../shared/blocks/image.ts";
|
|
57
|
+
import { PAGE_LIST, pageList } from "./page-list.ts";
|
|
56
58
|
|
|
57
59
|
const MARKER = "<!-- explainer -->";
|
|
58
60
|
const ISLANDS = "\0lernapps-explainer-islands";
|
|
@@ -263,7 +265,9 @@ export function explainer(): Plugin[] {
|
|
|
263
265
|
}
|
|
264
266
|
if (command !== "build" || content.components.length === 0) return;
|
|
265
267
|
const rendered = await withServer(root, (loader) => renderComponents(content, loader, true));
|
|
266
|
-
if (rendered.problems.length > 0)
|
|
268
|
+
if (rendered.problems.length > 0) {
|
|
269
|
+
this.error(new ContentError(rendered.problems, content.app.format ?? 1).message);
|
|
270
|
+
}
|
|
267
271
|
islands = rendered.islands;
|
|
268
272
|
descriptions = rendered.descriptions;
|
|
269
273
|
islandsChunk = this.emitFile({ type: "chunk", id: ISLANDS, name: "islands" });
|
|
@@ -289,6 +293,11 @@ export function explainer(): Plugin[] {
|
|
|
289
293
|
else this.emitFile({ type: "asset", fileName: `${page.route.path}index.html`, source: html });
|
|
290
294
|
}
|
|
291
295
|
this.emitFile({ type: "asset", fileName: "404.html", source: notFoundPage() });
|
|
296
|
+
// format 2: the list of the pages for the catalog
|
|
297
|
+
const list = pageList(content);
|
|
298
|
+
if (list !== undefined) {
|
|
299
|
+
this.emitFile({ type: "asset", fileName: PAGE_LIST, source: `${JSON.stringify(list, null, 2)}\n` });
|
|
300
|
+
}
|
|
292
301
|
for (const [image, file] of content.images) {
|
|
293
302
|
this.emitFile({ type: "asset", fileName: image, source: readFileSync(file) });
|
|
294
303
|
}
|
|
@@ -404,7 +413,7 @@ export function explainer(): Plugin[] {
|
|
|
404
413
|
);
|
|
405
414
|
}
|
|
406
415
|
}
|
|
407
|
-
if (problems.length > 0) this.error(new ContentError(problems).message);
|
|
416
|
+
if (problems.length > 0) this.error(new ContentError(problems, content.app.format ?? 1).message);
|
|
408
417
|
},
|
|
409
418
|
configureServer(server) {
|
|
410
419
|
devServer = server;
|
|
@@ -442,7 +451,7 @@ export function explainer(): Plugin[] {
|
|
|
442
451
|
if (dev === undefined) return next();
|
|
443
452
|
renderComponents(content, (id) => dev.ssrLoadModule(id), false)
|
|
444
453
|
.then(({ islands: rendered, descriptions: described, problems }) => {
|
|
445
|
-
if (problems.length > 0) throw new ContentError(problems);
|
|
454
|
+
if (problems.length > 0) throw new ContentError(problems, content.app.format ?? 1);
|
|
446
455
|
if (forScope) {
|
|
447
456
|
const markdown = path.endsWith(TUTOR_MARKDOWN);
|
|
448
457
|
const lang =
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// as inline SVG or an image, tables, timelines, definitions, quotes, highlighted code, the exercises (exercises.ts:
|
|
3
3
|
// each with its solution in a <details>, a generated one with three worked tasks), and the navigation (contents,
|
|
4
4
|
// breadcrumbs, previous and next, the contents of a page with three or more sections, the glossary, the language
|
|
5
|
-
// switch
|
|
5
|
+
// switch; a recap page marked in the contents, and at its top its notice with the link to the learning map). Without
|
|
6
|
+
// JavaScript the page is complete; the client module only adds. A vue-component block is its island:
|
|
6
7
|
// the component's markup as the build rendered it (`hidden` until it hydrates), its nojs text, its props in the page's
|
|
7
8
|
// language, and for an exercise its anchor, its task number with "Neue Aufgabe", the task of number 1 as its describe
|
|
8
9
|
// states it with the solution folded, and the place of its feedback. Links to other sites are plain links with rel="noopener":
|
|
@@ -13,6 +14,7 @@ import { questionMessagesIn } from "../shared/questions/render.ts";
|
|
|
13
14
|
import { propsIn } from "./components/props.ts";
|
|
14
15
|
import {
|
|
15
16
|
inLanguage,
|
|
17
|
+
mapUrl,
|
|
16
18
|
termId as termIdOf,
|
|
17
19
|
timelineEvents,
|
|
18
20
|
type Block,
|
|
@@ -322,7 +324,10 @@ function pageList(context: Context, pages: readonly NonNullable<Route["page"]>[]
|
|
|
322
324
|
page.description === undefined
|
|
323
325
|
? ""
|
|
324
326
|
: ` <span class="explainer-description">${context.text(page.description)}</span>`;
|
|
325
|
-
|
|
327
|
+
// a recap page repeats a competency the app does not practise: marked, so that no one takes it for the app's own
|
|
328
|
+
const recap =
|
|
329
|
+
page.recap === undefined ? "" : ` <span class="explainer-recap-note">(${plain(context.t("recapNote"))})</span>`;
|
|
330
|
+
return `<li><a href="${context.to(`${page.chapter}/${page.id}/`)}">${context.text(page.title)}</a>${recap}${description}</li>`;
|
|
326
331
|
})
|
|
327
332
|
.join("")}</ol>`;
|
|
328
333
|
}
|
|
@@ -407,6 +412,7 @@ function main(context: Context): string {
|
|
|
407
412
|
title,
|
|
408
413
|
tutorSection(context),
|
|
409
414
|
`</div>`,
|
|
415
|
+
page.recap === undefined ? "" : recapNotice(context, page.recap),
|
|
410
416
|
pageContents(context, page.blocks),
|
|
411
417
|
renderBlocks(context, page.blocks, 1),
|
|
412
418
|
].join("\n");
|
|
@@ -414,6 +420,24 @@ function main(context: Context): string {
|
|
|
414
420
|
}
|
|
415
421
|
}
|
|
416
422
|
|
|
423
|
+
/**
|
|
424
|
+
* At the top of a recap page: that it is a recap, and the link to the competency's page on the learning map, where the
|
|
425
|
+
* apps are that practise it. The runtime writes it, so that it cannot be missing; a plain link, nothing loaded before
|
|
426
|
+
* a click.
|
|
427
|
+
*/
|
|
428
|
+
function recapNotice(context: Context, recap: string): string {
|
|
429
|
+
const link = external(mapUrl(recap), context.t("recapLink"));
|
|
430
|
+
// the message keeps its placeholder {link} unfilled: the link goes in its place as markup
|
|
431
|
+
const [before = "", after = ""] = context.t("recapText").split("{link}");
|
|
432
|
+
const label = plain(context.t("recap"));
|
|
433
|
+
return [
|
|
434
|
+
`<aside class="explainer-recap" aria-label="${label}">`,
|
|
435
|
+
`<p class="explainer-recap-label">${label}</p>`,
|
|
436
|
+
`<p>${plain(before)}${link}${plain(after)}</p>`,
|
|
437
|
+
`</aside>`,
|
|
438
|
+
].join("\n");
|
|
439
|
+
}
|
|
440
|
+
|
|
417
441
|
/** What a part of a self-assessment needs of the page it is on (assessment-page.ts). */
|
|
418
442
|
const assessmentContext = (context: Context): AssessmentContext => ({
|
|
419
443
|
site: context.site,
|
|
@@ -1,25 +1,35 @@
|
|
|
1
|
-
// The explainer's content
|
|
2
|
-
//
|
|
1
|
+
// The explainer's content schemas at build time: format 1 (explainer.v1.schema.json) and format 2
|
|
2
|
+
// (explainer.v2.schema.json, which adds the strand of the app, the competencies of each page and recap pages, and refers to format 1
|
|
3
|
+
// for everything else); the validators of their files (the app, a chapter, a page, a data file) by format, and the explainer's own words for the errors of the schema (what a quote or a bundled
|
|
3
4
|
// image lacks, which fields a block type has, which an exercise of a question type); the general messages are
|
|
4
5
|
// shared/content.ts's.
|
|
5
6
|
import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js";
|
|
6
7
|
import schema from "./explainer.v1.schema.json" with { type: "json" };
|
|
8
|
+
import schemaV2 from "./explainer.v2.schema.json" with { type: "json" };
|
|
7
9
|
import questionSchema from "../shared/questions/question.v1.schema.json" with { type: "json" };
|
|
8
10
|
import { knownProperties, schemasById } from "../shared/schema.ts";
|
|
9
11
|
import { addressProblem, QUESTION_BRANCH, questionSchemas } from "../shared/questions/validate.ts";
|
|
10
12
|
import { isObject, schemaProblems, type DescribeError, type Problems, type YamlFile } from "../shared/content.ts";
|
|
11
13
|
|
|
12
14
|
export const SCHEMA_ID = schema.$id;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
export const SCHEMA_ID_V2 = schemaV2.$id;
|
|
16
|
+
/** The formats of the content: 1 without `format` in src/app.yaml, 2 with `format: 2`. */
|
|
17
|
+
export type Format = 1 | 2;
|
|
18
|
+
/** Where an app finds the schema of its content, by format, for the message that names it. */
|
|
19
|
+
export const SCHEMA_FILES: Readonly<Record<Format, string>> = {
|
|
20
|
+
1: "node_modules/@lernapps/app-templates/runtime/explainer/explainer.v1.schema.json",
|
|
21
|
+
2: "node_modules/@lernapps/app-templates/runtime/explainer/explainer.v2.schema.json",
|
|
22
|
+
};
|
|
23
|
+
export const SCHEMA_FILE = SCHEMA_FILES[1];
|
|
15
24
|
|
|
16
25
|
// union types ("type": ["string", "object"]) keep the messages of the comparator and the lists plain
|
|
17
26
|
const ajv = new Ajv2020({ allErrors: true, strict: true, allowUnionTypes: true });
|
|
18
27
|
for (const each of questionSchemas) ajv.addSchema(each);
|
|
19
28
|
ajv.addSchema(schema);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
29
|
+
ajv.addSchema(schemaV2);
|
|
30
|
+
const validator = (name: string, id = SCHEMA_ID): ValidateFunction => {
|
|
31
|
+
const validate = ajv.getSchema(`${id}#/$defs/${name}`);
|
|
32
|
+
if (validate === undefined) throw new Error(`the schema ${id} has no $defs/${name}`);
|
|
23
33
|
return validate;
|
|
24
34
|
};
|
|
25
35
|
export const validators = {
|
|
@@ -29,6 +39,20 @@ export const validators = {
|
|
|
29
39
|
data: validator("data"),
|
|
30
40
|
event: validator("event"),
|
|
31
41
|
};
|
|
42
|
+
/** The validators of format 2: its own app and page; chapters, data files and events are those of format 1. */
|
|
43
|
+
const validatorsV2: typeof validators = {
|
|
44
|
+
...validators,
|
|
45
|
+
app: validator("app", SCHEMA_ID_V2),
|
|
46
|
+
page: validator("page", SCHEMA_ID_V2),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The format of the app `app` (src/app.yaml as read): 2 when it names any format, which its schema then checks. */
|
|
50
|
+
export const formatOf = (app: unknown): Format => (isObject(app) && app["format"] !== undefined ? 2 : 1);
|
|
51
|
+
|
|
52
|
+
/** The fields of format 2, which format 1 does not know. */
|
|
53
|
+
const FORMAT_2_FIELDS = new Set(["format", "strand", "competencies", "recap"]);
|
|
54
|
+
/** The fields of format 2 on a page. */
|
|
55
|
+
const PAGE_FIELDS = new Set(["competencies", "recap"]);
|
|
32
56
|
|
|
33
57
|
const schemas = schemasById(...questionSchemas, schema);
|
|
34
58
|
|
|
@@ -61,6 +85,9 @@ const questionFields = (type: string): Set<string> => {
|
|
|
61
85
|
/** What `required` names as missing, with why it is needed. */
|
|
62
86
|
function missing(property: string, object: unknown): string {
|
|
63
87
|
const type = isObject(object) ? object["type"] : undefined;
|
|
88
|
+
if (property === "strand") {
|
|
89
|
+
return "strand is missing; name the strand of the learning map the app stays in, <subject>/<strand> (e.g. mathematik/stochastik), or none when the app is on no strand";
|
|
90
|
+
}
|
|
64
91
|
if (property === "origin") return "origin is missing; a quote names who said or wrote it, where and when";
|
|
65
92
|
if ((property === "licence" || property === "source") && type === "quote") {
|
|
66
93
|
return `${property} is missing; a quote names its source and its licence`;
|
|
@@ -80,7 +107,58 @@ const describe: DescribeError = (error, path, value) => {
|
|
|
80
107
|
const { lang, message } = addressProblem(value);
|
|
81
108
|
return { message, ...(lang === undefined ? {} : { lang }) };
|
|
82
109
|
}
|
|
110
|
+
case "not":
|
|
111
|
+
// a page with both: it practises competencies of the app's strand, or it repeats one the app does not practise
|
|
112
|
+
if (path.length !== 0 || !isObject(value) || !("recap" in value) || !("competencies" in value)) return undefined;
|
|
113
|
+
return {
|
|
114
|
+
path: ["recap"],
|
|
115
|
+
message:
|
|
116
|
+
"a page has competencies or recap, never both: a page practises competencies of the app's strand, a recap page only repeats a competency the app does not practise; remove one of them",
|
|
117
|
+
};
|
|
118
|
+
case "const":
|
|
119
|
+
// the format of the content: 2, or none at all for format 1
|
|
120
|
+
if (path.length !== 1 || path[0] !== "format") return undefined;
|
|
121
|
+
return { message: `${String(value)} is not a format; write format: 2, or leave format out for format 1` };
|
|
122
|
+
case "uniqueItems":
|
|
123
|
+
if (path.at(-1) !== "competencies") return undefined;
|
|
124
|
+
return { message: "names a competency twice; name each competency once" };
|
|
125
|
+
case "additionalProperties": {
|
|
126
|
+
// a field of format 2 in an app of format 1
|
|
127
|
+
const property = String(error.params["additionalProperty"]);
|
|
128
|
+
if (path.length !== 0 || !FORMAT_2_FIELDS.has(property) || !isObject(value)) return undefined;
|
|
129
|
+
if (PAGE_FIELDS.has(property) && "blocks" in value) {
|
|
130
|
+
return {
|
|
131
|
+
path: [property],
|
|
132
|
+
message: `${property} is a field of format 2; add format: 2 and the app's strand to src/app.yaml, or remove it`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (!PAGE_FIELDS.has(property) && "chapters" in value) {
|
|
136
|
+
return {
|
|
137
|
+
path: [property],
|
|
138
|
+
message: `${property} is a field of format 2; add format: 2 to src/app.yaml, with the app's strand`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
83
143
|
case "pattern":
|
|
144
|
+
// the strand of the app: <subject>/<strand> or none
|
|
145
|
+
if (path.length === 1 && path[0] === "strand") {
|
|
146
|
+
return {
|
|
147
|
+
message: `${String(value)} is no strand; write <subject>/<strand> with the ids of the learning map, e.g. mathematik/stochastik, or none`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
// the competency a recap page repeats: <subject>/<competency>
|
|
151
|
+
if (path.length === 1 && path[0] === "recap") {
|
|
152
|
+
return {
|
|
153
|
+
message: `${String(value)} is no competency of the learning map; write <subject>/<competency> with the ids of the learning map, e.g. physik/gewichtskraft-berechnen`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// a competency of a page: an id of the learning map
|
|
157
|
+
if (path.at(-2) === "competencies") {
|
|
158
|
+
return {
|
|
159
|
+
message: `${String(value)} is no competency id; use the competency's id in the learning map: lower-case letters, digits and hyphens, e.g. laplace`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
84
162
|
// an exercise of a self-assessment: by its page and id
|
|
85
163
|
if (path.at(-2) === "exercises" && path[0] === "selfAssessment") {
|
|
86
164
|
return { message: `${String(value)} is no exercise's address; write /<chapter>/<page>/#<exercise>` };
|
|
@@ -129,8 +207,13 @@ const describe: DescribeError = (error, path, value) => {
|
|
|
129
207
|
return undefined;
|
|
130
208
|
};
|
|
131
209
|
|
|
132
|
-
export function validate(
|
|
133
|
-
|
|
210
|
+
export function validate(
|
|
211
|
+
yaml: YamlFile,
|
|
212
|
+
kind: keyof typeof validators,
|
|
213
|
+
problems: Problems,
|
|
214
|
+
format: Format = 1,
|
|
215
|
+
): boolean {
|
|
216
|
+
const check = (format === 2 ? validatorsV2 : validators)[kind];
|
|
134
217
|
if (check(yaml.value)) return true;
|
|
135
218
|
const before = problems.lines.length;
|
|
136
219
|
schemaProblems(yaml, check.errors ?? [], problems, describe);
|
|
@@ -27,6 +27,7 @@ import { escape } from "../shared/html.ts";
|
|
|
27
27
|
import { answerText } from "../shared/questions/render.ts";
|
|
28
28
|
import {
|
|
29
29
|
inLanguage,
|
|
30
|
+
mapUrl,
|
|
30
31
|
type Block,
|
|
31
32
|
type ComponentExercise,
|
|
32
33
|
type Descriptions,
|
|
@@ -72,6 +73,8 @@ interface Words {
|
|
|
72
73
|
needsIntro: string;
|
|
73
74
|
needs: Readonly<Record<Need, string>>;
|
|
74
75
|
contextTitle: string;
|
|
76
|
+
/** On a recap page: that it has no exercise, and where the competency is practised ({url}). */
|
|
77
|
+
recap: string;
|
|
75
78
|
exercise: string;
|
|
76
79
|
steps: string;
|
|
77
80
|
link: string;
|
|
@@ -139,6 +142,8 @@ const WORDS: Readonly<Record<string, Words>> = {
|
|
|
139
142
|
"Zeig Zahlen der Seite als Menge, z. B. „37 sind 3 Zehner und 7 Einer“, aber nie das Ergebnis einer Aufgabe. Ein Schritt pro Nachricht, nie Zeitdruck.",
|
|
140
143
|
},
|
|
141
144
|
contextTitle: "Kontext: die Seite „{page}“",
|
|
145
|
+
recap:
|
|
146
|
+
"Diese Seite ist eine Wiederholung: Sie wiederholt etwas, das das Kind schon können sollte, und hat keine Übung. Erklär mit dem Inhalt der Seite und frag nach, ob das Kind ihn verstanden hat. Üben kann es das in anderen Apps; welche das sind, steht hier: {url}",
|
|
142
147
|
exercise: "Übung",
|
|
143
148
|
steps: "Lösungsweg",
|
|
144
149
|
link: "Link",
|
|
@@ -205,6 +210,8 @@ const WORDS: Readonly<Record<string, Words>> = {
|
|
|
205
210
|
"Show numbers of the page as amounts, e.g. “37 is 3 tens and 7 ones”, but never the result of a task. One step per message, never time pressure.",
|
|
206
211
|
},
|
|
207
212
|
contextTitle: "Context: the page “{page}”",
|
|
213
|
+
recap:
|
|
214
|
+
"This page is a recap: it goes over something the child should already know, and has no exercise. Explain with the content of the page and ask whether the child has understood it. It can practise it in other apps; which ones, it says here: {url}",
|
|
208
215
|
exercise: "Exercise",
|
|
209
216
|
steps: "Steps of the solution",
|
|
210
217
|
link: "Link",
|
|
@@ -341,7 +348,7 @@ export function generatedLines(spec: Generated, site: Site, lang: string, format
|
|
|
341
348
|
lines.push(`- ${words.linkNumber}: \`${page}?${spec.id}=n&via=tutor#${spec.id}\``);
|
|
342
349
|
const { paths, answer } = placeholdersOf(spec, lang);
|
|
343
350
|
const data = dataOf(spec, site.data);
|
|
344
|
-
const templates = templatesOf(spec, [lang]);
|
|
351
|
+
const templates = templatesOf(spec, [lang], data);
|
|
345
352
|
let choices = false;
|
|
346
353
|
const rows = taskSeeds(spec, data).map((seed, index) => {
|
|
347
354
|
const task = generate(spec, seed, data);
|
|
@@ -569,6 +576,7 @@ function pageMarkdown(
|
|
|
569
576
|
`## ${fill(words.contextTitle)}`,
|
|
570
577
|
"",
|
|
571
578
|
);
|
|
579
|
+
if (page.recap !== undefined) lines.push(words.recap.replace("{url}", mapUrl(page.recap)), "");
|
|
572
580
|
if (page.description !== undefined) lines.push(own(page.description), "");
|
|
573
581
|
lines.push(
|
|
574
582
|
...contentLines(
|
|
@@ -30,6 +30,24 @@
|
|
|
30
30
|
height: auto;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/* The drawings take the page's colours, so they follow the dark mode too (the attributes in figures.ts are the fallback
|
|
34
|
+
for an SVG outside a page) */
|
|
35
|
+
.block-drawing .fig-on {
|
|
36
|
+
fill: var(--page-accent);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.block-drawing .fig-off {
|
|
40
|
+
fill: var(--page-background);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.block-drawing .fig-line {
|
|
44
|
+
stroke: var(--page-muted);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.block-drawing .fig-text {
|
|
48
|
+
fill: var(--page-text);
|
|
49
|
+
}
|
|
50
|
+
|
|
33
51
|
.block-hundred-grid {
|
|
34
52
|
width: 16rem;
|
|
35
53
|
}
|
|
@@ -328,7 +328,7 @@
|
|
|
328
328
|
"type": "object"
|
|
329
329
|
},
|
|
330
330
|
"latex": {
|
|
331
|
-
"description": "One formula in the LaTeX subset of the formula command list: \\frac, \\cdot, \\times, \\div, \\%, \\text, ^, _, \\sqrt, \\approx, \\pm, \\le, \\ge, \\neq, \\, (thin space), parentheses and braces; \\ce{…} for chemical equations and \\pu{…} for units (mhchem). Written without the $ signs. A map by language only when it has \\text{…} that differs by language.",
|
|
331
|
+
"description": "One formula in the LaTeX subset of the formula command list: \\frac, \\cdot, \\times, \\div, \\%, \\text, ^, _, \\sqrt, \\approx, \\pm, \\le, \\ge, \\neq, \\, (thin space), \\ (space), \\quad, \\qquad, \\Rightarrow, \\Leftrightarrow, \\left( … \\right), parentheses and braces; \\sin, \\cos, \\tan, \\arcsin, \\arccos, \\arctan (or \\sin^{-1}), \\circ for degrees (30^\\circ); the Greek letters \\alpha to \\omega (also \\varepsilon, \\vartheta, \\varphi) and the capitals \\Gamma, \\Delta, \\Theta, \\Lambda, \\Xi, \\Pi, \\Sigma, \\Phi, \\Psi, \\Omega; \\ce{…} for chemical equations and \\pu{…} for units (mhchem). The build names any other command and lists these. Written without the $ signs. A map by language only when it has \\text{…} that differs by language.",
|
|
332
332
|
"$ref": "urn:lernapps:schema:text.v1#/$defs/text"
|
|
333
333
|
},
|
|
334
334
|
"table": {
|