In the last 8 episodes of this long article to twist and replace the i18n package of Angular, we accomplished the following:
- We recreated the functionality of translation via a
pipe
, and added externally fed language scripts, to be able to use a single build for all languages, we figured out a better way to do Plural functionality, and customized the Angular provided locales to a certain safe limit (to be specific, we added theWoolong
currency) - We created an ExpressJS server to serve the single build with different languages, driven by URL:
/en/route
, or driven by a cookie saved via browser/route
- We added a few nuts and bolts to make it run in SSR
- We dived into creating different
index.html
on runtime using Express Template Engines, and on build time using Angular builders, or Gulp tasks - We added UI elements to switch language whether by URL, or saving a cookie, we extracted the movable configurable parts into its own
config
file - We tested on cloud hosts with a bit more restricted environment than Express, mainly Netlify, Firebase and Surge
What is nice about this solution is:
- One build, serve all, whether by URL or cookie, SSR, or client, Express or cloud hosting
- The language scripts are external files, they can be managed separately
- We still made use of the out-of-the-box libraries for locales
- I might be biased here but I don't think it's as complicated as the classical solution, what do you think?
Extract task
The last bit to this mission is to extract translation keys into the cr-lang
scripts. This is done with Angular Builder or Gulp. Since we are running the task locally before build, the Gulp packages do not have to be committed into our project git. This is better when dealing with remote pipelines where the host installs the npm
packages; because Gulp packages are not well maintained.
The task should do the following:
- Scan
.html
and.ts
files inside a source folder (where components live) - Find patterns of
"something" | translate:"code"
- Create a key:
"code": "something"
ready to be placed in the language scripts - Ignore already existing keys: this is a step ahead Angular's
i18n extract
task, which regenerates the wholexlf
file leaving it to us to merge with already translated text. - Keep it simple, do not create Count and Select keys, most often than not, we have already created them during development
- If language file does not exist, copy from the default language first, the default language script is chosen to be other than the default
en
, which has the embedded script code
First, let's create the right replacement comment tags in the right place in our scripts, let's begin with our default language: /locale/en.js
, also let's move any references to the locales and languages into their own const
// ...
// locales/en.js or ar.js
// let's move language references to a key at the top
const _LocaleId = 'ar-JO';
const _Language = 'ar';
// ...
const keys {
NorRes: '',
SomethingDone: '', // always have a comma at the end
// place those two lines for Gulp and Angular Builder, at the end of the keys
// inject:translations
// endinject
}
The Angular Builder, we'll create a new task: /extract/index.ts
, and install glob
to help us collect the target files:
// we will use glob from npmjs/glob to find our files easier
import glob from 'glob';
// languages, have name "ar" and localeId: "ar-JO", and isDefault to use script for new languages
interface ILanguage { name: string, localeId: string, isDefault?: boolean; }
interface IOptions {
// the source location to look for components
scan: string;
// the locales folder for scripts
destination: string;
// supported languages
languages: ILanguage[];
// optional, if not provided, taken from other targets, for prefix-language file name
prefix: string;
}
// very generic regex: "words" | translate:"code"
const _translateReg = /\s*["']([\w\d?.,!\s\(\)]+)["']\s*\|\s*translate:['"]([\w]+)['"]\s*/gim;
// I could have more distinctive patterns for select and plural, but I don't wish to
export default createBuilder(ExtractKeys);
// read script content, if not existent, copy isDefault language file
const getScriptContent = (options: IOptions, prefix: string, lang: ILanguage): string => {
// read file /destination/prefix-lang.js
const fileName = `${options.destination}/${prefix}-${lang.name}.js`;
let content = '';
// if does not exist, create it, copy the default language content
if (!existsSync(fileName)) {
const defaultLanguage = options.languages.find(x => x.isDefault);
const defaultFileName = `${options.destination}/${prefix}-${defaultLanguage.name}.js`;
const defaultContent = readFileSync(defaultFileName, 'utf8');
// replace language keys
// example replace 'ar-JO' with 'fr-CA; This is why it is important to separate those
// keys in the language script
content = defaultContent
.replace(`'${defaultLanguage.localeId}'`, `'${lang.localeId}'`)
.replace(`'${defaultLanguage.name}'`, `'${lang.name}'`);
writeFileSync(fileName, content);
} else {
content = readFileSync(fileName, 'utf8');
}
return content;
};
// extract translation terms from all ts and html files under certain folder
const extractFunction = (options: IOptions, prefix: string, lang: ILanguage) => {
// per language
const fileName = `${options.destination}/${prefix}-${lang.name}.js`;
// read content
const script = getScriptContent(options, prefix, lang);
// get all ts and html files
const files = glob.sync(options.scan + '/**/*.@(ts|html)');
// read files, for each, extract translation regex, add key if it does not exist
let _keys: string = '';
files.forEach(file => {
const content = readFileSync(file, 'utf8');
let _match;
while ((_match = _translateReg.exec(content))) {
// extract first and second match
const key = _match[2];
// if already found skip, also check destination script if it has the key
if (_keys.indexOf(key + ':') < 0 && script.indexOf(key + ':') < 0) {
_keys += `${key}: '${_match[1]}',\n`;
}
}
});
// write and save, keep the comment for future extraction
_keys += '// inject:translations';
writeFileSync(fileName, script.replace('// inject:translations', _keys));
};
async function ExtractKeys(
options: IOptions,
context: BuilderContext,
): Promise<BuilderOutput> {
// read prefix from angular.json metadata
const { prefix } = await context.getProjectMetadata(context.target.project);
try {
options.languages.forEach(lang => {
extractFunction(options, options.prefix || prefix.toString(), lang);
});
} catch (err) {
context.logger.error('Failed to extract.');
return {
success: false,
error: err.message,
};
}
context.reportStatus('Done.');
return { success: true };
}
Add the new task and the schemas to the builders.json
file
{
"builders": {
// ... add new extract builder
"extract": {
"implementation": "./dist/extract/index.js",
"schema": "./extract/schema.json",
"description": "Extract translation terms"
}
}
}
In angular.json
create a new target for the extract task
// in angular.json add the prefix in project metadata, or pass prefix to extract options
"prefix": "cr",
"architect": {
// new task for extractions, see builder/extract
"extract": {
"builder": "./builder:extract",
"options": {
"destination": "./src/locale",
"scan": "./src/app/components",
// if different that meta data, you can pass prefix override here
"prefix": "cr",
"languages": [
{
"name": "en",
"localeId": "en"
},
{
"name": "ar",
"localeId": "ar-JO",
// copy from default file that has the injected script
"isDefault": true
},
{
"name": "fr",
"localeId": "fr-CA"
}
]
}
},
// ...
Build, then run ng run cr:extract
. This generates the right leftover keys, and creates missing files if needed. Find the builder code under StackBlitz /builder/extract
folder.
In Gulp, we create the missing files with simple sequence:
-
gulp.src
-
gulp.transform
-
gulp.rename
-
gulp.dest
Then I used gulp-inject
library to inject the keys, which is quite out of date, but it's otherwise awesome. Then simply gulp.series
to put them together. Find the final code in StackBlitz gulp/extract
folder.
Task enhancements
I can do this forever, going back to fix or enhance a few lines. But I will not scratch that itch.
Nevertheless, find under StackBlitz builder/locales/index.enhanced.ts
and under gulp/gulpfile.js
a couple of enhancements:
- Combined index file generators under one configuration, that generates only one scenario instead of both (URL or cookie driven,
index.[lang].html
, or[lang]/index.html
) - used
getProjectMetadata
in Angular builder to get the project prefix, not to repeat ourselves - I also separated the options in Gulp, into the
gulpfile.js
for better control
A detail
One of the details I avoided was relying on full names fr-CA
instead of two keys: fr
for language and fr-CA
for locale. I deliberately separated them because in my mind, French is French for all those who speak it, choosing the right locale is a business decision that we should not bother our users with. The Application should know whether the user is from Nigeria, or from Canada. The difference in outcome is however not a big difference. The index files would be named index.fr-CA.html
, all of our redirects would have fr-CA
instead of plain fr
, and our schemas would reflect that. The display language however, must be specific, in configuration file it would be something like this:
languages: [
{name: 'en', display: 'English'},
{name: 'fr-CA', display: 'Canadian French'},
{name: 'fr-NG', display: 'Nigerian French'},
]
But I pass, since for Arabic, it is quite annoying to ask the user to choose the version of Arabic to display with.
I am sure you'd find other enhancements throughout the project, I myself could not help going back to older episodes for more enhancements. Can you think of any? Share them with me please.
Thank you for reading this far, have you found any part of it too complicated? Was it worth the effort? Did you find out what Woolongs were? 🙂
Top comments (0)