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/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..b8c5fcd 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,10 +46,10 @@ "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(反转)", + "modeXA": "en-XA (重音)", + "modeXB": "en-XB (反转)", "inputPlaceholder": "输入要处理的文本", "outputPlaceholder": "处理结果", "process": "进行伪本地化!", @@ -61,38 +60,84 @@ "library": "字符库", "about": "关于", "exeVersion": "EXE 版本", - "langZh": "中文", - "langEn": "English", - "options": "选项", - "suffix": "后缀:", + "suffix": "后缀:", "suffixNone": "无", "suffixMS": "微软式 [!]", "suffixAndroid": "安卓式 (one two three)", "suffixNum": "数字式 (12345)", "suffixCustom": "自定义", - "customPrefix": "前缀:", - "customSuffix": "后缀:", - "customRepeat": "重复:", - "customRepeatCount": "每隔N字符重复:", + "customPrefix": "前缀:", + "customSuffix": "后缀:", + "customRepeat": "重复:", + "customRepeatCount": "每隔 N 字符重复:", "upper": "大写", "lower": "小写", "doubleVowel": "元音重复", - "doubleVowelCount": "元音重复次数:", + "doubleVowelCount": "元音重复次数:", "circleNumber": "处理数字", "addHash": "添加 Hash ID", - "hashLength": "ID 长度:", + "hashLength": "ID 长度:", "preserveEsc": "保留转义字符", "historyTitle": "历史记录", "historyClear": "清空", "historyEmpty": "暂无处理历史记录。", "historyCleared": "历史记录已清空。", - "historyInput": "输入:", - "historyOutput": "输出:", + "langModalTitle": "选择语言", + "langBtn": "语言", + "historyInput": "输入:", + "historyOutput": "输出:", "libraryCharacter": "字符", "libraryVariants": "变体", "libraryCount": "数量", "libraryTotal": "共 {count} 个字符", - "aboutTitle": "什么是伪本地化?", - "aboutContent": "伪本地化 (pseudo-localization) 是一种模拟本地化过程的测试方法,可以在实际翻译前发现问题。

在伪本地化过程中,英文字母会被替换为其他语言的重音字符,还会添加分隔符以增加字符串长度。

示例:\"Hello\" → \"[1iaT9][ Ĥêļļø !!! ]\"

更多信息:Microsoft 文档" + "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 => ` + + `).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() { 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()