feat(app): add global locale coverage (#40992)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
4abddbbfdd
commit
741244b69d
@@ -28,6 +28,10 @@
|
||||
- Prefer complete translated phrases. Do not concatenate grammatical fragments or make call sites assemble sentences. Keep placeholders to irreducible dynamic values such as names, paths, and counts.
|
||||
- If a translation cannot be expressed by the current API, deepen the shared language/UI i18n module so one typed call owns locale selection, plural resolution, fallback, and interpolation. Do not leak that machinery into product code.
|
||||
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
|
||||
- For developer-facing terminology, prefer the words already used by the target language's developer community over literal dictionary translations. Cross-check maintained localized developer products such as Firefox, KDE, and VS Code; use at least two independent corpora when they are available. If established practice keeps an English loanword or acronym, keep it rather than inventing a translation.
|
||||
- Translate complete UI phrases in context. A glossary hit is evidence, not permission to translate word-by-word. Check terse labels such as session, prompt, agent, model, fork, shell, terminal, workspace, and worktree in the same grammatical role before choosing a term.
|
||||
- Before a locale is ready, audit recurring concepts for one consistent translation and review every value that still equals English. Classify retained English as a product name, provider/tool name, URL, code token, keyboard legend, acronym, asset name, or established borrowing; translate unexplained leftovers.
|
||||
- In translation review notes, name the corpora used and call out uncertain or region-specific terminology so native speakers can focus review where it matters.
|
||||
- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels.
|
||||
|
||||
## Tool Calling
|
||||
|
||||
@@ -8,9 +8,11 @@ import { dict as en } from "@/i18n/en"
|
||||
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||
import {
|
||||
createDesktopNativeBundle,
|
||||
detectDesktopNativeLocale,
|
||||
DESKTOP_NATIVE_ENGLISH,
|
||||
DESKTOP_NATIVE_LABELS,
|
||||
DESKTOP_NATIVE_LOCALES,
|
||||
DESKTOP_NATIVE_LOCALE_TAGS,
|
||||
type DesktopNativeBundle,
|
||||
type DesktopNativeLocale,
|
||||
} from "@/i18n/desktop-native"
|
||||
@@ -18,7 +20,7 @@ import {
|
||||
export type Locale = DesktopNativeLocale
|
||||
export type Direction = "ltr" | "rtl"
|
||||
|
||||
const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "ur", "pa"])
|
||||
const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "ur", "pa", "fa", "dv"])
|
||||
|
||||
function localeDirection(locale: Locale): Direction {
|
||||
return RTL_LOCALES.has(locale) ? "rtl" : "ltr"
|
||||
@@ -39,36 +41,7 @@ function cookie(locale: Locale) {
|
||||
|
||||
const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES
|
||||
|
||||
const INTL: Record<Locale, string> = {
|
||||
en: "en",
|
||||
zh: "zh-Hans",
|
||||
zht: "zh-Hant",
|
||||
ko: "ko",
|
||||
de: "de",
|
||||
es: "es",
|
||||
fr: "fr",
|
||||
da: "da",
|
||||
ja: "ja",
|
||||
pl: "pl",
|
||||
ru: "ru",
|
||||
uk: "uk",
|
||||
ar: "ar",
|
||||
no: "nb-NO",
|
||||
br: "pt-BR",
|
||||
th: "th",
|
||||
bs: "bs",
|
||||
tr: "tr",
|
||||
hi: "hi-IN",
|
||||
nl: "nl-NL",
|
||||
id: "id-ID",
|
||||
vi: "vi-VN",
|
||||
it: "it-IT",
|
||||
ur: "ur-PK",
|
||||
pa: "pa-Arab-PK",
|
||||
az: "az-Latn-AZ",
|
||||
fi: "fi-FI",
|
||||
sv: "sv-SE",
|
||||
}
|
||||
const INTL = DESKTOP_NATIVE_LOCALE_TAGS
|
||||
|
||||
const base = i18n.flatten({ ...en, ...uiEn })
|
||||
const dicts = new Map<Locale, Dictionary>([["en", base]])
|
||||
@@ -104,6 +77,40 @@ const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
|
||||
az: () => merge(import("@/i18n/az"), import("@opencode-ai/ui/i18n/az")),
|
||||
fi: () => merge(import("@/i18n/fi"), import("@opencode-ai/ui/i18n/fi")),
|
||||
sv: () => merge(import("@/i18n/sv"), import("@opencode-ai/ui/i18n/sv")),
|
||||
am: () => merge(import("@/i18n/am"), import("@opencode-ai/ui/i18n/am")),
|
||||
bg: () => merge(import("@/i18n/bg"), import("@opencode-ai/ui/i18n/bg")),
|
||||
bn: () => merge(import("@/i18n/bn"), import("@opencode-ai/ui/i18n/bn")),
|
||||
ca: () => merge(import("@/i18n/ca"), import("@opencode-ai/ui/i18n/ca")),
|
||||
cs: () => merge(import("@/i18n/cs"), import("@opencode-ai/ui/i18n/cs")),
|
||||
dv: () => merge(import("@/i18n/dv"), import("@opencode-ai/ui/i18n/dv")),
|
||||
dz: () => merge(import("@/i18n/dz"), import("@opencode-ai/ui/i18n/dz")),
|
||||
el: () => merge(import("@/i18n/el"), import("@opencode-ai/ui/i18n/el")),
|
||||
et: () => merge(import("@/i18n/et"), import("@opencode-ai/ui/i18n/et")),
|
||||
fa: () => merge(import("@/i18n/fa"), import("@opencode-ai/ui/i18n/fa")),
|
||||
fo: () => merge(import("@/i18n/fo"), import("@opencode-ai/ui/i18n/fo")),
|
||||
hr: () => merge(import("@/i18n/hr"), import("@opencode-ai/ui/i18n/hr")),
|
||||
hu: () => merge(import("@/i18n/hu"), import("@opencode-ai/ui/i18n/hu")),
|
||||
hy: () => merge(import("@/i18n/hy"), import("@opencode-ai/ui/i18n/hy")),
|
||||
is: () => merge(import("@/i18n/is"), import("@opencode-ai/ui/i18n/is")),
|
||||
ka: () => merge(import("@/i18n/ka"), import("@opencode-ai/ui/i18n/ka")),
|
||||
km: () => merge(import("@/i18n/km"), import("@opencode-ai/ui/i18n/km")),
|
||||
lo: () => merge(import("@/i18n/lo"), import("@opencode-ai/ui/i18n/lo")),
|
||||
lt: () => merge(import("@/i18n/lt"), import("@opencode-ai/ui/i18n/lt")),
|
||||
lv: () => merge(import("@/i18n/lv"), import("@opencode-ai/ui/i18n/lv")),
|
||||
mk: () => merge(import("@/i18n/mk"), import("@opencode-ai/ui/i18n/mk")),
|
||||
mn: () => merge(import("@/i18n/mn"), import("@opencode-ai/ui/i18n/mn")),
|
||||
ms: () => merge(import("@/i18n/ms"), import("@opencode-ai/ui/i18n/ms")),
|
||||
my: () => merge(import("@/i18n/my"), import("@opencode-ai/ui/i18n/my")),
|
||||
ne: () => merge(import("@/i18n/ne"), import("@opencode-ai/ui/i18n/ne")),
|
||||
ro: () => merge(import("@/i18n/ro"), import("@opencode-ai/ui/i18n/ro")),
|
||||
si: () => merge(import("@/i18n/si"), import("@opencode-ai/ui/i18n/si")),
|
||||
sk: () => merge(import("@/i18n/sk"), import("@opencode-ai/ui/i18n/sk")),
|
||||
sl: () => merge(import("@/i18n/sl"), import("@opencode-ai/ui/i18n/sl")),
|
||||
sq: () => merge(import("@/i18n/sq"), import("@opencode-ai/ui/i18n/sq")),
|
||||
sr: () => merge(import("@/i18n/sr"), import("@opencode-ai/ui/i18n/sr")),
|
||||
tg: () => merge(import("@/i18n/tg"), import("@opencode-ai/ui/i18n/tg")),
|
||||
tk: () => merge(import("@/i18n/tk"), import("@opencode-ai/ui/i18n/tk")),
|
||||
uz: () => merge(import("@/i18n/uz"), import("@opencode-ai/ui/i18n/uz")),
|
||||
}
|
||||
|
||||
function loadDict(locale: Locale) {
|
||||
@@ -121,63 +128,9 @@ export function loadLocaleDict(locale: Locale) {
|
||||
return loadDict(locale).then(() => undefined)
|
||||
}
|
||||
|
||||
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
||||
{ locale: "en", match: (language) => language.startsWith("en") },
|
||||
{
|
||||
locale: "zht",
|
||||
match: (language) =>
|
||||
language.startsWith("zh") &&
|
||||
(language.includes("hant") || language.includes("-tw") || language.includes("-hk") || language.includes("-mo")),
|
||||
},
|
||||
{ locale: "zh", match: (language) => language.startsWith("zh") },
|
||||
{ locale: "ko", match: (language) => language.startsWith("ko") },
|
||||
{ locale: "de", match: (language) => language.startsWith("de") },
|
||||
{ locale: "es", match: (language) => language.startsWith("es") },
|
||||
{ locale: "fr", match: (language) => language.startsWith("fr") },
|
||||
{ locale: "da", match: (language) => language.startsWith("da") },
|
||||
{ locale: "ja", match: (language) => language.startsWith("ja") },
|
||||
{ locale: "pl", match: (language) => language.startsWith("pl") },
|
||||
{ locale: "ru", match: (language) => language.startsWith("ru") },
|
||||
{ locale: "uk", match: (language) => language.startsWith("uk") },
|
||||
{ locale: "ar", match: (language) => language.startsWith("ar") },
|
||||
{
|
||||
locale: "no",
|
||||
match: (language) => language.startsWith("no") || language.startsWith("nb") || language.startsWith("nn"),
|
||||
},
|
||||
{ locale: "br", match: (language) => language.startsWith("pt") },
|
||||
{ locale: "th", match: (language) => language.startsWith("th") },
|
||||
{ locale: "bs", match: (language) => language.startsWith("bs") },
|
||||
{ locale: "tr", match: (language) => language.startsWith("tr") },
|
||||
{ locale: "hi", match: (language) => language.startsWith("hi") },
|
||||
{ locale: "nl", match: (language) => language.startsWith("nl") },
|
||||
{ locale: "id", match: (language) => language.startsWith("id") },
|
||||
{ locale: "vi", match: (language) => language.startsWith("vi") },
|
||||
{ locale: "it", match: (language) => language.startsWith("it") },
|
||||
{ locale: "ur", match: (language) => language.startsWith("ur") },
|
||||
{
|
||||
locale: "pa",
|
||||
match: (language) => language.startsWith("pa") && (language.includes("arab") || language.includes("-pk")),
|
||||
},
|
||||
{
|
||||
locale: "az",
|
||||
match: (language) => language.startsWith("az") && !language.includes("arab") && !language.includes("cyrl"),
|
||||
},
|
||||
{ locale: "fi", match: (language) => language.startsWith("fi") },
|
||||
{ locale: "sv", match: (language) => language.startsWith("sv") },
|
||||
]
|
||||
|
||||
function detectLocale(): Locale {
|
||||
if (typeof navigator !== "object") return "en"
|
||||
|
||||
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
||||
for (const language of languages) {
|
||||
if (!language) continue
|
||||
const normalized = language.toLowerCase()
|
||||
const match = localeMatchers.find((entry) => entry.match(normalized))
|
||||
if (match) return match.locale
|
||||
}
|
||||
|
||||
return "en"
|
||||
return detectDesktopNativeLocale(navigator.languages?.length ? navigator.languages : [navigator.language])
|
||||
}
|
||||
|
||||
export function normalizeLocale(value: string): Locale {
|
||||
@@ -198,7 +151,17 @@ function readStoredLocale() {
|
||||
}
|
||||
|
||||
const warm = readStoredLocale() ?? detectLocale()
|
||||
if (warm !== "en") void loadDict(warm)
|
||||
const initialLocale =
|
||||
warm === "en"
|
||||
? Promise.resolve(warm)
|
||||
: loadDict(warm).then(
|
||||
() => warm,
|
||||
() => "en" as const,
|
||||
)
|
||||
|
||||
export function loadInitialLocale() {
|
||||
return initialLocale
|
||||
}
|
||||
|
||||
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
|
||||
name: "Language",
|
||||
@@ -244,7 +207,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
createEffect(() => {
|
||||
if (typeof document !== "object") return
|
||||
const value = locale()
|
||||
document.documentElement.lang = value
|
||||
document.documentElement.lang = intl()
|
||||
document.documentElement.dir = direction()
|
||||
document.cookie = cookie(value)
|
||||
})
|
||||
|
||||
+28
-25
@@ -3,6 +3,7 @@
|
||||
import * as Sentry from "@sentry/solid"
|
||||
import { render } from "solid-js/web"
|
||||
import { AppBaseProviders, AppInterface } from "@/app"
|
||||
import { loadInitialLocale } from "@/context/language"
|
||||
import { type Platform, PlatformProvider } from "@/context/platform"
|
||||
import { createBrowserDraftStore } from "@/utils/draft-store"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
@@ -149,29 +150,31 @@ if (import.meta.env.VITE_SENTRY_DSN) {
|
||||
}
|
||||
|
||||
if (root instanceof HTMLElement) {
|
||||
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
|
||||
clearAuthToken()
|
||||
const server: ServerConnection.Http = {
|
||||
type: "http",
|
||||
authToken: !!auth,
|
||||
http: {
|
||||
url: getCurrentUrl(),
|
||||
...auth,
|
||||
},
|
||||
}
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders>
|
||||
<AppInterface
|
||||
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
|
||||
canonicalLocalServer={ServerConnection.key(server)}
|
||||
servers={[server]}
|
||||
disableHealthCheck
|
||||
/>
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
),
|
||||
root,
|
||||
)
|
||||
void loadInitialLocale().then((locale) => {
|
||||
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
|
||||
clearAuthToken()
|
||||
const server: ServerConnection.Http = {
|
||||
type: "http",
|
||||
authToken: !!auth,
|
||||
http: {
|
||||
url: getCurrentUrl(),
|
||||
...auth,
|
||||
},
|
||||
}
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders locale={locale}>
|
||||
<AppInterface
|
||||
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
|
||||
canonicalLocalServer={ServerConnection.key(server)}
|
||||
servers={[server]}
|
||||
disableHealthCheck
|
||||
/>
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
),
|
||||
root,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ import {
|
||||
DESKTOP_NATIVE_KEYS,
|
||||
DESKTOP_NATIVE_LABELS,
|
||||
DESKTOP_NATIVE_LOCALES,
|
||||
DESKTOP_NATIVE_LOCALE_TAGS,
|
||||
detectDesktopNativeLocale,
|
||||
DESKTOP_NATIVE_MAX_PAYLOAD_BYTES,
|
||||
formatDesktopNativeMessage,
|
||||
parseDesktopNativeBundle,
|
||||
@@ -41,6 +43,40 @@ describe("desktop native translations", () => {
|
||||
"Azərbaycanca",
|
||||
"Suomi",
|
||||
"Svenska",
|
||||
"አማርኛ",
|
||||
"Български",
|
||||
"বাংলা",
|
||||
"Català",
|
||||
"Čeština",
|
||||
"ދިވެހި",
|
||||
"རྫོང་ཁ",
|
||||
"Ελληνικά",
|
||||
"Eesti",
|
||||
"فارسی",
|
||||
"Føroyskt",
|
||||
"Hrvatski",
|
||||
"Magyar",
|
||||
"Հայերեն",
|
||||
"Íslenska",
|
||||
"ქართული",
|
||||
"ខ្មែរ",
|
||||
"ລາວ",
|
||||
"Lietuvių",
|
||||
"Latviešu",
|
||||
"Македонски",
|
||||
"Монгол",
|
||||
"Bahasa Melayu",
|
||||
"မြန်မာ",
|
||||
"नेपाली",
|
||||
"Română",
|
||||
"සිංහල",
|
||||
"Slovenčina",
|
||||
"Slovenščina",
|
||||
"Shqip",
|
||||
"Српски",
|
||||
"Тоҷикӣ",
|
||||
"Türkmençe",
|
||||
"Oʻzbekcha",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -74,3 +110,42 @@ describe("desktop native translations", () => {
|
||||
expect(formatDesktopNativeMessage("{{known}} {{unknown}}", { known: "yes" })).toBe("yes {{unknown}}")
|
||||
})
|
||||
})
|
||||
|
||||
describe("desktop native locale detection", () => {
|
||||
test("follows preference order and skips invalid or unsupported tags", () => {
|
||||
expect(detectDesktopNativeLocale(["not_a_locale", "fr-FR"])).toBe("fr")
|
||||
expect(detectDesktopNativeLocale(["eo", "de-DE"])).toBe("de")
|
||||
})
|
||||
|
||||
test("uses Unicode likely subtags for script-sensitive bundles", () => {
|
||||
expect(detectDesktopNativeLocale(["zh-TW"])).toBe("zht")
|
||||
expect(detectDesktopNativeLocale(["zh-SG"])).toBe("zh")
|
||||
expect(detectDesktopNativeLocale(["pa-PK"])).toBe("pa")
|
||||
expect(detectDesktopNativeLocale(["pa-IN", "fr"])).toBe("fr")
|
||||
expect(detectDesktopNativeLocale(["az-Cyrl", "de"])).toBe("de")
|
||||
expect(detectDesktopNativeLocale(["sr-Cyrl"])).toBe("sr")
|
||||
expect(detectDesktopNativeLocale(["sr-Latn", "en"])).toBe("en")
|
||||
expect(detectDesktopNativeLocale(["uz-Latn"])).toBe("uz")
|
||||
})
|
||||
|
||||
test("recognizes Norwegian language tags", () => {
|
||||
expect(detectDesktopNativeLocale(["no"])).toBe("no")
|
||||
expect(detectDesktopNativeLocale(["nb-NO"])).toBe("no")
|
||||
expect(detectDesktopNativeLocale(["nn-NO"])).toBe("no")
|
||||
})
|
||||
})
|
||||
|
||||
describe("desktop native ICU data", () => {
|
||||
test("accepts every locale in standard Intl formatters", () => {
|
||||
for (const locale of DESKTOP_NATIVE_LOCALES) {
|
||||
const tag = DESKTOP_NATIVE_LOCALE_TAGS[locale]
|
||||
expect(() => new Intl.Locale(tag), `${locale} locale`).not.toThrow()
|
||||
expect(() => new Intl.NumberFormat(tag), `${locale} number`).not.toThrow()
|
||||
expect(() => new Intl.DateTimeFormat(tag), `${locale} date`).not.toThrow()
|
||||
expect(() => new Intl.PluralRules(tag), `${locale} plural`).not.toThrow()
|
||||
expect(() => new Intl.ListFormat(tag), `${locale} list`).not.toThrow()
|
||||
expect(() => new Intl.DisplayNames(tag, { type: "language" }), `${locale} names`).not.toThrow()
|
||||
expect(() => new Intl.Segmenter(tag), `${locale} segmenter`).not.toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,40 @@ export const DESKTOP_NATIVE_LOCALES = [
|
||||
"az",
|
||||
"fi",
|
||||
"sv",
|
||||
"am",
|
||||
"bg",
|
||||
"bn",
|
||||
"ca",
|
||||
"cs",
|
||||
"dv",
|
||||
"dz",
|
||||
"el",
|
||||
"et",
|
||||
"fa",
|
||||
"fo",
|
||||
"hr",
|
||||
"hu",
|
||||
"hy",
|
||||
"is",
|
||||
"ka",
|
||||
"km",
|
||||
"lo",
|
||||
"lt",
|
||||
"lv",
|
||||
"mk",
|
||||
"mn",
|
||||
"ms",
|
||||
"my",
|
||||
"ne",
|
||||
"ro",
|
||||
"si",
|
||||
"sk",
|
||||
"sl",
|
||||
"sq",
|
||||
"sr",
|
||||
"tg",
|
||||
"tk",
|
||||
"uz",
|
||||
] as const
|
||||
|
||||
export type DesktopNativeLocale = (typeof DESKTOP_NATIVE_LOCALES)[number]
|
||||
@@ -60,6 +94,131 @@ export const DESKTOP_NATIVE_LABELS: Record<DesktopNativeLocale, string> = {
|
||||
az: "Azərbaycanca",
|
||||
fi: "Suomi",
|
||||
sv: "Svenska",
|
||||
am: "አማርኛ",
|
||||
bg: "Български",
|
||||
bn: "বাংলা",
|
||||
ca: "Català",
|
||||
cs: "Čeština",
|
||||
dv: "ދިވެހި",
|
||||
dz: "རྫོང་ཁ",
|
||||
el: "Ελληνικά",
|
||||
et: "Eesti",
|
||||
fa: "فارسی",
|
||||
fo: "Føroyskt",
|
||||
hr: "Hrvatski",
|
||||
hu: "Magyar",
|
||||
hy: "Հայերեն",
|
||||
is: "Íslenska",
|
||||
ka: "ქართული",
|
||||
km: "ខ្មែរ",
|
||||
lo: "ລາວ",
|
||||
lt: "Lietuvių",
|
||||
lv: "Latviešu",
|
||||
mk: "Македонски",
|
||||
mn: "Монгол",
|
||||
ms: "Bahasa Melayu",
|
||||
my: "မြန်မာ",
|
||||
ne: "नेपाली",
|
||||
ro: "Română",
|
||||
si: "සිංහල",
|
||||
sk: "Slovenčina",
|
||||
sl: "Slovenščina",
|
||||
sq: "Shqip",
|
||||
sr: "Српски",
|
||||
tg: "Тоҷикӣ",
|
||||
tk: "Türkmençe",
|
||||
uz: "Oʻzbekcha",
|
||||
}
|
||||
|
||||
export const DESKTOP_NATIVE_LOCALE_TAGS: Record<DesktopNativeLocale, string> = {
|
||||
en: "en",
|
||||
zh: "zh-Hans",
|
||||
zht: "zh-Hant",
|
||||
ko: "ko",
|
||||
de: "de",
|
||||
es: "es",
|
||||
fr: "fr",
|
||||
da: "da",
|
||||
ja: "ja",
|
||||
pl: "pl",
|
||||
ru: "ru",
|
||||
uk: "uk",
|
||||
bs: "bs",
|
||||
ar: "ar",
|
||||
no: "nb-NO",
|
||||
br: "pt-BR",
|
||||
th: "th",
|
||||
tr: "tr",
|
||||
hi: "hi-IN",
|
||||
nl: "nl-NL",
|
||||
id: "id-ID",
|
||||
vi: "vi-VN",
|
||||
it: "it-IT",
|
||||
ur: "ur-PK",
|
||||
pa: "pa-Arab-PK",
|
||||
az: "az-Latn-AZ",
|
||||
fi: "fi-FI",
|
||||
sv: "sv-SE",
|
||||
am: "am-ET",
|
||||
bg: "bg-BG",
|
||||
bn: "bn-BD",
|
||||
ca: "ca-AD",
|
||||
cs: "cs-CZ",
|
||||
dv: "dv-MV",
|
||||
dz: "dz-BT",
|
||||
el: "el-GR",
|
||||
et: "et-EE",
|
||||
fa: "fa-IR",
|
||||
fo: "fo-FO",
|
||||
hr: "hr-HR",
|
||||
hu: "hu-HU",
|
||||
hy: "hy-AM",
|
||||
is: "is-IS",
|
||||
ka: "ka-GE",
|
||||
km: "km-KH",
|
||||
lo: "lo-LA",
|
||||
lt: "lt-LT",
|
||||
lv: "lv-LV",
|
||||
mk: "mk-MK",
|
||||
mn: "mn-MN",
|
||||
ms: "ms-MY",
|
||||
my: "my-MM",
|
||||
ne: "ne-NP",
|
||||
ro: "ro-RO",
|
||||
si: "si-LK",
|
||||
sk: "sk-SK",
|
||||
sl: "sl-SI",
|
||||
sq: "sq-AL",
|
||||
sr: "sr-Cyrl-RS",
|
||||
tg: "tg-Cyrl-TJ",
|
||||
tk: "tk-Latn-TM",
|
||||
uz: "uz-Latn-UZ",
|
||||
}
|
||||
|
||||
export function detectDesktopNativeLocale(languages: readonly string[]): DesktopNativeLocale {
|
||||
for (const language of languages) {
|
||||
const source = locale(language)
|
||||
if (!source) continue
|
||||
if (["no", "nb", "nn"].includes(source.language)) return "no"
|
||||
const match = DESKTOP_NATIVE_LOCALES.find((candidate) => {
|
||||
const target = locale(DESKTOP_NATIVE_LOCALE_TAGS[candidate])
|
||||
return target?.language === source.language && target.script === source.script
|
||||
})
|
||||
if (match) return match
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
export function desktopNativePluralCategories(locale: DesktopNativeLocale) {
|
||||
return new Intl.PluralRules(DESKTOP_NATIVE_LOCALE_TAGS[locale]).resolvedOptions().pluralCategories
|
||||
}
|
||||
|
||||
function locale(value: string) {
|
||||
try {
|
||||
return new Intl.Locale(value).maximize()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const DESKTOP_NATIVE_ENGLISH = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { desktopNativePluralCategories } from "./desktop-native"
|
||||
|
||||
const appLocales = [
|
||||
"ar",
|
||||
@@ -28,19 +29,51 @@ const appLocales = [
|
||||
"az",
|
||||
"fi",
|
||||
"sv",
|
||||
"am",
|
||||
"bg",
|
||||
"bn",
|
||||
"ca",
|
||||
"cs",
|
||||
"dv",
|
||||
"dz",
|
||||
"el",
|
||||
"et",
|
||||
"fa",
|
||||
"fo",
|
||||
"hr",
|
||||
"hu",
|
||||
"hy",
|
||||
"is",
|
||||
"ka",
|
||||
"km",
|
||||
"lo",
|
||||
"lt",
|
||||
"lv",
|
||||
"mk",
|
||||
"mn",
|
||||
"ms",
|
||||
"my",
|
||||
"ne",
|
||||
"ro",
|
||||
"si",
|
||||
"sk",
|
||||
"sl",
|
||||
"sq",
|
||||
"sr",
|
||||
"tg",
|
||||
"tk",
|
||||
"uz",
|
||||
] as const
|
||||
const desktopLocales = appLocales
|
||||
const pluralCategories: Partial<Record<(typeof appLocales)[number], readonly string[]>> = {
|
||||
ar: ["zero", "two", "few", "many"],
|
||||
br: ["many"],
|
||||
bs: ["few"],
|
||||
es: ["many"],
|
||||
fr: ["many"],
|
||||
it: ["many"],
|
||||
pl: ["few", "many"],
|
||||
ru: ["few", "many"],
|
||||
uk: ["few", "many"],
|
||||
}
|
||||
const pluralCategories = new Map(
|
||||
appLocales.map(
|
||||
(locale) =>
|
||||
[
|
||||
locale,
|
||||
desktopNativePluralCategories(locale).filter((category) => category !== "one" && category !== "other"),
|
||||
] as const,
|
||||
),
|
||||
)
|
||||
|
||||
const domains = [
|
||||
{
|
||||
@@ -74,7 +107,7 @@ describe("i18n parity", () => {
|
||||
.filter((key) => !Object.hasOwn(source, key))
|
||||
.sort()
|
||||
const expected = pluralFamilies(source)
|
||||
.flatMap((key) => (pluralCategories[locale] ?? []).map((category) => `${key}.${category}`))
|
||||
.flatMap((key) => (pluralCategories.get(locale) ?? []).map((category) => `${key}.${category}`))
|
||||
.sort()
|
||||
expect({ domain: domain.name, locale, missing, extra }).toEqual({
|
||||
domain: domain.name,
|
||||
@@ -95,7 +128,7 @@ describe("i18n parity", () => {
|
||||
(key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(),
|
||||
)
|
||||
const pluralMismatched = pluralFamilies(source).flatMap((key) =>
|
||||
(pluralCategories[locale] ?? [])
|
||||
(pluralCategories.get(locale) ?? [])
|
||||
.map((category) => `${key}.${category}`)
|
||||
.filter((variant) => placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join()),
|
||||
)
|
||||
@@ -144,12 +177,12 @@ describe("i18n plural parity", () => {
|
||||
for (const locale of domain.locales) {
|
||||
const target = await dictionary(domain.target(locale))
|
||||
const missing = families.flatMap((key) =>
|
||||
(pluralCategories[locale] ?? [])
|
||||
(pluralCategories.get(locale) ?? [])
|
||||
.map((category) => `${key}.${category}`)
|
||||
.filter((variant) => !Object.hasOwn(target, variant)),
|
||||
)
|
||||
const mismatched = families.flatMap((key) =>
|
||||
(pluralCategories[locale] ?? [])
|
||||
(pluralCategories.get(locale) ?? [])
|
||||
.map((category) => `${key}.${category}`)
|
||||
.filter(
|
||||
(variant) =>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user