From 9af1bc35dbc5d1c78e976501e1eda6185216c61b Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Wed, 6 Aug 2025 15:42:28 +0900 Subject: [PATCH] Add basic ICU message format support for translations (#1645) ## Description: This pull request adds support for ICU (Intl MessageFormat) syntax in the translation system. Existing translation files may need to be updated to fully leverage ICU features. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I have read and accepted the CLA agreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: DISCORD_USERNAME --- package-lock.json | 65 +++++++++++++++++++++++++++++++++++++- package.json | 1 + src/client/LangSelector.ts | 4 +-- src/client/Utils.ts | 42 ++++++++++++++++++++++-- 4 files changed, 106 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1c214df16..c0829ccb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "express": "^4.21.1", "express-rate-limit": "^7.5.0", "fastpriorityqueue": "^0.7.5", + "intl-messageformat": "^10.7.16", "ip-anonymize": "^0.1.0", "jose": "^6.0.10", "js-yaml": "^4.1.0", @@ -3625,6 +3626,57 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz", + "integrity": "sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/intl-localematcher": "0.6.1", + "decimal.js": "^10.4.3", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", + "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.2.tgz", + "integrity": "sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.4", + "@formatjs/icu-skeleton-parser": "1.8.14", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.8.14", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.14.tgz", + "integrity": "sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.4", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.1.tgz", + "integrity": "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -10362,7 +10414,6 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", - "dev": true, "license": "MIT" }, "node_modules/decompress-response": { @@ -12772,6 +12823,18 @@ "node": ">=10.13.0" } }, + "node_modules/intl-messageformat": { + "version": "10.7.16", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.16.tgz", + "integrity": "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.4", + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/icu-messageformat-parser": "2.11.2", + "tslib": "^2.8.0" + } + }, "node_modules/ip-anonymize": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/ip-anonymize/-/ip-anonymize-0.1.0.tgz", diff --git a/package.json b/package.json index f39f255aa..c9ee4acde 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "express": "^4.21.1", "express-rate-limit": "^7.5.0", "fastpriorityqueue": "^0.7.5", + "intl-messageformat": "^10.7.16", "ip-anonymize": "^0.1.0", "jose": "^6.0.10", "js-yaml": "^4.1.0", diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index 1f1ea4c45..61a51e3c5 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -35,8 +35,8 @@ import zh_CN from "../../resources/lang/zh-CN.json"; @customElement("lang-selector") export class LangSelector extends LitElement { @state() public translations: Record | undefined; - @state() private defaultTranslations: Record | undefined; - @state() private currentLang: string = "en"; + @state() public defaultTranslations: Record | undefined; + @state() public currentLang: string = "en"; @state() private languageList: any[] = []; @state() private showModal: boolean = false; @state() private debugMode: boolean = false; diff --git a/src/client/Utils.ts b/src/client/Utils.ts index 51dff4258..494109fbd 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -1,3 +1,4 @@ +import IntlMessageFormat from "intl-messageformat"; import { MessageType } from "../core/game/Game"; import { LangSelector } from "./LangSelector"; @@ -78,18 +79,20 @@ export function generateCryptoRandomUUID(): string { ); } -// Re-export translateText from LangSelector export const translateText = ( key: string, params: Record = {}, ): string => { + const self = translateText as any; + self.formatterCache ??= new Map(); + self.lastLang ??= null; + const langSelector = document.querySelector("lang-selector") as LangSelector; if (!langSelector) { console.warn("LangSelector not found in DOM"); return key; } - // Wait for translations to be loaded if ( !langSelector.translations || Object.keys(langSelector.translations).length === 0 @@ -97,7 +100,40 @@ export const translateText = ( return key; } - return langSelector.translateText(key, params); + if (self.lastLang !== langSelector.currentLang) { + self.formatterCache.clear(); + self.lastLang = langSelector.currentLang; + } + + let message = langSelector.translations[key]; + + if (!message && langSelector.defaultTranslations) { + const defaultTranslations = langSelector.defaultTranslations; + if (defaultTranslations && defaultTranslations[key]) { + message = defaultTranslations[key]; + } + } + + if (!message) return key; + + try { + const locale = + !langSelector.translations[key] && langSelector.currentLang !== "en" + ? "en" + : langSelector.currentLang; + const cacheKey = `${key}:${locale}:${message}`; + let formatter = self.formatterCache.get(cacheKey); + + if (!formatter) { + formatter = new IntlMessageFormat(message, locale); + self.formatterCache.set(cacheKey, formatter); + } + + return formatter.format(params) as string; + } catch (e) { + console.warn("ICU format error", e); + return message; + } }; /**