feat(i18n): add Traditional Chinese (zh-TW) locale
Adds full Traditional Chinese translation (628 keys, 100% parity with
en.json) and the registration plumbing to make it pickable in the UI.
Registration spans three layers that all needed updating for the locale
to actually be selectable end-to-end:
- static/locales/zh-TW.json (new) — TW vocabulary (儲存/雲端/檔案/偵測),
uses 「」 corner brackets for in-string quoting
- core/i18n.js: add 'zh-TW' to supportedLocales — without this,
setLocale('zh-TW') was silently rejected by the whitelist and the
previous locale stayed active (visible as the "picked 繁中 but the
setup step still shows 簡中" bug)
- core/languageSelector.js: add 'zh-TW' to AVAILABLE_LOCALES + fallback
- features/auth/auth.js: add 'zh-TW' to ALL_LANGUAGES (🇹🇼 繁體中文)
and LANGUAGE_TEXTS bootstrap table (used before i18n loads)
Browser detection rewrite (i18n.js + auth.js detectBrowserLanguage):
The previous navigator.language?.substring(0, 2) truncated zh-TW → zh
and routed Traditional Chinese browsers to Simplified. Replaced with
three-tier matching: exact full-tag > Chinese script/region heuristic
(zh-Hant*, zh-{TW,HK,MO}) > primary subtag fallback.
Disambiguates the existing zh entry: "Chinese / 中文" became
"Simplified Chinese / 简体中文".
Drive-by cleanups discovered while wiring up the above:
- Remove dead t() in i18n.js (export uses safeT, no callers of bare t)
- Remove dead fetchUserData() and logout() in auth.js (userMenu.js has
its own local logout())
- Extract errMessage(unknown→string) and inputVal(id) helpers for the
catch sites and getElementById('x').value sites that needed TS
narrowing under checkJs
- Type-annotate module-scope let forms/errors/panels with
HTMLFormElement and HTMLElement so .addEventListener and .reset()
resolve under strict
- Drop navigator.userLanguage IE legacy fallback (DOM lib has no field)
- jsconfig.json: drop exactOptionalPropertyTypes (only valid with
strictNullChecks, which the project deliberately disables)
- .gitignore: ignore docker-compose.override.yml for local bind-mount
dev workflow
Verified clean before commit: biome ci, tsc --noEmit, i18n key parity
(628/628), HTTP smoke test against running container.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+33
-86
@@ -5,18 +5,37 @@
|
||||
* It loads translations from the server and provides functions to translate keys.
|
||||
*/
|
||||
|
||||
// Current locale code (default to browser locale if available, fallback to English)
|
||||
let currentLocale = navigator.language?.substring(0, 2) || navigator.userLanguage?.substring(0, 2) || 'en';
|
||||
|
||||
// Supported locales (languages that have locale files on the server)
|
||||
// When a locale file is not found, the system gracefully falls back to English
|
||||
const supportedLocales = ['en', 'es', 'zh', 'fa', 'fr', 'de', 'pt', 'nl', 'it', 'hi', 'ar', 'ru', 'ja', 'ko', 'pl'];
|
||||
// Keep in sync with AVAILABLE_LOCALES in core/languageSelector.js
|
||||
const supportedLocales = ['en', 'es', 'zh', 'zh-TW', 'fa', 'fr', 'de', 'pt', 'nl', 'it', 'hi', 'ar', 'ru', 'ja', 'ko', 'pl'];
|
||||
|
||||
// Fallback to English if locale is not supported
|
||||
if (!supportedLocales.includes(currentLocale)) {
|
||||
currentLocale = 'en';
|
||||
// Resolve the best supported locale from a browser language list.
|
||||
// Priority: exact full-tag (zh-TW) > Chinese script/region heuristics > primary subtag (zh)
|
||||
function resolveBrowserLocale() {
|
||||
const browserLangs = navigator.languages || [navigator.language || 'en'];
|
||||
const lowerSupported = supportedLocales.map((l) => l.toLowerCase());
|
||||
|
||||
for (const bl of browserLangs) {
|
||||
const idx = lowerSupported.indexOf(bl.toLowerCase());
|
||||
if (idx !== -1) return supportedLocales[idx];
|
||||
}
|
||||
for (const bl of browserLangs) {
|
||||
const tag = bl.toLowerCase();
|
||||
if (!tag.startsWith('zh')) continue;
|
||||
const isTraditional = tag.includes('hant') || /\b(tw|hk|mo)\b/.test(tag);
|
||||
const target = isTraditional ? 'zh-TW' : 'zh';
|
||||
if (supportedLocales.includes(target)) return target;
|
||||
}
|
||||
for (const bl of browserLangs) {
|
||||
const primary = bl.substring(0, 2).toLowerCase();
|
||||
if (supportedLocales.includes(primary)) return primary;
|
||||
}
|
||||
return 'en';
|
||||
}
|
||||
|
||||
// Current locale code (overridden by saved preference in initI18n)
|
||||
let currentLocale = resolveBrowserLocale();
|
||||
|
||||
// Cache for translations
|
||||
/** @type {Record<string, Object>} */
|
||||
const translations = {};
|
||||
@@ -88,82 +107,6 @@ function getNestedValue(obj, path) {
|
||||
return typeof current === 'string' ? current : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a key to the current locale
|
||||
* @param {string} key - The translation key (dot notation, e.g., 'app.title')
|
||||
* @param {object} params - Parameters to replace in the translation (e.g., {name: 'John'})
|
||||
* @returns {string} - The translated string or the key itself if not found
|
||||
*/
|
||||
|
||||
// biome-ignore lint/correctness/noUnusedVariables: global function
|
||||
function t(key, params = {}) {
|
||||
// Get translation from cache
|
||||
const localeData = translations[currentLocale];
|
||||
if (!localeData) {
|
||||
// Translation not loaded yet, return key
|
||||
console.warn(`Translations for ${currentLocale} not loaded yet`);
|
||||
return key;
|
||||
}
|
||||
|
||||
// Special handling for shared_ and share_ prefixed keys
|
||||
if (key.startsWith('shared_') || key.startsWith('share_')) {
|
||||
const unprefixedKey = key.replace(/^(shared|share)_/, '');
|
||||
const prefixObj = key.startsWith('shared_') ? localeData.shared : localeData.share;
|
||||
|
||||
if (prefixObj && typeof prefixObj === 'object' && unprefixedKey in prefixObj) {
|
||||
return interpolate(prefixObj[unprefixedKey], params);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the translation value
|
||||
let value = getNestedValue(localeData, key);
|
||||
|
||||
// Compatibility aliases for legacy share.* keys used in some views
|
||||
if (!value) {
|
||||
const aliasMap = {
|
||||
'share.enablePassword': 'share.password',
|
||||
'share.enableExpiration': 'share.expiration',
|
||||
'share.notifyEmail': 'share.notifyEmailLabel',
|
||||
'share.notifyMessage': 'share.notifyMessageLabel'
|
||||
};
|
||||
const aliasKey = aliasMap[key];
|
||||
if (aliasKey) {
|
||||
value = getNestedValue(localeData, aliasKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
// Try fallback to English
|
||||
if (currentLocale !== 'en' && translations.en) {
|
||||
let fallbackValue = getNestedValue(translations.en, key);
|
||||
|
||||
if (!fallbackValue) {
|
||||
const aliasMap = {
|
||||
'share.enablePassword': 'share.password',
|
||||
'share.enableExpiration': 'share.expiration',
|
||||
'share.notifyEmail': 'share.notifyEmailLabel',
|
||||
'share.notifyMessage': 'share.notifyMessageLabel'
|
||||
};
|
||||
const aliasKey = aliasMap[key];
|
||||
if (aliasKey) {
|
||||
fallbackValue = getNestedValue(translations.en, aliasKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackValue) {
|
||||
return interpolate(fallbackValue, params);
|
||||
}
|
||||
}
|
||||
|
||||
// Key not found, return key
|
||||
console.warn(`Translation key not found: ${key}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
// Replace parameters
|
||||
return interpolate(value, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace parameters in a translation string
|
||||
* @param {string} text - The translation string with placeholders
|
||||
@@ -259,12 +202,16 @@ function translateElement(root) {
|
||||
|
||||
el.querySelectorAll('[data-i18n-placeholder]').forEach((element) => {
|
||||
const key = element.getAttribute('data-i18n-placeholder');
|
||||
element.placeholder = resolve(key);
|
||||
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
||||
element.placeholder = resolve(key);
|
||||
}
|
||||
});
|
||||
|
||||
el.querySelectorAll('[data-i18n-title]').forEach((element) => {
|
||||
const key = element.getAttribute('data-i18n-title');
|
||||
element.title = resolve(key);
|
||||
if (element instanceof HTMLElement) {
|
||||
element.title = resolve(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user