mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* perf(i18n): load built-in catalogs once, without copies or a runtime plural fill
Each catalog load parsed the JSON, rebuilt it to fill CLDR plural forms
from _other, then deep-copied and deep-merged it into the store (about
26 ms after parsing per 1.3 MB catalog), and concurrent loads of one
locale fetched it twice.
- sync:localization-catalog now commits the plural forms a locale's
CLDR rules need beside each _other (fr/es/pt many, tr one, a few
de/ru), and verify:localization-catalog fails when one is missing.
Plural forms may include {{count}} where English omits it.
- Renderer and main load built-in catalogs as-is; plugin packs keep the
runtime fill.
- Renderer catalog requests are shared per locale; a timed-out request
is dropped so a retry fetches again. Bundles are added with skipCopy.
* fix(i18n): require {{count}} in plural forms when English shows it
* refactor(i18n): share one i18next setup between main and renderer
- createOrcaI18n (src/shared/orca-i18n-instance.ts) owns the backend,
init options, plugin-pack registration, resource-language resolution
and the translate wrapper; main and renderer inject only how a
built-in catalog loads (dynamic import vs. fetch) and extra modules.
- Remove setRendererUiLanguage (tests only); tests load then switch.
- resolveUiLocale maps built-ins by lookup instead of an if-chain, and
catalog URLs/loaders are direct map lookups.
- Drop <I18nextProvider>: initReactI18next already binds the instance.
* refactor(i18n): native i18next plurals at call sites, plural-aware extraction
Call sites branched on count === 1 and passed literal _one/_other keys,
which resolvePluralFamilyKey stripped again so CLDR could pick the form.
They now call translate('x', otherDefault, { count, defaultValue_one })
and i18next selects the form natively; catalog keys stay x_one/x_other.
- Converted 284 families with an AST codemod plus a few by hand; added
defaultValue_one to 21 base-key families that relied on en.json.
- Extraction runs with plurals enabled, so both English defaults are
checked for drift. A key that only fills {{count}} (no defaultValue_one)
stays singular in en.json.
- verify/sync:localization-catalog expand a native plural call into its
x_one/x_other English entries.
- Removed 22 base keys that a sibling plural family shadowed in every
catalog.
* fix(i18n): pick English plural defaults by English rules; require count for plural refs
* refactor(i18n): convert plural sites merged from main; treat key-as-default plural extraction as dynamic
* fix(i18n): keep an undefined pass-through count absent; only dotted key-as-default is dynamic
* refactor(i18n): native plurals for exact-1 families; drop desktop suffix stripping
Twelve families kept special exact-1 copy ("Used the browser") beside an
_other-only family, so CLDR one (ru 21, 31) reused _other wording. They
now call the base key with an English _one translator source, the
exact-1 branch stays a separate key, and ru one forms get the right
noun ("21 раз"). With no suffixed call sites left, desktop translate
no longer strips plural suffixes; mobile keeps resolvePluralFamilyKey.
* test(i18n): update plural call expectations in two renderer tests
* refactor(i18n): convert a plural site merged from main
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
234 lines
8.4 KiB
JavaScript
234 lines
8.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'
|
|
|
|
import { collectSourceFiles } from './verify-localization-catalog.mjs'
|
|
|
|
const EN_CATALOG_PATH = path.join('src', 'renderer', 'src', 'i18n', 'locales', 'en.json')
|
|
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
|
|
// Extraction misses keys held in data structures or passed as variables, so any literal naming a key keeps it.
|
|
// Scoped to src/ so only changes that trigger this check can change its verdict.
|
|
const KEY_LITERAL_SCAN_ROOT = 'src'
|
|
const KEY_LITERAL_RE = /['"`]([A-Za-z][\w-]*(?:\.[\w-]+)+)['"`]/g
|
|
const DYNAMIC_KEY_HEAD_RE =
|
|
/\b(?:t|translate\w*)\(\s*(?:`([A-Za-z][\w-]*\.[\w.-]*)\$\{|['"]([A-Za-z][\w-]*\.[\w.-]*)['"]\s*\+)/g
|
|
// i18next resolves `key_one` etc. from the base key at runtime.
|
|
const PLURAL_SUFFIX_RE = /_(?:zero|one|two|few|many|other)$/
|
|
|
|
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('|')
|
|
}
|
|
|
|
// Plural extraction splits every count-bearing key; one that only fills {{count}} stays singular in en.json.
|
|
function collapseCountOnlyKeys(extracted, english) {
|
|
for (const [key, value] of extracted) {
|
|
// Plural extraction fills a non-literal default with the dotted base key; treat it as dynamic like other calls.
|
|
const base = key.replace(PLURAL_SUFFIX_RE, '')
|
|
if (base !== key && base.includes('.') && value === base) {
|
|
extracted.set(key, '')
|
|
}
|
|
}
|
|
for (const [key, value] of extracted) {
|
|
const base = key.endsWith('_other') ? key.slice(0, -'_other'.length) : null
|
|
// A suffixed key with count (x_one_other) is a pre-native plural call; keep it reported.
|
|
if (
|
|
base &&
|
|
!PLURAL_SUFFIX_RE.test(base) &&
|
|
!english.has(key) &&
|
|
english.has(base) &&
|
|
extracted.get(`${base}_one`) === value
|
|
) {
|
|
extracted.delete(key)
|
|
extracted.delete(`${base}_one`)
|
|
extracted.set(base, value)
|
|
}
|
|
}
|
|
return extracted
|
|
}
|
|
|
|
export function compareExtraction(extractedCatalog, englishCatalog) {
|
|
const english = flattenCatalog(englishCatalog)
|
|
const extracted = collapseCountOnlyKeys(flattenCatalog(extractedCatalog), english)
|
|
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
|
|
}
|
|
}
|
|
|
|
export function collectKeyLiterals(sourceText, evidence) {
|
|
for (const match of sourceText.matchAll(KEY_LITERAL_RE)) {
|
|
evidence.literals.add(match[1])
|
|
}
|
|
for (const match of sourceText.matchAll(DYNAMIC_KEY_HEAD_RE)) {
|
|
evidence.dynamicPrefixes.add(match[1] ?? match[2])
|
|
}
|
|
return evidence
|
|
}
|
|
|
|
async function collectSourceKeyEvidence(root) {
|
|
const evidence = { literals: new Set(), dynamicPrefixes: new Set() }
|
|
for (const file of await collectSourceFiles(root, path.join(root, KEY_LITERAL_SCAN_ROOT))) {
|
|
collectKeyLiterals(await fs.readFile(file, 'utf8'), evidence)
|
|
}
|
|
return evidence
|
|
}
|
|
|
|
export function findUnreferencedKeys(orphans, extracted, { literals, dynamicPrefixes }) {
|
|
const isNamed = (key) =>
|
|
extracted.has(key) ||
|
|
literals.has(key) ||
|
|
[...dynamicPrefixes].some((prefix) => key.startsWith(prefix))
|
|
return orphans.filter((key) => {
|
|
if (isNamed(key)) {
|
|
return false
|
|
}
|
|
const base = key.replace(PLURAL_SUFFIX_RE, '')
|
|
return base === key || !isNamed(base)
|
|
})
|
|
}
|
|
|
|
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, keyEvidence] = await Promise.all([
|
|
extractToTemporaryCatalog(root, tempDir),
|
|
fs.readFile(path.join(root, EN_CATALOG_PATH), 'utf8').then(JSON.parse),
|
|
collectSourceKeyEvidence(root)
|
|
])
|
|
const result = compareExtraction(extractedCatalog, englishCatalog)
|
|
const unreferenced = findUnreferencedKeys(result.orphans, result.extracted, keyEvidence)
|
|
|
|
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 (${unreferenced.length} named nowhere in source), 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
|
|
}
|
|
if (unreferenced.length > 0) {
|
|
printKeys(
|
|
'Catalog keys no source references. Delete them from every locale catalog and config/scripts/locale-*-overrides.*, then run `pnpm sync:localization-runtime-catalog`',
|
|
unreferenced
|
|
)
|
|
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())
|
|
}
|