@lernapps/app-templates 0.0.1 → 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 +142 -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,488 @@
|
|
|
1
|
+
// The build step of an explainer (@lernapps/app-templates/explainer/plugin), a Vite plugin. An app's vite.config.ts
|
|
2
|
+
// adds it to the shared preset of @lernapps/tooling:
|
|
3
|
+
//
|
|
4
|
+
// import { lernapps } from "@lernapps/tooling/preset";
|
|
5
|
+
// import { explainer } from "@lernapps/app-templates/explainer/plugin";
|
|
6
|
+
// export default lernapps({ plugins: [explainer()] });
|
|
7
|
+
//
|
|
8
|
+
// It reads the content in src/ (load.ts) and fails the build with one line per problem. index.html is Vite's only
|
|
9
|
+
// entry: Vite builds the one hashed client module (src/main.ts) and stylesheet from it. The built index.html is then
|
|
10
|
+
// the shell of every page: each page is the shell with its language, its head and its body where it says
|
|
11
|
+
// <!-- explainer -->, written into its folder, with the shell's relative asset URLs (./assets/…) rewritten for its
|
|
12
|
+
// depth. The bundled images go to images/, the tutor page of each content page to <chapter>/<page>/tutor/ with its
|
|
13
|
+
// Markdown beside it (index.md; tutorpage.ts), and of each self-assessment to <assessment>/tutor/ (assessment-page.ts),
|
|
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). `vp dev` serves the same pages and tutor pages, rendered on each request.
|
|
16
|
+
//
|
|
17
|
+
// Components (src/components/<Name>.vue, components.ts): only when the app has them, the plugin compiles them with
|
|
18
|
+
// @vitejs/plugin-vue and resolves vue to the copy this runtime depends on, so that the components and the islands
|
|
19
|
+
// module share one Vue. Before the pages are written, it runs the property test of each exercise component's logic
|
|
20
|
+
// module for every block that uses it, with the block's examples, reads what the logic describes (about, and describe
|
|
21
|
+
// for each task number in each language, for the no-JS text and the tutor), and renders each block's component with
|
|
22
|
+
// Vue's server renderer in each language, all in a Vite server of their own; a problem fails the build with its line. The islands module
|
|
23
|
+
// (islands.ts) is a chunk of its own with Vue, and only the pages with a component load it; each component is a chunk
|
|
24
|
+
// that its pages load when they hydrate it, and its own code, gzipped, must stay within the budget.
|
|
25
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
26
|
+
import { dirname, join, resolve } from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { gzipSync } from "node:zlib";
|
|
29
|
+
import type { Component } from "vue";
|
|
30
|
+
import type { Plugin, ViteDevServer } from "vite-plus";
|
|
31
|
+
import { describedProblems, exampleProblems, propertyProblems, type ExerciseLogic } from "./components/exercise.ts";
|
|
32
|
+
import { propsIn } from "./components/props.ts";
|
|
33
|
+
import { SIZE_BUDGET } from "./components/read.ts";
|
|
34
|
+
import { inLanguage, type Block, type ComponentDescription, type Descriptions, type Site } from "./content.ts";
|
|
35
|
+
import { ContentError, loadSite } from "./load.ts";
|
|
36
|
+
import { dataOf, taskSeeds } from "./exercises.ts";
|
|
37
|
+
import { MAX_TASKS } from "./tasks.ts";
|
|
38
|
+
import { renderSite, type Islands, type RenderedPage } from "./render.ts";
|
|
39
|
+
import { fromDepth, languageRoot, notFoundPage, withLanguage } from "../shared/pages.ts";
|
|
40
|
+
import { type Load, VUE_OPTIONS, vueAlias, withServer } from "../shared/vue.ts";
|
|
41
|
+
import { allBlocks, contentPages, routes } from "./routes.ts";
|
|
42
|
+
import { anchorsOf, linkProblems, tutorPageOf } from "./tutor.ts";
|
|
43
|
+
import { assessments, stepHint } from "./assessment.ts";
|
|
44
|
+
import { SCOPE_PAGE, scopeDocument, scopeFiles } from "./scope.ts";
|
|
45
|
+
import { assessmentTutorDocument, assessmentTutorMarkdown, assessmentTutorOf } from "./assessment-page.ts";
|
|
46
|
+
import {
|
|
47
|
+
bytesOf,
|
|
48
|
+
type Cut,
|
|
49
|
+
tableHint,
|
|
50
|
+
TUTOR_MARKDOWN,
|
|
51
|
+
TUTOR_PAGE_BUDGET,
|
|
52
|
+
tutorPage,
|
|
53
|
+
tutorPageDocument,
|
|
54
|
+
} from "./tutorpage.ts";
|
|
55
|
+
import { imageType } from "../shared/blocks/image.ts";
|
|
56
|
+
|
|
57
|
+
const MARKER = "<!-- explainer -->";
|
|
58
|
+
const ISLANDS = "\0lernapps-explainer-islands";
|
|
59
|
+
|
|
60
|
+
/** The shell with the page in it, and `head` (the islands module) at the end of its head. */
|
|
61
|
+
export function fill(shell: string, page: RenderedPage, head = ""): string {
|
|
62
|
+
if (!shell.includes(MARKER)) throw new Error(`index.html has no ${MARKER}: put it in <body>, where the pages go`);
|
|
63
|
+
const html = withLanguage(shell, page.route.lang)
|
|
64
|
+
.replace(/\s*<title>[\s\S]*?<\/title>/, "")
|
|
65
|
+
.replace("</head>", ` ${page.head}${head === "" ? "" : `\n ${head}`}\n </head>`);
|
|
66
|
+
return fromDepth(html, page.route.path).replace(MARKER, () => page.body);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The islands module, next to this file: islands.ts in the source, explainer-islands.mjs in dist/. */
|
|
70
|
+
const here = fileURLToPath(import.meta.url);
|
|
71
|
+
const ISLANDS_RUNTIME = here.endsWith(".ts")
|
|
72
|
+
? join(dirname(here), "islands.ts")
|
|
73
|
+
: join(dirname(here), "explainer-islands.mjs");
|
|
74
|
+
|
|
75
|
+
/** Whether the app in `root` has components. */
|
|
76
|
+
const hasComponents = (root: string): boolean => {
|
|
77
|
+
const dir = join(root, "src/components");
|
|
78
|
+
return existsSync(dir) && readdirSync(dir).some((file) => file.endsWith(".vue"));
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
type Hook = (this: unknown, ...args: unknown[]) => unknown;
|
|
82
|
+
/** @vitejs/plugin-vue, loaded only when the app has components: an app without them loads nothing of Vue. */
|
|
83
|
+
function vueWhenComponents(): Plugin {
|
|
84
|
+
let inner: Record<string, unknown> | undefined;
|
|
85
|
+
const delegate = (name: string): Hook =>
|
|
86
|
+
function (this: unknown, ...args: unknown[]): unknown {
|
|
87
|
+
const hook = inner?.[name] as Hook | { handler: Hook } | undefined;
|
|
88
|
+
if (hook === undefined) return undefined;
|
|
89
|
+
return (typeof hook === "function" ? hook : hook.handler).apply(this, args);
|
|
90
|
+
};
|
|
91
|
+
const hooks = [
|
|
92
|
+
"configResolved",
|
|
93
|
+
"options",
|
|
94
|
+
"buildStart",
|
|
95
|
+
"configureServer",
|
|
96
|
+
"handleHotUpdate",
|
|
97
|
+
"shouldTransformCachedModule",
|
|
98
|
+
"resolveId",
|
|
99
|
+
"load",
|
|
100
|
+
"transform",
|
|
101
|
+
];
|
|
102
|
+
return {
|
|
103
|
+
name: "lernapps-explainer-vue",
|
|
104
|
+
...Object.fromEntries(hooks.map((name) => [name, delegate(name)])),
|
|
105
|
+
async config(config, env) {
|
|
106
|
+
if (!hasComponents(resolve(config.root ?? process.cwd()))) return undefined;
|
|
107
|
+
const { default: vue } = await import("@vitejs/plugin-vue");
|
|
108
|
+
inner = vue(VUE_OPTIONS) as unknown as Record<string, unknown>;
|
|
109
|
+
const own = ((await delegate("config").call(this, config, env)) ?? {}) as { resolve?: object };
|
|
110
|
+
return { ...own, resolve: { ...own.resolve, alias: vueAlias() } };
|
|
111
|
+
},
|
|
112
|
+
} as Plugin;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const reason = (error: unknown) => (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The property test of each exercise component's logic with each block's props (`test`), what its logic describes
|
|
119
|
+
* (about, and describe for each task number in each language), and the markup of each block's component in each
|
|
120
|
+
* language, rendered by Vue's server renderer, an exercise component with the task number 1; or the problems, one
|
|
121
|
+
* line each.
|
|
122
|
+
*/
|
|
123
|
+
async function renderComponents(
|
|
124
|
+
site: Site,
|
|
125
|
+
load: Load,
|
|
126
|
+
test: boolean,
|
|
127
|
+
): Promise<{ islands: Islands; descriptions: Descriptions; problems: string[] }> {
|
|
128
|
+
const problems: string[] = [];
|
|
129
|
+
const islands = new Map<Block, Map<string, string>>();
|
|
130
|
+
const descriptions = new Map<Block, ComponentDescription>();
|
|
131
|
+
if (site.components.length === 0) return { islands, descriptions, problems };
|
|
132
|
+
const { createSSRApp } = (await load("vue")) as unknown as typeof import("vue");
|
|
133
|
+
const { renderToString } = (await load("vue/server-renderer")) as unknown as typeof import("vue/server-renderer");
|
|
134
|
+
for (const { block, component, place, examples } of site.components) {
|
|
135
|
+
if (component.logic !== undefined) {
|
|
136
|
+
const logic = (await load(`/${component.file.replace(/\.vue$/, ".ts")}`)) as unknown as ExerciseLogic;
|
|
137
|
+
const props = propsIn(block.props, component, "de");
|
|
138
|
+
const contract = test ? propertyProblems(logic, props) : [];
|
|
139
|
+
for (const problem of contract) {
|
|
140
|
+
problems.push(`${place}: ${component.name}.ts: ${problem} (vue-component-exercise-contract)`);
|
|
141
|
+
}
|
|
142
|
+
if (test && contract.length === 0) {
|
|
143
|
+
for (const { kind, index, message } of exampleProblems(logic, props, block.examples)) {
|
|
144
|
+
problems.push(
|
|
145
|
+
`${examples[kind][index] ?? place}: ${component.name}.ts: ${message} (vue-component-exercise-contract)`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (contract.length === 0) {
|
|
150
|
+
const described = describedProblems(logic, (lang) => propsIn(block.props, component, lang), site.app.languages);
|
|
151
|
+
for (const problem of described.problems) {
|
|
152
|
+
problems.push(`${place}: ${component.name}.ts: ${problem} (vue-component-exercise-contract)`);
|
|
153
|
+
}
|
|
154
|
+
if (described.description !== undefined) descriptions.set(block, described.description);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const definition = (await load(`/${component.file}`))["default"] as Component;
|
|
158
|
+
const markup = new Map<string, string>();
|
|
159
|
+
for (const lang of site.app.languages) {
|
|
160
|
+
const app = createSSRApp(definition, {
|
|
161
|
+
...propsIn(block.props, component, lang),
|
|
162
|
+
// the task of number 1, as the islands hydrate it
|
|
163
|
+
...(component.exercise ? { seed: descriptions.get(block)?.seeds[0] ?? 1 } : {}),
|
|
164
|
+
...(component.checked ? { checked: false } : {}),
|
|
165
|
+
});
|
|
166
|
+
const warnings = new Set<string>();
|
|
167
|
+
app.config.warnHandler = (message) => warnings.add(message);
|
|
168
|
+
try {
|
|
169
|
+
markup.set(lang, await renderToString(app));
|
|
170
|
+
} catch (error) {
|
|
171
|
+
problems.push(`${place} (${lang}): ${component.name} throws while the build renders it: ${reason(error)}`);
|
|
172
|
+
}
|
|
173
|
+
for (const warning of warnings) {
|
|
174
|
+
problems.push(`${place} (${lang}): Vue warns while the build renders ${component.name}: ${warning}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
islands.set(block, markup);
|
|
178
|
+
}
|
|
179
|
+
return { islands, descriptions, problems };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The islands module of the app: the islands runtime, with each component the pages use and the logic module of each
|
|
184
|
+
* exercise component, which checks its answers in the browser, loaded on first use.
|
|
185
|
+
*/
|
|
186
|
+
function islandsModule(site: Site): string {
|
|
187
|
+
const used = new Map(site.components.map((use) => [use.component.name, use.component]));
|
|
188
|
+
const loader = (name: string, file: string) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(file)}),`;
|
|
189
|
+
return [
|
|
190
|
+
`import { startIslands } from ${JSON.stringify(ISLANDS_RUNTIME)};`,
|
|
191
|
+
`startIslands({`,
|
|
192
|
+
...[...used.keys()].map((name) => loader(name, `/src/components/${name}.vue`)),
|
|
193
|
+
`}, {`,
|
|
194
|
+
...[...used.values()]
|
|
195
|
+
.filter((component) => component.exercise)
|
|
196
|
+
.map((component) => loader(component.name, `/src/components/${component.name}.ts`)),
|
|
197
|
+
`});`,
|
|
198
|
+
"",
|
|
199
|
+
].join("\n");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A tutor page as the build writes it: its Markdown, its HTML, and its exercises with numbers left out. */
|
|
203
|
+
interface TutorAsset {
|
|
204
|
+
markdown: string;
|
|
205
|
+
html: string;
|
|
206
|
+
cut: Cut[];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* What the build writes for a tutor beside the pages: the tutor page of each content page and of each self-assessment,
|
|
211
|
+
* by its folder from the app's root (with the language root), its HTML as index.html and its Markdown beside it.
|
|
212
|
+
*/
|
|
213
|
+
function tutorAssets(site: Site, descriptions: Descriptions): Map<string, TutorAsset> {
|
|
214
|
+
const pages = new Map<string, TutorAsset>();
|
|
215
|
+
for (const lang of site.app.languages) {
|
|
216
|
+
for (const page of contentPages(site)) {
|
|
217
|
+
const { markdown, cut } = tutorPage(site, page, lang, descriptions);
|
|
218
|
+
pages.set(`${languageRoot(lang)}${tutorPageOf(page)}`, {
|
|
219
|
+
markdown,
|
|
220
|
+
html: tutorPageDocument(markdown, site, page, lang),
|
|
221
|
+
cut,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
for (const assessment of assessments(site)) {
|
|
225
|
+
const markdown = assessmentTutorMarkdown(site, assessment, lang);
|
|
226
|
+
pages.set(`${languageRoot(lang)}${assessmentTutorOf(assessment)}`, {
|
|
227
|
+
markdown,
|
|
228
|
+
html: assessmentTutorDocument(markdown, site, lang),
|
|
229
|
+
cut: [],
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return pages;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The build step of an explainer: validates the content in src/ and writes every page. */
|
|
237
|
+
export function explainer(): Plugin[] {
|
|
238
|
+
let root = process.cwd();
|
|
239
|
+
let command: "build" | "serve" = "build";
|
|
240
|
+
let site: Site | undefined;
|
|
241
|
+
let islands: Islands = new Map();
|
|
242
|
+
let descriptions: Descriptions = new Map();
|
|
243
|
+
let islandsChunk: string | undefined;
|
|
244
|
+
let devServer: ViteDevServer | undefined;
|
|
245
|
+
const load = () => (site ??= loadSite(root));
|
|
246
|
+
const main: Plugin = {
|
|
247
|
+
name: "lernapps-explainer",
|
|
248
|
+
enforce: "post",
|
|
249
|
+
configResolved(config) {
|
|
250
|
+
root = config.root;
|
|
251
|
+
command = config.command;
|
|
252
|
+
},
|
|
253
|
+
async buildStart() {
|
|
254
|
+
site = undefined;
|
|
255
|
+
islands = new Map();
|
|
256
|
+
descriptions = new Map();
|
|
257
|
+
islandsChunk = undefined;
|
|
258
|
+
let content: Site;
|
|
259
|
+
try {
|
|
260
|
+
content = load();
|
|
261
|
+
} catch (error) {
|
|
262
|
+
this.error(error instanceof Error ? error.message : String(error));
|
|
263
|
+
}
|
|
264
|
+
if (command !== "build" || content.components.length === 0) return;
|
|
265
|
+
const rendered = await withServer(root, (loader) => renderComponents(content, loader, true));
|
|
266
|
+
if (rendered.problems.length > 0) this.error(new ContentError(rendered.problems).message);
|
|
267
|
+
islands = rendered.islands;
|
|
268
|
+
descriptions = rendered.descriptions;
|
|
269
|
+
islandsChunk = this.emitFile({ type: "chunk", id: ISLANDS, name: "islands" });
|
|
270
|
+
},
|
|
271
|
+
resolveId(id) {
|
|
272
|
+
return id === ISLANDS ? ISLANDS : undefined;
|
|
273
|
+
},
|
|
274
|
+
load(id) {
|
|
275
|
+
return id === ISLANDS ? islandsModule(load()) : undefined;
|
|
276
|
+
},
|
|
277
|
+
generateBundle(_options, bundle) {
|
|
278
|
+
const index = bundle["index.html"];
|
|
279
|
+
if (index === undefined || index.type !== "asset") return;
|
|
280
|
+
const shell = String(index.source);
|
|
281
|
+
const content = load();
|
|
282
|
+
const script =
|
|
283
|
+
islandsChunk === undefined
|
|
284
|
+
? ""
|
|
285
|
+
: `<script type="module" crossorigin src="./${this.getFileName(islandsChunk)}"></script>`;
|
|
286
|
+
for (const page of renderSite(content, islands, descriptions)) {
|
|
287
|
+
const html = fill(shell, page, page.islands ? script : "");
|
|
288
|
+
if (page.route.path === "") index.source = html;
|
|
289
|
+
else this.emitFile({ type: "asset", fileName: `${page.route.path}index.html`, source: html });
|
|
290
|
+
}
|
|
291
|
+
this.emitFile({ type: "asset", fileName: "404.html", source: notFoundPage() });
|
|
292
|
+
for (const [image, file] of content.images) {
|
|
293
|
+
this.emitFile({ type: "asset", fileName: image, source: readFileSync(file) });
|
|
294
|
+
}
|
|
295
|
+
const problems: string[] = [];
|
|
296
|
+
const tutor = tutorAssets(content, descriptions);
|
|
297
|
+
for (const lang of content.app.languages) {
|
|
298
|
+
const files = new Set([
|
|
299
|
+
...routes(content)
|
|
300
|
+
.filter((route) => route.lang === lang)
|
|
301
|
+
.map((route) => route.local),
|
|
302
|
+
...contentPages(content).map((page) => tutorPageOf(page)),
|
|
303
|
+
...assessments(content).map((assessment) => assessmentTutorOf(assessment)),
|
|
304
|
+
...[...scopeFiles(content, lang).keys()].map((folder) => `${folder}${SCOPE_PAGE}`),
|
|
305
|
+
]);
|
|
306
|
+
const anchors = new Map(
|
|
307
|
+
contentPages(content).map((page) => [`${page.chapter}/${page.id}/`, anchorsOf(content, page)]),
|
|
308
|
+
);
|
|
309
|
+
// the tutor page of each content page and each self-assessment with its Markdown, each link checked against
|
|
310
|
+
// the pages and anchors of the language tree
|
|
311
|
+
for (const local of [
|
|
312
|
+
...contentPages(content).map((contentPage) => tutorPageOf(contentPage)),
|
|
313
|
+
...assessments(content).map((assessment) => assessmentTutorOf(assessment)),
|
|
314
|
+
]) {
|
|
315
|
+
const page = tutor.get(`${languageRoot(lang)}${local}`);
|
|
316
|
+
if (page === undefined) continue;
|
|
317
|
+
problems.push(
|
|
318
|
+
...linkProblems(local, page.markdown, files, anchors).map((problem) => `${languageRoot(lang)}${problem}`),
|
|
319
|
+
);
|
|
320
|
+
this.emitFile({ type: "asset", fileName: `${languageRoot(lang)}${local}index.html`, source: page.html });
|
|
321
|
+
this.emitFile({
|
|
322
|
+
type: "asset",
|
|
323
|
+
fileName: `${languageRoot(lang)}${local}${TUTOR_MARKDOWN}`,
|
|
324
|
+
source: page.markdown,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// the scopes beside the app, each chapter and each page of each language tree, for the tutor only: their
|
|
329
|
+
// Markdown and a page for no menu
|
|
330
|
+
for (const lang of content.app.languages) {
|
|
331
|
+
for (const [folder, markdown] of scopeFiles(content, lang)) {
|
|
332
|
+
const at = `${languageRoot(lang)}${folder}${SCOPE_PAGE}`;
|
|
333
|
+
this.emitFile({ type: "asset", fileName: `${at}${TUTOR_MARKDOWN}`, source: markdown });
|
|
334
|
+
this.emitFile({
|
|
335
|
+
type: "asset",
|
|
336
|
+
fileName: `${at}index.html`,
|
|
337
|
+
source: scopeDocument(markdown, inLanguage(content.app.title, lang), lang, folder),
|
|
338
|
+
});
|
|
339
|
+
// the whole scope beyond the budget of a tutor page: noted, no problem
|
|
340
|
+
if (folder === "" && bytesOf(markdown) > TUTOR_PAGE_BUDGET) {
|
|
341
|
+
this.info(
|
|
342
|
+
`lernapps explainer: the scope of the whole app (${languageRoot(lang)}${SCOPE_PAGE}) is larger than ${TUTOR_PAGE_BUDGET / 1000} kB (${(bytesOf(markdown) / 1000).toFixed(0)} kB), which a tutor may get cut off`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// a tutor page beyond its budget: noted, no problem (the tutor may get it cut off)
|
|
348
|
+
const large = [...tutor].filter(([, page]) => bytesOf(page.html) > TUTOR_PAGE_BUDGET);
|
|
349
|
+
if (large.length > 0) {
|
|
350
|
+
this.info(
|
|
351
|
+
`lernapps explainer: ${large.length} tutor page${large.length === 1 ? " is" : "s are"} larger than ${TUTOR_PAGE_BUDGET / 1000} kB, which a tutor may get cut off: ${large.map(([file, page]) => `${file} (${(bytesOf(page.html) / 1000).toFixed(0)} kB)`).join(", ")}`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
// an exercise component whose numbers do not all fit on its tutor page: a warning, until it declares its
|
|
355
|
+
// table of tasks (template, params, mistakes and show of its logic module)
|
|
356
|
+
const cut = [...tutor].flatMap(([file, page]) => page.cut.map((one) => ({ file, ...one })));
|
|
357
|
+
if (cut.length > 0) {
|
|
358
|
+
this.warn(
|
|
359
|
+
`lernapps explainer: ${cut.length} exercise${cut.length === 1 ? " shows" : "s show"} only some of ${cut.length === 1 ? "its" : "their"} task numbers on the tutor page, to stay within ${TUTOR_PAGE_BUDGET / 1000} kB; export template, params, mistakes and show from its logic module for a table of all its numbers: ${cut.map((one) => `${one.file} ${one.id} (${one.shown} of ${one.count})`).join(", ")}`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
// an exercise component without its table of tasks: a hint for now
|
|
363
|
+
const hint = tableHint(content, descriptions);
|
|
364
|
+
if (hint !== undefined) this.info(hint);
|
|
365
|
+
// a page of a self-assessment with fewer or more exercises than it should show: a hint
|
|
366
|
+
const steps = stepHint(content);
|
|
367
|
+
if (steps !== undefined) this.info(steps);
|
|
368
|
+
// the exercises with fewer distinct tasks than the numbers allow: noted, no problem
|
|
369
|
+
const few = [
|
|
370
|
+
...contentPages(content).flatMap((page) =>
|
|
371
|
+
[...allBlocks(page.blocks)].flatMap((block) =>
|
|
372
|
+
block.type === "generated"
|
|
373
|
+
? [[block.id, taskSeeds(block, dataOf(block, content.data)).length] as const]
|
|
374
|
+
: [],
|
|
375
|
+
),
|
|
376
|
+
),
|
|
377
|
+
...content.components.flatMap((use) => {
|
|
378
|
+
const seeds = descriptions.get(use.block)?.seeds;
|
|
379
|
+
return seeds === undefined || use.id === undefined ? [] : [[use.id, seeds.length] as const];
|
|
380
|
+
}),
|
|
381
|
+
].filter(([, count]) => count < MAX_TASKS);
|
|
382
|
+
if (few.length > 0) {
|
|
383
|
+
this.info(
|
|
384
|
+
`lernapps explainer: ${few.length} exercise${few.length === 1 ? " has" : "s have"} fewer than ${MAX_TASKS} distinct tasks, numbered 1 to N: ${few.map(([id, count]) => `${id} (${count})`).join(", ")}`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
// each component's own code: its chunk and the chunks of its folder that it imports, gzipped
|
|
388
|
+
const chunks = Object.values(bundle).flatMap((output) => (output.type === "chunk" ? [output] : []));
|
|
389
|
+
const folder = join(root, "src/components");
|
|
390
|
+
for (const component of new Map(content.components.map((use) => [use.component.name, use.component])).values()) {
|
|
391
|
+
const chunk = chunks.find((candidate) => candidate.facadeModuleId === component.path);
|
|
392
|
+
if (chunk === undefined) continue;
|
|
393
|
+
const own = [
|
|
394
|
+
chunk.code,
|
|
395
|
+
...chunk.imports
|
|
396
|
+
.map((file) => chunks.find((candidate) => candidate.fileName === file))
|
|
397
|
+
.filter((imported) => imported !== undefined && imported.moduleIds.every((id) => id.startsWith(folder)))
|
|
398
|
+
.map((imported) => imported?.code ?? ""),
|
|
399
|
+
].join("\n");
|
|
400
|
+
const size = gzipSync(own).length;
|
|
401
|
+
if (size > SIZE_BUDGET) {
|
|
402
|
+
problems.push(
|
|
403
|
+
`${component.file}: ${(size / 1000).toFixed(1)} kB gzip of its own code, more than the budget of ${SIZE_BUDGET / 1000} kB; split it into smaller components, or ask for a built-in block (vue-component-size)`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (problems.length > 0) this.error(new ContentError(problems).message);
|
|
408
|
+
},
|
|
409
|
+
configureServer(server) {
|
|
410
|
+
devServer = server;
|
|
411
|
+
server.watcher.on("all", (_event, file) => {
|
|
412
|
+
if (file.startsWith(join(root, "src")) && !file.endsWith(".ts")) {
|
|
413
|
+
site = undefined;
|
|
414
|
+
server.ws.send({ type: "full-reload" });
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
// before Vite's own fallback to index.html
|
|
418
|
+
server.middlewares.use((request, response, next) => {
|
|
419
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
420
|
+
const path = decodeURIComponent(url.pathname).replace(/^\//, "");
|
|
421
|
+
if (path.startsWith("images/")) {
|
|
422
|
+
response.setHeader("content-type", imageType(path));
|
|
423
|
+
response.end(readFileSync(join(root, "src", path)));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const forTutor = new RegExp(`(^|/)tutor/(${TUTOR_MARKDOWN.replace(".", "\\.")})?$`).test(path);
|
|
427
|
+
const forScope = new RegExp(`(^|/)${SCOPE_PAGE}(${TUTOR_MARKDOWN.replace(".", "\\.")})?$`).test(path);
|
|
428
|
+
if (!(path === "" || path.endsWith("/") || forTutor || forScope)) return next();
|
|
429
|
+
const fail = (error: unknown) => {
|
|
430
|
+
response.statusCode = 500;
|
|
431
|
+
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
432
|
+
response.end(error instanceof Error ? error.message : String(error));
|
|
433
|
+
};
|
|
434
|
+
let content: Site;
|
|
435
|
+
try {
|
|
436
|
+
content = load();
|
|
437
|
+
} catch (error) {
|
|
438
|
+
fail(error);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const dev = devServer;
|
|
442
|
+
if (dev === undefined) return next();
|
|
443
|
+
renderComponents(content, (id) => dev.ssrLoadModule(id), false)
|
|
444
|
+
.then(({ islands: rendered, descriptions: described, problems }) => {
|
|
445
|
+
if (problems.length > 0) throw new ContentError(problems);
|
|
446
|
+
if (forScope) {
|
|
447
|
+
const markdown = path.endsWith(TUTOR_MARKDOWN);
|
|
448
|
+
const lang =
|
|
449
|
+
content.app.languages.find((one) => languageRoot(one) !== "" && path.startsWith(languageRoot(one))) ??
|
|
450
|
+
content.app.languages[0] ??
|
|
451
|
+
"de";
|
|
452
|
+
// the folder within the language tree whose scope this is
|
|
453
|
+
const folder = path
|
|
454
|
+
.slice(languageRoot(lang).length)
|
|
455
|
+
.replace(new RegExp(`${SCOPE_PAGE}(${TUTOR_MARKDOWN.replace(".", "\\.")})?$`), "");
|
|
456
|
+
const scope = scopeFiles(content, lang).get(folder);
|
|
457
|
+
if (scope === undefined) return next();
|
|
458
|
+
response.setHeader("content-type", `text/${markdown ? "markdown" : "html"}; charset=utf-8`);
|
|
459
|
+
response.end(markdown ? scope : scopeDocument(scope, inLanguage(content.app.title, lang), lang, folder));
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
if (forTutor) {
|
|
463
|
+
const markdown = path.endsWith(TUTOR_MARKDOWN);
|
|
464
|
+
const page = tutorAssets(content, described).get(markdown ? path.slice(0, -TUTOR_MARKDOWN.length) : path);
|
|
465
|
+
if (page === undefined) return next();
|
|
466
|
+
response.setHeader("content-type", `text/${markdown ? "markdown" : "html"}; charset=utf-8`);
|
|
467
|
+
response.end(markdown ? page.markdown : page.html);
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
const page = renderSite(content, rendered, described).find((candidate) => candidate.route.path === path);
|
|
471
|
+
if (page === undefined) return next();
|
|
472
|
+
const shell = readFileSync(join(root, "index.html"), "utf8");
|
|
473
|
+
const head = page.islands
|
|
474
|
+
? `<script type="module" src="/@id/__x00__lernapps-explainer-islands"></script>`
|
|
475
|
+
: "";
|
|
476
|
+
return dev
|
|
477
|
+
.transformIndexHtml(`/${path}`, fill(shell, { ...page, route: { ...page.route, path: "" } }, head))
|
|
478
|
+
.then((html) => {
|
|
479
|
+
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
480
|
+
response.end(html);
|
|
481
|
+
});
|
|
482
|
+
})
|
|
483
|
+
.catch(fail);
|
|
484
|
+
});
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
return [vueWhenComponents(), main];
|
|
488
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Where the learner is, for the two ways to the tutor (explainer.ts): as the query of the page's tutor page, which
|
|
2
|
+
// the learner's message names. The tutor page holds every task of the page (tutorpage.ts), and a static host serves
|
|
3
|
+
// it for any query: the tutor reads the query from its own prompt. It is the page's own address, the task number of
|
|
4
|
+
// each of its exercises (?<id>=<n>; tasks.ts). Way A, "Mit <name> lernen", is for learning the page from its start:
|
|
5
|
+
// the numbers alone (pageQuery), so that the tutor leads through the page before it comes to a task. Way B, "Hilfe",
|
|
6
|
+
// adds the exercise whose button was pressed (uebung) and its section (abschnitt; taskQuery). Pure and without Node:
|
|
7
|
+
// the client module calls it.
|
|
8
|
+
import { numberFromAddress } from "./tasks.ts";
|
|
9
|
+
|
|
10
|
+
/** What a page tells the client module about itself for the learner's position (render.ts). */
|
|
11
|
+
export interface PagePosition {
|
|
12
|
+
id: string;
|
|
13
|
+
/** Its exercises in order, each with the anchor of its section and whether its tasks have numbers. */
|
|
14
|
+
exercises: readonly { id: string; section?: string; numbered: boolean }[];
|
|
15
|
+
/** The anchors of its sections. */
|
|
16
|
+
sections: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Exercise = PagePosition["exercises"][number];
|
|
20
|
+
|
|
21
|
+
/** The anchor of the address `href`, decoded. */
|
|
22
|
+
const anchorOf = (href: string): string => decodeURIComponent(new URL(href).hash.slice(1));
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The exercise the learner is at on the page at `href`: the one answered last, else the one the anchor names, else the
|
|
26
|
+
* page's only exercise; none on a page with several and no such hint.
|
|
27
|
+
*/
|
|
28
|
+
export function currentExercise(href: string, page: PagePosition, last?: string): Exercise | undefined {
|
|
29
|
+
const anchor = anchorOf(href);
|
|
30
|
+
const byId = (id: string | undefined) => page.exercises.find((exercise) => exercise.id === id);
|
|
31
|
+
return byId(last) ?? byId(anchor) ?? (page.exercises.length === 1 ? page.exercises[0] : undefined);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The query of the page's tutor page: the task number the address `href` holds of each exercise with numbers, in the
|
|
36
|
+
* page's order, then the exercise `exercise` and the section `section`, each if any; "" for none of them.
|
|
37
|
+
*/
|
|
38
|
+
function tutorQuery(
|
|
39
|
+
href: string,
|
|
40
|
+
page: PagePosition,
|
|
41
|
+
exercise: Exercise | undefined,
|
|
42
|
+
section: string | undefined,
|
|
43
|
+
): string {
|
|
44
|
+
const query = new URLSearchParams();
|
|
45
|
+
for (const candidate of page.exercises) {
|
|
46
|
+
const n = candidate.numbered ? numberFromAddress(href, candidate.id) : undefined;
|
|
47
|
+
if (n !== undefined) query.set(candidate.id, String(n));
|
|
48
|
+
}
|
|
49
|
+
if (exercise !== undefined) query.set("uebung", exercise.id);
|
|
50
|
+
if (section !== undefined) query.set("abschnitt", section);
|
|
51
|
+
const text = query.toString();
|
|
52
|
+
return text === "" ? "" : `?${text}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Way A: the task numbers the address `href` holds of the page's exercises, for learning the page. */
|
|
56
|
+
export const pageQuery = (href: string, page: PagePosition): string => tutorQuery(href, page, undefined, undefined);
|
|
57
|
+
|
|
58
|
+
/** Way B: the page's task numbers at `href`, and the exercise `id` with its section. */
|
|
59
|
+
export function taskQuery(href: string, page: PagePosition, id: string): string {
|
|
60
|
+
const exercise = page.exercises.find((candidate) => candidate.id === id);
|
|
61
|
+
return tutorQuery(href, page, exercise ?? { id, numbered: false }, exercise?.section);
|
|
62
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The start prompt of a content page and the assistants that take it, way A to the tutor ("Mit <name> lernen"; way B,
|
|
2
|
+
// "Hilfe" at one task, is stuck.ts). The learner switches an assistant on once (Claude, ChatGPT or Mistral Vibe, the same
|
|
3
|
+
// for every app), or copying the text; then every content page has a bar beside its title. Its
|
|
4
|
+
// button opens the assistant with the start prompt in its address: one sentence in the learner's voice that names
|
|
5
|
+
// the page's tutor page (tutorpage.ts) with where the learner is in its query (position.ts). A chat assistant
|
|
6
|
+
// fetches only addresses its user wrote into the conversation, so the prompt names the one address the tutor needs,
|
|
7
|
+
// and the tutor page holds all the rest, its guide among it: the prompt itself gives the tutor no rules. Mistral Vibe
|
|
8
|
+
// treats a message from its address as not written by the user and loads nothing from it (`paste`): there the click
|
|
9
|
+
// copies the start prompt and opens the chat with a short placeholder message; the learner pastes, and the prompt,
|
|
10
|
+
// now the learner's own message, lets the tutor load its tutor page. For copying,
|
|
11
|
+
// the client module adds the tutor page's Markdown after the prompt, nothing going over the network but the app's
|
|
12
|
+
// own static file. Without JavaScript the provider links are plain links to the assistant, and the tutor page is a
|
|
13
|
+
// page to read and copy from. Nothing loads before a click.
|
|
14
|
+
import { type Route } from "./routes.ts";
|
|
15
|
+
import { tutorPageOf } from "./tutor.ts";
|
|
16
|
+
|
|
17
|
+
/** Where the start prompt's address starts: replaced by the address of the language tree when the page uses it. */
|
|
18
|
+
export const BASE = "⟦base⟧";
|
|
19
|
+
|
|
20
|
+
/** Where the start prompt of a content page has the learner's position: replaced by the query the page builds. */
|
|
21
|
+
export const QUERY = "⟦query⟧";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* An assistant the start page offers: its name, and its address with {prompt} where the prompt goes. An assistant that
|
|
25
|
+
* fetches no address from a message it got through its own address (Mistral Vibe) has `paste`: the address carries only a
|
|
26
|
+
* short placeholder message, and the learner pastes the copied start prompt, which then counts as written by them.
|
|
27
|
+
*/
|
|
28
|
+
export interface Provider {
|
|
29
|
+
name: string;
|
|
30
|
+
url: string;
|
|
31
|
+
paste?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The assistants a learner can switch on, the same for every app: each takes the prompt in its address. */
|
|
35
|
+
export const PROVIDERS: readonly Provider[] = [
|
|
36
|
+
{ name: "Claude", url: "https://claude.ai/new?q={prompt}" },
|
|
37
|
+
{ name: "ChatGPT", url: "https://chatgpt.com/?q={prompt}" },
|
|
38
|
+
{ name: "Mistral Vibe", url: "https://chat.mistral.ai/chat?q={prompt}", paste: true },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const MARK = "lernapps-prompt";
|
|
42
|
+
/** A provider's address without the prompt, for the link without JavaScript: its parameter with {prompt} left out. */
|
|
43
|
+
export function plainAddress(url: string): string {
|
|
44
|
+
const parsed = new URL(url.replace(/\{prompt\}/g, MARK));
|
|
45
|
+
const keys = Array.from(parsed.searchParams.entries()).flatMap(([key, value]) => (value.includes(MARK) ? [key] : []));
|
|
46
|
+
for (const key of keys) parsed.searchParams.delete(key);
|
|
47
|
+
return parsed.href.replace(/\?$/, "");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const LOAD: Readonly<Record<string, string>> = {
|
|
51
|
+
de: "Lade {url} und sei mein Tutor.",
|
|
52
|
+
en: "Load {url} and be my tutor.",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const PLACEHOLDER: Readonly<Record<string, string>> = {
|
|
56
|
+
de: "Ich brauche Hilfe bei einer Lern-Einheit. Ich schreibe Dir gleich, worum es geht.",
|
|
57
|
+
en: "I need help with a learning unit. I will tell you in a moment what it is about.",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The short message a `paste` assistant gets in its address, in `lang`: it holds no address to fetch. */
|
|
61
|
+
export const placeholderPrompt = (lang: string): string => PLACEHOLDER[lang] ?? PLACEHOLDER["de"] ?? "";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The start prompt a provider link carries from a content page, the one kind of page that starts a tutor, in its
|
|
65
|
+
* language: "Lade <chapter>/<page>/tutor/<QUERY> und sei mein Tutor.", the page's tutor page with
|
|
66
|
+
* where the learner is in its query, which the client module fills in (position.ts, pageQuery). Its address starts
|
|
67
|
+
* with BASE.
|
|
68
|
+
*/
|
|
69
|
+
export function startPrompt(route: Pick<Route, "lang" | "page">): string {
|
|
70
|
+
const page = { chapter: route.page?.chapter ?? "", id: route.page?.id ?? "" };
|
|
71
|
+
return (LOAD[route.lang] ?? LOAD["de"] ?? "").replace("{url}", `${BASE}${tutorPageOf(page)}${QUERY}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The prompt with its address from `base`, the language tree's address. */
|
|
75
|
+
export const withBase = (prompt: string, base: string): string => prompt.split(BASE).join(base);
|