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
+63
-11
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user