mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: use the right plural forms on Android instead of the English ones
Hermes has no `Intl.PluralRules`, so `src/i18n/format.ts` fell back to the English rule (1 is "one", everything else "other") for every language. Russian showed "5 письма" instead of "5 писем", Polish, Czech, Arabic and the other languages with more than two forms were wrong in the same way, and French treated 0 as plural. Add the CLDR 48 cardinal rules for the app's 27 locales and use them whenever `Intl.PluralRules` is missing or rejects the locale. Engines that have it keep using it; a test checks the built-in rules against it for every locale.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatMessage, pluralCategory } from '../format';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { builtinPluralCategory, formatMessage, pluralCategory } from '../format';
|
||||
|
||||
describe('formatMessage', () => {
|
||||
it('substitutes simple arguments', () => {
|
||||
@@ -66,3 +66,97 @@ describe('pluralCategory', () => {
|
||||
expect(pluralCategory(7, 'zz-not-a-locale')).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
// The app's locales (src/i18n/index.ts SUPPORTED_LOCALES).
|
||||
const APP_LOCALES = [
|
||||
'ar', 'ca', 'cs', 'sk', 'da', 'de', 'en', 'fa', 'es', 'fr', 'he', 'it', 'hu', 'lv',
|
||||
'nl', 'nb', 'pl', 'pt', 'ro', 'tr', 'ru', 'uk', 'ko', 'ja', 'mn', 'zh', 'zh-TW',
|
||||
];
|
||||
|
||||
describe('builtinPluralCategory (Hermes has no Intl.PluralRules)', () => {
|
||||
// Sample numbers from the CLDR language plural rules chart.
|
||||
const CLDR_EXAMPLES: Record<string, Partial<Record<string, number[]>>> = {
|
||||
ru: { one: [1, 21, 31, 101, 1001], few: [2, 3, 4, 22, 24, 102, 1002], many: [0, 5, 11, 12, 14, 19, 20, 25, 100, 111, 1000], other: [0.5, 1.5, 10.1] },
|
||||
uk: { one: [1, 21, 101], few: [2, 4, 22, 104], many: [0, 5, 11, 14, 20, 100, 1000], other: [0.5, 1.5] },
|
||||
pl: { one: [1], few: [2, 3, 4, 22, 24, 102, 1002], many: [0, 5, 11, 12, 14, 19, 21, 25, 100, 112, 1000], other: [0.5, 1.5, 10.1] },
|
||||
cs: { one: [1], few: [2, 3, 4], many: [0.5, 1.5, 10.1], other: [0, 5, 19, 100, 1000] },
|
||||
sk: { one: [1], few: [2, 3, 4], many: [0.5, 1.5], other: [0, 5, 100] },
|
||||
lv: { zero: [0, 10, 11, 19, 20, 30, 100, 1000, 0.11], one: [1, 21, 31, 101, 0.1, 1.1, 0.01, 0.21], other: [2, 9, 22, 29, 102, 0.2, 0.02, 1.5] },
|
||||
ar: { zero: [0], one: [1], two: [2], few: [3, 10, 103, 110, 1003], many: [11, 26, 99, 111, 1011], other: [100, 101, 102, 200, 1000, 0.1, 1.5] },
|
||||
he: { one: [1, 0.5, 0.1], two: [2], other: [0, 3, 10, 17, 20, 100, 1000, 1.5] },
|
||||
ro: { one: [1], few: [0, 2, 16, 19, 101, 119, 1001, 0.5, 1.5], other: [20, 35, 100, 120, 1000] },
|
||||
fr: { one: [0, 1, 1.5], many: [1000000, 2000000], other: [2, 17, 100, 1000, 10000] },
|
||||
es: { one: [1], many: [1000000], other: [0, 2, 16, 100, 1000, 0.5, 1.5] },
|
||||
it: { one: [1], many: [1000000], other: [0, 2, 16, 100, 1.5] },
|
||||
ca: { one: [1], many: [1000000], other: [0, 2, 16, 100, 1.5] },
|
||||
pt: { one: [0, 1, 0.5, 1.5], many: [1000000], other: [2, 17, 100, 1000] },
|
||||
da: { one: [1, 0.1, 1.6], other: [0, 2, 16, 100, 2.5] },
|
||||
fa: { one: [0, 1, 0.5], other: [2, 17, 100, 1.5] },
|
||||
de: { one: [1], other: [0, 2, 16, 100, 1.5] },
|
||||
en: { one: [1], other: [0, 2, 16, 100, 1.5] },
|
||||
nl: { one: [1], other: [0, 2, 1.5] },
|
||||
hu: { one: [1], other: [0, 2, 1.5] },
|
||||
nb: { one: [1], other: [0, 2, 1.5] },
|
||||
tr: { one: [1], other: [0, 2, 1.5] },
|
||||
mn: { one: [1], other: [0, 2, 1.5] },
|
||||
ko: { other: [0, 1, 2, 1.5] },
|
||||
ja: { other: [0, 1, 2, 1.5] },
|
||||
zh: { other: [0, 1, 2, 1.5] },
|
||||
'zh-TW': { other: [0, 1, 2, 1.5] },
|
||||
};
|
||||
|
||||
for (const [locale, categories] of Object.entries(CLDR_EXAMPLES)) {
|
||||
it(`follows the CLDR examples for ${locale}`, () => {
|
||||
for (const [category, samples] of Object.entries(categories)) {
|
||||
for (const n of samples ?? []) {
|
||||
expect(`${n} → ${builtinPluralCategory(n, locale)}`).toBe(`${n} → ${category}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('covers every app locale', () => {
|
||||
expect(Object.keys(CLDR_EXAMPLES).sort()).toEqual([...APP_LOCALES].sort());
|
||||
});
|
||||
|
||||
it('agrees with the engine Intl.PluralRules for every app locale', () => {
|
||||
const samples: number[] = [];
|
||||
for (let n = 0; n <= 1200; n++) samples.push(n);
|
||||
samples.push(1000000, 2000000, 1234567, 10000000, -1, -2, -5);
|
||||
for (let d = 0; d <= 40; d++) samples.push(d / 10, d / 100 + 1, d / 100 + 20);
|
||||
const mismatches: string[] = [];
|
||||
for (const locale of APP_LOCALES) {
|
||||
const intl = new Intl.PluralRules(locale);
|
||||
for (const n of samples) {
|
||||
const expected = intl.select(n);
|
||||
const actual = builtinPluralCategory(n, locale);
|
||||
if (actual !== expected) mismatches.push(`${locale} ${n}: ${actual}, Intl says ${expected}`);
|
||||
}
|
||||
}
|
||||
expect(mismatches.slice(0, 20)).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses the English rule for languages it has no rule for', () => {
|
||||
expect(builtinPluralCategory(1, 'xx')).toBe('one');
|
||||
expect(builtinPluralCategory(2, 'xx')).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMessage without Intl.PluralRules', () => {
|
||||
it('picks the plural form from the built-in rules', async () => {
|
||||
const intl = Intl as unknown as { PluralRules: unknown };
|
||||
const original = intl.PluralRules;
|
||||
intl.PluralRules = undefined;
|
||||
try {
|
||||
vi.resetModules();
|
||||
const fresh = await import('../format');
|
||||
const msg = '{count, plural, one {# письмо} few {# письма} many {# писем} other {# письма}}';
|
||||
expect(fresh.formatMessage(msg, { count: 2 }, 'ru')).toBe('2 письма');
|
||||
expect(fresh.formatMessage(msg, { count: 5 }, 'ru')).toBe('5 писем');
|
||||
expect(fresh.formatMessage(msg, { count: 21 }, 'ru')).toBe('21 письмо');
|
||||
expect(fresh.pluralCategory(0, 'fr')).toBe('one');
|
||||
} finally {
|
||||
intl.PluralRules = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+126
-4
@@ -30,10 +30,132 @@ function pluralRulesFor(locale: string): Intl.PluralRules | null {
|
||||
return rules;
|
||||
}
|
||||
|
||||
// ─── Built-in CLDR cardinal rules ─────────────────────────────────────────
|
||||
// Hermes ships no Intl.PluralRules, so without these every locale would get
|
||||
// the English one/other split ("2 писем" instead of "2 письма"). These are the
|
||||
// CLDR 48 cardinal rules (unicode.org/cldr/charts/latest/supplemental/
|
||||
// language_plural_rules.html) for the app's locales, written out from the
|
||||
// rule text. Operands as in UTS #35: n absolute value, i integer digits,
|
||||
// v number of visible fraction digits, f visible fraction digits (with
|
||||
// Intl's default formatting there are no trailing zeros, so t = f).
|
||||
|
||||
interface PluralOperands {
|
||||
n: number;
|
||||
i: number;
|
||||
v: number;
|
||||
f: number;
|
||||
}
|
||||
|
||||
function pluralOperands(value: number): PluralOperands {
|
||||
// Intl.PluralRules formats with at most three fraction digits by default.
|
||||
const n = Number(Math.abs(value).toFixed(3));
|
||||
if (Number.isInteger(n)) return { n, i: n, v: 0, f: 0 };
|
||||
const [int, frac] = String(n).split('.');
|
||||
return { n, i: Number(int), v: frac.length, f: Number(frac) };
|
||||
}
|
||||
|
||||
// CLDR ranges (`a..b`) only match integers: 3.5 is not in 3..10.
|
||||
function inRange(x: number, lo: number, hi: number): boolean {
|
||||
return Number.isInteger(x) && x >= lo && x <= hi;
|
||||
}
|
||||
|
||||
type PluralRule = (o: PluralOperands) => PluralCategory;
|
||||
|
||||
// "many" for whole millions (fr "1 million de …"): e = 0 and i != 0 and
|
||||
// i % 1000000 = 0 and v = 0. Plain numbers never use compact exponents.
|
||||
const isMillions = ({ i, v }: PluralOperands) => v === 0 && i !== 0 && i % 1000000 === 0;
|
||||
|
||||
const oneForIntegerOne: PluralRule = ({ i, v }) => (i === 1 && v === 0 ? 'one' : 'other');
|
||||
const oneForOne: PluralRule = ({ n }) => (n === 1 ? 'one' : 'other');
|
||||
const otherOnly: PluralRule = () => 'other';
|
||||
|
||||
// ru, uk
|
||||
const eastSlavic: PluralRule = ({ i, v }) => {
|
||||
if (v !== 0) return 'other';
|
||||
if (i % 10 === 1 && i % 100 !== 11) return 'one';
|
||||
if (inRange(i % 10, 2, 4) && !inRange(i % 100, 12, 14)) return 'few';
|
||||
return 'many';
|
||||
};
|
||||
|
||||
// cs, sk
|
||||
const czechSlovak: PluralRule = ({ i, v }) => {
|
||||
if (v !== 0) return 'many';
|
||||
if (i === 1) return 'one';
|
||||
if (inRange(i, 2, 4)) return 'few';
|
||||
return 'other';
|
||||
};
|
||||
|
||||
const PLURAL_RULES: Record<string, PluralRule> = {
|
||||
ar: ({ n }) => {
|
||||
if (n === 0) return 'zero';
|
||||
if (n === 1) return 'one';
|
||||
if (n === 2) return 'two';
|
||||
if (inRange(n % 100, 3, 10)) return 'few';
|
||||
if (inRange(n % 100, 11, 99)) return 'many';
|
||||
return 'other';
|
||||
},
|
||||
ca: (o) => (o.i === 1 && o.v === 0 ? 'one' : isMillions(o) ? 'many' : 'other'),
|
||||
cs: czechSlovak,
|
||||
sk: czechSlovak,
|
||||
da: ({ n, i, f }) => (n === 1 || (f !== 0 && (i === 0 || i === 1)) ? 'one' : 'other'),
|
||||
de: oneForIntegerOne,
|
||||
en: oneForIntegerOne,
|
||||
fa: ({ n, i }) => (i === 0 || n === 1 ? 'one' : 'other'),
|
||||
es: (o) => (o.n === 1 ? 'one' : isMillions(o) ? 'many' : 'other'),
|
||||
fr: (o) => (o.i === 0 || o.i === 1 ? 'one' : isMillions(o) ? 'many' : 'other'),
|
||||
he: ({ i, v }) => {
|
||||
if ((i === 1 && v === 0) || (i === 0 && v !== 0)) return 'one';
|
||||
if (i === 2 && v === 0) return 'two';
|
||||
return 'other';
|
||||
},
|
||||
it: (o) => (o.i === 1 && o.v === 0 ? 'one' : isMillions(o) ? 'many' : 'other'),
|
||||
hu: oneForOne,
|
||||
lv: ({ n, v, f }) => {
|
||||
if (n % 10 === 0 || inRange(n % 100, 11, 19) || (v === 2 && inRange(f % 100, 11, 19))) return 'zero';
|
||||
if (
|
||||
(n % 10 === 1 && n % 100 !== 11)
|
||||
|| (v === 2 && f % 10 === 1 && f % 100 !== 11)
|
||||
|| (v !== 2 && f % 10 === 1)
|
||||
) return 'one';
|
||||
return 'other';
|
||||
},
|
||||
nl: oneForIntegerOne,
|
||||
nb: oneForOne,
|
||||
pl: ({ i, v }) => {
|
||||
if (v !== 0) return 'other';
|
||||
if (i === 1) return 'one';
|
||||
if (inRange(i % 10, 2, 4) && !inRange(i % 100, 12, 14)) return 'few';
|
||||
return 'many';
|
||||
},
|
||||
pt: (o) => (o.i === 0 || o.i === 1 ? 'one' : isMillions(o) ? 'many' : 'other'),
|
||||
ro: ({ n, i, v }) => {
|
||||
if (i === 1 && v === 0) return 'one';
|
||||
if (v !== 0 || n === 0 || (n !== 1 && inRange(n % 100, 1, 19))) return 'few';
|
||||
return 'other';
|
||||
},
|
||||
tr: oneForOne,
|
||||
ru: eastSlavic,
|
||||
uk: eastSlavic,
|
||||
ko: otherOnly,
|
||||
ja: otherOnly,
|
||||
mn: oneForOne,
|
||||
zh: otherOnly,
|
||||
};
|
||||
|
||||
/**
|
||||
* CLDR plural category for `n` in `locale`. Falls back to the English rule
|
||||
* (1 → one, everything else → other) when Intl.PluralRules is unavailable
|
||||
* (older Hermes builds) or rejects the locale.
|
||||
* Plural category from the built-in CLDR rules above, by language subtag
|
||||
* (`zh-TW` → `zh`). Languages without a rule get the English one.
|
||||
*/
|
||||
export function builtinPluralCategory(n: number, locale: string): PluralCategory {
|
||||
const language = locale.toLowerCase().split(/[-_]/)[0];
|
||||
const rule = PLURAL_RULES[language] ?? oneForIntegerOne;
|
||||
return rule(pluralOperands(n));
|
||||
}
|
||||
|
||||
/**
|
||||
* CLDR plural category for `n` in `locale`. Uses Intl.PluralRules where the
|
||||
* engine has it and the built-in rules where it doesn't (Hermes) or rejects
|
||||
* the locale.
|
||||
*/
|
||||
export function pluralCategory(n: number, locale: string): PluralCategory {
|
||||
const rules = pluralRulesFor(locale);
|
||||
@@ -44,7 +166,7 @@ export function pluralCategory(n: number, locale: string): PluralCategory {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return n === 1 ? 'one' : 'other';
|
||||
return builtinPluralCategory(n, locale);
|
||||
}
|
||||
|
||||
// Find the index of the `}` that closes the `{` at `open`, honouring nesting.
|
||||
|
||||
Reference in New Issue
Block a user