Back to skills
SkillHub ClubBuild MobileFull StackData / AIMobile

app-localization

iOS/macOS app localization management for Tuist-based projects with .strings files. Use when: (1) Adding new translation keys to modules, (2) Validating .strings files for missing/duplicate keys, (3) Syncing translations across languages, (4) AI-powered translation from English to other locales, (5) Checking placeholder consistency (%@, %d), (6) Generating localization reports, (7) Updating Swift code to use localized strings instead of hardcoded text.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
24
Hot score
88
Updated
March 20, 2026
Overall rating
C2.3
Composite score
2.3
Best-practice grade
B71.9

Install command

npx @skill-hub/cli install tddworks-claude-skills-tuist-app-localization

Repository

tddworks/claude-skills

Skill path: skills/tuist-app-localization

iOS/macOS app localization management for Tuist-based projects with .strings files. Use when: (1) Adding new translation keys to modules, (2) Validating .strings files for missing/duplicate keys, (3) Syncing translations across languages, (4) AI-powered translation from English to other locales, (5) Checking placeholder consistency (%@, %d), (6) Generating localization reports, (7) Updating Swift code to use localized strings instead of hardcoded text.

Open repository

Best for

Primary workflow: Build Mobile.

Technical facets: Full Stack, Data / AI, Mobile.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: tddworks.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install app-localization into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/tddworks/claude-skills before adding app-localization to shared team environments
  • Use app-localization for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: app-localization
description: |
  iOS/macOS app localization management for Tuist-based projects with .strings files.
  Use when: (1) Adding new translation keys to modules, (2) Validating .strings files for missing/duplicate keys,
  (3) Syncing translations across languages, (4) AI-powered translation from English to other locales,
  (5) Checking placeholder consistency (%@, %d), (6) Generating localization reports,
  (7) Updating Swift code to use localized strings instead of hardcoded text.
---

# App Localization

Manage iOS/macOS .strings files in Tuist-based projects.

## Project Structure

```
<ModuleName>/
├── Resources/
│   ├── en.lproj/Localizable.strings       # Primary language (English)
│   ├── <locale>.lproj/Localizable.strings # Additional locales
│   └── ...
├── Derived/
│   └── Sources/
│       └── TuistStrings+<ModuleName>.swift  # Generated by Tuist
└── Sources/
    └── **/*.swift  # Uses <ModuleName>Strings.Section.key
```

After editing .strings files, run `tuist generate` to regenerate type-safe accessors.

## Complete Localization Workflow

### Step 1: Identify Hardcoded Strings

Find hardcoded strings in Swift files:
```bash
# Find Text("...") patterns with hardcoded strings
grep -rn 'Text("[A-Z]' <ModuleName>/Sources/
grep -rn 'title: "[A-Z]' <ModuleName>/Sources/
grep -rn 'label: "[A-Z]' <ModuleName>/Sources/
grep -rn 'placeholder: "[A-Z]' <ModuleName>/Sources/
```

### Step 2: Add Translation Keys

Add keys to **all** language files:

**en.lproj/Localizable.strings** (primary):
```
/* Section description */
"section.key.name" = "English value";
"section.key.withParam" = "Value with %@";
```

**Other locales** (translate appropriately):
```
"section.key.name" = "<translated value>";
"section.key.withParam" = "<translated> %@";
```

### Step 3: Generate Type-Safe Accessors

```bash
tuist generate
```

This creates `Derived/Sources/TuistStrings+<ModuleName>.swift` with accessors:
- `<ModuleName>Strings.Section.keyName` (static property)
- `<ModuleName>Strings.Section.keyWithParam(value)` (static function for %@ params)

See [references/tuist-strings-patterns.md](references/tuist-strings-patterns.md) for detailed patterns.

### Step 4: Update Swift Code

Replace hardcoded strings with generated accessors.

#### Pattern Mapping

