Zum Inhalt

Entwicklerhandbuch

Architektur

Frontend-Request
    │
    ├─ POST /nt-lingua/transform ─→ TransformApiMiddleware
    │        │
    │        ├─ isLanguageTarget(target) = true  ─→ TextTransformationService::translateStrings()
    │        └─ isLanguageTarget(target) = false ─→ TextTransformationService::transformField()
    │
    └─ GET Seitenaufruf (Cookie/Param)
             └─ TransformProcessor (DataProcessing) ─→ transformField() [Server-Fallback]

TextTransformationService
    ├─ einfache / leichte  ─→ AiProviderInterface (nt_ai)    + Prompts
    └─ Sprachcodes          ─→ TranslatorInterface (LLM/DeepL) + Glossar

TextCacheRepository
    └─ tx_ntlingua_text_cache (UNIQUE: content_hash + target)

Zwei Kopplungspunkte:

  • Frontend → Server: POST /nt-lingua/transform wird von TransformApiMiddleware verarbeitet
  • Server → KI/Übersetzung: TranslatorInterface (LLM oder DeepL) + AiProviderInterface (nt-ai)

nt_lingua kennt keine konkreten KI-Provider — der einzige externe Kopplungspunkt ist Netthinks\NtAi\Provider\AiProviderInterface.

Antwortformat und Überwachung

POST /nt-lingua/transform mit einem Sprachziel antwortet:

{
  "target": "fr",
  "translations": { "Quelltext": "Übersetzung" },
  "untranslated": 0,
  "degraded": false
}
Feld / Kopfzeile Bedeutung
translations Zuordnung Quelltext → Übersetzung. Für ein Segment ohne Übersetzung steht hier der Quelltext, damit die Seite vollständig bleibt.
untranslated Anzahl der Segmente ohne Übersetzung
degraded true, sobald mindestens ein Segment gescheitert ist
X-NtLingua-Untranslated Dieselbe Anzahl als Kopfzeile — für eine Prüfung ohne JSON-Auswertung

Statuscodes: Ein Teilausfall antwortet 200 (der übersetzte Teil ist brauchbar), ein vollständiger Ausfall 503. Für die Anzeige macht das keinen Unterschied — das Client-Skript wertet den Status nicht aus, es wendet an, was es bekommt, und lässt die Seite sonst in der Ausgangssprache stehen.

Daneben gelten die Statuscodes der Budget- und Auslastungsgrenzen: 429 bei erschöpftem Kontingent, 503 mit Retry-After, wenn alle gleichzeitigen Anbieter-Anfragen belegt sind. Eine Überwachung sollte deshalb untranslated bzw. die Kopfzeile auswerten und nicht allein den Status.

Der Grund für diese Signale: Ohne sie ist ein Ausfall von außen nicht erkennbar. Die Antwort enthält dann die Quelltexte und sieht wie eine vollständige Übersetzung aus.

Gescheiterte Segmente werden nicht zwischengespeichert — der nächste Aufruf versucht es erneut. Zusätzlich erhält jeder Ausfall einen Eintrag in tx_ntlingua_log, dessen Feld error die Anzahl nennt. Dort gezählt wird der Anbieter-Aufruf, also nur die nicht bereits zwischengespeicherten Segmente; die anfragebezogene Zahl steht in untranslated.

Datenmodell

tx_ntlingua_text_cache

Spalte Typ Beschreibung
content_hash varchar(64) sha256(normalize(source) \| target \| model)
target varchar(20) einfache, leichte oder Sprachcode (z. B. en)
source_lang varchar(10) Quellsprache (Standard: de)
source_text mediumtext Ursprünglicher Text
result_text mediumtext Transformierter/übersetzter Text
provider varchar(30) KI-Anbieter oder deepl
model varchar(80) Verwendetes Modell
tokens_used int Verbrauchte Token
readability decimal(5,2) Ø Wörter/Satz (niedriger = einfacher)
source_uid int UID des Quell-Datensatzes
source_table varchar(80) Tabelle des Quell-Datensatzes
source_field varchar(80) Feld des Quell-Datensatzes
crdate / tstamp int Unix-Timestamps

Unique-Key: (content_hash, target) — Einfache, Leichte und alle Sprachcodes teilen sich dieselbe Tabelle kollisionsfrei.

