feat: add modular i18n foundation
This commit is contained in:
76
src/i18n/i18n.test.ts
Normal file
76
src/i18n/i18n.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { isSupportedLocale, resolveInitialLocale, resolveLocaleFromNavigator } from "./locales.ts";
|
||||
import { messages } from "./messages.ts";
|
||||
import { readStoredLocale, writeStoredLocale } from "./storage.ts";
|
||||
import { defaultLocale, supportedLocales } from "./types.ts";
|
||||
|
||||
function flattenKeys(value: unknown, prefix = ""): string[] {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return [prefix];
|
||||
}
|
||||
|
||||
return Object.entries(value as Record<string, unknown>).flatMap(([key, nestedValue]) =>
|
||||
flattenKeys(nestedValue, prefix ? `${prefix}.${key}` : key),
|
||||
);
|
||||
}
|
||||
|
||||
describe("i18n locale model", () => {
|
||||
it("supports Chinese, English, and Thai", () => {
|
||||
assert.deepEqual([...supportedLocales], ["zh-CN", "en-US", "th-TH"]);
|
||||
assert.equal(defaultLocale, "zh-CN");
|
||||
});
|
||||
|
||||
it("accepts only supported locale codes", () => {
|
||||
assert.equal(isSupportedLocale("zh-CN"), true);
|
||||
assert.equal(isSupportedLocale("en-US"), true);
|
||||
assert.equal(isSupportedLocale("th-TH"), true);
|
||||
assert.equal(isSupportedLocale("en"), false);
|
||||
assert.equal(isSupportedLocale("fr-FR"), false);
|
||||
});
|
||||
|
||||
it("maps browser languages to supported locales", () => {
|
||||
assert.equal(resolveLocaleFromNavigator(["zh-Hans-CN"]), "zh-CN");
|
||||
assert.equal(resolveLocaleFromNavigator(["en-GB"]), "en-US");
|
||||
assert.equal(resolveLocaleFromNavigator(["th"]), "th-TH");
|
||||
assert.equal(resolveLocaleFromNavigator(["fr-FR"]), defaultLocale);
|
||||
});
|
||||
|
||||
it("prefers stored locale over browser language", () => {
|
||||
assert.equal(resolveInitialLocale({ storedLocale: "th-TH", navigatorLanguages: ["en-US"] }), "th-TH");
|
||||
});
|
||||
|
||||
it("falls back to browser language when stored locale is invalid", () => {
|
||||
assert.equal(resolveInitialLocale({ storedLocale: "invalid", navigatorLanguages: ["en-US"] }), "en-US");
|
||||
});
|
||||
|
||||
it("keeps translation key structure consistent across locales", () => {
|
||||
const referenceKeys = flattenKeys(messages["zh-CN"]).sort();
|
||||
|
||||
for (const locale of supportedLocales) {
|
||||
assert.deepEqual(flattenKeys(messages[locale]).sort(), referenceKeys, locale);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not throw when browser storage is unavailable", () => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
get() {
|
||||
throw new Error("storage blocked");
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
assert.equal(readStoredLocale(), null);
|
||||
assert.doesNotThrow(() => writeStoredLocale("zh-CN"));
|
||||
} finally {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, "localStorage", descriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, "localStorage");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
60
src/i18n/index.ts
Normal file
60
src/i18n/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { isSupportedLocale, resolveInitialLocale } from "./locales.ts";
|
||||
import { messages } from "./messages.ts";
|
||||
import { readStoredLocale, writeStoredLocale } from "./storage.ts";
|
||||
import { defaultLocale } from "./types.ts";
|
||||
import type { SupportedLocale } from "./types.ts";
|
||||
import { syncVantLocale } from "./vant.ts";
|
||||
|
||||
function getNavigatorLanguages(): string[] {
|
||||
if (typeof navigator === "undefined") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const languages = navigator.languages ? [...navigator.languages] : [];
|
||||
|
||||
if (languages.length > 0) {
|
||||
return languages;
|
||||
}
|
||||
|
||||
return navigator.language ? [navigator.language] : [];
|
||||
}
|
||||
|
||||
function setDocumentLanguage(locale: SupportedLocale): void {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.lang = locale;
|
||||
}
|
||||
|
||||
const initialLocale = resolveInitialLocale({
|
||||
storedLocale: readStoredLocale(),
|
||||
navigatorLanguages: getNavigatorLanguages(),
|
||||
});
|
||||
|
||||
syncVantLocale(initialLocale);
|
||||
setDocumentLanguage(initialLocale);
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: initialLocale,
|
||||
fallbackLocale: defaultLocale,
|
||||
messages,
|
||||
});
|
||||
|
||||
export function getCurrentLocale(): SupportedLocale {
|
||||
const locale = i18n.global.locale.value;
|
||||
return isSupportedLocale(locale) ? locale : defaultLocale;
|
||||
}
|
||||
|
||||
export function setLocale(locale: string): void {
|
||||
if (!isSupportedLocale(locale)) {
|
||||
return;
|
||||
}
|
||||
|
||||
i18n.global.locale.value = locale;
|
||||
writeStoredLocale(locale);
|
||||
syncVantLocale(locale);
|
||||
setDocumentLanguage(locale);
|
||||
}
|
||||
39
src/i18n/locales.ts
Normal file
39
src/i18n/locales.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defaultLocale, supportedLocales } from "./types.ts";
|
||||
import type { SupportedLocale } from "./types.ts";
|
||||
|
||||
export function isSupportedLocale(locale: string | null | undefined): locale is SupportedLocale {
|
||||
return supportedLocales.includes(locale as SupportedLocale);
|
||||
}
|
||||
|
||||
export function resolveLocaleFromNavigator(languages: readonly string[] = []): SupportedLocale {
|
||||
for (const language of languages) {
|
||||
const normalizedLanguage = language.toLowerCase();
|
||||
|
||||
if (normalizedLanguage.startsWith("zh")) {
|
||||
return "zh-CN";
|
||||
}
|
||||
|
||||
if (normalizedLanguage.startsWith("en")) {
|
||||
return "en-US";
|
||||
}
|
||||
|
||||
if (normalizedLanguage.startsWith("th")) {
|
||||
return "th-TH";
|
||||
}
|
||||
}
|
||||
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
type ResolveInitialLocaleOptions = {
|
||||
storedLocale?: string | null;
|
||||
navigatorLanguages?: readonly string[];
|
||||
};
|
||||
|
||||
export function resolveInitialLocale(options: ResolveInitialLocaleOptions = {}): SupportedLocale {
|
||||
if (isSupportedLocale(options.storedLocale)) {
|
||||
return options.storedLocale;
|
||||
}
|
||||
|
||||
return resolveLocaleFromNavigator(options.navigatorLanguages);
|
||||
}
|
||||
25
src/i18n/messages.ts
Normal file
25
src/i18n/messages.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { supportedLocales } from "./types.ts";
|
||||
import type { SupportedLocale } from "./types.ts";
|
||||
import { commonMessages } from "./modules/common/index.ts";
|
||||
import type { CommonMessages } from "./modules/common/index.ts";
|
||||
import { homeMessages } from "./modules/home/index.ts";
|
||||
import type { HomeMessages } from "./modules/home/index.ts";
|
||||
import { quickMessages } from "./modules/quick/index.ts";
|
||||
import type { QuickMessages } from "./modules/quick/index.ts";
|
||||
|
||||
export type AppMessages = {
|
||||
common: CommonMessages;
|
||||
home: HomeMessages;
|
||||
quick: QuickMessages;
|
||||
};
|
||||
|
||||
export const messages = Object.fromEntries(
|
||||
supportedLocales.map((locale) => [
|
||||
locale,
|
||||
{
|
||||
common: commonMessages[locale],
|
||||
home: homeMessages[locale],
|
||||
quick: quickMessages[locale],
|
||||
},
|
||||
]),
|
||||
) as Record<SupportedLocale, AppMessages>;
|
||||
14
src/i18n/modules/common/en-US.ts
Normal file
14
src/i18n/modules/common/en-US.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export default {
|
||||
actions: {
|
||||
cancel: "Cancel",
|
||||
confirm: "Confirm",
|
||||
retry: "Retry",
|
||||
},
|
||||
errors: {
|
||||
network: "Network error. Please try again later.",
|
||||
},
|
||||
state: {
|
||||
empty: "No data",
|
||||
loading: "Loading...",
|
||||
},
|
||||
} as const;
|
||||
12
src/i18n/modules/common/index.ts
Normal file
12
src/i18n/modules/common/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { LocaleMessageSchema, SupportedLocale } from "../../types.ts";
|
||||
import enUS from "./en-US.ts";
|
||||
import thTH from "./th-TH.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
|
||||
export type CommonMessages = LocaleMessageSchema<typeof zhCN>;
|
||||
|
||||
export const commonMessages = {
|
||||
"zh-CN": zhCN,
|
||||
"en-US": enUS,
|
||||
"th-TH": thTH,
|
||||
} satisfies Record<SupportedLocale, CommonMessages>;
|
||||
14
src/i18n/modules/common/th-TH.ts
Normal file
14
src/i18n/modules/common/th-TH.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export default {
|
||||
actions: {
|
||||
cancel: "ยกเลิก",
|
||||
confirm: "ยืนยัน",
|
||||
retry: "ลองอีกครั้ง",
|
||||
},
|
||||
errors: {
|
||||
network: "เครือข่ายผิดพลาด โปรดลองอีกครั้งในภายหลัง",
|
||||
},
|
||||
state: {
|
||||
empty: "ไม่มีข้อมูล",
|
||||
loading: "กำลังโหลด...",
|
||||
},
|
||||
} as const;
|
||||
14
src/i18n/modules/common/zh-CN.ts
Normal file
14
src/i18n/modules/common/zh-CN.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export default {
|
||||
actions: {
|
||||
cancel: "取消",
|
||||
confirm: "确认",
|
||||
retry: "重试",
|
||||
},
|
||||
errors: {
|
||||
network: "网络异常,请稍后重试",
|
||||
},
|
||||
state: {
|
||||
empty: "暂无数据",
|
||||
loading: "加载中...",
|
||||
},
|
||||
} as const;
|
||||
4
src/i18n/modules/home/en-US.ts
Normal file
4
src/i18n/modules/home/en-US.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
subtitle: "Welcome to the mobile app",
|
||||
title: "Home",
|
||||
} as const;
|
||||
12
src/i18n/modules/home/index.ts
Normal file
12
src/i18n/modules/home/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { LocaleMessageSchema, SupportedLocale } from "../../types.ts";
|
||||
import enUS from "./en-US.ts";
|
||||
import thTH from "./th-TH.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
|
||||
export type HomeMessages = LocaleMessageSchema<typeof zhCN>;
|
||||
|
||||
export const homeMessages = {
|
||||
"zh-CN": zhCN,
|
||||
"en-US": enUS,
|
||||
"th-TH": thTH,
|
||||
} satisfies Record<SupportedLocale, HomeMessages>;
|
||||
4
src/i18n/modules/home/th-TH.ts
Normal file
4
src/i18n/modules/home/th-TH.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
subtitle: "ยินดีต้อนรับสู่แอปมือถือ",
|
||||
title: "หน้าแรก",
|
||||
} as const;
|
||||
4
src/i18n/modules/home/zh-CN.ts
Normal file
4
src/i18n/modules/home/zh-CN.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
subtitle: "欢迎使用移动端应用",
|
||||
title: "首页",
|
||||
} as const;
|
||||
7
src/i18n/modules/quick/en-US.ts
Normal file
7
src/i18n/modules/quick/en-US.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
form: {
|
||||
placeholder: "Enter content",
|
||||
submit: "Submit",
|
||||
},
|
||||
title: "Quick Actions",
|
||||
} as const;
|
||||
12
src/i18n/modules/quick/index.ts
Normal file
12
src/i18n/modules/quick/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { LocaleMessageSchema, SupportedLocale } from "../../types.ts";
|
||||
import enUS from "./en-US.ts";
|
||||
import thTH from "./th-TH.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
|
||||
export type QuickMessages = LocaleMessageSchema<typeof zhCN>;
|
||||
|
||||
export const quickMessages = {
|
||||
"zh-CN": zhCN,
|
||||
"en-US": enUS,
|
||||
"th-TH": thTH,
|
||||
} satisfies Record<SupportedLocale, QuickMessages>;
|
||||
7
src/i18n/modules/quick/th-TH.ts
Normal file
7
src/i18n/modules/quick/th-TH.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
form: {
|
||||
placeholder: "กรอกเนื้อหา",
|
||||
submit: "ส่ง",
|
||||
},
|
||||
title: "การดำเนินการด่วน",
|
||||
} as const;
|
||||
7
src/i18n/modules/quick/zh-CN.ts
Normal file
7
src/i18n/modules/quick/zh-CN.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
form: {
|
||||
placeholder: "请输入内容",
|
||||
submit: "提交",
|
||||
},
|
||||
title: "快捷操作",
|
||||
} as const;
|
||||
35
src/i18n/storage.ts
Normal file
35
src/i18n/storage.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { isSupportedLocale } from "./locales.ts";
|
||||
import type { SupportedLocale } from "./types.ts";
|
||||
|
||||
export const localeStorageKey = "nianxx.locale";
|
||||
|
||||
type LocaleStorageLike = Pick<Storage, "getItem" | "setItem">;
|
||||
|
||||
function resolveStorage(storage?: LocaleStorageLike): LocaleStorageLike | undefined {
|
||||
if (storage) {
|
||||
return storage;
|
||||
}
|
||||
|
||||
try {
|
||||
return globalThis.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredLocale(storage?: LocaleStorageLike): SupportedLocale | null {
|
||||
try {
|
||||
const locale = resolveStorage(storage)?.getItem(localeStorageKey);
|
||||
return isSupportedLocale(locale) ? locale : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredLocale(locale: SupportedLocale, storage?: LocaleStorageLike): void {
|
||||
try {
|
||||
resolveStorage(storage)?.setItem(localeStorageKey, locale);
|
||||
} catch {
|
||||
// Storage can be unavailable in private or embedded browser contexts.
|
||||
}
|
||||
}
|
||||
9
src/i18n/types.ts
Normal file
9
src/i18n/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const supportedLocales = ["zh-CN", "en-US", "th-TH"] as const;
|
||||
|
||||
export type SupportedLocale = (typeof supportedLocales)[number];
|
||||
|
||||
export const defaultLocale: SupportedLocale = "zh-CN";
|
||||
|
||||
export type LocaleMessageSchema<T> = {
|
||||
readonly [Key in keyof T]: T[Key] extends string ? string : LocaleMessageSchema<T[Key]>;
|
||||
};
|
||||
15
src/i18n/vant.ts
Normal file
15
src/i18n/vant.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Locale } from "vant";
|
||||
import enUS from "vant/es/locale/lang/en-US";
|
||||
import thTH from "vant/es/locale/lang/th-TH";
|
||||
import zhCN from "vant/es/locale/lang/zh-CN";
|
||||
import type { SupportedLocale } from "./types.ts";
|
||||
|
||||
const vantMessages = {
|
||||
"zh-CN": zhCN,
|
||||
"en-US": enUS,
|
||||
"th-TH": thTH,
|
||||
} satisfies Record<SupportedLocale, Record<string, unknown>>;
|
||||
|
||||
export function syncVantLocale(locale: SupportedLocale): void {
|
||||
Locale.use(locale, vantMessages[locale]);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import { i18n } from "./i18n";
|
||||
import { pinia } from "./stores";
|
||||
import { router } from "./router";
|
||||
import "vant/lib/index.css";
|
||||
import "@/styles/main.css";
|
||||
|
||||
createApp(App).use(pinia).use(router).mount("#app");
|
||||
createApp(App).use(pinia).use(i18n).use(router).mount("#app");
|
||||
|
||||
Reference in New Issue
Block a user