| Hardcoded Pattern | Localized Pattern |
|-------------------|-------------------|
| `Text("Title")` | `Text(<Module>Strings.Section.title)` |
| `Text("Hello, \(name)")` | `Text(<Module>Strings.Section.hello(name))` |
| `title: "Submit"` | `title: <Module>Strings.Action.submit` |
| `placeholder: "Enter..."` | `placeholder: <Module>Strings.Field.placeholder` |

#### Example Transformations

**Before**:
```swift
Text("Settings")
    .font(.headline)

TextField("Enter your name", text: $name)

Button("Submit") { ... }

Text("Hello, \(userName)!")
```

**After**:
```swift
Text(<Module>Strings.Section.settings)
    .font(.headline)

TextField(<Module>Strings.Field.namePlaceholder, text: $name)

Button(<Module>Strings.Action.submit) { ... }

Text(<Module>Strings.Greeting.hello(userName))
```

#### Handling Parameters and Plurals

**String with parameter** (key: `"search.noResults" = "No results for \"%@\""`):
```swift
// Before
Text("No results for \"\(searchText)\"")

// After
Text(<Module>Strings.Search.noResults(searchText))
```

**Conditional plurals**:
```swift
// Keys:
// "item.count" = "%d item"
// "item.countPlural" = "%d items"

// Swift:
let label = count == 1
    ? <Module>Strings.Item.count(count)
    : <Module>Strings.Item.countPlural(count)
```

**Multiple parameters** (key: `"message.detail" = "%@ uploaded %d files"`):
```swift
Text(<Module>Strings.Message.detail(userName, fileCount))
```

### Step 5: Validate Changes

1. Build the project to catch missing keys
2. Run validation script to check consistency:
```bash
python scripts/validate_strings.py /path/to/<ModuleName>
```

## AI-Powered Translation

When translating strings to non-English locales:

1. Read the English source string
2. Consider context from the key name (e.g., `search.noResults` = search UI)
3. Translate appropriately for the target locale:
   - **zh-Hans**: Simplified Chinese, formal but friendly
   - **zh-Hant**: Traditional Chinese
   - **ja**: Japanese, polite form (desu/masu style)
   - **ko**: Korean, polite form (hamnida/yo style)
   - **de/fr/es/etc.**: Appropriate regional conventions
4. Preserve all placeholders exactly (%@, %d, %ld, etc.)

**Translation context by UI element**:
- Labels: Keep concise
- Buttons: Action-oriented verbs
- Placeholders: Instructive tone
- Error messages: Helpful and clear
- Confirmations: Clear consequences

## Validation Scripts

### Validate .strings Files

```bash
python scripts/validate_strings.py /path/to/<ModuleName>
```

Checks for:
- Missing keys between languages
- Duplicate keys
- Placeholder mismatches (%@, %d, %ld)
- Untranslated strings (value = English)

### Sync Missing Translations

Report missing keys:
```bash
python scripts/sync_translations.py /path/to/<ModuleName> --report
```

Add missing keys as placeholders:
```bash
python scripts/sync_translations.py /path/to/<ModuleName> --sync
```

## Key Naming Convention

Pattern: `"domain.context.element"` → `<Module>Strings.Domain.Context.element`

### Domain-Focused Naming (User Mental Model)

Keys should reflect **what the user is doing**, not technical UI components:

| User Mental Model | Key Pattern | Generated Accessor |
|-------------------|-------------|-------------------|
| "I'm looking at my profile" | `"profile.name"` | `Strings.Profile.name` |
| "I'm testing a build" | `"betaBuild.whatToTest"` | `Strings.BetaBuild.whatToTest` |
| "I'm adding a tester" | `"testerGroup.addTester"` | `Strings.TesterGroup.addTester` |
| "Something went wrong with sync" | `"sync.error.failed"` | `Strings.Sync.Error.failed` |

### Good vs Bad Examples

| Bad (Technical) | Good (Domain-Focused) |
|-----------------|----------------------|
| `button.save` | `profile.save` |
| `field.email` | `registration.email` |
| `placeholder.search` | `appSelector.searchPlaceholder` |
| `error.network` | `sync.connectionFailed` |
| `label.title` | `settings.title` |
| `alert.confirm` | `build.expireConfirm` |

