Developer Guide¶
Architecture¶
Frontend-Request
│
├─ POST /nt-lingua/transform ─→ TransformApiMiddleware
│ │
│ ├─ isLanguageTarget(target) = true ─→ TextTransformationService::translateStrings()
│ └─ isLanguageTarget(target) = false ─→ TextTransformationService::transformField()
│
└─ GET page request (cookie/param)
└─ TransformProcessor (DataProcessing) ─→ transformField() [server-side fallback]
TextTransformationService
├─ einfache / leichte ─→ AiProviderInterface (nt_ai) + Prompts
└─ language codes ─→ TranslatorInterface (LLM/DeepL) + Glossary
TextCacheRepository
└─ tx_ntlingua_text_cache (UNIQUE: content_hash + target)
Two coupling points:
- Frontend → Server:
POST /nt-lingua/transformhandled byTransformApiMiddleware - Server → AI/Translation:
TranslatorInterface(LLM or DeepL) +AiProviderInterface(nt-ai)
nt_lingua does not reference any concrete AI provider — the only external coupling is Netthinks\NtAi\Provider\AiProviderInterface.
Response format and monitoring¶
POST /nt-lingua/transform with a language target responds:
{
"target": "fr",
"translations": { "source text": "translation" },
"untranslated": 0,
"degraded": false
}
| Field / header | Meaning |
|---|---|
translations |
Source text → translation. A segment that could not be translated carries its source text, so the page stays complete. |
untranslated |
Number of segments without a translation |
degraded |
true as soon as at least one segment failed |
X-NtLingua-Untranslated |
The same count as a header — for checks without parsing JSON |
Status codes: A partial failure responds 200 (the translated part is still useful), a complete failure responds 503. This makes no difference to the rendering — the client script does not evaluate the status, it applies whatever it receives and otherwise leaves the page in the source language.
The budget and load limits add their own status codes: 429 when the quota is used up, 503 with Retry-After when all concurrent provider slots are taken. Monitoring should therefore read untranslated or the header rather than the status alone.
Why these signals exist: without them an outage is invisible from the outside. The response then carries the source texts and looks like a complete translation.
Failed segments are not cached — the next call retries them. Every failure also adds an entry to tx_ntlingua_log whose error field states the count. That count refers to the provider call, i.e. only the segments not already cached; the request-level number is untranslated.
Data model¶
tx_ntlingua_text_cache¶
| Column | Type | Description |
|---|---|---|
content_hash |
varchar(64) | sha256(normalize(source) \| target \| model) |
target |
varchar(20) | einfache, leichte or language code (e.g. en) |
source_lang |
varchar(10) | Source language (default: de) |
source_text |
mediumtext | Original text |
result_text |
mediumtext | Transformed/translated text |
provider |
varchar(30) | AI provider or deepl |
model |
varchar(80) | Model used |
tokens_used |
int | Tokens consumed |
readability |
decimal(5,2) | Avg. words per sentence (lower = simpler) |
source_uid |
int | UID of the source record |
source_table |
varchar(80) | Table of the source record |
source_field |
varchar(80) | Field of the source record |
crdate / tstamp |
int | Unix timestamps |
Unique key: (content_hash, target) — Einfache, Leichte and all language codes share the table without collision.
Cache invalidation: AfterDatabaseOperationsEvent → InvalidateTextCache deletes entries for the affected record when header or bodytext is saved.
tx_ntlingua_glossary¶
| Column | Description |
|---|---|
source_term |
Source term (normalised before comparison) |
target_lang |
Target language (ISO code) or * for all languages |
target_term |
Fixed translation (leave empty for ignore mode) |
mode |
fixed (force) or ignore (leave unchanged) |
Glossary entries are evaluated before the cache and the translation provider. target_lang='*' applies language-independently to all DOM translations.
tx_ntlingua_ratelimit¶
Fixed-window counters for the public endpoints (since 1.1.0). One row per dimension and window (bucket), the consumed units (used) and the purge time (expires). Rows are only changed through a conditional UPDATE … SET used = used + n WHERE used <= max - n, so parallel requests cannot exceed a limit.
tt_content / pages extension fields¶
| Field | Description |
|---|---|
tx_ntlingua_mt |
1 = machine-translated (readonly in backend) |
tx_ntlingua_srchash |
Hash of source fields at overlay creation time — enables delta detection |
CLI reference¶
ntlingua:warmup¶
| Option | Default | Description |
|---|---|---|
--target |
einfache |
einfache, leichte, or an ISO language code |
--limit |
0 |
Max. records to process (0 = all) |
Processes all tt_content records with sys_language_uid=0, deleted=0, hidden=0. Cache hits are skipped — the command is idempotent and safe to run as a nightly scheduler task.
# Pre-fill Einfache Sprache for all content elements:
vendor/bin/typo3 ntlingua:warmup --target=einfache
# Pre-fill English translation, at most 500 records:
vendor/bin/typo3 ntlingua:warmup --target=en --limit=500
ntlingua:cache-purge¶
Removes cached translations of the given target languages (since 1.4.3), so the next ntlingua:warmup-dom translates them again. For results that are wrong although the call succeeded — the cache key knows text, target and model, not the prompt. --target is required; there is deliberately no "all languages".
ntlingua:overlays¶
| Option | Description |
|---|---|
--language |
sys_language_uid of the target language (must exist in TYPO3 site config) |
--code |
ISO language code (e.g. en, fr) |
Creates connected-mode l10n overlays. Only changed records are processed on repeated runs (delta detection via tx_ntlingua_srchash).
Translated fields:
| Table | Fields |
|---|---|
pages |
title, nav_title, subtitle, seo_title, description |
tt_content |
header, subheader, bodytext |
Warning
Run ntlingua:overlays in the live workspace, not in draft workspaces.
JavaScript API¶
// Switch language or mode:
NtLingua.transform('fr', buttonElement); // DOM translation
NtLingua.transform('einfache', btn); // Plain language
NtLingua.transform('de'); // Reset to source language
// Query current state:
NtLingua.getCurrent(); // e.g. 'de', 'fr', 'einfache'
Custom events¶
document.addEventListener('NtLingua:ready', (e) => {
console.log('Initialised, active target:', e.detail.target);
});
document.addEventListener('NtLingua:done', (e) => {
console.log('Switch complete:', e.detail.target);
});
Adding a custom translator¶
Implement TranslatorInterface and register it as an alias in Services.yaml:
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 of your extension:
Netthinks\NtLingua\Translator\TranslatorInterface:
alias: MyVendor\MyExt\Translator\MyTranslator
Fluid wrappers (required for DOM field swap)¶
Content fields must be wrapped with data-ntlingua-* attributes in FSC template overrides:
<!-- bodytext (e.g. in an 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>
Place Fluid overrides under packages/netthinks/Resources/Private/Extensions/fluid_styled_content/.
Security of the public endpoints¶
ContentAccessGuarddecides with the frontend's own rules (PageRepository,RootlineUtility,RecordAccessVoter) whether a content element or page may be used for an anonymous request: hidden, start/stop time, access groups, "extend to subpages", site and language. Nothing is loaded into a provider before that check.TextTransformationService::transformField()returns sanitized HTML for HTML sources and plain text for plain sources — independent of the provider answer; cache hits are sanitized again on output. Consumers that write into HTML must escape plain text (TransformApiMiddlewaresendsformat,EinfacheSpracheMiddlewareescapes before restoring the page's own tags).- Public callers pass a
ProviderBudget(fromPublicUsageLimits::budgetFor()); it is reserved right before every provider call, including the LLM fallback. Backend/CLI code passes none, and translators then behave exactly as without budgets. - Translators that may send more than once per batch implement
BudgetAwareTranslatorInterfaceand reserve themselves before each send (LlmTranslator: every chunk, every resend of an empty chunk and, since 1.4.2, every half of a chunk whose answer did not fit — wrong length, no JSON array, or an element that is not text; chunks are halved until the answer fits or a single string is left, which then staysnull; when the budget refuses, splitting stops and what arrived is kept;DeepLTranslator: every request, including resends after 429). Contract:BudgetExceededExceptionmeans the batch produced nothing; a refusal after something was sent returns the batch with untranslated entries left as source text, whichTextTransformationServiceneither returns nor caches. A plainTranslatorInterfaceimplementation is treated as a single send and reserved once up front. RateLimitedProviderBudgetalso takes one ofmaxConcurrentProviderRequestsslots (ConcurrencySlots, rowsslot_<n>intx_ntlingua_ratelimit, taken by a conditionalUPDATEor the creatingINSERT) on its first reservation, and holds it untilrelease(), its destruction or the lease timeout. A full house raisesConcurrencyLimitExceededException(aBudgetExceededException); the API answers 503. Race test against MariaDB:Tests/Concurrency/concurrency-slots-race.php.- Accepted limitation (not a fix): DOM translation accepts rendered segments that are not records, so arbitrary text can be translated within the budgets unless
domTranslationCacheOnlyis enabled; a caller can also use up the global budget for everyone until the window ends.
Tests¶
Functional tests run on SQLite with in-process fake providers — no paid API is ever contacted. Tests/Concurrency/rate-limiter-race.php and Tests/Concurrency/concurrency-slots-race.php check the counters and the concurrency slots with real parallel processes against MariaDB/MySQL (see the file headers). Tests/JavaScript/dom-switch.cjs checks the DOM translation in the browser (Playwright, since 1.4.4): switching between two foreign languages in cache-only mode on network errors and with a slider that re-appends its slides — NODE_PATH=<path>/node_modules node Tests/JavaScript/dom-switch.cjs.
Accessibility¶
<html lang>anddirare updated on every switch (WCAG 3.1.1 / 3.1.2)- Language names rendered as autonyms with per-button
langattributes - The dropdown carries
data-nt-notranslate— language names are not translated when the page itself is translated role="status"/aria-live="polite"for error messages in the navbar areaaria-live="polite"on the disclaimer banneraria-busy="true"on<main>during translation (overlay spinner)aria-pressedon buttons for active state; native languages as links witharia-current- Native languages as links (semantically correct, usable without JS, no WCAG 3.2.2 issue)
- DOM languages and Plain Language as buttons (no page load, pure client-side behaviour)
data-ntlingua-nativeon native links → localStorage cleanup before navigation- Fully keyboard-navigable, visible focus; widget closes on Escape
Known issues¶
PHP: numeric string keys in arrays¶
PHP automatically converts array keys that look like integers ("1999", "42") to int. In TextTransformationService::translateStrings() this can cause $orig to be int in the foreach ($missing as $orig => $_) loop, even though normalize(string $s) expects a string. Fix: add $orig = (string)$orig; at the top of the loop.
DeepL: source_lang does not accept locale variants¶
DeepL's source_lang parameter only accepts base codes (EN, DE), not locale variants (EN-US, DE-AT). The <html lang> attribute may contain a locale variant (e.g. en-US when locale: en-US is set in the TYPO3 site configuration). DeepLTranslator truncates the source code with explode('-', $sourceLang)[0].
Chrome Autotranslate blocks the TreeWalker¶
Chrome's built-in translator sets class="notranslate" on the <html> element. The ignored() function explicitly stops at document.documentElement and does not check the <html> element itself — otherwise the entire page content would be treated as excluded and the TreeWalker would return an empty text pool.
Two switcher instances (mobile + desktop)¶
The partial is rendered twice (mobile and desktop, controlled by CSS). setToggleLabel() uses querySelectorAll('.nt-lingua-active-label') to keep both toggle buttons in sync.