By the end of this post we will build a Github Actions workflow to detect broken localisation keys in iOS projects; however this can be easily adapted to other cloud platforms (Travis, CircleCI, etc) and projects (Android, React, etc). Bonus: I will show how to quikly test that localisation files themselves are properly formatted or exist.
There are certain things we as developers enjoy: fast build, challenging tasks, and... reinventing wheels.
There are certain wheels we as developers enjoy reinventing: architecture best practices, data structures, and... continuous integration pipelines.
And yet, there are not many things worth reinventing, obviously unless they either bring one happiness, which is often the case. Or significantly speed up one’s developing process, which is not the case most of the time. Or just seem to be an interesting challenge to solve, which justifies the needs.
This is the case of my today’s post.
When building apps, I tend to do my best in following Apple’s best practices, whether it’s accessibility, or colour alignment, or localisation.
There are certain benefits to supporting localisation even when the target audience is based within the same language group:
- Strings are encoded as keys, and are located in the same place, so changing them by members of marketing or content teams is a breeze
- Tiny changes which you otherwise would not implement (e.g “favour” vs “favor” for British vs American English) are simple to support
- Reinventing wheels for plurals all over the codebase becomes redundant
Sadly, there is a price. And usually the price is introducing a human error.
Localisation 101 ¶
Let’s focus on a simple example. I have a button in the app, which says “Order cocktail”:
// Button.swift
let button = UIButton()
button.setTitle("Order cocktail", state: .normal)
I want to follow the best practices, and introduce localisation:
// Localizable.strings
"core.buttons.orderCocktail" = "Order cocktail";
// Button.swift`
let button = UIButton()
button.setTitle(
NSLocalizedString(
"core.buttons.orderCocktail",
comment: ""
),
state: .normal
)
The localisation code grows up with a boiler plate, and for my case I tend to introduce a simple extension to slightly simplify it:
// String+Extensions.swift
public extension String {
var localized: String {
return NSLocalizedString(self, bundle: Bundle.main, comment: self)
}
func format(_ arguments: CVarArg...) -> String {
return String(format: self, locale: Locale.current, arguments: arguments)
}
}
So we can trim it down a bit:
// Button.swift
let button = UIButton()
button.setTitle(
"core.button.orderCocktail".localized,
state: .normal
)
And if we want to pass any parameters, we can do it like that:
"core.invitations.drinkInvitation".localized.format(numberOfPeople)
What could go wrong here? ¶
If you go back to my very last example, you might notice a typo:
not
button
butbuttons
.
How would that typo look like in the app? ¶
Terrible.
What can we do with that? ¶
Surely read through the code we or other members of the team write?
Not really.
The chances of a human error, an accidental key pressed before a commit, a bad merge resolution or anything else not that dependent on us, are very high.
With the app’s growth, or with the teams growth, or with the growth of the amount of content, the number of localised strings will grow as well. And the chances of a mistake will do too.
Whenever we, developers, want to automate something and take this burden out of our heads, we look into either pre-commit hooks, or CI pipelines. The hooks are nice but local, sharing them across teams is usually a pain, and there is no way to control whether someone didn’t disable it.
The CI pipeline, on the other hand, is robust. Let’s see how we can embed the localisation verification in there.
Pre-requisites ¶
To resolve the tasks we need a few bits:
- A way to find all localisation keys used across the codebase
- A way to check each of them in
*.strings
files. If something is present in the codebase but not in the localisation, it’s an issue. If something is present in localisation but not in the codebase — it’s not a problem. - A way to run this script on each commit in a PR.
- A way to notify developers about localisation issues, ideally by pointing to the keys and lines where it happens.
For the first two tasks I am going to write a simple Bash script (heavily inspired by the wonderful folks from SO).
It’s a beautiful language full of potential I want to run this script in a Linux container, so it seems to be the most straightforward approach.
For the last two tasks I am going to use Github Actions. While you can set it up on any CI solution (from Travis to CircleCI), I don’t want to spend too much time on setting up Github hooks and secret keys to post comments on the PR, and as you will see, with Github Actions it’s trivial.
Find all keys and map them ¶
#!/bin/sh
if [[ -z $lang ]]
then
# We can either hardcode "Base" here
# Or pass "en" / "fr" / an array to iterate through all possible locales
lang=$lang
fi
searchDir="ProjectName/Supporting Files"
# We use an extension to localize strings (e.g `"blabla.key".localize`)
output=$(grep -R "\".localize" . --include="*.swift")
# Total number of keys
count=$(( 0 ))
# Keys not found in *.strings
missing=$(( 0 ))
# Keys found but not valid (e.g the key is too long)
unusable=$(( 0 ))
OIFS="${IFS}"
NIFS=$'\n'
IFS="${NIFS}"
for LINE in ${output} ; do
IFS="${OIFS}"
quotes=`echo $LINE | awk -F\" '{ for(i=2; i<=NF; i=i+2){ a = a"\""$i"\"""^";} {print a; a="";}}'`
key=`echo $quotes | cut -f1 -d"^"`
keyLength=$(echo ${#key})
keyString=$key" ="
# Could be /*.strings instead of /Localizable.strings but we don't use multiple files yet
found=$(iconv -sc -f UTF-16 -t UTF-8 "$searchDir/Localizable.strings" | grep "$keyString")
if [[ -z $found ]]
then
found=$(grep -r "$keyString" "$searchDir"/ --include=*.strings)
fi
if [[ -z $found ]]
then
(( missing += 1 ))
echo $key $LINE
fi
(( count += 1 ))
IFS="${NIFS}"
done
Format a comment ¶
Now let's imagine we already can post comments on PRs, and thing how the comments should be formatted.
For each missing key, I want both the key and its line to be printed out:
# Github Actions can't parse new lines,
# so we add <NL> manually and will convert it later;
# also use Markdown to format the output
echo "<NL>**Missing**: \`$key\` from: <NL>\`\`\`swift<NL>$LINE<NL>\`\`\`"
I also want to have a summary:
echo "<NL>Out of $count keys: <NL>- $unusable not determined<NL>- $missing missing"
And then, I want to notify Github whether there are missing keys or not, in case it should fail a build.
# This is an output object for Github Actions
if [[ $missing > 0 ]]
then
echo "::set-output name=keys_missing::true" >> output.txt
else
echo "::set-output name=keys_missing::false" >> output.txt
fi
Cover up edge cases ¶
There are a few scenarios we didn't cover, mostly around edge cases and error handling:
!/bin/sh
lang = "Base"
searchDir="ProjectName/Supporting Files"
echo "*Verifying NSLocalizationStrings in "$searchDir"*"
output=$(grep -R "\".localize" . --include="*.swift")
count=$(( 0 ))
missing=$(( 0 ))
unusable=$(( 0 ))
OIFS="${IFS}"
NIFS=$'\n'
IFS="${NIFS}"
for LINE in ${output} ; do
IFS="${OIFS}"
quotes=`echo $LINE | awk -F\" '{ for(i=2; i<=NF; i=i+2){ a = a"\""$i"\"""^";} {print a; a="";}}'`
key=`echo $quotes | cut -f1 -d"^"`
# 1. Issues with extracting keys
if [[ -z $key ]]
then
(( unusable += 1 ))
echo "Couldn't extract key: " $LINE
fi
keyLength=$(echo ${#key})
# 2. Issues with keys that are too long
if [ $keyLength -gt 79 ]
then
(( unusable += 1 ))
echo "Key too long ("$keyLength"): " $key
fi
keyString=$key" ="
found=$(iconv -sc -f UTF-16 -t UTF-8 "$searchDir/Localizable.strings" | grep "$keyString")
if [[ -z $found ]]
then
found=$(grep -r "$keyString" "$searchDir"/ --include=*.strings)
fi
if [[ -z $found ]]
then
(( missing += 1 ))
echo "<NL>**Missing**: \`$key\` from: <NL>\`\`\`swift<NL>$LINE<NL>\`\`\`"
fi
(( count += 1 ))
IFS="${NIFS}"
done
IFS="${OIFS}"
echo "<NL>Out of $count keys: <NL>- $unusable not determined<NL>- $missing missing"
if [[ $missing > 0 ]]
then
echo "::set-output name=keys_missing::true" >> output.txt
else
echo "::set-output name=keys_missing::false" >> output.txt
fi
Run this on Github Actions ¶
# .github/workflows/Localization.yml
name: Check localization
on: [pull_request]
jobs:
build:
# For some reasons, posting comments on MacOS is not supported
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Check localization strings
# We assign an id to this step, so we can work with its artifacts later
id: app-loc
# 1. Make the script executable
# 2. Store the output into an env variable
# 3-7. Format the output to trim whitespaces and line breaks, and replace them with proper chars
# 8. Print out the key from the file to trigger setting an env var
# 9. Set another env var with the formatted comment message
run: |
chmod +x localizable.sh
export LOCALIZATION_OUTPUT=$(bash ./localizable.sh)
export LOCALIZATION_OUTPUT="${LOCALIZATION_OUTPUT//'%'/'%25'}"
export LOCALIZATION_OUTPUT="${LOCALIZATION_OUTPUT//$'\n'/'%0A'}"
export LOCALIZATION_OUTPUT="${LOCALIZATION_OUTPUT//$'<NL>'/'%0A'}"
export LOCALIZATION_OUTPUT="${LOCALIZATION_OUTPUT//$' '/'%0A'}"
export LOCALIZATION_OUTPUT="${LOCALIZATION_OUTPUT//$'\r'/'%0D'}"
echo $(cat output.txt)
echo "::set-output name=message::$LOCALIZATION_OUTPUT"
- name: Create comment
# We're using the id of a previous step to check it's output var, and skip this step
# If there are no issues
if: steps.app-loc.outputs.keys_missing == 'true'
uses: unsplash/comment-on-pr@master
with:
check_for_duplicate_msg: false
# If there are missing keys, we use the id to access a formatted message
msg: $
env:
GITHUB_TOKEN: $
- name: Fail non-localized builds
if: steps.app-loc.outputs.keys_missing == 'true'
# Calling exit 0 would finish the execution,
# But calling exit <ANY OTHER INT> fails the execution
# So the PR doesn't turn green if a localization is missing
run: exit 1
Here is what we have after this step:
Which in return displayes the following message on the PR:
Bonus: how test that a localization exists ¶
We know how to verify that the keys used across the codebase exist. How’d we verify that the localisation files themselves are formatted as expected?
Use Xcode 11's Test Plans by passing a language to verify in arguments on launch:
-AppleLanguages (ru)