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:
ClaudeandClaude Opus 5.5 committed 2026-10-04 18:33:15 +00:00
1 parent 9455013833
commit e1fb632cdd
21 files changed
+1321 -66

No files matched your search

+63 -11
View File
@@ -3,6 +3,7 @@ import { resolve } from 'node:path';
import { runInNewContext } from 'node:vm';
import { describe, expect, it } from 'vitest';
import indexHtml from '../index.html?raw';
import { initialLang } from './i18n/i18n';
import { initialTheme } from './shell/theme';
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../public', name));
@@ -16,7 +17,11 @@ function pngInfo(buf: Buffer) {
describe('Name und Kopfbereich', () => {
it('Titel und Meta-Angaben nennen AirCard-IOS', () => {
expect(indexHtml).toContain('<title>AirCard-IOS – Kartendesigns für Apple Wallet</title>');
// Statisch englisch, damit Link-Vorschauen englisch sind; der Router setzt danach den Titel in der gewählten Sprache.
expect(indexHtml).toContain('<title>AirCard-IOS – Card designs for Apple Wallet</title>');
expect(indexHtml).toContain(
'<meta name="description" content="Discover community card designs for AirCard-iOS, create your own and download it as PNG." />',
);
expect(indexHtml).toContain('<meta name="theme-color" content="#0F1828" />');
expect(indexHtml).toContain('<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />');
});
@@ -33,10 +38,9 @@ describe('Name und Kopfbereich', () => {
);
});
it('Fußzeilen-Hinweis bleibt unverändert', () => {
expect(indexHtml).toContain(
'Inoffizielles Hobbyprojekt. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover. Alle Marken gehören ihren Inhabern.',
);
it('Fußzeile ist leer: ihren Text setzt mountFooter in der gewählten Sprache', () => {
expect(indexHtml).toContain('<footer id="app-footer"></footer>');
expect(indexHtml).not.toContain('Inoffizielles Hobbyprojekt');
});
});
@@ -50,20 +54,33 @@ describe('Icon-Dateien', () => {
});
});
describe('Inline-Skript für Hell/Dunkel in index.html', () => {
describe('Inline-Skript für Hell/Dunkel und Sprache in index.html', () => {
const script = /<script>([\s\S]*?)<\/script>/.exec(indexHtml)?.[1] ?? '';
/** Führt das Skript in einer leeren Umgebung aus (kein Browser) und liefert, was es gesetzt hat. */
function run(stored: string | null, prefersDark: boolean, storageThrows = false) {
/** Was der Browser zur Sprache hergibt: der gespeicherte Wert von `aircard.lang`, `navigator.languages` und `navigator.language`. */
interface BrowserLang {
stored?: string | null;
languages?: readonly string[];
language?: string;
}
/**
* Führt das Skript in einer leeren Umgebung aus (kein Browser) und liefert, was es gesetzt hat.
* `stored` ist der Wert von `aircard.theme`; die Sprache steht in `browser`.
*/
function run(stored: string | null, prefersDark: boolean, storageThrows = false, browser: BrowserLang = {}) {
const attributes: Record<string, string> = {};
const sandbox: Record<string, unknown> = {
localStorage: {
getItem: () => {
getItem: (key: string) => {
if (storageThrows) throw new Error('gesperrt');
return stored;
return key === 'aircard.lang' ? (browser.stored ?? null) : stored;
},
},
window: { matchMedia: () => ({ matches: prefersDark }) },
window: {
matchMedia: () => ({ matches: prefersDark }),
navigator: { languages: browser.languages, language: browser.language },
},
document: { documentElement: { setAttribute: (name: string, value: string) => (attributes[name] = value) } },
};
runInNewContext(script, sandbox);
@@ -79,6 +96,7 @@ describe('Inline-Skript für Hell/Dunkel in index.html', () => {
const { sandbox } = run('dark', false);
expect(Object.keys(sandbox).sort()).toEqual(['document', 'localStorage', 'window']);
expect('theme' in sandbox).toBe(false);
expect('lang' in sandbox).toBe(false);
});
it.each([
@@ -99,4 +117,38 @@ describe('Inline-Skript für Hell/Dunkel in index.html', () => {
expect(run(null, true, true).attributes['data-theme']).toBe('dark');
expect(run(null, false, true).attributes['data-theme']).toBe('light');
});
it.each([
[null, ['de-DE', 'en']],
[null, ['en-US', 'de']],
['en', ['de-DE']],
['de', ['en-US']],
['xx', ['de-AT']],
['xx', ['fr-FR']],
['', ['de']],
[null, ['DE']],
[null, ['de_CH']],
[null, []],
] as const)('setzt <html lang> nach derselben Regel wie initialLang(): gespeichert %j, Browsersprachen %j', (stored, languages) => {
const { attributes } = run(null, true, false, { stored, languages });
expect(attributes.lang).toBe(initialLang(stored, languages));
});
it('ohne navigator.languages gilt navigator.language (wie currentLang), ohne beides Englisch', () => {
expect(run(null, true, false, { language: 'de-CH' }).attributes.lang).toBe('de');
expect(run(null, true, false, { language: 'en-GB' }).attributes.lang).toBe('en');
expect(run(null, true, false, { languages: [], language: 'de' }).attributes.lang).toBe('de');
expect(run(null, true, false, {}).attributes.lang).toBe('en');
});
it('gesperrter localStorage: es gilt die Browsersprache', () => {
expect(run(null, true, true, { stored: 'en', languages: ['de-DE'] }).attributes.lang).toBe('de');
expect(run(null, true, true, { stored: 'de', languages: ['en-US'] }).attributes.lang).toBe('en');
});
it('die gespeicherte Sprache hat mit dem Theme nichts zu tun (und umgekehrt)', () => {
const { attributes } = run('light', false, false, { stored: 'en', languages: ['de'] });
expect(attributes['data-theme']).toBe('light');
expect(attributes.lang).toBe('en');
});
});