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:
tw199501
2026-05-16 04:59:53 +08:00
parent 8e6dc32baf
commit a142d52d05
6 changed files with 817 additions and 160 deletions
+33 -86
View File
@@ -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);
}
});
}