Получите все формы множественного числа для локали

Узнайте, для каких категорий множественного числа нужно подготовить переводы

Введение

При создании многоязычных приложений нужно использовать разные формы текста для разных количеств. На английском пишут «1 item» и «2 items». Это кажется простым, пока не начинаешь поддерживать другие языки.

В русском языке три разные формы в зависимости от числа. В арабском — шесть. В некоторых языках одна форма используется для всех количеств. Прежде чем переводить эти формы, нужно узнать, какие формы есть в каждом языке.

JavaScript позволяет узнать, какие категории множественного числа используются в локали. Метод resolvedOptions() экземпляра PluralRules возвращает свойство pluralCategories, в котором перечислены все формы множественного числа для локали. Это позволяет точно понять, какие переводы нужны, без догадок и ручных таблиц правил.

Что такое категории множественного числа

Категории множественного числа — это стандартизированные названия разных форм, используемых в языках. Unicode CLDR (Common Locale Data Repository) определяет шесть категорий: zero, one, two, few, many и other.

Не каждый язык использует все шесть категорий. В английском только две: one и other. Категория one применяется к числу 1, а other — ко всем остальным.

В арабском используются все шесть категорий. Категория zero — для 0, one — для 1, two — для 2, few — для чисел вроде 3–10, many — для 11–99, а other — для 100 и выше.

В русском три категории: one — для чисел, оканчивающихся на 1 (кроме 11), few — для чисел, оканчивающихся на 2–4 (кроме 12–14), и many — для всех остальных.

В японском и китайском языках используется только категория other, потому что в этих языках нет различия между единственным и множественным числом.

Эти категории отражают языковые правила каждого языка. Когда вы добавляете переводы, вы создаёте отдельную строку для каждой категории, которая используется в языке.

Получение категорий множественного числа с помощью resolvedOptions

Метод resolvedOptions() экземпляра PluralRules возвращает объект с информацией о правилах, включая используемые в локали категории множественного числа.

const enRules = new Intl.PluralRules('en-US');
const options = enRules.resolvedOptions();

console.log(options.pluralCategories);
// Output: ["one", "other"]

Свойство pluralCategories — это массив строк. Каждая строка — одно из шести стандартных имён категорий. В массиве содержатся только те категории, которые реально используются в локали.

Для английского массива содержатся one и other, потому что в английском различают единственное и множественное число.

В языке с более сложными правилами массив содержит больше категорий:

const arRules = new Intl.PluralRules('ar-EG');
const options = arRules.resolvedOptions();

console.log(options.pluralCategories);
// Output: ["zero", "one", "two", "few", "many", "other"]

В арабском используются все шесть категорий, поэтому массив содержит все шесть значений.

Как увидеть категории множественного числа для разных локалей

В разных языках разные правила образования множественного числа, поэтому используются разные наборы категорий. Сравните несколько языков, чтобы увидеть различия:

const locales = ['en-US', 'ar-EG', 'ru-RU', 'pl-PL', 'ja-JP', 'zh-CN'];

locales.forEach(locale => {
  const rules = new Intl.PluralRules(locale);
  const categories = rules.resolvedOptions().pluralCategories;
  console.log(`${locale}: [${categories.join(', ')}]`);
});

// Output:
// en-US: [one, other]
// ar-EG: [zero, one, two, few, many, other]
// ru-RU: [one, few, many, other]
// pl-PL: [one, few, many, other]
// ja-JP: [other]
// zh-CN: [other]

В английском две категории. В арабском — шесть. В русском и польском — по четыре. В японском и китайском — только одна, потому что там вообще нет различия по числу.

Это различие показывает, почему нельзя предполагать, что все языки работают как английский. Нужно проверять, какие категории используются в каждой локали, и предоставлять переводы для каждой из них.

Что означают категории для каждой локали

Одно и то же имя категории может означать разное в разных языках. Категория one в английском применяется только к числу 1. В русском one применяется к числам, оканчивающимся на 1, кроме 11, то есть к 1, 21, 31, 101 и так далее.

Проверьте, какие числа относятся к каким категориям в разных локалях:

const enRules = new Intl.PluralRules('en-US');
const ruRules = new Intl.PluralRules('ru-RU');

const numbers = [0, 1, 2, 3, 5, 11, 21, 22, 100];

