articles

Speedup Localization Works of Delivering Angular PWA

Background

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:

  1. JSON text nodes selected by JSONPath.
  2. XML text nodes selected by XPath.
  3. HTML document or nodes selected by XPath.

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:

  1. Angular 21+
  2. Angular Material Components.
  3. AppTranslation CLIlatest release along with either Google Translate v2/3 or MS Translator.

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.

References

App UI

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:

Technical Details Explained

“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);

Help HTML Content

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.

Technical Details Explained

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.

Test Contents

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.

Technical Details Explained

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)
}

manifest.webmanifest

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.

Technical Details Explained

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}`);
}

Build for Multilingual App

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"

Technical Details Explained

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:

  1. Generate a timestamp JS file.
  2. Generate locales.auto.ts

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.

Summary

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.

About AI

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:

  1. Analyzing the structure of meta format like XLIFF or XML will burn a lot tokens, with prompts to instruct what to translate.
  2. You can script the prompt upon the APIs of a AI engine, however, the overall process of analyzing the meta format along with translation could be much slower than AppTranslation CLI calling the dedicated translation engine. In short, you may end up with slowness of localization and the bill shock from the excessive burning of tokens.
  3. The analysis works of meta format done by AI are inherently approximal and underdetermanistic, even if you carefully prompt AI with the constraints of the meta format. Therefore the translated meta data may have some places mishandled.

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.