Cache-Invalidierung: AfterDatabaseOperationsEvent → InvalidateTextCache löscht alle Einträge zum betroffenen Datensatz, wenn header oder bodytext gespeichert werden.

tx_ntlingua_glossary

Spalte Beschreibung
source_term Quellbegriff (vor Vergleich normalisiert)
target_lang Zielsprache (ISO-Code) oder * für alle Sprachen
target_term Festgelegte Übersetzung (bei ignore-Modus leer lassen)
mode fixed (erzwingen) oder ignore (unverändert lassen)

Glossar-Einträge werden vor Cache und Provider ausgewertet. target_lang='*' gilt sprachunabhängig für alle DOM-Übersetzungen.

tx_ntlingua_ratelimit

Zähler mit festen Zeitfenstern für die öffentlichen Endpunkte (seit 1.1.0). Eine Zeile je Dimension und Fenster (bucket), verbrauchte Einheiten (used) und Löschzeitpunkt (expires). Geändert wird nur über ein bedingtes UPDATE … SET used = used + n WHERE used <= max - n; parallele Requests können ein Limit daher nicht überschreiten.

tt_content / pages (Erweiterungsfelder)

Feld Beschreibung
tx_ntlingua_mt 1 = maschinell übersetzt (readonly im Backend)
tx_ntlingua_srchash Hash der Quellfelder zum Zeitpunkt der Overlay-Erzeugung — ermöglicht Delta-Erkennung

CLI-Referenz

ntlingua:warmup

vendor/bin/typo3 ntlingua:warmup [--target=<ziel>] [--limit=<n>]
Option Standard Beschreibung
--target einfache einfache, leichte oder ISO-Sprachcode
--limit 0 Max. Datensätze (0 = alle)

Verarbeitet alle tt_content-Datensätze mit sys_language_uid=0, deleted=0, hidden=0. Cache-Hits werden übersprungen — der Befehl ist idempotent und kann als nächtlicher Scheduler-Task ausgeführt werden.

# Einfache Sprache für alle Content-Elemente vorwärmen:
vendor/bin/typo3 ntlingua:warmup --target=einfache

# Englische Übersetzung, maximal 500 Datensätze:
vendor/bin/typo3 ntlingua:warmup --target=en --limit=500

ntlingua:cache-purge

vendor/bin/typo3 ntlingua:cache-purge --target=uk,sr [--model=gpt-4o-mini] [--dry-run]

Löscht gecachte Übersetzungen der angegebenen Zielsprachen (seit 1.4.3), damit der nächste ntlingua:warmup-dom sie neu übersetzt. Für Ergebnisse, die falsch sind, obwohl der Aufruf gelang — der Cache-Schlüssel kennt Text, Ziel und Modell, nicht den Prompt. --target ist Pflicht; ein Löschen aller Sprachen gibt es bewusst nicht.

ntlingua:overlays

vendor/bin/typo3 ntlingua:overlays --language=<uid> --code=<iso>
Option Beschreibung
--language sys_language_uid der Zielsprache (muss in TYPO3-Site-Konfiguration vorhanden sein)
--code ISO-Sprachcode (z. B. en, fr)

Erzeugt verbundene l10n-Overlays (connected mode). Bei Wiederholung werden nur geänderte Datensätze verarbeitet (Delta-Erkennung via tx_ntlingua_srchash).

Übersetzte Felder:

Tabelle Felder
pages title, nav_title, subtitle, seo_title, description
tt_content header, subheader, bodytext

Warning

ntlingua:overlays im Live-Workspace ausführen, nicht in Draft-Workspaces.

JavaScript-API

// Sprache oder Modus wechseln:
NtLingua.transform('fr', buttonElement);   // DOM-Übersetzung
NtLingua.transform('einfache', btn);       // Einfache Sprache
NtLingua.transform('de');                  // Reset zur Ausgangssprache

// Aktuellen Zustand abfragen:
NtLingua.getCurrent();   // z. B. 'de', 'fr', 'einfache'

Custom Events

document.addEventListener('NtLingua:ready', (e) => {
    console.log('Initialisiert, aktives Ziel:', e.detail.target);
});
document.addEventListener('NtLingua:done', (e) => {
    console.log('Umschaltung abgeschlossen:', e.detail.target);
});

