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']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user