DEV Community

fonlow
fonlow

Posted on

Speedup Localization Work When Delivering an Angular PWA

Background

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:

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

I'll walk through how to use these tools with a real-world example: Ishihara Color Blind Test, a static-site PWA.

Development Platform:

  1. Angular 21+
  2. Angular Material Components.
  3. "AppTranslation CLI" (latest release), used together with either Google Translate v2/3 or MS Translator.

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.

References

App UI

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 
Enter fullscreen mode Exit fullscreen mode

Most of the localization work happens before ng build:

  • Whenever you change a localized string in the Angular code, run ng extract-i18n to update the XLF files in "Src/color-blind-app/src/locales", then run "TranslateLocales.ps1" in "myApp/src/locales".
  • Whenever you add a new supported locale, update "angular.json" under "projects/myApp/i18n/locales" and "myApp/extract-i18n/options/targetFiles". Then run 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".
  • Finally, run "TranslateLocales.ps1" to translate all the XLIFF files.

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

"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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

"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);
Enter fullscreen mode Exit fullscreen mode

Help HTML Content

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.

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)
}
Enter fullscreen mode Exit fullscreen mode

Hints:

  • Note that $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.

Test Contents

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"
            ]
          },
Enter fullscreen mode Exit fullscreen mode

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.

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)
}
Enter fullscreen mode Exit fullscreen mode

manifest.webmanifest

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.

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
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

Build for Multilingual App

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Technical Details Explained

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)"
Enter fullscreen mode Exit fullscreen mode

As you can see, the pre-build processing covers:

  1. Generating a timestamp JS file.
  2. Generating "locales.auto.ts".

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.

Summary

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.

About AI

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:

  1. Analyzing the structure of a meta format like XLIFF or XML burns a lot of tokens, especially once you factor in the prompts needed to instruct the model on what to translate.
  2. You can script prompts against an AI engine's API, but the overall process of analyzing the meta format and performing the translation can end up much slower than AppTranslation CLI calling a dedicated translation engine directly. In short, you risk slower localization and a surprising token bill.
  3. AI's analysis of meta formats is inherently approximate and non-deterministic, even with carefully constrained prompts. As a result, the translated meta data may end up mishandled in places.

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.

Top comments (0)