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
This commit is contained in:
Aotumuri
2025-08-06 15:42:28 +09:00
committed by evanpelle
parent 0e56211dd3
commit 9af1bc35db
4 changed files with 106 additions and 6 deletions
+64 -1
View File
@@ -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",
+1
View File
@@ -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",
+2 -2
View File
@@ -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<string, string> | undefined;
@state() private defaultTranslations: Record<string, string> | undefined;
@state() private currentLang: string = "en";
@state() public defaultTranslations: Record<string, string> | undefined;
@state() public currentLang: string = "en";
@state() private languageList: any[] = [];
@state() private showModal: boolean = false;
@state() private debugMode: boolean = false;
+39 -3
View File
@@ -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, string | number> = {},
): 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;
}
};
/**