Files
orca/config/scripts/verify-localization-extraction.mjs
T
OrcaWinandOrcaWin 17667304b9 fix(i18n): render revised UI copy and fail on fallback drift (#34642)
* fix(i18n): render revised UI copy and fail on fallback drift

en.json kept stale values for 47 translate() keys whose source fallback was
rewritten, so the revised copy never rendered in any language.

- Adopt the newer source copy into en.json and retranslate zh/ko/ja/es/fr/pt.
- Move copy whose meaning changed to new keys (artifacts sign-in, remote host
  access link / name, ssh config picker, jump palette automation label, Claude
  subagent navigation hint) so stale translations cannot survive.
- Split five keys shared by different sentences at the sites the extractor
  flagged (activity group-by, automation destination notices, Linear open
  action, workspace options label).
- Fix catalog/source mistakes: onboarding "notify you know" typo, crash report
  double dot and missing space, mobile search keyword "app", remote server
  restart warning repeating "live tabs", and stale source fallbacks where the
  catalog was right (Ghostty import, Skills button, too-many-changes banner,
  card style and chat UI descriptions).
- verify-localization-extraction now fails on fallback drift and prints the
  en.json and source values with how to fix it.

Closes #25572

* fix(i18n): translate es live tab/pane counts used in server restart warning

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-27 18:09:28 -07:00

156 lines
5.4 KiB
JavaScript

import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
const EN_CATALOG_PATH = path.join('src', 'renderer', 'src', 'i18n', 'locales', 'en.json')
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
function flattenCatalog(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)) {
flattenCatalog(child, prefix ? `${prefix}.${key}` : key, entries)
}
return entries
}
function placeholders(value) {
return [...(value.match(PLACEHOLDER_RE) ?? [])].sort().join('|')
}
export function compareExtraction(extractedCatalog, englishCatalog) {
const extracted = flattenCatalog(extractedCatalog)
const english = flattenCatalog(englishCatalog)
const dynamicDefaults = [...extracted.entries()]
.filter(([, value]) => value.length === 0)
.map(([key]) => key)
const missingFromEnglish = [...extracted.keys()].filter((key) => !english.has(key))
const orphans = [...english.keys()].filter((key) => !extracted.has(key))
const fallbackDrift = []
const placeholderMismatches = []
for (const [key, extractedValue] of extracted) {
const englishValue = english.get(key)
if (
extractedValue.length === 0 ||
englishValue === undefined ||
englishValue === extractedValue
) {
continue
}
fallbackDrift.push(key)
if (placeholders(extractedValue) !== placeholders(englishValue)) {
placeholderMismatches.push(key)
}
}
return {
extracted,
english,
dynamicDefaults,
missingFromEnglish,
orphans,
fallbackDrift,
placeholderMismatches
}
}
function printKeys(label, keys) {
if (keys.length === 0) {
return
}
console.error(`${label}:`)
for (const key of keys.slice(0, 20)) {
console.error(` ${key}`)
}
if (keys.length > 20) {
console.error(` ...and ${keys.length - 20} more`)
}
}
function printFallbackDrift(keys, extracted, english) {
console.error(
'Inline defaults differ from en.json. The catalog value is what renders, so editing a fallback alone changes nothing.\n' +
'Set the en.json value to the new copy and update every locale catalog to match, or move the new copy to a new key.'
)
for (const key of keys.slice(0, 20)) {
console.error(` ${key}`)
console.error(` en.json: ${JSON.stringify(english.get(key))}`)
console.error(` source: ${JSON.stringify(extracted.get(key))}`)
}
if (keys.length > 20) {
console.error(` ...and ${keys.length - 20} more`)
}
}
async function extractToTemporaryCatalog(root, tempDir) {
const cliPath = path.join(root, 'node_modules', 'i18next-cli', 'dist', 'esm', 'cli.js')
const outputPattern = path.join(tempDir, '{{language}}.json')
// Treat extraction as check evidence, not another committed catalog feature authors must synchronize.
const [{ runProcessSync }, { describeProcessFailure }, { resolvePinnedBunExecutable }] =
await Promise.all([
import('@orca/process-host'),
import('./process-failure-message.mjs'),
import('./pinned-bun-executable.mjs')
])
// Missing or unusable optional Bun keeps the existing parent runtime.
const program = resolvePinnedBunExecutable(root, runProcessSync) ?? process.execPath
const result = runProcessSync({
program,
args: [cliPath, '--config', 'config/i18next.config.ts', 'extract', '--sync-primary', '--quiet'],
cwd: root,
env: {
...process.env,
ORCA_I18N_EXTRACTION_OUTPUT: outputPattern.split(path.sep).join('/')
},
timeoutMs: null,
maxOutputBytes: 1024 * 1024
})
if (result.code !== 0 || result.signal || result.timedOut || result.outputTruncated) {
throw new Error(`Localization extraction failed: ${describeProcessFailure(result)}`)
}
return JSON.parse(await fs.readFile(path.join(tempDir, 'en.json'), 'utf8'))
}
export async function main(root = process.cwd()) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'orca-i18next-extraction-'))
try {
const [extractedCatalog, englishCatalog] = await Promise.all([
extractToTemporaryCatalog(root, tempDir),
fs.readFile(path.join(root, EN_CATALOG_PATH), 'utf8').then(JSON.parse)
])
const result = compareExtraction(extractedCatalog, englishCatalog)
console.log(
`Extracted ${result.extracted.size} keys; ${result.dynamicDefaults.length} dynamic defaults are report-only, ${result.orphans.length} existing English entries are not statically referenced, and ${result.fallbackDrift.length} inline defaults differ from en.json.`
)
let failed = false
if (result.missingFromEnglish.length > 0 || result.placeholderMismatches.length > 0) {
printKeys('Extracted keys missing from en.json', result.missingFromEnglish)
printKeys('Extracted defaults with incompatible placeholders', result.placeholderMismatches)
failed = true
}
if (result.fallbackDrift.length > 0) {
printFallbackDrift(result.fallbackDrift, result.extracted, result.english)
failed = true
}
return failed ? 1 : 0
} finally {
await fs.rm(tempDir, { recursive: true, force: true })
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}