8
0
Просмотр исходного кода

Merge pull request #334 from ckeditor/i/6526

Feature: Provided support for plural forms internalization. Part of ckeditor/ckeditor5#6526.

MINOR BREAKING CHANGE: The `translate` function from the `translation-service` was marked as protected. See #334.
MINOR BREAKING CHANGE: The format of translations added to the editor has been changed. If you use `window.CKEDITOR_TRANSLATIONS` please see #334.
Marek Lewandowski 5 лет назад
Родитель
Сommit
0af79976c0

+ 59 - 21
packages/ckeditor5-utils/src/locale.js

@@ -9,7 +9,7 @@
 
 /* globals console */
 
-import { translate } from './translation-service';
+import { _translate } from './translation-service';
 
 const RTL_LANGUAGE_CODES = [ 'ar', 'fa', 'he', 'ku', 'ug' ];
 
@@ -76,25 +76,48 @@ export default class Locale {
 		this.contentLanguageDirection = getLanguageDirection( this.contentLanguage );
 
 		/**
-		 * Translates the given string to the {@link #uiLanguage}. This method is also available in
-		 * {@link module:core/editor/editor~Editor#t} and {@link module:ui/view~View#t}.
+		 * Translates the given message to the {@link #uiLanguage}. This method is also available in
+		 * {@link module:core/editor/editor~Editor#t Editor} and {@link module:ui/view~View#t View}.
 		 *
-		 * The strings may contain placeholders (`%<index>`) for values which are passed as the second argument.
-		 * `<index>` is the index in the `values` array.
+		 * This method's context is statically bound to the `Locale` instance and **always should be called as a function**:
 		 *
-		 *		editor.t( 'Created file "%0" in %1ms.', [ fileName, timeTaken ] );
+		 *		const t = locale.t;
+		 *		t( 'Label' );
 		 *
-		 * This method's context is statically bound to Locale instance,
-		 * so it can be called as a function:
+		 * The message can be either a string or an object implementing the {@link module:utils/translation-service~Message} interface.
 		 *
-		 *		const t = this.t;
-		 *		t( 'Label' );
+		 * The message may contain placeholders (`%<index>`) for value(s) that are passed as a `values` parameter.
+		 * For an array of values the `%<index>` will be changed to an element of that array at the given index.
+		 * For a single value passed as the second argument, only the `%0` placeholders will be changed to the provided value.
+		 *
+		 *		t( 'Created file "%0" in %1ms.', [ fileName, timeTaken ] );
+		 * 		t( 'Created file "%0", fileName );
+		 *
+		 * The message supports plural forms. To specify the plural form, use the `plural` property. Singular or plural form
+		 * will be chosen depending on the first value from the passed `values`. The value of the `plural` property is used
+		 * as a default plural translation when the translation for the target language is missing.
+		 *
+		 *		t( { string: 'Add a space', plural: 'Add %0 spaces' }, 1 ); // 'Add a space' for the English language.
+		 *		t( { string: 'Add a space', plural: 'Add %0 spaces' }, 5 ); // 'Add 5 spaces' for the English language.
+		 *		t( { string: '%1 a space', plural: '%1 %0 spaces' }, [ 2, 'Add' ] ); // 'Add 2 spaces' for the English language.
+		 *
+		 * 		t( { string: 'Add a space', plural: 'Add %0 spaces' }, 1 ); // 'Dodaj spację' for the Polish language.
+		 *		t( { string: 'Add a space', plural: 'Add %0 spaces' }, 5 ); // 'Dodaj 5 spacji' for the Polish language.
+		 *		t( { string: '%1 a space', plural: '%1 %0 spaces' }, [ 2, 'Add' ] ); // 'Dodaj 2 spacje' for the Polish language.
+		 *
+		 *  * The message should provide an id using the `id` property when the message strings are not unique and their
+		 * translations should be different.
+		 *
+		 *		translate( 'en', { string: 'image', id: 'ADD_IMAGE' } );
+		 *		translate( 'en', { string: 'image', id: 'AN_IMAGE' } );
 		 *
 		 * @method #t
-		 * @param {String} str The string to translate.
-		 * @param {String[]} [values] Values that should be used to interpolate the string.
+		 * @param {String|module:utils/translation-service~Message} message A message that will be localized (translated).
+		 * @param {String|Number|Array.<String|Number>} [values] A value or an array of values that will fill message placeholders.
+		 * For messages supporting plural forms the first value will determine the plural form.
+		 * @returns {String}
 		 */
-		this.t = ( ...args ) => this._t( ...args );
+		this.t = ( message, values ) => this._t( message, values );
 	}
 
 	/**
@@ -122,23 +145,38 @@ export default class Locale {
 	}
 
 	/**
-	 * Base for the {@link #t} method.
+	 * An unbound version of the {@link #t} method.
 	 *
 	 * @private
+	 * @param {String|module:utils/translation-service~Message} message
+	 * @param {Number|String|Array.<Number|String>} [values]
+	 * @returns {String}
 	 */
