feat(i18n): Deutsch/Englisch, Sprachknopf und GitHub-Link in der Kopfzeile
- i18n-Kern im gettext-Stil (t/msg, Wörterbuch en.ts); Startsprache aus localStorage, sonst Browsersprache; <html lang> setzt auch das Inline-Skript in index.html. Ein Test prüft t()/msg() und en.ts in beide Richtungen. - Kopfzeile: GitHub-Link und Sprachknopf (Zielsprache, Beschriftung und lang in der Zielsprache), Fußzeile aus mountFooter, Banner und Seitentitel übersetzt, statisches index.html englisch. - Sprachwechsel ohne Neuladen (main.ts): Kopfzeile, Banner, Konto-Menü und Fußzeile neu aufgebaut, rerender() des Routers baut die Seite neu auf, der Editor-Store bleibt; der Appwrite-Client bekommt die Sprache. - Kopfzeile bleibt in einer Zeile: einzeilig ab 1000 px (vorher 900), GitHub-Text ab 1230 px, auf schmalen Telefonen steht „Anmelden“ neben der Suche. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9455013833
commit
e1fb632cdd
21 files changed
+1321
-66
No files matched your search
@@ -0,0 +1,123 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import ts from 'typescript';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EN } from './en';
|
||||
|
||||
const SRC = join(import.meta.dirname, '..');
|
||||
|
||||
/** Alle Quelldateien (ohne Tests) unter src/ als [Pfad relativ zu src, Text]. */
|
||||
function sources(dir = SRC): Array<[string, string]> {
|
||||
const out: Array<[string, string]> = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) out.push(...sources(path));
|
||||
else if (/\.ts$/.test(name) && !/\.test\.ts$/.test(name)) out.push([relative(SRC, path), readFileSync(path, 'utf8')]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface Found {
|
||||
/** Die deutschen Texte aus `t('…')` und `msg('…')` (Literale in '…', "…" oder `…` ohne ${…}). */
|
||||
texts: string[];
|
||||
/** Aufrufe mit einem Template-Literal samt ${…}: der Text steht nicht fest und lässt sich nicht nachschlagen. */
|
||||
dynamic: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt die Texte aller freistehenden Aufrufe `t(…)` und `msg(…)`. Gelesen wird der Syntaxbaum, daher zählen
|
||||
* Kommentare, Zeichenketten und Methodenaufrufe wie `x.t(…)` nicht. Ein Argument, das kein Literal ist
|
||||
* (etwa eine Konstante `t(TITEL)`), wird nicht gesammelt: seinen Text trägt das `msg('…')` der Konstante.
|
||||
*/
|
||||
function findTexts(code: string): Found {
|
||||
const found: Found = { texts: [], dynamic: [] };
|
||||
const file = ts.createSourceFile('quelle.ts', code, ts.ScriptTarget.Latest);
|
||||
const visit = (node: ts.Node): void => {
|
||||
const isTranslation = ts.isCallExpression(node) && ts.isIdentifier(node.expression) && ['t', 'msg'].includes(node.expression.text);
|
||||
if (isTranslation) {
|
||||
const arg = node.arguments[0];
|
||||
if (arg && (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg))) found.texts.push(arg.text);
|
||||
else if (arg && ts.isTemplateExpression(arg)) found.dynamic.push(arg.getText(file));
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(file);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Die Platzhalter `{name}` eines Textes, ohne Doppelte und sortiert. */
|
||||
const placeholders = (text: string): string[] => [...new Set(text.match(/\{\w+\}/g) ?? [])].sort();
|
||||
|
||||
// Text → Dateien, in denen er steht; dazu alle dynamischen Aufrufe als „Datei: Quelltext“.
|
||||
const used = new Map<string, string[]>();
|
||||
const dynamic: string[] = [];
|
||||
for (const [path, code] of sources()) {
|
||||
const found = findTexts(code);
|
||||
for (const text of found.texts) used.set(text, [...(used.get(text) ?? []), path]);
|
||||
dynamic.push(...found.dynamic.map((call) => `${path}: ${call}`));
|
||||
}
|
||||
|
||||
describe('Abdeckung der Übersetzungen (t/msg ↔ en.ts)', () => {
|
||||
it('der Scanner findet die Texte der Oberfläche (sonst bestünden die Prüfungen leer)', () => {
|
||||
expect(used.size).toBeGreaterThan(10);
|
||||
expect(used.has('Gestalten')).toBe(true);
|
||||
});
|
||||
|
||||
it('jeder Text aus t()/msg() hat eine (nicht leere) englische Übersetzung', () => {
|
||||
const translated = (text: string) => Object.hasOwn(EN, text) && EN[text].trim() !== '';
|
||||
const missing = [...used].filter(([text]) => !translated(text)).map(([text, files]) => `${files[0]}: ${text}`);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('jeder Eintrag in en.ts wird in t()/msg() benutzt', () => {
|
||||
expect(Object.keys(EN).filter((text) => !used.has(text))).toEqual([]);
|
||||
});
|
||||
|
||||
it('Platzhalter {…} stehen in Text und Übersetzung gleich', () => {
|
||||
const mismatched = Object.entries(EN)
|
||||
.filter(([text, english]) => placeholders(text).join() !== placeholders(english).join())
|
||||
.map(([text]) => text);
|
||||
expect(mismatched).toEqual([]);
|
||||
});
|
||||
|
||||
it('kein t()/msg() mit Template-Literal und ${…}', () => {
|
||||
expect(dynamic).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scanner für t() und msg()', () => {
|
||||
it('findet Literale in \'…\', "…" und `…` ohne ${…}, jeweils das erste Argument', () => {
|
||||
const code = [
|
||||
"const a = t('Eins');",
|
||||
'const b = msg("Zwei");',
|
||||
'const c = t(`Drei`);',
|
||||
"const d = t('Vier {n}', { n: 4 });",
|
||||
"const e = t('Mit \\'Escape\\'');",
|
||||
].join('\n');
|
||||
expect(findTexts(code)).toEqual({ texts: ['Eins', 'Zwei', 'Drei', 'Vier {n}', "Mit 'Escape'"], dynamic: [] });
|
||||
});
|
||||
|
||||
it('meldet Template-Literale mit ${…} als dynamisch', () => {
|
||||
const found = findTexts('const a = t(`Hallo ${name}`); const b = msg(`x${1}`);');
|
||||
expect(found.texts).toEqual([]);
|
||||
expect(found.dynamic).toEqual(['`Hallo ${name}`', '`x${1}`']);
|
||||
});
|
||||
|
||||
it('ignoriert Kommentare, Zeichenketten, Methodenaufrufe, ähnliche Namen und Konstanten', () => {
|
||||
const code = [
|
||||
"// t('Kommentar')",
|
||||
"/* msg('Block') */",
|
||||
"const s = \"t('in Zeichenkette')\";",
|
||||
"const m = obj.t('Methode');",
|
||||
"const f = format('Name') + git('x') + t_('y') + $t('z');",
|
||||
'const k = t(TITEL) + t(LABELS[key]);',
|
||||
'const leer = t();',
|
||||
].join('\n');
|
||||
expect(findTexts(code)).toEqual({ texts: [], dynamic: [] });
|
||||
});
|
||||
|
||||
it('findet verschachtelte Aufrufe', () => {
|
||||
const code = "el('a', { title: t('Innen') }, t('Außen'), cond ? t('Wenn') : msg('Sonst'));";
|
||||
expect(findTexts(code).texts).toEqual(['Innen', 'Außen', 'Wenn', 'Sonst']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// Englisches Wörterbuch: deutscher Text (Schlüssel) → Englisch, gelesen von t() in i18n.ts. Fehlt ein Eintrag, bleibt der deutsche Text.
|
||||
// Jeder Eintrag gehört zu einem t('…') oder msg('…') im Quelltext; coverage.test.ts prüft es in beide Richtungen.
|
||||
// Englisch: kurz, freundlich, US-Schreibweise. „AirCard-IOS“ ist der Name dieser Seite, „AirCard-iOS“ der Name der App.
|
||||
export const EN: Readonly<Record<string, string>> = {
|
||||
// Seitentitel (pages/router.ts)
|
||||
'AirCard-IOS – Kartendesigns für Apple Wallet': 'AirCard-IOS – Card designs for Apple Wallet',
|
||||
'Gestalten – AirCard-IOS': 'Create – AirCard-IOS',
|
||||
'Meine Karten – AirCard-IOS': 'My cards – AirCard-IOS',
|
||||
'Community – AirCard-IOS': 'Community – AirCard-IOS',
|
||||
'Passwort neu setzen – AirCard-IOS': 'Set new password – AirCard-IOS',
|
||||
|
||||
// Kopfzeile (shell/header.ts)
|
||||
'Hauptnavigation': 'Main navigation',
|
||||
'Community': 'Community',
|
||||
'Gestalten': 'Create',
|
||||
'Meine Karten': 'My cards',
|
||||
'Unterstützen': 'Support us',
|
||||
'Titel, Tags oder Herausgeber': 'Title, tags or issuer',
|
||||
'Community-Karten durchsuchen': 'Search community cards',
|
||||
'Suchen': 'Search',
|
||||
'Hell/Dunkel umschalten': 'Toggle light/dark mode',
|
||||
'Menü': 'Menu',
|
||||
'AirCard-iOS auf GitHub': 'AirCard-iOS on GitHub',
|
||||
|
||||
// Spenden-Banner (shell/donate-banner.ts)
|
||||
'Banner schließen': 'Close banner',
|
||||
'Gefällt dir AirCard-IOS? Spendier uns einen Kaffee – damit das Projekt weiterläuft.':
|
||||
'Enjoying AirCard-IOS? Buy us a coffee – it keeps the project running.',
|
||||
|
||||
// Fußzeile (shell/footer.ts)
|
||||
'Inoffizielle Community-Seite für die App AirCard-iOS – kein Teil des Original-Projekts und nicht mit Apple verbunden. Logos und Motive stammen von Nutzern; alle Marken gehören ihren Inhabern.':
|
||||
'Unofficial community site for the AirCard-iOS app – not part of the original project and not affiliated with Apple. Logos and artwork are uploaded by users; all trademarks belong to their owners.',
|
||||
};
|
||||
@@ -0,0 +1,258 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EN } from './en';
|
||||
import { currentLang, initialLang, LANG_KEY, msg, onLangChange, setLang, t, type Lang } from './i18n';
|
||||
|
||||
// Ein Wörterbuch-Eintrag mit Platzhalter nur für diese Tests: der Bestand hat (noch) keinen Text mit {…}.
|
||||
// (Ein vi.mock von ./en griffe hier nicht: das Test-Setup hat i18n.ts schon geladen.) Er wird nach jedem Test wieder entfernt.
|
||||
const WITH_PLACEHOLDER = 'Nichts gefunden für „{q}“';
|
||||
const dictionary = EN as Record<string, string>;
|
||||
|
||||
/** Abmeldungen der Hörer dieses Tests (sie werden nach jedem Test aufgehoben, auch wenn eine Prüfung scheitert). */
|
||||
const stops: Array<() => void> = [];
|
||||
|
||||
function listen(): Lang[] {
|
||||
const calls: Lang[] = [];
|
||||
stops.push(onLangChange((lang) => calls.push(lang)));
|
||||
return calls;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
dictionary[WITH_PLACEHOLDER] = 'Nothing found for “{q}”';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete dictionary[WITH_PLACEHOLDER];
|
||||
for (const stop of stops.splice(0)) stop();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('initialLang', () => {
|
||||
it.each([
|
||||
[null, ['de-DE', 'en'], 'de'],
|
||||
[null, ['en-US', 'de'], 'en'],
|
||||
['en', ['de-DE'], 'en'],
|
||||
['de', ['en-US'], 'de'],
|
||||
['xx', ['de-AT'], 'de'],
|
||||
['xx', ['en-GB'], 'en'],
|
||||
['', ['de'], 'de'],
|
||||
[null, [], 'en'],
|
||||
] as const)('gespeichert %j, Browsersprachen %j → %s', (stored, languages, expected) => {
|
||||
expect(initialLang(stored, languages)).toBe(expected);
|
||||
});
|
||||
|
||||
it('nur die erste Browsersprache zählt, Groß-/Kleinschreibung ist egal', () => {
|
||||
expect(initialLang(null, ['fr-FR', 'de-DE'])).toBe('en');
|
||||
expect(initialLang(null, ['DE'])).toBe('de');
|
||||
expect(initialLang(null, ['de_CH'])).toBe('de');
|
||||
});
|
||||
});
|
||||
|
||||
describe('t() und msg()', () => {
|
||||
it('Deutsch: der Text bleibt, wie er ist', () => {
|
||||
expect(t('Gestalten')).toBe('Gestalten');
|
||||
});
|
||||
|
||||
it('Englisch: übersetzt über das Wörterbuch', () => {
|
||||
setLang('en');
|
||||
expect(t('Gestalten')).toBe('Create');
|
||||
});
|
||||
|
||||
it('ein Text ohne Eintrag bleibt auch auf Englisch deutsch', () => {
|
||||
setLang('en');
|
||||
expect(t('Dieser Text steht in keinem Wörterbuch')).toBe('Dieser Text steht in keinem Wörterbuch');
|
||||
});
|
||||
|
||||
it('ersetzt Platzhalter in beiden Sprachen', () => {
|
||||
expect(t('Nichts gefunden für „{q}“', { q: 'x' })).toBe('Nichts gefunden für „x“');
|
||||
setLang('en');
|
||||
expect(t('Nichts gefunden für „{q}“', { q: 'x' })).toBe('Nothing found for “x”');
|
||||
});
|
||||
|
||||
it('ersetzt auch Platzhalter in Texten ohne Eintrag, Zahlen inklusive 0', () => {
|
||||
setLang('en');
|
||||
expect(t('{n} Karten von {name}', { n: 0, name: 'Ada' })).toBe('0 Karten von Ada');
|
||||
});
|
||||
|
||||
it('ein unbekannter Platzhalter bleibt stehen', () => {
|
||||
expect(t('Nichts gefunden für „{q}“', { x: 'y' })).toBe('Nichts gefunden für „{q}“');
|
||||
expect(t('Hallo {name}')).toBe('Hallo {name}');
|
||||
setLang('en');
|
||||
expect(t('Nichts gefunden für „{q}“', {})).toBe('Nothing found for “{q}”');
|
||||
});
|
||||
|
||||
it('ersetzt in einem Durchgang: Werte werden nicht erneut ausgewertet und nicht als Muster gelesen', () => {
|
||||
expect(t('{a} {b}', { a: '{b}', b: 'x' })).toBe('{b} x');
|
||||
expect(t('{a}', { a: '$& $1' })).toBe('$& $1');
|
||||
});
|
||||
|
||||
it('Namen aus Object.prototype sind weder Wörterbucheintrag noch Platzhalter', () => {
|
||||
setLang('en');
|
||||
expect(t('constructor')).toBe('constructor');
|
||||
expect(t('toString')).toBe('toString');
|
||||
expect(t('Wert {toString}', { x: 1 })).toBe('Wert {toString}');
|
||||
});
|
||||
|
||||
it('msg() gibt den Text unverändert zurück und übersetzt nicht', () => {
|
||||
setLang('en');
|
||||
expect(msg('Gestalten')).toBe('Gestalten');
|
||||
expect(t(msg('Gestalten'))).toBe('Create');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLang und currentLang', () => {
|
||||
it('currentLang liefert die zuletzt gesetzte Sprache', () => {
|
||||
setLang('en');
|
||||
expect(currentLang()).toBe('en');
|
||||
setLang('de');
|
||||
expect(currentLang()).toBe('de');
|
||||
});
|
||||
|
||||
it('setzt <html lang>, auch ohne Wechsel (etwa beim Start)', () => {
|
||||
setLang('en');
|
||||
expect(document.documentElement.lang).toBe('en');
|
||||
document.documentElement.lang = 'fr';
|
||||
setLang('en');
|
||||
expect(document.documentElement.lang).toBe('en');
|
||||
setLang('de');
|
||||
expect(document.documentElement.lang).toBe('de');
|
||||
});
|
||||
|
||||
it('speichert die Wahl in localStorage', () => {
|
||||
setLang('en');
|
||||
expect(localStorage.getItem(LANG_KEY)).toBe('en');
|
||||
expect(LANG_KEY).toBe('aircard.lang');
|
||||
});
|
||||
|
||||
it('mit persist: false wird nichts gespeichert, die Sprache gilt trotzdem', () => {
|
||||
setLang('en', { persist: false });
|
||||
expect(localStorage.getItem(LANG_KEY)).toBeNull();
|
||||
expect(currentLang()).toBe('en');
|
||||
expect(t('Gestalten')).toBe('Create');
|
||||
});
|
||||
|
||||
it('ein gesperrter Speicher ist kein Fehler; die Sprache wechselt trotzdem', () => {
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('voll', 'QuotaExceededError');
|
||||
});
|
||||
expect(() => setLang('en')).not.toThrow();
|
||||
expect(currentLang()).toBe('en');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLangChange', () => {
|
||||
it('meldet jeden echten Wechsel genau einmal, gleiche Sprache nie', () => {
|
||||
const calls = listen();
|
||||
setLang('de'); // schon Deutsch (Test-Setup)
|
||||
expect(calls).toEqual([]);
|
||||
setLang('en');
|
||||
setLang('en');
|
||||
expect(calls).toEqual(['en']);
|
||||
setLang('de');
|
||||
expect(calls).toEqual(['en', 'de']);
|
||||
});
|
||||
|
||||
it('persist: false meldet ebenfalls', () => {
|
||||
const calls = listen();
|
||||
setLang('en', { persist: false });
|
||||
expect(calls).toEqual(['en']);
|
||||
});
|
||||
|
||||
it('der Hörer sieht die neue Sprache schon (t() und currentLang)', () => {
|
||||
const seen: string[] = [];
|
||||
stops.push(
|
||||
onLangChange((lang) => {
|
||||
seen.push(`${lang}:${currentLang()}:${t('Gestalten')}:${document.documentElement.lang}`);
|
||||
}),
|
||||
);
|
||||
setLang('en');
|
||||
expect(seen).toEqual(['en:en:Create:en']);
|
||||
});
|
||||
|
||||
it('die Abmeldung wirkt', () => {
|
||||
const calls: Lang[] = [];
|
||||
const stop = onLangChange((lang) => calls.push(lang));
|
||||
setLang('en');
|
||||
stop();
|
||||
setLang('de');
|
||||
expect(calls).toEqual(['en']);
|
||||
});
|
||||
|
||||
it('ein Hörer darf sich beim Melden abmelden, die übrigen werden trotzdem gerufen', () => {
|
||||
const calls: string[] = [];
|
||||
const stopA = onLangChange(() => {
|
||||
calls.push('a');
|
||||
stopA();
|
||||
});
|
||||
stops.push(stopA);
|
||||
const second = listen();
|
||||
setLang('en');
|
||||
setLang('de');
|
||||
expect(calls).toEqual(['a']);
|
||||
expect(second).toEqual(['en', 'de']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('currentLang: Startsprache beim ersten Aufruf', () => {
|
||||
/** Eine frische Kopie des Moduls; die Startsprache wird erst beim ersten Aufruf von currentLang() bestimmt. */
|
||||
async function fresh() {
|
||||
vi.resetModules();
|
||||
return import('./i18n');
|
||||
}
|
||||
|
||||
it('die gespeicherte Wahl geht vor der Browsersprache', async () => {
|
||||
localStorage.setItem(LANG_KEY, 'en');
|
||||
vi.stubGlobal('navigator', { languages: ['de-DE'], language: 'de-DE' });
|
||||
expect((await fresh()).currentLang()).toBe('en');
|
||||
});
|
||||
|
||||
it('ohne Wahl entscheidet die erste Browsersprache', async () => {
|
||||
vi.stubGlobal('navigator', { languages: ['de-AT', 'en'], language: 'de-AT' });
|
||||
expect((await fresh()).currentLang()).toBe('de');
|
||||
vi.stubGlobal('navigator', { languages: ['en-US', 'de'], language: 'en-US' });
|
||||
expect((await fresh()).currentLang()).toBe('en');
|
||||
});
|
||||
|
||||
it('ein ungültiger gespeicherter Wert zählt als „nichts gespeichert“', async () => {
|
||||
localStorage.setItem(LANG_KEY, 'klingonisch');
|
||||
vi.stubGlobal('navigator', { languages: ['de-DE'], language: 'de-DE' });
|
||||
expect((await fresh()).currentLang()).toBe('de');
|
||||
});
|
||||
|
||||
it('ohne navigator.languages gilt navigator.language, ganz ohne Angabe Englisch', async () => {
|
||||
vi.stubGlobal('navigator', { language: 'de' });
|
||||
expect((await fresh()).currentLang()).toBe('de');
|
||||
vi.stubGlobal('navigator', { languages: [], language: 'de-CH' });
|
||||
expect((await fresh()).currentLang()).toBe('de');
|
||||
vi.stubGlobal('navigator', {});
|
||||
expect((await fresh()).currentLang()).toBe('en');
|
||||
});
|
||||
|
||||
it('ein gesperrter Speicher beim Lesen: es gilt die Browsersprache', async () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new DOMException('blockiert', 'SecurityError');
|
||||
});
|
||||
vi.stubGlobal('navigator', { languages: ['de-DE'], language: 'de-DE' });
|
||||
expect((await fresh()).currentLang()).toBe('de');
|
||||
});
|
||||
|
||||
it('wird nur beim ersten Aufruf bestimmt: spätere Änderungen am Speicher ändern nichts', async () => {
|
||||
vi.stubGlobal('navigator', { languages: ['de-DE'], language: 'de-DE' });
|
||||
const mod = await fresh();
|
||||
expect(mod.currentLang()).toBe('de');
|
||||
localStorage.setItem(LANG_KEY, 'en');
|
||||
expect(mod.currentLang()).toBe('de');
|
||||
});
|
||||
|
||||
it('setLang meldet gegenüber der Startsprache, nicht gegenüber „nichts“', async () => {
|
||||
vi.stubGlobal('navigator', { languages: ['de-DE'], language: 'de-DE' });
|
||||
const mod = await fresh();
|
||||
const calls: Lang[] = [];
|
||||
mod.onLangChange((lang) => calls.push(lang));
|
||||
mod.setLang('de', { persist: false }); // wie die Startsprache: kein Wechsel
|
||||
expect(calls).toEqual([]);
|
||||
mod.setLang('en', { persist: false });
|
||||
expect(calls).toEqual(['en']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// Sprache der Seite (Deutsch/Englisch) im gettext-Stil: Der deutsche Text ist zugleich der Schlüssel.
|
||||
// `t('Deutscher Text', { name })` schlägt ihn bei Englisch in EN (en.ts) nach und setzt die Platzhalter `{name}` ein;
|
||||
// fehlt der Eintrag, bleibt der deutsche Text. Übersetzt wird beim Aufruf, ein Sprachwechsel baut die Oberfläche neu auf (main.ts).
|
||||
import { EN } from './en';
|
||||
|
||||
export type Lang = 'de' | 'en';
|
||||
|
||||
export const LANG_KEY = 'aircard.lang';
|
||||
|
||||
/**
|
||||
* Startsprache: die gespeicherte Wahl (`de` oder `en`), sonst Deutsch, wenn die erste Browsersprache mit `de` beginnt,
|
||||
* andernfalls Englisch. Dieselbe Regel steht im Skript in index.html, das <html lang> vor dem ersten Paint setzt.
|
||||
*/
|
||||
export function initialLang(stored: string | null, browserLanguages: readonly string[]): Lang {
|
||||
if (stored === 'de' || stored === 'en') return stored;
|
||||
return (browserLanguages[0] ?? '').toLowerCase().startsWith('de') ? 'de' : 'en';
|
||||
}
|
||||
|
||||
function readStored(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(LANG_KEY);
|
||||
} catch {
|
||||
return null; // Speicher gesperrt (z. B. privates Fenster): es gilt die Browsersprache
|
||||
}
|
||||
}
|
||||
|
||||
/** Die bevorzugten Browsersprachen: `navigator.languages`, ersatzweise `[navigator.language]`. */
|
||||
function browserLanguages(): readonly string[] {
|
||||
if (navigator.languages?.length) return navigator.languages;
|
||||
return navigator.language ? [navigator.language] : [];
|
||||
}
|
||||
|
||||
// Die Sprache der Seite; null, bis sie zum ersten Mal gebraucht wird.
|
||||
let lang: Lang | null = null;
|
||||
const listeners = new Set<(lang: Lang) => void>();
|
||||
|
||||
/** Die Sprache der Seite; beim ersten Aufruf aus der gespeicherten Wahl bzw. der Browsersprache bestimmt. */
|
||||
export function currentLang(): Lang {
|
||||
lang ??= initialLang(readStored(), browserLanguages());
|
||||
return lang;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stellt die Sprache um: setzt <html lang>, merkt sie sich (außer mit `persist: false`) und meldet einen echten Wechsel an
|
||||
* die Hörer. Das Neuzeichnen von Kopfzeile und Seite ist Sache der Hörer.
|
||||
*/
|
||||
export function setLang(next: Lang, opts: { persist?: boolean } = {}): void {
|
||||
const changed = next !== currentLang();
|
||||
lang = next;
|
||||
document.documentElement.lang = next;
|
||||
if (opts.persist ?? true) {
|
||||
try {
|
||||
localStorage.setItem(LANG_KEY, next);
|
||||
} catch {
|
||||
// gilt dann nur für diese Sitzung
|
||||
}
|
||||
}
|
||||
// Kopie, damit sich ein Hörer beim Melden abmelden darf.
|
||||
if (changed) for (const fn of [...listeners]) fn(next);
|
||||
}
|
||||
|
||||
/** Meldet jeden Sprachwechsel (nicht den bloßen Aufruf mit der aktuellen Sprache). Gibt die Abmeldung zurück. */
|
||||
export function onLangChange(fn: (lang: Lang) => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersetzt einen deutschen Text in die Sprache der Seite und setzt die Platzhalter `{name}` aus `params` ein;
|
||||
* ein Platzhalter ohne Wert bleibt stehen. Ohne Eintrag in EN bleibt der deutsche Text.
|
||||
*/
|
||||
export function t(text: string, params?: Readonly<Record<string, string | number>>): string {
|
||||
const base = currentLang() === 'en' && Object.hasOwn(EN, text) ? EN[text] : text;
|
||||
if (!params) return base;
|
||||
return base.replace(/\{(\w+)\}/g, (placeholder, name: string) => (Object.hasOwn(params, name) ? String(params[name]) : placeholder));
|
||||
}
|
||||
|
||||
/** Markiert einen deutschen Text, der vorab in einer Konstante steht; übersetzt wird erst beim Anzeigen mit `t()`. */
|
||||
export function msg<T extends string>(text: T): T {
|
||||
return text;
|
||||
}
|
||||
Reference in new issue
Block a user