export const LOCALES = [ "en", "zh", "zht", "ko", "de", "es", "fr", "it", "da", "ja", "pl", "ru", "ar", "no", "br", "th", "tr", ] as const export type Locale = (typeof LOCALES)[number] export const LOCALE_COOKIE = "oc_locale" as const const LABEL = { en: "English", zh: "简体中文", zht: "繁體中文", ko: "한국어", de: "Deutsch", es: "Español", fr: "Français", it: "Italiano", da: "Dansk", ja: "日本語", pl: "Polski", ru: "Русский", ar: "العربية", no: "Norsk", br: "Português (Brasil)", th: "ไทย", tr: "Türkçe", } satisfies Record const TAG = { en: "en", zh: "zh-Hans", zht: "zh-Hant", ko: "ko", de: "de", es: "es", fr: "fr", it: "it", da: "da", ja: "ja", pl: "pl", ru: "ru", ar: "ar", no: "no", br: "pt-BR", th: "th", tr: "tr", } satisfies Record export function parseLocale(value: unknown): Locale | null { if (typeof value !== "string") return null if ((LOCALES as readonly string[]).includes(value)) return value as Locale return null } export function label(locale: Locale) { return LABEL[locale] } export function tag(locale: Locale) { return TAG[locale] } export function dir(locale: Locale) { if (locale === "ar") return "rtl" return "ltr" } function match(input: string): Locale | null { const value = input.trim().toLowerCase() if (!value) return null if (value.startsWith("zh")) { if (value.includes("hant") || value.includes("-tw") || value.includes("-hk") || value.includes("-mo")) return "zht" return "zh" } if (value.startsWith("ko")) return "ko" if (value.startsWith("de")) return "de" if (value.startsWith("es")) return "es" if (value.startsWith("fr")) return "fr" if (value.startsWith("it")) return "it" if (value.startsWith("da")) return "da" if (value.startsWith("ja")) return "ja" if (value.startsWith("pl")) return "pl" if (value.startsWith("ru")) return "ru" if (value.startsWith("ar")) return "ar" if (value.startsWith("tr")) return "tr" if (value.startsWith("th")) return "th" if (value.startsWith("pt")) return "br" if (value.startsWith("no") || value.startsWith("nb") || value.startsWith("nn")) return "no" if (value.startsWith("en")) return "en" return null } export function detectFromLanguages(languages: readonly string[]) { for (const language of languages) { const locale = match(language) if (locale) return locale } return "en" satisfies Locale } export function detectFromAcceptLanguage(header: string | null) { if (!header) return "en" satisfies Locale const items = header .split(",") .map((raw) => raw.trim()) .filter(Boolean) .map((raw) => { const parts = raw.split(";").map((x) => x.trim()) const lang = parts[0] ?? "" const q = parts .slice(1) .find((x) => x.startsWith("q=")) ?.slice(2) return { lang, q: q ? Number.parseFloat(q) : 1, } }) .sort((a, b) => b.q - a.q) for (const item of items) { if (!item.lang || item.lang === "*") continue const locale = match(item.lang) if (locale) return locale } return "en" satisfies Locale } export function localeFromCookieHeader(header: string | null) { if (!header) return null const raw = header .split(";") .map((x) => x.trim()) .find((x) => x.startsWith(`${LOCALE_COOKIE}=`)) ?.slice(`${LOCALE_COOKIE}=`.length) if (!raw) return null return parseLocale(decodeURIComponent(raw)) } export function localeFromRequest(request: Request) { return ( localeFromCookieHeader(request.headers.get("cookie")) ?? detectFromAcceptLanguage(request.headers.get("accept-language")) ) } export function cookie(locale: Locale) { return `${LOCALE_COOKIE}=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` } export function clearCookie() { return `${LOCALE_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax` }