Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/test_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ jobs:
run: npm install -g node-gyp@latest
- name: bun install
run: bun install
- name: Test internationalization
run: bun test tests/i18n.test.js
- name: Compile wow.export ${{ matrix.platform }}
run: bun build.js ${{ matrix.platform }}
- name: Ad-hoc codesign (macOS)
Expand Down
2 changes: 1 addition & 1 deletion src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -4274,4 +4274,4 @@ input[type=number]::-webkit-outer-spin-button {
.mv-go-to-model:hover {
background: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.9);
}
}
12 changes: 11 additions & 1 deletion src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const ExternalLinks = require('./js/external-links');
const textureRibbon = require('./js/ui/texture-ribbon');
const Shaders = require('./js/3D/Shaders');
const gpuInfo = require('./js/gpu-info');
const i18n = require('./js/i18n');

const Vue = require('vue/dist/vue.cjs.js');
window.Vue = Vue;
Expand Down Expand Up @@ -315,6 +316,11 @@ document.addEventListener('click', function(e) {
modules.setActive(module_name);
},

setInterfaceLanguage: function(locale) {
this.config.uiLocale = locale;
i18n.setPreference(locale);
},

handleContextMenuClick: function(opt) {
if (opt.action?.handler)
opt.action.handler();
Expand Down Expand Up @@ -512,6 +518,7 @@ document.addEventListener('click', function(e) {

// Interlink error handling for Vue.
app.config.errorHandler = err => crash('ERR_VUE', err.message);
i18n.init(app);

modules.register_components(app);
app.mount('#container');
Expand Down Expand Up @@ -575,6 +582,9 @@ document.addEventListener('click', function(e) {

// Load configuration.
await config.load();
if (core.view.config.uiLocale !== 'en-US' && core.view.config.uiLocale !== 'zh-CN')
core.view.config.uiLocale = 'en-US';
i18n.setPreference(core.view.config.uiLocale);

// Set-up default export directory if none configured.
if (core.view.config.exportDirectory === '') {
Expand Down Expand Up @@ -717,4 +727,4 @@ document.addEventListener('click', function(e) {

// Set source select as the currently active interface screen.
modules.source_select.setActive();
})();
})();
1 change: 1 addition & 0 deletions src/default_config.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"updateURL": "https://www.kruithne.net/wow.export/update/%s/",
"cacheExpiry": 7,
"cascLocale": 2,
"uiLocale": "en-US",
"recentLocal": [],
"sourceSelectUserRegion": null,
"cdnFallbackHosts": "cdn.blizzard.com, archive.wow.tools",
Expand Down
2 changes: 1 addition & 1 deletion src/js/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -584,4 +584,4 @@ const core = {
getScrollPosition
};

module.exports = core;
module.exports = core;
344 changes: 344 additions & 0 deletions src/js/i18n/en-US.json

Large diffs are not rendered by default.

143 changes: 143 additions & 0 deletions src/js/i18n/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
const english = require('./en-US.json');
const simplifiedChinese = require('./zh-CN.json');
const Vue = require('vue/dist/vue.cjs.js');

const LOCALES = {
'en-US': english,
'zh-CN': simplifiedChinese
};

const originalText = new WeakMap();
const originalAttributes = new WeakMap();

let preference = 'en-US';
const state = Vue.reactive({ locale: 'en-US' });
let observer = null;
let applying = false;
let missingKeys = new Set();

const flatten = (object, prefix = '') => {
const result = {};
for (const [key, value] of Object.entries(object)) {
const fullKey = prefix ? prefix + '.' + key : key;
if (value && typeof value === 'object')
Object.assign(result, flatten(value, fullKey));
else
result[fullKey] = value;
}
return result;
};

const dictionaries = Object.fromEntries(Object.entries(LOCALES).map(([key, value]) => [key, flatten(value)]));

const resolveLocale = value => {
if (value === 'zh-CN' || value === 'en-US')
return value;
return 'en-US';
};

const interpolate = (value, params = {}) => value.replace(/\{([^}]+)\}/g, (_, name) => {
return Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : '{' + name + '}';
});

const translateKey = (key, params) => {
const active = dictionaries[state.locale] || dictionaries['en-US'];
const value = active[key] ?? dictionaries['en-US'][key];
if (value === undefined) {
if (!missingKeys.has(key) && typeof BUILD_RELEASE !== 'undefined' && !BUILD_RELEASE)
console.warn('[i18n] Missing translation key:', key);
missingKeys.add(key);
return key;
}
return interpolate(value, params);
};

const translateText = text => {
const leading = text.match(/^\s*/)?.[0] || '';
const trailing = text.match(/\s*$/)?.[0] || '';
const trimmed = text.trim();
if (!trimmed)
return text;

const key = Object.entries(dictionaries['en-US']).find(([, value]) => value === trimmed)?.[0];
return key ? leading + translateKey(key) + trailing : text;
};

const translateNode = node => {
if (node.nodeType === Node.TEXT_NODE) {
const current = node.nodeValue;
const previous = originalText.get(node);
if (previous === undefined || (current !== previous && current !== translateText(previous)))
originalText.set(node, current);
const source = originalText.get(node);
const result = translateText(source);
if (node.nodeValue !== result)
node.nodeValue = result;
return;
}

if (node.nodeType !== Node.ELEMENT_NODE)
return;

for (const attribute of ['title', 'placeholder', 'aria-label', 'value']) {
if (!node.hasAttribute(attribute))
continue;
let values = originalAttributes.get(node);
if (!values) {
values = {};
originalAttributes.set(node, values);
}
if (!(attribute in values))
values[attribute] = node.getAttribute(attribute);
else if (node.getAttribute(attribute) !== values[attribute] && node.getAttribute(attribute) !== translateText(values[attribute]))
values[attribute] = node.getAttribute(attribute);
const translated = translateText(values[attribute]);
if (node.getAttribute(attribute) !== translated)
node.setAttribute(attribute, translated);
}

for (const child of node.childNodes)
translateNode(child);
};

const apply = () => {
if (typeof document === 'undefined' || applying)
return;
applying = true;
try {
translateNode(document.body);
} finally {
applying = false;
}
};

const setLocale = value => {
const nextLocale = resolveLocale(value);
preference = value === 'zh-CN' || value === 'en-US' ? value : 'en-US';
if (state.locale !== nextLocale) {
state.locale = nextLocale;
apply();
}
};

const init = app => {
if (app) {
app.config.globalProperties.$t = translateKey;
app.config.globalProperties.$tc = (key, count, params = {}) => translateKey(key, { ...params, count });
}
if (typeof MutationObserver !== 'undefined' && document.body) {
observer = new MutationObserver(() => apply());
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true });
}
apply();
};

const setPreference = value => {
preference = value || 'en-US';
setLocale(preference);
};

const getPreference = () => preference;
const getLocale = () => state.locale;

module.exports = { init, setLocale, setPreference, getPreference, getLocale, t: translateKey, tc: (key, count, params) => translateKey(key, { ...params, count }) };
Loading