feat: add system language detection and prompt on first launch

This commit is contained in:
pa
2026-03-17 20:31:56 +09:00
parent 6720f1a294
commit 120a4c3533
19 changed files with 323 additions and 34 deletions

View File

@@ -0,0 +1,116 @@
import { describe, expect, test } from 'vitest';
import { resolveSystemLanguage } from '../index';
import { languageCodes } from '../locales';
describe('resolveSystemLanguage', () => {
describe('returns null for invalid input', () => {
test('empty string', () => {
expect(resolveSystemLanguage('', languageCodes)).toBeNull();
});
test('null', () => {
expect(resolveSystemLanguage(null, languageCodes)).toBeNull();
});
test('undefined', () => {
expect(resolveSystemLanguage(undefined, languageCodes)).toBeNull();
});
test('unsupported language', () => {
expect(resolveSystemLanguage('de-DE', languageCodes)).toBeNull();
});
});
describe('exact match', () => {
test('zh-CN matches zh-CN', () => {
expect(resolveSystemLanguage('zh-CN', languageCodes)).toBe('zh-CN');
});
test('zh-TW matches zh-TW', () => {
expect(resolveSystemLanguage('zh-TW', languageCodes)).toBe('zh-TW');
});
test('en matches en', () => {
expect(resolveSystemLanguage('en', languageCodes)).toBe('en');
});
});
describe('prefix match', () => {
test('ja-JP matches ja', () => {
expect(resolveSystemLanguage('ja-JP', languageCodes)).toBe('ja');
});
test('ko-KR matches ko', () => {
expect(resolveSystemLanguage('ko-KR', languageCodes)).toBe('ko');
});
test('fr-FR matches fr', () => {
expect(resolveSystemLanguage('fr-FR', languageCodes)).toBe('fr');
});
test('en-US matches en', () => {
expect(resolveSystemLanguage('en-US', languageCodes)).toBe('en');
});
test('es-MX matches es', () => {
expect(resolveSystemLanguage('es-MX', languageCodes)).toBe('es');
});
test('pt-BR matches pt', () => {
expect(resolveSystemLanguage('pt-BR', languageCodes)).toBe('pt');
});
test('ru-RU matches ru', () => {
expect(resolveSystemLanguage('ru-RU', languageCodes)).toBe('ru');
});
});
describe('Chinese region-aware mapping', () => {
test('zh-HK maps to zh-TW (traditional)', () => {
expect(resolveSystemLanguage('zh-HK', languageCodes)).toBe('zh-TW');
});
test('zh-MO maps to zh-TW (traditional)', () => {
expect(resolveSystemLanguage('zh-MO', languageCodes)).toBe('zh-TW');
});
test('zh-SG maps to zh-CN (simplified)', () => {
expect(resolveSystemLanguage('zh-SG', languageCodes)).toBe('zh-CN');
});
test('bare zh maps to zh-CN', () => {
expect(resolveSystemLanguage('zh', languageCodes)).toBe('zh-CN');
});
test('zh-Hant maps to zh-TW (traditional script tag)', () => {
expect(resolveSystemLanguage('zh-Hant', languageCodes)).toBe(
'zh-TW'
);
});
test('zh-Hans maps to zh-CN (simplified script tag)', () => {
expect(resolveSystemLanguage('zh-Hans', languageCodes)).toBe(
'zh-CN'
);
});
test('zh-Hant-HK maps to zh-TW (script + region)', () => {
expect(resolveSystemLanguage('zh-Hant-HK', languageCodes)).toBe(
'zh-TW'
);
});
test('zh-Hans-CN maps to zh-CN (script + region)', () => {
expect(resolveSystemLanguage('zh-Hans-CN', languageCodes)).toBe(
'zh-CN'
);
});
test('zh-Hant-MO maps to zh-TW (script + traditional region)', () => {
expect(resolveSystemLanguage('zh-Hant-MO', languageCodes)).toBe(
'zh-TW'
);
});
});
});

View File

