summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app/src/lib
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-06 08:54:51 -0600
committerGitHub <[email protected]>2026-02-06 08:54:51 -0600
commit812597bb8b101896a8988493d37261ff851ae502 (patch)
tree3b713c13f49c65665e6a7296ed483a3f6f8befbf /packages/console/app/src/lib
parent0ec5f6608bdfea5be62dbbdc4c04a61de6d3e67c (diff)
downloadopencode-812597bb8b101896a8988493d37261ff851ae502.tar.gz
opencode-812597bb8b101896a8988493d37261ff851ae502.zip
feat(web): i18n (#12471)
Diffstat (limited to 'packages/console/app/src/lib')
-rw-r--r--packages/console/app/src/lib/form-error.ts83
-rw-r--r--packages/console/app/src/lib/language.ts175
2 files changed, 258 insertions, 0 deletions
diff --git a/packages/console/app/src/lib/form-error.ts b/packages/console/app/src/lib/form-error.ts
new file mode 100644
index 000000000..1f6e2ea1e
--- /dev/null
+++ b/packages/console/app/src/lib/form-error.ts
@@ -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
+}
diff --git a/packages/console/app/src/lib/language.ts b/packages/console/app/src/lib/language.ts
new file mode 100644
index 000000000..e1e62d8fd
--- /dev/null
+++ b/packages/console/app/src/lib/language.ts
@@ -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`
+}