console.log('English:');
numbers.forEach(n => {
  console.log(`  ${n}: ${enRules.select(n)}`);
});

console.log('Russian:');
numbers.forEach(n => {
  console.log(`  ${n}: ${ruRules.select(n)}`);
});

// Output:
// English:
//   0: other
//   1: one
//   2: other
//   3: other
//   5: other
//   11: other
//   21: other
//   22: other
//   100: other
// Russian:
//   0: many
//   1: one
//   2: few
//   3: few
//   5: many
//   11: many
//   21: one
//   22: few
//   100: many

В английском только 1 относится к категории one. В русском 1 и 21 относятся к one, потому что оканчиваются на 1. Числа 2, 3 и 22 относятся к few, так как оканчиваются на 2–4. Числа 0, 5, 11 и 100 относятся к many.

Это показывает, что нельзя предсказать, какая категория применяется к числу, не зная языковых правил. Массив pluralCategories показывает, какие категории существуют, а метод select() определяет, какая категория подходит для каждого числа.

Получение категорий для порядковых числительных

Порядковые числительные, такие как 1-й, 2-й, 3-й, имеют свои собственные правила множественного числа, отличающиеся от количественных. Создайте экземпляр PluralRules с type: 'ordinal', чтобы получить категории для порядковых числительных:

const enCardinalRules = new Intl.PluralRules('en-US', { type: 'cardinal' });
const enOrdinalRules = new Intl.PluralRules('en-US', { type: 'ordinal' });

console.log('Cardinal:', enCardinalRules.resolvedOptions().pluralCategories);
// Output: Cardinal: ["one", "other"]

console.log('Ordinal:', enOrdinalRules.resolvedOptions().pluralCategories);
// Output: Ordinal: ["one", "two", "few", "other"]

В английском для количественных числительных используются две категории. Для порядковых — четыре, потому что нужно различать 1-й, 2-й, 3-й и все остальные.

Категории порядковых числительных соответствуют суффиксам:

const enOrdinalRules = new Intl.PluralRules('en-US', { type: 'ordinal' });

const numbers = [1, 2, 3, 4, 11, 21, 22, 23];

numbers.forEach(n => {
  const category = enOrdinalRules.select(n);
  console.log(`${n}: ${category}`);
});

// Output:
// 1: one
// 2: two
// 3: few
// 4: other
// 11: other
// 21: one
// 22: two
// 23: few

Категория one соответствует суффиксу st (1st, 21st), two — nd (2nd, 22nd), few — rd (3rd, 23rd), а other — th (4th, 11th).

В разных языках свои категории порядковых числительных:

const locales = ['en-US', 'es-ES', 'fr-FR'];

locales.forEach(locale => {
  const rules = new Intl.PluralRules(locale, { type: 'ordinal' });
  const categories = rules.resolvedOptions().pluralCategories;
  console.log(`${locale}: [${categories.join(', ')}]`);
});

// Output:
// en-US: [one, two, few, other]
// es-ES: [other]
// fr-FR: [one, other]

В испанском только одна категория порядковых числительных, потому что там более простая система. Во французском две категории: первая позиция и все остальные.

Использование категорий множественного числа для построения карт перевода

Когда вы знаете, какие категории используются в локали, вы можете создать карту перевода с нужным количеством вариантов:

function buildTranslationMap(locale, translations) {
  const rules = new Intl.PluralRules(locale);
  const categories = rules.resolvedOptions().pluralCategories;

  const map = new Map();

  categories.forEach(category => {
    if (translations[category]) {
      map.set(category, translations[category]);
    } else {
      console.warn(`Missing translation for category "${category}" in locale "${locale}"`);
    }
  });

  return map;
}

const enTranslations = {
  one: 'item',
  other: 'items'
};

const arTranslations = {
  zero: 'لا توجد عناصر',
  one: 'عنصر واحد',
  two: 'عنصران',
  few: 'عناصر',
  many: 'عنصرًا',
  other: 'عنصر'
};

const enMap = buildTranslationMap('en-US', enTranslations);
const arMap = buildTranslationMap('ar-EG', arTranslations);

console.log(enMap);
// Output: Map(2) { 'one' => 'item', 'other' => 'items' }

console.log(arMap);
// Output: Map(6) { 'zero' => 'لا توجد عناصر', 'one' => 'عنصر واحد', ... }

