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:
1 parent
1ff1873b27
commit
7e66cd0f0f
20 files changed
+1164
-211
No files matched your search
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user