233 lines
9.9 KiB
Markdown
233 lines
9.9 KiB
Markdown
# Mehrsprachigkeit (DE/EN) – Vorschlag
|
||
|
||
## Ausgangslage
|
||
|
||
Die App ist heute komplett deutschsprachig, und zwar an drei verschiedenen Stellen, die unterschiedlich behandelt werden müssen:
|
||
|
||
1. **Statisches HTML-Markup** – Labels, Überschriften, `placeholder`/`title`-Attribute direkt in
|
||
`public/*.html` (`index.html`, `homing.html`, `calibration*.html`, …).
|
||
Beispiel: `<h2>Aktionen</h2>`, `<button title="Erst Homing ausführen">`.
|
||
2. **Dynamisch erzeugte Strings in Client-JS** – Template-Literals in `public/client.js`,
|
||
`public/homing.js`, `public/calibration.js` etc., z. B.
|
||
`txt.textContent = text || \`Schritt ${step} / ${total}\`;` oder
|
||
`result.innerHTML = '<span>⚠ Bitte Set auswählen, das verschoben werden soll.</span>'`.
|
||
3. **Server-seitige Strings**, die als JSON an den Client durchgereicht und dort angezeigt werden,
|
||
z. B. `res.status(400).json({ error: '"y" und "z" müssen Zahlen sein.' })` in
|
||
[server/server.js](../server/server.js).
|
||
|
||
Es gibt kein Build-Tool (kein Webpack/Vite/React) – alles ist Vanilla HTML/JS, das Express
|
||
als statische Dateien ausliefert ([server/server.js](../server/server.js)). Jede Lösung sollte
|
||
also **ohne Build-Schritt** funktionieren und sich inkrementell einführen lassen (nicht „big bang“,
|
||
sondern Seite für Seite).
|
||
|
||
## Zielbild
|
||
|
||
- Sprache wird primär aus `navigator.language` (Browser-Einstellung) bestimmt, mit manuellem
|
||
Override (Dropdown/Flag-Icon in der Topbar) und Persistenz in `localStorage`.
|
||
- Fallback-Sprache ist Deutsch (aktueller Stand), zweite Sprache Englisch.
|
||
- Übersetzungen liegen in einfachen JSON-Dateien, kein zusätzliches npm-Paket nötig
|
||
(es reicht ein paar Dutzend Zeilen eigener Lade-/Ersetzungslogik).
|
||
- Server-Fehlermeldungen werden nicht als fertiger Text geschickt, sondern als
|
||
**Fehlercode**, den der Client anhand der aktuellen Sprache übersetzt.
|
||
|
||
## 1. Struktur der Sprachdateien
|
||
|
||
```
|
||
public/
|
||
i18n/
|
||
de.json
|
||
en.json
|
||
i18n.js <- kleine Lade-/Helper-Bibliothek, von jeder Seite eingebunden
|
||
```
|
||
|
||
`de.json` (Auszug, Keys gruppiert nach Seite/Bereich, „flach mit Punktnotation“ ist für diese
|
||
Größenordnung einfacher zu pflegen als tief verschachteltes JSON):
|
||
|
||
```json
|
||
{
|
||
"common.back": "← Zurück",
|
||
"common.error": "Fehler",
|
||
"index.actions.title": "Aktionen",
|
||
"index.actions.runHoming": "📷 Foto & Homing berechnen",
|
||
"index.actions.sendToRobot": "✅ An Roboter senden",
|
||
"index.actions.sendDisabledTitle": "Erst Homing ausführen",
|
||
"index.status.idle": "○ Warte",
|
||
"index.status.running": "● Läuft …",
|
||
"calibration.move.selectSetWarning": "⚠ Bitte Set auswählen, das verschoben werden soll.",
|
||
"calibration.move.sameSetWarning": "⚠ \"Bleibt\" und \"verschoben\" dürfen nicht dasselbe Set sein.",
|
||
"server.error.yzMustBeNumbers": "\"y\" und \"z\" müssen Zahlen sein.",
|
||
"server.error.invalidAxisPayload": "Ungültige Nutzlast: axis.dir und axis.referencePoint erwartet"
|
||
}
|
||
```
|
||
|
||
`en.json` hat exakt dieselben Keys mit englischen Werten. Das ist die einzige Stelle, an der eine
|
||
fehlende Übersetzung sofort auffällt: Key in `de.json` ohne Pendant in `en.json` → Fallback auf
|
||
Deutsch (siehe unten), kein Crash.
|
||
|
||
**Warum nicht ein JSON pro HTML-Seite?** Weil viele Strings (Status-Badges, Fehlermeldungen,
|
||
Buttons wie „Zurück“) seitenübergreifend wiederverwendet werden. Eine flache `common.*`-Gruppe plus
|
||
eine Gruppe pro Seite (`index.*`, `homing.*`, `calibration.*`) hält das überschaubar, ohne dass man
|
||
Strings doppelt pflegt.
|
||
|
||
## 2. Statisches HTML markieren
|
||
|
||
Jedes übersetzbare Element bekommt ein `data-i18n`-Attribut mit dem Key. Für Attribute (z. B.
|
||
`title`, `placeholder`) gibt es ein zusätzliches `data-i18n-attr`:
|
||
|
||
```html
|
||
<h2 data-i18n="index.actions.title">Aktionen</h2>
|
||
|
||
<button id="btn-homing-send" disabled
|
||
data-i18n="index.actions.sendToRobot"
|
||
data-i18n-attr-title="index.actions.sendDisabledTitle"
|
||
title="Erst Homing ausführen">
|
||
✅ An Roboter senden
|
||
</button>
|
||
```
|
||
|
||
Wichtig: Der **deutsche Text bleibt im HTML stehen** (als Fallback/Default und damit die Seite ohne
|
||
JS oder bei Ladefehler nicht leer ist). `i18n.js` ersetzt den Inhalt nur, wenn die Zielsprache nicht
|
||
Deutsch ist bzw. wenn ein Override gesetzt wurde. Das macht die Migration risikoarm: Man kann Seite
|
||
für Seite Attribute ergänzen, ohne dass etwas kaputtgeht, falls eine Seite noch nicht migriert ist.
|
||
|
||
## 3. `i18n.js` – minimale Laufzeit-Bibliothek
|
||
|
||
Kernfunktionen, ca. 60–80 Zeilen, kein Tooling nötig:
|
||
|
||
```js
|
||
// public/i18n.js
|
||
const SUPPORTED = ['de', 'en'];
|
||
const FALLBACK = 'de';
|
||
|
||
function detectLang() {
|
||
const stored = localStorage.getItem('lang');
|
||
if (stored && SUPPORTED.includes(stored)) return stored;
|
||
const nav = (navigator.language || FALLBACK).slice(0, 2).toLowerCase();
|
||
return SUPPORTED.includes(nav) ? nav : FALLBACK;
|
||
}
|
||
|
||
let dict = {};
|
||
let fallbackDict = {};
|
||
|
||
export async function initI18n() {
|
||
const lang = detectLang();
|
||
[dict, fallbackDict] = await Promise.all([
|
||
fetch(`/i18n/${lang}.json`).then(r => r.json()),
|
||
lang === FALLBACK ? Promise.resolve({}) : fetch(`/i18n/${FALLBACK}.json`).then(r => r.json()),
|
||
]);
|
||
document.documentElement.lang = lang;
|
||
applyToDom();
|
||
return lang;
|
||
}
|
||
|
||
export function t(key, vars) {
|
||
let str = dict[key] ?? fallbackDict[key] ?? key; // Key selbst als letzter Fallback -> sichtbar im UI statt "undefined"
|
||
if (vars) for (const [k, v] of Object.entries(vars)) str = str.replaceAll(`{${k}}`, v);
|
||
return str;
|
||
}
|
||
|
||
function applyToDom(root = document) {
|
||
root.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); });
|
||
root.querySelectorAll('[data-i18n-attr-title]').forEach(el => { el.title = t(el.dataset.i18nAttrTitle); });
|
||
root.querySelectorAll('[data-i18n-attr-placeholder]').forEach(el => { el.placeholder = t(el.dataset.i18nAttrPlaceholder); });
|
||
}
|
||
|
||
export async function setLang(lang) {
|
||
localStorage.setItem('lang', lang);
|
||
location.reload(); // einfach & robust, kein dynamisches Re-Rendering nötig bei dieser App-Größe
|
||
}
|
||
```
|
||
|
||
Einbindung pro Seite (vor dem bestehenden Seiten-Script):
|
||
|
||
```html
|
||
<script type="module" src="/i18n.js"></script>
|
||
<script type="module">
|
||
import { initI18n } from '/i18n.js';
|
||
await initI18n();
|
||
</script>
|
||
<script src="/client.js"></script>
|
||
```
|
||
|
||
Da `client.js`/`homing.js`/`calibration.js` aktuell keine ES-Module sind, reicht es, `initI18n()`
|
||
in einem kleinen Inline-`<script type="module">` vor dem normalen `<script>`-Tag auszuführen –
|
||
kein Umbau der bestehenden Dateien zu Modulen nötig (DOM ist zu diesem Zeitpunkt geparst, weil
|
||
Module standardmäßig wie `defer` laufen).
|
||
|
||
## 4. Dynamische Strings in Client-JS
|
||
|
||
Für Strings, die per JS erzeugt werden (Status-Badges, Fehlermeldungen, generierte Tabellen),
|
||
wird `t()` global verfügbar gemacht (z. B. `window.t = t;` am Ende von `i18n.js`) und an den
|
||
Stellen, an denen heute literal Deutsch steht, durch einen Key-Aufruf ersetzt:
|
||
|
||
```js
|
||
// vorher (public/homing.js)
|
||
if (txt) txt.textContent = text || `Schritt ${step} / ${total}`;
|
||
|
||
// nachher
|
||
if (txt) txt.textContent = text || t('homing.progress.step', { step, total });
|
||
// "homing.progress.step": "Schritt {step} / {total}" / "Step {step} of {total}"
|
||
```
|
||
|
||
```js
|
||
// vorher (public/calibration.js)
|
||
result.innerHTML = '<span style="color:#f87171">⚠ Bitte Set auswählen, das verschoben werden soll.</span>';
|
||
|
||
// nachher
|
||
result.innerHTML = `<span style="color:#f87171">⚠ ${t('calibration.move.selectSetWarning')}</span>`;
|
||
```
|
||
|
||
Das ist die aufwändigste Stelle (laut grep aktuell **deutlich über 1000 Treffer** für deutsche
|
||
Wortfragmente über alle `public/*.js`-Dateien), aber rein mechanisch und gut inkrementell machbar:
|
||
eine Datei nach der anderen, jeweils committed und getestet.
|
||
|
||
## 5. Server-seitige Strings (Fehlermeldungen)
|
||
|
||
`server/server.js` schickt aktuell fertige deutsche Sätze im JSON zurück, z. B.
|
||
|
||
```js
|
||
return res.status(400).json({ error: '"y" und "z" müssen Zahlen sein.' });
|
||
```
|
||
|
||
Diese Strings landen direkt im UI (`infoEl.textContent = data.error` o. ä.) – der Server kann aber
|
||
die Browsersprache des Clients nicht zuverlässig kennen (und sollte sie auch nicht raten müssen).
|
||
Saubere Lösung: Server schickt einen **Code**, Client übersetzt:
|
||
|
||
```js
|
||
// server/server.js
|
||
return res.status(400).json({ errorCode: 'yzMustBeNumbers' });
|
||
```
|
||
|
||
```js
|
||
// Client, z. B. client.js / homing.js
|
||
const msg = data.errorCode ? t(`server.error.${data.errorCode}`) : (data.error ?? t('common.unknownError'));
|
||
```
|
||
|
||
Migration ist optional und kann zuletzt erfolgen – bis dahin einfach `error` (deutsch) weiter
|
||
durchreichen und im Client mit `data.error ?? t(...)` abfangen, damit nichts bricht, während man
|
||
Stück für Stück auf `errorCode` umstellt.
|
||
|
||
## 6. Sprachumschalter in der UI
|
||
|
||
Kleines Dropdown/Flag-Toggle in der Topbar (`calib-topbar` existiert schon in mehreren Seiten),
|
||
das `setLang('en')` / `setLang('de')` aufruft. Reicht als erster Schritt; ein automatisches
|
||
Re-Rendering ohne Reload ist bei dieser Seitenanzahl unnötiger Aufwand.
|
||
|
||
## 7. Reihenfolge der Umsetzung (Vorschlag)
|
||
|
||
1. `public/i18n/de.json` aus den **aktuellen** deutschen Strings extrahieren (1:1, keine
|
||
Textänderung) + `i18n.js` Lib bauen. Keine sichtbare Änderung im Verhalten.
|
||
2. `public/i18n/en.json` befüllen (Übersetzung).
|
||
3. Seite für Seite umstellen, anzufangen bei `index.html` + `client.js` (am meisten genutzt),
|
||
danach `homing.html`/`homing.js`, danach `calibration*.html`/`calibration.js`.
|
||
4. Sprachumschalter in der Topbar ergänzen.
|
||
5. Server-Fehlermeldungen optional zuletzt auf `errorCode` umstellen.
|
||
|
||
## Warum keine Library wie i18next?
|
||
|
||
Für die Größe dieser App (kein Build-Schritt, ~5 HTML-Seiten, kein Framework) wäre i18next/
|
||
FormatJS deutlich mehr Gewicht (zusätzliche Dependency, Build-Integration, ICU-Syntax) als Nutzen.
|
||
Die ~80 Zeilen oben decken Pluralisierung zwar nicht ab, aber dafür gibt es in der App aktuell
|
||
keinen Bedarf (keine zählerabhängigen Sätze wie „1 Marker“ vs. „3 Marker“ – falls das später
|
||
gebraucht wird, reicht eine simple `count === 1 ? key + '.one' : key + '.many'`-Konvention).
|