feat(web): i18n (#12471)

This commit is contained in:
Adam
2026-02-06 08:54:51 -06:00
committed by GitHub
parent 0ec5f6608b
commit 812597bb8b
75 changed files with 9868 additions and 726 deletions

View File

@@ -0,0 +1,83 @@
import type { Key } from "~/i18n"
export const formError = {
invalidPlan: "error.invalidPlan",
workspaceRequired: "error.workspaceRequired",
alreadySubscribed: "error.alreadySubscribed",
limitRequired: "error.limitRequired",
monthlyLimitInvalid: "error.monthlyLimitInvalid",
workspaceNameRequired: "error.workspaceNameRequired",
nameTooLong: "error.nameTooLong",
emailRequired: "error.emailRequired",
roleRequired: "error.roleRequired",
idRequired: "error.idRequired",
nameRequired: "error.nameRequired",
providerRequired: "error.providerRequired",
apiKeyRequired: "error.apiKeyRequired",
modelRequired: "error.modelRequired",
} as const
const map = {
[formError.invalidPlan]: "error.invalidPlan",
[formError.workspaceRequired]: "error.workspaceRequired",
[formError.alreadySubscribed]: "error.alreadySubscribed",
[formError.limitRequired]: "error.limitRequired",
[formError.monthlyLimitInvalid]: "error.monthlyLimitInvalid",
[formError.workspaceNameRequired]: "error.workspaceNameRequired",
[formError.nameTooLong]: "error.nameTooLong",
[formError.emailRequired]: "error.emailRequired",
[formError.roleRequired]: "error.roleRequired",
[formError.idRequired]: "error.idRequired",
[formError.nameRequired]: "error.nameRequired",
[formError.providerRequired]: "error.providerRequired",
[formError.apiKeyRequired]: "error.apiKeyRequired",
[formError.modelRequired]: "error.modelRequired",
"Invalid plan": "error.invalidPlan",
"Workspace ID is required": "error.workspaceRequired",
"Workspace ID is required.": "error.workspaceRequired",
"This workspace already has a subscription": "error.alreadySubscribed",
"Limit is required.": "error.limitRequired",
"Set a valid monthly limit": "error.monthlyLimitInvalid",
"Set a valid monthly limit.": "error.monthlyLimitInvalid",
"Workspace name is required.": "error.workspaceNameRequired",
"Name must be 255 characters or less.": "error.nameTooLong",
"Email is required": "error.emailRequired",
"Role is required": "error.roleRequired",
"ID is required": "error.idRequired",
"Name is required": "error.nameRequired",
"Provider is required": "error.providerRequired",
"API key is required": "error.apiKeyRequired",
"Model is required": "error.modelRequired",
} as const satisfies Record<string, Key>
export function formErrorReloadAmountMin(amount: number) {
return `error.reloadAmountMin:${amount}`
}
export function formErrorReloadTriggerMin(amount: number) {
return `error.reloadTriggerMin:${amount}`
}
export function localizeError(t: (key: Key, params?: Record<string, string | number>) => string, error?: string) {
if (!error) return ""
if (error.startsWith("error.reloadAmountMin:")) {
const amount = Number(error.split(":")[1] ?? 0)
return t("error.reloadAmountMin", { amount })
}
if (error.startsWith("error.reloadTriggerMin:")) {
const amount = Number(error.split(":")[1] ?? 0)
return t("error.reloadTriggerMin", { amount })
}
const amount = error.match(/^Reload amount must be at least \$(\d+)$/)
if (amount) return t("error.reloadAmountMin", { amount: Number(amount[1]) })
const trigger = error.match(/^Balance trigger must be at least \$(\d+)$/)
if (trigger) return t("error.reloadTriggerMin", { amount: Number(trigger[1]) })
const key = map[error as keyof typeof map]
if (key) return t(key)
return error
}

View File

@@ -0,0 +1,175 @@
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<Locale, string>
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<Locale, string>
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`
}