Эта функция проверяет, что вы добавили переводы для всех нужных категорий, и предупреждает, если что-то пропущено. Это помогает избежать ошибок во время работы приложения, когда используется категория без перевода.

Проверка полноты перевода

Используйте категории во множественном числе, чтобы убедиться, что ваши переводы содержат все нужные формы перед публикацией в продакшн:

function validateTranslations(locale, translations) {
  const rules = new Intl.PluralRules(locale);
  const requiredCategories = rules.resolvedOptions().pluralCategories;
  const providedCategories = Object.keys(translations);

  const missing = requiredCategories.filter(cat => !providedCategories.includes(cat));
  const extra = providedCategories.filter(cat => !requiredCategories.includes(cat));

  if (missing.length > 0) {
    console.error(`Locale ${locale} is missing categories: ${missing.join(', ')}`);
    return false;
  }

  if (extra.length > 0) {
    console.warn(`Locale ${locale} has unused categories: ${extra.join(', ')}`);
  }

  return true;
}

const enTranslations = {
  one: 'item',
  other: 'items'
};

const incompleteArTranslations = {
  one: 'عنصر واحد',
  other: 'عنصر'
};

validateTranslations('en-US', enTranslations);
// Output: true

validateTranslations('ar-EG', incompleteArTranslations);
// Output: Locale ar-EG is missing categories: zero, two, few, many
// Output: false

Такая проверка позволяет находить пропущенные переводы на этапе разработки, а не тогда, когда пользователи видят непереведённый текст.

Создание динамических интерфейсов для перевода

При создании инструментов для переводчиков запрашивайте категории во множественном числе, чтобы показать, какие именно формы нужно перевести:

function generateTranslationForm(locale, key) {
  const rules = new Intl.PluralRules(locale);
  const categories = rules.resolvedOptions().pluralCategories;

  const form = document.createElement('div');
  form.className = 'translation-form';

  const heading = document.createElement('h3');
  heading.textContent = `Translate "${key}" for ${locale}`;
  form.appendChild(heading);

  categories.forEach(category => {
    const label = document.createElement('label');
    label.textContent = `${category}:`;

    const input = document.createElement('input');
    input.type = 'text';
    input.name = `${key}.${category}`;
    input.placeholder = `Enter ${category} form`;

    const wrapper = document.createElement('div');
    wrapper.appendChild(label);
    wrapper.appendChild(input);
    form.appendChild(wrapper);
  });

  return form;
}

const enForm = generateTranslationForm('en-US', 'items');
const arForm = generateTranslationForm('ar-EG', 'items');

document.body.appendChild(enForm);
document.body.appendChild(arForm);

Это создаёт форму с нужным количеством полей для каждого языка. Для английского будет два поля (one и other), а для арабского — шесть (zero, one, two, few, many и other).

Сравнение категорий между языками

Если вы работаете с переводами для нескольких языков, сравните используемые категории, чтобы понять сложность перевода:

function compareLocalePluralCategories(locales) {
  const comparison = {};

  locales.forEach(locale => {
    const rules = new Intl.PluralRules(locale);
    const categories = rules.resolvedOptions().pluralCategories;
    comparison[locale] = categories;
  });

  return comparison;
}

const locales = ['en-US', 'es-ES', 'ar-EG', 'ru-RU', 'ja-JP'];
const comparison = compareLocalePluralCategories(locales);

console.log(comparison);
// Output:
// {
//   'en-US': ['one', 'other'],
//   'es-ES': ['one', 'other'],
//   'ar-EG': ['zero', 'one', 'two', 'few', 'many', 'other'],
//   'ru-RU': ['one', 'few', 'many', 'other'],
//   'ja-JP': ['other']
// }

Это показывает, что в английском и испанском одинаковые категории во множественном числе, поэтому структуру перевода легко использовать повторно. Для арабского потребуется больше работы, так как там шесть категорий.

Проверка, использует ли язык определённую категорию

Перед тем как использовать определённую категорию во множественном числе в коде, проверьте, поддерживает ли её язык:

function localeUsesCategory(locale, category) {
  const rules = new Intl.PluralRules(locale);
  const categories = rules.resolvedOptions().pluralCategories;
  return categories.includes(category);
}

console.log(localeUsesCategory('en-US', 'zero'));
// Output: false