### Structure by Feature/Screen

Organize keys by the feature or screen where they appear:

```
/* Profile Section */
"profile.title" = "Profile";
"profile.name" = "Name";
"profile.save" = "Save Changes";
"profile.saveSuccess" = "Profile updated";

/* Beta Builds */
"betaBuild.title" = "Beta Builds";
"betaBuild.whatToTest" = "What to Test";
"betaBuild.submitForReview" = "Submit for Review";
"betaBuild.expireConfirm" = "Expire this build?";

/* Tester Groups */
"testerGroup.create" = "Create Group";
"testerGroup.addTester" = "Add Tester";
"testerGroup.empty" = "No testers yet";
```

This mirrors how users think: "I'm in Beta Builds, submitting for review" → `betaBuild.submitForReview`

## .strings File Format

```
/* Comment describing the section */
"key.name" = "Value";
"key.with.parameter" = "Hello, %@!";
"key.with.number" = "%d items";
"key.with.multiple" = "%1$@ has %2$d items";
```

Rules:
- Keys must be unique within a file
- Values are UTF-8 encoded
- Escape quotes with backslash: `\"`
- Line ends with semicolon
- Use positional parameters (%1$@, %2$d) when order differs between languages


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### references/tuist-strings-patterns.md

```markdown
# Tuist Generated Strings Patterns

This reference shows how Tuist generates type-safe string accessors from `.strings` files.

## Generated File Location

```
Derived/Sources/TuistStrings+<ModuleName>.swift
```

## Accessor Patterns

### Simple Strings

**Localizable.strings**:
```
"settings.title" = "Settings";
"settings.subtitle" = "Configure your preferences";
```

**Generated Swift**:
```swift
public enum <ModuleName>Strings: Sendable {
  public enum Settings: Sendable {
    /// Settings
    public static let title = <ModuleName>Strings.tr("Localizable", "settings.title")
    /// Configure your preferences
    public static let subtitle = <ModuleName>Strings.tr("Localizable", "settings.subtitle")
  }
}
```

**Usage**:
```swift
Text(<ModuleName>Strings.Settings.title)
```

### Parameterized Strings

**Localizable.strings**:
```
"search.noResults" = "No results for \"%@\"";
"item.count" = "%d items";
"greeting.hello" = "Hello, %@!";
```

**Generated Swift**:
```swift
public enum Search: Sendable {
  /// No results for "%@"
  public static func noResults(_ p1: Any) -> String {
    return <ModuleName>Strings.tr("Localizable", "search.noResults", String(describing: p1))
  }
}

public enum Item: Sendable {
  /// %d items
  public static func count(_ p1: Int) -> String {
    return <ModuleName>Strings.tr("Localizable", "item.count", p1)
  }
}

public enum Greeting: Sendable {
  /// Hello, %@!
  public static func hello(_ p1: Any) -> String {
    return <ModuleName>Strings.tr("Localizable", "greeting.hello", String(describing: p1))
  }
}
```

**Usage**:
```swift
Text(<ModuleName>Strings.Search.noResults(searchText))
Text(<ModuleName>Strings.Item.count(itemCount))
Text(<ModuleName>Strings.Greeting.hello(userName))
```

### Multiple Parameters

**Localizable.strings**:
```
"message.detail" = "%@ uploaded %d files";
"transfer.status" = "%1$@ sent %2$d items to %3$@";
```

**Generated Swift**:
```swift
public static func detail(_ p1: Any, _ p2: Int) -> String {
  return <ModuleName>Strings.tr("Localizable", "message.detail", String(describing: p1), p2)
}

