It is simple mathematics: the more customers you reach, the more likely they are to discover and engage with your skill. Therefore, we always suggest developers localize and publish skills in as many languages as possible. The process of extending your code to other languages can be a time-consuming, albeit worthwhile, process. It takes time to translate and you may need to hire external translators if you don’t know multiple languages. Depending on how you architect your code, it might add significant overtime in terms of back-end development.
In today’s post, I share some tips on how to streamline the localization process for your Alexa skills using the Alexa Skills Kit (ASK) Software Development Kit for Node.js. I’ll teach you how to simplify your back-end development so that you can keep a single AWS Lambda function, regardless of how many languages you end up supporting. I also share some options on how to manage localizable content. Internationalization, also known as “i18n” because there are 18 letters between the “i” and “n”, is a long word that describes this process. Note that “internationalization,” “i18n,” and “localization” will be used interchangeably in this post.
To keep our code maintainable and easily extensible with future languages, we want to:
'Andrea'
, 'Hello %s!'
will turn into 'Hello Andrea!'
)First off, separate your strings from your logic. Even in terms of files. Your working directory should look something like:
/
├── i18n/ // your language strings are here
│ ├── en.js
│ ├── de.js
│ ├── fr.js
│ ├── it.js
│ └── es.js
├── lib/
│ └── ... // your other logic
├── node_modules/
│ └── ... // your npm modules
└── index.js // your lambda entry point
We structure our language files as follows. You'll notice string IDs can contain either a string or an array of strings. If you choose to use an array of strings, the localization library will automatically pick a random value from the array, helping you give variety to your skill responses.
You will also notice we can add wildcards to the strings in the form of '%s' (for string-like variables) or '%d' (for number-like variables). How they work is simple: you just need to pass in an additional argument for every wildcard you have in your string, like: requestAttributes.t('GREETING_WITH_NAME', 'Andrea').
// en.js
module.exports = {
translation : {
'SKILL_NAME' : 'Super Welcome', // <- can either be a string...
'GREETING' : [ // <- or an array of strings.
'Hello there',
'Hey',
'Hi!'
],
'GREETING_WITH_NAME' : [
'Hey %s', // --> That %s is a wildcard. It will
'Hi there, %s', // get turned into a name in our code.
'Hello, %s' // e.g. requestAttributes.t('GREETING_WITH_NAME', 'Andrea')
],
// ...more...
}
}
Similarly, another locale would have the same keys, but different values, like this:
// it.js
module.exports = {
translation : {
'SKILL_NAME' : 'Iper Benvenuto',
'GREETING' : [
'Ciao!',
'Ehila!',
'Buongiorno'
],
'GREETING_WITH_NAME' : [
'Ciao %s', // --> That %s is a wildcard. It will
'Ehila %s', // get turned into a name in our code.
'Buongiorno, %s' // e.g. requestAttributes.t('GREETING_WITH_NAME', 'Andrea')
],
// ...more...
}
}
In order to make the above work, we need two node modules: i18next
and i18next-sprintf-postprocessor
.
npm i —save i18next i18next-sprintf-postprocessor
In our main index.js
we require the two node modules:
// in the index.js file, we add i18next and
// i18next-sprintf-postprocessor as dependencies
const i18n = require('i18next');
const sprintf = require('i18next-sprintf-postprocessor');
We also need to aggregate those language files inside the index.js file.
// further down the index.js
const languageStrings = {
'en' : require('./i18n/en'),
'it' : require('./i18n/it'),
// ... etc
}
Now we need a little bit of code to adapt the generic (and open-source) i18next localization framework and make it work nicely with the SDK. Add the following updated LocalizationInterceptor
below. The interceptor will automatically parse the incoming request, detect the user's locale and pick the right language strings to use. It will also combine the power of i18next, the sprintf functionality of the i18next-sprintf-postprocessor and automatically pick a response at random if a specific key (like 'GREETING') has an array of possible responses.
// inside the index.js
const LocalizationInterceptor = {
process(handlerInput) {
const localizationClient = i18n.use(sprintf).init({
lng: handlerInput.requestEnvelope.request.locale,
fallbackLng: 'en', // fallback to EN if locale doesn't exist
resources: languageStrings
});
localizationClient.localize = function () {
const args = arguments;
let values = [];
for (var i = 1; i < args.length; i++) {
values.push(args[i]);
}
const value = i18n.t(args[0], {
returnObjects: true,
postProcess: 'sprintf',
sprintf: values
});
if (Array.isArray(value)) {
return value[Math.floor(Math.random() * value.length)];
} else {
return value;
}
}
const attributes = handlerInput.attributesManager.getRequestAttributes();
attributes.t = function (...args) { // pass on arguments to the localizationClient
return localizationClient.localize(...args);
};
},
};
Don't forget to register the LocalizationInterceptor when you instantiate your Skill Builder object:
// at the bottom of the index.js
const skillBuilder = Alexa.SkillBuilders.standard();
exports.handler = skillBuilder
.addRequestHandlers(
// your intent handlers here
)
.addRequestInterceptors(LocalizationInterceptor) // <-- ADD THIS LINE
.addErrorHandlers(ErrorHandler)
.lambda();
The reason we added a request interceptor is so that the SDK will run this every time a request comes in before we handle any request, pre-filling our request attributes with the localization strings for the incoming locale.
Now that the setup is complete, using it in your handlers is a breeze! Here are some examples:
// IN THE CASE OF A SIMPLE GREETING, WITHOUT THE NAME
const LaunchRequestHandler = {
canHandle(handlerInput) {
const request = handlerInput.requestEnvelope.request;
return request.type === 'LaunchRequest';
},
async handle(handlerInput) {
// we get the translator 't' function from the request attributes
const requestAttributes = handlerInput.attributesManager.getRequestAttributes();
// we call it using requestAttributes.t and reference the string key we want as the argument.
const speechOutput = requestAttributes.t('GREETING');
// -> speechOutput will now contain a 'GREETING' at random, such as 'Hello'
return handlerInput.responseBuilder
.speak(speechOutput)
.getResponse();
},
};
// IN THE CASE WE HAVE THE USER'S NAME
const LaunchRequestHandler = {
canHandle(handlerInput) {
const request = handlerInput.requestEnvelope.request;
return request.type === 'LaunchRequest';
},
async handle(handlerInput) {
const requestAttributes = handlerInput.attributesManager.getRequestAttributes();
const sessionAttributes = handlerInput.attributesManager.getSessionAttributes();
const username = sessionAttributes.username // <-- let's assume = 'Andrea'
const speechOutput = requestAttributes.t('GREETING_WITH_NAME', username); // < -- note the second argument
// -> speechOutput now contains a 'GREETING_WITH_NAME' at random, such as 'Hello, %s'
// and filled with the attribute we provided as the second argument 'username', i.e. 'Hello, Andrea'.
return handlerInput.responseBuilder
.speak(speechOutput)
.getResponse();
},
};
The great thing about the i18next framework is that you don't need to use arrays, you can use static strings if you want, and likewise you don't need to use the wildcard feature '%s'.
In some cases, we want some language files to cover multiple languages. For example: en.js might cover en-US, en-GB, en-IN. In other cases, we might want to override a specific string to make it more culturally relevant (e.g. sneakers vs trainers, brilliant vs awesome, etc). How would we do this?
Let's say for example, that we want to override the en.js with a custom en-GB greeting for our UK users. Simple! We would have a dedicated en-GB.js file with only the key/value pairs that change. i18next will automatically pick the available language string that is the most specific (similar to CSS selectors). For example, if there is a “en-GB” string available, it will pick it over the “en” equivalent.
/
├── i18n/
│ ├── en.js
│ ├── en-GB.js // <-- we add a special en-GB file
...
└── index.js
Our language files would look like this:
// en.js
module.exports = {
translation : {
// ... other strings
'SNEAKER_COMPLIMENT' : 'Those are awesome sneakers!' // <-- default
// ... other strings
}
Then we would add an en-GB file that ONLY overrides strings we want to change for en-GB.
// en-GB.js
module.exports = {
translation : {
'SNEAKER_COMPLIMENT' : 'Those are sick trainers!' // <--
}
Then, in our index.js file, we will make sure to add it to the languageStrings object:
// inside the index.js
const languageStrings = {
'en' : require('./i18n/en'),
'en-GB' : require('./i18n/en-GB'),
// ... etc
}
We're done!
While this method is great for simple skills, as your skill gets more and more complex, your list of strings will increase, and so will the effort required to maintain and localize them. If that happens it could make sense to ramp up your localization strategy with any of the below.
Sometimes your back end is already using hardcoded strings for speech output all over the place and sometimes these are just too many to process manually. Fortunately there are plenty of localization support libraries in Node.js (even a i18next scanner) that can scan your code, extract translation keys/values, and merge them into i18n resource files ready to use in the format described above. In order to automate the process you could even use gulp to run a string extraction task and generate the string resources programmatically.
It might be worth using a CMS or proper localization framework that allows external (non-technical) people to collaborate and provide translations for individual strings without giving access to your production environment. You would then export those strings and generate the language string files that your skill will read.
Some skill developers use cloud-based user friendly databases and ask their beta testers to contribute string resources and translations. Others prefer more i18n specific services where you can crowdsource the translation of strings to then manage them via API. Whatever you choose, the more strings your skill is dealing with the more necessary it becomes to centralize the management of these resources.
A fallback strategy when your backend gets incoming requests in unsupported locales would be to use a machine learning based translation service like Amazon Translate. In a Node.js based AWS Lambda function you would use the AWS SDK to get an AWS.Translate instance passing parameters like source language, destination language and the text to translate. Fortunately, AWS Lambda makes it very easy to connect to other AWS services: it already includes the AWS SDK as part of the execution environment so you don’t have to manually add it as a dependency. Additionally, it will automatically set the credentials required by the SDK to those of the IAM role associated with your function (you do not need to take any additional steps).