Skip to content
This repository has been archived by the owner on Jun 26, 2020. It is now read-only.

Provide support for translating plural forms #334

Merged
merged 37 commits into from
Apr 23, 2020
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
962f375
WIP - Adding support for passing object as a first argument for `t()`…
ma2ciek Apr 8, 2020
c14c653
Docs: Improved API docs.
ma2ciek Apr 17, 2020
a577cf6
Docs: Improved API docs.
ma2ciek Apr 17, 2020
05bb63a
Renamed `getFormIndex` to `getPluralForm`.
ma2ciek Apr 17, 2020
bf676ba
Changed: The message id used to determine the translation will be gen…
ma2ciek Apr 17, 2020
020072b
Tests: Added tests for the translation service.
ma2ciek Apr 17, 2020
2740664
Fix: Plural form will be set based on English rules if a function tha…
ma2ciek Apr 17, 2020
53f4639
Tests: Added tests for the locale.js.
ma2ciek Apr 17, 2020
7361eab
Merge branch 'master' into i/6526
ma2ciek Apr 17, 2020
0b01d9b
Internal: Fixed code style issues.
ma2ciek Apr 17, 2020
a36cad2
Docs: Improved `Locale` class API docs.
ma2ciek Apr 19, 2020
f116217
Docs: Improved `Locale` class API docs.
ma2ciek Apr 19, 2020
fc1c318
Docs: Improved `Locale` class and translation service API docs.
ma2ciek Apr 19, 2020
7515252
Fix: Simplified 'magic' code.
ma2ciek Apr 19, 2020
e36132d
Tests: Removed usage of deprecated API.
ma2ciek Apr 19, 2020
ea3ae82
Docs: Improved translation service API docs.
ma2ciek Apr 20, 2020
bf15d06
Other: Simplified the `Locale._t()` function.
ma2ciek Apr 20, 2020
e1c569b
Tests: Added missing test for the message context.
ma2ciek Apr 20, 2020
48d69e1
Docs: Improved translation service API docs.
ma2ciek Apr 20, 2020
30c31af
Docs: Improved `Local#t()` API docs.
ma2ciek Apr 20, 2020
aade538
Docs: Improved `Local#t()` API docs.
ma2ciek Apr 20, 2020
d0ddf47
Docs: Improved `Local#t()` API docs.
ma2ciek Apr 20, 2020
2acf51b
Added support for the shorthand `t` call signature: `t( message, valu…
ma2ciek Apr 21, 2020
7f0096d
Docs: Improved API docs for the `Locale#t()` call with a message and …
ma2ciek Apr 21, 2020
38db678
Docs: Fixed API docs issues.
ma2ciek Apr 21, 2020
db4f3c5
Docs: Fixed API docs issues in `translation-service`.
ma2ciek Apr 21, 2020
7797511
Apply suggestions from code review
ma2ciek Apr 21, 2020
33acb99
Other: Reworded amount to quantity in a few places.
ma2ciek Apr 21, 2020
358d8da
Applied suggestions from code review
ma2ciek Apr 21, 2020
11d5b8c
Other: Removed `message.context`, introduced the `message.id`.
ma2ciek Apr 22, 2020
9083b73
Other: Added support for plural rules that returns a boolean value.
ma2ciek Apr 22, 2020
da86205
Docs: Added a simpler scenario.
ma2ciek Apr 22, 2020
bf71980
Docs: Added a simpler scenario.
ma2ciek Apr 22, 2020
9fe6f52
Docs: Fixed API docs issue in `Locale`.
ma2ciek Apr 22, 2020
14d29a6
Docs: Added missing `module:` part.
ma2ciek Apr 22, 2020
cb5cd06
Applied suggestions from code review
ma2ciek Apr 22, 2020
3d8c423
Docs: Added docs for `translation-service#add() function`.
ma2ciek Apr 22, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 59 additions & 20 deletions src/locale.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' ];

