Files
appRobotHoming/doc/multilingual.md
2026-06-16 13:55:42 +02:00

9.9 KiB
Raw Blame History

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}`;oderresult.innerHTML = '⚠ Bitte Set auswählen, das verschoben werden soll.'`.
  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.

Es gibt kein Build-Tool (kein Webpack/Vite/React) alles ist Vanilla HTML/JS, das Express als statische Dateien ausliefert (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):

{
  "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:

<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. 6080 Zeilen, kein Tooling nötig:

// 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):

<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:

// 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}"
// 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.

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:

// server/server.js
return res.status(400).json({ errorCode: 'yzMustBeNumbers' });
// 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).