@lernapps/app-templates 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +195 -2
- package/dist/appname-Bh23cUuz.mjs +12 -0
- package/dist/assessment-Dy359O1O.mjs +1696 -0
- package/dist/block-BK_ohsH1.mjs +82 -0
- package/dist/blocks-CxtCPAa5.mjs +149 -0
- package/dist/blocks.mjs +4 -0
- package/dist/check-CteIFN96.mjs +254 -0
- package/dist/content-DO6oEJxj.mjs +140 -0
- package/dist/contract-PONpxJtd.mjs +52 -0
- package/dist/e2e-CU0n_unV.mjs +89 -0
- package/dist/engine-CArxqizW.mjs +136 -0
- package/dist/explainer-e2e.mjs +1311 -0
- package/dist/explainer-islands.mjs +197 -0
- package/dist/explainer-plugin.mjs +2132 -0
- package/dist/explainer.mjs +1246 -0
- package/dist/html-mffkHGHh.mjs +89 -0
- package/dist/image-CspJVfyQ.mjs +21 -0
- package/dist/interactive-e2e.mjs +140 -0
- package/dist/interactive-plugin.mjs +242 -0
- package/dist/interactive.mjs +75 -0
- package/dist/load-RosvaPZf.mjs +16976 -0
- package/dist/needs-BzEahJ6X.mjs +97 -0
- package/dist/pages-9RaaomoO.mjs +81 -0
- package/dist/questions-e2e.mjs +1 -0
- package/dist/questions-engine.mjs +3 -0
- package/dist/questions.mjs +6 -0
- package/dist/quiz-DnW-xtCh.mjs +70 -0
- package/dist/quiz-e2e.mjs +174 -0
- package/dist/quiz-plugin.mjs +266 -0
- package/dist/quiz.mjs +246 -0
- package/dist/render-D5kwkNt1.mjs +507 -0
- package/dist/render-DqHOJyHK.mjs +146 -0
- package/dist/render-GS1Htlu8.mjs +545 -0
- package/dist/result-D_mmCVwN.mjs +180 -0
- package/dist/tasks-DADUYy52.mjs +74 -0
- package/dist/validate-BDwL7Su3.mjs +1076 -0
- package/dist/validate-Bxy9NfB8.mjs +146 -0
- package/dist/vue-DO1m6q1R.mjs +40 -0
- package/dist/vue-code-BkJjW_I_.mjs +320 -0
- package/docs/arc42/01-introduction-and-goals.arc42.md +82 -0
- package/docs/arc42/02-constraints.arc42.md +228 -0
- package/docs/arc42/03-system-context.arc42.md +118 -0
- package/docs/arc42/04-solution-strategy.arc42.md +47 -0
- package/docs/arc42/05-building-blocks.arc42.md +307 -0
- package/docs/arc42/06-runtime-view.arc42.md +98 -0
- package/docs/arc42/07-deployment-view.arc42.md +160 -0
- package/docs/arc42/08-concepts.arc42.md +123 -0
- package/docs/arc42/09-decisions.arc42.md +246 -0
- package/docs/arc42/10-quality-requirements.arc42.md +155 -0
- package/docs/arc42/11-risks.arc42.md +113 -0
- package/docs/arc42/12-glossary.arc42.md +162 -0
- package/docs/arc42/architecture-evidence.md +395 -0
- package/explainer/.arc42ignore +3 -0
- package/explainer/README.md +54 -0
- package/explainer/_gitignore +5 -0
- package/explainer/docs/arc42/03-explainer-system-context.arc42.md +35 -0
- package/explainer/docs/arc42/05-explainer-building-blocks.arc42.md +659 -0
- package/explainer/docs/arc42/06-explainer-runtime-view.arc42.md +105 -0
- package/explainer/docs/arc42/07-explainer-deployment-view.arc42.md +34 -0
- package/explainer/docs/arc42/08-explainer-concepts.arc42.md +17 -0
- package/explainer/docs/arc42/09-explainer-decisions.arc42.md +443 -0
- package/explainer/docs/arc42/10-explainer-quality-requirements.arc42.md +54 -0
- package/explainer/docs/arc42/11-explainer-risks.arc42.md +17 -0
- package/explainer/docs/arc42/12-explainer-glossary.arc42.md +185 -0
- package/explainer/e2e/explainer.e2e.ts +3 -0
- package/explainer/index.html +11 -0
- package/explainer/package.json +18 -0
- package/explainer/playwright.config.ts +1 -0
- package/explainer/src/app.yaml +20 -0
- package/explainer/src/components/ProzentFeld.test.ts +30 -0
- package/explainer/src/components/ProzentFeld.ts +121 -0
- package/explainer/src/components/ProzentFeld.vue +84 -0
- package/explainer/src/images/hunderterfeld-klammern.svg +105 -0
- package/explainer/src/main.ts +4 -0
- package/explainer/src/prozent/chapter.yaml +18 -0
- package/explainer/src/prozent/grundwert.yaml +115 -0
- package/explainer/src/prozent/prozentsatz.yaml +99 -0
- package/explainer/src/prozent/prozentwert.yaml +119 -0
- package/explainer/tsconfig.json +1 -0
- package/explainer/vite.config.ts +4 -0
- package/interactive/.arc42ignore +5 -0
- package/interactive/README.md +28 -0
- package/interactive/_gitignore +4 -0
- package/interactive/docs/arc42/05-interactive-building-blocks.arc42.md +518 -0
- package/interactive/docs/arc42/06-interactive-runtime-view.arc42.md +58 -0
- package/interactive/docs/arc42/07-interactive-deployment-view.arc42.md +35 -0
- package/interactive/docs/arc42/08-interactive-concepts.arc42.md +52 -0
- package/interactive/docs/arc42/09-interactive-decisions.arc42.md +130 -0
- package/interactive/docs/arc42/10-interactive-quality-requirements.arc42.md +45 -0
- package/interactive/docs/arc42/11-interactive-risks.arc42.md +20 -0
- package/interactive/docs/arc42/12-interactive-glossary.arc42.md +80 -0
- package/interactive/e2e/interactive.e2e.ts +3 -0
- package/interactive/index.html +11 -0
- package/interactive/package.json +18 -0
- package/interactive/playwright.config.ts +1 -0
- package/interactive/src/app.yaml +28 -0
- package/interactive/src/core/index.ts +101 -0
- package/interactive/src/data/lessons.json +4 -0
- package/interactive/src/env.d.ts +6 -0
- package/interactive/src/main.ts +6 -0
- package/interactive/src/messages/de.json +12 -0
- package/interactive/src/playthroughs/grundreihe.yaml +33 -0
- package/interactive/src/playthroughs/obere-reihe.yaml +14 -0
- package/interactive/src/ui/App.vue +85 -0
- package/interactive/tsconfig.json +1 -0
- package/interactive/vite.config.ts +4 -0
- package/package.json +143 -4
- package/quiz/README.md +31 -0
- package/quiz/_gitignore +5 -0
- package/quiz/docs/arc42/05-quiz-building-blocks.arc42.md +283 -0
- package/quiz/docs/arc42/06-quiz-runtime-view.arc42.md +21 -0
- package/quiz/docs/arc42/07-quiz-deployment-view.arc42.md +33 -0
- package/quiz/docs/arc42/09-quiz-decisions.arc42.md +36 -0
- package/quiz/docs/arc42/10-quiz-quality-requirements.arc42.md +53 -0
- package/quiz/docs/arc42/11-quiz-risks.arc42.md +19 -0
- package/quiz/docs/arc42/12-quiz-glossary.arc42.md +27 -0
- package/quiz/e2e/quiz.e2e.ts +3 -0
- package/quiz/index.html +11 -0
- package/quiz/package.json +18 -0
- package/quiz/playwright.config.ts +1 -0
- package/quiz/src/images/berge-ordnen.jpg +0 -0
- package/quiz/src/images/everest-hoehe.jpg +0 -0
- package/quiz/src/images/galapagos.jpg +0 -0
- package/quiz/src/images/grand-canyon.jpg +0 -0
- package/quiz/src/images/totes-meer.jpg +0 -0
- package/quiz/src/images/uluru.jpg +0 -0
- package/quiz/src/images/wasserfaelle-laender.jpg +0 -0
- package/quiz/src/main.ts +4 -0
- package/quiz/src/quiz.json +312 -0
- package/quiz/tsconfig.json +1 -0
- package/quiz/vite.config.ts +4 -0
- package/runtime/explainer/assessment-client.ts +478 -0
- package/runtime/explainer/assessment-page.ts +448 -0
- package/runtime/explainer/assessment.ts +393 -0
- package/runtime/explainer/check-page.ts +520 -0
- package/runtime/explainer/components/exercise.ts +409 -0
- package/runtime/explainer/components/props.ts +230 -0
- package/runtime/explainer/components/read.ts +428 -0
- package/runtime/explainer/content.ts +277 -0
- package/runtime/explainer/e2e.ts +1488 -0
- package/runtime/explainer/exercises.ts +326 -0
- package/runtime/explainer/explainer.css +1283 -0
- package/runtime/explainer/explainer.ts +838 -0
- package/runtime/explainer/explainer.v1.schema.json +1165 -0
- package/runtime/explainer/expression.ts +407 -0
- package/runtime/explainer/fraction.ts +107 -0
- package/runtime/explainer/generated/comparator.ts +31 -0
- package/runtime/explainer/generated/question.ts +165 -0
- package/runtime/explainer/generated/soundness.ts +280 -0
- package/runtime/explainer/generated/texts.ts +257 -0
- package/runtime/explainer/generated/types.ts +103 -0
- package/runtime/explainer/generated/values.ts +136 -0
- package/runtime/explainer/islands.ts +281 -0
- package/runtime/explainer/load.ts +457 -0
- package/runtime/explainer/messages/de.json +200 -0
- package/runtime/explainer/messages/en.json +200 -0
- package/runtime/explainer/messages.ts +13 -0
- package/runtime/explainer/needs.ts +122 -0
- package/runtime/explainer/open-task.ts +235 -0
- package/runtime/explainer/outline.ts +306 -0
- package/runtime/explainer/plugin.ts +488 -0
- package/runtime/explainer/position.ts +62 -0
- package/runtime/explainer/prompt.ts +75 -0
- package/runtime/explainer/render.ts +686 -0
- package/runtime/explainer/result.ts +238 -0
- package/runtime/explainer/routes.ts +119 -0
- package/runtime/explainer/schema.ts +140 -0
- package/runtime/explainer/scope.ts +87 -0
- package/runtime/explainer/stuck.ts +57 -0
- package/runtime/explainer/tasks.ts +109 -0
- package/runtime/explainer/tutor.ts +469 -0
- package/runtime/explainer/tutorpage.ts +669 -0
- package/runtime/explainer/video.ts +123 -0
- package/runtime/interactive/contract.ts +121 -0
- package/runtime/interactive/e2e.ts +190 -0
- package/runtime/interactive/interactive.css +82 -0
- package/runtime/interactive/interactive.ts +115 -0
- package/runtime/interactive/interactive.v1.schema.json +146 -0
- package/runtime/interactive/load.ts +188 -0
- package/runtime/interactive/messages/de.json +14 -0
- package/runtime/interactive/messages/en.json +14 -0
- package/runtime/interactive/messages.ts +11 -0
- package/runtime/interactive/playthrough.v1.schema.json +143 -0
- package/runtime/interactive/plugin.ts +266 -0
- package/runtime/interactive/render.ts +126 -0
- package/runtime/quiz/e2e.ts +233 -0
- package/runtime/quiz/engine.ts +242 -0
- package/runtime/quiz/messages/de.json +23 -0
- package/runtime/quiz/messages/en.json +23 -0
- package/runtime/quiz/plugin.ts +150 -0
- package/runtime/quiz/quiz.css +154 -0
- package/runtime/quiz/quiz.ts +56 -0
- package/runtime/quiz/quiz.v1.schema.json +72 -0
- package/runtime/quiz/render.ts +173 -0
- package/runtime/quiz/validate.ts +108 -0
- package/runtime/shared/appname.ts +16 -0
- package/runtime/shared/blocks/block.ts +162 -0
- package/runtime/shared/blocks/blocks.css +190 -0
- package/runtime/shared/blocks/blocks.v1.schema.json +432 -0
- package/runtime/shared/blocks/code.ts +26 -0
- package/runtime/shared/blocks/figures.ts +49 -0
- package/runtime/shared/blocks/image.ts +25 -0
- package/runtime/shared/blocks/index.ts +13 -0
- package/runtime/shared/blocks/messages/de.json +4 -0
- package/runtime/shared/blocks/messages/en.json +4 -0
- package/runtime/shared/blocks/render.ts +134 -0
- package/runtime/shared/blocks/validate.ts +165 -0
- package/runtime/shared/content.ts +197 -0
- package/runtime/shared/examples.ts +20 -0
- package/runtime/shared/html.ts +10 -0
- package/runtime/shared/languages.ts +94 -0
- package/runtime/shared/page.css +101 -0
- package/runtime/shared/pages.ts +114 -0
- package/runtime/shared/questions/e2e.ts +115 -0
- package/runtime/shared/questions/engine.ts +209 -0
- package/runtime/shared/questions/index.ts +39 -0
- package/runtime/shared/questions/messages/de.json +26 -0
- package/runtime/shared/questions/messages/en.json +26 -0
- package/runtime/shared/questions/question.ts +433 -0
- package/runtime/shared/questions/question.v1.schema.json +346 -0
- package/runtime/shared/questions/questions.css +192 -0
- package/runtime/shared/questions/render.ts +255 -0
- package/runtime/shared/questions/validate.ts +518 -0
- package/runtime/shared/schema.ts +44 -0
- package/runtime/shared/text/check.ts +68 -0
- package/runtime/shared/text/formula.ts +69 -0
- package/runtime/shared/text/markdown.ts +185 -0
- package/runtime/shared/text/speech.ts +39 -0
- package/runtime/shared/text/text.v1.schema.json +34 -0
- package/runtime/shared/vue-code.ts +395 -0
- package/runtime/shared/vue.ts +38 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { i as inLanguage } from "./html-mffkHGHh.mjs";
|
|
2
|
+
import { n as markerOf } from "./block-BK_ohsH1.mjs";
|
|
3
|
+
import { t as checkText } from "./check-CteIFN96.mjs";
|
|
4
|
+
// runtime/shared/blocks/validate.ts
|
|
5
|
+
/** The definition of each type of block in the schema of the blocks (blocks.v1.schema.json). */
|
|
6
|
+
const MATERIAL_DEFINITIONS = {
|
|
7
|
+
text: "textBlock",
|
|
8
|
+
formula: "formula",
|
|
9
|
+
figure: "figure",
|
|
10
|
+
"labelled-figure": "labelledFigure",
|
|
11
|
+
table: "table",
|
|
12
|
+
quote: "quote",
|
|
13
|
+
code: "code"
|
|
14
|
+
};
|
|
15
|
+
/** Whether `text` names `marker`: as a word of its own, so that the marker A is not found in "Grundriss". */
|
|
16
|
+
const names = (text, marker) => new RegExp(`(?<![\\p{L}\\p{N}])${marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\p{L}\\p{N}])`, "u").test(text);
|
|
17
|
+
/** The problems of `blocks`. */
|
|
18
|
+
function checkMaterial(blocks, checks) {
|
|
19
|
+
const problems = [];
|
|
20
|
+
const text = (at, value, kind) => {
|
|
21
|
+
if (value === void 0) return;
|
|
22
|
+
const { findings, links } = checkText(value, kind, checks.languages, checks.declaredIn);
|
|
23
|
+
for (const finding of findings) problems.push({
|
|
24
|
+
...finding,
|
|
25
|
+
path: [...at, ...finding.path]
|
|
26
|
+
});
|
|
27
|
+
for (const link of links) {
|
|
28
|
+
const place = [...at, ...link.path];
|
|
29
|
+
if (checks.onLink !== void 0) checks.onLink({
|
|
30
|
+
...link,
|
|
31
|
+
path: place
|
|
32
|
+
});
|
|
33
|
+
else problems.push({
|
|
34
|
+
path: place,
|
|
35
|
+
lang: link.lang,
|
|
36
|
+
message: `${link.href} is a link to a page of the app; here there are no pages: link an https address`
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
blocks.forEach((block, index) => {
|
|
41
|
+
const at = [index];
|
|
42
|
+
switch (block.type) {
|
|
43
|
+
case "text":
|
|
44
|
+
text([...at, "text"], block.text, "markdown");
|
|
45
|
+
break;
|
|
46
|
+
case "formula":
|
|
47
|
+
text([...at, "formula"], block.formula, "latex");
|
|
48
|
+
break;
|
|
49
|
+
case "figure":
|
|
50
|
+
case "labelled-figure":
|
|
51
|
+
if (block.type === "figure" && block.draw === void 0 === (block.image === void 0)) problems.push({
|
|
52
|
+
path: at,
|
|
53
|
+
message: block.draw === void 0 ? "draw or image is missing; a figure is drawn from data (draw) or a bundled image (image)" : "draw and image: a figure is either drawn from data or a bundled image"
|
|
54
|
+
});
|
|
55
|
+
if (block.image !== void 0 && !checks.imageExists(block.image)) problems.push({
|
|
56
|
+
path: [...at, "image"],
|
|
57
|
+
message: `${block.image} is missing: put the file into src/images/`
|
|
58
|
+
});
|
|
59
|
+
text([...at, "alt"], block.alt, "plain");
|
|
60
|
+
text([...at, "caption"], block.caption, "markdown");
|
|
61
|
+
if (block.type === "labelled-figure") {
|
|
62
|
+
const seen = /* @__PURE__ */ new Set();
|
|
63
|
+
block.labels.forEach((label, number) => {
|
|
64
|
+
text([
|
|
65
|
+
...at,
|
|
66
|
+
"labels",
|
|
67
|
+
number,
|
|
68
|
+
"text"
|
|
69
|
+
], label.text, "plain");
|
|
70
|
+
const marker = markerOf(label, number);
|
|
71
|
+
if (seen.has(marker)) problems.push({
|
|
72
|
+
path: [
|
|
73
|
+
...at,
|
|
74
|
+
"labels",
|
|
75
|
+
number,
|
|
76
|
+
"marker"
|
|
77
|
+
],
|
|
78
|
+
message: `${marker} is used twice; give each label its own marker`
|
|
79
|
+
});
|
|
80
|
+
seen.add(marker);
|
|
81
|
+
});
|
|
82
|
+
if (block.legend === false) for (const lang of checks.languages) {
|
|
83
|
+
const alt = inLanguage(block.alt, lang);
|
|
84
|
+
block.labels.forEach((label, number) => {
|
|
85
|
+
const marker = markerOf(label, number);
|
|
86
|
+
if (!names(alt, marker)) problems.push({
|
|
87
|
+
path: [...at, "alt"],
|
|
88
|
+
lang,
|
|
89
|
+
message: `alt does not name the marker ${marker}; with legend: false, alt names every marker with its part`
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
text([...at, "source"], block.source, "credit");
|
|
95
|
+
break;
|
|
96
|
+
case "table": {
|
|
97
|
+
text([...at, "caption"], block.caption, "plain");
|
|
98
|
+
block.header?.forEach((cell, number) => text([
|
|
99
|
+
...at,
|
|
100
|
+
"header",
|
|
101
|
+
number
|
|
102
|
+
], cell, "markdown"));
|
|
103
|
+
const columns = block.header?.length ?? block.rows[0]?.length ?? 0;
|
|
104
|
+
block.rows.forEach((row, line) => {
|
|
105
|
+
if (row.length !== columns) problems.push({
|
|
106
|
+
path: [
|
|
107
|
+
...at,
|
|
108
|
+
"rows",
|
|
109
|
+
line
|
|
110
|
+
],
|
|
111
|
+
message: `${row.length} cells, but the table has ${columns} columns; give each row a cell per column`
|
|
112
|
+
});
|
|
113
|
+
row.forEach((_cell, number) => text([
|
|
114
|
+
...at,
|
|
115
|
+
"rows",
|
|
116
|
+
line,
|
|
117
|
+
number
|
|
118
|
+
], row[number], "markdown"));
|
|
119
|
+
});
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case "quote":
|
|
123
|
+
text([...at, "text"], block.text, "markdown");
|
|
124
|
+
text([
|
|
125
|
+
...at,
|
|
126
|
+
"origin",
|
|
127
|
+
"author"
|
|
128
|
+
], block.origin.author, "name");
|
|
129
|
+
text([
|
|
130
|
+
...at,
|
|
131
|
+
"origin",
|
|
132
|
+
"work"
|
|
133
|
+
], block.origin.work, "name");
|
|
134
|
+
if (typeof block.origin.year !== "number") text([
|
|
135
|
+
...at,
|
|
136
|
+
"origin",
|
|
137
|
+
"year"
|
|
138
|
+
], block.origin.year, "plain");
|
|
139
|
+
text([...at, "source"], block.source, "credit");
|
|
140
|
+
break;
|
|
141
|
+
case "code": text([...at, "caption"], block.caption, "plain");
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
return problems;
|
|
145
|
+
}
|
|
146
|
+
export { checkMaterial as n, MATERIAL_DEFINITIONS as t };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
// runtime/shared/vue.ts
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
/** vue, and vue/<path>, resolved to the copy this runtime depends on. */
|
|
6
|
+
const vueAlias = () => [{
|
|
7
|
+
find: /^vue(?=$|\/)/,
|
|
8
|
+
replacement: dirname(require.resolve("vue/package.json"))
|
|
9
|
+
}];
|
|
10
|
+
/** The options of @vitejs/plugin-vue: a component's scope id from its file and source alone, the same in the build and
|
|
11
|
+
* in the server that renders it. */
|
|
12
|
+
const VUE_OPTIONS = { features: { componentIdGenerator: "filepath-source" } };
|
|
13
|
+
/** Loads modules of the app in `root` for Node in a Vite server of its own, with the components compiled for it. */
|
|
14
|
+
async function withServer(root, act) {
|
|
15
|
+
const [{ createServer }, { default: vue }] = await Promise.all([import("vite-plus"), import("@vitejs/plugin-vue")]);
|
|
16
|
+
const server = await createServer({
|
|
17
|
+
root,
|
|
18
|
+
configFile: false,
|
|
19
|
+
logLevel: "silent",
|
|
20
|
+
plugins: [vue(VUE_OPTIONS)],
|
|
21
|
+
resolve: { alias: vueAlias() },
|
|
22
|
+
appType: "custom",
|
|
23
|
+
server: {
|
|
24
|
+
middlewareMode: true,
|
|
25
|
+
hmr: false,
|
|
26
|
+
ws: false,
|
|
27
|
+
watch: null
|
|
28
|
+
},
|
|
29
|
+
optimizeDeps: {
|
|
30
|
+
noDiscovery: true,
|
|
31
|
+
include: []
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
try {
|
|
35
|
+
return await act((id) => server.ssrLoadModule(id));
|
|
36
|
+
} finally {
|
|
37
|
+
await server.close();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export { vueAlias as n, withServer as r, VUE_OPTIONS as t };
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, posix } from "node:path";
|
|
4
|
+
// runtime/shared/vue-code.ts
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
let compiler;
|
|
7
|
+
/** Vue's SFC compiler, loaded on first use: an app without components never loads it. */
|
|
8
|
+
const sfc = () => compiler ??= require("vue/compiler-sfc");
|
|
9
|
+
const TOOLING = [
|
|
10
|
+
"@lernapps/tooling/storage",
|
|
11
|
+
"@lernapps/tooling/i18n",
|
|
12
|
+
"@lernapps/tooling/a11y"
|
|
13
|
+
];
|
|
14
|
+
/** The packages a component may import, for a message. */
|
|
15
|
+
const PACKAGES = `vue, @lernapps/app-templates, ${TOOLING.join(", ")}`;
|
|
16
|
+
const NETWORK = /* @__PURE__ */ new Set([
|
|
17
|
+
"fetch",
|
|
18
|
+
"XMLHttpRequest",
|
|
19
|
+
"WebSocket",
|
|
20
|
+
"EventSource"
|
|
21
|
+
]);
|
|
22
|
+
const BROWSER = /* @__PURE__ */ new Set([
|
|
23
|
+
"window",
|
|
24
|
+
"document",
|
|
25
|
+
"navigator",
|
|
26
|
+
"location",
|
|
27
|
+
"history",
|
|
28
|
+
"localStorage",
|
|
29
|
+
"sessionStorage",
|
|
30
|
+
"indexedDB",
|
|
31
|
+
"matchMedia",
|
|
32
|
+
"requestAnimationFrame",
|
|
33
|
+
"cancelAnimationFrame",
|
|
34
|
+
"getComputedStyle",
|
|
35
|
+
"alert",
|
|
36
|
+
"confirm",
|
|
37
|
+
"prompt",
|
|
38
|
+
"screen",
|
|
39
|
+
"customElements",
|
|
40
|
+
"IntersectionObserver",
|
|
41
|
+
"ResizeObserver",
|
|
42
|
+
"MutationObserver"
|
|
43
|
+
]);
|
|
44
|
+
/** The hooks that never run while the build renders the component: inside them, browser globals are allowed. */
|
|
45
|
+
const BROWSER_HOOKS = /* @__PURE__ */ new Set([
|
|
46
|
+
"onMounted",
|
|
47
|
+
"onBeforeUnmount",
|
|
48
|
+
"onUnmounted"
|
|
49
|
+
]);
|
|
50
|
+
const HTML = /* @__PURE__ */ new Set([
|
|
51
|
+
"innerHTML",
|
|
52
|
+
"outerHTML",
|
|
53
|
+
"insertAdjacentHTML"
|
|
54
|
+
]);
|
|
55
|
+
const GLOBAL_OBJECTS = /* @__PURE__ */ new Set([
|
|
56
|
+
"window",
|
|
57
|
+
"self",
|
|
58
|
+
"globalThis"
|
|
59
|
+
]);
|
|
60
|
+
/** A line of a problem in `file`, at `place` when it is known. */
|
|
61
|
+
const line = (file, place, message) => `${file}${place === void 0 ? "" : `:${place.line}${place.column === void 0 ? "" : `:${place.column}`}`}: ${message}`;
|
|
62
|
+
const isNode = (value) => typeof value === "object" && value !== null && typeof value.type === "string";
|
|
63
|
+
/** Every node of `root`, with its parents from the root down. */
|
|
64
|
+
function* nodes(root, parents = []) {
|
|
65
|
+
yield [root, parents];
|
|
66
|
+
for (const [key, value] of Object.entries(root)) {
|
|
67
|
+
if (key === "loc" || key === "leadingComments" || key === "trailingComments" || key === "innerComments") continue;
|
|
68
|
+
for (const child of Array.isArray(value) ? value : [value]) if (isNode(child)) yield* nodes(child, [...parents, root]);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const parse = (code) => sfc().babelParse(code, {
|
|
72
|
+
sourceType: "module",
|
|
73
|
+
plugins: ["typescript"]
|
|
74
|
+
}).program;
|
|
75
|
+
/** The name of a member's property: x.innerHTML, x["innerHTML"]. */
|
|
76
|
+
function propertyName(node) {
|
|
77
|
+
if (node.type !== "MemberExpression" && node.type !== "OptionalMemberExpression") return void 0;
|
|
78
|
+
if (!node.computed && node.property.type === "Identifier") return node.property.name;
|
|
79
|
+
return node.property.type === "StringLiteral" ? node.property.value : void 0;
|
|
80
|
+
}
|
|
81
|
+
/** The import specifiers of `program`, with their nodes: static, re-exports and dynamic imports. */
|
|
82
|
+
function imports(program) {
|
|
83
|
+
const found = [];
|
|
84
|
+
for (const [node] of nodes(program)) if (node.type === "ImportDeclaration" && node.importKind !== "type" || (node.type === "ExportNamedDeclaration" || node.type === "ExportAllDeclaration") && node.source !== null && node.source !== void 0 && node.exportKind !== "type") {
|
|
85
|
+
if (node.source) found.push({
|
|
86
|
+
source: node.source.value,
|
|
87
|
+
node
|
|
88
|
+
});
|
|
89
|
+
} else if (node.type === "CallExpression" && node.callee.type === "Import") {
|
|
90
|
+
const [argument] = node.arguments;
|
|
91
|
+
found.push({
|
|
92
|
+
source: argument?.type === "StringLiteral" ? argument.value : "(an expression)",
|
|
93
|
+
node
|
|
94
|
+
});
|
|
95
|
+
} else if (node.type === "ImportExpression") found.push({
|
|
96
|
+
source: node.source.type === "StringLiteral" ? node.source.value : "(an expression)",
|
|
97
|
+
node
|
|
98
|
+
});
|
|
99
|
+
return found;
|
|
100
|
+
}
|
|
101
|
+
/** The package a bare import specifier names: "yaml" for "yaml/util", "@scope/name" for "@scope/name/sub". */
|
|
102
|
+
const packageOf = (source) => source.split("/").slice(0, source.startsWith("@") ? 2 : 1).join("/");
|
|
103
|
+
/**
|
|
104
|
+
* What the code of `program` may not do, and its imports of the app's own modules (`isLocal`); `own` names these for
|
|
105
|
+
* a message, `packages` are the libraries it may import besides.
|
|
106
|
+
*/
|
|
107
|
+
function findings(program, isLocal, own, packages = []) {
|
|
108
|
+
const found = [];
|
|
109
|
+
const locals = [];
|
|
110
|
+
for (const { source, node } of imports(program)) if (/^[a-z][a-z0-9+.-]*:|^\/\//i.test(source)) found.push({
|
|
111
|
+
node,
|
|
112
|
+
message: `an import from ${source}; a component loads nothing from the network, so bundle what it needs (vue-component-no-network)`
|
|
113
|
+
});
|
|
114
|
+
else if (isLocal(source)) locals.push({
|
|
115
|
+
source,
|
|
116
|
+
node
|
|
117
|
+
});
|
|
118
|
+
else if (source !== "vue" && source !== "@lernapps/app-templates" && !source.startsWith("@lernapps/app-templates/") && !TOOLING.includes(source) && !packages.includes(packageOf(source))) found.push({
|
|
119
|
+
node,
|
|
120
|
+
message: `an import from ${source}; a component imports only from ${PACKAGES} and ${own} (vue-component-imports)`
|
|
121
|
+
});
|
|
122
|
+
const { walkIdentifiers } = sfc();
|
|
123
|
+
if (program.body.length === 0) return {
|
|
124
|
+
found,
|
|
125
|
+
locals
|
|
126
|
+
};
|
|
127
|
+
walkIdentifiers(program, (identifier, parent, parents, isReference, isLocal) => {
|
|
128
|
+
if (!isReference || isLocal) return;
|
|
129
|
+
const { name } = identifier;
|
|
130
|
+
if (NETWORK.has(name)) found.push({
|
|
131
|
+
node: identifier,
|
|
132
|
+
message: `${name}: a component makes no network request; give it what it needs through its props (vue-component-no-network)`
|
|
133
|
+
});
|
|
134
|
+
else if (name === "eval" || name === "Function" && (parent?.type === "NewExpression" || parent?.type === "CallExpression") && parent.callee === identifier) found.push({
|
|
135
|
+
node: identifier,
|
|
136
|
+
message: `${name === "eval" ? "eval" : "Function"}: a component runs no code from strings (vue-component-no-html-injection)`
|
|
137
|
+
});
|
|
138
|
+
else if (BROWSER.has(name)) {
|
|
139
|
+
const stack = [...parents, identifier];
|
|
140
|
+
if (!stack.some((node, index) => node.type === "CallExpression" && node.callee.type === "Identifier" && BROWSER_HOOKS.has(node.callee.name) && node.arguments.some((argument) => argument === stack[index + 1]))) found.push({
|
|
141
|
+
node: identifier,
|
|
142
|
+
message: `${name} outside onMounted; the build renders the component where there is no ${name}, so use it only inside onMounted (vue-component-browser-globals)`
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
for (const [node] of nodes(program)) {
|
|
147
|
+
const property = propertyName(node);
|
|
148
|
+
if (property !== void 0 && HTML.has(property)) found.push({
|
|
149
|
+
node,
|
|
150
|
+
message: `${property}: a component writes no HTML from strings; let the template render it (vue-component-no-html-injection)`
|
|
151
|
+
});
|
|
152
|
+
else if (property === "sendBeacon") found.push({
|
|
153
|
+
node,
|
|
154
|
+
message: `sendBeacon: a component makes no network request (vue-component-no-network)`
|
|
155
|
+
});
|
|
156
|
+
else if (property !== void 0 && NETWORK.has(property) && (node.type === "MemberExpression" || node.type === "OptionalMemberExpression") && node.object.type === "Identifier" && GLOBAL_OBJECTS.has(node.object.name)) found.push({
|
|
157
|
+
node,
|
|
158
|
+
message: `${property}: a component makes no network request; give it what it needs through its props (vue-component-no-network)`
|
|
159
|
+
});
|
|
160
|
+
else if (node.type === "ObjectProperty" && !node.computed && (node.key.type === "Identifier" && node.key.name === "innerHTML" || node.key.type === "StringLiteral" && node.key.value === "innerHTML")) found.push({
|
|
161
|
+
node,
|
|
162
|
+
message: `v-html: a component writes no HTML from strings; let the template render it (vue-component-no-html-injection)`
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
found,
|
|
167
|
+
locals
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/** The problem lines of `found`, each once, at its place; one without a place only when no other has the message. */
|
|
171
|
+
function placed(file, found, place) {
|
|
172
|
+
const lines = [];
|
|
173
|
+
const withPlace = /* @__PURE__ */ new Set();
|
|
174
|
+
const without = [];
|
|
175
|
+
for (const finding of found) {
|
|
176
|
+
const at = place(finding.node);
|
|
177
|
+
if (at === void 0) without.push(finding.message);
|
|
178
|
+
else {
|
|
179
|
+
withPlace.add(finding.message);
|
|
180
|
+
const text = line(file, at, finding.message);
|
|
181
|
+
if (!lines.includes(text)) lines.push(text);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (const message of new Set(without)) if (!withPlace.has(message)) lines.push(line(file, void 0, message));
|
|
185
|
+
return lines;
|
|
186
|
+
}
|
|
187
|
+
/** The libraries in the dependencies of the app's package.json, by their package names. */
|
|
188
|
+
function dependencies(root) {
|
|
189
|
+
const file = join(root, "package.json");
|
|
190
|
+
if (!existsSync(file)) return [];
|
|
191
|
+
const manifest = JSON.parse(readFileSync(file, "utf8"));
|
|
192
|
+
return Object.keys(manifest.dependencies ?? {});
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The scope of an app whose components may import any of its files in src/ and the libraries in the dependencies of
|
|
196
|
+
* its package.json (the interactive archetype).
|
|
197
|
+
*/
|
|
198
|
+
const sourceScope = (root) => ({
|
|
199
|
+
own: "the app's own files in src/ or a library in the dependencies of package.json",
|
|
200
|
+
packages: dependencies(root),
|
|
201
|
+
resolve(file, source) {
|
|
202
|
+
if (!source.startsWith("./") && !source.startsWith("../")) return void 0;
|
|
203
|
+
const target = posix.normalize(posix.join(dirname(file), source));
|
|
204
|
+
if (!target.startsWith("src/")) return { problem: `${source} is outside src/; a component imports only the app's own files in src/` };
|
|
205
|
+
return existsSync(join(root, target)) ? { file: target } : { problem: `${source} does not exist; import a file of src/, with its extension` };
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
/** A single-file component as the compiler reads it; undefined, with its problems, when it has none to check. */
|
|
209
|
+
function readSfc(root, file, problems) {
|
|
210
|
+
const source = readFileSync(join(root, file), "utf8");
|
|
211
|
+
const { descriptor, errors } = sfc().parse(source, {
|
|
212
|
+
filename: join(root, file),
|
|
213
|
+
sourceMap: true
|
|
214
|
+
});
|
|
215
|
+
for (const error of errors) {
|
|
216
|
+
const loc = "loc" in error ? error.loc : void 0;
|
|
217
|
+
problems.push(line(file, loc ? {
|
|
218
|
+
line: loc.start.line,
|
|
219
|
+
column: loc.start.column
|
|
220
|
+
} : void 0, error.message));
|
|
221
|
+
}
|
|
222
|
+
if (errors.length > 0) return void 0;
|
|
223
|
+
const setup = descriptor.scriptSetup;
|
|
224
|
+
if (setup === null || setup.lang !== "ts") {
|
|
225
|
+
problems.push(line(file, void 0, `a component has <script setup lang="ts">`));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
descriptor,
|
|
230
|
+
setup
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The checks of components' code, each problem into `problems`: `compile` compiles a component's <script setup> with
|
|
235
|
+
* its template and checks the output, `module` checks a TypeScript module; each follows the imports of the app's own
|
|
236
|
+
* TypeScript modules (`scope`) and checks each module once.
|
|
237
|
+
*/
|
|
238
|
+
function codeChecker(root, scope, problems) {
|
|
239
|
+
const { SourceMapConsumer } = require("source-map-js");
|
|
240
|
+
const checked = /* @__PURE__ */ new Set();
|
|
241
|
+
const check = (file, program) => {
|
|
242
|
+
const { found, locals } = findings(program, (source) => scope.resolve(file, source) !== void 0, scope.own, scope.packages);
|
|
243
|
+
for (const { source, node } of locals) {
|
|
244
|
+
const target = scope.resolve(file, source);
|
|
245
|
+
if (target === void 0) continue;
|
|
246
|
+
if ("problem" in target) found.push({
|
|
247
|
+
node,
|
|
248
|
+
message: target.problem
|
|
249
|
+
});
|
|
250
|
+
else if (target.file.endsWith(".ts")) module(target.file);
|
|
251
|
+
}
|
|
252
|
+
return found;
|
|
253
|
+
};
|
|
254
|
+
const module = (file) => {
|
|
255
|
+
if (checked.has(file)) return;
|
|
256
|
+
checked.add(file);
|
|
257
|
+
problems.push(...placed(file, check(file, parse(readFileSync(join(root, file), "utf8"))), (node) => node.loc ? {
|
|
258
|
+
line: node.loc.start.line,
|
|
259
|
+
column: node.loc.start.column + 1
|
|
260
|
+
} : void 0));
|
|
261
|
+
};
|
|
262
|
+
const compile = (file, descriptor) => {
|
|
263
|
+
let compiled;
|
|
264
|
+
try {
|
|
265
|
+
compiled = sfc().compileScript(descriptor, {
|
|
266
|
+
id: file.replace(/^.*\//, "").replace(/\.vue$/, ""),
|
|
267
|
+
inlineTemplate: true,
|
|
268
|
+
sourceMap: true,
|
|
269
|
+
isProd: true,
|
|
270
|
+
fs: {
|
|
271
|
+
fileExists: existsSync,
|
|
272
|
+
readFile: (name) => readFileSync(name, "utf8")
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
} catch (error) {
|
|
276
|
+
const loc = error.loc;
|
|
277
|
+
const message = (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
|
|
278
|
+
problems.push(line(file, loc ? {
|
|
279
|
+
line: loc.start.line,
|
|
280
|
+
column: loc.start.column
|
|
281
|
+
} : void 0, message));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const program = parse(compiled.content);
|
|
285
|
+
const consumer = compiled.map ? new SourceMapConsumer(compiled.map) : void 0;
|
|
286
|
+
problems.push(...placed(file, check(file, program), (node) => {
|
|
287
|
+
if (!node.loc || consumer === void 0) return void 0;
|
|
288
|
+
const original = consumer.originalPositionFor({
|
|
289
|
+
line: node.loc.start.line,
|
|
290
|
+
column: node.loc.start.column
|
|
291
|
+
});
|
|
292
|
+
return original.line === null ? void 0 : {
|
|
293
|
+
line: original.line,
|
|
294
|
+
column: original.column + 1
|
|
295
|
+
};
|
|
296
|
+
}));
|
|
297
|
+
return {
|
|
298
|
+
program,
|
|
299
|
+
consumer
|
|
300
|
+
};
|
|
301
|
+
};
|
|
302
|
+
return {
|
|
303
|
+
module,
|
|
304
|
+
compile
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* The checks of the components' code for the single-file components `files` (from the app's root) and the app's own
|
|
309
|
+
* TypeScript modules they import (`scope`): one line per problem, with the file and its place.
|
|
310
|
+
*/
|
|
311
|
+
function codeProblems(root, files, scope) {
|
|
312
|
+
const problems = [];
|
|
313
|
+
const code = codeChecker(root, scope, problems);
|
|
314
|
+
for (const file of files) {
|
|
315
|
+
const read = readSfc(root, file, problems);
|
|
316
|
+
if (read !== void 0) code.compile(file, read.descriptor);
|
|
317
|
+
}
|
|
318
|
+
return problems;
|
|
319
|
+
}
|
|
320
|
+
export { parse as a, sourceScope as c, nodes as i, codeProblems as n, readSfc as o, line as r, sfc as s, codeChecker as t };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Introduction and Goals
|
|
2
|
+
|
|
3
|
+
<!--
|
|
4
|
+
Arc42 chapter 1. Describes the relevant requirements and the driving forces that
|
|
5
|
+
architects and the development team must consider.
|
|
6
|
+
|
|
7
|
+
This chapter has three subsections:
|
|
8
|
+
|
|
9
|
+
1.1 Requirements Overview
|
|
10
|
+
Short description of the functional requirements and business goals — not a full
|
|
11
|
+
requirements spec. Write a compact summary or refer to an external requirements
|
|
12
|
+
document. A brief prose paragraph and/or a table of the most important use cases.
|
|
13
|
+
|
|
14
|
+
1.2 Quality Goals
|
|
15
|
+
The top three to five quality goals for the architecture (highest-importance only).
|
|
16
|
+
Write a single sentence or short paragraph, then link to chapter 10.
|
|
17
|
+
Do NOT repeat the quality-goal blocks here — they live in 10-quality-requirements.arc42.md.
|
|
18
|
+
|
|
19
|
+
Example:
|
|
20
|
+
|
|
21
|
+
See [10-quality-requirements.arc42.md](10-quality-requirements.arc42.md) for the
|
|
22
|
+
complete quality catalog with priorities and measurable scenarios.
|
|
23
|
+
|
|
24
|
+
1.3 Stakeholders
|
|
25
|
+
A table of all stakeholders: their role, contact, and expectations from the
|
|
26
|
+
architecture and its documentation. Think broadly — include teams that must
|
|
27
|
+
maintain the system, operators, auditors, and external partners.
|
|
28
|
+
|
|
29
|
+
See https://docs.arc42.org/section-1/ for further guidance.
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
|
|
33
|
+
## 1.1 Requirements Overview
|
|
34
|
+
|
|
35
|
+
Every app of lernapps.net is a small, frontend-only learning app, made by a creator with their AI assistant from one
|
|
36
|
+
of three archetypes: a quiz about things to know (a question bank), an explainer (pages of content with an exercise on
|
|
37
|
+
every page), or an interactive app in which doing is the content (a typing game, a Kniffel trainer, a proofreading
|
|
38
|
+
app). The archetypes stay separate, but they share their quality goals and so one architecture: this generic
|
|
39
|
+
architecture, which each archetype extends in its template folder (`quiz/docs/arc42/`, `explainer/docs/arc42/`,
|
|
40
|
+
`interactive/docs/arc42/`). Every app has the same frame: static pages rendered at build in every declared language,
|
|
41
|
+
the archetype's code in the browser started by a runtime the app does not change, build checks and end-to-end tests
|
|
42
|
+
that come with the runtime, the tooling's helpers, and an optional tutor, which hands the current task to the
|
|
43
|
+
learner's own AI assistant (no model runs in the browser). The app reaches learners through one entry in the
|
|
44
|
+
lernapps.net app overview, which links to it.
|
|
45
|
+
|
|
46
|
+
| Use case | Who | What the architecture must allow |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| Find out what the app does | learner, teacher, parent | static pages in every declared language; what each archetype shows without JavaScript is in its extension |
|
|
49
|
+
| Learn and practise | learner | the archetype's code in the browser, its state only on the device |
|
|
50
|
+
| Open a part directly | learner, catalog entry | deep links (a question, a page and task, a mode or level), a seed in the address |
|
|
51
|
+
| Build and extend the app | creator and their AI assistant | what the archetype lets the creator write, under rules the build and `lernapps check` verify |
|
|
52
|
+
| Get help with a task | learner | an optional handoff of the task to the learner's own assistant, after a click |
|
|
53
|
+
|
|
54
|
+
## 1.2 Quality Goals
|
|
55
|
+
|
|
56
|
+
The top quality goals, in this order:
|
|
57
|
+
|
|
58
|
+
- `qg-privacy`: the app sends nothing anywhere and requests nothing from another server before a click; what it
|
|
59
|
+
remembers stays on the learner's device.
|
|
60
|
+
- `qg-checkable`: the rules an app keeps are verified by machine, at build and in the browser.
|
|
61
|
+
- `qg-accessibility`: the pages meet WCAG 2.1 AA and work at 360 px by default; an app that cannot declares its
|
|
62
|
+
limits in its entry.
|
|
63
|
+
- `qg-understandable`: a reviewer or the creator's agent understands an app's structure from its architecture.
|
|
64
|
+
|
|
65
|
+
The set and the priorities (privacy and checkable high, accessibility and understandable medium) are the owner's
|
|
66
|
+
(2026-10-16). The quiz and the explainer, both text-heavy, add readability without JavaScript as a goal of their own
|
|
67
|
+
in their extensions.
|
|
68
|
+
|
|
69
|
+
See [10-quality-requirements.arc42.md](10-quality-requirements.arc42.md) for the complete
|
|
70
|
+
quality catalog with priorities and measurable scenarios.
|
|
71
|
+
|
|
72
|
+
## 1.3 Stakeholders
|
|
73
|
+
|
|
74
|
+
| Role | Contact | Expectations |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| Learner (adults and students, often in German schools) | — | An app that works right away, in the browser, without an account, that keeps no data off the device and does not overwhelm |
|
|
77
|
+
| Creator | the app's repo | To build and list an app with almost no effort, and to hear when it helped |
|
|
78
|
+
| The creator's AI assistant | the app's repo, the `lernapps-*` skills | A clear frame: which parts are fixed, what the archetype lets it write, which rules the build checks |
|
|
79
|
+
| Platform owner | Oliver Jägle (@mrsimpson), lernapps@beimir.net | Every app keeps the platform's rules (privacy, accessibility default, licences) so it can be listed without doubt |
|
|
80
|
+
| Teachers and parents | lernapps.net | To see from the pages and the entry what the app teaches and what it does not offer, and that it may be used |
|
|
81
|
+
| Reviewers (the review agent before listing, the owner who decides the listing) | `lernapps/apps` | An architecture that says what each part does and why, readable without the code |
|
|
82
|
+
| Maintainers of an archetype | `lernapps/app-templates` | One generic architecture and one extension per archetype, so a change to the frame is made once |
|