mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-08 08:35:22 +00:00
feat(app): add global locale coverage (#40992)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
This commit is contained in:
parent
4abddbbfdd
commit
741244b69d
143 changed files with 47929 additions and 334 deletions
|
|
@ -15,7 +15,10 @@ Requirements:
|
|||
- Preserve existing translations unless they have a listed placeholder mismatch.
|
||||
- Preserve meaning, intent, tone, capitalization, punctuation, whitespace, and formatting.
|
||||
- Preserve technical terms and artifacts exactly: OpenCode, API names, identifiers, code, commands, flags, paths, URLs, versions, error messages, config keys, and placeholder tokens.
|
||||
- For developer-facing terminology, use the words already recognized by the target language's developer community instead of literal dictionary translations. Check at least two maintained localized developer corpora among Firefox, KDE, and VS Code when they are available; use Microsoft or official language authorities as supporting evidence.
|
||||
- Translate complete phrases in their product context. Check recurring concepts such as session, prompt, agent, model, provider, fork, shell, terminal, workspace, worktree, context, permission, tool, and server for consistent, grammatical usage. Keep an established English borrowing when the developer corpora do.
|
||||
- If maintained target-language corpora are sparse or disagree, choose conservative wording and identify the uncertain terms in the final response instead of inventing terminology.
|
||||
- Apply the locale glossary included in the request.
|
||||
- `ui.sessionTurn.diffs.changed.one` and `ui.sessionTurn.diffs.changed.other` are complete count phrases. Preserve `{{count}}` and translate the whole phrase naturally rather than composing translated fragments.
|
||||
- Use only read, glob, grep, and edit tools. Do not run commands or delegate work.
|
||||
- Use only read, glob, grep, webfetch, websearch, and edit tools. Do not run commands or delegate work.
|
||||
- Finish only when every requested key is synchronized and no other file has changed.
|
||||
|
|
|
|||
|
|
@ -65,7 +65,16 @@ describe("translate app", () => {
|
|||
"packages/ui/src/i18n/fr.ts",
|
||||
"packages/desktop/src/renderer/i18n/fr.ts",
|
||||
])
|
||||
expect(targetFiles("tr")).toEqual(["packages/app/src/i18n/tr.ts", "packages/ui/src/i18n/tr.ts"])
|
||||
expect(targetFiles("tr")).toEqual([
|
||||
"packages/app/src/i18n/tr.ts",
|
||||
"packages/ui/src/i18n/tr.ts",
|
||||
"packages/desktop/src/renderer/i18n/tr.ts",
|
||||
])
|
||||
expect(targetFiles("dv")).toEqual([
|
||||
"packages/app/src/i18n/dv.ts",
|
||||
"packages/ui/src/i18n/dv.ts",
|
||||
"packages/desktop/src/renderer/i18n/dv.ts",
|
||||
])
|
||||
})
|
||||
|
||||
test("maps product locale codes to their glossaries", () => {
|
||||
|
|
@ -83,6 +92,48 @@ describe("translate app", () => {
|
|||
).toEqual({ missing: ["missing"], extra: ["extra"], placeholders: ["changed"] })
|
||||
})
|
||||
|
||||
test("accepts locale-specific CLDR plural variants", () => {
|
||||
expect(
|
||||
findDrift(
|
||||
{ "files.one": "{{count}} file", "files.other": "{{count}} files" },
|
||||
{
|
||||
"files.one": "{{count}} ملف",
|
||||
"files.two": "ملفان: {{count}}",
|
||||
"files.few": "{{count}} ملفات",
|
||||
"files.many": "{{count}} ملفًا",
|
||||
"files.zero": "{{count}} ملف",
|
||||
"files.other": "{{count}} ملف",
|
||||
},
|
||||
"ar",
|
||||
),
|
||||
).toEqual({ missing: [], extra: [], placeholders: [] })
|
||||
})
|
||||
|
||||
test("reports missing locale-specific CLDR plural variants", () => {
|
||||
const drift = findDrift(
|
||||
{ "files.one": "{{count}} file", "files.other": "{{count}} files" },
|
||||
{ "files.one": "{{count}} ملف", "files.other": "{{count}} ملف" },
|
||||
"ar",
|
||||
)
|
||||
expect(drift.missing).toContain("files.few")
|
||||
})
|
||||
|
||||
test("reports placeholder drift in locale-specific CLDR plural variants", () => {
|
||||
const drift = findDrift(
|
||||
{ "files.one": "{{count}} file", "files.other": "{{count}} files" },
|
||||
{
|
||||
"files.one": "{{count}} ملف",
|
||||
"files.two": "ملفان: {{count}}",
|
||||
"files.few": "{{count}} ملفات",
|
||||
"files.many": "ملفات كثيرة",
|
||||
"files.zero": "{{count}} ملف",
|
||||
"files.other": "{{count}} ملف",
|
||||
},
|
||||
"ar",
|
||||
)
|
||||
expect(drift.placeholders).toContain("files.many")
|
||||
})
|
||||
|
||||
test("runs work with the requested maximum concurrency", async () => {
|
||||
const active = new Set<number>()
|
||||
const peaks: number[] = []
|
||||
|
|
@ -143,6 +194,8 @@ opencode/next
|
|||
expect(config.share).toBe("disabled")
|
||||
expect(config.formatter).toBe(false)
|
||||
expect(config.lsp).toBe(false)
|
||||
expect(config.agent["translate-app-fr"].permission.webfetch).toBe("allow")
|
||||
expect(config.agent["translate-app-fr"].permission.websearch).toBe("allow")
|
||||
expect(config.agent["translate-app-fr"].permission.edit).toEqual({
|
||||
"*": "deny",
|
||||
"packages/app/src/i18n/fr.ts": "allow",
|
||||
|
|
|
|||
|
|
@ -3,27 +3,14 @@
|
|||
import path from "path"
|
||||
import { parseArgs } from "util"
|
||||
import { pathToFileURL } from "url"
|
||||
import {
|
||||
DESKTOP_NATIVE_LOCALES,
|
||||
desktopNativePluralCategories,
|
||||
type DesktopNativeLocale,
|
||||
} from "../packages/app/src/i18n/desktop-native"
|
||||
|
||||
const locales = [
|
||||
"ar",
|
||||
"br",
|
||||
"bs",
|
||||
"da",
|
||||
"de",
|
||||
"es",
|
||||
"fr",
|
||||
"ja",
|
||||
"ko",
|
||||
"no",
|
||||
"pl",
|
||||
"ru",
|
||||
"uk",
|
||||
"th",
|
||||
"tr",
|
||||
"zh",
|
||||
"zht",
|
||||
] as const
|
||||
type Locale = (typeof locales)[number]
|
||||
type Locale = Exclude<DesktopNativeLocale, "en">
|
||||
const locales = DESKTOP_NATIVE_LOCALES.filter((locale): locale is Locale => locale !== "en")
|
||||
|
||||
const languages = {
|
||||
ar: "Arabic",
|
||||
|
|
@ -41,6 +28,50 @@ const languages = {
|
|||
uk: "Ukrainian",
|
||||
th: "Thai",
|
||||
tr: "Turkish",
|
||||
hi: "Hindi",
|
||||
nl: "Dutch",
|
||||
id: "Indonesian",
|
||||
vi: "Vietnamese",
|
||||
it: "Italian",
|
||||
ur: "Urdu",
|
||||
pa: "Punjabi (Shahmukhi)",
|
||||
az: "Azerbaijani (Latin)",
|
||||
fi: "Finnish",
|
||||
sv: "Swedish",
|
||||
am: "Amharic",
|
||||
bg: "Bulgarian",
|
||||
bn: "Bengali",
|
||||
ca: "Catalan",
|
||||
cs: "Czech",
|
||||
dv: "Dhivehi",
|
||||
dz: "Dzongkha",
|
||||
el: "Greek",
|
||||
et: "Estonian",
|
||||
fa: "Persian",
|
||||
fo: "Faroese",
|
||||
hr: "Croatian",
|
||||
hu: "Hungarian",
|
||||
hy: "Armenian",
|
||||
is: "Icelandic",
|
||||
ka: "Georgian",
|
||||
km: "Khmer",
|
||||
lo: "Lao",
|
||||
lt: "Lithuanian",
|
||||
lv: "Latvian",
|
||||
mk: "Macedonian",
|
||||
mn: "Mongolian (Cyrillic)",
|
||||
ms: "Malay",
|
||||
my: "Burmese",
|
||||
ne: "Nepali",
|
||||
ro: "Romanian",
|
||||
si: "Sinhala",
|
||||
sk: "Slovak",
|
||||
sl: "Slovenian",
|
||||
sq: "Albanian",
|
||||
sr: "Serbian (Cyrillic)",
|
||||
tg: "Tajik",
|
||||
tk: "Turkmen",
|
||||
uz: "Uzbek (Latin)",
|
||||
zh: "Simplified Chinese",
|
||||
zht: "Traditional Chinese",
|
||||
} as const satisfies Record<Locale, string>
|
||||
|
|
@ -49,7 +80,7 @@ type Dictionary = Record<string, string>
|
|||
type Drift = ReturnType<typeof findDrift>
|
||||
type Domain = { name: string; source: string; target: string; drift: Drift }
|
||||
|
||||
const desktopLocales = new Set<Locale>(locales.filter((locale) => locale !== "th" && locale !== "tr"))
|
||||
const desktopLocales = new Set<Locale>(locales)
|
||||
const root = path.resolve(import.meta.dir, "..")
|
||||
|
||||
export function parseTranslationArgs(args: string[]) {
|
||||
|
|
@ -97,13 +128,33 @@ export function glossaryFile(locale: Locale) {
|
|||
return `.opencode/glossary/${locale}.md`
|
||||
}
|
||||
|
||||
export function findDrift(source: Dictionary, target: Dictionary) {
|
||||
export function findDrift(source: Dictionary, target: Dictionary, locale?: Locale) {
|
||||
const pluralVariants = new Map(
|
||||
(locale
|
||||
? pluralFamilies(source).flatMap((key) =>
|
||||
desktopNativePluralCategories(locale)
|
||||
.filter((category) => category !== "one" && category !== "other")
|
||||
.map((category) => [`${key}.${category}`, `${key}.other`] as const),
|
||||
)
|
||||
: []) as ReadonlyArray<readonly [string, string]>,
|
||||
)
|
||||
return {
|
||||
missing: Object.keys(source).filter((key) => !Object.hasOwn(target, key)),
|
||||
extra: Object.keys(target).filter((key) => !Object.hasOwn(source, key)),
|
||||
placeholders: Object.keys(source).filter(
|
||||
(key) => Object.hasOwn(target, key) && tokens(source[key]).join() !== tokens(target[key]).join(),
|
||||
),
|
||||
missing: [
|
||||
...Object.keys(source).filter((key) => !Object.hasOwn(target, key)),
|
||||
...Array.from(pluralVariants.keys()).filter((key) => !Object.hasOwn(target, key)),
|
||||
],
|
||||
extra: Object.keys(target).filter((key) => !Object.hasOwn(source, key) && !pluralVariants.has(key)),
|
||||
placeholders: [
|
||||
...Object.keys(source).filter(
|
||||
(key) => Object.hasOwn(target, key) && tokens(source[key]).join() !== tokens(target[key]).join(),
|
||||
),
|
||||
...Array.from(pluralVariants.entries())
|
||||
.filter(
|
||||
([key, sourceKey]) =>
|
||||
Object.hasOwn(target, key) && tokens(source[sourceKey]).join() !== tokens(target[key]).join(),
|
||||
)
|
||||
.map(([key]) => key),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,6 +212,8 @@ export function translationConfig(agent: string, model: string, targets: string[
|
|||
read: "allow" as const,
|
||||
glob: "allow" as const,
|
||||
grep: "allow" as const,
|
||||
webfetch: "allow" as const,
|
||||
websearch: "allow" as const,
|
||||
edit: Object.fromEntries([["*", "deny"], ...targets.map((target) => [target, "allow"])]),
|
||||
},
|
||||
},
|
||||
|
|
@ -315,7 +368,7 @@ async function inspect(locale: Locale) {
|
|||
name: target.includes("packages/app/") ? "app" : target.includes("packages/ui/") ? "ui" : "desktop",
|
||||
source,
|
||||
target,
|
||||
drift: findDrift(dictionaries[0], dictionaries[1]),
|
||||
drift: findDrift(dictionaries[0], dictionaries[1], locale),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
|
@ -458,6 +511,17 @@ function isDictionary(value: unknown): value is Dictionary {
|
|||
return Object.values(value).every((item) => typeof item === "string")
|
||||
}
|
||||
|
||||
function pluralFamilies(dictionary: Dictionary) {
|
||||
return Object.keys(dictionary)
|
||||
.filter(
|
||||
(key) =>
|
||||
key.endsWith(".one") &&
|
||||
dictionary[key].includes("{{count}}") &&
|
||||
dictionary[`${key.slice(0, -4)}.other`]?.includes("{{count}}"),
|
||||
)
|
||||
.map((key) => key.slice(0, -4))
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue