Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

243
tools/check-js.mjs Normal file
View File

@@ -0,0 +1,243 @@
#!/usr/bin/env node
/**
* Sucht Bezeichner, die verwendet, aber nirgends deklariert oder
* importiert werden.
*
* node tools/check-js.mjs
*
* Hintergrund: Ohne Build-Schritt gibt es keinen Linter, und der Browser
* meldet "x is not defined" erst, wenn die betroffene Zeile tatsächlich
* ausgeführt wird. Eine Funktion, die nur im Menü eines Eintrags
* gebraucht wird, kann so lange fehlen, ohne dass es auffällt.
*
* Das ist bewusst eine grobe Prüfung ohne echten Parser: Sie kennt keine
* Blockgeltungsbereiche und meldet daher nichts, was irgendwo in der
* Datei deklariert ist. Für den Zweck - "ganz vergessen" statt "am
* falschen Ort" - reicht das.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
const ROOT = process.argv[2] || "web/html/js";
/** Was der Browser mitbringt. Bewusst knapp gehalten - je kürzer, desto
* mehr findet die Prüfung. */
const GLOBALS = new Set([
// Sprache
"Array", "Boolean", "Date", "Error", "Infinity", "Intl", "JSON", "Map",
"Math", "NaN", "Number", "Object", "Promise", "RegExp", "Set", "String",
"Symbol", "TypeError", "Uint8Array", "Uint8ClampedArray", "WeakMap",
"globalThis", "isNaN", "parseFloat", "parseInt", "structuredClone",
"undefined", "queueMicrotask", "console",
// Browser
"AbortController", "Blob", "CustomEvent", "DOMException", "Event",
"EventSource", "FileReader", "FormData", "Headers", "IDBKeyRange",
"Image", "ImageData", "Node", "Request", "Response", "URL",
"URLSearchParams", "alert", "clearInterval", "clearTimeout", "confirm",
"crypto", "document", "fetch", "history", "indexedDB", "localStorage",
"location", "navigator", "performance", "prompt", "requestAnimationFrame",
"Notification", "PushManager", "ServiceWorkerRegistration",
"self", "sessionStorage", "setInterval", "setTimeout", "window",
"BarcodeDetector", "Element", "HTMLElement", "MediaStream", "TextDecoder",
"TextEncoder", "atob", "btoa", "decodeURIComponent", "encodeURIComponent",
// Service Worker
"caches", "clients", "registration", "skipWaiting",
// Schlüsselwörter, die als Wort auftauchen
"arguments", "as", "async", "await", "break", "case", "catch", "class", "const",
"continue", "default", "delete", "do", "else", "export", "extends",
"false", "finally", "for", "from", "function", "get", "if", "import",
"in", "instanceof", "let", "new", "null", "of", "return", "set", "static",
"super", "switch", "this", "throw", "true", "try", "typeof", "var", "void",
"while", "yield",
]);
/** Entfernt Kommentare und Zeichenkettenliterale. Der Inhalt von
* Template-Ausdrücken ${...} bleibt erhalten - dort steht echter Code. */
function stripNoise(source) {
let out = "";
let i = 0;
const n = source.length;
while (i < n) {
const two = source.slice(i, i + 2);
if (two === "//") {
while (i < n && source[i] !== "\n") i++;
continue;
}
if (two === "/*") {
i += 2;
while (i < n && source.slice(i, i + 2) !== "*/") i++;
i += 2;
continue;
}
if (source[i] === '"' || source[i] === "'") {
const quote = source[i++];
while (i < n && source[i] !== quote) {
if (source[i] === "\\") i++;
i++;
}
i++;
out += '""';
continue;
}
if (source[i] === "`") {
i++;
while (i < n && source[i] !== "`") {
if (source[i] === "\\") { i += 2; continue; }
if (source.slice(i, i + 2) === "${") {
// Ausdruck übernehmen, Klammern zählen - und rekursiv säubern,
// denn darin können wieder Zeichenketten stehen.
i += 2;
let depth = 1;
let inner = "";
while (i < n && depth > 0) {
if (source[i] === "{") depth++;
else if (source[i] === "}") depth--;
if (depth > 0) inner += source[i];
i++;
}
out += " " + stripNoise(inner) + " ";
continue;
}
i++;
}
i++;
out += '""';
continue;
}
// Regulärer Ausdruck? Ein / ist nur dann Beginn eines Literals, wenn
// davor ein Operator oder Klammeranfang steht - sonst ist es eine
// Division. Ohne diese Unterscheidung landen die Flags (g, i, s) und
// Zeichenklassen (\D, \w) in der Bezeichnerliste.
if (source[i] === "/") {
const head = out.replace(/\s+$/, "");
const before = head.slice(-1);
// Nach einem Schlüsselwort steht ebenfalls ein Literal, keine
// Division: `return /x/.test(s)` ist gültiges JavaScript.
const keyword = /(?:^|[^\w$])(return|typeof|instanceof|in|of|case|do|else|void|delete|await|yield|new|throw)$/.test(head);
if (before === "" || keyword || "(,=:[!&|?{};+-*%<>~^".includes(before)) {
i++;
let inClass = false;
while (i < n) {
if (source[i] === "\\") { i += 2; continue; }
if (source[i] === "[") inClass = true;
else if (source[i] === "]") inClass = false;
else if (source[i] === "/" && !inClass) break;
else if (source[i] === "\n") break;
i++;
}
i++;
while (i < n && /[a-z]/.test(source[i])) i++; // Flags
out += "0";
continue;
}
}
out += source[i++];
}
return out;
}
function declaredNames(code) {
const names = new Set();
const add = (raw) => {
// Destrukturierung entfernen, dann jeden Teil einzeln betrachten.
for (const part of raw.replace(/[{}[\]]/g, "").split(",")) {
// "a = 1" -> a, "a: b" -> b (der zweite Name ist die Bindung)
const pieces = part.split("=")[0].split(":");
const name = pieces[pieces.length - 1].trim().replace(/^\.\.\./, "");
if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
}
};
// Importe
for (const m of code.matchAll(/import\s+\*\s+as\s+([\w$]+)/g)) names.add(m[1]);
for (const m of code.matchAll(/import\s+\{([^}]*)\}/g)) {
for (const part of m[1].split(",")) {
const name = part.trim().split(/\s+as\s+/).pop().trim();
if (name) names.add(name);
}
}
for (const m of code.matchAll(/import\s+([\w$]+)\s+from/g)) names.add(m[1]);
// Funktionen und Klassen
for (const m of code.matchAll(/(?:function|class)\s+([\w$]+)/g)) names.add(m[1]);
// Variablen, auch destrukturiert
for (const m of code.matchAll(/(?:const|let|var)\s+(\{[^}]*\}|\[[^\]]*\]|[\w$]+)/g)) {
add(m[1]);
}
// Parameterlisten: alles zwischen ( ) vor einem => oder {
for (const m of code.matchAll(/\(([^()]*)\)\s*(?:=>|\{)/g)) add(m[1]);
// Einzelner Pfeilparameter ohne Klammern
for (const m of code.matchAll(/(?:^|[^\w$.])([\w$]+)\s*=>/gm)) names.add(m[1]);
// catch (e)
for (const m of code.matchAll(/catch\s*\(\s*([\w$]+)/g)) names.add(m[1]);
// for (const x of ...) ist oben abgedeckt; benannte Objektmethoden
for (const m of code.matchAll(/([\w$]+)\s*\([^()]*\)\s*\{/g)) names.add(m[1]);
return names;
}
function usedNames(code) {
const names = new Set();
// Kein Punkt davor (sonst wäre es ein Eigenschaftszugriff), kein
// Doppelpunkt danach (sonst wäre es ein Objektschlüssel oder Label).
for (const m of code.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)/g)) {
const name = m[1];
const after = code.slice(m.index + name.length, m.index + name.length + 40);
if (/^\s*:/.test(after)) continue;
names.add(name);
}
return names;
}
function walk(dir, files = []) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) walk(path, files);
else if (entry.name.endsWith(".js")) files.push(path);
}
return files;
}
let problems = 0;
const targets = walk(ROOT);
targets.push("web/html/sw.js");
for (const file of targets) {
let source;
try {
source = readFileSync(file, "utf8");
} catch {
continue;
}
const code = stripNoise(source);
const declared = declaredNames(code);
const used = usedNames(code);
const unknown = [...used].filter(
(name) => !declared.has(name) && !GLOBALS.has(name)
).sort();
if (unknown.length) {
console.log(`\n ${file}`);
for (const name of unknown) {
// Zeilennummer des ersten Vorkommens für die Fehlersuche
const line = source.split("\n").findIndex((l) =>
new RegExp(`(^|[^\\w$.])${name}\\b`).test(l)) + 1;
console.log(` Zeile ${line}: ${name} wird verwendet, aber nirgends deklariert`);
problems++;
}
}
}
console.log(
problems
? `\n${problems} möglicherweise undefinierte(r) Bezeichner`
: `${targets.length} Dateien geprüft, keine undefinierten Bezeichner`
);
process.exit(problems ? 1 : 0);