Source originale du contenu
One of the new features coming to ECMAScript 6 (ES6), the next version of JavaScript standards, is the template string. The simplest use cases for template strings are creating multiline strings, and doing string interpolation.
Multiline string:
let s = `This is
a multiline
string`;
String interpolation:
let firstName = 'Bob', lastName = 'Smith';
let msg = `Hello ${firstName} ${lastName}!`; // 'Hello Bob Smith!'
You can also tag a template string by adding an expression before it. When a template string is tagged, the literals and substitutions are passed to the tag function, and whatever returns from the function is the resulting value.
function foo(literals, ...values) {
return 42;
}
foo`What do you get if you multiply ${6} by ${9}?`; // 42
// In the foo function:
// literals = ['What do you get if you multiply ', ' by ', '?']
// values[0] = 6
// values[1] = 9
This feature opens up some interesting syntactic sugar for producing and manipulating content. One such example is Internationalization or i18n.
In this post weâll look at:
- Implementing a basic i18n tag function for translating messages.
- Localization (l10n) of values using the Intl native object.
This post requires some knowledge of new ES6 features such as array comprehensions, arrow functions, spreads, etc. If you are new to ES6, I suggest brushing up on the basics. I found Ariyaâs posts on ES6 to be useful.
The i18n tag
Letâs start with an example of how we want to use the i18n
tag.
let name = 'Bob';
let amount = 100;
i18n`Hello ${name}, you have ${amount}:c(CAD) in your bank account.`
Here, we are expecting to translate the literals into the language of our choosing.
We are also adding optional annotations to the substitutions. In this case, the
amount
value has the :c(CAD)
annotation, which marks it as a currency type
that is in Canadian Dollars (CAD).
If we translated this string to German, for example, we expect the result to be
'Hallo Bob, Sie haben 1.234,56 $CA auf Ihrem Bankkonto.'
Note that the currency
amount has thousands and decimal separator as per German locale.
Implementation
Weâll allow configuration of our i18n
tag through I18n.use
.
let messageBundle_de = {
'Hello {0}, you have {1} in your bank account.': 'Hallo {0}, Sie haben {1} auf Ihrem Bankkonto.'
};
let i18n = I18n.use({
locale: 'de-DE',
defaultCurrency: 'EUR',
messageBundle: messageBundle_de});
// Now we can translate our string to German, with the Euro being the default currency
i18n`Hello ${name}, you have ${amount}:c(CAD) in your bank account.`
// => Hallo Bob, Sie haben 1.234,56 $CA auf Ihrem Bankkonto.
Letâs start with implementing the I18n.use
method, which should return the
tag function.
let I18n = {
use({lang, defaultCurrency, messageBundle}) {
I18n.lang = lang;
I18n.defaultCurrency = defaultCurrency;
I18n.messageBundle = messageBundle;
return I18n.translate;
},
translate(literals, ...values) {
let translationKey = ...;
let translationString = I18n.messages[translationKey];
if (translationString) {
let localizedValues = ...;
return I18n._buildMessage(translationString, ...localizedValues);
}
return 'Error: translation missing!';
}
};
The translationString
should match the keys in our message bundle. In our previous
German translation example, this would be 'Hello {0}, you have {1} in your bank account.'
.
The localizedValues
array is a map of substitution values
in their localized
forms. For example, in German 1000.12
should show as '1.000,12'
.
Now, I will show a full implementation of the library.
// Matches optional type annotations in i18n strings.
// e.g. i18n`This is a number ${x}:n(2)` formats x as number
// with two fractional digits.
const typeInfoRegex = /^:([a-z])(\((.+)\))?/;
let I18n = {
use({locale, defaultCurrency, messageBundle}) {
I18n.locale = locale;
I18n.defaultCurrency = defaultCurrency;
I18n.messageBundle = messageBundle;
return I18n.translate;
},
translate(literals, ...values) {
let translationKey = I18n._buildKey(literals);
let translationString = I18n.messageBundle[translationKey];
if (translationString) {
let typeInfoForValues = literals.slice(1).map(I18n._extractTypeInfo);
let localizedValues = values.map((v, i) => I18n._localize(v, typeInfoForValues[i]));
return I18n._buildMessage(translationString, ...localizedValues);
}
return 'Error: translation missing!';
},
_localizers: {
s /*string*/: v => v.toLocaleString(I18n.locale),
c /*currency*/: (v, currency) => (
v.toLocaleString(I18n.locale, {
style: 'currency',
currency: currency || I18n.defaultCurrency
})
),
n /*number*/: (v, fractionalDigits) => (
v.toLocaleString(I18n.locale, {
minimumFractionDigits: fractionalDigits,
maximumFractionDigits: fractionalDigits
})
)
},
_extractTypeInfo(literal) {
let match = typeInfoRegex.exec(literal);
if (match) {
return {type: match[1], options: match[3]};
} else {
return {type: 's', options: ''};
}
},
_localize(value, {type, options}) {
return I18n._localizers[type](value, options);
},
// e.g. I18n._buildKey(['', ' has ', ':c in the']) == '{0} has {1} in the bank'
_buildKey(literals) {
let stripType = s => s.replace(typeInfoRegex, '');
let lastPartialKey = stripType(literals[literals.length - 1]);
let prependPartialKey = (memo, curr, i) => `${stripType(curr)}{${i}}${memo}`;
return literals.slice(0, -1).reduceRight(prependPartialKey, lastPartialKey);
},
// e.g. I18n._formatStrings('{0} {1}!', 'hello', 'world') == 'hello world!'
_buildMessage(str, ...values) {
return str.replace(/{(\d)}/g, (_, index) => values[Number(index)]);
}
};
// Usage
let messageBundle_fr = {
'Hello {0}, you have {1} in your bank account.': 'Bonjour {0}, vous avez {1} dans votre compte bancaire.'
};
let messageBundle_de = {
'Hello {0}, you have {1} in your bank account.': 'Hallo {0}, Sie haben {1} auf Ihrem Bankkonto.'
};
let messageBundle_zh_Hant = {
'Hello {0}, you have {1} in your bank account.': 'ä½ å¥½{0}ï¼ä½ æ{1}å¨æ¨çéè¡å¸³æ¶ã'
};
let name = 'Bob';
let amount = 1234.56;
let i18n;
i18n = I18n.use({locale: 'fr-CA', defaultCurrency: 'CAD', messageBundle: messageBundle_fr});
console.log(i18n `Hello ${name}, you have ${amount}:c in your bank account.`);
i18n = I18n.use({locale: 'de-DE', defaultCurrency: 'EUR', messageBundle: messageBundle_de});
console.log(i18n `Hello ${name}, you have ${amount}:c in your bank account.`);
i18n = I18n.use({locale: 'zh-Hant-CN', defaultCurrency: 'CNY', messageBundle: messageBundle_zh_Hant});
console.log(i18n `Hello ${name}, you have ${amount}:c in your bank account.`);
One important thing to note here is that the _localizers
are using the
native Intl
object to localize values.
ES Internationalization API
The Internationalization API
adds new methods to native types, such as Number.prototype.toLocaleString()
and Date.prototype.toLocaleDateString()
.
These methods delegate to three objects: Intl.Collator
, Intl.NumberFormat
, and Intl.DateTimeFormat
. The
collator object enables string comparisons to be language sensitive, while the other two are pretty self-explanatory.
An option of the NumberFormat
we took advantage of in our implementation is the style
. The style value can be
decimal
(default), currency
, or percent
. In our case, we used both the currency and decimal support for the :c
and
:n
annotations respectively. For number localization, we also support rounding of fractional digits through the use of
minimumFractionDigits
and maximumFractionDigits
options.
Example usages
Now, we can translate our original string into different languages by configuring
the I18n
object using corresponding locales and message bundle.
let name = 'Bob';
let amount = 1234.56;
let i18n;
let messageBundle_fr = {
'Hello {0}, you have {1} in your bank account.': 'Bonjour {0}, vous avez {1} dans votre compte bancaire.'
};
let messageBundle_de = {
'Hello {0}, you have {1} in your bank account.': 'Hallo {0}, Sie haben {1} auf Ihrem Bankkonto'
};
let messageBundle_zh_Hant = {
'Hello {0}, you have {1} in your bank account.': 'ä½ å¥½{0}ï¼ä½ æ{1}å¨æ¨çéè¡å¸³æ¶ã'
};
i18n = I18n.use({locale: 'fr-CA', defaultCurrency: 'CAD', messageBundle: messageBundle_fr});
i18n`Hello ${name}, you have ${amount}:c in your bank account.`;
// => 'Bonjour Bob, vous avez 1 234,56 $CA dans votre compte bancaire.'
i18n = I18n.use({locale: 'de-DE', defaultCurrency: 'EUR', messageBundle: messageBundle_de});
i18n`Hello ${name}, you have ${amount}:c in your bank account.`;
// => 'Hallo Bob, Sie haben 1.234,56 ⬠auf Ihrem Bankkonto.'
i18n = I18n.use({locale: 'zh-Hant-CN', defaultCurrency: 'CNY', messageBundle: messageBundle_zh_Hant});
i18n`Hello ${name}, you have ${amount}:c in your bank account.`;
// => 'ä½ å¥½Bobï¼ä½ æï¿¥1,234.56å¨æ¨çéè¡å¸³æ¶ã'
Hereâs a link to an ES6 Fiddle for a live demo.
Concluding thoughts
The tagged template string can be used to provide powerful DSL to JavaScript libraries. I chose to try my hand at adding simple i18n support. There are still a lot of pieces missing to provide a comprehensive i18n solution, but I hope to have shown how one may go about building out this framework.
Pluralization can be a very tricky thing to tackle, and I am not qualified as of this writing to understand all the nuances of implementing this feature. Perhaps with a little more thinking, there may be some hope to add it in as well.
In any case, I do see a lot of potential in tagged template strings to add clean, clear syntax for library functionalities.
Edit (2014/03/21): Fixed German translation and added a conclusion section.
Edit (2014/10/01): Updated examples to not use array comprehension since they were removed in ES6