diff --git a/.changeset/show-empty-related-plural-base-key-3863.md b/.changeset/show-empty-related-plural-base-key-3863.md new file mode 100644 index 000000000..53b630864 --- /dev/null +++ b/.changeset/show-empty-related-plural-base-key-3863.md @@ -0,0 +1,14 @@ +--- +'@object-ui/i18n': patch +'@object-ui/plugin-detail': patch +--- + +`detail.showEmptyRelated` renders Russian and Arabic again — the "+N empty" button no longer falls through to English at the counts it takes most often + +This was the repo's only pre-existing i18next plural family, and all ten packs defined exactly two slots: `_one` and `_other`. i18next asks `Intl.PluralRules` for the one suffix a language needs for that number, and when the pack has no such slot it walks `fallbackLng` to `en`. Russian has four plural categories and Arabic six, so `ru` at counts 2-4 (`few`) and 5-20, 25-30, … (`many`), and `ar` at 0, 2, 3-10 and 11-99, resolved nothing locally and rendered the English string. The call site is the collapsed-empties button in the record detail's reference rail, whose count is the number of empty related lists — 2 to 4 are the most common values it ever takes, so a Russian user essentially always read English. + +The fix is a base key (no suffix) beside the two existing slots, in all ten packs. The base key is always in i18next's lookup chain, so every category a pack did not enumerate resolves to it, in that pack's own language — and, unlike adding `_few`/`_many` to `ru` alone, it keeps the ten packs' key sets identical, which full key parity requires. Same shape objectui#3546 slice six established for `perm.facet.*`. Where the base key is genuinely reachable it carries a count-invariant phrasing: `ru` uses the «Существительное: {{count}}» form the pack already writes 22 times, `ar` the «{{count}} مفرد(جمع)» marker it uses throughout. For `en`/`de`/`zh`/`ja`/`ko` the base key cannot be reached at all (their categories are covered by the two existing slots) and repeats `_other` for parity; `fr`/`es`/`pt` reach it only from a million up, where the plural form is already correct. No English copy moves. + +The provider-less path needed the same row for a different reason: `createSafeTranslation`'s fallback resolves `defaults[key]` literally and never appends a plural suffix, so the two suffixed rows in plugin-detail's defaults table were unreachable through it and that path answered with the raw key. It now carries the base key too. + +Parity across packs turned out to be necessary and not sufficient — ten identical key sets were green throughout, because the defect is one level below key names: the slot the language needs is not in the set. So the invariant "a plural family must carry a base key" is now asserted over all ten packs in `all-locales-key-parity.test.ts`, where it is pack-intrinsic and fails at PR time without needing a call site to exist. It went red on all ten packs before this change and names the family that is missing its base. diff --git a/packages/i18n/src/__tests__/all-locales-key-parity.test.ts b/packages/i18n/src/__tests__/all-locales-key-parity.test.ts index 5d60ee7ce..b70a4b2dc 100644 --- a/packages/i18n/src/__tests__/all-locales-key-parity.test.ts +++ b/packages/i18n/src/__tests__/all-locales-key-parity.test.ts @@ -38,6 +38,38 @@ * That gate skips any key a pack does not define — including the four * `OUTBOUND_KEYS` below — precisely because their key sets are this file's * business, so the two cannot contradict each other on the same fact. + * + * ## The second invariant here: a plural family carries a base key (objectui#3863) + * + * Parity across packs is necessary and NOT sufficient, and `detail.showEmptyRelated` + * was the proof: ten packs, identical key sets, `_one` and `_other` in every one of + * them — full parity, green — and `ru` still rendered ENGLISH at counts 2-20 and `ar` + * at 2-99. The mechanism is one level below key sets. i18next asks + * `Intl.PluralRules` for the ONE suffix a language needs for that number and, when + * the pack has no such slot, walks `fallbackLng` to `en`. `ru` has four categories + * (`one/few/many/other`) and `ar` six (`+ zero/two`); no pack in this repo defines + * `_few`/`_many`/`_two`/`_zero`, so those categories resolved nothing locally. + * + * Enumerating the missing slots per language is the fix that CANNOT be taken here: + * giving `ru` a `_few` would be a key `en` lacks, which the parity assertions above + * fail by design. The fix that composes with parity is the BASE key (no suffix) — + * always in i18next's lookup chain, so every category a pack did not enumerate lands + * on it, in the pack's own language, and the key set stays identical across ten packs. + * + * So this file owns the rule "a plural family must carry a base key" for a measured + * reason rather than by convenience — the two candidate homes were compared: + * + * - `scripts/check-i18n-call-site-keys.mjs` reads exactly ONE pack + * (`collectEnKeys`, `packages/i18n/src/locales/en.ts`). Slot coverage is a + * per-pack fact about `ru` and `ar`; an `en`-only instrument cannot state it, and + * it only sees families reached from a statically parsable `t()` literal — a + * family added to the packs before its call site lands (the objectui#3546 + * transition, which ran for months) would be invisible. Tightening its + * `resolvesLeaf` would also make it report a complete-but-baseless family as + * `missing-key`, whose remediation text reads "The key exists in no locale pack" — + * false for a family nine packs define. + * - Here, the rule is pack-intrinsic: it walks all ten packs' own key sets, needs no + * call site to exist, and fails in `pnpm test` at PR time. */ import { describe, it, expect } from 'vitest'; import { builtInLocales } from '../locales'; @@ -141,3 +173,89 @@ describe('all locale packs are at full key parity with en (objectui#2872)', () = expect(mismatches).toEqual([]); }); }); + +/** + * i18next's plural suffixes, CLDR order. Deliberately the same list as + * `scripts/check-i18n-call-site-keys.mjs`'s `PLURAL_SUFFIXES`, and asserted equal to + * `Intl.PluralRules`' own vocabulary below so the two cannot drift apart silently. + */ +const PLURAL_SUFFIXES = ['_zero', '_one', '_two', '_few', '_many', '_other'] as const; + +const ALL_LOCALES = Object.keys(builtInLocales) as LocaleCode[]; + +/** Every leaf path of a pack — no `OUTBOUND_KEYS` subtraction: a base key must be a + * real leaf of the SAME pack, whatever the parity exemptions are. */ +const leavesOf = (pack: unknown) => new Set(keyPaths(pack)); + +/** + * The plural families of one pack: base path → the suffixes it defines. + * A leaf whose name merely ends in one of the suffixes IS a family member — that is + * exactly how i18next reads it, so a key accidentally named `foo_one` is a real + * defect here and not a false positive. + */ +function familiesOf(pack: unknown): Map { + const families = new Map(); + for (const path of leavesOf(pack)) { + const suffix = PLURAL_SUFFIXES.find((s) => path.endsWith(s) && path.length > s.length); + if (suffix === undefined) continue; + const base = path.slice(0, -suffix.length); + families.set(base, [...(families.get(base) ?? []), suffix]); + } + return families; +} + +describe('every plural family carries a base key (objectui#3863)', () => { + it('the walk finds the families it is meant to judge — not an empty assertion', () => { + // Without this, deleting every plural family (or breaking `familiesOf`) would + // make the rule below trivially green. The count is `en`'s and parity carries it + // to the other nine; it is a floor, not a pin, so a new family does not have to + // edit this line — only a family DISAPPEARING has to be explained. + expect(familiesOf(builtInLocales.en).size).toBeGreaterThanOrEqual(5); + expect(ALL_LOCALES).toHaveLength(10); + // The suffix list is i18next's, which takes it from `Intl.PluralRules`. Compared + // as sets against the union of all ten packs' languages so a CLDR category this + // repo can actually meet cannot be missing from the list above. + const categories = new Set( + ALL_LOCALES.flatMap((l) => new Intl.PluralRules(l).resolvedOptions().pluralCategories), + ); + expect([...categories].map((c) => `_${c}`).sort()).toEqual([...PLURAL_SUFFIXES].sort()); + }); + + it.each(ALL_LOCALES)('%s defines the base key of every plural family it has', (lang) => { + // THE rule. i18next resolves `key_` for the one category the number + // needs; the base key is the only slot that answers for every category the pack + // did not spell out, and it answers IN THIS PACK instead of falling through + // `fallbackLng` to English. A family without it leaks English at exactly the + // counts its language meets first (objectui#3863: `ru` 2-20, `ar` 2-99). + const leaves = leavesOf(builtInLocales[lang]); + const baseless = [...familiesOf(builtInLocales[lang])] + .filter(([base]) => !leaves.has(base)) + .map(([base, suffixes]) => `${base} [${suffixes.sort().join(',')}] has no base key`) + .sort(); + expect(baseless, `${lang}: ${baseless.length} plural family/families with no base key`).toEqual( + [], + ); + }); + + it('the rule bites — five of the ten packs have categories that only a base key can serve', () => { + // Why the rule is not cosmetic, stated as data rather than prose. `en`/`de` and + // `zh`/`ja`/`ko` genuinely cannot reach the base key (their whole category set is + // covered by `_one`/`_other`), so for them it is parity ballast; for the other + // six it is the slot a real user hits. + const reachable = ALL_LOCALES.filter((l) => + new Intl.PluralRules(l) + .resolvedOptions() + .pluralCategories.some((c) => c !== 'one' && c !== 'other'), + ); + expect(reachable.sort()).toEqual(['ar', 'es', 'fr', 'pt', 'ru']); + // …and `ru`/`ar` reach it at everyday counts, which is what makes this a + // user-visible defect rather than a theoretical one: `fr`/`es`/`pt` only use + // `many` from a million up. + expect(new Intl.PluralRules('ru').select(3)).toBe('few'); + expect(new Intl.PluralRules('ru').select(7)).toBe('many'); + expect(new Intl.PluralRules('ar').select(2)).toBe('two'); + expect(new Intl.PluralRules('ar').select(30)).toBe('many'); + expect(new Intl.PluralRules('fr').select(100)).toBe('other'); + expect(new Intl.PluralRules('fr').select(1_000_000)).toBe('many'); + }); +}); diff --git a/packages/i18n/src/__tests__/showEmptyRelated-plural-base-3863.test.tsx b/packages/i18n/src/__tests__/showEmptyRelated-plural-base-3863.test.tsx new file mode 100644 index 000000000..3cec07d0c --- /dev/null +++ b/packages/i18n/src/__tests__/showEmptyRelated-plural-base-3863.test.tsx @@ -0,0 +1,286 @@ +/** + * `detail.showEmptyRelated` renders in the user's own language at EVERY plural + * category — objectui#3863, the base-key fix, asserted through the real i18next. + * + * ## What was broken + * + * This was the repo's only pre-existing i18next plural family, and all ten packs + * defined exactly two slots: `_one` and `_other`. i18next asks `Intl.PluralRules` + * for the ONE suffix a language needs for that number and, finding no such slot, + * walks `fallbackLng` to `en` — so the button rendered ENGLISH at: + * + * - `ru` counts 2-4 (`few`) and 5-20, 25-30, … (`many`) + * - `ar` counts 0 (`zero`), 2 (`two`), 3-10 (`few`), 11-99 (`many`) + * + * The call site is the collapsed-empties button in + * `packages/plugin-detail/src/renderers/record-reference-rail.tsx`, whose count is + * `emptyTitles.length` — so 2 to 4 are the *most common* values it ever takes, and a + * Russian user essentially always saw English. + * + * ## Why the fix is a base key and not `_few`/`_many` + * + * `all-locales-key-parity.test.ts` requires the ten packs to hold IDENTICAL key sets, + * so giving `ru` a `_few` would be "a key `en` lacks" and fails by design. The base + * key (no suffix) is always in i18next's lookup chain, so every category a pack did + * not enumerate lands on it — in that pack's language — while the key set stays + * identical everywhere. Established by objectui#3546 slice six for `perm.facet.*` + * (`perm-home-namespace-3546.test.tsx`, the template this file follows); that slice + * filed this family rather than fixing it, and this is that debt. + * + * The rule itself ("a plural family must carry a base key") lives in + * `all-locales-key-parity.test.ts` — pack-intrinsic, so the NEXT plural family fails + * at PR time without needing a call site to exist. This file pins the rendering. + * + * ## Why these assertions are the ones that discriminate + * + * `en` output is identical before and after (its categories are exactly `one`/`other`, + * so it never reaches the base key), and so is `de`/`zh`/`ja`/`ko` output. Every + * assertion that can tell the fix from its absence is a `ru` or `ar` one — the rest + * prove reachability and prove the English wording did not move. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import React from 'react'; +import { I18nProvider, useObjectTranslation } from '../provider'; +import { builtInLocales } from '../locales/index'; + +const KEY = 'detail.showEmptyRelated'; +const SLOTS = [KEY, `${KEY}_one`, `${KEY}_other`] as const; + +/** Derived from the map, not `string[]`: `Object.keys` erases which keys it + * enumerated, making `builtInLocales[lang]` an implicit-`any` index (TS7053). */ +type LocaleCode = keyof typeof builtInLocales; +const LANGS = Object.keys(builtInLocales) as LocaleCode[]; + +/** The counts the card measured, plus 0 and 21 — `ru` 21 is `one` (not `other`), + * which is the trap a "1 is singular, everything else is plural" model falls into. */ +const COUNTS = [0, 1, 2, 3, 5, 11, 21, 100] as const; + +const CALL_SITE = 'packages/plugin-detail/src/renderers/record-reference-rail.tsx'; +const DEFAULTS = 'packages/plugin-detail/src/useDetailTranslation.ts'; + +const at = (pack: unknown, path: string): unknown => + path.split('.').reduce((n, k) => (n as Record | undefined)?.[k], pack); + +const wrapperFor = (lang: string) => + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); + }; + +/** Read a source file and prove the read landed, or assertions on it are vacuous. + * `import.meta.url` is not a file: URL in the dom project, so resolve from the + * vitest root — which the invocation guard pins to the repo root. */ +function sourceOf(rel: string): string { + const path = join(process.cwd(), rel); + expect(existsSync(path), `source not found at ${path}`).toBe(true); + return readFileSync(path, 'utf8'); +} + +beforeEach(() => { + // The provider persists the last language (objectstack#5406); without this a stale + // locale leaks into the next case. + window.localStorage.clear(); +}); + +describe('objectui#3863 — detail.showEmptyRelated carries a base key in all ten packs', () => { + it('covers ten packs and three slots — guards the loops from emptying', () => { + expect(LANGS).toHaveLength(10); + expect(SLOTS).toHaveLength(3); + expect(COUNTS.length).toBeGreaterThanOrEqual(7); + }); + + it.each(LANGS)('%s defines all three slots as non-empty strings', (lang) => { + for (const slot of SLOTS) { + const value = at(builtInLocales[lang], slot); + expect(typeof value, `${lang}.${slot}`).toBe('string'); + expect((value as string).trim().length, `${lang}.${slot} is empty`).toBeGreaterThan(0); + } + }); + + it('every slot in every pack interpolates exactly {{count}}', () => { + // A translation that drops the hole renders a button with no number in it; one + // that invents a second hole renders braces verbatim. Two regexes on purpose — a + // /g regex is stateful and reusing it for `.test()` skips every other match. + const HOLES = /\{\{\w+\}\}/g; + for (const lang of LANGS) { + for (const slot of SLOTS) { + const holes = ((at(builtInLocales[lang], slot) as string).match(HOLES) ?? []).join(','); + expect(holes, `${lang}.${slot}`).toBe('{{count}}'); + } + } + }); + + it('the premise: the call site still asks for this key with a count', () => { + // Everything below describes what that button renders. If it stopped passing + // `count`, or moved to another key, these assertions would be about nothing. + const src = sourceOf(CALL_SITE); + expect(src).toContain("t('detail.showEmptyRelated', {"); + expect(src).toContain("defaultValue: '+ {{count}} empty',"); + expect(src).toContain('count: emptyTitles.length,'); + }); + + describe('through the real i18next — provider mounted', () => { + it.each(LANGS)('%s stays in its own language at every plural category', (lang) => { + // THE assertion. `Intl.PluralRules` is the same resolver i18next uses, so + // `select()` predicts which slot must answer: a category the pack enumerates + // takes its suffixed slot, everything else falls to the base key. Computing the + // expectation from the pack (rather than hard-coding strings) is what makes this + // fail loudly if a pack's WORDING moves without its slots moving. + const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }); + const rules = new Intl.PluralRules(lang); + const enumerated = new Set(['one', 'other']); + for (const count of COUNTS) { + const category = rules.select(count); + const slot = enumerated.has(category) ? `${KEY}_${category}` : KEY; + const expected = (at(builtInLocales[lang], slot) as string).replace( + '{{count}}', + String(count), + ); + expect(result.current.t(KEY, { count }), `${lang} at count=${count} (${category})`).toBe( + expected, + ); + } + }); + + it('ru renders Russian at few and many — the counts this button takes most often', () => { + // Named rather than left to the loop above, because these are the exact values + // the card measured rendering English, and the numbers are the point. + expect(new Intl.PluralRules('ru').select(2)).toBe('few'); + expect(new Intl.PluralRules('ru').select(3)).toBe('few'); + expect(new Intl.PluralRules('ru').select(7)).toBe('many'); + expect(new Intl.PluralRules('ru').select(11)).toBe('many'); + // 21 is `one` in Russian — the base key must NOT swallow it. + expect(new Intl.PluralRules('ru').select(21)).toBe('one'); + + const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor('ru') }); + const { t } = result.current; + expect(t(KEY, { count: 3 })).toBe('+ Пустых: 3'); + expect(t(KEY, { count: 7 })).toBe('+ Пустых: 7'); + expect(t(KEY, { count: 11 })).toBe('+ Пустых: 11'); + expect(t(KEY, { count: 1 })).toBe('+ 1 пустое'); + expect(t(KEY, { count: 21 })).toBe('+ 21 пустое'); + // …and NOT the English the fallback chain produced before the base key existed. + expect(t(KEY, { count: 3 })).not.toBe('+ 3 empty'); + for (const count of COUNTS) { + expect(t(KEY, { count }), `ru leaked English at ${count}`).not.toContain('empty'); + } + }); + + it('ar renders Arabic at zero, two, few and many', () => { + expect(new Intl.PluralRules('ar').select(0)).toBe('zero'); + expect(new Intl.PluralRules('ar').select(2)).toBe('two'); + expect(new Intl.PluralRules('ar').select(5)).toBe('few'); + expect(new Intl.PluralRules('ar').select(30)).toBe('many'); + // 100 is `other` in Arabic and was already correct before the fix — kept so a + // regression that breaks the enumerated slots is visible too. + expect(new Intl.PluralRules('ar').select(100)).toBe('other'); + + const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor('ar') }); + const { t } = result.current; + expect(t(KEY, { count: 2 })).toBe('+ 2 فارغ(فارغة)'); + expect(t(KEY, { count: 5 })).toBe('+ 5 فارغ(فارغة)'); + expect(t(KEY, { count: 30 })).toBe('+ 30 فارغ(فارغة)'); + expect(t(KEY, { count: 1 })).toBe('+ 1 فارغ'); + expect(t(KEY, { count: 100 })).toBe('+ 100 فارغة'); + for (const count of COUNTS) { + expect(t(KEY, { count }), `ar leaked English at ${count}`).not.toContain('empty'); + } + }); + + it('no pack leaks the English string at any count', () => { + // The aggregate form of the defect, stated once over the whole matrix: "empty" + // may appear ONLY in the pack that owns that word. Before the base key this was + // false for 5 of the 8 ru counts and 5 of the 8 ar counts. + const leaks: string[] = []; + for (const lang of LANGS.filter((l) => l !== 'en')) { + window.localStorage.clear(); + const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }); + for (const count of COUNTS) { + const value = result.current.t(KEY, { count }); + if (value === `+ ${count} empty`) leaks.push(`${lang} @ ${count}: ${value}`); + } + } + expect(leaks).toEqual([]); + }); + + it('en is byte-for-byte unchanged — this fix must not move any English copy', () => { + // The other half of a translation fix: the language that was already correct has + // to stay correct, at every count and through every slot. + const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor('en') }); + for (const count of COUNTS) { + expect(result.current.t(KEY, { count }), `en at count=${count}`).toBe(`+ ${count} empty`); + } + for (const slot of SLOTS) { + expect(at(builtInLocales.en, slot), `en.${slot}`).toBe('+ {{count}} empty'); + } + }); + + it('the packs that cannot reach the base key still render their own language', () => { + // de/zh/ja/ko have no category outside `one`/`other`, so their base key is + // ballast. Pinned anyway: a "fix" that accidentally routed everything through the + // base key would be invisible in ru/ar (where base and _other agree in meaning) + // and visible here only if someone asserts it. + const CASES: Array<[LocaleCode, number, string]> = [ + ['de', 1, '+ 1 leer'], + ['de', 7, '+ 7 leer'], + ['zh', 3, '+ 3 个为空'], + ['ja', 3, '+ 3 件(空)'], + ['ko', 3, '+ 3개 비어 있음'], + ['fr', 1, '+ 1 vide'], + ['fr', 7, '+ 7 vides'], + ['es', 7, '+ 7 vacíos'], + ['pt', 7, '+ 7 vazios'], + ]; + for (const [lang, count, expected] of CASES) { + window.localStorage.clear(); + const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }); + expect(result.current.t(KEY, { count }), `${lang} at ${count}`).toBe(expected); + } + }); + + it('fr, es and pt reach their base key only from a million up', () => { + // Their third category is `many`, and CLDR starts it at 1e6 — so their base key + // is real but practically unreachable, which is why it repeats `_other` instead + // of restructuring the way ru and ar had to. + for (const lang of ['fr', 'es', 'pt'] as const) { + expect(new Intl.PluralRules(lang).select(100)).toBe('other'); + expect(new Intl.PluralRules(lang).select(1_000_000)).toBe('many'); + window.localStorage.clear(); + const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }); + // Whichever slot answers, it is this pack's own words — never English. + expect(result.current.t(KEY, { count: 1_000_000 }), `${lang} at 1e6`).toBe( + (at(builtInLocales[lang], KEY) as string).replace('{{count}}', '1000000'), + ); + } + }); + }); + + describe('the provider-less path — the same key through the defaults table', () => { + it("fallbackT resolves the base key literally, which is why the table needed one", () => { + // `createSafeTranslation`'s `fallbackT` reads `defaults[key] || key` and never + // appends a plural suffix, so the two suffixed rows are unreachable through it: + // before this fix the provider-less path answered with the RAW KEY. Pinned + // against the source so a refactor that teaches `fallbackT` plural resolution + // has to come past this comment. + const defaults = sourceOf(DEFAULTS); + expect(defaults).toContain("'detail.showEmptyRelated': '+ {{count}} empty',"); + expect(defaults).toContain("'detail.showEmptyRelated_one': '+ {{count}} empty',"); + const helper = sourceOf('packages/i18n/src/useSafeTranslation.ts'); + expect(helper).toContain('let value = defaults[key] || key;'); + }); + }); + + it('the base key is what parity now requires of every plural family', () => { + // The cross-reference that keeps this fix from being undone in isolation: the rule + // lives next door, and it is stated over ALL families, not just this one. + const parity = sourceOf('packages/i18n/src/__tests__/all-locales-key-parity.test.ts'); + expect(parity).toContain('every plural family carries a base key (objectui#3863)'); + expect(parity).toContain('defines the base key of every plural family it has'); + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index ceec7f891..f79a32f7c 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -951,6 +951,12 @@ const ar = { highlightFields: "الحقول الرئيسية", createdBy: "أنشأه", updatedBy: "حدّثه", + // objectui#3863 — base key, and in ar this is the slot users actually hit: `zero`, + // `two` (2), `few` (3-10) and `many` (11-99) are unenumerated, so before this key + // those counts rendered ENGLISH. It uses the «{{count}} مفرد(جمع)» marker this + // pack writes throughout (perm.facet.objects, and the sibling toggle + // showEmptyFields below), which reads correctly for every count. + showEmptyRelated: "+ {{count}} فارغ(فارغة)", showEmptyRelated_one: "+ {{count}} فارغ", showEmptyRelated_other: "+ {{count}} فارغة", copyEmail: "نسخ البريد الإلكتروني", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index e813dafbe..da7ac79af 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -947,6 +947,9 @@ const de = { updatedBy: "Aktualisiert von", created: "Erstellt", updated: "Aktualisiert", + // objectui#3863 — base key. de's categories are exactly `one`/`other`, so this + // slot is unreachable here and equals `_other` for key-set parity. + showEmptyRelated: "+ {{count}} leer", showEmptyRelated_one: "+ {{count}} leer", showEmptyRelated_other: "+ {{count}} leer", copyEmail: "E-Mail kopieren", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index bc059fba8..8f67b38eb 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1002,6 +1002,13 @@ const en = { nextRecordKey: 'Next record (→)', lastRecord: 'Last record (End)', noRecords: 'No records', + // objectui#3863 — the BASE key is the slot every plural category a pack did not + // enumerate resolves to, keeping that pack in its own language instead of falling + // through `fallbackLng` to English. `en` itself can never reach it (its categories + // are exactly `one`/`other`), so this value is parity ballast — and it must stay + // byte-identical to the call site's inline `defaultValue` in + // `record-reference-rail.tsx`, which `check:i18n-keys` now judges as class 3. + showEmptyRelated: '+ {{count}} empty', showEmptyRelated_one: '+ {{count}} empty', showEmptyRelated_other: '+ {{count}} empty', searchWhileNavigating: 'Search while navigating', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 15dc75d7d..3dbd10e76 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -951,6 +951,9 @@ const es = { updatedBy: "Actualizado por", created: "Creado", updated: "Actualizado", + // objectui#3863 — base key. es's third category `many` starts at a million, where + // the plural form is correct, so the base repeats `_other`. + showEmptyRelated: "+ {{count}} vacíos", showEmptyRelated_one: "+ {{count}} vacío", showEmptyRelated_other: "+ {{count}} vacíos", copyEmail: "Copiar correo", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 575cfe62e..034b0fe45 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -947,6 +947,11 @@ const fr = { highlightFields: "Champs clés", createdBy: "Créé par", updatedBy: "Mis à jour par", + // objectui#3863 — base key. fr has a third category, `many`, which CLDR uses from + // a million up; the plural form is correct there, so the base repeats `_other` + // rather than restructuring (unlike ru/ar, whose unenumerated categories cover + // everyday counts). + showEmptyRelated: "+ {{count}} vides", showEmptyRelated_one: "+ {{count}} vide", showEmptyRelated_other: "+ {{count}} vides", copyEmail: "Copier l'e-mail", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 281a4b6ec..44d59af5e 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -947,6 +947,9 @@ const ja = { updatedBy: "更新者", created: "作成", updated: "更新", + // objectui#3863 — base key. ja has a single plural category (`other`), so this + // slot is unreachable here and equals `_other` for key-set parity. + showEmptyRelated: "+ {{count}} 件(空)", showEmptyRelated_one: "+ {{count}} 件(空)", showEmptyRelated_other: "+ {{count}} 件(空)", copyEmail: "メールをコピー", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index e5290353a..08fb85682 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -947,6 +947,9 @@ const ko = { updatedBy: "업데이트한 사람", created: "작성됨", updated: "업데이트됨", + // objectui#3863 — base key. ko has a single plural category (`other`), so this + // slot is unreachable here and equals `_other` for key-set parity. + showEmptyRelated: "+ {{count}}개 비어 있음", showEmptyRelated_one: "+ {{count}}개 비어 있음", showEmptyRelated_other: "+ {{count}}개 비어 있음", copyEmail: "이메일 복사", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index d902a6172..f8190f684 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -946,6 +946,9 @@ const pt = { highlightFields: "Campos principais", createdBy: "Criado por", updatedBy: "Atualizado por", + // objectui#3863 — base key. pt's third category `many` starts at a million, where + // the plural form is correct, so the base repeats `_other`. + showEmptyRelated: "+ {{count}} vazios", showEmptyRelated_one: "+ {{count}} vazio", showEmptyRelated_other: "+ {{count}} vazios", copyEmail: "Copiar e-mail", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 302b7f37b..c8e40d763 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -953,6 +953,13 @@ const ru = { deleteRowTitle: "Удалить запись", createdBy: "Создано", updatedBy: "Обновлено", + // objectui#3863 — base key, and in ru this is the slot users actually hit: `few` + // (2-4) and `many` (5-20, 25-30, …) are unenumerated, so before this key those + // counts rendered ENGLISH. The value therefore cannot agree with the numeral — + // it uses the «Существительное: {{count}}» form this pack already writes 22 times + // (lookup.recordCount `Записей: {{count}}`, notifications.groupCount), which is + // correct for EVERY Russian count. `_one` keeps the agreeing form for 1/21/31/… + showEmptyRelated: "+ Пустых: {{count}}", showEmptyRelated_one: "+ {{count}} пустое", showEmptyRelated_other: "+ {{count}} пустых", copyEmail: "Копировать email", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 84a77a2ff..d74cd09b0 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -959,6 +959,9 @@ const zh = { nextRecordKey: '下一条记录 (→)', lastRecord: '最后一条记录 (End)', noRecords: '无记录', + // objectui#3863 — base key. zh has a single plural category (`other`), so this + // slot is unreachable here and equals `_other` for key-set parity. + showEmptyRelated: '+ {{count}} 个为空', showEmptyRelated_one: '+ {{count}} 个为空', showEmptyRelated_other: '+ {{count}} 个为空', searchWhileNavigating: '导航时搜索', diff --git a/packages/plugin-detail/src/useDetailTranslation.ts b/packages/plugin-detail/src/useDetailTranslation.ts index f9c08f727..02b77b179 100644 --- a/packages/plugin-detail/src/useDetailTranslation.ts +++ b/packages/plugin-detail/src/useDetailTranslation.ts @@ -152,6 +152,13 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.nextRecordKey': 'Next record (→)', 'detail.lastRecord': 'Last record (End)', 'detail.noRecords': 'No records', + // objectui#3863 — the packs grew a BASE key for this family, and this map has to + // mirror it for a reason of its own: `fallbackT` (createSafeTranslation) resolves + // `defaults[key]` LITERALLY and never appends a plural suffix, so with only the two + // suffixed rows below the provider-less path answered `t('detail.showEmptyRelated', + // { count })` with the raw key. The base row is the only one that path can reach; + // the suffixed rows are kept so the map's key set still mirrors the packs'. + 'detail.showEmptyRelated': '+ {{count}} empty', 'detail.showEmptyRelated_one': '+ {{count}} empty', 'detail.showEmptyRelated_other': '+ {{count}} empty', 'detail.searchWhileNavigating': 'Search while navigating',