Zum Inhalt springen

Editor einbetten

@findsl/editor liefert einen einbettbaren Monaco-Editor mit Syntax-Hervorhebung, Diagnosen, Hover mit Bezug auf die gesetzliche Quelle, Play-Pfeilen an jedem prüfe-Block und einer kleinen API zum Prüfen und Generieren. mountFindslEditor() kapselt das fragile Monaco-↔-Worker-↔-Grammatik-Wiring in einem Aufruf.

Eine FinDSL-Editor-Instanz besteht aus zwei Teilen, die über das Language Server Protocol (LSP) miteinander reden:

Hauptthread (UI) Web-Worker (Hintergrund)
┌────────────────────────┐ LSP / JSON-RPC ┌───────────────────────────┐
│ Monaco-Editor │ ◀────────────────▶ │ @findsl/web-Worker │
│ Eingabe · Highlighting │ │ Langium-Sprachserver: │
│ Play-Pfeile · Hover │ │ Parser · Type-Checker · │
│ │ │ Diagnosen · Completion · │
│ mountFindslEditor() │ │ Hover · check · generate │
└────────────────────────┘ └───────────────────────────┘
  • Der Monaco-Editor läuft im Hauptthread und ist nur die Oberfläche: Texteingabe, Darstellung, Klicks.
  • Der LSP-Worker (aus @findsl/web) trägt die gesamte Sprachintelligenz und läuft in einem separaten Thread, damit das Parsen und Prüfen die UI nicht blockiert.

