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.
Install command
npx @skill-hub/cli install tddworks-claude-skills-tuist-app-localization
Repository
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 repositoryBest 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
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()
```