As a lazy software developer who enjoys being highly productive during development and delivery, over years I have developed a few automation solutions for development, in addition to many products provided by prominent vendors and the open source communities.
Recently I have further enriched AppTranslation CLI, a collection of developer-focused CLI tools and libraries designed to automate the batch translation of application resources (UI text, localization resource files like XLIFF) with the following new generic meta formats:
I will explain the usages of the tool collection through a real world example: Ishihara Color Blind Test which is a static site PWA.
Development Platform:
It is assumed that you are already proficient in developing SPA/PWA using Angular 2+ or React etc. as well as understand internationalization and localization.
This article is focused on speeding up the localization works of delivering PWA, provided internationalization has been well designed and implemented.
Development frameworks like Angular provides built-in support for internationalization with translation resources like XLIFF.
Workflow:
Update XLIFF files through ng extract-i18n
↓
Translate updated nodes of XLIFF using MsTranslatorXliff.exe
The localization works around mostly done before ng build:
ng extract-i18n to update XLF files in “Src/color-blind-app/src/locales”. And run “TranslateLocales.ps1” in “myApp/src/locales”.ng extract-i18n and node generate-lang-codes.js which generated locales array for other scripts: “src/app/locales.auto.ts”, “src/app/locales.auto.html” and “locales.auto.ps1”.“TranslateLocales.ps1”
Set-Location $PSScriptRoot
$commandPath = 'C:/VsProjects/OpenSource/Translation/Release/All_Win/GoogleTranslateXliff.exe'
$apiKey = 'YourGoogleTranslateV2ApiKey'
$locales = & "$PSScriptRoot/locales.auto.ps1"
foreach ($lang in $locales) {
if ($lang -ne 'en') {
$cmd = "$commandPath /AK=$apiKey /B /F=src/locales/messages.$lang.xlf"
Invoke-Expression $ExecutionContext.InvokeCommand.ExpandString($cmd)
}
}
“locales.auto.ps1” is generated by “generate-lang-codes.js”
$names = @("en", "ar", "de", "es", "fr", "hi", "it", "ja", "ko", "pt", "ru", "tr", "vi", "zh-Hans", "zh-Hant")
return $names
One functional feature of Ishihara Color Blind Test for User Experience is that, the first time when the user launch the app through the default startup URL https://appHost/en, the app will deliver the UI and the content in the preferred language of the system/browser. For example, if the preferred language is “fr-CA”, the localized app loaded will become https://appHost/fr. After the first launch, the user may change the default settings and switch to any supported locale.
Therefore, the app had better does client side redirection before the bootstrap of the launched Angular localized app in the initial startup as well as loading the localized app of last used locale.
For the sake of direction before or after the bootstrap, the app code ought to know the locales to be supported. Rather than hard coding the supported locales in code, src/app/locales.auto.ts is generated by “generate-lang-codes.js” from “angular.json” which is the ultimate source of truth of what locales to be supported.
“src/app/locales.auto.ts”:
export const SUPPORTED_LOCALES : string[] = [
"en",
"ar",
"de",
"es",
"fr",
"hi",
"it",
"ja",
"ko",
"pt",
"ru",
"tr",
"vi",
"zh-Hans",
"zh-Hant"
] as const;
“generate-lang-codes.js”:
// generate language codes declared in angular.json
// run `node generate-lang-codes.js` to update src/app/locales.auto.ts, src/app/locales.auto.html and locales.auto.ps1
const fs = require('fs');
const angularJson = JSON.parse(
fs.readFileSync('angular.json', 'utf8')
);
const locales = [
angularJson.projects['color-blind-app'].i18n.sourceLocale.code,
...Object.keys(
angularJson.projects['color-blind-app'].i18n.locales
)
];
fs.writeFileSync(
'src/app/locales.auto.ts',
`export const SUPPORTED_LOCALES : string[] = ${JSON.stringify(locales, null, 2)} as const;`
);
// --- write the HTML link list ---
function getLanguageDisplayObject(code) {
const dn = new Intl.DisplayNames(['en'], { type: 'language' });
const dnLocalized = new Intl.DisplayNames([code], { type: 'language' });
return {
code,
display: dn.of(code),
localizedDisplay: dnLocalized.of(code),
};
}
const listItems = locales
.map((code) => {
const { display, localizedDisplay } = getLanguageDisplayObject(code);
const label =
display === localizedDisplay
? display
: `${display} ~ ${localizedDisplay}`;
return `\t\t<li><a href="${code}/">${label}</a></li>`;
})
.join('\n');
const html = `\t<ul>\n${listItems}\n\t</ul>\n`;
fs.writeFileSync('src/app/locales.auto.html', html);
const csvText = locales.map((code) => `"${code}"`).join(', ');
const ps1 = `$names = @(${csvText})\nreturn $names`;
fs.writeFileSync('locales.auto.ps1', ps1);
console.log(locales);
The help contents are standalone HTML files hosted in the same host but not part of the build assets. They are under “public/help”. And the app loads respective HTML files when needed.
Whenever the help contents are changed, run “TranslateHelpHtml.ps1” to translate what in “public/help” into folder “metaLocalized”. And the post build handling of buildParams.ps1 will copy the HTML files to the respective help folder of each localized build.
TranslateHelpHtml.ps1:
# Translate HTML data artifacts.
Set-Location $PSScriptRoot
$commandPath = 'C:/VsProjects/OpenSource/Translation/Release/All_Win/GoogleTranslateHtml.exe'
$apiKey = 'YourGoogleTranslateV2ApiKey'
$locales = & "$PSScriptRoot/locales.auto.ps1"
foreach ($lang in $locales) {
$targetDir = "./metaLocalized/$lang"
New-Item -Path $targetDir -ItemType Directory -Force | Out-Null
$cmd = "$commandPath /AK=$apiKey /TL=$lang /F=./public/help/startupHelp.html /TF=$targetDir/startupHelp.html"
Invoke-Expression $ExecutionContext.InvokeCommand.ExpandString($cmd)
}
foreach ($lang in $locales) {
$targetDir = "./metaLocalized/$lang"
New-Item -Path $targetDir -ItemType Directory -Force | Out-Null
$cmd = "$commandPath /AK=$apiKey /TL=$lang /F=./public/help/testHelp.html /TF=$targetDir/testHelp.html"
Invoke-Expression $ExecutionContext.InvokeCommand.ExpandString($cmd)
}
Hints:
Here’s the HTML help content in Spanish of the localized build for Spanish.
The app load “index.json” for the color blind test contents:
{
"$schema": "https://raw.githubusercontent.com/zijianhuang/schemas/refs/heads/main/json/AllColorBlindTestsSchema.json",
"title": "Ishihara Plates (2026)",
"description": "Multiple sets of Ishihara plates",
"testContents": [
{
"dir": "14-plate",
"test": {
"title": "14 Ishihara Plates",
"description": "14 Ishihara Plates refined by Martin Krzywinski",
"dir": "../../mk_svg_sources/svglowrespng",
"plates": [
{
"platePath": "1.svg",
"nature": "Letter",
"input": "Buttons",
"answer": "12",
"description": "Everyone should see the number 12.",
"buttonTexts": [
"21",
"17",
"12",
"72",
"89"
]
},
A few nodes of index.json for Test Contents need to be translated. Run “TranslatePlatesIndexJson.ps1”.
For example, here’s the localized index.json in Spanish.
TranslatePlatesIndexJson.ps1:
Set-Location $PSScriptRoot
$commandPath = 'C:/VsProjects/OpenSource/Translation/Release/All_Win/GoogleTranslateJson.exe'
$apiKey = 'YourGoogleTranslateV2ApiKey'
$locales = & "$PSScriptRoot/locales.auto.ps1"
foreach ($lang in $locales) {
$targetDir = "./metaLocalized/$lang"
New-Item -Path $targetDir -ItemType Directory -Force | Out-Null
$cmd = "$commandPath /AK=$apiKey /B /TL=$lang /F=../CONTENT_META/index.json /TF=$targetDir/index.json /PSF=JsonPaths.txt "
Invoke-Expression $ExecutionContext.InvokeCommand.ExpandString($cmd)
}
A few nodes of the file need to be translated and adjusted for each localized build regarding “start_url”. Run “TranslateManifest.ps1”.
For example, here’s the manifest.webmanifest in Spanish.
TranslateManifest.ps1:
# Translate manifest file for each localized app, being stored in folder metaLocalized.
Set-Location $PSScriptRoot
$commandPath = 'C:/VsProjects/OpenSource/Translation/Release/All_Win/GoogleTranslateJson.exe'
$apiKey = 'YourGoogleTranslateV2ApiKey'
$locales = & "$PSScriptRoot/locales.auto.ps1"
foreach ($lang in $locales) {
$targetDir = "./metaLocalized/$lang"
New-Item -Path $targetDir -ItemType Directory -Force | Out-Null
$cmd = "$commandPath /AK=$apiKey /B /TL=$lang /F=./public/manifest.webmanifest /TF=$targetDir/manifest.webmanifest /PS=name short_name description "
Invoke-Expression $ExecutionContext.InvokeCommand.ExpandString($cmd)
}
node adjustManifest.js
adjustManifest.js:
// change the lang, scope and start_url of manifest for each locale, stored in folder metaLocalized.
// Generally called inside TranslateManifest.ps1.
// crafted by Claude.ai
const fs = require('fs');
const path = require('path');
const distDir = path.join(__dirname, 'metaLocalized');
const locales = fs.readdirSync(distDir).filter(f =>
fs.statSync(path.join(distDir, f)).isDirectory()
);
locales.push('en'); //because en is not included in metaLocalized.
for (const locale of locales) {
const manifestPath = path.join(distDir, locale, 'manifest.webmanifest');
if (!fs.existsSync(manifestPath)) continue;
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
manifest.scope = '/'; // always root, same for every locale
manifest.start_url = `/${locale}/`; // locale-specific
manifest.lang = locale; // optional, but good practice
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
console.log(`Patched manifest for locale: ${locale}`);
}
Sometimes, the localization of the frontend involves only the application’s UI, while the other types, multiple types of technical and functional data require localization, just like the sample app of this article.
During development for testing the development build on local machine without involving localized builds, simply run ./buildParams.ps1.
And during development for testing the production build on local machine, run “buildProdEn.ps1”:
./buildParams.ps1 -buildConfig "production" -outputPath "../ngdist/prodEn"
To test the production build on local machine or deploy to “https://cbt.fonlow.org”, here’s the build script “buildProdLocalize.ps1”:
Set-Location $PSScriptRoot
$outputPath="../ngdist/prodLocalize"
./buildParams.ps1 -buildConfig "production" -baseHref "/" -outputPath $outputPath -localize $true
copy-item .\OnBoardingIndex.html -Destination "$outputPath/browser/index.html"
To deploy to “https://zijianhuang.github.io/cbt”, here’s the build script “buildProdGitHubPages.ps1”:
Set-Location $PSScriptRoot
$outputPath="../ngdist/prodGHP"
$githubRepo="cbt"
./buildParams.ps1 -buildConfig "production" -baseHref $githubRepo -outputPath $outputPath -localize $true
copy-item .\OnBoardingIndex.html -Destination "$outputPath/browser/index.html"
node alterHtmlBaseHref.js "/$githubRepo/" "$outputPath/browser/index.html"
The core of the build scripts shown above is buildParams.ps1:
<#
.SYNOPSIS
Default build script for development. And this can be used for the other build profiles with parameters, and different deployment targets.
.DESCRIPTION
Build according to angular.json, adjust config and copy non-assets files.
.PARAMETER apiBaseUri
Backend API base URI. Default to "http://localhost:5000/".
.PARAMETER buildConfig
Build configuration, default to "development". This will be passed to ng build --configuration.
.PARAMETER baseHref
Base href for the Angular app. Default to "/".
.PARAMETER outputPath
Output path for the build output. Default to "../ngdist/dev".
.PARAMETER ghPages404
Whether to generate 404.html for GitHub Pages. Default to $false.
#>
param(
[string]$buildConfig = "development",
[string]$baseHref = "/",
[string]$outputPath = "../ngdist/dev",
[bool]$ghPages404 = $false,
[bool]$localize = $false
)
# For Local app /app/ to serve frontend
Set-Location $PSScriptRoot
$epochMilliseconds = [int64]((Get-Date).ToUniversalTime() - [datetime]'1970-01-01').TotalMilliseconds
"const BUILD_META={buildTime: $epochMilliseconds};" | out-file -FilePath src/conf_template/buildTime.js
Write-Output "Ready to output to $outputPath ..."
$baseHrefText = ($baseHref -eq "/" ? "/" : "/$baseHref/")
Write-Output "Ready to output to $outputPath with base-href $baseHrefText ..."
# Generate locales.auto.ts then build
if ($localize) {
# Generate src/app/locales.auto.ts for ng build --localize
node.exe generate-lang-codes.js
ng build --configuration=$buildConfig --output-path="$outputPath" --base-href=$baseHrefText --localize
}
else {
node.exe clear-lang-codes.js
ng build --configuration=$buildConfig --output-path="$outputPath" --base-href=$baseHrefText
}
if ($LASTEXITCODE -ne 0) {
Write-Error "Angular build failed with exit code $LASTEXITCODE"
exit $LASTEXITCODE
}
if ($ghPages404) {
# Post build step 1: GitHub Pages 404 handling
copy-item "$outputPath/browser/index.html" "$outputPath/browser/404.html"
}
# Post build step 2: Copy other non-asset files, e.g. SVG plates.
if ($localize) {
$locales = & "$PSScriptRoot/locales.auto.ps1"
# step 2.1: per locale items of data and config
foreach ($lang in $locales) {
# Copy translated/transformed help content to replace what had been copied from the public folder of Angular sourcecode via ng build.
copy-item "./metaLocalized/$lang/startupHelp.html" -Destination "$outputPath/browser/$lang/help/" -Force
copy-item "./metaLocalized/$lang/testHelp.html" -Destination "$outputPath/browser/$lang/help/" -Force
# Copy translated test content
New-Item -ItemType Directory -Path "$outputPath/browser/$lang/CONTENT_META/" -Force | Out-Null
copy-item "./metaLocalized/$lang/index.json" -Destination "$outputPath/browser/$lang/CONTENT_META/" -Force
# Copy translated/transformed app meta of config
copy-item "./src/conf/" -Destination "$outputPath/browser/$lang" -Force -Recurse
copy-item "./metaLocalized/$lang/manifest.webmanifest" -Destination "$outputPath/browser/$lang/" -Force
# Copy web host config per locale
copy-item "./webPerLocale.config" -Destination "$outputPath/browser/$lang/web.config"
copy-item "./apachePerLocale.htaccess" -Destination "$outputPath/browser/$lang/.htaccess"
}
# Copy binary data shared by locales
copy-item "../mk_svg_sources/" -Destination "$outputPath/browser" -Force -Recurse
copy-item "../SVG_Files/" -Destination "$outputPath/browser" -Force -Recurse
# Copy Web host config at root
copy-item ./webLocalize.config -Destination "$outputPath/browser/web.config"
copy-item ./apacheLocalize.htaccess -Destination "$outputPath/browser/.htaccess"
}
else {
copy-item "./src/conf/" -Destination "$outputPath/browser/conf/" -Force -Recurse
copy-item "../CONTENT_META/" -Destination "$outputPath/browser" -Force -Recurse
copy-item "../mk_svg_sources/" -Destination "$outputPath/browser" -Force -Recurse
copy-item "../SVG_Files/" -Destination "$outputPath/browser" -Force -Recurse
# Web site config
copy-item ./webSingle.config -Destination "$outputPath/browser/web.config"
copy-item ./apacheSingle.htaccess -Destination "$outputPath/browser/.htaccess"
}
Write-Output "done $(Get-Date)"
As you can see, the pre-build processing covers:
Both files will become part of the JS bundle during ng build.
The post-build processing copes the translated resources and adjust some meta data and config for various technical requirements, outside what ng build could handle.
In a small software development shop, developers often have to carryout the localization works, especially when the app needs localized data and config. Through proper ClI tools and scripts, such works can been speeded up with less headache.
Machine translation today involves LLMs heavily. And prominent AI engines like Claude and ChatGPT etc. can actually do what the AppTranslation CLI can do, however, with some catches:
Nevertheless, sometimes I do use AI to do ad-hoc translation of a misc meta format. After a while I found myself need to deal with such translations more often, the catches mentioned started to bite me. Here come the AppTranslation CLI tools for XML, JSON and HTML.