mountFindslEditor() startet beide, verbindet sie und gibt dir ein Handle zurück. Das Setup ist versionssensibel (Monaco + @codingame/* müssen im Gleichschritt sein) — genau deshalb nimmt dir das Paket es ab.

  1. Das Paket und den Monaco-Stack installieren. Letzterer ist als peerDependencies deklariert, damit im Bundle genau eine @codingame/*-Instanz existiert (mehrere führen zu einem Service-Init-Deadlock) — die @codingame/*-Versionen müssen im Lockstep stehen:

    Terminal-Fenster
    npm install @findsl/editor @findsl/web \
    monaco-languageclient \
    @codingame/monaco-vscode-api@25.1.2 \
    @codingame/monaco-vscode-editor-api@25.1.2 \
    @codingame/monaco-vscode-configuration-service-override@25.1.2
  2. Vite konfigurieren — der Monaco-Stack braucht ES-Module-Worker und Pre-Bundling (vite.config.ts):

    import importMetaUrlPlugin from "@codingame/esbuild-import-meta-url-plugin";
    export default {
    worker: { format: "es" },
    optimizeDeps: {
    include: [
    "monaco-languageclient/vscodeApiWrapper",
    "monaco-languageclient/editorApp",
    "monaco-languageclient/lcwrapper",
    "monaco-languageclient/workerFactory",
    "@codingame/monaco-vscode-configuration-service-override",
    "@codingame/monaco-vscode-editor-api",
    "@codingame/monaco-vscode-api/extensions",
    ],
    esbuildOptions: { plugins: [importMetaUrlPlugin] },
    },
    };

    @findsl/editor ist Vite-first (webpack/CDN: best effort, ungetestet).

  3. Den Worker als statisches Asset bereitstellen (siehe nächster Abschnitt).

Der Worker (@findsl/web/worker) ist dieselbe Langium-Engine wie die VS-Code-Extension, nur im Browser: Er parst und typprüft das Dokument, liefert Diagnosen, Vervollständigung, Hover und Semantic Tokens und beantwortet die beiden Custom-Requests findsl/check und findsl/generate.

Drei Punkte sind wichtig:

  • Eigener Thread. Parsen, Type-Checking und das Auswerten der prüfe-Blöcke laufen off-main-thread — die Eingabe bleibt flüssig, auch bei großen Modulen.

  • Komplett lokal. Der Worker ist eine in sich geschlossene Engine; nichts wird an einen Server gesendet. Der Editor funktioniert offline und air-gapped.

  • Vorgebautes Asset, kein Re-Bundling. Der Worker ist ein fertiges ~2-MB-Bundle mit Lazy-Chunks. Er wird nicht mit deiner App gebündelt, sondern als statische Datei ausgeliefert. Das mitgelieferte Bin kopiert ihn dorthin:

    package.json
    {
    "scripts": {
    "predev": "findsl-editor-copy-worker", // → public/findsl-web/
    "prebuild": "findsl-editor-copy-worker"
    }
    }

    Standardmäßig lädt mountFindslEditor den Worker root-absolut unter /findsl-web/worker.js — das funktioniert auch auf Unterseiten wie /app/editor/.

mountFindslEditor(container, options) braucht ein DOM-Element mit Größe. Ein vollständiges Minimalbeispiel:

<div id="editor" style="height: 70vh; border: 1px solid #ccc"></div>
<script type="module" src="/src/main.ts"></script>

Der Container muss eine Höhe haben — Monaco füllt ihn aus, bringt aber keine eigene mit.

OptionDefaultZweck
initialCode""Anfangsinhalt des Editors
workerUrl/findsl-web/worker.jsPfad zum LSP-Worker (siehe oben)
theme"auto""light" · "dark" · "auto" (folgt prefers-color-scheme) · eigenes Spec
appearancefontFamily, fontSize (13), tabSize (4), minimap (false), glyphMargin (true, die Play-Pfeile)
behaviorwordWrap (false), scrollBeyondLastLine (false)
onChangeBei echten Nutzer-Änderungen (nicht bei setCode)
onRunKlick auf den Play-Pfeil / die CodeLens „Testfälle ausführen”
onErrorconsole.warnNicht-fatale Fehlerpfade

theme: "auto" folgt der Systemeinstellung. Für ein eigenes Design-System gibt es themeFromCssVars, das CSS-Custom-Properties zu sRGB-Hex auflöst und die Basis aus einem DOM-Attribut liest:

import { mountFindslEditor, themeFromCssVars } from "@findsl/editor";
const VARS = { "editor.background": "--surface" };
const editor = await mountFindslEditor(container, {
theme: themeFromCssVars(VARS), // liest data-theme + löst --surface auf
});
// Bei eigenem Dark-/Light-Toggle nachziehen:
new MutationObserver(() => editor.setTheme(themeFromCssVars(VARS))).observe(
document.documentElement,
{ attributeFilter: ["data-theme"] },
);

mountFindslEditor liefert ein Handle zum Steuern des Editors:

MethodeZweck
getCode() / setCode(code)Editor-Inhalt lesen/setzen (setCode löst onChange nicht aus)
check()prüfe-Blöcke auswerten → CheckResult
generate(target, opts?)Artefakt erzeugen → GenerateResult
onChange(fn) / onRun(fn)Listener; geben jeweils eine Abmelde-Funktion zurück
setTheme(theme)Theme zur Laufzeit wechseln
dispose()Editor, Worker und Listener sauber abbauen

check() und generate() sind dünne Wrapper um die Custom-Requests des Workers. Beide arbeiten auf dem aktuellen Editor-Inhalt — du musst nichts übergeben.

const result = await editor.check();
if (result.error) {
// check selbst gescheitert (z. B. Dokument nicht offen)
console.error(result.error);
} else {
console.log(`${result.passed}/${result.total} Testfälle grün · ${result.durationMs} ms`);
for (const c of result.cases) {
// c.status: "pass" | "fail" | "error"; c.message: ausgewerteter Wert bzw. Fehler
console.log(`${c.status === "pass" ? "" : ""} ${c.name}${c.message ?? ""}`);
}
}

CheckResult enthält cases (je testfall ein PruefeCase mit name, status, message), die Zähler passed/total, die durationMs und optional diagnostics (LSP-Diagnosen des Dokuments). error ist nur gesetzt, wenn die Prüfung selbst scheiterte — unterscheidbar von „0 Tests, alle grün”.

const res = await editor.generate("java", { className: "Einkommensteuer" });
if (res.ok && res.artifact) {
const { filename, mime, text } = res.artifact;
download(filename, mime, text!); // eigene Download-/Anzeige-Logik
} else {
console.error(res.error);
}

target ist eines von:

TargetErgebnis (artifact)
java, ts, jsQuellcode in text
markdown, htmlDokumentation in text
papProgrammablaufplan als Mermaid-Quelle in mermaid (mit mermaid.js rendern)
pdfpdfmake-Doc-Definition (JSON) in text — clientseitig mit pdfmake zu Bytes rendern

opts.className setzt einen sprechenden Klassen-/Modulnamen fürs Generat (sonst aus der Dokument-URI abgeleitet). Der Wert wird serverseitig saniert (PascalCase, gültiger Identifier).

Der Editor zeigt an jedem prüfe-Block einen Play-Pfeil und eine CodeLens „Testfälle ausführen”. Beide lösen onRun aus — verbinde es mit deiner Prüf-Logik:

const editor = await mountFindslEditor(container, {
onRun: () => void runUndZeige(), // dieselbe Funktion wie dein „Prüfen"-Knopf
});
async function runUndZeige() {
const result = await editor.check();
/* Ergebnis in deine UI rendern */
}

Für React-Anwendungen gibt es @findsl/editor-react — die Komponente <FindslEditor> als dünne Bindung um @findsl/editor. Sie übernimmt Mount und Abbau im Komponenten-Lebenszyklus (StrictMode- und async-race-fest) und bietet eine Ref-API für check()/generate().

react und — über @findsl/editor — der Monaco-Stack sind peerDependencies:

Terminal-Fenster
npm i @findsl/editor-react @findsl/editor @findsl/web react react-dom \
monaco-languageclient \
@codingame/monaco-vscode-api@25.1.2 \
@codingame/monaco-vscode-editor-api@25.1.2 \
@codingame/monaco-vscode-configuration-service-override@25.1.2

Worker-Hosting (findsl-editor-copy-worker) und Vite-Konfiguration sind identisch zu @findsl/editor (siehe oben).

import { useRef } from "react";
import { FindslEditor, type FindslEditorRef } from "@findsl/editor-react";
function Editor() {
const ref = useRef<FindslEditorRef>(null);
return (
<FindslEditor
ref={ref}
defaultValue={"fn Verdopple(x: Ganzzahl): Ganzzahl = x + x\n"}
theme="auto"
appearance={{ fontFamily: "'IBM Plex Mono', monospace" }}
onChange={(code) => {
/* z. B. Ergebnisse veralten */
}}
onRun={() => ref.current?.check()}
style={{ height: 420 }}
/>
);
}
// imperativ über den Ref:
const result = await ref.current?.check();
const java = await ref.current?.generate("java", { className: "Beispiel" });
PropBedeutung
defaultValueAnfangsinhalt — uncontrolled; spätere Änderungen wirken nur über einen key-Wechsel
workerUrlWorker-URL (Mount-Zeit; Wechsel ⇒ Re-Mount)
theme'light' | 'dark' | 'auto' | ThemeSpec — Wechsel wird live angewandt (kein Re-Mount)
appearance / behaviorOptik / Verhalten (Mount-Zeit)
onChange(code) · onRun · onError · onReady(handle)Callbacks
className / styleContainer-div

Über die Ref (FindslEditorRef): check(), generate(target, opts?), evaluate(expr) (wertet einen FinDSL-Ausdruck im Dokument-Scope aus — Wert, Typ und formatierter Text als EvalResult), getCode(), setCode(), setTheme() sowie handle (das rohe @findsl/editor-Handle als Escape-Hatch). check()/generate()/evaluate() werfen vor dem Mount; getCode()/setCode()/setTheme() sind tolerant (No-op bzw. '').

themeFromCssVars und alle Typen (CheckResult, GenerateResult, EvalResult, Target …) werden aus @findsl/editor-react re-exportiert — ein zusätzlicher Import aus @findsl/editor ist nicht nötig.

Der Editor ist browser-only (Monaco + Worker) und darf nicht serverseitig gerendert werden — daher client-only laden:

"use client";
import dynamic from "next/dynamic";
const FindslEditor = dynamic(
() => import("@findsl/editor-react").then((m) => m.FindslEditor),
{ ssr: false },
);

Wenn du keine Editor-Oberfläche brauchst, sondern nur die Sprach-Engine im Browser, nutze @findsl/web direkt — ohne den schweren Monaco-/@codingame-Stack. Das Paket stellt den LSP-Worker (@findsl/web/worker) und die Ergebnis-Typen bereit.

Typische Anwendungsfälle:

  • Validierung im Hintergrund. Ein Behördenformular oder Wizard, in dem Fachregeln als FinDSL hinterlegt sind und gegen Eingaben geprüft werden — ohne dass der Nutzer Code sieht.
  • Eigener Editor statt Monaco. Du nutzt bereits CodeMirror, Ace o. ä. und willst nur FinDSL-Diagnosen und das Auswerten der prüfe-Blöcke ergänzen.
  • Generieren auf Knopfdruck. Ein „Nach Java exportieren”-Button auf einer ansonsten statischen Seite, ohne eingebetteten Editor.
  • Eigene Toolchain-UI. Ein Review-Werkzeug, das Module lädt, prüft und die Artefakte nebeneinanderstellt.

