feat: add modular i18n foundation

This commit is contained in:
duanshuwen
2026-05-26 14:37:32 +08:00
parent a9b00627e2
commit b05d5a72cd
24 changed files with 606 additions and 4 deletions

76
src/i18n/i18n.test.ts Normal file
View 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
View 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
View 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
View 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>;

View 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;

View 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>;

View File

@@ -0,0 +1,14 @@
export default {
actions: {
cancel: "ยกเลิก",
confirm: "ยืนยัน",
retry: "ลองอีกครั้ง",
},
errors: {
network: "เครือข่ายผิดพลาด โปรดลองอีกครั้งในภายหลัง",
},
state: {
empty: "ไม่มีข้อมูล",
loading: "กำลังโหลด...",
},
} as const;

View File

@@ -0,0 +1,14 @@
export default {
actions: {
cancel: "取消",
confirm: "确认",
retry: "重试",
},
errors: {
network: "网络异常,请稍后重试",
},
state: {
empty: "暂无数据",
loading: "加载中...",
},
} as const;

View File

@@ -0,0 +1,4 @@
export default {
subtitle: "Welcome to the mobile app",
title: "Home",
} as const;

View 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>;

View File

@@ -0,0 +1,4 @@
export default {
subtitle: "ยินดีต้อนรับสู่แอปมือถือ",
title: "หน้าแรก",
} as const;

View File

@@ -0,0 +1,4 @@
export default {
subtitle: "欢迎使用移动端应用",
title: "首页",
} as const;

View File

@@ -0,0 +1,7 @@
export default {
form: {
placeholder: "Enter content",
submit: "Submit",
},
title: "Quick Actions",
} as const;

View 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>;

View File

@@ -0,0 +1,7 @@
export default {
form: {
placeholder: "กรอกเนื้อหา",
submit: "ส่ง",
},
title: "การดำเนินการด่วน",
} as const;

View File

@@ -0,0 +1,7 @@
export default {
form: {
placeholder: "请输入内容",
submit: "提交",
},
title: "快捷操作",
} as const;

35
src/i18n/storage.ts Normal file
View 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
View 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
View 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]);
}

View File

@@ -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");