I’ve been slowly splitting The Stack up into small internal Swift packages.
Unfortunately, if you use localizable string catalogues in Xcode, there are a few wrinkles.
Lots Of Small Targets
I’ve been splitting Xcode app projects into multiple packages for a few years now.
Mihaela Mihaljevic wrote about something similar a while ago. Some of the details in that post are different, but the basic idea is similar to what I do.
I find it a good way to minimize entangled dependencies, and it has other advantages.
It gives you an obvious place to put a small targeted set of unit tests, it can help with SwiftUI preview performance, and it lets you build and test a lot of code without the need for Xcode.
Using Catalogues In Packages
If you just want localised strings in a package, the basics are straightforward:
- set
defaultLocalizationinPackage.swift - put your catalogue in
Sources/MyTarget/Resources/Localizable.xcstrings - tell Package.swift to process the resource file
Something like this:
let package = Package(
name: "StackCore",
defaultLocalization: "en",
// ...
targets: [
.target(
name: "StackCommands",
resources: [.process("Resources")]
)
]
)
If you’re only using Xcode, this mostly works.
Which Catalogue Was It Again?
By default, localization lookup assumes that the strings are in your main bundle.
That is fine for apps, but not so fine for strings that actually live inside a package target.
You can force this to work in a library target by being explicit:
LocalizedStringResource("show.active", bundle: .module)
That works, but it is noisy, and is particularly clunky in SwiftUI views where the visual clutter detracts from otherwise clean code. It is also easy to forget to add the extra bundle parameter.
A nicer option is to get Swift to generate symbols for your localization keys, and to use these directly from code:
public let name = String(localized: .actionNew)
Text(.settingsHotkeyHelp(appName))
One benefit of this is that if the key changes, the generated symbol name will change, and your code won’t build. No more mismatched keys.
However, another benefit specifically useful for libraries is that those generated constants carry the right context. If you use the generated symbol in a place that expects a localizable string, it will automatically pull it from the correct bundle.
This is nice. Less boilerplate, fewer mistakes with keys, and localizations close to the code that uses them.
Building Outside Xcode
There is another wrinkle if you build packages from the command line.
If you build within Xcode, or with the xcodebuild command line tool, symbol generation
for .xcstrings happens automatically, even for SwiftPM library targets.
If you build with swift build or swift test however, it doesn’t. This is because Swift Package Manager does not support string catalogs out of the box.
Luckily, there is a command line tool to do the required work:
xcrun xcstringstool generate-symbols ...
It’s easy enough to write a small SwiftPM plugin to call that tool for each of your targets.
That solves command-line builds, but introduces a new problem: when Xcode does its own generation, it will run your plugin too, so you end up with duplicate symbols.
The way around this is to only apply the plugin when the build is not being driven by Xcode.
In your Package.swift you can work around this with a small environment check1:
#if canImport(Darwin)
import Darwin
let buildingInXcode = Context.environment["__CFBundleIdentifier"]?.contains("Xcode") ?? false
#else
let buildingInXcode = false
#endif
let localizationPlugins: [Target.PluginUsage] = buildingInXcode ? [] : [.plugin(name: "StackStringCatalogSymbols")]
Then targets that own catalogues just use plugins: localizationPlugins.
It’s a little bit of plumbing, but it keeps both worlds happy: Xcode builds and command-line SwiftPM builds.
Other Possible Patterns
I did wonder whether having all the localizations split up like this is actually a good idea in the first place? You do end up with a lot of different catalogues, which could be hard to maintain.
Maybe, instead, localization should be a layer that you only add at the application level? That way they’re all in one place, and different apps could even use the same library but overlay different localizations.
Unfortunately, if you want to use the generated string symbols in your code, your code needs to be able to see them. If the code is in a library, the symbol has to also be generated by that library, or visible to it.
Following this line of reasoning a bit further, could I make a library target with all the localizations in it, and then import that from the libraries that want to use the symbols?
This would still tie a library to a specific set of localizations for its keys, but at least all the strings would be in a single catalogue, which might be easier to manage.
Sadly this doesn’t work either, because the code generator, that creates the symbols, declares them as internal. You don’t get a free, public, cross-package symbol API that every other target can just import and use 😢.
For now, I’ve stuck with per-target catalogues in the Stack, each local to the package that owns the UI or command surface.
This feels consistent with the rest of the architecture. Generally the Stack code is factored into services that are responsible for one aspect of the application. Each service target owns its commands, views, tests, logic and resources - including strings.
Currently, I like this setup. Your Mileage May Vary™.
-
Thanks to Gwynne for pointing me in the right direction with this check. I originally tried importing
Foundationand looking at the environment withProcessInfo, but I couldn’t find something that was reliably set by Xcode and not by SwiftPM. UsingContextworks, and also removes theFoundationimport, which is apparently best avoided. ↩