Skip to content

Commit 60954ee

Browse files
guan404mingsanederchik
authored andcommitted
Add translation completeness lint (apache#51166)
1 parent 7d56efd commit 60954ee

File tree

12 files changed

+525
-356
lines changed

12 files changed

+525
-356
lines changed

airflow-core/src/airflow/ui/eslint.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
* @import { FlatConfig } from "@typescript-eslint/utils/ts-eslint";
2222
*/
2323
import { coreRules } from "./rules/core.js";
24+
import { i18nRules } from "./rules/i18n.js";
2425
import { i18nextRules } from "./rules/i18next.js";
2526
import { perfectionistRules } from "./rules/perfectionist.js";
2627
import { prettierRules } from "./rules/prettier.js";
@@ -46,4 +47,5 @@ export default /** @type {const} @satisfies {ReadonlyArray<FlatConfig.Config>} *
4647
stylisticRules,
4748
unicornRules,
4849
i18nextRules,
50+
i18nRules,
4951
]);
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/*!
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
/* eslint-disable @typescript-eslint/no-unsafe-argument */
21+
22+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
23+
import fs from "node:fs";
24+
import path from "node:path";
25+
import { fileURLToPath } from "node:url";
26+
27+
export const i18nNamespace = "i18n";
28+
29+
/**
30+
* Extract all nested keys from translation object
31+
* @param {Record<string, any>} obj
32+
* @param {string} [prefix]
33+
* @returns {string[]}
34+
*/
35+
const getKeys = (obj, prefix = "") => {
36+
if (Array.isArray(obj)) {
37+
return [];
38+
}
39+
40+
return Object.keys(obj).flatMap((key) => {
41+
const newPrefix = prefix ? `${prefix}.${key}` : key;
42+
const value = obj[key];
43+
44+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
45+
return [newPrefix, ...getKeys(value, newPrefix)];
46+
}
47+
48+
return [newPrefix];
49+
});
50+
};
51+
52+
// Path to locales directory
53+
const localesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../src/i18n/locales");
54+
55+
// Default language (English) as reference
56+
const defaultLanguage = "en";
57+
const defaultLanguageKeys = /** @type {Record<string, string[]>} */ ({});
58+
const defaultLanguageDir = path.join(localesDir, defaultLanguage);
59+
60+
// Load translation keys from default language files
61+
fs.readdirSync(defaultLanguageDir)
62+
.filter((file) => file.endsWith(".json"))
63+
.forEach((jsonFile) => {
64+
const ns = path.basename(jsonFile, ".json");
65+
const filePath = path.join(defaultLanguageDir, jsonFile);
66+
const fileContent = fs.readFileSync(filePath, "utf8");
67+
const parsedJson = JSON.parse(fileContent);
68+
69+
if (typeof parsedJson === "object" && parsedJson !== null && !Array.isArray(parsedJson)) {
70+
defaultLanguageKeys[ns] = getKeys(parsedJson);
71+
}
72+
});
73+
74+
export const i18nPlugin = {
75+
files: ["**/i18n/locales/**/*.json"],
76+
rules: {
77+
"check-translation-completeness": {
78+
/** @param {import('@typescript-eslint/utils').TSESLint.RuleContext<'missingKeys' | 'fileError', []>} context */
79+
create(context) {
80+
return {
81+
/** @param {import('@typescript-eslint/utils').TSESTree.Program} node */
82+
Program(node) {
83+
// Get language code and namespace from file path
84+
const currentFilePath = context.filename;
85+
const langCode = path.dirname(path.relative(localesDir, currentFilePath));
86+
const namespace = path.basename(currentFilePath, ".json");
87+
88+
if (langCode === defaultLanguage) {
89+
return;
90+
}
91+
92+
// Get keys from current file
93+
const referenceKeys = defaultLanguageKeys[namespace];
94+
let langKeys;
95+
96+
try {
97+
const parsedLangJson = JSON.parse(context.sourceCode.text);
98+
99+
if (
100+
typeof parsedLangJson === "object" &&
101+
parsedLangJson !== null &&
102+
!Array.isArray(parsedLangJson)
103+
) {
104+
langKeys = getKeys(parsedLangJson);
105+
} else {
106+
context.report({
107+
data: { error: "Invalid JSON object.", filePath: currentFilePath },
108+
messageId: "fileError",
109+
node,
110+
});
111+
112+
return;
113+
}
114+
} catch (error) {
115+
const message = error instanceof Error ? error.message : String(error);
116+
117+
context.report({
118+
data: { error: message, filePath: currentFilePath },
119+
messageId: "fileError",
120+
node,
121+
});
122+
123+
return;
124+
}
125+
126+
// Check for missing translations
127+
const langKeysSet = new Set(langKeys);
128+
const missingKeys = referenceKeys.filter((key) => !langKeysSet.has(key));
129+
130+
if (missingKeys.length > 0) {
131+
context.report({
132+
data: { keys: missingKeys.join(", "), lang: langCode, namespace },
133+
messageId: "missingKeys",
134+
node,
135+
});
136+
}
137+
},
138+
};
139+
},
140+
meta: {
141+
docs: {
142+
category: "Best Practices",
143+
description: "Ensures non-default lang files have all keys from default.",
144+
recommended: "warn",
145+
},
146+
messages: {
147+
fileError: "Failed to read/parse {{filePath}}. Error: {{error}}",
148+
missingKeys: "Lang '{{lang}}' (namespace: {{namespace}}) missing keys: {{keys}}",
149+
},
150+
type: "problem",
151+
},
152+
},
153+
},
154+
};
155+
156+
/** @type {import("@typescript-eslint/utils/ts-eslint").FlatConfig.Config} */
157+
export const i18nRules = {
158+
files: ["**/i18n/locales/**/*.json"],
159+
plugins: {
160+
[i18nNamespace]: i18nPlugin,
161+
},
162+
rules: {
163+
"@typescript-eslint/no-unused-expressions": "off",
164+
[`${i18nNamespace}/check-translation-completeness`]: "warn",
165+
"no-unused-expressions": "off",
166+
},
167+
};
Lines changed: 64 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,92 @@
11
{
2+
"admin": {
3+
"Config": "Konfiguration",
4+
"Connections": "Verbindungen",
5+
"Plugins": "Plug-ins",
6+
"Pools": "Pools",
7+
"Providers": "Providers",
8+
"Variables": "Variablen"
9+
},
10+
"assetEvent_one": "Ereignis zu Datensets (Asset)",
11+
"assetEvent_other": "Ereignisse zu Datensets (Asset)",
12+
"browse": {
13+
"auditLog": "Prüf-Log",
14+
"xcoms": "Task Kommunikation (XComs)"
15+
},
16+
"dagRun_one": "Dag Lauf",
17+
"dagRun_other": "Dag Läufe",
218
"defaultToGraphView": "Graph-Ansicht als Standard",
319
"defaultToGridView": "Gitter-Ansicht als Standard",
20+
"docs": {
21+
"documentation": "Dokumentation",
22+
"githubRepo": "GitHub Ablage",
23+
"restApiReference": "REST API Referenz"
24+
},
425
"logout": "Abmelden",
526
"logoutConfirmation": "Sie sind dabei sich von dem System abzumelden.",
6-
"switchToDarkMode": "Zum Dunkelmodus wechseln",
7-
"switchToLightMode": "Zum Hellmodus wechseln",
8-
"timezone": "Zeitzone",
9-
"user": "Benutzer",
10-
"selectLanguage": "Sprache wählen",
27+
"modal": {
28+
"cancel": "Abbrechen",
29+
"confirm": "Bestätigen"
30+
},
1131
"nav": {
12-
"home": "Start",
32+
"admin": "Verwaltung",
1333
"assets": "Datensets (Assets)",
1434
"browse": "Browsen",
15-
"admin": "Verwaltung",
16-
"security": "Sicherheit",
1735
"docs": "Doku",
18-
"plugins": "Plug-ins"
19-
},
20-
"browse": {
21-
"auditLog": "Prüf-Log",
22-
"xcoms": "Task Kommunikation (XComs)"
36+
"home": "Start",
37+
"plugins": "Plug-ins",
38+
"security": "Sicherheit"
2339
},
24-
"admin": {
25-
"Variables": "Variablen",
26-
"Pools": "Pools",
27-
"Providers": "Providers",
28-
"Plugins": "Plug-ins",
29-
"Connections": "Verbindungen",
30-
"Config": "Konfiguration"
40+
"pools": {
41+
"deferred": "Delegiert",
42+
"open": "Frei",
43+
"pools_one": "Pool",
44+
"pools_other": "Pools",
45+
"queued": "Wartend",
46+
"running": "Laufend",
47+
"scheduled": "Geplant"
3148
},
3249
"security": {
33-
"users": "Benutzer",
34-
"roles": "Rollen",
3550
"actions": "Aktionen",
51+
"permissions": "Berechtigungen",
3652
"resources": "Ressourcen",
37-
"permissions": "Berechtigungen"
38-
},
39-
"timeRange": {
40-
"duration": "Laufzeit",
41-
"lastHour": "Letzte Stunde",
42-
"last12Hours": "Letzte 12 Stunden",
43-
"last24Hours": "Letzte 24 Stunden",
44-
"pastWeek": "Letzte Woche"
45-
},
46-
"docs": {
47-
"documentation": "Dokumentation",
48-
"githubRepo": "GitHub Ablage",
49-
"restApiReference": "REST API Referenz"
53+
"roles": "Rollen",
54+
"users": "Benutzer"
5055
},
56+
"selectLanguage": "Sprache wählen",
5157
"states": {
52-
"queued": "Wartend",
53-
"running": "Laufend",
54-
"success": "Erfolgreich",
58+
"deferred": "Delegiert",
5559
"failed": "Fehlgeschlagen",
56-
"skipped": "Übersprungen",
60+
"no_status": "Kein Status",
61+
"queued": "Wartend",
5762
"removed": "Entfernt",
58-
"scheduled": "Geplant",
5963
"restarting": "Im Neustart",
60-
"up_for_retry": "Wartet auf neuen Versuch",
64+
"running": "Laufend",
65+
"scheduled": "Geplant",
66+
"skipped": "Übersprungen",
67+
"success": "Erfolgreich",
6168
"up_for_reschedule": "Wartet auf Neuplanung",
62-
"upstream_failed": "Vorgelagerte fehlgeschlagen",
63-
"deferred": "Delegiert",
64-
"no_status": "Kein Status"
69+
"up_for_retry": "Wartet auf neuen Versuch",
70+
"upstream_failed": "Vorgelagerte fehlgeschlagen"
6571
},
66-
"dagRun_one": "Dag Lauf",
67-
"dagRun_other": "Dag Läufe",
72+
"switchToDarkMode": "Zum Dunkelmodus wechseln",
73+
"switchToLightMode": "Zum Hellmodus wechseln",
6874
"taskInstance_one": "Task Instanz",
6975
"taskInstance_other": "Task Instanzen",
70-
"assetEvent_one": "Ereignis zu Datensets (Asset)",
71-
"assetEvent_other": "Ereignisse zu Datensets (Asset)",
72-
"triggered": "Angestoßen",
73-
"pools": {
74-
"open": "Frei",
75-
"running": "Laufend",
76-
"queued": "Wartend",
77-
"scheduled": "Geplant",
78-
"deferred": "Delegiert",
79-
"pools_one": "Pool",
80-
"pools_other": "Pools"
81-
},
82-
"modal": {
83-
"cancel": "Abbrechen",
84-
"confirm": "Bestätigen"
76+
"timeRange": {
77+
"duration": "Laufzeit",
78+
"last12Hours": "Letzte 12 Stunden",
79+
"last24Hours": "Letzte 24 Stunden",
80+
"lastHour": "Letzte Stunde",
81+
"pastWeek": "Letzte Woche"
8582
},
83+
"timezone": "Zeitzone",
8684
"timezoneModal": {
87-
"title": "Auswahl der Zeitzone",
88-
"placeholder": "Wählen Sie eine Zeitzone",
8985
"current-timezone": "Aktuelle Zeit in",
86+
"placeholder": "Wählen Sie eine Zeitzone",
87+
"title": "Auswahl der Zeitzone",
9088
"utc": "UTC (Koordinierte Weltzeit)"
91-
}
89+
},
90+
"triggered": "Angestoßen",
91+
"user": "Benutzer"
9292
}
Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,38 @@
11
{
2-
"welcome": "Willkommen",
3-
"stats": {
4-
"stats": "Statistiken",
5-
"failedDags": "Fehlgeschlagene Dags",
6-
"runningDags": "Laufende Dags",
7-
"activeDags": "Aktive Dags",
8-
"queuedDags": "Dags in Warteschlange"
9-
},
2+
"group": "Gruppe",
103
"health": {
4+
"dagProcessor": "Dag Prozessor",
115
"health": "System-Gesundheit",
6+
"healthy": "Gut",
7+
"lastHeartbeat": "Letztes Lebenszeichen",
128
"metaDatabase": "Meta-Datenbank",
139
"scheduler": "Planer",
14-
"triggerer": "Triggerer",
15-
"dagProcessor": "Dag Prozessor",
1610
"status": "Status",
17-
"lastHeartbeat": "Letztes Lebenszeichen",
18-
"healthy": "Gut",
11+
"triggerer": "Triggerer",
1912
"unhealthy": "Nicht in Ordnung"
2013
},
14+
"history": "Historie",
2115
"importErrors": {
22-
"searchByFile": "Nach Datei suchen",
2316
"dagImportError_one": "Fehler beim Laden des Dags",
2417
"dagImportError_other": "Fehler beim Laden der Dags",
18+
"searchByFile": "Nach Datei suchen",
2519
"timestamp": "Zeitstempel"
2620
},
27-
"poolSlots": "Pool Belegung",
2821
"managePools": "Pools verwalten",
29-
"history": "Historie",
22+
"noAssetEvents": "Keine Ereignisse zu Datensets vorliegend.",
23+
"poolSlots": "Pool Belegung",
3024
"sortBy": {
3125
"newestFirst": "Neueste zuerst",
3226
"oldestFirst": "Älteste zuerst"
3327
},
3428
"source": "Quellcode",
35-
"group": "Gruppe",
29+
"stats": {
30+
"activeDags": "Aktive Dags",
31+
"failedDags": "Fehlgeschlagene Dags",
32+
"queuedDags": "Dags in Warteschlange",
33+
"runningDags": "Laufende Dags",
34+
"stats": "Statistiken"
35+
},
3636
"uri": "Uri",
37-
"noAssetEvents": "Keine Ereignisse zu Datensets vorliegend."
37+
"welcome": "Willkommen"
3838
}

0 commit comments

Comments
 (0)