Expand Down Expand Up @@ -76,25 +76,49 @@ export default class Locale {
this.contentLanguageDirection = getLanguageDirection( this.contentLanguage );

/**
* Translates the given string to the {@link #uiLanguage}. This method is also available in
* Translates the given message to the {@link #uiLanguage}. This method is also available in
* {@link module:core/editor/editor~Editor#t} and {@link module:ui/view~View#t}.
ma2ciek marked this conversation as resolved.
Show resolved Hide resolved
*
* 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 the second argument.
ma2ciek marked this conversation as resolved.
Show resolved Hide resolved
* 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 a plural form, use the `plural` property. Single or plural form
ma2ciek marked this conversation as resolved.
Show resolved Hide resolved
* will be chosen depending on the first value from the passed `values`. The value of this property is used
* as a default plural translation when the translation for the target language is missing. Therefor, it should be a number.
ma2ciek marked this conversation as resolved.
Show resolved Hide resolved
*
* 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 can provide a context using the `context` property when the message ids created from message strings
* are not unique. When the `context` property is set the message id will be constructed in
* the following way: `${ message.string }_${ message.context }`. This context will be also used
* by translators later as an additional context for the translated message.
*
* t( { string: 'image', context: 'Add/Remove 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.
mlewand marked this conversation as resolved.
Show resolved Hide resolved
* 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 );
}

/**
Expand Down Expand Up @@ -122,23 +146,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;
mlewand marked this conversation as resolved.
Show resolved Hide resolved

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.
Expand Down
153 changes: 125 additions & 28 deletions src/translation-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,62 +9,127 @@
* @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 either from the message string or from the message string and the message context in the following form:
* `<messageString>_<messageContext>` (this happens rarely and mostly for short messages or messages with placeholders).
* Since the editor displays only the message string, the message context 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_Insert image': 'obraz', // Note that the `Insert image` comes from the message context.
* } );
*
* If the message is supposed to support various plural forms, make sure to provide an array with the single form and all plural forms:
ma2ciek marked this conversation as resolved.
Show resolved Hide resolved
*
* add( 'pl', {
* 'Add space': [ 'Dodaj spację', 'Dodaj %0 spacje', 'Dodaj %0 spacji' ]
ma2ciek marked this conversation as resolved.
Show resolved Hide resolved
* } );
*
* 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.
*
* add( 'pl', {
* // ... Translations.
* }, n => n == 1 ? 0 : n % 10 >= 2 && n % 10 <= 4 && ( n % 100 < 10 || n % 100 >= 20 ) ? 1 : 2 );
ma2ciek marked this conversation as resolved.
Show resolved Hide resolved
*
* 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 languageTranslations = window.CKEDITOR_TRANSLATIONS[ language ];
*
* const dictionary = window.CKEDITOR_TRANSLATIONS[ language ] || ( 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 ];

Object.assign( dictionary, translations );
languageTranslations.dictionary = languageTranslations.dictionary || {};
languageTranslations.getPluralForm = getPluralForm || languageTranslations.getPluralForm;

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'
*
* 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.
* 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.
*
* translate( 'pl', 'Cancel [context: reject]' );
* 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'
*
* The message can provide a context using the `context` property when the message ids created from message strings
* are not unique. When the `context` property is set the message id will be constructed in
* the following way: `${ message.string }_${ message.context }`. This context will be also used
* by translators later as an additional context for the translated message.
*
* translate( 'en', { string: 'image', context: 'Add/Remove 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 ) {
Expand All @@ -73,14 +138,31 @@ export function translate( language, translationKey ) {
language = Object.keys( window.CKEDITOR_TRANSLATIONS )[ 0 ];
}

if ( numberOfLanguages === 0 || !hasTranslation( language, translationKey ) ) {
return translationKey.replace( / \[context: [^\]]+\]$/, '' );
// Use message context to enhance the message id when passed.
const messageId = message.context ?
message.string + '_' + message.context :
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 = getPluralForm( quantity );

// Note: The `translate` function is not responsible for replacing `%0, %1, ...` with values.
return dictionary[ messageId ][ pluralFormIndex ];
}

/**
Expand All @@ -93,13 +175,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} 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} [context] The message context. If passed then the message id is constructed form both,
* the message string and the message string in the following format: `<messageString>_<messageContext>`. This property is useful when
* various messages can share the same message string, when omitting a context would result in a broken translation.
* @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.
*/
Loading