perf(i18n): load built-in catalogs once, without copies or a runtime plural fill (#35615)

* 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

* chore(i18n): sync plural forms for catalogs merged from main

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-28 19:58:12 -07:00
committed by GitHub
co-authored by OrcaWin
parent b7aa695182
commit 704d3cdd6f
14 changed files with 1871 additions and 413 deletions
+84
View File
@@ -0,0 +1,84 @@
import fs from 'node:fs/promises'
import path from 'node:path'
const PLURAL_KEY_RE = /^(.*)_(zero|one|two|few|many|other)$/
// Locales with more CLDR plural categories than English (ru few/many) source those forms from `_other`.
export function resolveLocaleSourceKey(enEntries, localeName, key) {
if (enEntries.has(key)) {
return key
}
const plural = PLURAL_KEY_RE.exec(key)
if (!plural || !localeName || !enEntries.has(`${plural[1]}_other`)) {
return undefined
}
return pluralCategories(localeName).includes(plural[2]) ? `${plural[1]}_other` : undefined
}
const COUNT_PLACEHOLDER_RE = /^\{\{\s*count\s*[,}]/
// i18next always passes count to plural forms, so a locale may add it where English omits it (ru 21 uses _one).
export function interpolationVariablesMatch(sourceKey, enVariables, localeVariables) {
if (enVariables.join('|') === localeVariables.join('|')) {
return true
}
const withoutCount = (variable) => !COUNT_PLACEHOLDER_RE.test(variable)
return (
PLURAL_KEY_RE.test(sourceKey) &&
!enVariables.some((variable) => COUNT_PLACEHOLDER_RE.test(variable)) &&
enVariables.join('|') === localeVariables.filter(withoutCount).join('|')
)
}
function pluralCategories(localeName) {
try {
return new Intl.PluralRules(localeName).resolvedOptions().pluralCategories
} catch {
return []
}
}
// Untranslated CLDR forms (fr/es/pt many, tr one) reuse the locale's `_other` instead of English; inserted beside it.
function withMissingPluralForms(catalog, categories, prefix = '', added = []) {
const filled = {}
for (const [key, value] of Object.entries(catalog)) {
const keyPath = prefix ? `${prefix}.${key}` : key
filled[key] =
typeof value === 'object' && value !== null && !Array.isArray(value)
? withMissingPluralForms(value, categories, keyPath, added).catalog
: value
if (typeof value !== 'string' || !key.endsWith('_other')) {
continue
}
const base = key.slice(0, -'other'.length)
for (const category of categories) {
if (!Object.hasOwn(catalog, `${base}${category}`)) {
filled[`${base}${category}`] = value
added.push(`${prefix ? `${prefix}.` : ''}${base}${category}`)
}
}
}
return { catalog: filled, added }
}
/** Verifies (or with fix, writes) the plural forms a desktop locale catalog needs; runtimes load catalogs as-is. */
export async function syncLocalePluralForms(catalogPath, catalog, fix) {
const fileName = path.basename(catalogPath)
const plural = withMissingPluralForms(catalog, pluralCategories(fileName.replace(/\.json$/, '')))
if (plural.added.length === 0) {
return { ok: true, catalog }
}
if (!fix) {
console.error(`${fileName} is missing CLDR plural forms that runtimes no longer fill:`)
console.error(
plural.added
.slice(0, 20)
.map((key) => `missing plural form: ${key}`)
.join('\n')
)
return { ok: false }
}
await fs.writeFile(catalogPath, `${JSON.stringify(plural.catalog, null, 2)}\n`, 'utf8')
console.log(`Added ${plural.added.length} plural form(s) to ${fileName} from \`_other\`.`)
return { ok: true, catalog: plural.catalog }
}
+2
View File
@@ -6,6 +6,8 @@ export const LOCALIZATION_SURFACES = {
localesDir: path.join('src', 'renderer', 'src', 'i18n', 'locales'),
sourceRoots: [path.join('src', 'renderer', 'src'), path.join('src', 'main')],
runtimeRequiredPath: path.join('src', 'renderer', 'src', 'i18n', 'en-runtime-required.json'),
// Desktop runtimes load built-in catalogs as-is; plural forms CLDR adds beyond `_other` are committed.
fillsPluralForms: true,
syncCatalogCommand: 'pnpm run sync:localization-catalog',
syncRuntimeCatalogCommand: 'pnpm run sync:localization-runtime-catalog'
},
+17 -20
View File
@@ -9,6 +9,11 @@ import ts from 'typescript-api'
import { canonicalGenericRenderings } from './locale-generic-ui-terms.mjs'
import { LOCALIZATION_SURFACES, readLocalizationSurface } from './localization-surfaces.mjs'
import { repairTranslatedValue } from './locale-translation-policy.mjs'
import {
interpolationVariablesMatch,
resolveLocaleSourceKey,
syncLocalePluralForms
} from './locale-plural-forms.mjs'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
@@ -283,24 +288,6 @@ function setCatalogEntry(catalog, key, value) {
cursor[parts.at(-1)] = value
}
// Locales with more CLDR plural categories than English (ru few/many) source those forms from `_other`.
function resolveLocaleSourceKey(enEntries, localeName, key) {
if (enEntries.has(key)) {
return key
}
const plural = /^(.*)_(zero|one|two|few|many|other)$/.exec(key)
if (!plural || !localeName || !enEntries.has(`${plural[1]}_other`)) {
return undefined
}
try {
return new Intl.PluralRules(localeName).resolvedOptions().pluralCategories.includes(plural[2])
? `${plural[1]}_other`
: undefined
} catch {
return undefined
}
}
function collectLocaleParityIssues(enCatalog, localeCatalog, localeName) {
const enEntries = flattenCatalogEntries(enCatalog)
const localeEntries = flattenCatalogEntries(localeCatalog)
@@ -317,7 +304,7 @@ function collectLocaleParityIssues(enCatalog, localeCatalog, localeName) {
}
const enVariables = collectInterpolationVariables(enEntries.get(sourceKey))
const localeVariables = collectInterpolationVariables(localeValue)
if (enVariables.join('|') !== localeVariables.join('|')) {
if (!interpolationVariablesMatch(sourceKey, enVariables, localeVariables)) {
interpolationMismatches.push(key)
}
}
@@ -657,10 +644,20 @@ export async function main(
for (const fileName of localeFiles) {
const localeName = fileName.replace(/\.json$/, '')
const localeCatalogPath = path.join(localesDir, fileName)
const localeCatalog = await readCatalogFile(localeCatalogPath)
let localeCatalog = await readCatalogFile(localeCatalogPath)
if (localeCatalog === undefined || reportDottedCatalogKeys(fileName, localeCatalog) !== 0) {
return 1
}
if (surface.fillsPluralForms) {
const plural = await syncLocalePluralForms(localeCatalogPath, localeCatalog, options.fix)
if (!plural.ok) {
console.error(
`Run \`${surface.syncCatalogCommand}\` to copy them from each \`_other\` form.`
)
return 1
}
localeCatalog = plural.catalog
}
const exitCode = verifyLocaleCatalog(catalog, localeName, localeCatalog)
if (exitCode !== 0) {
console.error('')
@@ -183,6 +183,43 @@ describe('verify-localization-catalog', () => {
await expect(verifyLocalizationCatalog(root)).resolves.toBe(1)
})
it('commits CLDR plural forms a locale lacks beside its _other form', async () => {
const { root, localesDir } = makeProject({
sourceText: 'export {}\n',
enCatalog: {
files_one: 'One file',
files_other: '{{count}} files',
name: 'Name'
}
})
const frenchPath = path.join(localesDir, 'fr.json')
writeJson(frenchPath, { files_other: '{{count}} fichiers', name: 'Nom' })
const report = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
} finally {
report.mockRestore()
}
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
expect(Object.entries(readJson(frenchPath))).toEqual([
['files_other', '{{count}} fichiers'],
['files_one', '{{count}} fichiers'],
['files_many', '{{count}} fichiers'],
['name', 'Nom']
])
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(0)
// Only an English form without {{count}} lets the locale add it; dropping it is still a mismatch.
writeJson(frenchPath, { files_other: 'fichiers', files_one: 'fichier', files_many: 'fichiers' })
const mismatch = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
} finally {
mismatch.mockRestore()
}
})
it('reports partial plugin catalog gaps but rejects malformed interpolation', async () => {
const { root } = makeProject({
sourceText: 'export {}\n',
+2 -12
View File
@@ -6,10 +6,7 @@ import i18next, {
type TOptions
} from 'i18next'
import {
resolvePluralFamilyKey,
withPluralCategoriesFromOther
} from '../../shared/i18n-plural-family'
import { resolvePluralFamilyKey } from '../../shared/i18n-plural-family'
import { isPseudoLocalizationLocale, pseudoLocalizeString } from '../../shared/pseudo-localization'
import { DEFAULT_UI_LOCALE, resolveUiLocale, type SupportedUiLocale } from '../../shared/ui-locale'
import { UI_LANGUAGE_SYSTEM, type UiLanguage } from '../../shared/ui-language'
@@ -49,14 +46,7 @@ const lazyLocaleBackend: BackendModule = {
return
}
loader().then(
(mod) =>
callback(
null,
withPluralCategoriesFromOther(
mod.default,
new Intl.PluralRules(language).resolvedOptions().pluralCategories
)
),
(mod) => callback(null, mod.default),
(error) => callback(error instanceof Error ? error : new Error(String(error)), false)
)
}
+31 -9
View File
@@ -28,10 +28,7 @@ import { isPluginUiLanguage, type UiLanguage } from '../../../shared/ui-language
import type { PluginLanguagePackRegistration } from '../../../shared/plugins/plugin-language-pack-artifact'
import { replacePluginLanguagePackResources } from '../../../shared/plugins/plugin-language-pack-i18n-resources'
import { withTimeout } from '../../../shared/promise-timeout-fallback'
import {
resolvePluralFamilyKey,
withPluralCategoriesFromOther
} from '../../../shared/i18n-plural-family'
import { resolvePluralFamilyKey } from '../../../shared/i18n-plural-family'
export const i18n: I18nInstance = i18next.createInstance()
@@ -53,13 +50,30 @@ function localeCatalogUrl(language: string): string | undefined {
return Object.entries(NON_DEFAULT_LOCALE_URLS).find(([name]) => name === language)?.[1]
}
async function loadLocaleCatalog(language: string, url: string): Promise<Record<string, unknown>> {
// Built-in catalogs ship every CLDR plural form (sync:localization-catalog), so they load as-is.
async function fetchLocaleCatalog(url: string): Promise<Record<string, unknown>> {
const response = await fetch(url, { cache: 'no-cache' })
if (!response.ok) {
throw new Error(`Language catalog request failed: ${response.status}`)
}
const categories = new Intl.PluralRules(language).resolvedOptions().pluralCategories
return withPluralCategoriesFromOther(await response.json(), categories)
return response.json()
}
// The provider, onboarding switcher and backend can ask for one locale at once; share the fetch.
const catalogRequests = new Map<string, Promise<Record<string, unknown>>>()
function loadLocaleCatalog(language: string, url: string): Promise<Record<string, unknown>> {
const pending = catalogRequests.get(language)
if (pending) {
return pending
}
const request = fetchLocaleCatalog(url).finally(() => {
if (catalogRequests.get(language) === request) {
catalogRequests.delete(language)
}
})
catalogRequests.set(language, request)
return request
}
const lazyLocaleBackend: BackendModule = {
@@ -151,11 +165,19 @@ export async function loadRendererUiLanguage(language: UiLanguage): Promise<stri
: resourceLanguage
const url = localeCatalogUrl(locale)
if (url && !i18n.hasResourceBundle(locale, 'translation')) {
const catalog = await withTimeout(loadLocaleCatalog(locale, url), 5000, null)
const request = loadLocaleCatalog(locale, url)
const catalog = await withTimeout(request, 5000, null)
if (catalog === null) {
// A retry after a timeout must start a fresh request instead of joining the stalled one.
if (catalogRequests.get(locale) === request) {
catalogRequests.delete(locale)
}
throw new Error('Language catalog unavailable')
}
i18n.addResourceBundle(locale, 'translation', catalog, true, true)
if (!i18n.hasResourceBundle(locale, 'translation')) {
// Freshly parsed and never mutated, so skip i18next's defensive deep copy.
i18n.addResourceBundle(locale, 'translation', catalog, false, false, { skipCopy: true })
}
}
return locale
}
+18 -1
View File
@@ -8,7 +8,12 @@ import {
UI_LANGUAGE_RUSSIAN,
UI_LANGUAGE_SPANISH
} from '../../../shared/ui-language'
import { i18n, setRendererPluginLanguagePacks, setRendererUiLanguage } from './i18n'
import {
i18n,
loadRendererUiLanguage,
setRendererPluginLanguagePacks,
setRendererUiLanguage
} from './i18n'
import { pluginLanguageResourceId } from '../../../shared/plugins/plugin-language-pack-artifact'
// Every changeLanguage caller must transparently load real non-English translations before its promise settles.
@@ -72,6 +77,18 @@ describe('renderer i18n lazy locale loading', () => {
expect(i18n.t('zhOnlyKey', { defaultValue: 'English' })).toBe('English')
})
it('shares one catalog request between concurrent loads of a locale', async () => {
i18n.removeResourceBundle('ko', 'translation')
const request = vi.spyOn(globalThis, 'fetch')
try {
await Promise.all([loadRendererUiLanguage('ko'), loadRendererUiLanguage('ko')])
expect(request).toHaveBeenCalledTimes(1)
expect(i18n.getFixedT('ko')('menu.file', { defaultValue: 'File' })).toBe('파일')
} finally {
request.mockRestore()
}
})
it.each(['network', 'http', 'invalid-json'] as const)(
'retries a rejected %s catalog load with a fresh request',
async (failure) => {
+9 -2
View File
@@ -5101,7 +5101,8 @@
"description": "{{value0}} aktualisieren, damit Agenten den aktuellen Anweisungen für diese Orca-Version folgen.",
"updateOne": "Skill aktualisieren",
"updateMany": "Skills aktualisieren",
"title_other": "{{count}} installierte Orca-Skills sind veraltet"
"title_other": "{{count}} installierte Orca-Skills sind veraltet",
"title_one": "{{count}} installierte Orca-Skills sind veraltet"
},
"SkillFreshnessUpdateDialog": {
"title": "Skills aktualisieren",
@@ -5124,6 +5125,7 @@
"updateServer": "Zum Aktualisieren der dortigen Orca-Skills Orca auf dem Server aktualisieren.",
"connectHost": "Eine Verbindung zu diesem SSH-Host herstellen, um die dortigen Orca-Skills zu prüfen.",
"upToDate_other": "{{count}} Skills sind auf dem neuesten Stand.",
"upToDate_one": "{{count}} Skills sind auf dem neuesten Stand.",
"update_one": "{{count}} Aktualisierung verfügbar",
"update_other": "{{count}} Aktualisierungen verfügbar",
"current_one": "{{count}} Skill ist auf dem neuesten Stand",
@@ -5488,7 +5490,8 @@
"placementSummaryParts": "Entfernt {{parts}} in {{roots}}.",
"placementJoin": " und ",
"retainedSource": "Der Skill selbst bleibt unter {{path}} erhalten.",
"nothing_other": "Keiner der {{count}} ausgewählten Skills kann hier gelöscht werden."
"nothing_other": "Keiner der {{count}} ausgewählten Skills kann hier gelöscht werden.",
"nothing_one": "Keiner der {{count}} ausgewählten Skills kann hier gelöscht werden."
},
"skillPlanOpCopy": {
"busy": "Eine andere Installation läuft noch",
@@ -18885,11 +18888,13 @@
"runWebSearch_one": "{{count}}-mal im Web gesucht",
"runWebSearch_other": "{{count}}-mal im Web gesucht",
"runBrowser_other": "Browser {{count}}-mal verwendet",
"runBrowser_one": "Browser {{count}}-mal verwendet",
"runIntegration_one": "{{count}} Integration verwendet",
"runIntegration_other": "{{count}} Integrationen verwendet",
"runAgent_one": "{{count}} Agent ausgeführt",
"runAgent_other": "{{count}} Agenten ausgeführt",
"runPlan_other": "Plan {{count}}-mal aktualisiert",
"runPlan_one": "Plan {{count}}-mal aktualisiert",
"runTool_one": "{{count}} Werkzeug verwendet",
"runTool_other": "{{count}} Werkzeuge verwendet",
"runLiveRead_one": "{{count}} Datei wird gelesen",
@@ -18905,11 +18910,13 @@
"runLiveWebSearch_one": "{{count}} Websuche läuft",
"runLiveWebSearch_other": "{{count}} Websuchen laufen",
"runLiveBrowser_other": "Browser wird {{count}}-mal verwendet",
"runLiveBrowser_one": "Browser wird {{count}}-mal verwendet",
"runLiveIntegration_one": "{{count}} Integration wird verwendet",
"runLiveIntegration_other": "{{count}} Integrationen werden verwendet",
"runLiveAgent_one": "{{count}} Agent wird ausgeführt",
"runLiveAgent_other": "{{count}} Agenten werden ausgeführt",
"runLivePlan_other": "Plan wird {{count}}-mal aktualisiert",
"runLivePlan_one": "Plan wird {{count}}-mal aktualisiert",
"runLiveTool_one": "{{count}} Werkzeug wird verwendet",
"runLiveTool_other": "{{count}} Werkzeuge werden verwendet"
},
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
+13 -2
View File
@@ -5241,7 +5241,8 @@
"updateMany": "Обновить навыки",
"title_few": "{{count}} установленных навыка Orca устарели",
"title_many": "{{count}} установленных навыков Orca устарели",
"title_other": "Устаревшие установленные навыки Orca: {{count}}"
"title_other": "Устаревшие установленные навыки Orca: {{count}}",
"title_one": "Устаревшие установленные навыки Orca: {{count}}"
},
"SkillFreshnessUpdateDialog": {
"title": "Обновить навыки",
@@ -5266,6 +5267,7 @@
"upToDate_few": "{{count}} навыка актуальны.",
"upToDate_many": "{{count}} навыков актуальны.",
"upToDate_other": "Актуальных навыков: {{count}}.",
"upToDate_one": "Актуальных навыков: {{count}}.",
"update_one": "Доступно {{count}} обновление",
"update_few": "Доступно {{count}} обновления",
"update_many": "Доступно {{count}} обновлений",
@@ -5676,7 +5678,8 @@
"retainedSource": "Сам навык останется в {{path}}.",
"nothing_few": "Нельзя удалить отсюда ни один из выбранных навыков ({{count}}).",
"nothing_many": "Нельзя удалить отсюда ни один из выбранных навыков ({{count}}).",
"nothing_other": "Нельзя удалить отсюда ни один из выбранных навыков ({{count}})."
"nothing_other": "Нельзя удалить отсюда ни один из выбранных навыков ({{count}}).",
"nothing_one": "Нельзя удалить отсюда ни один из выбранных навыков ({{count}})."
},
"skillPlanOpCopy": {
"busy": "Другая установка ещё выполняется",
@@ -19404,6 +19407,7 @@
"runBrowser_few": "Браузер использован {{count}} раза",
"runBrowser_many": "Браузер использован {{count}} раз",
"runBrowser_other": "Браузер использован {{count}} раза",
"runBrowser_one": "Браузер использован {{count}} раза",
"runIntegration_one": "Использована {{count}} интеграция",
"runIntegration_few": "Использовано {{count}} интеграции",
"runIntegration_many": "Использовано {{count}} интеграций",
@@ -19415,6 +19419,7 @@
"runPlan_few": "План обновлён {{count}} раза",
"runPlan_many": "План обновлён {{count}} раз",
"runPlan_other": "План обновлён {{count}} раза",
"runPlan_one": "План обновлён {{count}} раза",
"runTool_one": "Использован {{count}} инструмент",
"runTool_few": "Использовано {{count}} инструмента",
"runTool_many": "Использовано {{count}} инструментов",
@@ -19446,6 +19451,7 @@
"runLiveBrowser_few": "Использует браузер {{count}} раза",
"runLiveBrowser_many": "Использует браузер {{count}} раз",
"runLiveBrowser_other": "Использует браузер {{count}} раза",
"runLiveBrowser_one": "Использует браузер {{count}} раза",
"runLiveIntegration_one": "Использует {{count}} интеграцию",
"runLiveIntegration_few": "Использует {{count}} интеграции",
"runLiveIntegration_many": "Использует {{count}} интеграций",
@@ -19457,6 +19463,7 @@
"runLivePlan_few": "Обновляет план {{count}} раза",
"runLivePlan_many": "Обновляет план {{count}} раз",
"runLivePlan_other": "Обновляет план {{count}} раза",
"runLivePlan_one": "Обновляет план {{count}} раза",
"runLiveTool_one": "Использует {{count}} инструмент",
"runLiveTool_few": "Использует {{count}} инструмента",
"runLiveTool_many": "Использует {{count}} инструментов",
@@ -20667,10 +20674,14 @@
"discard": {
"deleteUntrackedFileDescription": "Этот неотслеживаемый файл будет безвозвратно удалён. Это действие нельзя отменить.",
"deleteUntrackedFilesDescription_other": "Неотслеживаемые файлы ({{count}}) будут безвозвратно удалены. Это действие нельзя отменить.",
"deleteUntrackedFilesDescription_few": "Неотслеживаемые файлы ({{count}}) будут безвозвратно удалены. Это действие нельзя отменить.",
"deleteUntrackedFilesDescription_many": "Неотслеживаемые файлы ({{count}}) будут безвозвратно удалены. Это действие нельзя отменить.",
"restoreStagedVersionDescription": "Будет восстановлена последняя версия из индекса, а удаление отменено. Это действие нельзя отменить.",
"unstagedChangesDescription": "Изменения этого файла вне индекса будут отменены. Это действие нельзя отменить.",
"unstagedSelectionIncludingUntrackedDescription": "Изменения вне индекса будут отменены, а неотслеживаемые файлы среди выбранных — безвозвратно удалены. Это действие нельзя отменить.",
"unstagedFilesIncludingUntrackedDescription_other": "Изменения вне индекса будут отменены, а неотслеживаемые файлы среди выбранных ({{count}}) — безвозвратно удалены. Это действие нельзя отменить.",
"unstagedFilesIncludingUntrackedDescription_few": "Изменения вне индекса будут отменены, а неотслеживаемые файлы среди выбранных ({{count}}) — безвозвратно удалены. Это действие нельзя отменить.",
"unstagedFilesIncludingUntrackedDescription_many": "Изменения вне индекса будут отменены, а неотслеживаемые файлы среди выбранных ({{count}}) — безвозвратно удалены. Это действие нельзя отменить.",
"delete": "Удалить",
"deleteCount": "Удалить {{count}}",
"deleteUntrackedFilesDescription_one": "Будет безвозвратно удалён {{count}} неотслеживаемый файл. Это действие нельзя отменить.",
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@ export function resolvePluralFamilyKey(key: string, options?: TOptions): string
return typeof options?.count === 'number' ? key.replace(PLURAL_SUFFIX_RE, '') : key
}
// Catalogs translated from English often ship only _one/_other; reuse _other so few/many stay in-language.
// Plugin packs often ship only _one/_other; reuse _other so few/many stay in-language (built-ins commit these forms).
export function withPluralCategoriesFromOther(
catalog: Record<string, unknown>,
categories: readonly string[]