| 1 | export const LOCALE_STORAGE_KEY = "nanobot.locale"; |
| 2 | |
| 3 | export const supportedLocales = [ |
| 4 | { code: "en", label: "English", nativeLabel: "English" }, |
| 5 | { code: "zh-CN", label: "Chinese (Simplified)", nativeLabel: "简体中文" }, |
| 6 | { code: "zh-TW", label: "Chinese (Traditional)", nativeLabel: "繁體中文" }, |
| 7 | { code: "fr", label: "French", nativeLabel: "Français" }, |
| 8 | { code: "ja", label: "Japanese", nativeLabel: "日本語" }, |
| 9 | { code: "ko", label: "Korean", nativeLabel: "한국어" }, |
| 10 | { code: "es", label: "Spanish", nativeLabel: "Español" }, |
| 11 | { code: "vi", label: "Vietnamese", nativeLabel: "Tiếng Việt" }, |
| 12 | { code: "id", label: "Indonesian", nativeLabel: "Bahasa Indonesia" }, |
| 13 | ] as const; |
| 14 | |
| 15 | export type SupportedLocale = (typeof supportedLocales)[number]["code"]; |
| 16 | |
| 17 | export const defaultLocale: SupportedLocale = "en"; |
| 18 | export const fallbackLocale: SupportedLocale = "en"; |
| 19 | |
| 20 | export function normalizeLocale( |
| 21 | input: string | null | undefined, |
| 22 | ): SupportedLocale { |
| 23 | if (!input) return defaultLocale; |
| 24 | const trimmed = input.trim(); |
| 25 | if (!trimmed) return defaultLocale; |
| 26 | |
| 27 | const exact = supportedLocales.find((locale) => locale.code === trimmed); |
| 28 | if (exact) return exact.code; |
| 29 | |
| 30 | const lower = trimmed.toLowerCase(); |
| 31 | if (lower === "zh" || lower.startsWith("zh-cn") || lower.startsWith("zh-sg")) { |
| 32 | return "zh-CN"; |
| 33 | } |
| 34 | if ( |
| 35 | lower.startsWith("zh-tw") || |
| 36 | lower.startsWith("zh-hk") || |
| 37 | lower.startsWith("zh-mo") || |
| 38 | lower.startsWith("zh-hant") |
| 39 | ) { |
| 40 | return "zh-TW"; |
| 41 | } |
| 42 | |
| 43 | const base = lower.split("-")[0]; |
| 44 | const baseMatch = supportedLocales.find( |
| 45 | (locale) => locale.code.toLowerCase() === base, |
| 46 | ); |
| 47 | return baseMatch?.code ?? defaultLocale; |
| 48 | } |
| 49 | |
| 50 | export function readStoredLocale(): SupportedLocale | null { |
| 51 | if (typeof window === "undefined") return null; |
| 52 | try { |
| 53 | const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY); |
| 54 | return raw ? normalizeLocale(raw) : null; |
| 55 | } catch { |
| 56 | return null; |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | export function detectNavigatorLocale(): SupportedLocale { |
| 61 | if (typeof navigator === "undefined") return defaultLocale; |
| 62 | const candidates = [ |
| 63 | ...(navigator.languages ?? []), |
| 64 | navigator.language, |
| 65 | ].filter(Boolean); |
| 66 | for (const locale of candidates) { |
| 67 | const normalized = normalizeLocale(locale); |
| 68 | if (normalized) return normalized; |
| 69 | } |
| 70 | return defaultLocale; |
| 71 | } |
| 72 | |
| 73 | export function resolveInitialLocale(): SupportedLocale { |
| 74 | return defaultLocale; |
| 75 | } |
| 76 | |
| 77 | export function persistLocale(locale: SupportedLocale): void { |
| 78 | if (typeof window === "undefined") return; |
| 79 | try { |
| 80 | window.localStorage.setItem(LOCALE_STORAGE_KEY, locale); |
| 81 | } catch { |
| 82 | // ignore storage errors |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | export function applyDocumentLocale(locale: SupportedLocale): void { |
| 87 | if (typeof document === "undefined") return; |
| 88 | document.documentElement.lang = locale; |
| 89 | } |
| 90 | |
| 91 | export function localeOption(locale: SupportedLocale) { |
| 92 | return supportedLocales.find((entry) => entry.code === locale) ?? supportedLocales[0]; |
| 93 | } |
| 94 |