Eigenen Übersetzer hinzufügen

TranslatorInterface implementieren und in Services.yaml als Alias registrieren:

namespace MyVendor\MyExt\Translator;

use Netthinks\NtLingua\Translator\TranslatorInterface;

final class MyTranslator implements TranslatorInterface
{
    public function translateBatch(array $texts, string $sourceLang, string $targetLang): array { … }
    public function getName(): string  { return 'my-translator'; }
    public function getModel(): string { return 'my-model'; }
}
# Services.yaml der eigenen Extension:
Netthinks\NtLingua\Translator\TranslatorInterface:
  alias: MyVendor\MyExt\Translator\MyTranslator

Fluid-Wrapper (Pflicht für JS-Feldtausch)

Content-Felder müssen in FSC-Template-Overrides mit data-ntlingua-*-Attributen umschlossen werden:

<!-- bodytext (z. B. in einem EXT:fluid_styled_content Override): -->
<div data-ntlingua-uid="{data.uid}" data-ntlingua-field="bodytext">
    <f:format.html>{data.bodytext}</f:format.html>
</div>

<!-- header: -->
<div data-ntlingua-uid="{data.uid}" data-ntlingua-field="header">
    {data.header}
</div>

Fluid-Overrides unter packages/netthinks/Resources/Private/Extensions/fluid_styled_content/ anlegen.

Sicherheit der öffentlichen Endpunkte

  • ContentAccessGuard entscheidet mit den Regeln des Frontends (PageRepository, RootlineUtility, RecordAccessVoter), ob ein Inhaltselement bzw. eine Seite für einen anonymen Request verwendet werden darf: versteckt, Start-/Stoppzeit, Zugriffsgruppen, „Auf Unterseiten ausdehnen", Site und Sprache. Vor dieser Prüfung wird nichts an einen Anbieter gegeben.
  • TextTransformationService::transformField() liefert für HTML-Quellen bereinigtes HTML und für Textquellen reinen Text — unabhängig von der Antwort des Anbieters; Cache-Treffer werden bei der Ausgabe erneut bereinigt. Wer Text in HTML schreibt, muss escapen (TransformApiMiddleware sendet format, EinfacheSpracheMiddleware escaped vor dem Wiedereinsetzen der Original-Tags).
  • Öffentliche Aufrufer übergeben ein ProviderBudget (aus PublicUsageLimits::budgetFor()); reserviert wird unmittelbar vor jedem Anbieter-Aufruf, auch vor dem LLM-Fallback. Backend-/CLI-Code übergibt keines; die Übersetzer verhalten sich dann genau wie ohne Budgets.
  • Übersetzer, die je Stapel mehr als einmal senden können, implementieren BudgetAwareTranslatorInterface und reservieren selbst vor jedem Senden (LlmTranslator: jeder Teilblock, jede Wiederholung eines leeren Teilblocks und seit 1.4.2 jede Hälfte eines Teilblocks, dessen Antwort nicht passte — falsche Länge, kein JSON-Array oder ein Element, das kein Text ist; halbiert wird, bis die Antwort passt oder ein einzelner Text übrig ist, der dann null bleibt; lehnt das Budget ab, endet das Aufteilen und Erhaltenes bleibt; DeepLTranslator: jede Anfrage, auch Wiederholungen nach 429). Vertrag: BudgetExceededException heißt, der Stapel hat nichts ergeben; eine Ablehnung, nachdem schon gesendet wurde, liefert den Stapel mit unübersetzten Einträgen als Quelltext zurück — TextTransformationService gibt diese weder aus noch cacht es sie. Eine einfache TranslatorInterface-Implementierung gilt als ein einziger Sendevorgang und wird vorab einmal reserviert.
  • RateLimitedProviderBudget belegt bei der ersten Reservierung außerdem einen von maxConcurrentProviderRequests Plätzen (ConcurrencySlots, Zeilen slot_<n> in tx_ntlingua_ratelimit, belegt per bedingtem UPDATE oder durch das anlegende INSERT) und hält ihn bis release(), zur Zerstörung des Objekts oder bis zum Ablauf der Belegungsdauer. Sind alle belegt, folgt ConcurrencyLimitExceededException (eine BudgetExceededException); die API antwortet mit 503. Wettlauftest gegen MariaDB: Tests/Concurrency/concurrency-slots-race.php.
  • Bewusst akzeptierte Einschränkung (keine Behebung): Die DOM-Übersetzung nimmt gerenderte Segmente an, die keine Datensätze sind; ohne domTranslationCacheOnly lässt sich daher beliebiger Text im Rahmen der Budgets übersetzen, und ein Aufrufer kann das globale Budget bis zum Ende des Zeitfensters für alle aufbrauchen.

