@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,69 @@
|
|
|
1
|
+
// Formulas at build time: the LaTeX subset of the formula command list, checked, then rendered by Temml to MathML,
|
|
2
|
+
// which current browsers show and screen readers read without a script or a font. \ce{…} and \pu{…} come from
|
|
3
|
+
// Temml's mhchem extension, which registers itself on a global temml; it is loaded once, here.
|
|
4
|
+
import temml from "temml";
|
|
5
|
+
|
|
6
|
+
(globalThis as { temml?: typeof temml }).temml = temml;
|
|
7
|
+
const mhchem: string = "temml/contrib/mhchem/mhchem.js";
|
|
8
|
+
await import(mhchem);
|
|
9
|
+
|
|
10
|
+
/** The commands a formula may use (explainer.v1.schema.json, $defs/latex), besides ^, _, braces and parentheses. */
|
|
11
|
+
export const COMMANDS: readonly string[] = [
|
|
12
|
+
"\\frac",
|
|
13
|
+
"\\cdot",
|
|
14
|
+
"\\times",
|
|
15
|
+
"\\div",
|
|
16
|
+
"\\%",
|
|
17
|
+
"\\text",
|
|
18
|
+
"\\sqrt",
|
|
19
|
+
"\\approx",
|
|
20
|
+
"\\pm",
|
|
21
|
+
"\\le",
|
|
22
|
+
"\\ge",
|
|
23
|
+
"\\neq",
|
|
24
|
+
"\\,",
|
|
25
|
+
"\\{",
|
|
26
|
+
"\\}",
|
|
27
|
+
"\\ce",
|
|
28
|
+
"\\pu",
|
|
29
|
+
];
|
|
30
|
+
const allowed = new Set(COMMANDS);
|
|
31
|
+
|
|
32
|
+
/** The commands of `tex` outside the arguments of \ce and \pu, which mhchem reads in its own notation. */
|
|
33
|
+
function commands(tex: string): string[] {
|
|
34
|
+
const found: string[] = [];
|
|
35
|
+
for (let at = 0; at < tex.length; at++) {
|
|
36
|
+
if (tex[at] !== "\\") continue;
|
|
37
|
+
const word = /^\\(?:[a-zA-Z]+|.)/.exec(tex.slice(at))?.[0] ?? "\\";
|
|
38
|
+
found.push(word);
|
|
39
|
+
at += word.length - 1;
|
|
40
|
+
if ((word === "\\ce" || word === "\\pu") && tex[at + 1] === "{") {
|
|
41
|
+
let depth = 0;
|
|
42
|
+
for (at++; at < tex.length; at++) {
|
|
43
|
+
if (tex[at] === "{") depth++;
|
|
44
|
+
else if (tex[at] === "}" && --depth === 0) break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return found;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What is wrong with the formula `tex`, or undefined when it is in the command list and parses. */
|
|
52
|
+
export function formulaProblem(tex: string): string | undefined {
|
|
53
|
+
const unknown = commands(tex).find((command) => !allowed.has(command));
|
|
54
|
+
if (unknown !== undefined) {
|
|
55
|
+
return `${unknown} is not in the formula command list; write the formula with the commands the schema lists ($defs/latex)`;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
temml.renderToString(tex, { throwOnError: true });
|
|
59
|
+
return undefined;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const message = (error instanceof Error ? error.message : String(error)).split("\n")[0]?.trim() ?? "";
|
|
62
|
+
return `the formula does not parse: ${message}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The formula as MathML: a <math> element, on its own line when `display`. */
|
|
67
|
+
export function renderFormula(tex: string, display: boolean): string {
|
|
68
|
+
return temml.renderToString(tex, { displayMode: display, throwOnError: true });
|
|
69
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Restricted Markdown, the format of every learner text with formatting: paragraphs, **bold**, *italics*, lists,
|
|
2
|
+
// links, line breaks and formulas as $…$. Everything else Markdown knows is a problem with the block to use instead,
|
|
3
|
+
// so that structure comes only from section blocks and the heading levels from the nesting. Raw HTML is a problem
|
|
4
|
+
// too. Rendered at build time; formulas become MathML.
|
|
5
|
+
import { Marked, type Token, type Tokens } from "marked";
|
|
6
|
+
import { formulaProblem, renderFormula } from "./formula.ts";
|
|
7
|
+
import { escape } from "../html.ts";
|
|
8
|
+
|
|
9
|
+
/** A problem in a text, `line` lines below its first. */
|
|
10
|
+
export interface TextProblem {
|
|
11
|
+
line: number;
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** What a text holds besides its prose: its problems, the formulas and the targets of its links. */
|
|
16
|
+
export interface Inspection {
|
|
17
|
+
problems: TextProblem[];
|
|
18
|
+
formulas: string[];
|
|
19
|
+
links: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface MathToken extends Tokens.Generic {
|
|
23
|
+
type: "math";
|
|
24
|
+
raw: string;
|
|
25
|
+
tex: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Where a link leads from the page being rendered; set for each render. */
|
|
29
|
+
let resolveLink: (href: string) => string = (href) => href;
|
|
30
|
+
|
|
31
|
+
const marked = new Marked({
|
|
32
|
+
gfm: true,
|
|
33
|
+
breaks: false,
|
|
34
|
+
extensions: [
|
|
35
|
+
{
|
|
36
|
+
name: "math",
|
|
37
|
+
level: "inline",
|
|
38
|
+
start(source: string) {
|
|
39
|
+
const at = source.indexOf("$");
|
|
40
|
+
return at < 0 ? undefined : at;
|
|
41
|
+
},
|
|
42
|
+
tokenizer(source: string): MathToken | undefined {
|
|
43
|
+
const match = /^\$((?:\\.|[^\\$\n])+?)\$/.exec(source);
|
|
44
|
+
return match === null ? undefined : { type: "math", raw: match[0], tex: match[1] ?? "" };
|
|
45
|
+
},
|
|
46
|
+
renderer(token: Tokens.Generic) {
|
|
47
|
+
return renderFormula(String(token["tex"]), false);
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
renderer: {
|
|
52
|
+
html(token) {
|
|
53
|
+
return escape(token.text);
|
|
54
|
+
},
|
|
55
|
+
link({ href, tokens }) {
|
|
56
|
+
const text = this.parser.parseInline(tokens);
|
|
57
|
+
const external = /^https?:\/\//.test(href);
|
|
58
|
+
return `<a href="${escape(external ? href : resolveLink(href))}"${external ? ' rel="noopener"' : ""}>${text}</a>`;
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** What each construct outside restricted Markdown is, and what to use instead. */
|
|
64
|
+
const NOT_ALLOWED: Readonly<Record<string, string>> = {
|
|
65
|
+
blockquote: "a quotation in Markdown; use a quote block",
|
|
66
|
+
code: "code in a text; use a code block",
|
|
67
|
+
codespan: "code in a text; use a code block, or $…$ for a formula",
|
|
68
|
+
def: "a link definition; write the link in place: [text](url)",
|
|
69
|
+
del: "strikethrough is not part of restricted Markdown",
|
|
70
|
+
hr: "a horizontal rule; structure the page with section blocks",
|
|
71
|
+
image: "an image in a text; use a figure block",
|
|
72
|
+
table: "a table in Markdown; use a table block",
|
|
73
|
+
checkbox: "a task list is not part of restricted Markdown",
|
|
74
|
+
};
|
|
75
|
+
const ALLOWED = new Set([
|
|
76
|
+
"space",
|
|
77
|
+
"paragraph",
|
|
78
|
+
"text",
|
|
79
|
+
"list",
|
|
80
|
+
"list_item",
|
|
81
|
+
"strong",
|
|
82
|
+
"em",
|
|
83
|
+
"link",
|
|
84
|
+
"escape",
|
|
85
|
+
"br",
|
|
86
|
+
"math",
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
function children(token: Token): Token[] {
|
|
90
|
+
const nested: Token[] = [];
|
|
91
|
+
if ("tokens" in token && Array.isArray(token.tokens)) nested.push(...(token.tokens as Token[]));
|
|
92
|
+
if (token.type === "list") nested.push(...token.items);
|
|
93
|
+
return nested;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The problems, formulas and links of a text in restricted Markdown. */
|
|
97
|
+
export function inspect(source: string): Inspection {
|
|
98
|
+
const found: Inspection = { problems: [], formulas: [], links: [] };
|
|
99
|
+
const lineOf = (raw: string) => {
|
|
100
|
+
const at = source.indexOf(raw.trimStart());
|
|
101
|
+
return at < 0 ? 0 : source.slice(0, at).split("\n").length - 1;
|
|
102
|
+
};
|
|
103
|
+
const add = (raw: string, message: string) => {
|
|
104
|
+
if (!found.problems.some((problem) => problem.message === message)) {
|
|
105
|
+
found.problems.push({ line: lineOf(raw), message });
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const visit = (tokens: Token[]) => {
|
|
109
|
+
for (const token of tokens) {
|
|
110
|
+
if (token.type === "heading") {
|
|
111
|
+
const title = (token as Tokens.Heading).text.trim();
|
|
112
|
+
add(token.raw, `a Markdown heading ("${title}"); use a section block with the title "${title}"`);
|
|
113
|
+
} else if (token.type === "html") {
|
|
114
|
+
const tag = /^<\/?([a-zA-Z][\w-]*)/.exec(token.raw.trim())?.[1] ?? "";
|
|
115
|
+
const first = found.problems.some((problem) => problem.message.startsWith("raw HTML"));
|
|
116
|
+
if (!first) {
|
|
117
|
+
add(
|
|
118
|
+
token.raw,
|
|
119
|
+
`raw HTML (<${tag}>) is not allowed; write restricted Markdown: **bold**, *italics*, lists, links`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
} else if (token.type === "math") {
|
|
123
|
+
const tex = String((token as MathToken).tex);
|
|
124
|
+
found.formulas.push(tex);
|
|
125
|
+
const problem = formulaProblem(tex);
|
|
126
|
+
if (problem !== undefined) add(token.raw, `$${tex}$: ${problem}`);
|
|
127
|
+
} else if (token.type === "link") {
|
|
128
|
+
const href = (token as Tokens.Link).href;
|
|
129
|
+
if (/^https?:\/\/\S+$/.test(href) || href.startsWith("/")) found.links.push(href);
|
|
130
|
+
else {
|
|
131
|
+
add(
|
|
132
|
+
token.raw,
|
|
133
|
+
`the link ${href} leads nowhere a learner can follow; link to https://… or to a page of the app: /<chapter>/<page>/`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
} else if (!ALLOWED.has(token.type)) {
|
|
137
|
+
add(token.raw, NOT_ALLOWED[token.type] ?? `${token.type} is not part of restricted Markdown`);
|
|
138
|
+
}
|
|
139
|
+
visit(children(token));
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
visit(marked.lexer(source));
|
|
143
|
+
return found;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A text in restricted Markdown as HTML; links to pages of the app go through `resolve`. */
|
|
147
|
+
export function renderMarkdown(source: string, resolve: (href: string) => string): string {
|
|
148
|
+
resolveLink = resolve;
|
|
149
|
+
return marked.parse(source, { async: false }).trim();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A short text in restricted Markdown as HTML without a paragraph around it, e.g. a table cell or a caption. */
|
|
153
|
+
export function renderInline(source: string, resolve: (href: string) => string): string {
|
|
154
|
+
resolveLink = resolve;
|
|
155
|
+
return marked.parseInline(source, { async: false }).trim();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A plain text as HTML. */
|
|
159
|
+
export const plain = escape;
|
|
160
|
+
|
|
161
|
+
const FORMULA = /\$((?:\\.|[^\\$\n])+?)\$/g;
|
|
162
|
+
|
|
163
|
+
/** A plain text as HTML in which only $…$ is a formula (MathML); everything else stays text, as in a quiz. */
|
|
164
|
+
export function renderWithFormulas(text: string): string {
|
|
165
|
+
let html = "";
|
|
166
|
+
let from = 0;
|
|
167
|
+
for (const match of text.matchAll(FORMULA)) {
|
|
168
|
+
html += escape(text.slice(from, match.index)) + renderFormula(match[1] ?? "", false);
|
|
169
|
+
from = match.index + match[0].length;
|
|
170
|
+
}
|
|
171
|
+
return html + escape(text.slice(from));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** What is wrong with the formulas $…$ of a plain text; none when each is in the command list and parses. */
|
|
175
|
+
export function formulaProblems(text: string): string[] {
|
|
176
|
+
return [...text.matchAll(FORMULA)].flatMap((match) => {
|
|
177
|
+
const problem = formulaProblem(match[1] ?? "");
|
|
178
|
+
return problem === undefined ? [] : [`$${match[1]}$: ${problem}`];
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The first HTML tag in a plain text, if it has one. */
|
|
183
|
+
export function htmlTag(text: string): string | undefined {
|
|
184
|
+
return /<\/?([a-zA-Z][\w-]*)[^>]*>/.exec(text)?.[1];
|
|
185
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:lernapps:schema:text.v1",
|
|
4
|
+
"title": "A learner text",
|
|
5
|
+
"description": "The texts of the content of an app: a text, and restricted Markdown, each a string while German is the only language of the app, else a map of texts by language. The schemas of the quiz, of the explainer and of the blocks they share refer to it.",
|
|
6
|
+
"$defs": {
|
|
7
|
+
"text": {
|
|
8
|
+
"description": "A text for learners without formatting: a string while German is the only language of the app, else a map of texts by language ({ de: …, en: … }) with every declared language.",
|
|
9
|
+
"anyOf": [
|
|
10
|
+
{
|
|
11
|
+
"type": "string",
|
|
12
|
+
"minLength": 1,
|
|
13
|
+
"pattern": "\\S"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"type": "object",
|
|
17
|
+
"minProperties": 1,
|
|
18
|
+
"propertyNames": {
|
|
19
|
+
"pattern": "^[a-z]{2}$"
|
|
20
|
+
},
|
|
21
|
+
"additionalProperties": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"minLength": 1,
|
|
24
|
+
"pattern": "\\S"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"markdown": {
|
|
30
|
+
"description": "Restricted Markdown, as a text or a map of texts by language: paragraphs, **bold**, *italics*, lists, links and line breaks, formulas as $…$ in the formula command list. No headings (use a section block), no HTML, no images (use a figure block), no code (use a code block), no quotes (use a quote block), no tables (use a table block). A link to a page of the app starts with / and names it from the app's root: /<chapter>/<page>/#<section>.",
|
|
31
|
+
"$ref": "urn:lernapps:schema:text.v1#/$defs/text"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// The checks of the code of single-file Vue components at build time, for every archetype that has components (the
|
|
2
|
+
// explainer, the interactive archetype). They run on the output of Vue's own SFC compiler, the script with the template
|
|
3
|
+
// compiled into it, mapped back to the line and column of the .vue file, and on the TypeScript modules of the app that
|
|
4
|
+
// it imports: no network (fetch, XMLHttpRequest, WebSocket, EventSource, sendBeacon, an import from a URL), no code or
|
|
5
|
+
// HTML from strings (eval, new Function, innerHTML, v-html), imports only from vue, the runtime
|
|
6
|
+
// @lernapps/app-templates, @lernapps/tooling/storage, i18n and a11y and the app's own modules (the CodeScope says
|
|
7
|
+
// which), and browser globals (window, document, localStorage, …) only inside onMounted and the hooks that run after
|
|
8
|
+
// it, because the build renders the component without a browser.
|
|
9
|
+
//
|
|
10
|
+
// What the interactive archetype's components may import is wider than the explainer's: any of the app's own files in
|
|
11
|
+
// src/ (sourceScope), and the libraries the app lists in the dependencies of its package.json (a bare import of such a
|
|
12
|
+
// package, bundled by the build; their licences are lernapps check's dependency-licence). An import from a URL stays
|
|
13
|
+
// forbidden.
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join, posix } from "node:path";
|
|
17
|
+
import type { Node, Program } from "@babel/types";
|
|
18
|
+
import type { SourceMapConsumer as Consumer } from "source-map-js";
|
|
19
|
+
|
|
20
|
+
export type Sfc = typeof import("vue/compiler-sfc");
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
let compiler: Sfc | undefined;
|
|
24
|
+
/** Vue's SFC compiler, loaded on first use: an app without components never loads it. */
|
|
25
|
+
export const sfc = (): Sfc => (compiler ??= require("vue/compiler-sfc") as Sfc);
|
|
26
|
+
|
|
27
|
+
/** Where a problem is: a line, and a column where it is known. */
|
|
28
|
+
export interface Place {
|
|
29
|
+
line: number;
|
|
30
|
+
column?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TOOLING = ["@lernapps/tooling/storage", "@lernapps/tooling/i18n", "@lernapps/tooling/a11y"];
|
|
34
|
+
/** The packages a component may import, for a message. */
|
|
35
|
+
const PACKAGES = `vue, @lernapps/app-templates, ${TOOLING.join(", ")}`;
|
|
36
|
+
const NETWORK = new Set(["fetch", "XMLHttpRequest", "WebSocket", "EventSource"]);
|
|
37
|
+
const BROWSER = new Set([
|
|
38
|
+
"window",
|
|
39
|
+
"document",
|
|
40
|
+
"navigator",
|
|
41
|
+
"location",
|
|
42
|
+
"history",
|
|
43
|
+
"localStorage",
|
|
44
|
+
"sessionStorage",
|
|
45
|
+
"indexedDB",
|
|
46
|
+
"matchMedia",
|
|
47
|
+
"requestAnimationFrame",
|
|
48
|
+
"cancelAnimationFrame",
|
|
49
|
+
"getComputedStyle",
|
|
50
|
+
"alert",
|
|
51
|
+
"confirm",
|
|
52
|
+
"prompt",
|
|
53
|
+
"screen",
|
|
54
|
+
"customElements",
|
|
55
|
+
"IntersectionObserver",
|
|
56
|
+
"ResizeObserver",
|
|
57
|
+
"MutationObserver",
|
|
58
|
+
]);
|
|
59
|
+
/** The hooks that never run while the build renders the component: inside them, browser globals are allowed. */
|
|
60
|
+
const BROWSER_HOOKS = new Set(["onMounted", "onBeforeUnmount", "onUnmounted"]);
|
|
61
|
+
const HTML = new Set(["innerHTML", "outerHTML", "insertAdjacentHTML"]);
|
|
62
|
+
const GLOBAL_OBJECTS = new Set(["window", "self", "globalThis"]);
|
|
63
|
+
|
|
64
|
+
/** A line of a problem in `file`, at `place` when it is known. */
|
|
65
|
+
export const line = (file: string, place: Place | undefined, message: string) =>
|
|
66
|
+
`${file}${place === undefined ? "" : `:${place.line}${place.column === undefined ? "" : `:${place.column}`}`}: ${message}`;
|
|
67
|
+
|
|
68
|
+
const isNode = (value: unknown): value is Node =>
|
|
69
|
+
typeof value === "object" && value !== null && typeof (value as { type?: unknown }).type === "string";
|
|
70
|
+
|
|
71
|
+
/** Every node of `root`, with its parents from the root down. */
|
|
72
|
+
export function* nodes(root: Node, parents: Node[] = []): Generator<[Node, readonly Node[]]> {
|
|
73
|
+
yield [root, parents];
|
|
74
|
+
for (const [key, value] of Object.entries(root)) {
|
|
75
|
+
if (key === "loc" || key === "leadingComments" || key === "trailingComments" || key === "innerComments") continue;
|
|
76
|
+
for (const child of Array.isArray(value) ? value : [value]) {
|
|
77
|
+
if (isNode(child)) yield* nodes(child, [...parents, root]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const parse = (code: string): Program =>
|
|
83
|
+
sfc().babelParse(code, { sourceType: "module", plugins: ["typescript"] }).program as Program;
|
|
84
|
+
|
|
85
|
+
/** The name of a member's property: x.innerHTML, x["innerHTML"]. */
|
|
86
|
+
function propertyName(node: Node): string | undefined {
|
|
87
|
+
if (node.type !== "MemberExpression" && node.type !== "OptionalMemberExpression") return undefined;
|
|
88
|
+
if (!node.computed && node.property.type === "Identifier") return node.property.name;
|
|
89
|
+
return node.property.type === "StringLiteral" ? node.property.value : undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** What one file of a component may not do, found in its code (the SFC compiler's output, or a module). */
|
|
93
|
+
interface Finding {
|
|
94
|
+
message: string;
|
|
95
|
+
node: Node;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The import specifiers of `program`, with their nodes: static, re-exports and dynamic imports. */
|
|
99
|
+
function imports(program: Program): { source: string; node: Node }[] {
|
|
100
|
+
const found: { source: string; node: Node }[] = [];
|
|
101
|
+
for (const [node] of nodes(program)) {
|
|
102
|
+
if (
|
|
103
|
+
(node.type === "ImportDeclaration" && node.importKind !== "type") ||
|
|
104
|
+
((node.type === "ExportNamedDeclaration" || node.type === "ExportAllDeclaration") &&
|
|
105
|
+
node.source !== null &&
|
|
106
|
+
node.source !== undefined &&
|
|
107
|
+
node.exportKind !== "type")
|
|
108
|
+
) {
|
|
109
|
+
if (node.source) found.push({ source: node.source.value, node });
|
|
110
|
+
} else if (node.type === "CallExpression" && node.callee.type === "Import") {
|
|
111
|
+
const [argument] = node.arguments;
|
|
112
|
+
found.push({ source: argument?.type === "StringLiteral" ? argument.value : "(an expression)", node });
|
|
113
|
+
} else if (node.type === "ImportExpression") {
|
|
114
|
+
found.push({ source: node.source.type === "StringLiteral" ? node.source.value : "(an expression)", node });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return found;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The package a bare import specifier names: "yaml" for "yaml/util", "@scope/name" for "@scope/name/sub". */
|
|
121
|
+
const packageOf = (source: string): string =>
|
|
122
|
+
source
|
|
123
|
+
.split("/")
|
|
124
|
+
.slice(0, source.startsWith("@") ? 2 : 1)
|
|
125
|
+
.join("/");
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* What the code of `program` may not do, and its imports of the app's own modules (`isLocal`); `own` names these for
|
|
129
|
+
* a message, `packages` are the libraries it may import besides.
|
|
130
|
+
*/
|
|
131
|
+
function findings(
|
|
132
|
+
program: Program,
|
|
133
|
+
isLocal: (source: string) => boolean,
|
|
134
|
+
own: string,
|
|
135
|
+
packages: readonly string[] = [],
|
|
136
|
+
): { found: Finding[]; locals: { source: string; node: Node }[] } {
|
|
137
|
+
const found: Finding[] = [];
|
|
138
|
+
const locals: { source: string; node: Node }[] = [];
|
|
139
|
+
for (const { source, node } of imports(program)) {
|
|
140
|
+
if (/^[a-z][a-z0-9+.-]*:|^\/\//i.test(source)) {
|
|
141
|
+
found.push({
|
|
142
|
+
node,
|
|
143
|
+
message: `an import from ${source}; a component loads nothing from the network, so bundle what it needs (vue-component-no-network)`,
|
|
144
|
+
});
|
|
145
|
+
} else if (isLocal(source)) locals.push({ source, node });
|
|
146
|
+
else if (
|
|
147
|
+
source !== "vue" &&
|
|
148
|
+
source !== "@lernapps/app-templates" &&
|
|
149
|
+
!source.startsWith("@lernapps/app-templates/") &&
|
|
150
|
+
!TOOLING.includes(source) &&
|
|
151
|
+
!packages.includes(packageOf(source))
|
|
152
|
+
) {
|
|
153
|
+
found.push({
|
|
154
|
+
node,
|
|
155
|
+
message: `an import from ${source}; a component imports only from ${PACKAGES} and ${own} (vue-component-imports)`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const { walkIdentifiers } = sfc();
|
|
160
|
+
if (program.body.length === 0) return { found, locals };
|
|
161
|
+
walkIdentifiers(program, (identifier, parent, parents, isReference, isLocal) => {
|
|
162
|
+
if (!isReference || isLocal) return;
|
|
163
|
+
const { name } = identifier;
|
|
164
|
+
if (NETWORK.has(name)) {
|
|
165
|
+
found.push({
|
|
166
|
+
node: identifier,
|
|
167
|
+
message: `${name}: a component makes no network request; give it what it needs through its props (vue-component-no-network)`,
|
|
168
|
+
});
|
|
169
|
+
} else if (
|
|
170
|
+
name === "eval" ||
|
|
171
|
+
(name === "Function" &&
|
|
172
|
+
(parent?.type === "NewExpression" || parent?.type === "CallExpression") &&
|
|
173
|
+
parent.callee === identifier)
|
|
174
|
+
) {
|
|
175
|
+
found.push({
|
|
176
|
+
node: identifier,
|
|
177
|
+
message: `${name === "eval" ? "eval" : "Function"}: a component runs no code from strings (vue-component-no-html-injection)`,
|
|
178
|
+
});
|
|
179
|
+
} else if (BROWSER.has(name)) {
|
|
180
|
+
const stack = [...parents, identifier];
|
|
181
|
+
const inHook = stack.some(
|
|
182
|
+
(node, index) =>
|
|
183
|
+
node.type === "CallExpression" &&
|
|
184
|
+
node.callee.type === "Identifier" &&
|
|
185
|
+
BROWSER_HOOKS.has(node.callee.name) &&
|
|
186
|
+
node.arguments.some((argument) => argument === stack[index + 1]),
|
|
187
|
+
);
|
|
188
|
+
if (!inHook) {
|
|
189
|
+
found.push({
|
|
190
|
+
node: identifier,
|
|
191
|
+
message: `${name} outside onMounted; the build renders the component where there is no ${name}, so use it only inside onMounted (vue-component-browser-globals)`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
for (const [node] of nodes(program)) {
|
|
197
|
+
const property = propertyName(node);
|
|
198
|
+
if (property !== undefined && HTML.has(property)) {
|
|
199
|
+
found.push({
|
|
200
|
+
node,
|
|
201
|
+
message: `${property}: a component writes no HTML from strings; let the template render it (vue-component-no-html-injection)`,
|
|
202
|
+
});
|
|
203
|
+
} else if (property === "sendBeacon") {
|
|
204
|
+
found.push({
|
|
205
|
+
node,
|
|
206
|
+
message: `sendBeacon: a component makes no network request (vue-component-no-network)`,
|
|
207
|
+
});
|
|
208
|
+
} else if (
|
|
209
|
+
property !== undefined &&
|
|
210
|
+
NETWORK.has(property) &&
|
|
211
|
+
(node.type === "MemberExpression" || node.type === "OptionalMemberExpression") &&
|
|
212
|
+
node.object.type === "Identifier" &&
|
|
213
|
+
GLOBAL_OBJECTS.has(node.object.name)
|
|
214
|
+
) {
|
|
215
|
+
found.push({
|
|
216
|
+
node,
|
|
217
|
+
message: `${property}: a component makes no network request; give it what it needs through its props (vue-component-no-network)`,
|
|
218
|
+
});
|
|
219
|
+
} else if (
|
|
220
|
+
node.type === "ObjectProperty" &&
|
|
221
|
+
!node.computed &&
|
|
222
|
+
((node.key.type === "Identifier" && node.key.name === "innerHTML") ||
|
|
223
|
+
(node.key.type === "StringLiteral" && node.key.value === "innerHTML"))
|
|
224
|
+
) {
|
|
225
|
+
// what the template compiler makes of v-html
|
|
226
|
+
found.push({
|
|
227
|
+
node,
|
|
228
|
+
message: `v-html: a component writes no HTML from strings; let the template render it (vue-component-no-html-injection)`,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return { found, locals };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The problem lines of `found`, each once, at its place; one without a place only when no other has the message. */
|
|
236
|
+
function placed(file: string, found: readonly Finding[], place: (node: Node) => Place | undefined): string[] {
|
|
237
|
+
const lines: string[] = [];
|
|
238
|
+
const withPlace = new Set<string>();
|
|
239
|
+
const without: string[] = [];
|
|
240
|
+
for (const finding of found) {
|
|
241
|
+
const at = place(finding.node);
|
|
242
|
+
if (at === undefined) without.push(finding.message);
|
|
243
|
+
else {
|
|
244
|
+
withPlace.add(finding.message);
|
|
245
|
+
const text = line(file, at, finding.message);
|
|
246
|
+
if (!lines.includes(text)) lines.push(text);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const message of new Set(without)) if (!withPlace.has(message)) lines.push(line(file, undefined, message));
|
|
250
|
+
return lines;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Which imports of a component's code are the app's own modules: `resolve` gives, for an import of `source` in `file`
|
|
255
|
+
* (both from the app's root), the module it names, or the problem when it names none a component may import, or
|
|
256
|
+
* undefined when the import is not one of the app's own. `own` names the app's own modules (and the packages) for a
|
|
257
|
+
* message; `packages` are the libraries a component may import besides, by their package names.
|
|
258
|
+
*/
|
|
259
|
+
export interface CodeScope {
|
|
260
|
+
own: string;
|
|
261
|
+
packages?: readonly string[];
|
|
262
|
+
resolve(file: string, source: string): { file: string } | { problem: string } | undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The libraries in the dependencies of the app's package.json, by their package names. */
|
|
266
|
+
function dependencies(root: string): string[] {
|
|
267
|
+
const file = join(root, "package.json");
|
|
268
|
+
if (!existsSync(file)) return [];
|
|
269
|
+
const manifest = JSON.parse(readFileSync(file, "utf8")) as { dependencies?: Record<string, string> };
|
|
270
|
+
return Object.keys(manifest.dependencies ?? {});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The scope of an app whose components may import any of its files in src/ and the libraries in the dependencies of
|
|
275
|
+
* its package.json (the interactive archetype).
|
|
276
|
+
*/
|
|
277
|
+
export const sourceScope = (root: string): CodeScope => ({
|
|
278
|
+
own: "the app's own files in src/ or a library in the dependencies of package.json",
|
|
279
|
+
packages: dependencies(root),
|
|
280
|
+
resolve(file, source) {
|
|
281
|
+
if (!source.startsWith("./") && !source.startsWith("../")) return undefined;
|
|
282
|
+
const target = posix.normalize(posix.join(dirname(file), source));
|
|
283
|
+
if (!target.startsWith("src/")) {
|
|
284
|
+
return { problem: `${source} is outside src/; a component imports only the app's own files in src/` };
|
|
285
|
+
}
|
|
286
|
+
return existsSync(join(root, target))
|
|
287
|
+
? { file: target }
|
|
288
|
+
: { problem: `${source} does not exist; import a file of src/, with its extension` };
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
/** A single-file component as the compiler reads it; undefined, with its problems, when it has none to check. */
|
|
293
|
+
export function readSfc(
|
|
294
|
+
root: string,
|
|
295
|
+
file: string,
|
|
296
|
+
problems: string[],
|
|
297
|
+
):
|
|
298
|
+
| {
|
|
299
|
+
descriptor: ReturnType<Sfc["parse"]>["descriptor"];
|
|
300
|
+
setup: NonNullable<ReturnType<Sfc["parse"]>["descriptor"]["scriptSetup"]>;
|
|
301
|
+
}
|
|
302
|
+
| undefined {
|
|
303
|
+
const source = readFileSync(join(root, file), "utf8");
|
|
304
|
+
const { descriptor, errors } = sfc().parse(source, { filename: join(root, file), sourceMap: true });
|
|
305
|
+
for (const error of errors) {
|
|
306
|
+
const loc = "loc" in error ? error.loc : undefined;
|
|
307
|
+
problems.push(line(file, loc ? { line: loc.start.line, column: loc.start.column } : undefined, error.message));
|
|
308
|
+
}
|
|
309
|
+
if (errors.length > 0) return undefined;
|
|
310
|
+
const setup = descriptor.scriptSetup;
|
|
311
|
+
if (setup === null || setup.lang !== "ts") {
|
|
312
|
+
problems.push(line(file, undefined, `a component has <script setup lang="ts">`));
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
return { descriptor, setup };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The checks of components' code, each problem into `problems`: `compile` compiles a component's <script setup> with
|
|
320
|
+
* its template and checks the output, `module` checks a TypeScript module; each follows the imports of the app's own
|
|
321
|
+
* TypeScript modules (`scope`) and checks each module once.
|
|
322
|
+
*/
|
|
323
|
+
export function codeChecker(root: string, scope: CodeScope, problems: string[]) {
|
|
324
|
+
const { SourceMapConsumer } = require("source-map-js") as typeof import("source-map-js");
|
|
325
|
+
const checked = new Set<string>();
|
|
326
|
+
const check = (file: string, program: Program): Finding[] => {
|
|
327
|
+
const { found, locals } = findings(
|
|
328
|
+
program,
|
|
329
|
+
(source) => scope.resolve(file, source) !== undefined,
|
|
330
|
+
scope.own,
|
|
331
|
+
scope.packages,
|
|
332
|
+
);
|
|
333
|
+
for (const { source, node } of locals) {
|
|
334
|
+
const target = scope.resolve(file, source);
|
|
335
|
+
if (target === undefined) continue;
|
|
336
|
+
if ("problem" in target) found.push({ node, message: target.problem });
|
|
337
|
+
else if (target.file.endsWith(".ts")) module(target.file);
|
|
338
|
+
}
|
|
339
|
+
return found;
|
|
340
|
+
};
|
|
341
|
+
const module = (file: string): void => {
|
|
342
|
+
if (checked.has(file)) return;
|
|
343
|
+
checked.add(file);
|
|
344
|
+
problems.push(
|
|
345
|
+
...placed(file, check(file, parse(readFileSync(join(root, file), "utf8"))), (node) =>
|
|
346
|
+
node.loc ? { line: node.loc.start.line, column: node.loc.start.column + 1 } : undefined,
|
|
347
|
+
),
|
|
348
|
+
);
|
|
349
|
+
};
|
|
350
|
+
const compile = (
|
|
351
|
+
file: string,
|
|
352
|
+
descriptor: ReturnType<Sfc["parse"]>["descriptor"],
|
|
353
|
+
): { program: Program; consumer: Consumer | undefined } | undefined => {
|
|
354
|
+
let compiled: ReturnType<Sfc["compileScript"]>;
|
|
355
|
+
try {
|
|
356
|
+
compiled = sfc().compileScript(descriptor, {
|
|
357
|
+
id: file.replace(/^.*\//, "").replace(/\.vue$/, ""),
|
|
358
|
+
inlineTemplate: true,
|
|
359
|
+
sourceMap: true,
|
|
360
|
+
isProd: true,
|
|
361
|
+
fs: { fileExists: existsSync, readFile: (name) => readFileSync(name, "utf8") },
|
|
362
|
+
});
|
|
363
|
+
} catch (error) {
|
|
364
|
+
const loc = (error as { loc?: { start: { line: number; column: number } } }).loc;
|
|
365
|
+
const message = (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
|
|
366
|
+
problems.push(line(file, loc ? { line: loc.start.line, column: loc.start.column } : undefined, message));
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
const program = parse(compiled.content);
|
|
370
|
+
const consumer: Consumer | undefined = compiled.map ? new SourceMapConsumer(compiled.map) : undefined;
|
|
371
|
+
problems.push(
|
|
372
|
+
...placed(file, check(file, program), (node) => {
|
|
373
|
+
if (!node.loc || consumer === undefined) return undefined;
|
|
374
|
+
const original = consumer.originalPositionFor({ line: node.loc.start.line, column: node.loc.start.column });
|
|
375
|
+
return original.line === null ? undefined : { line: original.line, column: original.column + 1 };
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
return { program, consumer };
|
|
379
|
+
};
|
|
380
|
+
return { module, compile };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The checks of the components' code for the single-file components `files` (from the app's root) and the app's own
|
|
385
|
+
* TypeScript modules they import (`scope`): one line per problem, with the file and its place.
|
|
386
|
+
*/
|
|
387
|
+
export function codeProblems(root: string, files: readonly string[], scope: CodeScope): string[] {
|
|
388
|
+
const problems: string[] = [];
|
|
389
|
+
const code = codeChecker(root, scope, problems);
|
|
390
|
+
for (const file of files) {
|
|
391
|
+
const read = readSfc(root, file, problems);
|
|
392
|
+
if (read !== undefined) code.compile(file, read.descriptor);
|
|
393
|
+
}
|
|
394
|
+
return problems;
|
|
395
|
+
}
|