-	_t( str, values ) {
-		let translatedString = translate( this.uiLanguage, str );
+	_t( message, values = [] ) {
+		if ( !Array.isArray( values ) ) {
+			values = [ values ];
+		}
 
-		if ( values ) {
-			translatedString = translatedString.replace( /%(\d+)/g, ( match, index ) => {
-				return ( index < values.length ) ? values[ index ] : match;
-			} );
+		if ( typeof message === 'string' ) {
+			message = { string: message };
 		}
 
-		return translatedString;
+		const hasPluralForm = !!message.plural;
+		const quantity = hasPluralForm ? values[ 0 ] : 1;
+
+		const translatedString = _translate( this.uiLanguage, message, quantity );
+
+		return interpolateString( translatedString, values );
 	}
 }
 
+// Fills the `%0, %1, ...` string placeholders with values.
+function interpolateString( string, values ) {
+	return string.replace( /%(\d+)/g, ( match, index ) => {
+		return ( index < values.length ) ? values[ index ] : match;
+	} );
+}
+
 // Helps determine whether a language is LTR or RTL.
 //
 // @param {String} language The ISO 639-1 language code.

+ 139 - 28
packages/ckeditor5-utils/src/translation-service.js

@@ -9,62 +9,144 @@
  * @module utils/translation-service
  */
 
+import CKEditorError from './ckeditorerror';
+
 /* istanbul ignore else */
 if ( !window.CKEDITOR_TRANSLATIONS ) {
 	window.CKEDITOR_TRANSLATIONS = {};
 }
 
 /**
- * Adds translations to existing ones.
- * These translations will later be available for the {@link module:utils/translation-service~translate `translate()`} function.
+ * Adds translations to existing ones or overrides the existing translations. These translations will later
+ * be available for the {@link module:utils/locale~Locale#t `t()`} function.
+ *
+ * The `translations` is an object which consists of a `messageId: translation` pairs. Note that the message id can be
+ * either constructed from the message string or from the message id if it was passed
+ * (this happens rarely and mostly for short messages or messages with placeholders).
+ * Since the editor displays only the message string, the message id can be found either in the source code or in the
+ * built translations for another language.
  *
  *		add( 'pl', {
- *			'OK': 'OK',
- *			'Cancel [context: reject]': 'Anuluj'
+ *			'Cancel': 'Anuluj',
+ *			'IMAGE': 'obraz', // Note that the `IMAGE` comes from the message id, while the string can be `image`.
  *		} );
  *
+ * If the message is supposed to support various plural forms, make sure to provide an array with the singular form and all plural forms:
+ *
+ *		add( 'pl', {
+ *	 		'Add space': [ 'Dodaj spację', 'Dodaj %0 spacje', 'Dodaj %0 spacji' ]
+ * 		} );
+ *
+ * You should also specify the third argument (the `getPluralForm` function) that will be used to determine the plural form if no
+ * language file was loaded for that language. All language files coming from CKEditor 5 sources will have this option set, so
+ * these plural form rules will be reused by other translations added to the registered languages. The `getPluralForm` function
+ * can return either a boolean or a number.
+ *
+ * 		add( 'en', {
+ *	 		// ... Translations.
+ * 		}, n => n !== 1 );
+ * 		add( 'pl', {
+ *	 		// ... Translations.
+ * 		}, n => n == 1 ? 0 : n % 10 >= 2 && n % 10 <= 4 && ( n % 100 < 10 || n % 100 >= 20 ) ? 1 : 2 );
+ *
+ * All translations extend the global `window.CKEDITOR_TRANSLATIONS` object. An example of this object can be found below:
+ *
+ * 		{
+ * 			pl: {
+ *				dictionary: {
+ *					'Cancel': 'Anuluj',
+ *					'Add space': [ 'Dodaj spację', 'Dodaj %0 spacje', 'Dodaj %0 spacji' ]
+ *				},
+ *				// A function that returns the plural form index.
+ *				getPluralForm: n => n !==1
+ *			}
+ *			// other languages.
+ *		}
+ *
  * If you cannot import this function from this module (e.g. because you use a CKEditor 5 build), then you can
  * still add translations by extending the global `window.CKEDITOR_TRANSLATIONS` object by using a function like
  * the one below:
  *
- *		function addTranslations( language, translations ) {
+ *		function addTranslations( language, translations, getPluralForm ) {
  *			if ( !window.CKEDITOR_TRANSLATIONS ) {
  *				window.CKEDITOR_TRANSLATIONS = {};
  *			}
+
+ *			if ( !window.CKEDITOR_TRANSLATIONS[ language ] ) {
+ *				window.CKEDITOR_TRANSLATIONS[ language ] = {};
+ *			}
  *
- *			const dictionary = window.CKEDITOR_TRANSLATIONS[ language ] || ( window.CKEDITOR_TRANSLATIONS[ language ] = {} );
+ *			const languageTranslations = window.CKEDITOR_TRANSLATIONS[ language ];
+ *
+ * 			languageTranslations.dictionary = languageTranslations.dictionary || {};
+ * 			languageTranslations.getPluralForm = getPluralForm || languageTranslations.getPluralForm;
  *
  *			// Extend the dictionary for the given language.
- *			Object.assign( dictionary, translations );
+ *			Object.assign( languageTranslations.dictionary, translations );
  *		}
  *
  * @param {String} language Target language.
- * @param {Object.<String, String>} translations Translations which will be added to the dictionary.
+ * @param {Object.<String,*>} translations An object with translations which will be added to the dictionary.
+ * For each message id the value should be either a translation or an array of translations if the message
+ * should support plural forms.
+ * @param {Function} getPluralForm A function that returns the plural form index (a number).
  */
-export function add( language, translations ) {
-	const dictionary = window.CKEDITOR_TRANSLATIONS[ language ] || ( window.CKEDITOR_TRANSLATIONS[ language ] = {} );
+export function add( language, translations, getPluralForm ) {
+	if ( !window.CKEDITOR_TRANSLATIONS[ language ] ) {
+		window.CKEDITOR_TRANSLATIONS[ language ] = {};
+	}
+
+	const languageTranslations = window.CKEDITOR_TRANSLATIONS[ language ];
+
+	languageTranslations.dictionary = languageTranslations.dictionary || {};
+	languageTranslations.getPluralForm = getPluralForm || languageTranslations.getPluralForm;
 
-	Object.assign( dictionary, translations );
+	Object.assign( languageTranslations.dictionary, translations );
 }
 
 /**
- * Translates string if the translation of the string was previously added to the dictionary.
- * See {@link module:utils/translation-service Translation Service}.
- * This happens in a multi-language mode were translation modules are created by the bundler.
+ * **Note:** this method is internal, use {@link module:utils/locale~Locale#t the `t()` function} instead to translate
+ * editor UI parts.
+ *
+ * This function is responsible for translating messages to the specified language. It uses perviously added translations
+ * by {@link module:utils/translation-service~add} (a translations dictionary and and the `getPluralForm` function
+ * to provide accurate translations of plural forms).
  *
  * When no translation is defined in the dictionary or the dictionary doesn't exist this function returns
- * the original string without the `'[context: ]'` (happens in development and single-language modes).
+ * the original message string or message plural depending on the number of elements.
+ *
+ *		translate( 'pl', { string: 'Cancel' } ); // 'Cancel'
+ *
+ * The third optional argument is the number of elements, based on which the single form or one of plural forms
+ * should be picked when the message is supposed to support various plural forms.
  *
- * In a single-language mode (when values passed to `t()` were replaced with target language strings) the dictionary
- * is left empty, so this function will return the original strings always.
+ * 		translate( 'en', { string: 'Add a space', plural: 'Add %0 spaces' }, 1 ); // 'Add a space'
+ * 		translate( 'en', { string: 'Add a space', plural: 'Add %0 spaces' }, 3 ); // 'Add %0 spaces'
  *
- *		translate( 'pl', 'Cancel [context: reject]' );
+ * The message should provide an id using the `id` property when the message strings are not unique and their
+ * translations should be different.
  *
+ *		translate( 'en', { string: 'image', id: 'ADD_IMAGE' } );
+ *		translate( 'en', { string: 'image', id: 'AN_IMAGE' } );
+ *
+ * @protected
  * @param {String} language Target language.
- * @param {String} translationKey String that will be translated.
+ * @param {module:utils/translation-service~Message|String} message A message that will be translated.
+ * @param {Number} [quantity] A number of elements for which a plural form should be picked from the target language dictionary.
  * @returns {String} Translated sentence.
  */
-export function translate( language, translationKey ) {
+export function _translate( language, message, quantity = 1 ) {
+	if ( typeof quantity !== 'number' ) {
+		/**
+		 * The incorrect value has been passed to the `translation` function. This probably was caused
+		 * by the incorrect message interpolation of a plural form. Note that for messages supporting plural forms
+		 * the second argument of the `t()` function should always be a number or an array with number as the first element.
+		 *
+		 * @error translation-service-quantity-not-a-number
+		 */
+		throw new CKEditorError( 'translation-service-quantity-not-a-number: Expecting `quantity` to be a number.', null, { quantity } );
+	}
+
 	const numberOfLanguages = getNumberOfLanguages();
 
 	if ( numberOfLanguages === 1 ) {
@@ -73,14 +155,28 @@ export function translate( language, translationKey ) {
 		language = Object.keys( window.CKEDITOR_TRANSLATIONS )[ 0 ];
 	}
 
-	if ( numberOfLanguages === 0 || !hasTranslation( language, translationKey ) ) {
-		return translationKey.replace( / \[context: [^\]]+\]$/, '' );
+	const messageId = message.id || message.string;
+
+	if ( numberOfLanguages === 0 || !hasTranslation( language, messageId ) ) {
+		if ( quantity !== 1 ) {
+			// Return the default plural form that was passed in the `message.plural` parameter.
+			return message.plural;
+		}
+
+		return message.string;
 	}
 
-	const dictionary = window.CKEDITOR_TRANSLATIONS[ language ];
+	const dictionary = window.CKEDITOR_TRANSLATIONS[ language ].dictionary;
+	const getPluralForm = window.CKEDITOR_TRANSLATIONS[ language ].getPluralForm || ( n => n === 1 ? 0 : 1 );
 
-	// In case of missing translations we still need to cut off the `[context: ]` parts.
-	return dictionary[ translationKey ].replace( / \[context: [^\]]+\]$/, '' );
+	if ( typeof dictionary[ messageId ] === 'string' ) {
+		return dictionary[ messageId ];
+	}
+
+	const pluralFormIndex = Number( getPluralForm( quantity ) );
+
+	// Note: The `translate` function is not responsible for replacing `%0, %1, ...` with values.
+	return dictionary[ messageId ][ pluralFormIndex ];
 }
 
 /**
@@ -93,13 +189,28 @@ export function _clear() {
 }
 
 // Checks whether the dictionary exists and translation in that dictionary exists.
-function hasTranslation( language, translationKey ) {
+function hasTranslation( language, messageId ) {
 	return (
-		( language in window.CKEDITOR_TRANSLATIONS ) &&
-		( translationKey in window.CKEDITOR_TRANSLATIONS[ language ] )
+		!!window.CKEDITOR_TRANSLATIONS[ language ] &&
+		!!window.CKEDITOR_TRANSLATIONS[ language ].dictionary[ messageId ]
 	);
 }
 
 function getNumberOfLanguages() {
 	return Object.keys( window.CKEDITOR_TRANSLATIONS ).length;
 }
+
+/**
+ * The internationalization message interface. A message that implements this interface can be passed to the `t()` function
+ * to be translated to the target ui language.
+ *
+ * @typedef {Object} module:utils/translation-service~Message
+ *
+ * @property {String} string The message string to translate. Acts as a default translation if the translation for given language
+ * is not defined. When the message is supposed to support plural forms then the string should be the English singular form of the message.
+ * @property {String} [id] The message id. If passed then the message id is taken from this property instead of the `message.string`.
+ * This property is useful when various messages share the same message string. E.g. `editor` string in `in the editor` and `my editor`
+ * sentences.
+ * @property {String} [plural] The plural form of the message. This property should be skipped when a message is not supposed
+ * to support plural forms. Otherwise it should always be set to a string with the English plural form of the message.
+ */

+ 91 - 17
packages/ckeditor5-utils/tests/locale.js

@@ -6,15 +6,15 @@
 /* globals console */
 
 import Locale from '../src/locale';
+import {
+	add as addTranslations,
+	_clear as clearTranslations
+} from '../src/translation-service';
+import { expectToThrowCKEditorError } from './_utils/utils';
 
 describe( 'Locale', () => {
-	let locale;
-
-	beforeEach( () => {
-		locale = new Locale();
-	} );
-
 	afterEach( () => {
+		clearTranslations();
 		sinon.restore();
 	} );
 
@@ -115,42 +115,115 @@ describe( 'Locale', () => {
 	} );
 
 	describe( 't', () => {
-		it( 'has the context bound', () => {
+		let locale;
+
+		beforeEach( () => {
+			// eslint-disable-next-line no-nested-ternary
+			const getPolishPluralForm = n => n == 1 ? 0 : n % 10 >= 2 && n % 10 <= 4 && ( n % 100 < 10 || n % 100 >= 20 ) ? 1 : 2;
+
+			addTranslations( 'pl', {
+				'foo': 'foo_pl',
+				'bar': [ 'bar_pl_0', '%0 bar_pl_1', '%0 bar_pl_2' ]
+			}, getPolishPluralForm );
+
+			addTranslations( 'de', {
+				'foo': 'foo_de',
+				'bar': [ 'bar_de_0', '%0 bar_de_1', '%0 bar_de_2' ]
+			} );
+
+			locale = new Locale( {
+				uiLanguage: 'pl',
+				contentLanguage: 'de'
+			} );
+		} );
+
+		it( 'should translate a message to the target ui language', () => {
 			const t = locale.t;
 
-			expect( t( 'Foo' ) ).to.equal( 'Foo' );
+			expect( t( 'foo' ) ).to.equal( 'foo_pl' );
 		} );
 
-		it( 'interpolates 1 value', () => {
+		it( 'should translate a message using the message id if it was passed', () => {
 			const t = locale.t;
 
-			expect( t( '%0 - %0', [ 'foo' ] ) ).to.equal( 'foo - foo' );
+			addTranslations( 'pl', {
+				'ADD_IMAGE': 'obrazek',
+				'image': 'foo'
+			} );
+
+			expect( t( { string: 'image', id: 'ADD_IMAGE' } ) ).to.equal( 'obrazek' );
 		} );
 
-		it( 'interpolates 3 values', () => {
+		it( 'should translate a message supporting plural forms', () => {
 			const t = locale.t;
 
-			expect( t( '%1 - %0 - %2', [ 'a', 'b', 'c' ] ) ).to.equal( 'b - a - c' );
+			expect( t( { string: 'bar', plural: '%0 bars' }, [ 1 ] ), 1 ).to.equal( 'bar_pl_0' );
+			expect( t( { string: 'bar', plural: '%0 bars' }, [ 2 ] ), 2 ).to.equal( '2 bar_pl_1' );
+			expect( t( { string: 'bar', plural: '%0 bars' }, [ 5 ] ), 3 ).to.equal( '5 bar_pl_2' );
 		} );
 
-		// Those test make sure that if %0 is really to be used, then it's going to work.
-		// It'd be a super rare case if one would need to use %0 and at the same time interpolate something.
-		it( 'does not interpolate placeholders if values not passed', () => {
+		it( 'should translate a message supporting plural forms with a message id if it was passed', () => {
 			const t = locale.t;
 
-			expect( t( '%1 - %0 - %2' ) ).to.equal( '%1 - %0 - %2' );
+			addTranslations( 'pl', {
+				'ADD_SPACE': [ '%1 spację', '%1 %0 spacje', '%1 %0 spacji' ],
+				'Add': 'Dodaj',
+				'Remove': 'Usuń'
+			} );
+
+			const addOrRemoveSpaceMessage = { string: '%1 a space', plural: '%1 %0 spaces', id: 'ADD_SPACE' };
+
+			expect( t( addOrRemoveSpaceMessage, [ 1, t( 'Add' ) ] ), 1 ).to.equal( 'Dodaj spację' );
+			expect( t( addOrRemoveSpaceMessage, [ 2, t( 'Remove' ) ] ), 2 ).to.equal( 'Usuń 2 spacje' );
+			expect( t( addOrRemoveSpaceMessage, [ 5, t( 'Add' ) ] ), 3 ).to.equal( 'Dodaj 5 spacji' );
 		} );
 
-		it( 'does not interpolate those placeholders for which values has not been passed', () => {
+		it( 'should interpolate a message with provided values', () => {
 			const t = locale.t;
 
+			expect( t( '%0 - %0', [ 'foo' ] ) ).to.equal( 'foo - foo' );
+			expect( t( '%1 - %0 - %2', [ 'a', 'b', 'c' ] ) ).to.equal( 'b - a - c' );
+
+			// Those test make sure that if %0 is really to be used, then it's going to work.
+			// It'd be a super rare case if one would need to use %0 and at the same time interpolate something.
+			expect( t( '%1 - %0 - %2' ) ).to.equal( '%1 - %0 - %2' );
 			expect( t( '%1 - %0 - %2', [ 'a' ] ) ).to.equal( '%1 - a - %2' );
 		} );
+
+		it( 'should interpolate a message with a provided value (shorthand version)', () => {
+			const t = locale.t;
+
+			expect( t( 'Add %0', 'space' ) ).to.equal( 'Add space' );
+			expect( t( 'Remove %0 %1', 'spaces' ) ).to.equal( 'Remove spaces %1' );
+
+			expect( t( '%0 bar %0', 'foo' ) ).to.equal( 'foo bar foo' );
+		} );
+
+		it( 'should throw an error when a value used to determine the plural version is not a number', () => {
+			const t = locale.t;
+
+			expectToThrowCKEditorError( () => {
+				t( { string: 'Add space', plural: 'Add %0 spaces' }, 'space' );
+			}, /translation-service-quantity-not-a-number:/, null, { quantity: 'space' } );
+
+			expectToThrowCKEditorError( () => {
+				t( { string: 'Add space', plural: 'Add %0 spaces' }, [ 'space' ] );
+			}, /translation-service-quantity-not-a-number:/, null, { quantity: 'space' } );
+
+			expect( () => {
+				t( { string: 'Add space', plural: 'Add %0 spaces' }, [ 3 ] );
+				t( { string: 'Add space', plural: 'Add %0 spaces' }, 3 );
+				t( { string: 'Add %1', plural: 'Add %0 %1' }, [ 3, 'spaces' ] );
+				t( { string: 'Add %0' }, [ 'space' ] );
+				t( { string: 'Add %0' }, 'space' );
+			} ).to.not.throw();
+		} );
 	} );
 
 	describe( 'language()', () => {
 		it( 'should return #uiLanguage', () => {
 			const stub = sinon.stub( console, 'warn' );
+			const locale = new Locale();
 
 			expect( locale.language ).to.equal( locale.uiLanguage );
 			sinon.assert.calledWithMatch( stub, 'locale-deprecated-language-property' );
@@ -158,6 +231,7 @@ describe( 'Locale', () => {
 
 		it( 'should warn about deprecation', () => {
 			const stub = sinon.stub( console, 'warn' );
+			const locale = new Locale();
 
 			expect( locale.language ).to.equal( 'en' );
 			sinon.assert.calledWithMatch( stub, 'locale-deprecated-language-property' );

+ 118 - 40
packages/ckeditor5-utils/tests/translation-service.js

@@ -3,74 +3,152 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-import { translate, add, _clear } from '../src/translation-service';
+import { _translate, add, _clear } from '../src/translation-service';
 
 describe( 'translation-service', () => {
 	afterEach( () => {
 		_clear();
 	} );
 
-	it( 'should return english string if no translation exists', () => {
-		const translation = translate( 'pl', 'Bold' );
+	describe( 'add()', () => {
+		it( 'should merge translation added several times', () => {
+			add( 'pl', { 'foo': 'foo_pl' } );
+			add( 'pl', { 'bar': 'bar_pl' } );
 
-		expect( translation ).to.be.equal( 'Bold' );
-	} );
+			const translatedFoo = _translate( 'pl', { string: 'foo' } );
+			const translatedBar = _translate( 'pl', { string: 'bar' } );
+
+			expect( translatedFoo ).to.equal( 'foo_pl' );
+			expect( translatedBar ).to.equal( 'bar_pl' );
+		} );
 
-	it( 'should return english string without context if no translation exists', () => {
-		const translation = translate( 'pl', 'Bold [context: bold]' );
+		it( 'should overwrite previously added translations for the same message ids', () => {
+			add( 'pl', { 'foo': 'First' } );
+			add( 'pl', { 'foo': 'Second' } );
 
-		expect( translation ).to.be.equal( 'Bold' );
-	} );
+			const translatedFoo = _translate( 'pl', { string: 'foo' } );
 
-	it( 'should return translation if the translation for the concrete language is defined', () => {
-		add( 'pl', {
-			'OK': 'OK',
-			'Cancel [context: reject]': 'Anuluj'
+			expect( translatedFoo ).to.equal( 'Second' );
 		} );
 
-		const translation = translate( 'pl', 'Cancel [context: reject]' );
+		it( 'should set the plural form function if it is provided', () => {
+			add( 'pl', {
+				'Add space': [ 'Dodaj spację', 'Dodaj %0 spacje', 'Dodaj %0 spacji' ]
+			} );
 
-		expect( translation ).to.be.equal( 'Anuluj' );
+			// eslint-disable-next-line no-nested-ternary
+			add( 'pl', {}, n => n == 1 ? 0 : n % 10 >= 2 && n % 10 <= 4 && ( n % 100 < 10 || n % 100 >= 20 ) ? 1 : 2 );
+
+			expect( _translate( 'pl', { string: 'Add space' }, 0 ) ).to.equal( 'Dodaj %0 spacji' );
+			expect( _translate( 'pl', { string: 'Add space' }, 1 ) ).to.equal( 'Dodaj spację' );
+			expect( _translate( 'pl', { string: 'Add space' }, 3 ) ).to.equal( 'Dodaj %0 spacje' );
+			expect( _translate( 'pl', { string: 'Add space' }, 13 ) ).to.equal( 'Dodaj %0 spacji' );
+		} );
 	} );
 
-	it( 'should return english string without context if the translations for the concrete language exist, ' +
+	describe( '_translate()', () => {
+		it( 'should return translated messages when translations are defined', () => {
+			add( 'pl', {
+				'OK': 'OK',
+				'Cancel': 'Anuluj'
+			} );
+
+			add( 'en_US', {
+				'OK': 'OK',
+				'Cancel': 'Cancel'
+			} );
+
+			const translatedCancelPL = _translate( 'pl', { string: 'Cancel' } );
+			const translatedCancelEN = _translate( 'en', { string: 'Cancel' } );
+
+			expect( translatedCancelPL ).to.be.equal( 'Anuluj' );
+			expect( translatedCancelEN ).to.be.equal( 'Cancel' );
+		} );
+
+		it( 'should return the original message string if no translation exists for the given message', () => {
+			const translatedBold = _translate( 'pl', { string: 'Bold' } );
+
+			expect( translatedBold ).to.be.equal( 'Bold' );
+		} );
+
+		it( 'should return the correct plural form of english message if no translation exists for the given message', () => {
+			const addSpaces = _translate( 'pl', { string: 'Add a space', plural: 'Add %0 spaces' }, 3 );
+			const addASpace = _translate( 'pl', { string: 'Add a space', plural: 'Add %0 spaces' }, 1 );
+
+			expect( addSpaces ).to.be.equal( 'Add %0 spaces' );
+			expect( addASpace ).to.be.equal( 'Add a space' );
+		} );
+
+		it( 'should return the original message string if a translation for the target language does not exist' +
 		'but translation doesn\'t', () => {
-		add( 'pl', {
-			'OK': 'OK',
-			'Cancel [context: reject]': 'Anuluj'
+			add( 'pl', {
+				'OK': 'OK',
+				'Cancel': 'Anuluj'
+			} );
+
+			const translatedBold = _translate( 'pl', { string: 'Bold' } );
+
+			expect( translatedBold ).to.be.equal( 'Bold' );
 		} );
 
-		const translation = translate( 'pl', 'Bold [context: bold]' );
+		it( 'should return a translated message when only one language is provided', () => {
+			add( 'pl', {
+				'OK': 'OK',
+				'Cancel': 'Anuluj'
+			} );
 
-		expect( translation ).to.be.equal( 'Bold' );
-	} );
+			const translatedCancel = _translate( 'de', { string: 'Cancel' } );
 
-	it( 'should use provided language if only one is provided', () => {
-		add( 'pl', {
-			'OK': 'OK',
-			'Cancel [context: reject]': 'Anuluj'
+			expect( translatedCancel ).to.be.equal( 'Anuluj' );
 		} );
 
-		const translation = translate( 'de', 'Cancel [context: reject]' );
+		it( 'should return a translated message based on message id when it was passed', () => {
+			add( 'pl', {
+				'ADD_IMAGE': 'obraz'
+			} );
 
-		expect( translation ).to.be.equal( 'Anuluj' );
-	} );
+			const translatedFooBar = _translate( 'pl', { string: 'image', id: 'ADD_IMAGE' } );
 
-	it( 'should be able to merge translations', () => {
-		add( 'pl', {
-			'OK': 'OK',
-			'Cancel [context: reject]': 'Anuluj'
+			expect( translatedFooBar ).to.equal( 'obraz' );
 		} );
 
-		add( 'en_US', {
-			'OK': 'OK',
-			'Cancel [context: reject]': 'Cancel'
+		it( 'should return the correct plural form of the message based on the provided function', () => {
+			add( 'pl', {
+				'Add space': [ 'Dodaj spację', 'Dodaj %0 spacje', 'Dodaj %0 spacji' ],
+				'Cancel': 'Anuluj'
+				// eslint-disable-next-line no-nested-ternary
+			}, n => n == 1 ? 0 : n % 10 >= 2 && n % 10 <= 4 && ( n % 100 < 10 || n % 100 >= 20 ) ? 1 : 2 );
+
+			expect( _translate( 'pl', { string: 'Add space' }, 0 ) ).to.equal( 'Dodaj %0 spacji' );
+			expect( _translate( 'pl', { string: 'Add space' }, 1 ) ).to.equal( 'Dodaj spację' );
+			expect( _translate( 'pl', { string: 'Add space' }, 3 ) ).to.equal( 'Dodaj %0 spacje' );
+			expect( _translate( 'pl', { string: 'Add space' }, 13 ) ).to.equal( 'Dodaj %0 spacji' );
 		} );
 
-		const translationPL = translate( 'pl', 'Cancel [context: reject]' );
-		const translationEN = translate( 'en', 'Cancel [context: reject]' );
+		it( 'should return a plural form based on rules for English if no function to determine the plural form was provided', () => {
+			add( 'pl', {
+				'Add space': [ 'Dodaj spację', 'Dodaj %0 spacje', 'Dodaj %0 spacji' ],
+				'Cancel': 'Anuluj'
+			} );
 
-		expect( translationPL ).to.be.equal( 'Anuluj' );
-		expect( translationEN ).to.be.equal( 'Cancel' );
+			expect( _translate( 'pl', { string: 'Add space' }, 1 ) ).to.equal( 'Dodaj spację' );
+
+			expect( _translate( 'pl', { string: 'Add space' }, 0 ) ).to.equal( 'Dodaj %0 spacje' );
+			expect( _translate( 'pl', { string: 'Add space' }, 3 ) ).to.equal( 'Dodaj %0 spacje' );
+			expect( _translate( 'pl', { string: 'Add space' }, 13 ) ).to.equal( 'Dodaj %0 spacje' );
+		} );
+
+		it( 'should support a plural form rule that returns a boolean', () => {
+			add( 'pl', {
+				'Add space': [ 'Dodaj spację', 'Dodaj %0 spacje' ],
+				'Cancel': 'Anuluj'
+			}, n => n !== 1 );
+
+			expect( _translate( 'pl', { string: 'Add space' }, 1 ) ).to.equal( 'Dodaj spację' );
+
+			expect( _translate( 'pl', { string: 'Add space' }, 0 ) ).to.equal( 'Dodaj %0 spacje' );
+			expect( _translate( 'pl', { string: 'Add space' }, 3 ) ).to.equal( 'Dodaj %0 spacje' );
+			expect( _translate( 'pl', { string: 'Add space' }, 13 ) ).to.equal( 'Dodaj %0 spacje' );
+		} );
 	} );
 } );