Using Package Plugins to Generate Localization Files

Posted Monday, September 11, 2023.

Introduction

As seen when creating a CLI in an artifact bundle, we made a cli and packaged it into an artifact bundle. Then in creating an SPM build plugin we used it to generate some code and resources for a library. We are going to use (and modify) these techniques to generate localization code and files for an iOS application.

We have a web application that helps us manage all our localized keys in a table format. The details of it are out of scope of this article but image it presents an interface as such:

Keyenfr
foo.bar.baz (a greeting message)HelloBonjour
foo.baz (a parting message)GoodbyeAu revoir

This gives us all the information we need to localize these 2 strings for 2 different languages. If we don't use any third party tools, without this tool our workflow might be as such:

  1. Create variables to access each key (namespaced by key parts - separated by .)
enum MyCoolType {
enum Foo {
static let baz = Bundle.main.localizedString(
forKey: "foo.baz",
value: nil,
table: nil
)

enum Bar {
static let baz = Bundle.main.localizedString(
forKey: "foo.bar.baz",
value: nil,
table: nil
)
}
}
}

In this case we dont provide a default value or table as we have the internal understanding there will be no empty values for a language/translation pair

  1. Update English en.lproj/Localized.strings file
/* a greeting message */
"foo.bar.baz" = "Hello";

/* a parting message */
"foo.baz" = "Goodbye";
  1. Update French fr.lproj/Localized.strings file
/* a greeting message */
"foo.bar.baz" = "Bonjour";

/* a parting message */
"foo.baz" = "Au revoir";

This can be a repetitive and error prone process if you have a lot of strings, or want to change the keys as you have to update them in n + 1 places (1 being the Swift code, and n being the number of language files). Additionally, the keys can drift far from the variable names which could eventually be unintuitive.

Lets try to generate both the Swift code and the Localized.strings files automatically from the above table. Assume we have a json representation of the table as well.

Strategy

  • Create package with a CLI tool that parses the table json and is capable of creating/writing the Swift code along with the Localized.strings files and their content.
  • Create another package that will be used for accessing the localized strings. This package will have a build plugin that uses the CLI
  • Make some updates to our iOS project
  • Consume strings in iOS project

CLI tool

We will modify the CLI we built from here as we need to slightly update the code generation that the MyCLI command does.

Our json representation of the table above will now go into info.json. This way, our build plugin and CLI can know all the languages, keys and strings we will need to write.

Generate Swift

The writeSwift(_:to:) function is responsible for taking all the translation keys (and their comments), using the keys to figure out which nested enum they should live in, and creating the Swift code with a static variable containing access to a localized string.

Given foo.bar.baz and foo.baz, it would generate the following:

public enum Foo {
/// a parting message
///
/// key: foo.baz
public static let baz = Bundle.module.localizedString(
forKey: "foo.baz",
value: nil,
table: nil
)

public enum Bar {
/// a greeting message
///
/// key: foo.bar.baz
public static let baz = Bundle.module.localizedString(
forKey: "foo.bar.baz",
value: nil,
table: nil
)
}
}

The comment which generally exists in the Localized.strings entries can be nicely used for autocompletion information from Xcode

Note we need to use Bundle.module because we are in a Swift package

All this generated code would exist within extension MyCoolType { ... } in place of where we were previously reading text files.

Generate Resources

The writeResources(_:to:) function is responsible for generating the folders and files for each language's Localized.strings file.

For our example table, it would create 2 files:

English

en.lproj/Localized.strings

/* a greeting message */
"foo.bar.baz" = "Hello";

/* a parting message */
"foo.baz" = "Goodbye";
French

fr.lproj/Localized.strings

/* a greeting message */
"foo.bar.baz" = "Bonjour";

/* a parting message */
"foo.baz" = "Au revoir";

Package Plugin and Library

We will modify the package plugin and consuming library we built here.

Package.swift Updates

We need to include defaultLocalization property because we are going to be vending localization files which will be consumed by our iOS application.

...

let package = Package(
...,
defaultLocalization: "en",
...
)

Plugin Updates

In the plugin, instead of determining text file paths we will generate, we need to determin the .lproj directories that well be generated as a function of supported languages.

In the plugin:

...

@main
struct Plugin: BuildToolPlugin {
func createBuildCommands(...) async throws -> [Command] {
...
- let txtPaths = info.keys.map { "\($0).txt" }
+ let languagePaths = languages.map { resourcesPath.appending("\($0).lproj") }

return [
.buildCommand(
...,
- outputFiles: [generatedPath] + txtPaths
+ outputFiles: [generatedPath] + languagePaths
)
]
}
}

MyCoolLib Target

Whenever the MyCoolLib target is built, we will have access to our strings! In this case there will be 2 available:

let greeting = MyCoolType.Foo.Bar.baz
let parting = MyCoolType.Foo.baz

iOS Application

Only a few steps are required to use this new package in your iOS application:

  1. Set CFBundleAllowMixedLocalizations in your Info.plist - it is required if a package has more or less language support than the app itself
  2. Add the my-cool-lib package in package dependencies 2.1 If you want to import MyCoolLib into multiple targets (ie the app but also frameworks), you will need to change the MyCoolLib library type to dynamic in its Package.swift
  3. Import package and use strings!
import MyCoolLib

print(MyCoolType.Foo.Bar.baz, "!")

Now you can switch languages in system settings (or in scheme when running in xcode) and see the changes in your copy update appropriately!


Tagged With: