import { useSyncExternalStore } from 'react' /** * UI locale store: a module-level singleton exactly like `shared/lib/theme`, so * the boot sequence, plain modules (formatters, `meta.ts` label tables) and * React components all read the same value without a provider in scope. * * Resolution order — account preference, then the device choice, then the * browser, then the reference locale: * * 1. `users.language` (written by the settings modal through `PATCH /me`) * 2. localStorage, the explicit choice made on this device * 3. `navigator.languages` * 4. zh-CN, the reference dictionary every key is authored against * * Only the locales shipped in `messages/` are accepted. A tag such as `ja` * or `zh-Hant` normalizes to `null` and simply loses its turn in the chain, so * unsupported account preferences never blank the interface. */ /** UI locales shipped with this build. */ export type Locale = 'zh-CN' | 'en' export const LOCALES: readonly Locale[] = ['zh-CN', 'en'] export const DEFAULT_LOCALE: Locale = 'zh-CN' const STORAGE_KEY = 'gitdata-locale' let accountLocale: Locale | null = null let current: Locale = DEFAULT_LOCALE const listeners = new Set<() => void>() /** * Fold a BCP-47 or POSIX tag onto a shipped locale. * * `zh_Hans`, `zh-Hans-CN`, `zh-CN`, `zh` and `en-US` all collapse; `zh-Hant`, * `zh-TW`, `zh-HK` and `zh-MO` deliberately do **not** — rendering Simplified * copy for a Traditional reader is worse than falling back to English. */ export function normalizeLocale(raw: string | null | undefined): Locale | null { const tag = (raw ?? '').trim().toLowerCase().replace(/_/g, '-') if (!tag) return null if (tag === 'zh' || tag.startsWith('zh-')) { const parts = tag.split('-').slice(1) if (parts.some((part) => part.startsWith('hant'))) return null if (parts.some((part) => part === 'tw' || part === 'hk' || part === 'mo')) return null return 'zh-CN' } if (tag === 'en' || tag.startsWith('en-')) return 'en' return null } function fromNavigator(): Locale | null { if (typeof navigator === 'undefined') return null const candidates = navigator.languages?.length ? navigator.languages : [navigator.language] for (const candidate of candidates) { const locale = normalizeLocale(candidate) if (locale) return locale } return null } function storedLocale(): Locale | null { try { return normalizeLocale(localStorage.getItem(STORAGE_KEY)) } catch { return null } } function persist(locale: Locale) { try { localStorage.setItem(STORAGE_KEY, locale) } catch { /* private mode / storage disabled — the session keeps the in-memory value */ } } function resolve(): Locale { return accountLocale ?? storedLocale() ?? fromNavigator() ?? DEFAULT_LOCALE } function paint(locale: Locale) { if (typeof document !== 'undefined') document.documentElement.lang = locale for (const listener of listeners) listener() } /** Boot-time entry point: resolve once, before the first paint of localized UI. */ export function bootLocale() { current = resolve() if (typeof document !== 'undefined') document.documentElement.lang = current } /** * Explicit switch from the settings UI. The choice is remembered on this * device; the caller is responsible for persisting it to the account, and the * server value re-applies on the next `fetchMe`. */ export function setLocale(locale: Locale) { accountLocale = null persist(locale) if (current === locale) return current = locale paint(locale) } /** * Apply the signed-in account's preference (`users.language` first, then * `users.locale`). Passing values that normalize to `null` clears the override, * which hands control back to the device/browser chain. */ export function applyAccountLocale(...candidates: (string | null | undefined)[]) { let next: Locale | null = null for (const candidate of candidates) { next = normalizeLocale(candidate) if (next) break } if (next === accountLocale) return accountLocale = next const resolved = resolve() if (resolved === current) return current = resolved paint(resolved) } export function getLocale(): Locale { return current } /** Human-readable locale name for pickers, in the locale's own language. */ export function localeLabel(locale: Locale): string { return locale === 'en' ? 'English' : '简体中文' } function subscribe(listener: () => void): () => void { listeners.add(listener) return () => listeners.delete(listener) } export function useLocale(): Locale { return useSyncExternalStore(subscribe, getLocale) }