Wie es funktioniert: Der Worker ist ein vollwertiger LSP-Server. Du startest ihn als Web-Worker, verbindest einen LSP-Client, öffnest das Dokument (textDocument/didOpen) und sendest dann dieselben Custom-Requests, die auch @findsl/editor intern nutzt:

// Worker starten (als ES-Modul-Worker; Pfad = der kopierte Worker)
const worker = new Worker(new URL("/findsl-web/worker.js", location.origin), {
type: "module",
});
// … einen LSP-Client (z. B. via vscode-jsonrpc/browser) mit dem Worker
// verbinden, initialisieren und das Dokument mit didOpen öffnen …
// Danach: dieselben Requests wie der Editor
const check = await client.sendRequest("findsl/check", { uri });
const java = await client.sendRequest("findsl/generate", { uri, target: "java" });

Die Ergebnis-Typen (CheckResult, GenerateResult, EvalResult, Target …) importierst du aus @findsl/web. Das LSP-Handshake (Initialize + didOpen) musst du selbst aufsetzen — der Quellcode des Playgrounds zeigt das vollständige Muster (dort über monaco-languageclient, das sich auch ohne Editor headless betreiben lässt).

Neben check und generate beantwortet der Worker findsl/eval — er wertet einen freien FinDSL-Ausdruck im Scope des offenen Dokuments aus und liefert Wert, Typ und formatierten Text zurück. Genau richtig für ein Formular, das live rechnet: die Fachregel liegt als FinDSL-Modul im Hintergrund, der Nutzer sieht nur Eingabe und Ergebnis.

Das Modul (einmal per didOpen geladen) — eine vereinfachte Körperschaftsteuer:

@Quelle("§ 23 Absatz 1 Nummer 1 KStG")
konst KST_SATZ: Prozent = 15%
@Quelle("§ 23 KStG")
fn Koerperschaftsteuer(einkommen: Euro): Euro =
(KST_SATZ * einkommen).abrunden()

Das Formular — ein Euro-Betrag rein, die Steuer raus:

<form id="kst-rechner">
<label>
zu versteuerndes Einkommen (€)
<input id="betrag" type="number" min="0" step="1000" value="100000" />
</label>
<output id="ergebnis" for="betrag"></output>
</form>

Im Hintergrund: Worker und LSP-Client wie oben aufsetzen, das Modul einmal öffnen und bei jeder Eingabe findsl/eval mit dem zusammengesetzten Ausdruck senden:

import type { EvalResult } from "@findsl/web";
const MODUL = `
@Quelle("§ 23 Absatz 1 Nummer 1 KStG")
konst KST_SATZ: Prozent = 15%
@Quelle("§ 23 KStG")
fn Koerperschaftsteuer(einkommen: Euro): Euro =
(KST_SATZ * einkommen).abrunden()
`;
const uri = "inmemory://kst/modul.findsl";
// … Worker starten, LSP-Client verbinden + initialisieren (siehe oben) …
// Modul einmal öffnen — die Funktion lebt fortan in seinem Scope:
client.sendNotification("textDocument/didOpen", {
textDocument: { uri, languageId: "findsl", version: 1, text: MODUL },
});
const betrag = document.querySelector<HTMLInputElement>("#betrag")!;
const ergebnis = document.querySelector<HTMLOutputElement>("#ergebnis")!;
async function berechne() {
const wert = betrag.value.trim();
if (!wert) return;
// Freien Ausdruck im Dokument-Scope auswerten:
const res = await client.sendRequest<EvalResult>("findsl/eval", {
uri,
expr: `Koerperschaftsteuer(${wert})`,
});
ergebnis.value = res.ok ? res.text! : `— (${res.error})`;
}
betrag.addEventListener("input", () => void berechne());
void berechne();

Eine Eingabe von 100000 liefert EvalResult { ok: true, value: "15.000", type: "Euro", text: "15.000 €" }. Die drei Darstellungen sind getrennt: text ist voll formatiert inklusive Einheit, value die reine Zahl (für eigenes Weiterrechnen) und type der FinDSL-Typ. Scheitert die Auswertung — ein Parse-Fehler oder ein abbruch aus dem Modul (etwa bei unzulässiger Eingabe) —, ist ok false und error trägt die Begründung.