@lernapps/app-templates 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +195 -2
- package/dist/appname-Bh23cUuz.mjs +12 -0
- package/dist/assessment-By2GGTJC.mjs +1696 -0
- package/dist/block-BK_ohsH1.mjs +82 -0
- package/dist/blocks-BmRrD7UY.mjs +149 -0
- package/dist/blocks.mjs +4 -0
- package/dist/check-BkEl-m-F.mjs +254 -0
- package/dist/content-cYBPYg_J.mjs +140 -0
- package/dist/contract-PONpxJtd.mjs +52 -0
- package/dist/e2e-BiMI-pCL.mjs +89 -0
- package/dist/engine-BqF2Vffn.mjs +136 -0
- package/dist/explainer-e2e.mjs +1297 -0
- package/dist/explainer-islands.mjs +197 -0
- package/dist/explainer-plugin.mjs +2131 -0
- package/dist/explainer.mjs +1246 -0
- package/dist/html-mffkHGHh.mjs +89 -0
- package/dist/image-CspJVfyQ.mjs +21 -0
- package/dist/interactive-e2e.mjs +140 -0
- package/dist/interactive-plugin.mjs +242 -0
- package/dist/interactive.mjs +75 -0
- package/dist/load-C-0hUaSC.mjs +16947 -0
- package/dist/needs-BzEahJ6X.mjs +97 -0
- package/dist/pages-9RaaomoO.mjs +81 -0
- package/dist/questions-e2e.mjs +1 -0
- package/dist/questions-engine.mjs +3 -0
- package/dist/questions.mjs +6 -0
- package/dist/quiz-BKc8JYtC.mjs +70 -0
- package/dist/quiz-e2e.mjs +174 -0
- package/dist/quiz-plugin.mjs +266 -0
- package/dist/quiz.mjs +246 -0
- package/dist/render-BJey0bwl.mjs +540 -0
- package/dist/render-BL6fV_NI.mjs +507 -0
- package/dist/render-DpRO_RKm.mjs +146 -0
- package/dist/result-_eQcamoz.mjs +180 -0
- package/dist/tasks-DADUYy52.mjs +74 -0
- package/dist/validate-COaZso7u.mjs +1072 -0
- package/dist/validate-CxtdeQ5S.mjs +146 -0
- package/dist/vue-DO1m6q1R.mjs +40 -0
- package/dist/vue-code-BkJjW_I_.mjs +320 -0
- package/docs/arc42/01-introduction-and-goals.arc42.md +82 -0
- package/docs/arc42/02-constraints.arc42.md +228 -0
- package/docs/arc42/03-system-context.arc42.md +118 -0
- package/docs/arc42/04-solution-strategy.arc42.md +47 -0
- package/docs/arc42/05-building-blocks.arc42.md +307 -0
- package/docs/arc42/06-runtime-view.arc42.md +98 -0
- package/docs/arc42/07-deployment-view.arc42.md +160 -0
- package/docs/arc42/08-concepts.arc42.md +123 -0
- package/docs/arc42/09-decisions.arc42.md +246 -0
- package/docs/arc42/10-quality-requirements.arc42.md +155 -0
- package/docs/arc42/11-risks.arc42.md +113 -0
- package/docs/arc42/12-glossary.arc42.md +162 -0
- package/docs/arc42/architecture-evidence.md +395 -0
- package/explainer/.arc42ignore +3 -0
- package/explainer/README.md +54 -0
- package/explainer/_gitignore +5 -0
- package/explainer/docs/arc42/03-explainer-system-context.arc42.md +35 -0
- package/explainer/docs/arc42/05-explainer-building-blocks.arc42.md +659 -0
- package/explainer/docs/arc42/06-explainer-runtime-view.arc42.md +105 -0
- package/explainer/docs/arc42/07-explainer-deployment-view.arc42.md +34 -0
- package/explainer/docs/arc42/08-explainer-concepts.arc42.md +17 -0
- package/explainer/docs/arc42/09-explainer-decisions.arc42.md +443 -0
- package/explainer/docs/arc42/10-explainer-quality-requirements.arc42.md +54 -0
- package/explainer/docs/arc42/11-explainer-risks.arc42.md +17 -0
- package/explainer/docs/arc42/12-explainer-glossary.arc42.md +185 -0
- package/explainer/e2e/explainer.e2e.ts +3 -0
- package/explainer/index.html +11 -0
- package/explainer/package.json +18 -0
- package/explainer/playwright.config.ts +1 -0
- package/explainer/src/app.yaml +20 -0
- package/explainer/src/components/ProzentFeld.test.ts +30 -0
- package/explainer/src/components/ProzentFeld.ts +121 -0
- package/explainer/src/components/ProzentFeld.vue +84 -0
- package/explainer/src/images/hunderterfeld-klammern.svg +105 -0
- package/explainer/src/main.ts +4 -0
- package/explainer/src/prozent/chapter.yaml +18 -0
- package/explainer/src/prozent/grundwert.yaml +115 -0
- package/explainer/src/prozent/prozentsatz.yaml +99 -0
- package/explainer/src/prozent/prozentwert.yaml +119 -0
- package/explainer/tsconfig.json +1 -0
- package/explainer/vite.config.ts +4 -0
- package/interactive/.arc42ignore +5 -0
- package/interactive/README.md +28 -0
- package/interactive/_gitignore +4 -0
- package/interactive/docs/arc42/05-interactive-building-blocks.arc42.md +518 -0
- package/interactive/docs/arc42/06-interactive-runtime-view.arc42.md +58 -0
- package/interactive/docs/arc42/07-interactive-deployment-view.arc42.md +35 -0
- package/interactive/docs/arc42/08-interactive-concepts.arc42.md +52 -0
- package/interactive/docs/arc42/09-interactive-decisions.arc42.md +130 -0
- package/interactive/docs/arc42/10-interactive-quality-requirements.arc42.md +45 -0
- package/interactive/docs/arc42/11-interactive-risks.arc42.md +20 -0
- package/interactive/docs/arc42/12-interactive-glossary.arc42.md +80 -0
- package/interactive/e2e/interactive.e2e.ts +3 -0
- package/interactive/index.html +11 -0
- package/interactive/package.json +18 -0
- package/interactive/playwright.config.ts +1 -0
- package/interactive/src/app.yaml +28 -0
- package/interactive/src/core/index.ts +101 -0
- package/interactive/src/data/lessons.json +4 -0
- package/interactive/src/env.d.ts +6 -0
- package/interactive/src/main.ts +6 -0
- package/interactive/src/messages/de.json +12 -0
- package/interactive/src/playthroughs/grundreihe.yaml +33 -0
- package/interactive/src/playthroughs/obere-reihe.yaml +14 -0
- package/interactive/src/ui/App.vue +85 -0
- package/interactive/tsconfig.json +1 -0
- package/interactive/vite.config.ts +4 -0
- package/package.json +144 -4
- package/quiz/README.md +31 -0
- package/quiz/_gitignore +5 -0
- package/quiz/docs/arc42/05-quiz-building-blocks.arc42.md +283 -0
- package/quiz/docs/arc42/06-quiz-runtime-view.arc42.md +21 -0
- package/quiz/docs/arc42/07-quiz-deployment-view.arc42.md +33 -0
- package/quiz/docs/arc42/09-quiz-decisions.arc42.md +36 -0
- package/quiz/docs/arc42/10-quiz-quality-requirements.arc42.md +53 -0
- package/quiz/docs/arc42/11-quiz-risks.arc42.md +19 -0
- package/quiz/docs/arc42/12-quiz-glossary.arc42.md +27 -0
- package/quiz/e2e/quiz.e2e.ts +3 -0
- package/quiz/index.html +11 -0
- package/quiz/package.json +18 -0
- package/quiz/playwright.config.ts +1 -0
- package/quiz/src/images/berge-ordnen.jpg +0 -0
- package/quiz/src/images/everest-hoehe.jpg +0 -0
- package/quiz/src/images/galapagos.jpg +0 -0
- package/quiz/src/images/grand-canyon.jpg +0 -0
- package/quiz/src/images/totes-meer.jpg +0 -0
- package/quiz/src/images/uluru.jpg +0 -0
- package/quiz/src/images/wasserfaelle-laender.jpg +0 -0
- package/quiz/src/main.ts +4 -0
- package/quiz/src/quiz.json +312 -0
- package/quiz/tsconfig.json +1 -0
- package/quiz/vite.config.ts +4 -0
- package/runtime/explainer/assessment-client.ts +478 -0
- package/runtime/explainer/assessment-page.ts +446 -0
- package/runtime/explainer/assessment.ts +393 -0
- package/runtime/explainer/check-page.ts +520 -0
- package/runtime/explainer/components/exercise.ts +409 -0
- package/runtime/explainer/components/props.ts +230 -0
- package/runtime/explainer/components/read.ts +428 -0
- package/runtime/explainer/content.ts +277 -0
- package/runtime/explainer/e2e.ts +1474 -0
- package/runtime/explainer/exercises.ts +324 -0
- package/runtime/explainer/explainer.css +1283 -0
- package/runtime/explainer/explainer.ts +838 -0
- package/runtime/explainer/explainer.v1.schema.json +1165 -0
- package/runtime/explainer/expression.ts +407 -0
- package/runtime/explainer/fraction.ts +107 -0
- package/runtime/explainer/generated/comparator.ts +31 -0
- package/runtime/explainer/generated/question.ts +165 -0
- package/runtime/explainer/generated/soundness.ts +280 -0
- package/runtime/explainer/generated/texts.ts +257 -0
- package/runtime/explainer/generated/types.ts +103 -0
- package/runtime/explainer/generated/values.ts +136 -0
- package/runtime/explainer/islands.ts +281 -0
- package/runtime/explainer/load.ts +457 -0
- package/runtime/explainer/messages/de.json +200 -0
- package/runtime/explainer/messages/en.json +200 -0
- package/runtime/explainer/messages.ts +13 -0
- package/runtime/explainer/needs.ts +122 -0
- package/runtime/explainer/open-task.ts +235 -0
- package/runtime/explainer/outline.ts +299 -0
- package/runtime/explainer/plugin.ts +488 -0
- package/runtime/explainer/position.ts +62 -0
- package/runtime/explainer/prompt.ts +75 -0
- package/runtime/explainer/render.ts +686 -0
- package/runtime/explainer/result.ts +238 -0
- package/runtime/explainer/routes.ts +119 -0
- package/runtime/explainer/schema.ts +140 -0
- package/runtime/explainer/scope.ts +87 -0
- package/runtime/explainer/stuck.ts +57 -0
- package/runtime/explainer/tasks.ts +109 -0
- package/runtime/explainer/tutor.ts +469 -0
- package/runtime/explainer/tutorpage.ts +669 -0
- package/runtime/explainer/video.ts +123 -0
- package/runtime/interactive/contract.ts +121 -0
- package/runtime/interactive/e2e.ts +190 -0
- package/runtime/interactive/interactive.css +82 -0
- package/runtime/interactive/interactive.ts +115 -0
- package/runtime/interactive/interactive.v1.schema.json +146 -0
- package/runtime/interactive/load.ts +188 -0
- package/runtime/interactive/messages/de.json +14 -0
- package/runtime/interactive/messages/en.json +14 -0
- package/runtime/interactive/messages.ts +11 -0
- package/runtime/interactive/playthrough.v1.schema.json +143 -0
- package/runtime/interactive/plugin.ts +266 -0
- package/runtime/interactive/render.ts +126 -0
- package/runtime/quiz/e2e.ts +233 -0
- package/runtime/quiz/engine.ts +242 -0
- package/runtime/quiz/messages/de.json +23 -0
- package/runtime/quiz/messages/en.json +23 -0
- package/runtime/quiz/plugin.ts +150 -0
- package/runtime/quiz/quiz.css +154 -0
- package/runtime/quiz/quiz.ts +56 -0
- package/runtime/quiz/quiz.v1.schema.json +72 -0
- package/runtime/quiz/render.ts +173 -0
- package/runtime/quiz/validate.ts +108 -0
- package/runtime/shared/appname.ts +16 -0
- package/runtime/shared/blocks/block.ts +162 -0
- package/runtime/shared/blocks/blocks.css +190 -0
- package/runtime/shared/blocks/blocks.v1.schema.json +432 -0
- package/runtime/shared/blocks/code.ts +26 -0
- package/runtime/shared/blocks/figures.ts +49 -0
- package/runtime/shared/blocks/image.ts +25 -0
- package/runtime/shared/blocks/index.ts +13 -0
- package/runtime/shared/blocks/messages/de.json +4 -0
- package/runtime/shared/blocks/messages/en.json +4 -0
- package/runtime/shared/blocks/render.ts +134 -0
- package/runtime/shared/blocks/validate.ts +165 -0
- package/runtime/shared/content.ts +197 -0
- package/runtime/shared/examples.ts +20 -0
- package/runtime/shared/html.ts +10 -0
- package/runtime/shared/languages.ts +94 -0
- package/runtime/shared/page.css +101 -0
- package/runtime/shared/pages.ts +114 -0
- package/runtime/shared/questions/e2e.ts +115 -0
- package/runtime/shared/questions/engine.ts +209 -0
- package/runtime/shared/questions/index.ts +39 -0
- package/runtime/shared/questions/messages/de.json +26 -0
- package/runtime/shared/questions/messages/en.json +26 -0
- package/runtime/shared/questions/question.ts +430 -0
- package/runtime/shared/questions/question.v1.schema.json +342 -0
- package/runtime/shared/questions/questions.css +192 -0
- package/runtime/shared/questions/render.ts +247 -0
- package/runtime/shared/questions/validate.ts +518 -0
- package/runtime/shared/schema.ts +44 -0
- package/runtime/shared/text/check.ts +68 -0
- package/runtime/shared/text/formula.ts +69 -0
- package/runtime/shared/text/markdown.ts +185 -0
- package/runtime/shared/text/text.v1.schema.json +34 -0
- package/runtime/shared/vue-code.ts +395 -0
- package/runtime/shared/vue.ts +38 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// The components of an explainer at build time (src/components/<Name>.vue, vue-component blocks): what the build reads
|
|
2
|
+
// from each single-file component with Vue's own SFC compiler, and what it checks there. Every component has
|
|
3
|
+
// <script setup lang="ts"> and its test file <Name>.test.ts. The checks run on the compiler's output, the script with
|
|
4
|
+
// the template compiled into it, mapped back to the line and column of the .vue file, and on the TypeScript modules of
|
|
5
|
+
// the component's folder that it imports: no network (fetch, XMLHttpRequest, WebSocket, EventSource, sendBeacon, an
|
|
6
|
+
// import from a URL), no code or HTML from strings (eval, new Function, innerHTML, v-html), imports only from vue, the
|
|
7
|
+
// runtime @lernapps/app-templates, @lernapps/tooling/storage, i18n and a11y and the component's own folder, and browser
|
|
8
|
+
// globals (window, document, localStorage, …) only inside onMounted and the hooks that run after it, because the build
|
|
9
|
+
// renders the component without a browser. A component that emits answer is an exercise: it is the input of its
|
|
10
|
+
// task and nothing more. It declares the prop seed: number, the task number the explainer gives it (tasks.ts), shows
|
|
11
|
+
// the task generate(seed, props), and emits answer with the learner's answer each time it changes (undefined while there
|
|
12
|
+
// is none); it may declare the prop checked: boolean, true once the explainer checked the answer it has now. The
|
|
13
|
+
// explainer draws "Prüfen" under it, calls check, reason and show of its logic module, and gives the feedback. Its
|
|
14
|
+
// logic module <Name>.ts exports generate(seed, props), check(task, answer), solution(task), wrong(task), which the
|
|
15
|
+
// build tests over fixed seeds (propertyProblems), reason(task, answer, props, lang), the app's message for a wrong
|
|
16
|
+
// answer, describe(task, props, lang), the task and its solution as a learner reads them, and about, what the
|
|
17
|
+
// exercise is, for a tutor (describedProblems). A component keeps its nojs text, folded as a
|
|
18
|
+
// text description under it, when it declares defineOptions({ nojs: "caption" }); by default it replaces it.
|
|
19
|
+
//
|
|
20
|
+
// The interactive archetype runs the same code checks on its components (codeProblems, with sourceScope): they may
|
|
21
|
+
// import any of the app's own files in src/ instead of their own folder only, and the libraries the app lists in the
|
|
22
|
+
// dependencies of its package.json (a bare import of such a package, bundled by the build; their licences are
|
|
23
|
+
// lernapps check's dependency-licence). An import from a URL stays forbidden.
|
|
24
|
+
//
|
|
25
|
+
// The props of a block are checked against the component's prop types (defineProps<…>()), which the SFC compiler
|
|
26
|
+
// resolves, as Vue itself does for its runtime props, rather than vue-component-meta, which needs TypeScript's
|
|
27
|
+
// language service: the types a block can give from YAML are data, which the compiler's type resolution reads in
|
|
28
|
+
// milliseconds, without a program. A prop type is a string, number or boolean, a literal type, a union of these, an
|
|
29
|
+
// array, or an object type of them (inline, an interface or type of the component, or an object type it imports); a
|
|
30
|
+
// string is a learner text, given in YAML as a string or a map by language like every text of the explainer, and the
|
|
31
|
+
// component gets it in the page's language.
|
|
32
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
import { type Node, type Program } from "@babel/types";
|
|
35
|
+
import { type ComponentInfo, type Field, type PropType } from "../content.ts";
|
|
36
|
+
import {
|
|
37
|
+
codeChecker,
|
|
38
|
+
type CodeScope,
|
|
39
|
+
line,
|
|
40
|
+
nodes,
|
|
41
|
+
parse,
|
|
42
|
+
type Place,
|
|
43
|
+
readSfc,
|
|
44
|
+
sfc,
|
|
45
|
+
type Sfc,
|
|
46
|
+
} from "../../shared/vue-code.ts";
|
|
47
|
+
import { CONTRACT } from "./exercise.ts";
|
|
48
|
+
|
|
49
|
+
type TypeResolveContext = Parameters<Sfc["resolveTypeElements"]>[0];
|
|
50
|
+
|
|
51
|
+
/** The budget of a component's own code, gzipped, in bytes; Vue is loaded once per page and not counted. */
|
|
52
|
+
export const SIZE_BUDGET = 20_000;
|
|
53
|
+
|
|
54
|
+
const NAME = /^[A-Z][A-Za-z0-9]*$/;
|
|
55
|
+
const EXERCISE = ["generate", "check", "solution", "wrong", "reason", "describe", "about"];
|
|
56
|
+
|
|
57
|
+
/** The scope of an explainer's components: the files of src/components/ itself. */
|
|
58
|
+
const componentsScope = (root: string): CodeScope => ({
|
|
59
|
+
own: "its own folder",
|
|
60
|
+
resolve(_file, source) {
|
|
61
|
+
if (!source.startsWith("./") || source.slice(2).includes("/")) return undefined;
|
|
62
|
+
return existsSync(join(root, "src/components", source.slice(2)))
|
|
63
|
+
? { file: `src/components/${source.slice(2)}` }
|
|
64
|
+
: { problem: `${source} does not exist in src/components/; import a file of the folder, with its extension` };
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** The names a module exports. */
|
|
69
|
+
function exported(program: Program): Set<string> {
|
|
70
|
+
const names = new Set<string>();
|
|
71
|
+
for (const statement of program.body) {
|
|
72
|
+
if (statement.type !== "ExportNamedDeclaration" || statement.exportKind === "type") continue;
|
|
73
|
+
const declaration = statement.declaration;
|
|
74
|
+
if (declaration?.type === "FunctionDeclaration" && declaration.id) names.add(declaration.id.name);
|
|
75
|
+
if (declaration?.type === "VariableDeclaration") {
|
|
76
|
+
for (const variable of declaration.declarations)
|
|
77
|
+
if (variable.id.type === "Identifier") names.add(variable.id.name);
|
|
78
|
+
}
|
|
79
|
+
for (const specifier of statement.specifiers) {
|
|
80
|
+
if (specifier.type === "ExportSpecifier" && specifier.exported.type === "Identifier") {
|
|
81
|
+
names.add(specifier.exported.name);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return names;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The component's prop types from defineProps<…>(); problems for what a block could not give. */
|
|
89
|
+
function propTypes(
|
|
90
|
+
file: string,
|
|
91
|
+
path: string,
|
|
92
|
+
content: string,
|
|
93
|
+
startLine: number,
|
|
94
|
+
): { props: Record<string, Field>; problems: string[] } {
|
|
95
|
+
const { babelParse, resolveTypeElements } = sfc();
|
|
96
|
+
const program = babelParse(content, { sourceType: "module", plugins: ["typescript"] }).program as Program;
|
|
97
|
+
const problems: string[] = [];
|
|
98
|
+
const at = (node: Node): Place | undefined =>
|
|
99
|
+
node.loc ? { line: startLine + node.loc.start.line - 1, column: node.loc.start.column + 1 } : undefined;
|
|
100
|
+
const call = [...nodes(program)]
|
|
101
|
+
.map(([node]) => node)
|
|
102
|
+
.find(
|
|
103
|
+
(node) =>
|
|
104
|
+
node.type === "CallExpression" && node.callee.type === "Identifier" && node.callee.name === "defineProps",
|
|
105
|
+
);
|
|
106
|
+
if (call === undefined || call.type !== "CallExpression") return { props: {}, problems };
|
|
107
|
+
const declared = call.typeParameters?.params[0];
|
|
108
|
+
if (declared === undefined) {
|
|
109
|
+
problems.push(
|
|
110
|
+
line(
|
|
111
|
+
file,
|
|
112
|
+
at(call),
|
|
113
|
+
"declare the props with their types, defineProps<{ … }>(), so that the build can check each block's props (vue-component-props-valid)",
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
return { props: {}, problems };
|
|
117
|
+
}
|
|
118
|
+
const context = {
|
|
119
|
+
filename: path,
|
|
120
|
+
source: content,
|
|
121
|
+
ast: program.body,
|
|
122
|
+
options: { fs: { fileExists: existsSync, readFile: (name: string) => readFileSync(name, "utf8") } },
|
|
123
|
+
error(message: string): never {
|
|
124
|
+
throw new Error(message);
|
|
125
|
+
},
|
|
126
|
+
warn() {},
|
|
127
|
+
helper: (key: string) => key,
|
|
128
|
+
getString: (node: Node) => content.slice(node.start ?? 0, node.end ?? 0),
|
|
129
|
+
propsDestructuredBindings: {},
|
|
130
|
+
isCE: false,
|
|
131
|
+
} as unknown as TypeResolveContext;
|
|
132
|
+
type Scope = { types: Record<string, Node>; source: string; offset: number };
|
|
133
|
+
type Scoped = Node & { _ownerScope?: Scope };
|
|
134
|
+
/** A declaration as written in its file, on one line, e.g. "parts: string[]". */
|
|
135
|
+
const written = (node: Node, scope: Scope | undefined): string | undefined =>
|
|
136
|
+
scope === undefined ||
|
|
137
|
+
node.start === null ||
|
|
138
|
+
node.start === undefined ||
|
|
139
|
+
node.end === null ||
|
|
140
|
+
node.end === undefined
|
|
141
|
+
? undefined
|
|
142
|
+
: scope.source
|
|
143
|
+
.slice(node.start - scope.offset, node.end - scope.offset)
|
|
144
|
+
.replace(/[;,]$/, "")
|
|
145
|
+
.replace(/\s+/g, " ");
|
|
146
|
+
const unsupported = new Error("unsupported");
|
|
147
|
+
const convert = (node: Node, scope: Scoped["_ownerScope"]): PropType => {
|
|
148
|
+
switch (node.type) {
|
|
149
|
+
case "TSStringKeyword":
|
|
150
|
+
return { kind: "string" };
|
|
151
|
+
case "TSNumberKeyword":
|
|
152
|
+
return { kind: "number" };
|
|
153
|
+
case "TSBooleanKeyword":
|
|
154
|
+
return { kind: "boolean" };
|
|
155
|
+
case "TSUnknownKeyword":
|
|
156
|
+
return { kind: "unknown" };
|
|
157
|
+
case "TSLiteralType": {
|
|
158
|
+
const literal = node.literal;
|
|
159
|
+
if (
|
|
160
|
+
literal.type === "StringLiteral" ||
|
|
161
|
+
literal.type === "NumericLiteral" ||
|
|
162
|
+
literal.type === "BooleanLiteral"
|
|
163
|
+
) {
|
|
164
|
+
return { kind: "literal", values: [literal.value] };
|
|
165
|
+
}
|
|
166
|
+
if (literal.type === "UnaryExpression" && literal.argument.type === "NumericLiteral") {
|
|
167
|
+
return { kind: "literal", values: [-literal.argument.value] };
|
|
168
|
+
}
|
|
169
|
+
throw unsupported;
|
|
170
|
+
}
|
|
171
|
+
case "TSParenthesizedType":
|
|
172
|
+
case "TSTypeOperator":
|
|
173
|
+
return convert(node.typeAnnotation, scope);
|
|
174
|
+
case "TSArrayType":
|
|
175
|
+
return { kind: "array", of: convert(node.elementType, scope) };
|
|
176
|
+
case "TSUnionType": {
|
|
177
|
+
const members = node.types.map((member) => convert(member, scope));
|
|
178
|
+
if (members.every((member) => member.kind === "literal")) {
|
|
179
|
+
return {
|
|
180
|
+
kind: "literal",
|
|
181
|
+
values: members.flatMap((member) => (member.kind === "literal" ? member.values : [])),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return { kind: "union", of: members };
|
|
185
|
+
}
|
|
186
|
+
case "TSTypeReference": {
|
|
187
|
+
const name = node.typeName.type === "Identifier" ? node.typeName.name : undefined;
|
|
188
|
+
const [parameter] = node.typeParameters?.params ?? [];
|
|
189
|
+
if ((name === "Array" || name === "ReadonlyArray") && parameter !== undefined) {
|
|
190
|
+
return { kind: "array", of: convert(parameter, scope) };
|
|
191
|
+
}
|
|
192
|
+
const local = name === undefined ? undefined : scope?.types[name];
|
|
193
|
+
if (local?.type === "TSTypeAliasDeclaration") return convert(local.typeAnnotation, scope);
|
|
194
|
+
return object(node, scope);
|
|
195
|
+
}
|
|
196
|
+
case "TSTypeLiteral":
|
|
197
|
+
case "TSInterfaceBody":
|
|
198
|
+
return object(node, scope);
|
|
199
|
+
default:
|
|
200
|
+
throw unsupported;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const object = (node: Node, scope: Scoped["_ownerScope"]): PropType => {
|
|
204
|
+
let elements: ReturnType<Sfc["resolveTypeElements"]>;
|
|
205
|
+
try {
|
|
206
|
+
elements = resolveTypeElements(context, node as Parameters<Sfc["resolveTypeElements"]>[1], scope as never);
|
|
207
|
+
} catch {
|
|
208
|
+
throw unsupported;
|
|
209
|
+
}
|
|
210
|
+
if ((elements.calls ?? []).length > 0) throw unsupported;
|
|
211
|
+
return { kind: "object", fields: fields(elements.props) };
|
|
212
|
+
};
|
|
213
|
+
const fields = (elements: ReturnType<Sfc["resolveTypeElements"]>["props"]): Record<string, Field> => {
|
|
214
|
+
const result: Record<string, Field> = {};
|
|
215
|
+
for (const [name, element] of Object.entries(elements)) {
|
|
216
|
+
if (
|
|
217
|
+
element.type !== "TSPropertySignature" ||
|
|
218
|
+
element.typeAnnotation === null ||
|
|
219
|
+
element.typeAnnotation === undefined
|
|
220
|
+
) {
|
|
221
|
+
throw unsupported;
|
|
222
|
+
}
|
|
223
|
+
const owner = (element as unknown as Scoped)._ownerScope;
|
|
224
|
+
result[name] = {
|
|
225
|
+
type: convert(element.typeAnnotation.typeAnnotation, owner),
|
|
226
|
+
optional: element.optional === true,
|
|
227
|
+
source: written(element, owner) ?? name,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
return result;
|
|
231
|
+
};
|
|
232
|
+
try {
|
|
233
|
+
const elements = resolveTypeElements(context, declared as Parameters<Sfc["resolveTypeElements"]>[1]);
|
|
234
|
+
const props: Record<string, Field> = {};
|
|
235
|
+
for (const [name, element] of Object.entries(elements.props)) {
|
|
236
|
+
try {
|
|
237
|
+
Object.assign(props, fields({ [name]: element }));
|
|
238
|
+
} catch {
|
|
239
|
+
problems.push(
|
|
240
|
+
line(
|
|
241
|
+
file,
|
|
242
|
+
at(element),
|
|
243
|
+
`the prop ${name} has a type that a block cannot give; use string, number, boolean, literal types, and arrays and object types of them (vue-component-props-valid)`,
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { props, problems };
|
|
249
|
+
} catch (error) {
|
|
250
|
+
problems.push(
|
|
251
|
+
line(
|
|
252
|
+
file,
|
|
253
|
+
at(declared),
|
|
254
|
+
`the build cannot read the prop types (${error instanceof Error ? error.message.split("\n")[0] : String(error)}); declare them as an object type (vue-component-props-valid)`,
|
|
255
|
+
),
|
|
256
|
+
);
|
|
257
|
+
return { props: {}, problems };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The components in `root`/src/components, and the problems of each: one line each, with the file and its place. */
|
|
262
|
+
export function readComponents(root: string): {
|
|
263
|
+
components: Map<string, ComponentInfo>;
|
|
264
|
+
/** Every component of the folder, valid or not. */
|
|
265
|
+
names: Set<string>;
|
|
266
|
+
problems: string[];
|
|
267
|
+
} {
|
|
268
|
+
const dir = join(root, "src/components");
|
|
269
|
+
const components = new Map<string, ComponentInfo>();
|
|
270
|
+
const names = new Set<string>();
|
|
271
|
+
const problems: string[] = [];
|
|
272
|
+
const files = existsSync(dir) ? readdirSync(dir).sort() : [];
|
|
273
|
+
for (const name of files) {
|
|
274
|
+
const base = name.replace(/(\.test)?\.(vue|ts)$/, "");
|
|
275
|
+
if (!NAME.test(base) || !/^[A-Za-z0-9]+(\.test\.ts|\.ts|\.vue)$/.test(name)) {
|
|
276
|
+
problems.push(
|
|
277
|
+
`src/components/${name}: not part of a component; src/components/ holds <Name>.vue, its logic <Name>.ts and its test <Name>.test.ts, each Name in PascalCase`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const code = codeChecker(root, componentsScope(root), problems);
|
|
282
|
+
for (const name of files.filter((candidate) => candidate.endsWith(".vue"))) {
|
|
283
|
+
const component = name.slice(0, -".vue".length);
|
|
284
|
+
if (!NAME.test(component)) continue;
|
|
285
|
+
names.add(component);
|
|
286
|
+
const file = `src/components/${name}`;
|
|
287
|
+
const path = join(dir, name);
|
|
288
|
+
const before = problems.length;
|
|
289
|
+
const read = readSfc(root, file, problems);
|
|
290
|
+
if (read === undefined) continue;
|
|
291
|
+
const { descriptor, setup } = read;
|
|
292
|
+
if (!existsSync(join(dir, `${component}.test.ts`))) {
|
|
293
|
+
problems.push(
|
|
294
|
+
line(
|
|
295
|
+
file,
|
|
296
|
+
undefined,
|
|
297
|
+
`its test file src/components/${component}.test.ts is missing; every component has one (vue-component-tested)`,
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
const compiled = code.compile(file, descriptor);
|
|
302
|
+
if (compiled === undefined) continue;
|
|
303
|
+
const { program, consumer } = compiled;
|
|
304
|
+
// what the compiled component declares: its emits and its options
|
|
305
|
+
const emitted = new Set<string>();
|
|
306
|
+
let caption = false;
|
|
307
|
+
for (const [node] of nodes(program)) {
|
|
308
|
+
if (node.type !== "ObjectProperty" || node.computed || node.key.type !== "Identifier") continue;
|
|
309
|
+
if (node.key.name === "emits") {
|
|
310
|
+
const value = node.value;
|
|
311
|
+
if (value.type === "ArrayExpression") {
|
|
312
|
+
for (const element of value.elements) if (element?.type === "StringLiteral") emitted.add(element.value);
|
|
313
|
+
} else if (value.type === "ObjectExpression") {
|
|
314
|
+
for (const property of value.properties) {
|
|
315
|
+
if (property.type !== "ObjectProperty") continue;
|
|
316
|
+
if (property.key.type === "Identifier") emitted.add(property.key.name);
|
|
317
|
+
else if (property.key.type === "StringLiteral") emitted.add(property.key.value);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} else if (node.key.name === "nojs") {
|
|
321
|
+
if (node.value.type === "StringLiteral" && (node.value.value === "caption" || node.value.value === "replace")) {
|
|
322
|
+
caption = node.value.value === "caption";
|
|
323
|
+
} else {
|
|
324
|
+
const original =
|
|
325
|
+
consumer && node.loc
|
|
326
|
+
? consumer.originalPositionFor({ line: node.loc.start.line, column: node.loc.start.column })
|
|
327
|
+
: undefined;
|
|
328
|
+
problems.push(
|
|
329
|
+
line(
|
|
330
|
+
file,
|
|
331
|
+
original?.line ? { line: original.line, column: original.column + 1 } : undefined,
|
|
332
|
+
`nojs is "caption" or "replace": whether the component keeps its nojs text, folded as a text description under it, once it runs, or replaces it`,
|
|
333
|
+
),
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const exercise = emitted.has("answer");
|
|
339
|
+
if (emitted.has("answered")) {
|
|
340
|
+
problems.push(
|
|
341
|
+
line(
|
|
342
|
+
file,
|
|
343
|
+
undefined,
|
|
344
|
+
`it emits answered, which the explainer no longer takes: an exercise component is the input of its task; emit answer with the learner's answer each time it changes, drop its own "Prüfen" and the call of check, and export reason(task, answer, props, lang) from its logic module, which gives the app's message for a wrong answer; the explainer draws "Prüfen", checks and gives the feedback (vue-component-exercise-contract)`,
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
if (exercise && caption) {
|
|
349
|
+
problems.push(
|
|
350
|
+
line(
|
|
351
|
+
file,
|
|
352
|
+
undefined,
|
|
353
|
+
`it emits answer, so it is an exercise, and keeps its nojs text, which gives the task with its solution: an exercise replaces its nojs text, so the solution shows only after the answer; remove nojs: "caption" from defineOptions (vue-component-nojs)`,
|
|
354
|
+
),
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
const props = propTypes(file, path, setup.content, setup.loc.start.line);
|
|
358
|
+
problems.push(...props.problems);
|
|
359
|
+
let logic: string | undefined;
|
|
360
|
+
const blockProps: Record<string, Field> = { ...props.props };
|
|
361
|
+
// whether the explainer tells it that the answer it has is checked
|
|
362
|
+
const checked = exercise && blockProps["checked"] !== undefined;
|
|
363
|
+
if (exercise) {
|
|
364
|
+
const flag = blockProps["checked"];
|
|
365
|
+
delete blockProps["checked"];
|
|
366
|
+
if (flag !== undefined && flag.type.kind !== "boolean") {
|
|
367
|
+
problems.push(
|
|
368
|
+
line(
|
|
369
|
+
file,
|
|
370
|
+
undefined,
|
|
371
|
+
`its prop checked is no boolean; the explainer gives an exercise component checked: true once it checked the answer the component has now, and false when the answer changes, so declare checked: boolean (vue-component-exercise-contract)`,
|
|
372
|
+
),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
// the task number comes from the explainer, not from a block
|
|
376
|
+
const seed = blockProps["seed"];
|
|
377
|
+
delete blockProps["seed"];
|
|
378
|
+
if (seed?.type.kind !== "number" || seed.optional) {
|
|
379
|
+
problems.push(
|
|
380
|
+
line(
|
|
381
|
+
file,
|
|
382
|
+
undefined,
|
|
383
|
+
`it emits answer, so it is an exercise, and declares no prop seed: number; the explainer gives an exercise component the seed of its task number as the prop seed, shows it as "Aufgabe Nr." with "Neue Aufgabe" and keeps it in the address, so declare seed: number and show the task generate(seed, props) (vue-component-exercise-contract)`,
|
|
384
|
+
),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
logic = join(dir, `${component}.ts`);
|
|
388
|
+
const contract = CONTRACT;
|
|
389
|
+
if (!existsSync(logic)) {
|
|
390
|
+
problems.push(
|
|
391
|
+
line(
|
|
392
|
+
file,
|
|
393
|
+
undefined,
|
|
394
|
+
`it emits answer, so it is an exercise, and its logic module src/components/${component}.ts is missing: export ${contract} from it (vue-component-exercise-contract)`,
|
|
395
|
+
),
|
|
396
|
+
);
|
|
397
|
+
} else {
|
|
398
|
+
// the build runs it, so it is checked as the component's code even when the component does not import it
|
|
399
|
+
code.module(`src/components/${component}.ts`);
|
|
400
|
+
const names = exported(parse(readFileSync(logic, "utf8")));
|
|
401
|
+
const lacking = EXERCISE.filter((function_) => !names.has(function_));
|
|
402
|
+
if (lacking.length > 0) {
|
|
403
|
+
problems.push(
|
|
404
|
+
line(
|
|
405
|
+
`src/components/${component}.ts`,
|
|
406
|
+
undefined,
|
|
407
|
+
`exports no ${lacking.join(", ")}; the logic module of an exercise component exports ${contract} (vue-component-exercise-contract)`,
|
|
408
|
+
),
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (problems.length > before) continue;
|
|
414
|
+
components.set(component, {
|
|
415
|
+
name: component,
|
|
416
|
+
file,
|
|
417
|
+
path,
|
|
418
|
+
props: blockProps,
|
|
419
|
+
exercise,
|
|
420
|
+
checked,
|
|
421
|
+
caption,
|
|
422
|
+
...(logic === undefined ? {} : { logic }),
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
return { components, names, problems };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** A problem with a block's props, at a path below props. */
|