Tests

composer install
typo3DatabaseDriver=pdo_sqlite vendor/bin/phpunit -c phpunit.xml.dist

Die Functional-Tests laufen auf SQLite mit Fake-Anbietern im Prozess — es wird nie eine kostenpflichtige API angesprochen. Tests/Concurrency/rate-limiter-race.php und Tests/Concurrency/concurrency-slots-race.php prüfen Zähler und Anfrage-Plätze mit echten Parallelprozessen gegen MariaDB/MySQL (siehe Dateiköpfe). Tests/JavaScript/dom-switch.cjs prüft die DOM-Übersetzung im Browser (Playwright, seit 1.4.4): Wechsel zwischen zwei Fremdsprachen im Cache-only-Betrieb bei Netzfehlern und mit einem Slider, der seine Folien neu einhängt — NODE_PATH=<pfad>/node_modules node Tests/JavaScript/dom-switch.cjs.

Barrierefreiheit

  • <html lang> und dir werden bei jedem Umschalten gesetzt (WCAG 3.1.1 / 3.1.2)
  • Sprachnamen als Autonyme mit lang-Attribut je Button/Link
  • Dropdown trägt data-nt-notranslate — Sprachnamen werden beim Übersetzen der Seite nicht selbst übersetzt
  • role="status" / aria-live="polite" für Fehlermeldungen im Navbar-Bereich
  • aria-live="polite" auf dem Disclaimer-Banner
  • aria-busy="true" auf <main> während der Übersetzung (Overlay-Spinner)
  • aria-pressed auf Buttons für den aktiven Zustand; native Sprachen als Links mit aria-current
  • Native Sprachen als Links (semantisch korrekt, ohne JS nutzbar, kein WCAG-3.2.2-Problem)
  • DOM-Sprachen und Vereinfachung als Buttons (kein Seitenaufruf, reines Client-Verhalten)
  • data-ntlingua-native auf nativen Links → localStorage-Cleanup vor der Navigation
  • Voll tastaturbedienbar, sichtbarer Fokus; Widget schließt bei Escape

Bekannte Eigenheiten

PHP: Numerische String-Keys in Arrays

PHP konvertiert Array-Keys, die wie Ganzzahlen aussehen ("1999", "42"), automatisch zu int. In TextTransformationService::translateStrings() kann das im foreach ($missing as $orig => $_)-Loop dazu führen, dass $orig ein int ist, obwohl normalize(string $s) einen string erwartet. Fix: $orig = (string)$orig; am Beginn des Loops.

DeepL: source_lang akzeptiert keine Locale-Varianten

DeepLs source_lang-Parameter akzeptiert nur Basiscodes (EN, DE), keine Locale-Varianten (EN-US, DE-AT). Das <html lang>-Attribut kann Locale-Varianten enthalten (z. B. en-US bei locale: en-US in der TYPO3-Site-Konfiguration). DeepLTranslator kürzt den Source-Code mit explode('-', $sourceLang)[0].

Chrome Autotranslate blockiert den TreeWalker

Chromes eingebauter Übersetzer setzt class="notranslate" auf das <html>-Element. Die ignored()-Funktion stoppt explizit bei document.documentElement und prüft das <html>-Element selbst nicht — andernfalls würde der gesamte Seiteninhalt als ausgeschlossen gewertet und der TreeWalker lieferte einen leeren Text-Pool.

Zwei Sprachwähler-Instanzen (Mobile + Desktop)

Das Partial wird zweimal gerendert (Mobile und Desktop, CSS-gesteuert). setToggleLabel() nutzt querySelectorAll('.nt-lingua-active-label'), um beide Toggle-Buttons synchron zu halten.