Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@
/skill-guides/*.md text eol=lf
/skills/*/SKILL.md text eol=lf
/src/cli/bundled-skill-guides.ts text eol=lf
/src/renderer/src/i18n/locales/zh.json text eol=lf
/src/renderer/src/i18n/locales/zh-TW.json text eol=lf
7 changes: 7 additions & 0 deletions config/scripts/bootstrap-locale-catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ function parseLocaleArg(argv) {
}

export async function main(root = process.cwd(), locale = parseLocaleArg(process.argv)) {
if (locale === 'zh') {
console.error(
'Simplified Chinese is human-reviewed only. Add a zh key override, then run `pnpm run repair:locale-catalog -- --locale zh`.'
)
return 1
}

const config = LOCALE_CONFIG[locale]
if (!config) {
console.error(
Expand Down
16 changes: 16 additions & 0 deletions config/scripts/bootstrap-locale-catalog.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it, vi } from 'vitest'

import { main } from './bootstrap-locale-catalog.mjs'

describe('bootstrap-locale-catalog', () => {
it('rejects machine translation for Simplified Chinese', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})

await expect(main('/path/that/must/not/be-read', 'zh')).resolves.toBe(1)
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('Simplified Chinese is human-reviewed only')
)

consoleError.mockRestore()
})
})
84 changes: 84 additions & 0 deletions config/scripts/generate-zh-tw-catalog.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'

import OpenCC from 'opencc-js'

const SOURCE_CATALOG_PATH = path.join('src', 'renderer', 'src', 'i18n', 'locales', 'zh.json')
const OUTPUT_CATALOG_PATH = path.join('src', 'renderer', 'src', 'i18n', 'locales', 'zh-TW.json')
const INTERPOLATION_PART_RE = /(\{\{[^{}]+\}\})/g
const WHOLE_INTERPOLATION_RE = /^\{\{[^{}]+\}\}$/
const toTraditionalChinese = OpenCC.Converter({ from: 'cn', to: 'tw' })

export function convertSimplifiedText(value) {
// Why: interpolation identifiers are an application contract, not user-visible copy.
return value
.split(INTERPOLATION_PART_RE)
.map((part) => (WHOLE_INTERPOLATION_RE.test(part) ? part : toTraditionalChinese(part)))
.join('')
}

export function convertSimplifiedCatalog(value) {
if (typeof value === 'string') {
return convertSimplifiedText(value)
}
if (Array.isArray(value)) {
return value.map((item) => convertSimplifiedCatalog(item))
}
if (typeof value === 'object' && value !== null) {
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [key, convertSimplifiedCatalog(child)])
)
}
return value
}

export function serializeCatalog(catalog) {
return `${JSON.stringify(catalog, null, 2)}\n`
}

async function expectedCatalogText(root) {
const source = JSON.parse(await fs.readFile(path.join(root, SOURCE_CATALOG_PATH), 'utf8'))
return serializeCatalog(convertSimplifiedCatalog(source))
}

export async function main(root = process.cwd(), argv = process.argv.slice(2)) {
const write = argv.includes('--write')
const check = argv.includes('--check')
if (write === check) {
console.error('Pass exactly one of --write or --check.')
return 1
}

const outputPath = path.join(root, OUTPUT_CATALOG_PATH)
const expected = await expectedCatalogText(root)

if (write) {
await fs.writeFile(outputPath, expected, 'utf8')
console.log(`Generated ${OUTPUT_CATALOG_PATH} from ${SOURCE_CATALOG_PATH}.`)
return 0
}

let actual
try {
actual = await fs.readFile(outputPath, 'utf8')
} catch (error) {
if (error?.code !== 'ENOENT') {
throw error
}
}

if (actual !== expected) {
console.error(`${OUTPUT_CATALOG_PATH} is stale or missing.`)
console.error('Run `pnpm run generate:zh-tw-catalog` and commit the generated catalog.')
return 1
}

console.log(`Verified ${OUTPUT_CATALOG_PATH} is generated from ${SOURCE_CATALOG_PATH}.`)
return 0
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}
60 changes: 60 additions & 0 deletions config/scripts/generate-zh-tw-catalog.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import fs from 'node:fs/promises'
import path from 'node:path'

import { describe, expect, it } from 'vitest'

import {
convertSimplifiedCatalog,
convertSimplifiedText,
serializeCatalog
} from './generate-zh-tw-catalog.mjs'

const LOCALES_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales')
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g

function flattenStrings(value, prefix = '', entries = new Map()) {
if (typeof value === 'string') {
entries.set(prefix, value)
return entries
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return entries
}
for (const [key, child] of Object.entries(value)) {
flattenStrings(child, prefix ? `${prefix}.${key}` : key, entries)
}
return entries
}