public static func status(_ p1: Any, _ p2: Int, _ p3: Any) -> String {
  return <ModuleName>Strings.tr("Localizable", "transfer.status", String(describing: p1), p2, String(describing: p3))
}
```

**Usage**:
```swift
Text(<ModuleName>Strings.Message.detail(userName, fileCount))
Text(<ModuleName>Strings.Transfer.status(sender, itemCount, recipient))
```

## Key Naming to Accessor Mapping

Use **domain-focused naming** that reflects the user's mental model:

| Key Pattern | Generated Accessor |
|-------------|-------------------|
| `"profile.name"` | `<Module>Strings.Profile.name` |
| `"betaBuild.whatToTest"` | `<Module>Strings.BetaBuild.whatToTest` |
| `"testerGroup.form.title"` | `<Module>Strings.TesterGroup.Form.title` |

## Nested Enum Structure

Keys are split by `.` and each segment becomes a nested enum:

```
"betaBuild.action.submit"
→ <Module>Strings.BetaBuild.Action.submit

"testerGroup.empty.title"
→ <Module>Strings.TesterGroup.Empty.title

"sync.error.connectionFailed"
→ <Module>Strings.Sync.Error.connectionFailed
```

## Domain-Focused vs Technical Naming

Prefer keys that match how users think about features:

| Avoid (Technical) | Prefer (Domain-Focused) |
|-------------------|------------------------|
| `button.save` | `profile.save` |
| `error.network` | `sync.connectionFailed` |
| `placeholder.search` | `appSelector.searchHint` |
| `label.name` | `registration.name` |

## Parameter Type Mapping

| Format Specifier | Swift Parameter Type |
|-----------------|---------------------|
| `%@` | `Any` |
| `%d`, `%i` | `Int` |
| `%ld`, `%lld` | `Int` |
| `%f` | `Double` |
| `%s` | `UnsafePointer<CChar>` |

## Positional Parameters

When word order differs between languages, use positional parameters:

**English**: `"%1$@ uploaded %2$d files"`
**Japanese**: `"%2$d個のファイルを%1$@がアップロードしました"`

Both use the same Swift call:
```swift
<Module>Strings.Message.detail(userName, fileCount)
```

```

### scripts/validate_strings.py

