UI-Lokalisierung — eine Sprache hinzufügen
Wie die Übersetzungen der Owlat-Oberfläche funktionieren: wo die vier Message-Kataloge liegen, wie Sie eine Locale hinzufügen, welche Regeln für übersetzbare Strings gelten und wie die Sprache einer Besucherin erkannt, gewechselt und deklariert wird.
UI-Lokalisierung — eine Sprache hinzufügen
Owlat hat zwei voneinander unabhängige Übersetzungssysteme. Verwechseln Sie sie nicht:
| System | Was es übersetzt | Wo es lebt |
|---|---|---|
| Übersetzungen von E-Mail-Inhalten | Die Kampagnen, Templates und Blöcke, die Sie versenden | Pro Workspace, in Convex gespeichert — siehe Übersetzungen in der Produktanleitung |
| UI-Lokalisierung (diese Seite) | Die Owlat-Oberfläche, die Ihr Team und Ihre Empfänger lesen | Ein Katalog pro App plus einer für die gemeinsame UI-Schicht, mit dem Code ausgeliefert — siehe Wo was liegt |
Die UI-Schicht ist @nuxtjs/i18n. en ist die Standard-Locale und der Fallback für jede andere, sodass ein nicht übersetzter Schlüssel seinen englischen Text rendert statt eines rohen Schlüsselpfads.
Wo was liegt
Es gibt vier Kataloge, einen je Nuxt-App plus einen für die Schicht, die alle erweitern. Jeder ist ein eigenes Verzeichnis, und jede App registriert ihre eigene Locale-Liste:
apps/web/i18n/locales/{en,de}.json # das Produkt: Dashboard, Postbox, Admin, Auth, Empfängerseiten
apps/marketing/i18n/locales/{en,de}.json # die öffentliche Website (owlat.app)
apps/docs/i18n/locales/{en,de}.json # die Rahmenoberfläche dieser Website — die SEITEN liegen in content/{en,de}/
packages/ui/i18n/locales/{en,de}.json # die gemeinsame UI-Schicht (`ui.*`), von allen dreien geerbt
Eine einzelne App sieht so aus:
apps/web/
├── nuxt.config.ts # the `i18n` block: locale list, strategy, detection
└── i18n/
├── i18n.config.ts # vue-i18n runtime options (fallbackLocale, warnings)
└── locales/
├── en.json # source of truth
└── de.json # a translation
Locale-Dateien werden lazy geladen: Ein Besucher lädt nur den Katalog der Locale herunter, die ihm tatsächlich angezeigt wird.
In der App sind Routen nicht mit einem Präfix versehen (strategy: 'no_prefix'). Jede Owlat-URL dort ist entweder eine Dashboard-Route oder ein Token-Link, der bereits in einer versendeten E-Mail abgedruckt wurde (/unsubscribe?token=…), sodass ein /de/-Segment Live-Links zerstören würde. Die Marketing-Website und diese Dokumentationswebsite setzen stattdessen ein Präfix (prefix_except_default); die Begründung steht weiter unten unter Diese Dokumentationswebsite.
Die gemeinsame UI-Schicht
packages/ui ist ein Nuxt-Layer, seine Copy (ui.* — Schließen-Buttons von Dialogen, Alert-Überschriften, „Erneut versuchen“) kann also in keinem einzelnen App-Katalog leben. Die Schicht steuert ihre eigenen i18n.locales aus packages/ui/nuxt.config.ts bei, und @nuxtjs/i18n merged die Dateilisten pro Locale mit der Datei der Schicht zuerst und der App-Datei zuletzt — eine App kann jede ui.*-Message also überschreiben, indem sie den Schlüssel in ihrem eigenen Katalog erneut deklariert.
Die Schicht führt @nuxtjs/i18n nicht in ihren modules: Das Modul wird von den konsumierenden Apps installiert, und ein Layer darf es einer App, die i18n noch nicht übernommen hat, nicht aufzwingen. Deshalb übersetzen die Komponenten der Schicht über useUiI18n() (packages/ui/composables/useUiI18n.ts) statt direkt über useI18n() — es fällt auf den mitgelieferten englischen Katalog zurück, wenn es gar keine vue-i18n-Instanz gibt oder wenn deren Messages kein ui.* enthalten (so sieht der Komponententest einer App aus). Der Preis dieses Fallbacks: Eine kaputte Verdrahtung der Schicht liest sich als unübersetztes Englisch, nicht als sichtbare Schlüsselpfade — prüfen Sie die Verdrahtung deshalb an der deutschen Copy: Der Schließen-Button eines Dialogs muss „Dialog schließen“ heißen.
Die Marketing-Website
apps/marketing folgt denselben Regeln und registriert @nuxtjs/i18n als Erstes in modules, damit die SEO-Module (Sitemap, robots, og-image) die Locale-Liste beim Aufsetzen sehen und Einträge pro Locale ausgeben. i18n.baseUrl ist die kanonische Website-URL — sie macht die hreflang-Alternates absolut. Die Browsererkennung ist dort bewusst aus: Die Sprache wird über den Umschalter in der Kopfzeile gewählt, Suchmaschinen werden durch die Alternates auf die richtige Variante gelenkt, und eine automatische Weiterleitung anhand von Accept-Language würde einen deutsch konfigurierten Browser von einem Link wegwerfen, der ausdrücklich als englische Seite geteilt wurde.
Diese Dokumentationswebsite
Die Docs (apps/docs) folgen denselben Regeln mit zwei Ergänzungen, weil sie Seiten übersetzen, nicht nur die Rahmenoberfläche.
URLs tragen ein Präfix (strategy: 'prefix_except_default'). Englisch behält die URLs, die es immer schon veröffentlicht hat — /guide/quick-start —, und Deutsch lebt unter /de/guide/quick-start. Nichts davon wurde je in einer E-Mail abgedruckt, ein Locale-Segment ist also kostenlos, und genau das erlaubt es, eine deutsche Seite zu teilen, zu indexieren und mit einer eigenen hreflang-Alternate zu versehen.
Der Markdown-Baum ist pro Locale gespiegelt, jeweils ein Verzeichnis und eine Content-Collection:
apps/docs/
├── content.config.ts # one page collection per locale
├── i18n/
│ ├── i18n.config.ts
│ └── locales/{en,de}.json
└── content/
├── en/1.guide/2.quick-start.md # → /guide/quick-start
└── de/1.guide/2.quick-start.md # → /de/guide/quick-start
Jede Collection entfernt ihr eigenes Locale-Verzeichnis (source: { include: 'de/**/*.md', prefix: '' }), sodass jede Collection dieselben locale-freien Pfade veröffentlicht. Eine Query benennt nie eine Locale im Pfad — sie wählt stattdessen die Collection:
const { locale } = useI18n();
const page = await queryDocsPage(docsCollection(locale.value), '/guide/quick-start');
docsCollection, contentPath (das die /de entfernt, die ein Router-Pfad mitträgt) und die Query-Helfer liegen allesamt in app/composables/useDocsContent.ts. Nutzen Sie diese statt queryCollection direkt — dort wohnt der Fallback.
Eine Übersetzung darf hinterherhinken; eine Seite nicht
Der deutsche Baum ist heute vollständig, und er bleibt auch dann auslieferbar, wenn er es nicht ist: eine fehlende Seite fällt auf ihre englische Quelle zurück, statt einen 404 zu werfen. Das erlaubt es, eine neue englische Seite zu veröffentlichen, ohne auf ihre Übersetzung zu warten. Die Regel gilt überall dort, wo eine Seite gelesen wird:
| Oberfläche | Verhalten |
|---|---|
| Seiteninhalt | Die Seite der Locale, sonst die englische Seite — unter der deutschen URL, in deutscher Rahmenoberfläche |
| Suche | Die Seiten der Locale, aufgefüllt mit englischen Seiten, für die es noch keine Übersetzung gibt |
| Zurück / Weiter | Die Lesereihenfolge kommt immer aus dem Englischen (dem einzigen vollständigen Baum); Titel werden lokalisiert, wo sie existieren |
| Seitenleiste, Breadcrumb, seiteninterne Links | Locale-freie Pfade laufen durch localePath, sodass eine Leserin in /de bleibt |
Die eine Stelle, an der das nicht gilt, ist eine falsche URL: /de/nonsense ist ein echter 404 und rendert app/error.vue.
Beschriftungen der Seitenleiste
app/utils/sidebarConfig.ts bleibt die Single Source of Truth für die Navigation, englische Beschriftungen inklusive. Übersetzungen werden unter einem Schlüssel nachgeschlagen, der aus dem Eintrag abgeleitet wird — sidebar.groups.<section>-<label>, sidebar.items.<path>, beide slugifiziert —, und fallen auf die Beschriftung der Konfiguration zurück, sodass eine neue Seite ohne Katalogänderung Englisch statt eines rohen Schlüsselpfads rendert. __tests__/localeCatalogs.test.ts schlägt bei genau dieser Drift fehl, ebenso bei einem verwaisten Schlüssel und bei zwei Einträgen, die auf denselben Schlüssel zusammenfallen würden.
Eine Locale hinzufügen
- Kopieren Sie den Quellkatalog.
cp apps/web/i18n/locales/en.json apps/web/i18n/locales/fr.json - Registrieren Sie sie in
apps/web/nuxt.config.ts:locales: [ { code: 'en', language: 'en-US', name: 'English', file: 'en.json' }, { code: 'de', language: 'de-DE', name: 'Deutsch', file: 'de.json' }, { code: 'fr', language: 'fr-FR', name: 'Français', file: 'fr.json' }, ],codeist die Kennung, auf die die App umschaltet,languageist das BCP-47-Tag, das inlang/hreflanggeschrieben wird, undnameist das, was die Sprachauswahl anzeigt. Die Sprachauswahl liest genau diese Liste — eine registrierte Locale erscheint dort ohne weiteres Zutun. - Übersetzen Sie die Werte, niemals die Schlüssel. Halten Sie die Objektform identisch zu
en.json— der Katalog-Guard-Test vergleicht die beiden Schlüsselmengen und schlägt bei einem fehlenden oder erfundenen Schlüssel fehl. - Führen Sie die Guards aus.
cd apps/web && npx vitest run app/__tests__/localeCatalogs.test.ts cd apps/docs && npx vitest run __tests__/localeCatalogs.test.ts
Das prüft für jede Locale: vollständige Schlüsselabdeckung gegenüberen, identische Platzhalternamen je Message, kein Markup und kein unescaptes@. - Prüfen Sie die langen Strings in der Oberfläche. Deutsch und Französisch laufen 20–35 % länger als Englisch; Buttons, Schalterzeilen und Karten-Überschriften sind die Stellen, an denen das zuerst auffällt.
Wählen Sie ein Register und bleiben Sie dabei
Jede Locale sollte sich wie eine Stimme lesen. Der ausgelieferte de-Katalog verwendet durchgängig das förmliche Sie, weil die meistgelesenen Oberflächen (Abmeldung, Präferenzzentrum, Double-Opt-in) von Fremden gelesen werden, die sich nie für Owlat entschieden haben. Folgen Sie dem bestehenden Katalog statt den Gewohnheiten einer einzelnen Datei.
Übersetzbare Strings schreiben
Jeder sichtbare String geht durch t(). Importieren Sie es einmal pro Komponente:
<script setup lang="ts">
const { t } = useI18n();
</script>
<template>
<p>{{ t('recipient.preferences.noTopics') }}</p>
</template>
Skriptseitige Strings — Validierungsmeldungen, Fehlertexte, useBackendOperation-Labels, useHead-/useSeoMeta-Titel — laufen über dasselbe t().
Alles, was einmalig beim Setup erfasst wird, nimmt einen Getter, keinen Wert. Ein blankes t('…') in einem Options-Objekt wird ausgewertet, während die Komponente aufgesetzt wird, und dort eingefroren — es behält also die Locale, die beim Mounten zufällig aktiv war. useHead/useSeoMeta und das label von useBackendOperation akzeptieren jeweils () => t('…') und lesen es erst, wenn sie es tatsächlich verwenden:
useHead({ title: () => t('auth.login.pageTitle') });
const { run } = useBackendOperation(api.auth.userProfiles.create, {
label: () => t('auth.register.createProfileOperation'),
});
Interpolieren Sie Werte, verketten Sie niemals Sätze. Die Wortstellung unterscheidet sich je Sprache, deshalb ist ein String, der um einen Wert herum in Präfix und Suffix zerlegt wurde, nicht übersetzbar:
{ "intro": "Update your email preferences for {organization}." }
Wenn ein Satzteil Markup trägt, verwenden Sie <I18nT> mit einem benannten Slot — nicht v-html, das einen ungeescapten Kontakt- oder Organisationsnamen direkt in die Seite rendern würde:
<I18nT keypath="recipient.preferences.intro" tag="span" scope="global">
<template #organization><strong>{{ contactInfo.teamName }}</strong></template>
</I18nT>
Escapen Sie ein wörtliches @. Der Message-Compiler liest @ als Marker für eine verlinkte Message, ein E-Mail-Platzhalter wird deshalb you{'@'}example.com geschrieben.
Kein HTML in Message-Werten. compilation.strictMessage weist es zur Build-Zeit zurück, ein eingeschmuggeltes <strong> ist also ein fehlgeschlagener Build statt eines kaputten Strings.
Formatieren Sie Datums- und Zahlenwerte gegen die aktive Locale, nicht gegen eine fest gepinnte:
const { t, locale } = useI18n();
new Intl.DateTimeFormat(locale.value, { dateStyle: 'long' }).format(sentAt);
Ein Modul, das t() nicht aufrufen kann, gibt einen Schlüssel zurück, keinen Satz. Die Vokabular-Tabellen unter app/utils (deliverabilityRamp, deliverabilityMeasurement, breadcrumbRoutes, …) sind reine Module ohne Komponenteninstanz. Sie geben deshalb einen Katalogschlüssel zurück — oder { key, params }, wenn der Satz Zahlen interpoliert —, und wer ihn rendert, macht Worte daraus:
type LocalizedText = string | { key: string; params?: Record<string, unknown> };
const localized = (value: LocalizedText) =>
typeof value === 'string' ? t(value) : t(value.key, value.params ?? {});
Einen solchen Wert direkt in ein Template zu schreiben malt [object Object] oder einen rohen Schlüsselpfad vor die Leserin. Jede konsumierende Stelle ist also eine Render-Grenze — auch die Nitro-Routen unter server/, die kein vue-i18n haben und dieselben Schlüssel mit localizeEn() (server/utils/localizedText.ts) auf Englisch rendern, für ein Skript, eine Logzeile oder curl.
Registries außerhalb von apps/web verwenden den Namensraum sharedPkg.* — Vokabulare aus packages/shared und Convex-Read-Models halten ihre Schlüssel unter sharedPkg.<Modulname>.*, damit sie niemals mit den oben beschriebenen, aus Dateipfaden abgeleiteten Namensräumen kollidieren. Es gibt zwei Muster, gewählt danach, wer die Texte sonst noch liest:
- Nur im Web verwendete Texte speichern direkt Schlüssel (
operatingModes,snoozePresets, die Zugangsdaten-Felder vonsendProviderCatalog): Das Modul trägt den Schlüssel, die Web-Komponente rendertt(key)— dieselbe Regel wie fürapp/utils. - Texte mit einem Konsumenten außerhalb des Webs behalten ihr Englisch (
featureFlags— die Setup-CLI gibt sie aus;deliverabilityChecklist— Convex speichert und verschickt sie; dieadaptiveDashboard-Karten — ein ausgeliefertes Read Model): Das Modul bleibt englisch, und das Web löst einen abgeleiteten Schlüssel auf (useFeatureCopy(),useDashboardCardCopy()), mit dem Englisch der Registry als Fallback für Einträge, die kein ausgelieferter Katalog kennen kann — etwa von Plugins geprägte Flags. Ein Katalogtest verklammert beide Kopien des englischen Texts, damit sie nicht auseinanderdriften können.
Was extrahiert ist
Alles. Die Extraktion landete Oberfläche für Oberfläche und ist abgeschlossen:
| Oberfläche | Stand |
|---|---|
apps/web — Dashboard, Postbox, Zustellung und Admin, Setup- und Desktop-Abläufe, Auth, Willkommen, empfängerseitige Seiten | Extrahiert |
apps/marketing — die öffentliche Website | Extrahiert |
apps/docs — die Rahmenoberfläche dieser Website und die Seiten selbst unter content/{en,de}/ | Extrahiert und übersetzt |
packages/ui — die eigene Copy der gemeinsamen Schicht (ui.*) | Extrahiert |
Ein neuer String, der nicht durch t() läuft, ist ein Review-Kommentar, kein Folgeticket: Eine Oberfläche ist entweder vollständig extrahiert, oder sie erzeugt einen Satz in zwei Sprachen.
Der Docs-Baum ist die eine Stelle, an der eine Übersetzung berechtigterweise hinterherhinken darf, und er degradiert dort mit Absicht — siehe den Abschnitt „Eine Übersetzung darf hinterherhinken; eine Seite nicht“ weiter oben. Überall sonst halten die Katalog-Guards en und de auf Parität und schlagen bei einem fehlenden Schlüssel fehl, statt eine englische Zeile mitten auf der Seite auftauchen zu lassen.
Erkennung, die Sprachauswahl und <html lang>
Die Erkennung der Browsersprache ist in apps/web eingeschaltet:
detectBrowserLanguage: { useCookie: true, cookieKey: 'owlat-locale' },
Eine Besucherin bekommt beim ersten Mal die Locale, nach der ihr Browser fragt. Das Cookie ist die tragende Hälfte: Bei no_prefix trägt die URL keine Locale, ohne das Cookie würde also jeder Reload die Erkennung erneut ausführen und allen, deren Browser anderer Meinung ist, die gewählte Sprache wieder wegnehmen.
Gewählt wird sie in app/components/LanguagePicker.vue, unter Einstellungen (/dashboard/preferences), neben dem Erscheinungsbild — dieselbe Art von Einstellung: pro Person, pro Gerät, in diesem Browser statt am Konto gespeichert, sodass ein Wechsel auf dem Telefon die Desktop-Sitzung unangetastet lässt. Die Auswahl listet die Locales, mit denen das Modul konfiguriert ist, und schaltet über setLocale um, was den Zielkatalog lädt, die aktive Locale wechselt und owlat-locale schreibt. Die Marketing-Website und diese Website haben eigene Umschalter in der Kopfzeile, wo eine präfixierte URL den Wechsel zu einer Navigation macht.
<html lang> folgt der aktiven Locale. app.head.htmlAttrs.lang ist in nuxt.config.ts nicht gepinnt — ein dort gesetzter Wert wird in jede Seite eingebacken und würde eine deutsche Leserin auf einem Dokument zurücklassen, das weiterhin lang="en" behauptet, und genau daran wählt ein Screenreader seine Stimme (WCAG 3.1.1). Stattdessen schreibt app/app.vue den Wert aus useLocaleHead(), über einen useHead-Getter, damit er beim Sprachwechsel neu ausgewertet wird statt beim ersten Rendern einzufrieren:
const localeHead = useLocaleHead();
useHead(() => ({ htmlAttrs: localeHead.value.htmlAttrs }));
In apps/web wird nur htmlAttrs übernommen: Bei no_prefix teilen sich alle Locales eine URL, die hreflang-Alternates, die useLocaleHead() ebenfalls ausgeben kann, zeigten also alle auf dieselbe Seite. Die Marketing- und die Docs-App übernehmen zusätzlich link und meta, weil die Alternates dort echte URLs sind. Und da apps/web mit ssr: false läuft, wird sein lang beim Start der App geschrieben, nicht in die statische Shell.