main
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)
}