```python
#!/usr/bin/env python3
"""
Validate .strings files for iOS/macOS localization.

Checks for:
- Missing keys between languages
- Duplicate keys within a file
- Invalid .strings format
- Placeholder mismatches (%@, %d, %ld, etc.)
- Untranslated strings (value same as key)

Usage:
    python validate_strings.py <module_path>
    python validate_strings.py /path/to/Modules/AppNexusKit

Output: JSON report with issues found
"""

import re
import sys
import json
from pathlib import Path
from collections import defaultdict


def parse_strings_file(file_path: Path) -> tuple[dict[str, str], list[str]]:
    """Parse a .strings file and return key-value pairs and any errors."""
    strings = {}
    errors = []

    try:
        content = file_path.read_text(encoding='utf-8')
    except UnicodeDecodeError:
        try:
            content = file_path.read_text(encoding='utf-16')
        except Exception as e:
            return {}, [f"Cannot read file: {e}"]

    # Remove comments
    content = re.sub(r'/\*.*?\*/', '', content, flags=re.DOTALL)
    content = re.sub(r'//.*$', '', content, flags=re.MULTILINE)

    # Match key = value pairs
    pattern = r'"([^"\\]*(?:\\.[^"\\]*)*)"\s*=\s*"([^"\\]*(?:\\.[^"\\]*)*)"\s*;'

    for match in re.finditer(pattern, content):
        key, value = match.groups()
        if key in strings:
            errors.append(f"Duplicate key: {key}")
        strings[key] = value

    return strings, errors


def extract_placeholders(text: str) -> list[str]:
    """Extract format placeholders from a string."""
    # Match %@, %d, %ld, %lld, %f, %.2f, %1$@, etc.
    pattern = r'%(?:\d+\$)?[-+0 #]*(?:\d+)?(?:\.\d+)?(?:hh|h|l|ll|L|z|j|t)?[diouxXeEfFgGaAcspn@%]'
    return re.findall(pattern, text)


def validate_module(module_path: Path) -> dict:
    """Validate all .strings files in a module."""
    resources_path = module_path / "Resources"
    if not resources_path.exists():
        return {"error": f"Resources directory not found: {resources_path}"}

    # Find all .lproj directories
    lproj_dirs = list(resources_path.glob("*.lproj"))
    if not lproj_dirs:
        return {"error": "No .lproj directories found"}

    # Parse all strings files
    all_strings = {}
    parse_errors = {}

    for lproj_dir in lproj_dirs:
        lang = lproj_dir.name.replace(".lproj", "")
        strings_file = lproj_dir / "Localizable.strings"

        if strings_file.exists():
            strings, errors = parse_strings_file(strings_file)
            all_strings[lang] = strings
            if errors:
                parse_errors[lang] = errors

    if not all_strings:
        return {"error": "No Localizable.strings files found"}

    # Use 'en' as primary language
    primary_lang = "en"
    if primary_lang not in all_strings:
        primary_lang = list(all_strings.keys())[0]

    primary_keys = set(all_strings[primary_lang].keys())

    issues = {
        "missing_keys": {},
        "extra_keys": {},
        "placeholder_mismatches": {},
        "untranslated": {},
        "parse_errors": parse_errors,
    }

    for lang, strings in all_strings.items():
        if lang == primary_lang:
            continue

        lang_keys = set(strings.keys())

        # Missing keys
        missing = primary_keys - lang_keys
        if missing:
            issues["missing_keys"][lang] = sorted(list(missing))

        # Extra keys
        extra = lang_keys - primary_keys
        if extra:
            issues["extra_keys"][lang] = sorted(list(extra))

        # Placeholder mismatches and untranslated
        placeholder_issues = []
        untranslated = []

        for key in primary_keys & lang_keys:
            primary_value = all_strings[primary_lang][key]
            lang_value = strings[key]

            # Check placeholders
            primary_placeholders = extract_placeholders(primary_value)
            lang_placeholders = extract_placeholders(lang_value)

            if sorted(primary_placeholders) != sorted(lang_placeholders):
                placeholder_issues.append({
                    "key": key,
                    "primary": primary_placeholders,
                    "translated": lang_placeholders,
                })

            # Check if untranslated (same as primary)
            if lang_value == primary_value and primary_value.strip():
                untranslated.append(key)

        if placeholder_issues:
            issues["placeholder_mismatches"][lang] = placeholder_issues
        if untranslated:
            issues["untranslated"][lang] = sorted(untranslated)

    # Summary
    summary = {
        "module": module_path.name,
        "languages": list(all_strings.keys()),
        "primary_language": primary_lang,
        "total_keys": len(primary_keys),
        "issues_count": sum(
            len(v) for v in issues["missing_keys"].values()
        ) + sum(
            len(v) for v in issues["placeholder_mismatches"].values()
        ),
    }

    return {"summary": summary, "issues": issues}


def main():
    if len(sys.argv) < 2:
        print("Usage: python validate_strings.py <module_path>")
        sys.exit(1)

    module_path = Path(sys.argv[1])
    if not module_path.exists():
        print(f"Error: Path does not exist: {module_path}")
        sys.exit(1)

    result = validate_module(module_path)
    print(json.dumps(result, indent=2, ensure_ascii=False))


if __name__ == "__main__":
    main()

```

### scripts/sync_translations.py