console.log(localeUsesCategory('ar-EG', 'zero'));
// Output: true

console.log(localeUsesCategory('ja-JP', 'one'));
// Output: false

Это не даст вам ошибочно предположить, что в каждом языке есть категория zero или one. Используйте такую проверку, чтобы безопасно реализовать логику для конкретных категорий.

Что такое категория other

В каждом языке есть категория other. Она используется по умолчанию, если не подходит ни одна другая категория.

В английском языке категория other охватывает все количества, кроме 1. В арабском — большие числа, например 100 и больше. В японском other используется для всех количеств, так как в японском нет различия между единственным и множественным числом.

Всегда добавляйте перевод для категории other. Эта категория есть во всех локалях и будет использоваться, если не найдено более подходящей категории.

const locales = ['en-US', 'ar-EG', 'ru-RU', 'ja-JP'];

locales.forEach(locale => {
  const rules = new Intl.PluralRules(locale);
  const categories = rules.resolvedOptions().pluralCategories;
  const hasOther = categories.includes('other');
  console.log(`${locale} uses "other": ${hasOther}`);
});

// Output:
// en-US uses "other": true
// ar-EG uses "other": true
// ru-RU uses "other": true
// ja-JP uses "other": true

Получение всех разрешённых опций вместе

Метод resolvedOptions() возвращает не только категории множественного числа. Он также включает информацию о локали, типе и параметрах форматирования чисел:

const rules = new Intl.PluralRules('de-DE', {
  type: 'cardinal',
  minimumFractionDigits: 2,
  maximumFractionDigits: 2
});

const options = rules.resolvedOptions();

console.log(options);
// Output:
// {
//   locale: 'de-DE',
//   type: 'cardinal',
//   pluralCategories: ['one', 'other'],
//   minimumIntegerDigits: 1,
//   minimumFractionDigits: 2,
//   maximumFractionDigits: 2,
//   minimumSignificantDigits: undefined,
//   maximumSignificantDigits: undefined
// }

Свойство pluralCategories — это одна из характеристик объекта разрешённых опций. Остальные свойства показывают точную конфигурацию экземпляра PluralRules, включая параметры, установленные по умолчанию.

Кэширование категорий множественного числа для производительности

Создание экземпляров PluralRules и вызов resolvedOptions() требует ресурсов. Кэшируйте результаты для каждой локали, чтобы не запрашивать их каждый раз:

const categoriesCache = new Map();

function getPluralCategories(locale, type = 'cardinal') {
  const key = `${locale}:${type}`;

  if (categoriesCache.has(key)) {
    return categoriesCache.get(key);
  }

  const rules = new Intl.PluralRules(locale, { type });
  const categories = rules.resolvedOptions().pluralCategories;

  categoriesCache.set(key, categories);

  return categories;
}

const enCardinal = getPluralCategories('en-US', 'cardinal');
const enOrdinal = getPluralCategories('en-US', 'ordinal');
const arCardinal = getPluralCategories('ar-EG', 'cardinal');

console.log('en-US cardinal:', enCardinal);
console.log('en-US ordinal:', enOrdinal);
console.log('ar-EG cardinal:', arCardinal);

// Subsequent calls use cached results
const enCardinal2 = getPluralCategories('en-US', 'cardinal');
// No new PluralRules instance created

Этот подход особенно важен в приложениях, где форматируется много строк с множественным числом или поддерживается много локалей.

Поддержка браузеров и совместимость

Свойство pluralCategories в resolvedOptions() появилось в JavaScript в 2020 году. Поддерживается в Chrome 106+, Firefox 116+, Safari 15.4+ и Edge 106+.

В старых браузерах, которые поддерживают Intl.PluralRules, но не pluralCategories, это свойство будет возвращать undefined. Проверьте его наличие перед использованием:

function getPluralCategories(locale) {
  const rules = new Intl.PluralRules(locale);
  const options = rules.resolvedOptions();

  if (options.pluralCategories) {
    return options.pluralCategories;
  }

  // Fallback for older browsers
  return ['one', 'other'];
}

Этот запасной вариант предполагает простую двухкатегорийную систему, что подходит для английского и многих европейских языков, но может быть неверным для языков со сложными правилами. Для лучшей совместимости используйте языковые запасные варианты или полифиллы.