feat(i18n): Community, Startseite und Meine Karten auf Englisch

Task 11: alle sichtbaren Texte von community/, pages/home-page.ts, pages/community-page.ts, pages/cards-page.ts und pages/format.ts
(Texte, aria-label, title, Platzhalter, Alt-Texte, Rückfragen, Meldungen) gehen durch t()/msg(); en.ts bekommt 82 Einträge (214 → 296).

- Konstanten bleiben deutsche Strings per msg() und gehen beim Anzeigen durch t() (publish.ts, publish-dialog.ts, gallery.ts und die Seiten);
  geworfene Fehler tragen weiter den deutschen Text, damit die Vergleiche gehen. Funktionen, die Text für Nutzer liefern, übersetzen beim
  Zurückgeben (validatePublishForm, publishFailureText, formatDate, reportsLabel). Template-Literale sind Platzhalter geworden ({max},
  {reason}, {name}, {title}, {query}, {count}); der deutsche Text bleibt Zeichen für Zeichen derselbe.
- API-Fehler: jeder ApiError wird mit apiErrorText() angezeigt (Veröffentlichen-Dialog, Detailseite, Meine Karten), nach dem Code des Dienstes
  statt nach dem deutschen Servertext. Logik, die err.message vergleicht (publishCard, 404 beim Zurückziehen), bleibt unverändert.
- Bekannte Reste aus Task 10 behoben: LOGIN_NEEDED ging in publish-dialog.ts, community-page.ts und cards-page.ts ohne t() an die Oberfläche;
  die Meldungen zu Bild lesen/Pixelgrenze/fehlendem Logo (cards-page.ts, GERMAN_MESSAGES in publish-dialog.ts) erscheinen jetzt übersetzt;
  die Zahl der Meldungen in der Logo-Infozeile („3 reports“).
- pages/format.ts: formatDate liefert Deutsch dd.mm.yyyy und Englisch „Oct 4, 2026“ (Intl.DateTimeFormat en-US), reportsLabel „1 report“ /
  „3 reports“. „Herausgeber“ heißt als Bank „Issuer / bank“.
- logos/prepare-logo.ts: der Helfer problem() ist weg, die sechs Stellen schreiben new LogoError(t(MELDUNG)) selbst (gleiches Verhalten);
  so sieht der neue Prüfer unten jede Verwendung einer Konstante.
- i18n/coverage.test.ts: Die Heuristik für deutsche Literale deckt jetzt die ganze Oberfläche ab (account/, card/, community/, editor/, logos/,
  pages/, shell/, main.ts, i18n/api-messages.ts). Sie erkennt zusätzlich Wörter mit Klammer („Ausgeblendet (gemeldet)“) und nimmt rel und
  Namen in Klammerschreibweise (headers['X-Aircard-Jwt']) aus. Acht begründete Ausnahmen kommen dazu (zwei AppwriteException-Meldungen,
  vier interne Logo-Fehler, der Sprachknopf in der Zielsprache). Neu: ein Prüfer über den Syntaxbaum, der verlangt, dass jede Konstante
  mit msg() nur in t(), new Error(), Vergleichen, case und Listen/Mengen vorkommt (auch über Importe und Umbenennungen); ohne Ausnahmen.
- Tests: format.test.ts (beide Sprachen), Englisch-Blöcke in publish-dialog, gallery-grid, home-page, community-page, cards-page und
  logo-info (Texte, Fehlerwege, „Publishing failed – This card is already published.“, kein deutscher Wörterbuchtext auf der Seite). Die
  Hilfen texts()/germanLeft() liegen jetzt in i18n/english-check.ts (vorher in english-ui.test.ts). Der bestehende Englisch-Test in
  cards-page.test.ts sucht den Knopf „Download“ statt „Herunterladen“.
- Vitest 53 Dateien, 1812 Tests grün (vorher 1779), tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 00:34:10 +00:00
1 parent 1ff1873b27
commit 7e66cd0f0f
20 files changed
+1164 -211

No files matched your search