```python
#!/usr/bin/env python3
"""
Sync translations across language files.

Identifies missing translations and can:
1. Report missing keys per language
2. Copy missing keys from primary language (en) as placeholders
3. Generate a translation report

Usage:
    python sync_translations.py <module_path> [--report | --sync]

    --report: Generate a report of missing translations (default)
    --sync:   Add missing keys with English values as placeholders

Output: JSON report or synced files
"""

import re
import sys
import json
from pathlib import Path
from datetime import datetime


def parse_strings_file(file_path: Path) -> tuple[dict[str, str], list[tuple[str, str]]]:
    """Parse a .strings file and return key-value pairs preserving order."""
    strings = {}
    ordered_pairs = []

    try:
        content = file_path.read_text(encoding='utf-8')
    except UnicodeDecodeError:
        content = file_path.read_text(encoding='utf-16')

    # Match key = value pairs while preserving comments
    lines = content.split('\n')
    current_comment = []

    for line in lines:
        stripped = line.strip()

        # Collect comments
        if stripped.startswith('/*') or stripped.startswith('//'):
            current_comment.append(line)
            continue

        # Match key-value pair
        match = re.match(r'"([^"\\]*(?:\\.[^"\\]*)*)"\s*=\s*"([^"\\]*(?:\\.[^"\\]*)*)"\s*;', stripped)
        if match:
            key, value = match.groups()
            strings[key] = value
            ordered_pairs.append((key, value))
            current_comment = []

    return strings, ordered_pairs


def format_strings_entry(key: str, value: str, comment: str = None) -> str:
    """Format a single .strings entry."""
    if comment:
        return f'\n/* {comment} */\n"{key}" = "{value}";'
    return f'"{key}" = "{value}";'


def sync_module(module_path: Path, mode: str = "report") -> dict:
    """Sync translations for a module."""
    resources_path = module_path / "Resources"
    if not resources_path.exists():
        return {"error": f"Resources directory not found: {resources_path}"}

    # Find all .lproj directories
    lproj_dirs = list(resources_path.glob("*.lproj"))
    if not lproj_dirs:
        return {"error": "No .lproj directories found"}

    # Parse primary language (en)
    en_lproj = resources_path / "en.lproj"
    if not en_lproj.exists():
        return {"error": "English (en.lproj) not found"}

    en_strings_file = en_lproj / "Localizable.strings"
    if not en_strings_file.exists():
        return {"error": "English Localizable.strings not found"}

    primary_strings, primary_ordered = parse_strings_file(en_strings_file)
    primary_keys = set(primary_strings.keys())

    report = {
        "module": module_path.name,
        "primary_language": "en",
        "total_keys": len(primary_keys),
        "languages": {},
        "timestamp": datetime.now().isoformat(),
    }

    synced_files = []

    for lproj_dir in sorted(lproj_dirs):
        lang = lproj_dir.name.replace(".lproj", "")
        if lang == "en":
            continue

        strings_file = lproj_dir / "Localizable.strings"
        if not strings_file.exists():
            report["languages"][lang] = {
                "status": "file_missing",
                "missing_count": len(primary_keys),
            }
            continue

        lang_strings, _ = parse_strings_file(strings_file)
        lang_keys = set(lang_strings.keys())

        missing = primary_keys - lang_keys
        extra = lang_keys - primary_keys

        report["languages"][lang] = {
            "status": "ok" if not missing else "incomplete",
            "total_keys": len(lang_keys),
            "missing_count": len(missing),
            "extra_count": len(extra),
            "missing_keys": sorted(list(missing)) if missing else [],
            "completion_percentage": round((len(lang_keys) / len(primary_keys)) * 100, 1) if primary_keys else 100,
        }

        # Sync mode: add missing keys
        if mode == "sync" and missing:
            try:
                content = strings_file.read_text(encoding='utf-8')
            except UnicodeDecodeError:
                content = strings_file.read_text(encoding='utf-16')

            # Add missing keys at the end
            additions = [
                f'\n/* TODO: Translate from English */\n"{key}" = "{primary_strings[key]}";'
                for key in sorted(missing)
            ]

            new_content = content.rstrip() + "\n" + "\n".join(additions) + "\n"
            strings_file.write_text(new_content, encoding='utf-8')
            synced_files.append(str(strings_file))

    if mode == "sync":
        report["synced_files"] = synced_files

    return report


def main():
    if len(sys.argv) < 2:
        print("Usage: python sync_translations.py <module_path> [--report | --sync]")
        sys.exit(1)

    module_path = Path(sys.argv[1])
    mode = "report"

    if len(sys.argv) > 2:
        if sys.argv[2] == "--sync":
            mode = "sync"

    if not module_path.exists():
        print(f"Error: Path does not exist: {module_path}")
        sys.exit(1)

    result = sync_module(module_path, mode)
    print(json.dumps(result, indent=2, ensure_ascii=False))


if __name__ == "__main__":
    main()

```