# String Catalogs in Depth

## Contents

1. [What a catalog is](#what-a-catalog-is)
2. [Creating one](#creating-one)
3. [What extraction picks up](#what-extraction-picks-up)
4. [Managing keys by hand](#managing-keys-by-hand)
5. [Strings outside SwiftUI](#strings-outside-swiftui)
6. [Bundles](#bundles)
7. [Swift packages and multiple modules](#swift-packages-and-multiple-modules)
8. [Plurals](#plurals)
9. [Device variants](#device-variants)
10. [Working with translators](#working-with-translators)
11. [The JSON format](#the-json-format)
12. [Generated symbols (Xcode 26)](#generated-symbols-xcode-26)
13. [Testing](#testing)
14. [Migrating from .strings and .stringsdict](#migrating-from-strings-and-stringsdict)
15. [Practices that scale](#practices-that-scale)

## What a catalog is

One `.xcstrings` file per target, stored as JSON, holds the source strings,
their translations, plural forms and device variants. Catalogs arrived in
Xcode 15 and work on every platform Xcode targets. Xcode 26 adds generated Swift
symbols as an extra layer; the catalog itself does not need Xcode 26.

## Creating one

1. File > New > File, choose String Catalog.
2. Call it `Localizable.xcstrings`. That name is the default table, the same
   role `Localizable.strings` used to play.
3. Put it in the target's source folder.
4. Add languages under Project > Info > Localizations.

Any other name is a separate table. A file named `Help.xcstrings` is read with
`String(localized: "faq.title", table: "Help")`.

## What extraction picks up

The compiler finds localizable strings on every build by looking at known
initializers and parameter types.

```swift
// Picked up
Text("Welcome aboard")
Label("Downloads", systemImage: "arrow.down.circle")
Button("Retry") { }
Toggle("Offline mode", isOn: $offline)
Section("Account") { }
someView.navigationTitle("Library")

String(localized: "sync.failed", defaultValue: "Sync failed", comment: "Toast after a failed sync")
LocalizedStringResource("Open in Maps")

enum Copy {
    static let emptyInbox: LocalizedStringResource = "Nothing new today"
}

// Not picked up
Text(viewModel.headline)           // value known only at runtime
Text(verbatim: "v2.4.1")           // opts out of localization
let screenName = "Library"         // plain String
print("Library opened")            // logging
```

`NSLocalizedString` is legacy, but Xcode can still export its literal keys.
If extraction misses something, add the key in the catalog editor.

## Managing keys by hand

In the editor:

- (+) adds a key.
- Delete marks a key Stale; if no code references it, the next build drops it.
- The Comment field gives translators context they cannot see in the UI.
- Right-click to mark an entry Needs Review or Reviewed.
- Vary > Plural and Vary > Device add variants.

Name keys like stable identifiers, grouped with dots:

```
checkout.summary.title
checkout.summary.total
error.offline.title
error.offline.message
```

With Xcode 26 symbols these keys also become readable, predictable accessors.
For simple SwiftUI screens it is fine to let the literal be the key. Strings
that are shared, used across modules, or reworded often should get a stable key.

## Strings outside SwiftUI

```swift
struct DeliveryStatusText {
    func text(for status: DeliveryStatus, eta: Date) -> String {
        switch status {
        case .preparing:
            String(localized: "delivery.preparing",
                   defaultValue: "The kitchen is preparing your order",
                   comment: "Order tracking, first step")
        case .onTheWay:
            String(localized: "delivery.on_the_way",
                   defaultValue: "Arriving \(eta, format: .dateTime.month().day())",
                   comment: "Order tracking with the expected day")
        case .delivered:
            String(localized: "delivery.done",
                   defaultValue: "Delivered",
                   comment: "Order tracking, final step")
        }
    }
}

let help = String(localized: "faq.title", table: "Help")
let packaged = String(localized: "player.skip",
                      table: "Player",
                      bundle: .module,
                      comment: "Skips 15 seconds forward")
```

## Bundles

- App target: `Bundle.main` is implied, so pass nothing.
- Swift package: `.module` is the package's resource bundle. Use
  `String(localized: "...", bundle: .module)`, and in SwiftUI pass it
  explicitly, `Text("...", bundle: .module)`. `Text` does not pick up the
  package bundle on its own.
- Framework: find the bundle through a class that lives in it.

```swift
final class ChartsBundleToken {}

let chartsBundle = Bundle(for: ChartsBundleToken.self)
let axisLabel = String(localized: "axis.time", bundle: chartsBundle)
```

Any code that is not in the main bundle has to name its bundle: `.module` in a
package, `Bundle(for:)` in a framework, or the current-target bundle macro
where the toolchain provides it.

## Swift packages and multiple modules

Every package target that shows text needs its own catalog.

```swift
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "ProfileKit",
    defaultLocalization: "en",
    targets: [
        .target(name: "ProfileKit", resources: [.process("Resources")])
    ]
)
// Catalog path: Sources/ProfileKit/Resources/Localizable.xcstrings
```

```swift
public struct SignOutButton: View {
    let action: () -> Void
    public var body: some View {
        Button(String(localized: "Sign out", bundle: .module), action: action)
    }
}
```

When a package keeps falling back to the development language, check setup
before lookup:

1. `defaultLocalization` is set in `Package.swift`.
2. The target's `resources` processes the catalog's folder, for example
   `.process("Resources")`.
3. The catalog file is actually inside that folder.

## Plurals

1. Write the string with an integer interpolation.
2. Build.
3. Select the key in the catalog.
4. Choose Vary by Plural in the inspector.
5. Fill each form every language needs.

English, with a named positional placeholder:

| Category | Value |
|---|---|
| one | `%1$(songCount)lld song` |
| other | `%1$(songCount)lld songs` |

Arabic fills all six categories: zero, one, two, few (`%lld` ...), many
(`%lld` ...) and other (`%lld` ...), each with its own wording.

Two integer interpolations in one string create a matrix. For
`"\(photos) photos in \(albums) albums"` English needs one/one, one/other,
other/one and other/other.

## Device variants

Vary by Device lets a key read differently on iPhone, iPad, Apple Watch, Mac,
Apple TV and Apple Vision Pro. The Swift call site does not change.

## Working with translators

Export with Product > Export Localizations... or on the command line. The
result is one `.xcloc` bundle per language, with XLIFF 1.2 inside.

```bash
xcodebuild -exportLocalizations \
  -project Journal.xcodeproj \
  -localizationPath ./l10n-out \
  -exportLanguage fr \
  -exportLanguage ko
```

Bring translations back with Product > Import Localizations...; Xcode merges
them into the catalog, and the diff viewer shows what changed.

```bash
xcodebuild -importLocalizations \
  -project Journal.xcodeproj \
  -localizationPath ./l10n-in/fr.xcloc
```

## The JSON format

Edit through the Xcode editor or the export/import round trip where possible.
Knowing the format helps when a parser-based check or a careful bulk edit is
needed; validate any scripted change before committing it.

```json
{
  "sourceLanguage" : "en",
  "version" : "1.1",
  "strings" : {
    "cart_empty" : {
      "comment" : "Shown when the cart has no items",
      "extractionState" : "manual",
      "localizations" : {
        "en" : { "stringUnit" : { "state" : "translated", "value" : "Your cart is empty" } },
        "de" : { "stringUnit" : { "state" : "translated", "value" : "Dein Warenkorb ist leer" } }
      }
    },
    "reviews_count" : {
      "localizations" : {
        "en" : {
          "variations" : {
            "plural" : {
              "one"   : { "stringUnit" : { "state" : "translated", "value" : "%lld review" } },
              "other" : { "stringUnit" : { "state" : "translated", "value" : "%lld reviews" } }
            }
          }
        }
      }
    }
  }
}
```

The top level has `sourceLanguage`, `version` and `strings`. Each key may hold
`comment`, `extractionState` and `localizations`, where every language has a
`stringUnit` with `state` and `value`. Plurals nest under
`localizations.<lang>.variations.plural.<category>.stringUnit`. Note that
`cart_empty` is a manual key: the English copy lives in `value`, not in the key.

Translation `state`:

| State | Meaning |
|---|---|
| `new` | Not translated yet |
| `translated` | Done |
| `needs_review` | Source text changed or someone flagged it |
| `stale` | The key is gone from code; a clean build removes it |

`extractionState`:

| State | Meaning |
|---|---|
| `extracted_with_value` | Found in source by the build |
| `manual` | Added by hand; build sync never changes or deletes it |
| `stale` | No longer found; its translations are orphaned |
| `migrated` | Came from `.strings` or `.stringsdict` |

Manual keys have Generate Swift Symbol on by default and, with the build
setting on, turn into compiler-checked `LocalizedStringResource` accessors.
Extracted keys opt in one at a time or through Convert Strings to Symbols.

## Generated symbols (Xcode 26)

Frame answers the same way as in the main skill: catalogs are the Xcode 15+
workflow; generated symbols are Xcode 26's typed layer on top. Never state that
catalogs need Xcode 26 or iOS 17.

Xcode 26 writes format version `"1.1"` into the file as soon as symbol metadata
exists.

Naming: the key is camelCased and its first segment lowercased.

| Key | Symbol |
|---|---|
| `cart_empty` | `.cartEmpty` |
| `profile.privacy.header` | `.profilePrivacyHeader` |
| `DONE` | `.done` |

Keys with format specifiers turn into functions. A named positional placeholder
such as `%1$(nights)lld` gives a readable label; a bare `%lld` gives a generic
one.

| Key and value | Symbol |
|---|---|
| `stay_length` = `%1$(nights)lld nights` | `.stayLength(nights: Int)` |
| `welcome_user` = `Hi %@` | `.welcomeUser(_ arg1: String)` |

Parameters can be renamed while refactoring.

```swift
Text(.cartEmpty)
Text(.stayLength(nights: 4))

let plain = String(localized: .cartEmpty)
let styled = AttributedString(localized: .welcomeUser(member.firstName))
```

Code completion lists the symbols after you type `.`.

Converting existing literals: select the keys, Control-click, Refactor > Convert
Strings to Symbols. Xcode rewrites the literals at their call sites. Convert
Symbols to Strings goes the other way.

Across modules: symbols are `internal`. The usual fix is a public extension on
`LocalizedStringResource` that forwards to them:

```swift
public extension LocalizedStringResource {
    static var sharedRetry: LocalizedStringResource { .retryButton }
}
```

When those wrappers become tedious, `xcstrings-tool`, an open-source Swift
package plugin, generates public constants for you.

Inside a Swift package, generated symbols read from the `.module` bundle
automatically, and they are only visible within the same package target.

## Testing

Scheme override: Edit Scheme > Run > Options > App Language starts the app in
another language without touching the system setting.

Pseudolanguages from the same menu:

| Pseudolanguage | Finds |
|---|---|
| Accented | Hard-coded strings that bypass lookup |
| Right-to-Left | Mirroring bugs |
| Double-Length | Truncation and overflow |
| Bounded String | Missing localizations (strings are wrapped in brackets) |

UI test in a specific locale:

```swift
import XCTest

final class GermanCheckoutUITests: XCTestCase {
    @MainActor
    func testPayButtonIsLocalized() {
        let app = XCUIApplication()
        app.launchArguments += ["-AppleLanguages", "(de)", "-AppleLocale", "de_DE"]
        app.launch()

        let pay = app.buttons["Jetzt bezahlen"]
        XCTAssertTrue(pay.exists)
        XCTAssertTrue(pay.isHittable)
    }
}
```

Screenshot comparison per locale: loop over `en_US`, `de_DE`, `ar_SA` and
`ja_JP`, set `-AppleLanguages` for each, launch, capture, compare against the
stored image.

Translation coverage in CI: parse the catalog and fail when any language entry
is untranslated or empty.

```python
#!/usr/bin/env python3
import json, sys

catalog = json.load(open(sys.argv[1], encoding="utf-8"))
gaps = []
for key, entry in catalog.get("strings", {}).items():
    for lang, loc in entry.get("localizations", {}).items():
        units = [loc["stringUnit"]] if "stringUnit" in loc else [
            v["stringUnit"]
            for group in loc.get("variations", {}).values()
            for v in group.values() if "stringUnit" in v
        ]
        for unit in units:
            if unit.get("state", "new") == "new" or not unit.get("value"):
                gaps.append(f"{lang}: {key}")

print("\n".join(gaps) or "all strings translated")
sys.exit(1 if gaps else 0)
```

## Migrating from .strings and .stringsdict

Automatic: right-click the `.strings` file, choose Migrate to String Catalog...,
check the result, then remove the old files from the target.

By hand, when a bundle setup is unusual or files come from CocoaPods:

1. Create a new catalog.
2. Build so extraction fills it.
3. Copy translations across from the old files.
4. Rebuild `.stringsdict` rules as plural variants.
5. Remove the legacy files.

Before calling it done:

- [ ] Every key made it across
- [ ] Plurals are variants now
- [ ] Custom bundle references point to the right place
- [ ] The build shows no missing-localization warnings
- [ ] Every language was run
- [ ] Legacy files are gone
- [ ] `.xcstrings` is committed (it diffs cleanly as JSON)

During the transition both formats work: lookup tries the catalog first, then
the `.strings` file. Delete the legacy file once you have verified the catalog.

## Practices that scale

1. One catalog per target as the source of truth.
2. A comment on every ambiguous key; translators never see the screen.
3. After each build, look for keys in state `new` and get them translated.
4. Keep `.xcstrings` in version control and review translation diffs in pull
   requests.
5. Run a translation coverage check in CI.
6. Send `.xcloc` exports to translators at every sprint or feature merge.
7. Run UI tests under the double-length and RTL pseudolanguages in CI.
8. Prefer stable keys with generated symbols for compile-time checking and
   autocompletion.
