fonlowBackground References App UI Technical Details Explained Help HTML Content Technical Details...
As a lazy software developer who enjoys being highly productive during development and delivery, I've spent years building automation solutions for my own workflow, on top of the many tools already provided by prominent vendors and open source communities.
Recently, I extended 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'll walk through how to use these tools with a real-world example: Ishihara Color Blind Test, a static-site PWA.
Development Platform:
This article assumes you're already comfortable building an SPA/PWA with Angular 2+, React, or similar, and that you understand internationalization and localization.
The focus here is on speeding up the localization work involved in delivering a PWA, assuming internationalization has already been well designed and implemented.
Frameworks like Angular provide built-in support for internationalization through translation resources such as XLIFF.
Workflow:
Update XLIFF files through ng extract-i18n
↓
Translate updated nodes of XLIFF using MsTranslatorXliff.exe
Most of the localization work happens before ng build:
ng extract-i18n to update the XLF files in "Src/color-blind-app/src/locales", then run "TranslateLocales.ps1" in "myApp/src/locales".ng extract-i18n followed by node generate-lang-codes.js, which regenerates the locales array used by 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 key UX feature of the Ishihara Color Blind Test is that, the first time a user launches the app through the default startup URL https://appHost/en, the app detects the preferred language of the system/browser and delivers the UI and content in that language. For example, if the preferred language is "fr-CA", the app redirects to https://appHost/fr. After the first launch, the user can change this default and switch to any supported locale.
Because of this, the app needs to perform client-side redirection before the bootstrap of the initially launched Angular app, and also needs to load whichever locale the user last selected.
To handle redirection correctly, both before and after bootstrap, the app code needs to know which locales are supported. Rather than hard-coding the list, src/app/locales.auto.ts is generated by "generate-lang-codes.js" from "angular.json", which remains the single source of truth for supported locales.
"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 content consists of standalone HTML files hosted on the same server, but not part of the build assets. They live under "public/help", and the app loads the relevant HTML file when needed.
Whenever the help content changes, run "TranslateHelpHtml.ps1" to translate the files in "public/help" into the "metaLocalized" folder. The post-build handling in "buildParams.ps1" then copies the translated HTML files into the corresponding 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:
$locales may include "en". In that case, GoogleTranslateHtml.exe simply copies the file over without translating it, since the target and source languages match. This is handy when the target filename doesn't need a ".$lang" suffix before the extension.Here's the HTML help content in Spanish from the localized Spanish build.
The app loads "index.json" for the color blind test content:
{
"$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 handful of nodes in "index.json" need translation. Run "TranslatePlatesIndexJson.ps1" to handle this.
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 this file need translation, and "start_url" needs adjusting for each localized build. Run "TranslateManifest.ps1" to handle both.
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 only the frontend UI needs localization. Other times, as with the sample app in this article, several additional types of technical and functional data need localization too.
During development, to test a development build on your local machine without involving localized builds, simply run ./buildParams.ps1.
To test a production build locally, run "buildProdEn.ps1":
./buildParams.ps1 -buildConfig "production" -outputPath "../ngdist/prodEn"
To test the production build locally, or deploy to "https://cbt.fonlow.org", use 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", use 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 all the build scripts 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 ..."
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) {
# Step 5: GitHub Pages 404 handling
copy-item "$outputPath/browser/index.html" "$outputPath/browser/404.html"
}
# Copy other non-asset files, e.g. SVG plates.
if ($localize) {
$locales = & "$PSScriptRoot/locales.auto.ps1"
foreach ($lang in $locales) {
copy-item "./src/conf/" -Destination "$outputPath/browser/$lang" -Force -Recurse
copy-item "../CONTENT_META/" -Destination "$outputPath/browser/$lang" -Force -Recurse
copy-item "./webPerLocale.config" -Destination "$outputPath/browser/$lang/web.config"
copy-item "./apachePerLocale.htaccess" -Destination "$outputPath/browser/$lang/.htaccess"
# Copy translated/transformed app meta data
copy-item "./metaLocalized/$lang/manifest.webmanifest" -Destination "$outputPath/browser/$lang/" -Force
copy-item "./metaLocalized/$lang/index.json" -Destination "$outputPath/browser/$lang/CONTENT_META/" -Force
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-item "../mk_svg_sources/" -Destination "$outputPath/browser" -Force -Recurse
copy-item "../SVG_Files/" -Destination "$outputPath/browser" -Force -Recurse
# Optional Web site config
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
# Optional 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 become part of the JS bundle during ng build.
The post-build processing copies the translated resources and adjusts some metadata and config for various technical requirements that fall outside what ng build can handle.
In a small software development shop, developers often have to carry out localization work themselves, especially when the app needs localized data and config alongside localized UI text. With the right CLI tools and scripts in place, this work can be sped up significantly, with a lot less headache.
Machine translation today relies heavily on LLMs, and prominent AI engines like Claude and ChatGPT can, in principle, do everything the AppTranslation CLI does. However, there are some catches:
That said, I still use AI occasionally for ad-hoc translation of a one-off meta format. But as I found myself needing this more often, these catches started to bite. That's what led to building AppTranslation CLI's support for XML, JSON, and HTML.