+228 -7
View File
@@ -60,8 +60,10 @@ for (const [path, code] of sources()) {
/**
* Bereiche, die schon vollständig übersetzt sind: Ordner (mit „/“ am Ende) oder einzelne Dateien, relativ zu src/.
* Dort darf kein deutsches Text-Literal mehr außerhalb von t()/msg() stehen; ein weiterer übersetzter Bereich kommt hier dazu.
* Mit Task 11 ist die ganze Oberfläche übersetzt. Nicht in der Liste stehen nur Dateien ohne eigene Texte (config.ts, i18n/i18n.ts,
* Typdateien) und das Wörterbuch (i18n/en.ts: seine Werte sind Englisch).
*/
const TRANSLATED_AREAS = ['account/', 'editor/', 'card/', 'pages/reset-page.ts'];
const TRANSLATED_AREAS = ['account/', 'card/', 'community/', 'editor/', 'logos/', 'pages/', 'shell/', 'main.ts', 'i18n/api-messages.ts'];
/**
* Literale, die wie deutscher Text aussehen, aber nie angezeigt werden. Jede Ausnahme nennt ihren Grund; der Test unten
@@ -98,6 +100,46 @@ const ALLOWED_LITERALS: ReadonlyArray<{ file: string; text: string; reason: stri
text: 'Logo konnte nicht geladen werden: ',
reason: 'Entwicklermeldung mit interner ID; Nutzer sehen die eigenen Texte der Seiten (im Editor LOGO_ERROR, „Die Logos konnten nicht geladen werden.“)',
},
{
file: 'community/gallery.ts',
text: 'File not found',
reason: 'Englische Meldung der selbst gebauten AppwriteException (wie vom Server); die Seiten zeigen nur ihre eigenen Texte (Keine Vorschau, IMAGE_ERROR)',
},
{
file: 'logos/logos.ts',
text: 'File not found',
reason: 'Englische Meldung der selbst gebauten AppwriteException (wie vom Server); die Kachel zeigt bei einem Fehler nur den leeren Platzhalter',
},
{
file: 'logos/logo-asset.ts',
text: 'Das Logo konnte nicht gelesen werden.',
reason: 'Interner Fehler von decodeWithImage; decode() fängt ihn ab und meldet „kein Bild“ (null), er wird nie angezeigt',
},
{
file: 'logos/logo-asset.ts',
text: 'Canvas wird in diesem Browser nicht unterstützt.',
reason: 'Interner Fehler von fillShape; die Seiten zeigen bei einem Ladefehler der Logos ihre eigenen Texte (LOGO_ERROR, allgemeine Meldung von errorMessage())',
},
{
file: 'logos/logo-image.ts',
text: 'Das Logo ist zu groß.',
reason: 'Interner Fehler von checkImage; showLogoImage fängt ihn ab und zeigt den leeren Platzhalter (data-state="unusable")',
},
{
file: 'logos/logo-image.ts',
text: 'Das Logo konnte nicht gelesen werden.',
reason: 'Interner Fehler von checkImage; showLogoImage fängt ihn ab und zeigt den leeren Platzhalter (data-state="unusable")',
},
{
file: 'shell/header.ts',
text: 'Switch to English',
reason: 'Sprachknopf: Beschriftung in der Zielsprache (Spec § 7), unabhängig von der Sprache der Seite – darf deshalb nicht durch t()',
},
{
file: 'shell/header.ts',
text: 'Auf Deutsch umschalten',
reason: 'Sprachknopf: Beschriftung in der Zielsprache (Spec § 7), unabhängig von der Sprache der Seite – darf deshalb nicht durch t()',
},
];
/** Liegt die Datei in einem Bereich, in dem alle Texte schon übersetzt sein müssen? */
@@ -106,12 +148,13 @@ const inTranslatedArea = (path: string): boolean =>
/**
* Sieht ein Text-Literal wie deutscher Text aus? Ja bei einem Umlaut oder ß, bei mindestens zwei Wörtern („Bitte melde dich an.“;
* ein Wort hat mindestens zwei Buchstaben, Satzzeichen am Ende zählen nicht mit) und bei einem einzelnen großgeschriebenen
* Wort („Hell“, „Speichert …“, „E-Mail“). Ein Wort mit Bindestrich-Teilen („auth-btn“) und CamelCase („ArrowLeft“) sind Bezeichner.
* ein Wort hat mindestens zwei Buchstaben, Satzzeichen am Ende und eine Klammer davor oder dahinter zählen nicht mit) und bei einem
* einzelnen großgeschriebenen Wort („Hell“, „Speichert …“, „E-Mail“). Ein Wort mit Bindestrich-Teilen („auth-btn“) und CamelCase
* („ArrowLeft“) sind Bezeichner.
*/
function looksGerman(text: string): boolean {
if (/[äöüÄÖÜß]/.test(text)) return true;
if (text.split(/\s+/).filter((word) => /^\p{L}{2,}[.,:;!?…]*$/u.test(word)).length >= 2) return true;
if (text.split(/\s+/).filter((word) => /^\(?\p{L}{2,}[.,:;!?…]*\)?[.,:;!?…]*$/u.test(word)).length >= 2) return true;
const single = text.replace(/[\s.,:;!?…]+$/u, '');
return /^\p{Lu}[\p{L}-]*$/u.test(single) && /\p{Ll}/u.test(single) && !/\p{Ll}\p{Lu}/u.test(single);
}
@@ -125,9 +168,11 @@ function isExemptPosition(node: ts.Node): boolean {
if (ts.isImportDeclaration(parent) || ts.isExportDeclaration(parent) || ts.isLiteralTypeNode(parent)) return true;
if (ts.isCaseClause(parent) || (ts.isBinaryExpression(parent) && EQUALITY.includes(parent.operatorToken.kind))) return true; // key === 'Escape'
if (ts.isPropertyAssignment(parent)) {
// { 'aria-label': … } (der Name, nicht der Wert) und { class: 'card tile' } (CSS-Klassen)
return parent.name === node || ['class', 'className'].includes(ts.isIdentifier(parent.name) ? parent.name.text : '');
// { 'aria-label': … } (der Name, nicht der Wert), { class: 'card tile' } (CSS-Klassen) und { rel: 'noopener noreferrer' }
return parent.name === node || ['class', 'className', 'rel'].includes(ts.isIdentifier(parent.name) ? parent.name.text : '');
}
// headers['X-Aircard-Jwt'] = …: ein Name in Klammerschreibweise, kein Text
if (ts.isElementAccessExpression(parent)) return parent.argumentExpression === node;
if (!ts.isCallExpression(parent)) return false;
const callee = parent.expression;
if (ts.isIdentifier(callee)) return ['t', 'msg'].includes(callee.text) && parent.arguments[0] === node;
@@ -163,6 +208,102 @@ const untranslated: Array<{ file: string; text: string }> = sources()
.filter(([path]) => inTranslatedArea(path))
.flatMap(([path, code]) => findUntranslated(code).map((text) => ({ file: path, text })));
// ---- Konstanten mit msg() dürfen nur über t() angezeigt werden -------------------------------------------------------------------
// Die Heuristik oben sieht deutsche Literale, nicht aber eine Konstante `const TEXT = msg('…')`, die ohne t() im DOM landet:
// ihr Literal steht ja in msg(). Dafür liest dieser Teil, wo eine solche Konstante vorkommt.
const isCall = (node: ts.Node, name: string): node is ts.CallExpression =>
ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === name;
/** Die Namen der Konstanten `const NAME = msg('…')` einer Datei, dazu die davon exportierten. */
function msgConstants(file: ts.SourceFile): { all: Set<string>; exported: Set<string> } {
const out = { all: new Set<string>(), exported: new Set<string>() };
const visit = (node: ts.Node): void => {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isCall(node.initializer, 'msg')) {
out.all.add(node.name.text);
const statement = node.parent.parent;
if (ts.isVariableStatement(statement) && statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
out.exported.add(node.name.text);
}
}
ts.forEachChild(node, visit);
};
visit(file);
return out;
}
/** Ist der Bezeichner ein Wert-Gebrauch (nicht der Name der Deklaration, einer Eigenschaft oder eines Imports)? */
function isValueUse(node: ts.Identifier): boolean {
const parent = node.parent;
if (ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent) || ts.isTypeQueryNode(parent)) return false;
if (ts.isPropertyAccessExpression(parent)) return parent.name !== node; // obj.NAME ist nicht die Konstante
if (ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent) || ts.isParameter(parent)) return parent.name !== node;
return true;
}
const FALLBACKS = [ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.BarBarToken, ts.SyntaxKind.AmpersandAmpersandToken];
/**
* Wird der Text der Konstante dort nur übersetzt, verglichen oder als Fehlermeldung weitergegeben? Das sind `t(NAME)`,
* `new Error(NAME)`, `===`/`!==`/`case` und eine Liste oder Menge zum Vergleichen (`[NAME, …]`, `new Set([NAME, …])`); Klammern,
* `ok ? A : B`, `??`, `||` und `&&` zählen mit. Alles andere (Argument einer anderen Funktion, Wert einer Eigenschaft, Teil eines
* Templates, Zuweisung …) zeigt den deutschen Text an oder reicht ihn an einen weiter, der es tut. Nicht erkannt wird, was später
* aus einer Liste gelesen und ohne t() angezeigt wird.
*/
function isTranslatedOrCompared(node: ts.Identifier): boolean {
let child: ts.Node = node;
let parent = node.parent;
while (
ts.isParenthesizedExpression(parent) ||
(ts.isConditionalExpression(parent) && parent.condition !== child) ||
(ts.isBinaryExpression(parent) && FALLBACKS.includes(parent.operatorToken.kind))
) {
child = parent;
parent = parent.parent;
}
if (isCall(parent, 't')) return parent.arguments[0] === child;
if (ts.isNewExpression(parent)) return ts.isIdentifier(parent.expression) && parent.expression.text === 'Error';
if (ts.isBinaryExpression(parent)) return EQUALITY.includes(parent.operatorToken.kind);
return ts.isCaseClause(parent) || ts.isArrayLiteralExpression(parent);
}
/**
* Die Stellen („Zeile: Quelltext“), an denen eine msg()-Konstante der Datei nicht durch t() geht. Bekannt sind die Konstanten der
* Datei selbst und die, die sie aus einer anderen Datei importiert (`exportsOf`: Pfad relativ zu src/ → exportierte msg()-Konstanten).
*/
function findRawConstantUses(path: string, code: string, exportsOf: ReadonlyMap<string, ReadonlySet<string>>): string[] {
const file = ts.createSourceFile(path, code, ts.ScriptTarget.Latest, true);
const known = new Set(msgConstants(file).all);
for (const statement of file.statements) {
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
if (!statement.moduleSpecifier.text.startsWith('.')) continue;
const target = `${join(path, '..', statement.moduleSpecifier.text)}.ts`;
const bindings = statement.importClause?.namedBindings;
if (!bindings || !ts.isNamedImports(bindings)) continue;
for (const item of bindings.elements) {
if (exportsOf.get(target)?.has((item.propertyName ?? item.name).text)) known.add(item.name.text);
}
}
const out: string[] = [];
const visit = (node: ts.Node): void => {
if (ts.isIdentifier(node) && known.has(node.text) && isValueUse(node) && !isTranslatedOrCompared(node)) {
const line = file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1;
out.push(`${line}: ${node.parent.getText(file).replace(/\s+/g, ' ').slice(0, 80)}`);
}
ts.forEachChild(node, visit);
};
visit(file);
return out;
}
// Pfad → exportierte msg()-Konstanten, dann je Datei die Stellen, an denen eine davon unübersetzt vorkommt.
const msgExports = new Map(
sources().map(([path, code]) => [path, msgConstants(ts.createSourceFile(path, code, ts.ScriptTarget.Latest, true)).exported] as const),
);
const rawConstantUses: string[] = sources().flatMap(([path, code]) =>
findRawConstantUses(path, code, msgExports).map((use) => `${path}:${use}`),
);
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);
@@ -259,6 +400,7 @@ describe('Scanner für deutsche Literale außerhalb von t() und msg()', () => {
"const f = 'E-Mail';",
"const g = 'Bearbeitet: ';",
'const h = `Nur Bild`;',
"const i = 'Ausgeblendet (gemeldet)';",
].join('\n');
expect(findUntranslated(code)).toEqual([
'Schließen',
@@ -270,6 +412,7 @@ describe('Scanner für deutsche Literale außerhalb von t() und msg()', () => {
'E-Mail',
'Bearbeitet: ',
'Nur Bild',
'Ausgeblendet (gemeldet)',
]);
});
@@ -290,13 +433,15 @@ describe('Scanner für deutsche Literale außerhalb von t() und msg()', () => {
expect(findUntranslated(code)).toEqual([]);
});
it('ignoriert technische Zeichenketten: CSS-Klassen, Kleinbuchstaben-Wörter, Formate, Bezeichner, Zahlen', () => {
it('ignoriert technische Zeichenketten: CSS-Klassen, rel, Kopfzeilennamen, Kleinbuchstaben-Wörter, Formate, Bezeichner, Zahlen', () => {
const code = [
"el('button', { type: 'button', class: 'auth-btn auth-btn-small', 'aria-describedby': 'reset-hint reset-error' }, '×');",
"el('div', { class: 'card tile' });",
"const a = 'user_invalid_credentials' + 'image/png' + '#account-slot button' + 'ArrowLeft' + 'LoginCancelled' + 'PLATINUM';",
"const b = '100 %' + '1536 × 969 PNG' + '•••• •••• •••• 8888' + 'M548.75 379.89a98.21 98.21 0 0 1 0 98.21';",
'const c = `aircard-${name}.png` + `data:image/svg+xml;charset=utf-8,${svg}`;',
"const link = { href, target: '_blank', rel: 'noopener noreferrer' };",
"headers['X-Aircard-Jwt'] = jwt;",
].join('\n');
expect(findUntranslated(code)).toEqual([]);
});
@@ -305,3 +450,79 @@ describe('Scanner für deutsche Literale außerhalb von t() und msg()', () => {
expect(findUntranslated("const a = t(ok ? 'Gespeichert' : 'Speichern');")).toEqual(['Gespeichert', 'Speichern']);
});
});
describe('msg()-Konstanten werden nur über t() angezeigt', () => {
it('der Scanner kennt die Konstanten (sonst bestünde die Prüfung leer)', () => {
expect(msgExports.get('account/with-login.ts')).toContain('LOGIN_NEEDED');
expect(msgExports.get('editor/upload.ts')).toContain('READ_MESSAGE');
});
it('keine Konstante mit msg() steht ohne t() im DOM', () => {
expect(rawConstantUses).toEqual([]);
});
});
describe('Scanner für msg()-Konstanten ohne t()', () => {
const none = new Map<string, ReadonlySet<string>>();
it('findet eine Konstante als Argument, Eigenschaftswert, Template-Teil, Rückgabewert und Zuweisung', () => {
const code = [
"const TEXT = msg('Hallo');",
'el(\'p\', {}, TEXT);',
'const a = { title: TEXT };',
'const b = `Fehler: ${TEXT}`;',
'function c() { return TEXT; }',
'node.textContent = ok ? TEXT : TEXT;',
'show(TEXT ?? t(TEXT));',
].join('\n');
expect(findRawConstantUses('quelle.ts', code, none).map((use) => use.split(':')[0])).toEqual(['2', '3', '4', '5', '6', '6', '7']);
});
it('lässt t(NAME), Bedingungen und Rückfälle darin, new Error, Vergleiche, case, Listen und Mengen durch', () => {
const code = [
"const A = msg('Eins');",
"const B = msg('Zwei');",
'const x = t(A) + t(ok ? A : B) + t((A ?? B) || A);',
'const y = t(A, { n: 1 });',
'throw new Error(A);',
'throw new Error(ok ? A : B);',
'if (err.message === A || B !== err.message) f();',
'switch (text) { case A: break; }',
'const known = new Set<string>([A, B]);',
'const list = [A, B];',
].join('\n');
expect(findRawConstantUses('quelle.ts', code, none)).toEqual([]);
});
it('die Bedingung einer Bedingung zählt nicht als Weitergabe des Textes', () => {
const code = ["const A = msg('Eins');", 'const x = t(A ? 1 : 2);'].join('\n');
expect(findRawConstantUses('quelle.ts', code, none).map((use) => use.split(':')[0])).toEqual(['2']);
});
it('ignoriert Deklaration, Eigenschaftsnamen, Importe, Typen und gleich lautende Eigenschaften', () => {
const code = [
"import { A } from './x';",
"export const B = msg('Zwei');",
'const o = { B: 1, C: obj.B };',
'type T = typeof B;',
'export { B };',
].join('\n');
expect(findRawConstantUses('quelle.ts', code, new Map([['x.ts', new Set(['A'])]]))).toEqual([]);
});
it('kennt Konstanten aus anderen Dateien über den Import (auch umbenannt) und nur die exportierten', () => {
const exportsOf = new Map<string, ReadonlySet<string>>([['account/with-login.ts', new Set(['LOGIN_NEEDED'])]]);
const code = [
"import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';",
"import { LOGIN_NEEDED as NEEDED } from '../account/with-login';",
"import { OTHER } from '../account/with-login';",
"import { LOGIN_NEEDED as ELSE } from '../account/anders';",
'const a = LOGIN_NEEDED;',
'const b = NEEDED;',
'const c = OTHER;',
'const d = ELSE;',
'const e = t(LOGIN_NEEDED) + t(NEEDED);',
].join('\n');
expect(findRawConstantUses('pages/seite.ts', code, exportsOf).map((use) => use.split(':')[0])).toEqual(['5', '6']);
});
});
+105
View File
@@ -224,6 +224,111 @@ export const EN: Readonly<Record<string, string>> = {
'Alles, was du seit dem letzten Speichern geändert hast, geht verloren.': 'Everything you’ve changed since the last save will be lost.',
'Verwerfen': 'Discard',
// Galerie-Raster, Galeriebilder und Zahl der Meldungen (community/gallery-grid.ts, community/gallery.ts, pages/format.ts).
// „Ausgeblendet“ steht weiter oben.
'Keine Vorschau': 'No preview',
'Karten mit diesem Tag zeigen': 'Show cards with this tag',
'Das Bild konnte nicht geladen werden.': 'The image couldn’t be loaded.',
'1 Meldung': '1 report',
'{count} Meldungen': '{count} reports',
// Veröffentlichen-Dialog (community/publish-dialog.ts, community/publish.ts). „Abbrechen“, „Schließen“, „Anonym“, „Anzeigename (optional)“,
// die Erklärung dazu und „Diese Karte ist schon veröffentlicht.“ stehen an anderer Stelle.
'Karte veröffentlichen': 'Publish card',
'„{name}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.':
'“{name}” will be shown publicly in the community gallery, including its image.',
'Die Karte wird mit Bild öffentlich in der Community-Galerie gezeigt.':
'The card will be shown publicly in the community gallery, including its image.',
'Titel (Pflicht)': 'Title (required)',
'Herausgeber / Bank (optional)': 'Issuer / bank (optional)',
'Tags (optional)': 'Tags (optional)',
'Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.': 'Up to 5, separated by commas, e.g. “visa, gold”.',
'Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.':
'The image is mine or I’m allowed to use it, and it contains no real card data.',
'Veröffentlichen': 'Publish',
'Wird veröffentlicht …': 'Publishing …',
'Veröffentlichen fehlgeschlagen – {reason}': 'Publishing failed – {reason}',
'Bitte gib einen Titel ein.': 'Please enter a title.',
'Der Titel enthält ungültige Zeichen.': 'The title contains invalid characters.',
'Der Titel ist zu lang (höchstens {max} Zeichen).': 'The title is too long (at most {max} characters).',
'Herausgeber / Bank enthält ungültige Zeichen.': 'Issuer / bank contains invalid characters.',
'Herausgeber / Bank ist zu lang (höchstens {max} Zeichen).': 'Issuer / bank is too long (at most {max} characters).',
'Tags enthalten ungültige Zeichen.': 'Tags contain invalid characters.',
'Ein Tag darf höchstens {max} Zeichen lang sein.': 'A tag can be at most {max} characters long.',
'Bitte gib höchstens {max} Tags an.': 'Please enter at most {max} tags.',
'Bitte bestätige die Erklärung zum Bild.': 'Please confirm the statement about the image.',
'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).': 'The card image is too large for the gallery (max. 5 MB).',
'Das Kartenbild konnte nicht erzeugt werden.': 'The card image couldn’t be created.',
// Startseite (pages/home-page.ts). „Gestalten“, „Gemeldet“, „Ausgeblendet“, „Nochmal versuchen“ und „Mehr laden“ stehen an anderer Stelle.
'Willkommen': 'Welcome',
'Ein bisschen Kunst für dein Wallet': 'A little art for your Wallet',
'Kartendesigns für Apple Wallet': 'Card designs for Apple Wallet',
'Entdecke Motive der Community, gestalte dein eigenes und lade es als PNG herunter.':
'Discover designs from the community, create your own and download it as a PNG.',
'Karten entdecken ↓': 'Discover cards ↓',
'Beispielmotiv: violetter Verlauf mit Wellen und dem AirCard-IOS-Symbol':
'Sample design: purple gradient with waves and the AirCard-IOS symbol',
'Community-Karte „{title}“': 'Community card “{title}”',
'Community-Karten': 'Community cards',
'Entdecke Motive der Community – und veröffentliche deine eigenen.': 'Discover designs from the community – and publish your own.',
'Gestalten & veröffentlichen': 'Create & publish',
'Suche': 'Search',
'Suche zurücksetzen': 'Clear search',
'Tag': 'Tag',
'Tag-Filter zurücksetzen': 'Clear tag filter',
'Ansicht': 'View',
'Alle': 'All',
'Karten werden geladen …': 'Loading cards …',
'Die Community-Karten konnten gerade nicht geladen werden.': 'The community cards couldn’t be loaded right now.',
'Weitere Karten konnten nicht geladen werden. Bitte nochmal versuchen.': 'More cards couldn’t be loaded. Please try again.',
'Noch keine Community-Karten – sei die erste!': 'No community cards yet – be the first!',
'Keine Karten in dieser Ansicht.': 'No cards in this view.',
'Nichts gefunden für „{query}“': 'Nothing found for “{query}”',
// Community-Detailseite (pages/community-page.ts). „Ausgeblendet“, „Melden“, „Löschen“, „Ausblenden“, „Einblenden“, „Herunterladen“,
// „Nochmal versuchen“, „von {name}“, „Anonym“, „Karte wird geladen …“, „Diese Karte ist nicht (mehr) verfügbar.“ und
// „Die Gründe konnten nicht geladen werden.“ stehen an anderer Stelle.
'Die Karte konnte gerade nicht geladen werden.': 'The card couldn’t be loaded right now.',
'Bild wird geladen …': 'Loading image …',
'Der Melden-Dialog konnte nicht geöffnet werden.': 'The report dialog couldn’t be opened.',
'Diese Karte ist für andere nicht sichtbar.': 'This card isn’t visible to others.',
'Zur Galerie': 'Back to gallery',
'← Zur Galerie': '← Back to gallery',
'Wird geladen …': 'Downloading …',
'Als Vorlage gestalten': 'Use as template',
'Zurückziehen': 'Withdraw',
'Moderation': 'Moderation',
'Karte eingeblendet.': 'Card unhidden.',
'Karte ausgeblendet.': 'Card hidden.',
'Karte „{title}“ zurückziehen?': 'Withdraw card “{title}”?',
'Die Karte verschwindet aus der Galerie, ihr Bild und die Meldungen werden gelöscht. Deine private Karte bleibt erhalten.':
'The card disappears from the gallery, and its image and reports are deleted. Your private card is kept.',
'Karte „{title}“ löschen?': 'Delete card “{title}”?',
'Die Karte, ihr Bild und alle Meldungen werden endgültig gelöscht.': 'The card, its image and all reports will be deleted permanently.',
// Meine Karten (pages/cards-page.ts). „Meine Karten“, „Anmelden“, „Herunterladen“, „Löschen“, „Speichern“, „Abbrechen“,
// „Nochmal versuchen“ und „Karten werden geladen …“ stehen an anderer Stelle.
'Melde dich an, um deine Karten zu sehen.': 'Sign in to see your cards.',
'Noch keine Karten gespeichert.': 'No cards saved yet.',
'Zum Editor': 'Go to editor',
'Gespeicherte Karten': 'Saved cards',
'Vorschau von „{name}“': 'Preview of “{name}”',
'Vorschau lädt …': 'Loading preview …',
'Der Veröffentlichungsstatus konnte nicht geladen werden.': 'The publishing status couldn’t be loaded.',
'Öffnen': 'Open',
'Umbenennen': 'Rename',
'Ansehen': 'View',
'Veröffentlicht': 'Published',
'Ausgeblendet (gemeldet)': 'Hidden (reported)',
'Wird erstellt …': 'Creating …',
'Neuer Name für „{name}“': 'New name for “{name}”',
'Die Karte und ihr Bild werden endgültig gelöscht.': 'The card and its image will be deleted permanently.',
'Die veröffentlichte Karte bleibt in der Community-Galerie, bis du sie zurückziehst – das geht dann nur noch auf ihrer Community-Seite.':
'The published card stays in the community gallery until you withdraw it – after deleting this card, you can only do that on its community page.',
'Falls diese Karte veröffentlicht ist, bleibt die Veröffentlichung bestehen – zurückziehen kannst du sie auf ihrer Community-Seite.':
'If this card is published, it stays in the gallery – you can withdraw it on its community page.',
// API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich.
'Bitte melde dich an.': 'Please sign in.',
'Nicht gefunden.': 'Not found.',
+17
View File
@@ -0,0 +1,17 @@
// Hilfen der Englisch-Tests (nur Tests importieren sie): die sichtbaren Texte eines Teilbaums und die deutschen Texte, die auf einer
// englischen Seite noch darin stehen.
import { EN } from './en';
/** Alle sichtbaren Texte (Textknoten) und Textattribute unter `root`. */
export function texts(root: ParentNode): string[] {
const out: string[] = [];
const walker = document.createTreeWalker(root as Node, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) if (node.textContent?.trim()) out.push(node.textContent.trim());
for (const el of root.querySelectorAll('*')) {
for (const attr of ['aria-label', 'title', 'placeholder', 'alt']) if (el.getAttribute(attr)) out.push(el.getAttribute(attr) as string);
}
return out;
}
/** Deutsche Wörterbuchtexte, die auf einer englischen Seite stehen: also Texte, die an `t()` vorbeigegangen sind. */
export const germanLeft = (root: ParentNode): string[] => texts(root).filter((text) => Object.hasOwn(EN, text) && EN[text] !== text);
+1 -15
View File
@@ -2,7 +2,7 @@
// Texten hängt (Abgleich der Passwörter, übersetzte Fehler, Beschriftung des wiederverwendeten Login-Dialogs) arbeitet in beiden
// Sprachen gleich. Wer Module mit `vi.resetModules()` frisch lädt, stellt die Sprache an der frischen Kopie von i18n.ts ein.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EN } from './en';
import { germanLeft, texts } from './english-check';
const mocks = vi.hoisted(() => ({
acct: {
@@ -40,20 +40,6 @@ async function freshI18n(lang: 'de' | 'en') {
return i18n;
}
/** Alle sichtbaren Texte (Textknoten) und Textattribute unter `root`. */
function texts(root: ParentNode): string[] {
const out: string[] = [];
const walker = document.createTreeWalker(root as Node, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) if (node.textContent?.trim()) out.push(node.textContent.trim());
for (const el of root.querySelectorAll('*')) {
for (const attr of ['aria-label', 'title', 'placeholder', 'alt']) if (el.getAttribute(attr)) out.push(el.getAttribute(attr) as string);
}
return out;
}
/** Deutsche Wörterbuchtexte, die auf einer englischen Seite stehen: also Texte, die an `t()` vorbeigegangen sind. */
const germanLeft = (root: ParentNode) => texts(root).filter((text) => Object.hasOwn(EN, text) && EN[text] !== text);
describe('Fehlertexte und Standardname auf Englisch', () => {
it('errorMessage und validatePassword übersetzen beim Zurückgeben', async () => {
await freshI18n('en');