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 10 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
17 changes: 13 additions & 4 deletions src/locale.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,10 @@ export default class Locale {
* t( 'Label' );
*
* @method #t
* @param {String} str The string to translate.
* @param {String} message The string to translate.
ma2ciek marked this conversation as resolved.
Show resolved Hide resolved
* @param {String[]} [values] Values that should be used to interpolate the string.
*/
this.t = ( ...args ) => this._t( ...args );
this.t = ( message, values ) => this._t( message, values );
}

/**
Expand Down Expand Up @@ -124,10 +124,19 @@ export default class Locale {
/**
* Base for the {@link #t} method.
*
* @param {Object|String} message
* @param {String[]} [values]
* @private
*/
_t( str, values ) {
let translatedString = translate( this.uiLanguage, str );
_t( message, values = [] ) {
if ( typeof message === 'string' ) {
message = { string: message };
}

const hasPluralForm = !!message.plural;
const amount = hasPluralForm ? values[ 0 ] : 1;

let translatedString = translate( this.uiLanguage, message, amount );

if ( values ) {
translatedString = translatedString.replace( /%(\d+)/g, ( match, index ) => {
Expand Down
87 changes: 70 additions & 17 deletions src/translation-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

/* globals window */
/* globals window, console */

/**
* @module utils/translation-service
Expand All @@ -20,9 +20,15 @@ if ( !window.CKEDITOR_TRANSLATIONS ) {
*
* add( 'pl', {
* 'OK': 'OK',
* 'Cancel [context: reject]': 'Anuluj'
* 'Cancel': 'Anuluj'
* } );
*
* If the message supports plural forms, make sure to provide an array with all plural forms:
*
* add( 'pl', {
* 'Add editor': [ 'Dodaj edytor', 'Dodaj %0 edytory', 'Dodaj %0 edytorów' ]
ma2ciek marked this conversation as resolved.
Show resolved Hide resolved
ma2ciek marked this conversation as resolved.
Show resolved Hide resolved
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:
Expand All @@ -40,11 +46,15 @@ if ( !window.CKEDITOR_TRANSLATIONS ) {
*
* @param {String} language Target language.
* @param {Object.<String, String>} translations Translations which will be added to the dictionary.
* @param {Function} getPluralForm A function that returns the plural form index.
*/
export function add( language, translations ) {
const dictionary = window.CKEDITOR_TRANSLATIONS[ language ] || ( window.CKEDITOR_TRANSLATIONS[ language ] = {} );
export function add( language, translations, getPluralForm ) {
const languageTranslations = window.CKEDITOR_TRANSLATIONS[ language ] || ( window.CKEDITOR_TRANSLATIONS[ language ] = {} );
ma2ciek marked this conversation as resolved.
Show resolved Hide resolved

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

Object.assign( languageTranslations.dictionary, translations );
}

/**
Expand All @@ -53,18 +63,32 @@ export function add( language, translations ) {
* This happens in a multi-language mode were translation modules are created by the bundler.
*
* 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 string.
*
* 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( 'pl', 'Cancel [context: reject]' );
* translate( 'pl', { string: 'Cancel' } );
ma2ciek marked this conversation as resolved.
Show resolved Hide resolved
*
* 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 supports plural forms.
*
* translate( 'en', { string: 'Add a space', plural: 'Add %0 spaces }, 1 ); // 'Add %0 space'
ma2ciek marked this conversation as resolved.
Show resolved Hide resolved
* translate( 'en', { string: 'Add a space', plural: 'Add %0 spaces }, 3 ); // 'Add %0 spaces'
*
* @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} [amount] 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, amount = 1 ) {
if ( typeof message === 'string' ) {
// TODO
ma2ciek marked this conversation as resolved.
Show resolved Hide resolved
console.warn( 'deprecated usage' );

message = { string: message };
}

const numberOfLanguages = getNumberOfLanguages();

if ( numberOfLanguages === 1 ) {
Expand All @@ -73,14 +97,30 @@ 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.context ?
message.string + '_' + message.context :
message.string;

if ( numberOfLanguages === 0 || !hasTranslation( language, messageId ) ) {
// return english forms:
ma2ciek marked this conversation as resolved.
Show resolved Hide resolved
if ( amount !== 1 ) {
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 );

// TODO - maybe a warning could be helpful for some mismatches.

// 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 ];
}

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

/**
Expand All @@ -93,13 +133,26 @@ 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 internalization message interface. A translation for the given language can be found.
ma2ciek marked this conversation as resolved.
Show resolved Hide resolved
*
* TODO
ma2ciek marked this conversation as resolved.
Show resolved Hide resolved
*
* @typedef {Object} Message
*
* @property {String} string The message string. It becomes the message id when no context is provided.
* @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>`.
* @property {String} [plural] The plural form of the message.
*/
57 changes: 37 additions & 20 deletions tests/locale.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,14 @@
/* globals console */

import Locale from '../src/locale';
import {
add as addTranslations,
_clear as clearTranslations
} from '../src/translation-service';

describe( 'Locale', () => {
let locale;

beforeEach( () => {
locale = new Locale();
} );

afterEach( () => {
clearTranslations();
sinon.restore();
} );

Expand Down Expand Up @@ -115,49 +114,67 @@ describe( 'Locale', () => {
} );

describe( 't', () => {
it( 'has the context bound', () => {
const t = locale.t;
let locale;

expect( t( 'Foo' ) ).to.equal( 'Foo' );
} );
beforeEach( () => {
ma2ciek marked this conversation as resolved.
Show resolved Hide resolved
// 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;

it( 'interpolates 1 value', () => {
const t = locale.t;
addTranslations( 'pl', {
'foo': 'foo_pl',
'bar': [ 'bar_pl_0', '%0 bar_pl_1', '%0 bar_pl_2' ]
}, getPolishPluralForm );

expect( t( '%0 - %0', [ 'foo' ] ) ).to.equal( 'foo - foo' );
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( 'interpolates 3 values', () => {
it( 'should translate a string to the target ui language', () => {
const t = locale.t;

expect( t( '%1 - %0 - %2', [ 'a', 'b', 'c' ] ) ).to.equal( 'b - a - c' );
expect( t( 'foo' ) ).to.equal( 'foo_pl' );
} );

// 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 plural form to the target ui language based on the first value and interpolate the string', () => {
const t = locale.t;

expect( t( '%1 - %0 - %2' ) ).to.equal( '%1 - %0 - %2' );
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' );
} );

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.
scofalik marked this conversation as resolved.
Show resolved Hide resolved
expect( t( '%1 - %0 - %2' ) ).to.equal( '%1 - %0 - %2' );
expect( t( '%1 - %0 - %2', [ 'a' ] ) ).to.equal( '%1 - a - %2' );
} );
} );

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' );
} );

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' );
Expand Down
Loading