@@ -43,6 +43,10 @@
"devEndpoint": "Dev Endpoint",
"endpoint": "Endpoint",
"websocket": "WebSocket"
},
"language_detect": {
"title": "Detekován jazyk",
"description": "Váš systémový jazyk je {language}. Chcete na něj přepnout?"
}
},
"feed": {

View File

@@ -215,6 +215,10 @@
"devEndpoint": "Dev Endpoint",
"endpoint": "Endpoint",
"websocket": "WebSocket"
},
"language_detect": {
"title": "Language Detected",
"description": "Your system language appears to be {language}. Would you like to switch to it?"
}
},
"feed": {
@@ -2680,7 +2684,7 @@
"table": {
"header_menu": {
"reset_all": "Reset All",
"lock_column_order": "Lock Column Order"
"lock_column_order": "Lock Column"
},
"pagination": {
"rows_per_page": "Rows per page",

View File

@@ -45,6 +45,10 @@
"devEndpoint": "Punto Final de Desarrollo",
"endpoint": "Punto Final",
"websocket": "WebSocket"
},
"language_detect": {
"title": "Idioma detectado",
"description": "El idioma de su sistema parece ser {language}. ¿Le gustaría cambiar a él?"
}
},
"feed": {

View File

@@ -43,6 +43,10 @@
"devEndpoint": "Dev Endpoint",
"endpoint": "Endpoint",
"websocket": "WebSocket"
},
"language_detect": {
"title": "Langue détectée",
"description": "La langue de votre système semble être {language}. Souhaitez-vous y passer ?"
}
},
"feed": {

View File

@@ -43,6 +43,10 @@
"devEndpoint": "Fejlesztői végpont",
"endpoint": "Végpont",
"websocket": "WebSocket"
},
"language_detect": {
"title": "Nyelv észlelve",
"description": "A rendszernyelv úgy tűnik, hogy {language}. Szeretne átváltani rá?"
}
},
"feed": {

View File

@@ -4,29 +4,25 @@ const localizedStringsUrls = import.meta.glob('./*.json', {
import: 'default'
});
async function fetchJson(url) {
const response = await fetch(url);
if (!response.ok) {
console.warn(`Failed to fetch localization: ${response.status}`);
}
return response.json();
}
async function getLocalizedStrings(code) {
const fallbackUrl = localizedStringsUrls['./en.json'];
const localizedStringsUrl =
localizedStringsUrls[`./${code}.json`] || fallbackUrl;
const url = localizedStringsUrls[`./${code}.json`] || fallbackUrl;
let localizedStrings = {};
try {
localizedStrings = await fetchJson(localizedStringsUrl);
const res = await fetch(url);
if (!res.ok) throw new Error(res.status);
return await res.json();
} catch {
if (localizedStringsUrl !== fallbackUrl) {
localizedStrings = await fetchJson(fallbackUrl).catch(() => ({}));
if (url !== fallbackUrl) {
try {
const res = await fetch(fallbackUrl);
return await res.json();
} catch {
return {};
}
}
return {};
}
return localizedStrings;
}
const languageNames = import.meta.glob('./*.json', {
@@ -38,5 +34,45 @@ function getLanguageName(code) {
return String(languageNames[`./${code}.json`] ?? code);
}
/**
* @param {string} systemLanguage - BCP-47 code from AppApi.CurrentLanguage()
* @param {string[]} codes - supported language codes
* @returns {string | null} matched language code, or null
*/
function resolveSystemLanguage(systemLanguage, codes) {
if (!systemLanguage) return null;
// Exact match (e.g. zh-CN → zh-CN)
if (codes.includes(systemLanguage)) {
return systemLanguage;
}
const lang = systemLanguage.split('-')[0];
// Chinese: script-tag and region-aware mapping
// BCP-47 forms: zh-CN, zh-TW, zh-Hant, zh-Hans, zh-Hant-HK, zh-Hans-CN, etc.
if (lang === 'zh') {
const parts = systemLanguage.split('-').slice(1); // everything after 'zh'
const hasHant = parts.includes('Hant');
const hasHans = parts.includes('Hans');
const traditionalRegions = ['TW', 'HK', 'MO'];
const hasTraditionalRegion = parts.some((p) =>
traditionalRegions.includes(p)
);
if (hasHant || hasTraditionalRegion) {
return codes.includes('zh-TW') ? 'zh-TW' : null;
}
if (hasHans) {
return codes.includes('zh-CN') ? 'zh-CN' : null;
}
// Bare 'zh' or unknown region (e.g. zh-SG) → simplified
return codes.includes('zh-CN') ? 'zh-CN' : null;
}
// Generic prefix match (e.g. ja-JP → ja)
return codes.find((code) => code.split('-')[0] === lang) ?? null;
}
export * from './locales';
export { getLanguageName, getLocalizedStrings };
export { getLanguageName, getLocalizedStrings, resolveSystemLanguage };

View File

@@ -99,6 +99,10 @@
"devEndpoint": "開発者用エンドポイント",
"endpoint": "エンドポイント",
"websocket": "ウェブソケット"
},
"language_detect": {
"title": "言語を検出しました",
"description": "システム言語は {language} のようです。切り替えますか?"
}
},
"feed": {

View File

@@ -43,6 +43,10 @@
"devEndpoint": "개발자용 API",
"endpoint": "Endpoint",
"websocket": "WebSocket"
},
"language_detect": {
"title": "언어 감지됨",
"description": "시스템 언어가 {language}인 것 같습니다. 전환하시겠습니까?"
}
},
"feed": {

View File

@@ -79,6 +79,10 @@
"devEndpoint": "Tryb deweloperski",
"endpoint": "Endpoint",
"websocket": "WebSocket"
},
"language_detect": {
"title": "Wykryto język",
"description": "Język systemu to {language}. Czy chcesz na niego przełączyć?"
}
},
"feed": {

View File

@@ -43,6 +43,10 @@
"devEndpoint": "Dev Endpoint",
"endpoint": "Ponto final",
"websocket": "WebSocket"
},
"language_detect": {
"title": "Idioma detectado",
"description": "O idioma do seu sistema parece ser {language}. Gostaria de mudar para ele?"
}
},
"feed": {

View File

@@ -46,6 +46,10 @@
"devEndpoint": "Разработчик",
"endpoint": "Endpoint",
"websocket": "WebSocket"
},
"language_detect": {
"title": "Обнаружен язык",
"description": "Язык вашей системы — {language}. Хотите переключиться на него?"
}
},
"feed": {

View File

@@ -45,6 +45,10 @@
"devEndpoint": "Dev Endpoint",
"endpoint": "Endpoint",
"websocket": "WebSocket"
},
"language_detect": {
"title": "ตรวจพบภาษา",
"description": "ภาษาของระบบของคุณดูเหมือนจะเป็น {language} คุณต้องการเปลี่ยนไปใช้หรือไม่?"
}
},
"feed": {

View File

@@ -43,6 +43,10 @@
"devEndpoint": "Dev Endpoint",
"endpoint": "Endpoint",
"websocket": "WebSocket"
},
"language_detect": {
"title": "Đã phát hiện ngôn ngữ",
"description": "Ngôn ngữ hệ thống của bạn có vẻ là {language}. Bạn có muốn chuyển sang không?"
}
},
"feed": {

View File

@@ -99,6 +99,10 @@
"devEndpoint": "自定义 API 接口",
"endpoint": "接口地址",
"websocket": "WebSocket"
},
"language_detect": {
"title": "检测到语言",
"description": "您的系统语言似乎是 {language}。是否切换到该语言?"
}
},
"feed": {

View File

@@ -99,6 +99,10 @@
"devEndpoint": "開發介面",
"endpoint": "Endpoint",
"websocket": "WebSocket"
},
"language_detect": {
"title": "偵測到語言",
"description": "您的系統語言似乎是 {language}。是否切換到該語言?"
}
},
"feed": {