From d34367ae43da97575a174e26ce39b8c2ff9a517e Mon Sep 17 00:00:00 2001
From: Damon Lu <59256766+WhatDamon@users.noreply.github.com>
Date: Wed, 18 Feb 2026 15:14:35 +0800
Subject: [PATCH 1/4] Enhance i18n support
---
css/style.css | 75 ++++++++++++++++++++++++++++++++
data/i18n.json | 59 +++++++++++++++++++++++---
index.html | 12 ++++++
src/events.js | 33 +++++++++++++--
src/i18n.js | 113 ++++++++++++++++++++++++++++++++++++++++++++++---
src/state.js | 3 +-
6 files changed, 278 insertions(+), 17 deletions(-)
diff --git a/css/style.css b/css/style.css
index 09c63dc..9945fb3 100644
--- a/css/style.css
+++ b/css/style.css
@@ -472,6 +472,57 @@ md-text-button .material-icons {
margin-top: 16px;
}
+.lang-modal-panel {
+ max-width: 360px;
+}
+
+.lang-option {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding: 12px 16px;
+ cursor: pointer;
+ border-radius: 8px;
+ transition: background-color 0.15s;
+ margin-bottom: 4px;
+ border: none;
+ background: transparent;
+ text-align: left;
+ font-family: inherit;
+ font-size: inherit;
+}
+
+.lang-option:focus {
+ outline: 2px solid #6750A4;
+ outline-offset: 2px;
+}
+
+.lang-option:hover {
+ background: rgba(103, 80, 164, 0.08);
+}
+
+.lang-option.active {
+ background: rgba(103, 80, 164, 0.12);
+}
+
+.lang-native {
+ font-size: 1rem;
+ font-weight: 500;
+ color: #1C1B1F;
+ flex: 1;
+}
+
+.lang-name {
+ font-size: 0.85rem;
+ color: #49454F;
+ margin-left: 8px;
+}
+
+.lang-check {
+ color: #6750A4;
+ font-size: 20px;
+}
+
@media (max-width: 768px) {
.topbar-actions {
display: none;
@@ -644,4 +695,28 @@ md-text-button .material-icons {
--md-switch-unselected-hover-thumb-color: #605C66;
--md-switch-unselected-hover-track-color: #605C66;
}
+
+ .lang-option:hover {
+ background: rgba(208, 188, 255, 0.08);
+ }
+
+ .lang-option:focus {
+ outline-color: #D0BCFF;
+ }
+
+ .lang-option.active {
+ background: rgba(208, 188, 255, 0.12);
+ }
+
+ .lang-native {
+ color: #E6E0E9;
+ }
+
+ .lang-name {
+ color: #938F99;
+ }
+
+ .lang-check {
+ color: #D0BCFF;
+ }
}
\ No newline at end of file
diff --git a/data/i18n.json b/data/i18n.json
index ddc7250..9008290 100644
--- a/data/i18n.json
+++ b/data/i18n.json
@@ -13,9 +13,6 @@
"library": "Character Library",
"about": "About",
"exeVersion": "EXE Version",
- "langZh": "中文",
- "langEn": "English",
- "options": "Options",
"suffix": "Suffix:",
"suffixNone": "None",
"suffixMS": "Microsoft style [!]",
@@ -38,6 +35,8 @@
"historyClear": "Clear",
"historyEmpty": "No processing history yet.",
"historyCleared": "History cleared.",
+ "langModalTitle": "Select Language",
+ "langBtn": "Language",
"historyInput": "Input:",
"historyOutput": "Output:",
"libraryCharacter": "Character",
@@ -47,7 +46,7 @@
"aboutTitle": "What is Pseudo-Localization?",
"aboutContent": "Pseudolocalization (or qps-ploc, en-XA, en-XB) is a way to simulate the localization process to identify potential issues before actual translation. During pseudo-localization, English letters are replaced with accented characters from other scripts. Separators are also added to increase string length. Example: \"Hello\" → \"[1iaT9][ Ĥêļļø !!! ]\" More info: Microsoft Docs "
},
- "zh": {
+ "zh-Hans": {
"title": "伪本地化演示",
"modeXA": "en-XA(重音)",
"modeXB": "en-XB(反转)",
@@ -61,9 +60,6 @@
"library": "字符库",
"about": "关于",
"exeVersion": "EXE 版本",
- "langZh": "中文",
- "langEn": "English",
- "options": "选项",
"suffix": "后缀:",
"suffixNone": "无",
"suffixMS": "微软式 [!]",
@@ -86,6 +82,8 @@
"historyClear": "清空",
"historyEmpty": "暂无处理历史记录。",
"historyCleared": "历史记录已清空。",
+ "langModalTitle": "选择语言",
+ "langBtn": "语言",
"historyInput": "输入:",
"historyOutput": "输出:",
"libraryCharacter": "字符",
@@ -94,5 +92,52 @@
"libraryTotal": "共 {count} 个字符",
"aboutTitle": "什么是伪本地化?",
"aboutContent": "伪本地化 (pseudo-localization) 是一种模拟本地化过程的测试方法,可以在实际翻译前发现问题。 在伪本地化过程中,英文字母会被替换为其他语言的重音字符,还会添加分隔符以增加字符串长度。 示例:\"Hello\" → \"[1iaT9][ Ĥêļļø !!! ]\" 更多信息:Microsoft 文档 "
+ },
+ "zh-Hant": {
+ "title": "偽本地化示範",
+ "modeXA": "en-XA(重音)",
+ "modeXB": "en-XB(反轉)",
+ "inputPlaceholder": "輸入要處理的文字",
+ "outputPlaceholder": "處理結果",
+ "process": "進行偽本地化!",
+ "clear": "清除",
+ "copy": "複製",
+ "copySuccess": "已複製!",
+ "history": "歷史記錄",
+ "library": "字元庫",
+ "about": "關於",
+ "exeVersion": "EXE 版本",
+ "suffix": "後綴:",
+ "suffixNone": "無",
+ "suffixMS": "微軟式 [!]",
+ "suffixAndroid": "安卓式 (one two three)",
+ "suffixNum": "數字式 (12345)",
+ "suffixCustom": "自訂",
+ "customPrefix": "前綴:",
+ "customSuffix": "後綴:",
+ "customRepeat": "重複:",
+ "customRepeatCount": "每隔N字元重複:",
+ "upper": "大寫",
+ "lower": "小寫",
+ "doubleVowel": "母音重複",
+ "doubleVowelCount": "母音重複次數:",
+ "circleNumber": "處理數字",
+ "addHash": "添加 Hash ID",
+ "hashLength": "ID 長度:",
+ "preserveEsc": "保留轉義字元",
+ "historyTitle": "歷史記錄",
+ "historyClear": "清除",
+ "historyEmpty": "暫無處理歷史記錄。",
+ "historyCleared": "歷史記錄已清除。",
+ "langModalTitle": "選擇語言",
+ "langBtn": "語言",
+ "historyInput": "輸入:",
+ "historyOutput": "輸出:",
+ "libraryCharacter": "字元",
+ "libraryVariants": "變體",
+ "libraryCount": "數量",
+ "libraryTotal": "共 {count} 個字元",
+ "aboutTitle": "什麼是偽本地化?",
+ "aboutContent": "偽本地化 (pseudo-localization) 是一種模擬本地化過程的測試方法,可以在實際翻譯前發現問題。 在偽本地化過程中,英文字母會被替換為其他語言的重音字元,還會添加分隔符以增加字串長度。 範例:\"Hello\" → \"[1iaT9][ Ĥêļļø !!! ]\" 更多資訊:Microsoft 文件 "
}
}
diff --git a/index.html b/index.html
index a257cd2..ae2bbbc 100644
--- a/index.html
+++ b/index.html
@@ -13,6 +13,7 @@
+
@@ -201,6 +202,17 @@
+
+
diff --git a/src/events.js b/src/events.js
index 2af04eb..2e86f1e 100644
--- a/src/events.js
+++ b/src/events.js
@@ -182,7 +182,7 @@ export function initEvents() {
$("lang-btn-mobile")?.addEventListener("click", () => {
$("mobile-menu")?.classList.remove("show");
- setLang(state.currentLang === "zh" ? "en" : "zh");
+ showModal("lang-modal");
});
$("github-btn-mobile")?.addEventListener("click", () => {
@@ -199,7 +199,32 @@ export function initEvents() {
window.open("https://github.com/suntrise/Pseudo-localization-Demo", "_blank");
});
- $("lang-btn")?.addEventListener("click", () => setLang(state.currentLang === "zh" ? "en" : "zh"));
+ $("lang-btn")?.addEventListener("click", () => showModal("lang-modal"));
+
+ $("lang-list")?.addEventListener("click", (e) => {
+ const langOption = e.target.closest(".lang-option");
+ if (langOption) {
+ const selectedLang = langOption.dataset.lang;
+ if (selectedLang && selectedLang !== state.currentLang) {
+ setLang(selectedLang);
+ hideModal("lang-modal");
+ }
+ }
+ });
+
+ $("lang-list")?.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ const langOption = e.target.closest(".lang-option");
+ if (langOption) {
+ e.preventDefault();
+ const selectedLang = langOption.dataset.lang;
+ if (selectedLang && selectedLang !== state.currentLang) {
+ setLang(selectedLang);
+ hideModal("lang-modal");
+ }
+ }
+ }
+ });
const modalEvents = [
["close-history-btn", "history-modal"],
@@ -207,7 +232,9 @@ export function initEvents() {
["close-library-btn", "library-modal"],
["library-backdrop", "library-modal"],
["close-about-btn", "about-modal"],
- ["about-backdrop", "about-modal"]
+ ["about-backdrop", "about-modal"],
+ ["close-lang-btn", "lang-modal"],
+ ["lang-backdrop", "lang-modal"]
];
modalEvents.forEach(([btnId, modalId]) => {
$(btnId)?.addEventListener("click", () => hideModal(modalId));
diff --git a/src/i18n.js b/src/i18n.js
index adf1e84..e2cd938 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -1,6 +1,48 @@
import { state, getState } from "./state.js";
import { $ } from "./dom.js";
+const LANGUAGE_NAMES_FALLBACK = {
+ "en": { name: "English", nativeName: "English" },
+ "zh": { name: "Chinese", nativeName: "中文" }
+};
+
+function getLangInfo(langCode) {
+ const [baseCode, region] = langCode.split("-");
+
+ if (LANGUAGE_NAMES_FALLBACK[langCode]) {
+ return LANGUAGE_NAMES_FALLBACK[langCode];
+ }
+
+ if (LANGUAGE_NAMES_FALLBACK[baseCode] && !region) {
+ return LANGUAGE_NAMES_FALLBACK[baseCode];
+ }
+
+ try {
+ const enDisplayNames = new Intl.DisplayNames(["en"], { type: "language", languageDisplay: "dialect" });
+ const name = enDisplayNames.of(langCode) || baseCode;
+
+ const nativeDisplayNames = new Intl.DisplayNames([baseCode], { type: "language" });
+ const nativeName = nativeDisplayNames.of(langCode) || nativeDisplayNames.of(baseCode) || baseCode;
+
+ return { name, nativeName };
+ } catch (e) {
+ return LANGUAGE_NAMES_FALLBACK[baseCode] || { name: langCode, nativeName: langCode };
+ }
+}
+
+function buildSupportedLanguages(i18nData) {
+ const langCodes = Object.keys(i18nData);
+ return langCodes.map(code => {
+ const info = getLangInfo(code);
+ return {
+ code,
+ baseCode: code.split("-")[0],
+ name: info.name,
+ nativeName: info.nativeName
+ };
+ });
+}
+
export async function loadI18n() {
const basePath = window.BASE_PATH || './';
const [i18nRes, metaRes] = await Promise.all([
@@ -9,6 +51,23 @@ export async function loadI18n() {
]);
state.i18nData = await i18nRes.json();
state.metadata = await metaRes.json();
+
+ state.supportedLanguages = buildSupportedLanguages(state.i18nData);
+}
+
+function getLangFallback(langCode) {
+ if (state.i18nData[langCode]) {
+ return langCode;
+ }
+ const baseCode = langCode.split("-")[0];
+ if (state.i18nData[baseCode]) {
+ return baseCode;
+ }
+ const matchedLang = state.supportedLanguages?.find(l => l.code.startsWith(baseCode));
+ if (matchedLang) {
+ return matchedLang.code;
+ }
+ return Object.keys(state.i18nData)[0] || "en";
}
export function setLang(lang) {
@@ -18,7 +77,8 @@ export function setLang(lang) {
}
export function t() {
- return state.i18nData[state.currentLang] || state.i18nData["en"];
+ const fallbackLang = getLangFallback(state.currentLang);
+ return state.i18nData[fallbackLang] || state.i18nData["en"];
}
export function applyLanguage() {
@@ -30,6 +90,8 @@ export function applyLanguage() {
const titleWithVersion = version ? `${txt.title} v${version}` : txt.title;
document.title = txt.title;
+ const currentLangObj = state.supportedLanguages?.find(l => l.code === state.currentLang) || { nativeName: "Language" };
+
const bindings = {
"app-title": titleWithVersion,
"label-mode-xa": txt.modeXA,
@@ -54,7 +116,8 @@ export function applyLanguage() {
"label-preserve-esc": txt.preserveEsc,
"history-title-text": txt.historyTitle,
"library-title-text": txt.library,
- "about-title-text": txt.aboutTitle
+ "about-title-text": txt.aboutTitle,
+ "lang-modal-title": txt.langModalTitle || "Select Language"
};
for (const [id, value] of Object.entries(bindings)) {
@@ -78,8 +141,8 @@ export function applyLanguage() {
{ id: "about-btn-mobile", text: txt.about, icon: "info" },
{ id: "exe-btn", text: txt.exeVersion, icon: "code" },
{ id: "exe-btn-mobile", text: txt.exeVersion, icon: "code" },
- { id: "lang-btn", text: state.currentLang === "zh" ? txt.langEn : txt.langZh, icon: "language" },
- { id: "lang-btn-mobile", text: state.currentLang === "zh" ? txt.langEn : txt.langZh, icon: "language" },
+ { id: "lang-btn", text: txt.langBtn || currentLangObj.nativeName, icon: "language" },
+ { id: "lang-btn-mobile", text: txt.langBtn || currentLangObj.nativeName, icon: "language" },
{ id: "clear-history-btn", text: txt.historyClear, icon: "delete_sweep" },
{ id: "github-btn-mobile", text: "GitHub", icon: "open_in_new" }
];
@@ -91,12 +154,50 @@ export function applyLanguage() {
});
$("about-content").innerHTML = txt.aboutContent;
+
+ renderLangList();
+}
+
+function renderLangList() {
+ const langList = $("lang-list");
+ if (!langList) return;
+
+ const currentLang = state.currentLang;
+
+ langList.innerHTML = state.supportedLanguages?.map(lang => `
+
+ ${lang.nativeName}
+ ${lang.name}
+ ${lang.code === currentLang ? 'check ' : ''}
+
+ `).join('') || '';
}
export function getSavedLang() {
const savedLang = localStorage.getItem("pseudo-lang");
- if (savedLang && (savedLang === "en" || savedLang === "zh")) {
+ if (savedLang && state.supportedLanguages?.some(l => l.code === savedLang)) {
return savedLang;
}
- return navigator.language?.startsWith("zh") ? "zh" : "en";
+
+ const browserLang = navigator.language || "en";
+ const [langPart, regionPart] = browserLang.split('-');
+
+ if (state.supportedLanguages?.some(l => l.code === browserLang)) {
+ return browserLang;
+ }
+
+ if (regionPart && state.supportedLanguages?.some(l => l.code === `${langPart}-${regionPart}`)) {
+ return `${langPart}-${regionPart}`;
+ }
+
+ if (state.supportedLanguages?.some(l => l.code === langPart)) {
+ return langPart;
+ }
+
+ const matchedLang = state.supportedLanguages?.find(l => l.code.startsWith(langPart));
+ if (matchedLang) {
+ return matchedLang.code;
+ }
+
+ return Object.keys(state.i18nData)[0] || "en";
}
diff --git a/src/state.js b/src/state.js
index f0a1a66..67a75de 100644
--- a/src/state.js
+++ b/src/state.js
@@ -4,7 +4,8 @@ export const state = {
currentLang: "en",
currentMode: "XA",
processingHistory: [],
- charLib: null
+ charLib: null,
+ supportedLanguages: []
};
export function getState() {
From d00bae13f03bdb6ade12e812a50efaf0cc03f69e Mon Sep 17 00:00:00 2001
From: Damon Lu <59256766+WhatDamon@users.noreply.github.com>
Date: Wed, 18 Feb 2026 15:22:57 +0800
Subject: [PATCH 2/4] Unified i18n style
---
data/i18n.json | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/data/i18n.json b/data/i18n.json
index 9008290..cff3630 100644
--- a/data/i18n.json
+++ b/data/i18n.json
@@ -48,8 +48,8 @@
},
"zh-Hans": {
"title": "伪本地化演示",
- "modeXA": "en-XA(重音)",
- "modeXB": "en-XB(反转)",
+ "modeXA": "en-XA (重音)",
+ "modeXB": "en-XB (反转)",
"inputPlaceholder": "输入要处理的文本",
"outputPlaceholder": "处理结果",
"process": "进行伪本地化!",
@@ -69,7 +69,7 @@
"customPrefix": "前缀:",
"customSuffix": "后缀:",
"customRepeat": "重复:",
- "customRepeatCount": "每隔N字符重复:",
+ "customRepeatCount": "每隔 N 字符重复:",
"upper": "大写",
"lower": "小写",
"doubleVowel": "元音重复",
@@ -95,8 +95,8 @@
},
"zh-Hant": {
"title": "偽本地化示範",
- "modeXA": "en-XA(重音)",
- "modeXB": "en-XB(反轉)",
+ "modeXA": "en-XA (重音)",
+ "modeXB": "en-XB (反轉)",
"inputPlaceholder": "輸入要處理的文字",
"outputPlaceholder": "處理結果",
"process": "進行偽本地化!",
@@ -116,7 +116,7 @@
"customPrefix": "前綴:",
"customSuffix": "後綴:",
"customRepeat": "重複:",
- "customRepeatCount": "每隔N字元重複:",
+ "customRepeatCount": "每隔 N 字元重複:",
"upper": "大寫",
"lower": "小寫",
"doubleVowel": "母音重複",
From 69f3f7b0366c2cc8600f31d1e907bff39cbebedf Mon Sep 17 00:00:00 2001
From: Damon Lu <59256766+WhatDamon@users.noreply.github.com>
Date: Wed, 18 Feb 2026 15:48:04 +0800
Subject: [PATCH 3/4] Update i18n.json
---
data/i18n.json | 40 ++++++++++++++++++++--------------------
1 file changed, 20 insertions(+), 20 deletions(-)
diff --git a/data/i18n.json b/data/i18n.json
index cff3630..b8c5fcd 100644
--- a/data/i18n.json
+++ b/data/i18n.json
@@ -60,23 +60,23 @@
"library": "字符库",
"about": "关于",
"exeVersion": "EXE 版本",
- "suffix": "后缀:",
+ "suffix": "后缀:",
"suffixNone": "无",
"suffixMS": "微软式 [!]",
"suffixAndroid": "安卓式 (one two three)",
"suffixNum": "数字式 (12345)",
"suffixCustom": "自定义",
- "customPrefix": "前缀:",
- "customSuffix": "后缀:",
- "customRepeat": "重复:",
+ "customPrefix": "前缀:",
+ "customSuffix": "后缀:",
+ "customRepeat": "重复:",
"customRepeatCount": "每隔 N 字符重复:",
"upper": "大写",
"lower": "小写",
"doubleVowel": "元音重复",
- "doubleVowelCount": "元音重复次数:",
+ "doubleVowelCount": "元音重复次数:",
"circleNumber": "处理数字",
"addHash": "添加 Hash ID",
- "hashLength": "ID 长度:",
+ "hashLength": "ID 长度:",
"preserveEsc": "保留转义字符",
"historyTitle": "历史记录",
"historyClear": "清空",
@@ -84,14 +84,14 @@
"historyCleared": "历史记录已清空。",
"langModalTitle": "选择语言",
"langBtn": "语言",
- "historyInput": "输入:",
- "historyOutput": "输出:",
+ "historyInput": "输入:",
+ "historyOutput": "输出:",
"libraryCharacter": "字符",
"libraryVariants": "变体",
"libraryCount": "数量",
"libraryTotal": "共 {count} 个字符",
- "aboutTitle": "什么是伪本地化?",
- "aboutContent": "伪本地化 (pseudo-localization) 是一种模拟本地化过程的测试方法,可以在实际翻译前发现问题。 在伪本地化过程中,英文字母会被替换为其他语言的重音字符,还会添加分隔符以增加字符串长度。 示例:\"Hello\" → \"[1iaT9][ Ĥêļļø !!! ]\" 更多信息:Microsoft 文档 "
+ "aboutTitle": "什么是伪本地化?",
+ "aboutContent": "伪本地化 (pseudo-localization) 是一种模拟本地化过程的测试方法, 可以在实际翻译前发现问题。 在伪本地化过程中, 英文字母会被替换为其他语言的重音字符, 还会添加分隔符以增加字符串长度。 示例:\"Hello\" → \"[1iaT9][ Ĥêļļø !!! ]\" 更多信息: Microsoft 文档 "
},
"zh-Hant": {
"title": "偽本地化示範",
@@ -107,23 +107,23 @@
"library": "字元庫",
"about": "關於",
"exeVersion": "EXE 版本",
- "suffix": "後綴:",
+ "suffix": "後綴:",
"suffixNone": "無",
"suffixMS": "微軟式 [!]",
"suffixAndroid": "安卓式 (one two three)",
"suffixNum": "數字式 (12345)",
"suffixCustom": "自訂",
- "customPrefix": "前綴:",
- "customSuffix": "後綴:",
- "customRepeat": "重複:",
+ "customPrefix": "前綴:",
+ "customSuffix": "後綴:",
+ "customRepeat": "重複:",
"customRepeatCount": "每隔 N 字元重複:",
"upper": "大寫",
"lower": "小寫",
"doubleVowel": "母音重複",
- "doubleVowelCount": "母音重複次數:",
+ "doubleVowelCount": "母音重複次數:",
"circleNumber": "處理數字",
"addHash": "添加 Hash ID",
- "hashLength": "ID 長度:",
+ "hashLength": "ID 長度:",
"preserveEsc": "保留轉義字元",
"historyTitle": "歷史記錄",
"historyClear": "清除",
@@ -131,13 +131,13 @@
"historyCleared": "歷史記錄已清除。",
"langModalTitle": "選擇語言",
"langBtn": "語言",
- "historyInput": "輸入:",
- "historyOutput": "輸出:",
+ "historyInput": "輸入:",
+ "historyOutput": "輸出:",
"libraryCharacter": "字元",
"libraryVariants": "變體",
"libraryCount": "數量",
"libraryTotal": "共 {count} 個字元",
- "aboutTitle": "什麼是偽本地化?",
- "aboutContent": "偽本地化 (pseudo-localization) 是一種模擬本地化過程的測試方法,可以在實際翻譯前發現問題。 在偽本地化過程中,英文字母會被替換為其他語言的重音字元,還會添加分隔符以增加字串長度。 範例:\"Hello\" → \"[1iaT9][ Ĥêļļø !!! ]\" 更多資訊:Microsoft 文件 "
+ "aboutTitle": "什麼是偽本地化?",
+ "aboutContent": "偽本地化 (pseudo-localization) 是一種模擬本地化過程的測試方法,可以在實際翻譯前發現問題。 在偽本地化過程中, 英文字母會被替換為其他語言的重音字元, 還會添加分隔符以增加字串長度。 範例:\"Hello\" → \"[1iaT9][ Ĥêļļø !!! ]\" 更多資訊: Microsoft 檔案 "
}
}
From edcacf72b1cee3d698d36a50fc2e4542d0577b33 Mon Sep 17 00:00:00 2001
From: Damon Lu <59256766+WhatDamon@users.noreply.github.com>
Date: Wed, 18 Feb 2026 15:54:45 +0800
Subject: [PATCH 4/4] Add i18n Check Workflow
---
.github/workflows/check-code.yml | 17 ++++
tests/check_i18n.py | 152 +++++++++++++++++++++++++++++++
2 files changed, 169 insertions(+)
create mode 100644 .github/workflows/check-code.yml
create mode 100755 tests/check_i18n.py
diff --git a/.github/workflows/check-code.yml b/.github/workflows/check-code.yml
new file mode 100644
index 0000000..1841e2b
--- /dev/null
+++ b/.github/workflows/check-code.yml
@@ -0,0 +1,17 @@
+name: Check Code
+
+on:
+ pull_request:
+
+jobs:
+ check-i18n:
+ name: Check Translation Files
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.x"
+
+ - run: python3 tests/check_i18n.py
diff --git a/tests/check_i18n.py b/tests/check_i18n.py
new file mode 100755
index 0000000..78c336d
--- /dev/null
+++ b/tests/check_i18n.py
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+import json
+import re
+import sys
+import unicodedata
+
+I18N_FILE = "data/i18n.json"
+
+FULLWIDTH_RANGES = [
+ (0xFF01, 0xFF60),
+ (0xFFE0, 0xFFE6),
+]
+
+FULLWIDTH_WHITELIST = set([
+ "\u3002",
+ "\u300C",
+ "\u300D",
+ "\u300A",
+ "\u300B",
+ "\u3001",
+ "\u00B7",
+])
+
+PLACEHOLDER_PATTERN = re.compile(r"\{[^}]+\}")
+
+
+def is_fullwidth_char(ch):
+ if ch in FULLWIDTH_WHITELIST:
+ return False
+ cp = ord(ch)
+ for start, end in FULLWIDTH_RANGES:
+ if start <= cp <= end:
+ return True
+ return False
+
+
+def check_fullwidth(locales):
+ warnings = []
+ for lang, entries in locales.items():
+ for key, value in entries.items():
+ for i, ch in enumerate(value):
+ if is_fullwidth_char(ch):
+ warnings.append(
+ f"[{lang}.{key}] pos {i}: '{ch}' "
+ f"(U+{ord(ch):04X} {unicodedata.name(ch, 'UNKNOWN')})"
+ f"\n context: ...{value[max(0, i - 10):i + 11]}..."
+ )
+ return warnings
+
+
+def check_missing_keys(locales, base_lang="en"):
+ warnings = []
+ base_keys = set(locales.get(base_lang, {}).keys())
+ for lang, entries in locales.items():
+ if lang == base_lang:
+ continue
+ current_keys = set(entries.keys())
+ for key in sorted(base_keys - current_keys):
+ warnings.append(f"[{lang}] missing key: '{key}'")
+ for key in sorted(current_keys - base_keys):
+ warnings.append(f"[{lang}] extra key: '{key}' (not in {base_lang})")
+ return warnings
+
+
+def check_empty_values(locales):
+ warnings = []
+ for lang, entries in locales.items():
+ for key, value in entries.items():
+ if not value or not value.strip():
+ warnings.append(f"[{lang}.{key}] empty or whitespace-only value")
+ return warnings
+
+
+def check_placeholders(locales, base_lang="en"):
+ warnings = []
+ base = locales.get(base_lang, {})
+ for lang, entries in locales.items():
+ if lang == base_lang:
+ continue
+ for key, value in entries.items():
+ if key not in base:
+ continue
+ base_ph = set(PLACEHOLDER_PATTERN.findall(base[key]))
+ curr_ph = set(PLACEHOLDER_PATTERN.findall(value))
+ for ph in sorted(base_ph - curr_ph):
+ warnings.append(f"[{lang}.{key}] missing placeholder: {ph}")
+ for ph in sorted(curr_ph - base_ph):
+ warnings.append(f"[{lang}.{key}] extra placeholder: {ph}")
+ return warnings
+
+
+def check_whitespace(locales):
+ warnings = []
+ for lang, entries in locales.items():
+ for key, value in entries.items():
+ if value != value.strip():
+ stripped = value.strip()
+ leading = value[:len(value) - len(value.lstrip())]
+ trailing = value[len(value.rstrip()):]
+ detail = []
+ if leading:
+ detail.append(f"leading {repr(leading)}")
+ if trailing:
+ detail.append(f"trailing {repr(trailing)}")
+ warnings.append(f"[{lang}.{key}] {', '.join(detail)}")
+ return warnings
+
+
+
+def main():
+ try:
+ with open(I18N_FILE, "r", encoding="utf-8") as f:
+ locales = json.load(f)
+ except FileNotFoundError:
+ print(f"ERROR: {I18N_FILE} not found.")
+ sys.exit(2)
+ except json.JSONDecodeError as e:
+ print(f"ERROR: Failed to parse {I18N_FILE}: {e}")
+ sys.exit(2)
+
+ checks = [
+ ("Fullwidth characters", check_fullwidth(locales)),
+ ("Missing/extra keys", check_missing_keys(locales)),
+ ("Empty values", check_empty_values(locales)),
+ ("Placeholder consistency", check_placeholders(locales)),
+ ("Leading/trailing whitespace", check_whitespace(locales)),
+ ]
+
+ GREEN = "\033[32m"
+ YELLOW = "\033[33m"
+ RESET = "\033[0m"
+
+ has_warnings = False
+ for name, warnings in checks:
+ if warnings:
+ has_warnings = True
+ print(f"{YELLOW}[WARN]{RESET} {name} ({len(warnings)}):\n")
+ for w in warnings:
+ print(f" {w}")
+ print()
+ else:
+ print(f"{GREEN}[PASS]{RESET} {name}")
+
+ if has_warnings:
+ sys.exit(1)
+ else:
+ print(f"\nAll checks passed for {I18N_FILE}.")
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()