async function readCatalog(fileName) {
return JSON.parse(await fs.readFile(path.join(LOCALES_DIR, fileName), 'utf8'))
}

describe('Traditional Chinese catalog generation', () => {
it('uses deterministic OpenCC conversion while preserving interpolation identifiers', () => {
expect(convertSimplifiedText('汉语 {{用户}}')).toBe('漢語 {{用户}}')
expect(convertSimplifiedCatalog({ label: '设置', count: 2 })).toEqual({
label: '設置',
count: 2
})
})

it('matches the committed catalog exactly', async () => {
const simplified = await readCatalog('zh.json')
const traditionalText = await fs.readFile(path.join(LOCALES_DIR, 'zh-TW.json'), 'utf8')

expect(serializeCatalog(convertSimplifiedCatalog(simplified))).toBe(traditionalText)
})

it('keeps catalog keys and placeholders identical to Simplified Chinese', async () => {
const simplifiedEntries = flattenStrings(await readCatalog('zh.json'))
const traditionalEntries = flattenStrings(await readCatalog('zh-TW.json'))

expect([...traditionalEntries.keys()]).toEqual([...simplifiedEntries.keys()])
for (const [key, simplifiedValue] of simplifiedEntries) {
expect(traditionalEntries.get(key)?.match(PLACEHOLDER_RE) ?? []).toEqual(
simplifiedValue.match(PLACEHOLDER_RE) ?? []
)
}
})
})
11 changes: 11 additions & 0 deletions config/scripts/locale-cjk-latin-spaced-terms.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,22 @@ export const CJK_LATIN_SPACED_TERMS = [
'Claude Code',
'Codex',
'Gemini',
'Hermes',
'Kimi',
'OpenCode',
'Orca',
'Cursor',
'Bitbucket',
'OpenAI',
'Google',
'Chrome',
'DuckDuckGo',
'Atlassian',
'macOS',
'Ubuntu',
'Debian',
'WebGL',
'SCSS',
'Tailscale',
'Kagi',
'SSH',
Expand Down
12 changes: 7 additions & 5 deletions config/scripts/locale-phrase-fixes.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { KO_PHRASE_FIXES_ROUND4 } from './locale-ko-phrase-fixes.mjs'
import { ZH_HUMAN_PHRASE_FIXES } from './locale-zh-phrase-fixes-human.mjs'
import { ZH_PHRASE_FIXES_ROUND5 } from './locale-zh-phrase-fixes-round5.mjs'

import { JA_PHRASE_FIXES } from './locale-ja-phrase-fixes.mjs'
Expand Down Expand Up @@ -131,15 +132,15 @@ export const LOCALE_PHRASE_FIXES = {
...KO_PHRASE_FIXES_ROUND4
],
zh: [
{ pattern: /客服人员/g, replacement: '代理', whenEnIncludes: 'agent' },
{ pattern: /客服人员/g, replacement: '智能体', whenEnIncludes: 'agent' },
{ pattern: /会议/g, replacement: '会话', whenEnIncludes: 'session' },
{ pattern: /港口/g, replacement: '端口', whenEnIncludes: 'ort' },
{ pattern: /公关/g, replacement: 'PR', whenEnIncludes: 'PR' },
{ pattern: /虎鲸:\/\//g, replacement: 'orca://', whenEnIncludes: 'orca://' },
{ pattern: /代理商/g, replacement: '代理', whenEnIncludes: 'agent' },
{ pattern: /智能体/g, replacement: '代理', whenEnIncludes: 'agent' },
{ pattern: /代理商/g, replacement: '智能体', whenEnIncludes: 'agent' },
{ pattern: /代理/g, replacement: '智能体', whenEnMatches: /\bagents?\b/i },
{ pattern: /分支机构/g, replacement: '分支', whenEnIncludes: 'ranch' },
{ pattern: /座席/g, replacement: '代理', whenEnIncludes: 'agent' },
{ pattern: /座席/g, replacement: '智能体', whenEnIncludes: 'agent' },
{ pattern: /汽车/g, replacement: '自动', whenEnIncludes: 'Auto' },
{ pattern: /清爽/g, replacement: '刷新中', whenEnIncludes: 'Refreshing' },
{ pattern: /瓦斯尔/g, replacement: 'WSL', whenEnIncludes: 'wsl' },
Expand All @@ -157,7 +158,7 @@ export const LOCALE_PHRASE_FIXES = {
{ pattern: /电脑使用/g, replacement: '计算机控制', whenEnIncludes: 'Computer Use' },
{ pattern: /快捷方式/g, replacement: '快捷键', whenEnIncludes: 'Shortcuts' },
{ pattern: /入职清单/g, replacement: '入门清单', whenEnIncludes: 'Onboarding checklist' },
{ pattern: /发射代理/g, replacement: '启动代理', whenEnIncludes: 'Launch agent' },
{ pattern: /发射代理/g, replacement: '启动智能体', whenEnIncludes: 'Launch agent' },
{ pattern: /地位/g, replacement: '状态', whenEnIncludes: 'Status' },
{ pattern: /受让人/g, replacement: '负责人', whenEnIncludes: 'assignee' },
{ pattern: /开放工作区/g, replacement: '打开工作区', whenEnIncludes: 'Open workspace' },
Expand Down Expand Up @@ -376,6 +377,7 @@ export const LOCALE_PHRASE_FIXES = {
{ pattern: /中性的/g, replacement: 'Neutral', whenEnIncludes: 'Neutral' },
{ pattern: /破坏性的/g, replacement: 'destructive', whenEnIncludes: 'destructive' },
{ pattern: /注解/g, replacement: '批注', whenEnIncludes: 'Annotation' },
...ZH_HUMAN_PHRASE_FIXES,
...ZH_PHRASE_FIXES_ROUND5
],
ja: JA_PHRASE_FIXES,
Expand Down
27 changes: 11 additions & 16 deletions config/scripts/locale-translation-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
import { SEARCH_KEYWORD_OVERRIDES } from './locale-search-keyword-overrides.mjs'
import { LOCALE_VALUE_OVERRIDES } from './locale-value-overrides.mjs'
import { ZH_HUMAN_KEY_OVERRIDES } from './locale-zh-human-key-overrides.mjs'

export { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
export { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
Expand Down Expand Up @@ -236,24 +237,8 @@ export const BRAND_MISTRANSLATIONS = {
Linear: ['线性', '线形'],
Jira: ['吉拉'],
Tailscale: ['尾鳞', '尾鱗'],
Agent: ['代理', '智能体'],
Agents: ['代理', '智能体'],
agent: ['代理', '智能体'],
agents: ['代理', '智能体'],
Commit: ['提交'],
Commits: ['提交'],
commit: ['提交'],
commits: ['提交'],
Markdown: ['降价'],
markdown: ['降价'],
Repo: ['存储库', '仓库', '回购协议', '回购'],
Repos: ['存储库', '仓库', '回购协议', '回购'],
repo: ['存储库', '仓库', '回购协议', '回购'],
repos: ['存储库', '仓库', '回购协议', '回购'],
Terminal: ['终端', '端子'],
Terminals: ['终端', '端子'],
terminal: ['终端', '端子'],
terminals: ['终端', '端子'],
Bash: ['重击'],
PowerShell: ['电源外壳'],
REST: ['休息'],
Expand Down Expand Up @@ -457,6 +442,16 @@ function applyPhraseFixes(enValue, localeValue, locale) {
}

export function repairTranslatedValue({ key, enValue, localeValue, locale }) {
const humanKeyOverride = locale === 'zh' ? ZH_HUMAN_KEY_OVERRIDES[key]?.zh : undefined
if (humanKeyOverride) {
// Human-reviewed Simplified Chinese is the final copy, not another MT input to normalize.
return humanKeyOverride
}

if (isEnglishOnlyKey(key)) {
return enValue
}

const keyOverride = LOCALE_KEY_OVERRIDES[key]?.[locale]
if (keyOverride) {
// Why: exact key overrides can still carry stale MT output, so glossary repairs remain the final gate.
Expand Down
8 changes: 4 additions & 4 deletions config/scripts/locale-translation-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -267,15 +267,15 @@ describe('locale-translation-policy', () => {
localeValue: '未已检测代理',
locale: 'zh'
})
).toBe('未检测到 agents')
).toBe('未检测到智能体')
expect(
repairTranslatedValue({
key: 'auto.components.skills.SkillsPage.38e0951c3a',
enValue: 'Agent Skills',
localeValue: '代理技巧',
locale: 'zh'
})
).toBe('Agent 技能')
).toBe('智能体技能')
expect(
repairTranslatedValue({
key: 'auto.components.settings.appearance.search.9ae151b26b',
Expand Down Expand Up @@ -522,7 +522,7 @@ describe('locale-translation-policy', () => {
localeValue: '排队',
locale: 'zh'
})
).toBe('内联')
).toBe('行内')
expect(
repairTranslatedValue({
key: 'auto.components.github.pr.merge.state.bf5e4c6c92',
Expand All @@ -546,7 +546,7 @@ describe('locale-translation-policy', () => {
localeValue: '玩',
locale: 'zh'
})
).toBe('Play')
).toBe('播放')
expect(
repairTranslatedValue({
key: 'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bc0bdbb5e',
Expand Down
Loading
Loading