mirror of
https://github.com/phranck/TUIkit.git
synced 2026-05-21 09:50:35 +00:00
Refactor: Move i18n documentation from separate files to DocC
- Created new Localization.md article in DocC catalog - Integrated user guide and developer guide content into single article - Deleted separate Documentation/i18n-guide.md and i18n-developer.md - Updated README.md to link to DocC documentation - Updated GettingStarted.md to reference Localization guide Documentation now follows project-wide DocC standards instead of separate Markdown files.
This commit is contained in:
@@ -1,377 +0,0 @@
|
||||
# TUIkit i18n Developer Guide
|
||||
|
||||
This guide is for developers working on TUIkit's localization infrastructure, not for app developers using TUIkit. See [i18n-guide.md](i18n-guide.md) for end-user documentation.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The TUIkit localization system consists of:
|
||||
|
||||
### 1. **LocalizationService** (`LocalizationService.swift`)
|
||||
Core service that manages:
|
||||
- Language selection and persistence
|
||||
- JSON translation file loading
|
||||
- String resolution with fallback chains
|
||||
- Thread-safe access via `NSLock`
|
||||
|
||||
### 2. **LocalizationKey** (`LocalizationKeys.swift`)
|
||||
Type-safe enum hierarchy providing:
|
||||
- Compile-time safe key references
|
||||
- Organized by category (Button, Label, Error, etc.)
|
||||
- Convenient extensions for LocalizedString and Text views
|
||||
|
||||
### 3. **LocalizedString** (`LocalizedString.swift`)
|
||||
Simple View component that displays localized strings by key.
|
||||
|
||||
### 4. **Translation Files** (`Localization/translations/*.json`)
|
||||
JSON files for each language:
|
||||
- `en.json` - English (primary reference)
|
||||
- `de.json` - German
|
||||
- `fr.json` - French
|
||||
- `it.json` - Italian
|
||||
- `es.json` - Spanish
|
||||
|
||||
## Adding New Translation Categories
|
||||
|
||||
To add a new category of strings (e.g., for a new UI area):
|
||||
|
||||
### Step 1: Update LocalizationKey Enum
|
||||
|
||||
Edit `Sources/TUIkit/Localization/LocalizationKeys.swift`:
|
||||
|
||||
```swift
|
||||
public enum LocalizationKey {
|
||||
// ... existing categories
|
||||
|
||||
/// New category for notifications
|
||||
public enum Notification: String {
|
||||
case success = "notification.success"
|
||||
case warning = "notification.warning"
|
||||
case error = "notification.error"
|
||||
}
|
||||
}
|
||||
|
||||
// Add convenient extension
|
||||
extension LocalizedString {
|
||||
public init(_ key: LocalizationKey.Notification) {
|
||||
self.init(key.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
extension Text {
|
||||
public init(localized key: LocalizationKey.Notification) {
|
||||
self.init(localized: key.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalizationService {
|
||||
public func string(for key: LocalizationKey.Notification) -> String {
|
||||
string(for: key.rawValue)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Add Keys to All Translation Files
|
||||
|
||||
For each of the 5 language files:
|
||||
|
||||
**en.json**:
|
||||
```json
|
||||
{
|
||||
"notification.success": "Success",
|
||||
"notification.warning": "Warning",
|
||||
"notification.error": "Error",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**de.json**:
|
||||
```json
|
||||
{
|
||||
"notification.success": "Erfolg",
|
||||
"notification.warning": "Warnung",
|
||||
"notification.error": "Fehler",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**fr.json**, **it.json**, **es.json**: Similar translations
|
||||
|
||||
### Step 3: Add Tests
|
||||
|
||||
Edit `Tests/TUIkitTests/LocalizationKeyConsistencyTests.swift`:
|
||||
|
||||
```swift
|
||||
// MARK: - Notification Key Tests
|
||||
|
||||
func testAllNotificationKeysExistInTranslations() {
|
||||
let keys = [
|
||||
LocalizationKey.Notification.success,
|
||||
LocalizationKey.Notification.warning,
|
||||
LocalizationKey.Notification.error,
|
||||
]
|
||||
|
||||
for key in keys {
|
||||
XCTAssertNotNil(
|
||||
englishTranslations[key.rawValue],
|
||||
"Notification key '\(key.rawValue)' not found in translations"
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Also update `testNoExtraneousKeysInTranslations()` to include the new keys in the `enumKeys` set.
|
||||
|
||||
Update `testAllEnumKeysAreCovered()` to reflect the new key count.
|
||||
|
||||
### Step 4: Add to Service Extensions (optional)
|
||||
|
||||
If you want LocalizationService to support the new category:
|
||||
|
||||
```swift
|
||||
// LocalizationService.swift (inside public extension)
|
||||
public func string(for key: LocalizationKey.Notification) -> String {
|
||||
string(for: key.rawValue)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Run Tests
|
||||
|
||||
```bash
|
||||
swift test --filter LocalizationKeyConsistencyTests
|
||||
```
|
||||
|
||||
All consistency tests should pass, verifying:
|
||||
- Every enum key exists in translations
|
||||
- No extraneous keys in translation files
|
||||
- Correct total key count
|
||||
|
||||
## Adding a New Language
|
||||
|
||||
To add support for a completely new language:
|
||||
|
||||
### Step 1: Update Language Enum
|
||||
|
||||
Edit `Sources/TUIkit/Localization/LocalizationService.swift`:
|
||||
|
||||
```swift
|
||||
public enum Language: String, Codable {
|
||||
case english = "en"
|
||||
case german = "de"
|
||||
case french = "fr"
|
||||
case italian = "it"
|
||||
case spanish = "es"
|
||||
case portuguese = "pt" // NEW
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
// ... existing cases
|
||||
case .portuguese: "Português" // NEW
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Create Translation File
|
||||
|
||||
Create `Sources/TUIkit/Localization/translations/pt.json` with ALL keys from the framework:
|
||||
|
||||
```json
|
||||
{
|
||||
"button.ok": "Tudo bem",
|
||||
"button.cancel": "Cancelar",
|
||||
...
|
||||
// Include ALL keys - use en.json as reference
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: Every key from en.json must be present in the new language file.
|
||||
|
||||
### Step 3: Run Consistency Tests
|
||||
|
||||
```bash
|
||||
swift test --filter LocalizationKeyConsistencyTests
|
||||
```
|
||||
|
||||
This will verify that all enum keys exist in the new language file.
|
||||
|
||||
### Step 4: Test the New Language
|
||||
|
||||
```swift
|
||||
let service = LocalizationService()
|
||||
service.setLanguage(.portuguese)
|
||||
|
||||
// Verify strings load correctly
|
||||
let ok = service.string(for: .button(.ok))
|
||||
XCTAssertNotEqual(ok, "button.ok") // Should be translated
|
||||
```
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
### 1. Dot-Notation Keys
|
||||
Keys use `category.subcategory` format:
|
||||
- `button.ok` - Button in Button category
|
||||
- `error.not_found` - "not_found" error in Error category
|
||||
- `validation.email_invalid` - "email_invalid" in Validation category
|
||||
|
||||
**Why**: Clearly organizes related strings, easy to grep, matches JSON structure.
|
||||
|
||||
### 2. Enum Categories
|
||||
Each JSON prefix becomes an enum:
|
||||
- All `button.*` keys → `LocalizationKey.Button` enum
|
||||
- All `error.*` keys → `LocalizationKey.Error` enum
|
||||
- All `validation.*` keys → `LocalizationKey.Validation` enum
|
||||
|
||||
**Why**: Type safety, IDE autocomplete, compile-time checking.
|
||||
|
||||
### 3. Fallback Chain
|
||||
Resolution tries: Current Language → English → Key
|
||||
```swift
|
||||
service.setLanguage(.french)
|
||||
let text = service.string(for: "button.ok")
|
||||
// Tries: French → English → Returns "button.ok"
|
||||
```
|
||||
|
||||
**Why**: Graceful degradation if a key is missing or language incomplete.
|
||||
|
||||
### 4. Persistence
|
||||
Language choice is saved to disk and restored on app restart:
|
||||
- **macOS**: `~/Library/Application Support/tuikit/language`
|
||||
- **Linux**: `~/.config/tuikit/language`
|
||||
|
||||
**Why**: Users expect their language preference to persist.
|
||||
|
||||
### 5. Thread Safety
|
||||
`LocalizationService` uses `NSLock()` for thread-safe access:
|
||||
- Language switching is atomic
|
||||
- Translation loading is atomic
|
||||
- Multiple threads can safely access simultaneously
|
||||
|
||||
**Why**: Apps may need to change language from background threads.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### LocalizationServiceTests.swift
|
||||
Core functionality tests:
|
||||
- Bundle loading for all languages
|
||||
- String resolution and dot-notation
|
||||
- Fallback behavior
|
||||
- Language switching
|
||||
- Persistence to disk
|
||||
- Thread safety
|
||||
|
||||
### LocalizationKeyTests.swift
|
||||
Type-safe key testing:
|
||||
- Each key category resolves correctly
|
||||
- Keys work across all languages
|
||||
- Enum raw values match string keys
|
||||
|
||||
### LocalizationKeyConsistencyTests.swift
|
||||
**Most important**: Validates sync between code and translations:
|
||||
- Every enum key exists in en.json
|
||||
- Every enum key exists in all other languages
|
||||
- No extraneous keys in translation files
|
||||
- Expected total key count
|
||||
|
||||
**Run after any localization changes**:
|
||||
```bash
|
||||
swift test --filter LocalizationKeyConsistencyTests
|
||||
```
|
||||
|
||||
## JSON File Format
|
||||
|
||||
### Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"category.key": "English text",
|
||||
"category.another_key": "More text",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Rules
|
||||
1. **Flat structure**: No nested objects (all keys are top-level)
|
||||
2. **Dot-separated keys**: `"button.ok"`, not `"button": { "ok": ... }`
|
||||
3. **UTF-8 encoding**: Required for non-Latin characters
|
||||
4. **Valid JSON**: No trailing commas, proper escaping
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"button.ok": "OK",
|
||||
"button.cancel": "Cancel",
|
||||
"error.not_found": "Not found",
|
||||
"error.unicode_test": "Unicode: café, Ñoño, 日本語",
|
||||
"placeholder.enter_name": "Enter name..."
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Caching
|
||||
Translations are cached per language after first load:
|
||||
```swift
|
||||
// First access: loads from Bundle, caches
|
||||
let text1 = service.string(for: "button.ok")
|
||||
|
||||
// Subsequent accesses: use cache
|
||||
let text2 = service.string(for: "button.ok") // Cache hit
|
||||
```
|
||||
|
||||
### Bundle Resources
|
||||
Translation files are bundled at compile time:
|
||||
- Included in `Package.swift` with `.copy("Localization/translations")`
|
||||
- Loaded via `Bundle.module.url(...)`
|
||||
- Zero runtime overhead for bundling
|
||||
|
||||
### Lock Contention
|
||||
While `NSLock` is used, contention is minimal:
|
||||
- Lock is only held during cache operations
|
||||
- String lookups are fast dictionary access
|
||||
- Lock is released immediately after
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### 1. Forgetting a Language
|
||||
Adding a key but not translating to all 5 languages:
|
||||
- **Caught by**: `testNoExtraneousKeysInTranslations()` in consistency tests
|
||||
- **Fix**: Add key to all 5 JSON files
|
||||
|
||||
### 2. Typos in Enum
|
||||
Using `button.ок` (Cyrillic 'k') instead of `button.ok` (Latin 'k'):
|
||||
- **Caught by**: IDE if you copy from enum
|
||||
- **Prevention**: Always use `LocalizationKey.Button.ok.rawValue`
|
||||
|
||||
### 3. Mismatched JSON Structure
|
||||
Changing key format after release (e.g., `button_ok` → `button.ok`):
|
||||
- **Issue**: Breaks existing preferences if keys changed
|
||||
- **Prevention**: Keys are permanent API, don't rename
|
||||
|
||||
### 4. Incomplete Translations
|
||||
Leaving English text in non-English translations:
|
||||
- **Testing**: Manual review of all languages
|
||||
- **Tools**: String comparison scripts (planned for future)
|
||||
|
||||
### 5. Character Encoding Issues
|
||||
Special characters garbled in translation files:
|
||||
- **Cause**: File saved in wrong encoding
|
||||
- **Fix**: Ensure UTF-8 encoding, validate with `jsonlint`
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Planned improvements to the localization system:
|
||||
|
||||
1. **Pluralization**: Handle plural forms ("1 item", "2 items")
|
||||
2. **Interpolation**: Support `"Hello {name}"` style formatting
|
||||
3. **Context**: Multiple translations based on context (e.g., Cancel as button vs command)
|
||||
4. **Import/Export Tool**: `tuikit i18n export --format=xliff` for easier translation management
|
||||
5. **String Analysis Tool**: Detect unused keys and missing translations
|
||||
6. **RTL Support**: Right-to-left language support for Arabic, Hebrew
|
||||
|
||||
## References
|
||||
|
||||
- **Main Documentation**: See [i18n-guide.md](i18n-guide.md)
|
||||
- **LocalizationService API**: `Sources/TUIkit/Localization/LocalizationService.swift`
|
||||
- **LocalizationKey Enums**: `Sources/TUIkit/Localization/LocalizationKeys.swift`
|
||||
- **Translation Files**: `Sources/TUIkit/Localization/translations/`
|
||||
@@ -1,435 +0,0 @@
|
||||
# TUIkit Internationalization (i18n) Guide
|
||||
|
||||
TUIkit provides comprehensive internationalization support with 5 languages built-in: English, German, French, Italian, and Spanish. All framework strings use a type-safe, dot-notation based localization system.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Quick Start](#quick-start)
|
||||
2. [Using Localized Strings](#using-localized-strings)
|
||||
3. [Type-Safe Keys](#type-safe-keys)
|
||||
4. [Switching Languages](#switching-languages)
|
||||
5. [Adding New Keys](#adding-new-keys)
|
||||
6. [Adding New Languages](#adding-new-languages)
|
||||
7. [Advanced Usage](#advanced-usage)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
Use `LocalizedString` to display localized text:
|
||||
|
||||
```swift
|
||||
import TUIkit
|
||||
|
||||
VStack {
|
||||
LocalizedString(.button(.ok))
|
||||
LocalizedString(.label(.name))
|
||||
LocalizedString(.error(.notFound))
|
||||
}
|
||||
```
|
||||
|
||||
Or use the `Text(localized:)` convenience initializer:
|
||||
|
||||
```swift
|
||||
Text(localized: .dialog(.confirm))
|
||||
```
|
||||
|
||||
### Switching Languages at Runtime
|
||||
|
||||
```swift
|
||||
// In your app
|
||||
AppState.shared.setLanguage(.german)
|
||||
|
||||
// The UI automatically re-renders with German strings
|
||||
```
|
||||
|
||||
## Using Localized Strings
|
||||
|
||||
### With LocalizedString View
|
||||
|
||||
`LocalizedString` is a `View` that displays a localized string:
|
||||
|
||||
```swift
|
||||
VStack {
|
||||
LocalizedString(.button(.save))
|
||||
LocalizedString(.error(.invalidInput))
|
||||
LocalizedString(.validation(.emailInvalid))
|
||||
}
|
||||
```
|
||||
|
||||
### With Text View
|
||||
|
||||
Use the `Text(localized:)` initializer for cases where you need a `Text` view directly:
|
||||
|
||||
```swift
|
||||
struct MyControl: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text(localized: .menu(.file))
|
||||
Text(localized: .placeholder(.search))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With String Keys
|
||||
|
||||
For dynamic key resolution or advanced use cases:
|
||||
|
||||
```swift
|
||||
let key = "button.ok"
|
||||
let localizedText = LocalizationService.shared.string(for: key)
|
||||
```
|
||||
|
||||
## Type-Safe Keys
|
||||
|
||||
All framework strings are available as type-safe `LocalizationKey` enums. This provides:
|
||||
- **Compile-time safety**: Typos are caught by the compiler
|
||||
- **IDE autocomplete**: Full support for all available keys
|
||||
- **Refactoring safety**: Safe renaming across the codebase
|
||||
|
||||
### Key Categories
|
||||
|
||||
#### Button Keys
|
||||
|
||||
```swift
|
||||
LocalizationKey.Button.ok
|
||||
LocalizationKey.Button.cancel
|
||||
LocalizationKey.Button.save
|
||||
LocalizationKey.Button.delete
|
||||
// ... and 17 more
|
||||
```
|
||||
|
||||
#### Label Keys
|
||||
|
||||
```swift
|
||||
LocalizationKey.Label.name
|
||||
LocalizationKey.Label.description
|
||||
LocalizationKey.Label.value
|
||||
LocalizationKey.Label.status
|
||||
// ... and 13 more
|
||||
```
|
||||
|
||||
#### Error Keys
|
||||
|
||||
```swift
|
||||
LocalizationKey.Error.invalidInput
|
||||
LocalizationKey.Error.notFound
|
||||
LocalizationKey.Error.accessDenied
|
||||
LocalizationKey.Error.timeout
|
||||
// ... and 7 more
|
||||
```
|
||||
|
||||
#### Placeholder Keys
|
||||
|
||||
```swift
|
||||
LocalizationKey.Placeholder.search
|
||||
LocalizationKey.Placeholder.enterText
|
||||
LocalizationKey.Placeholder.selectOption
|
||||
// ... and 3 more
|
||||
```
|
||||
|
||||
#### Menu Keys
|
||||
|
||||
```swift
|
||||
LocalizationKey.Menu.file
|
||||
LocalizationKey.Menu.edit
|
||||
LocalizationKey.Menu.view
|
||||
LocalizationKey.Menu.help
|
||||
// ... and 4 more
|
||||
```
|
||||
|
||||
#### Dialog Keys
|
||||
|
||||
```swift
|
||||
LocalizationKey.Dialog.confirm
|
||||
LocalizationKey.Dialog.deleteConfirmation
|
||||
LocalizationKey.Dialog.unsavedChanges
|
||||
// ... and 4 more
|
||||
```
|
||||
|
||||
#### Validation Keys
|
||||
|
||||
```swift
|
||||
LocalizationKey.Validation.emailInvalid
|
||||
LocalizationKey.Validation.passwordTooShort
|
||||
LocalizationKey.Validation.usernameTaken
|
||||
LocalizationKey.Validation.fieldRequired
|
||||
```
|
||||
|
||||
## Switching Languages
|
||||
|
||||
### Get Current Language
|
||||
|
||||
```swift
|
||||
let current = AppState.shared.currentLanguage
|
||||
print(current.displayName) // "Deutsch", "Français", etc.
|
||||
```
|
||||
|
||||
### Supported Languages
|
||||
|
||||
- `.english` - English
|
||||
- `.german` - Deutsch
|
||||
- `.french` - Français
|
||||
- `.italian` - Italiano
|
||||
- `.spanish` - Español
|
||||
|
||||
### Change Language at Runtime
|
||||
|
||||
```swift
|
||||
// Using AppState
|
||||
AppState.shared.setLanguage(.german)
|
||||
|
||||
// Or directly via LocalizationService
|
||||
LocalizationService.shared.setLanguage(.french)
|
||||
```
|
||||
|
||||
### Language Persistence
|
||||
|
||||
Language preferences are automatically saved to disk:
|
||||
- **macOS**: `~/Library/Application Support/tuikit/language`
|
||||
- **Linux**: `~/.config/tuikit/language`
|
||||
|
||||
The saved preference is restored when the app restarts.
|
||||
|
||||
## Adding New Keys
|
||||
|
||||
When you need to add new localized strings to the framework:
|
||||
|
||||
### 1. Add to LocalizationKey Enum
|
||||
|
||||
Edit `Sources/TUIkit/Localization/LocalizationKeys.swift`:
|
||||
|
||||
```swift
|
||||
public enum LocalizationKey {
|
||||
public enum Button: String {
|
||||
// ... existing cases
|
||||
case myNewButton = "button.my_new_button"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Add to All Translation Files
|
||||
|
||||
Add the same key to all 5 translation JSON files:
|
||||
|
||||
- `Sources/TUIkit/Localization/translations/en.json`
|
||||
- `Sources/TUIkit/Localization/translations/de.json`
|
||||
- `Sources/TUIkit/Localization/translations/fr.json`
|
||||
- `Sources/TUIkit/Localization/translations/it.json`
|
||||
- `Sources/TUIkit/Localization/translations/es.json`
|
||||
|
||||
**en.json**:
|
||||
```json
|
||||
{
|
||||
"button.my_new_button": "My New Button",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**de.json**:
|
||||
```json
|
||||
{
|
||||
"button.my_new_button": "Mein neuer Button",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
And similarly for French, Italian, and Spanish.
|
||||
|
||||
### 3. Update Tests (if needed)
|
||||
|
||||
If adding a new category, add corresponding test methods in:
|
||||
- `Tests/TUIkitTests/LocalizationServiceTests.swift`
|
||||
- `Tests/TUIkitTests/LocalizationKeyConsistencyTests.swift`
|
||||
|
||||
### 4. Run Consistency Tests
|
||||
|
||||
Verify that all keys in the enum exist in the translation files:
|
||||
|
||||
```bash
|
||||
swift test --filter LocalizationKeyConsistencyTests
|
||||
```
|
||||
|
||||
## Adding New Languages
|
||||
|
||||
To add support for a new language:
|
||||
|
||||
### 1. Add to Language Enum
|
||||
|
||||
Edit `Sources/TUIkit/Localization/LocalizationService.swift`:
|
||||
|
||||
```swift
|
||||
public enum Language: String, Codable {
|
||||
case english = "en"
|
||||
case german = "de"
|
||||
case french = "fr"
|
||||
case italian = "it"
|
||||
case spanish = "es"
|
||||
case portuguese = "pt" // New language
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .english: "English"
|
||||
case .german: "Deutsch"
|
||||
case .french: "Français"
|
||||
case .italian: "Italiano"
|
||||
case .spanish: "Español"
|
||||
case .portuguese: "Português" // Add display name
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create Translation File
|
||||
|
||||
Create a new JSON file with all keys translated:
|
||||
|
||||
`Sources/TUIkit/Localization/translations/pt.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"button.ok": "OK",
|
||||
"button.cancel": "Cancelar",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Make sure all keys from the other language files are included.
|
||||
|
||||
### 3. Update Package.swift
|
||||
|
||||
The translations are automatically discovered from the directory, no changes needed.
|
||||
|
||||
### 4. Test the New Language
|
||||
|
||||
```swift
|
||||
let service = LocalizationService()
|
||||
service.setLanguage(.portuguese)
|
||||
let ok = service.string(for: LocalizationKey.Button.ok)
|
||||
print(ok) // "OK"
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Direct Service Access
|
||||
|
||||
For advanced scenarios, access the localization service directly:
|
||||
|
||||
```swift
|
||||
let service = LocalizationService.shared
|
||||
|
||||
// Get current language
|
||||
let current = service.currentLanguage
|
||||
|
||||
// Get a string
|
||||
let text = service.string(for: "button.ok")
|
||||
|
||||
// Change language
|
||||
service.setLanguage(.german)
|
||||
```
|
||||
|
||||
### Using Raw String Keys
|
||||
|
||||
While not recommended (loses type safety), you can use raw string keys:
|
||||
|
||||
```swift
|
||||
let text = LocalizationService.shared.string(for: "button.ok")
|
||||
```
|
||||
|
||||
### Fallback Behavior
|
||||
|
||||
String resolution uses a fallback chain:
|
||||
1. **Current language**: Try to find the key in the active language
|
||||
2. **English**: If not found, fall back to English
|
||||
3. **Key itself**: If not found in any language, return the key as-is
|
||||
|
||||
This ensures the app always has something to display, even with incomplete translations.
|
||||
|
||||
### Environment Access
|
||||
|
||||
`LocalizationService` is available in the environment:
|
||||
|
||||
```swift
|
||||
struct MyView: View {
|
||||
@Environment(\.localizationService) var localization
|
||||
|
||||
var body: some View {
|
||||
Text(localization.string(for: "button.ok"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Views
|
||||
|
||||
### In Custom Components
|
||||
|
||||
```swift
|
||||
struct MyDialog: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
LocalizedString(.dialog(.confirm))
|
||||
HStack {
|
||||
Button("OK") { /* ... */ }
|
||||
.buttonLabel(LocalizedString(.button(.cancel)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### In Form Validation
|
||||
|
||||
```swift
|
||||
struct LoginForm: View {
|
||||
@State var email = ""
|
||||
@State var showError = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
TextField(LocalizationKey.Placeholder.enterName.rawValue, text: $email)
|
||||
|
||||
if showError {
|
||||
LocalizedString(.validation(.emailInvalid))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use type-safe keys**: Use `LocalizationKey` enums instead of raw strings
|
||||
2. **Group related strings**: Keep strings organized by category (Button, Label, Error, etc.)
|
||||
3. **Keep keys concise**: Use short, descriptive key names
|
||||
4. **Run consistency tests**: Always run `LocalizationKeyConsistencyTests` after adding keys
|
||||
5. **Test all languages**: Verify strings look correct in all supported languages
|
||||
6. **Document dynamic strings**: If using raw keys, document why they're necessary
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### String Not Appearing
|
||||
|
||||
- Check that the key exists in `LocalizationKey` enum
|
||||
- Verify the key is in all translation JSON files
|
||||
- Run consistency tests: `swift test --filter LocalizationKeyConsistencyTests`
|
||||
|
||||
### Wrong Language Showing
|
||||
|
||||
- Verify the language was set: `AppState.shared.currentLanguage`
|
||||
- Check that the language is supported (en, de, fr, it, es)
|
||||
- Ensure the translation file exists for that language
|
||||
|
||||
### Translation File Errors
|
||||
|
||||
If JSON syntax errors prevent loading:
|
||||
- Validate JSON: Use `jsonlint` or an online JSON validator
|
||||
- Check for missing commas or quotes
|
||||
- Ensure no trailing commas in objects/arrays
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **Localization Service API**: See `LocalizationService.swift`
|
||||
- **Localization Keys**: See `LocalizationKeys.swift`
|
||||
- **Translation Files**: `Sources/TUIkit/Localization/translations/`
|
||||
@@ -202,7 +202,7 @@ struct MyView: View {
|
||||
|
||||
**Supported languages**: English, Deutsch, Français, Italiano, Español
|
||||
|
||||
For complete documentation, see [Documentation/i18n-guide.md](Documentation/i18n-guide.md) and [Documentation/i18n-developer.md](Documentation/i18n-developer.md).
|
||||
For complete documentation, see [Localization Guide](https://github.com/phranck/TUIkit/blob/main/Sources/TUIkit/TUIkit.docc/Articles/Localization.md) in the DocC documentation.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -105,3 +105,4 @@ renderOnce {
|
||||
- Learn about the framework's <doc:Architecture>
|
||||
- Explore <doc:StateManagement> for reactive UIs
|
||||
- Customize your app's look with <doc:ThemingGuide>
|
||||
- Build multilingual apps with <doc:Localization>
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
# Localization
|
||||
|
||||
Build multilingual terminal applications with TUIkit's comprehensive internationalization system.
|
||||
|
||||
## Overview
|
||||
|
||||
TUIkit provides built-in support for 5 languages: English, German, French, Italian, and Spanish. All framework strings use a type-safe, dot-notation based localization system with persistent language preferences and automatic fallback chains.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Type-safe keys**: Compile-time verified `LocalizationKey` enums with IDE autocomplete
|
||||
- **5 languages built-in**: EN, DE, FR, IT, ES with complete translations
|
||||
- **Persistent storage**: Language preference automatically saved and restored (XDG-compatible paths)
|
||||
- **Fallback chain**: Current language → English → key itself for graceful degradation
|
||||
- **Thread-safe**: Safe language switching from any thread at runtime
|
||||
- **JSON-based**: Easy to extend with new strings and languages
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Display Localized Strings
|
||||
|
||||
Use `LocalizedString` to show localized text:
|
||||
|
||||
```swift
|
||||
import TUIkit
|
||||
|
||||
VStack {
|
||||
LocalizedString(.button(.ok))
|
||||
LocalizedString(.error(.notFound))
|
||||
LocalizedString(.dialog(.confirm))
|
||||
}
|
||||
```
|
||||
|
||||
Or use the `Text(localized:)` convenience initializer:
|
||||
|
||||
```swift
|
||||
Text(localized: .button(.save))
|
||||
```
|
||||
|
||||
### Switch Language at Runtime
|
||||
|
||||
```swift
|
||||
AppState.shared.setLanguage(.german)
|
||||
// UI automatically re-renders with German strings
|
||||
```
|
||||
|
||||
### Supported Languages
|
||||
|
||||
- `.english` - English
|
||||
- `.german` - Deutsch
|
||||
- `.french` - Français
|
||||
- `.italian` - Italiano
|
||||
- `.spanish` - Español
|
||||
|
||||
## Type-Safe Keys
|
||||
|
||||
All localized strings are available as `LocalizationKey` enums organized by category. This provides compile-time safety, IDE autocomplete, and refactoring support.
|
||||
|
||||
### Key Categories
|
||||
|
||||
#### Button Keys
|
||||
|
||||
21 button strings including: ok, cancel, yes, no, save, delete, close, apply, reset, submit, search, clear, add, remove, edit, done, next, previous, back, forward, refresh
|
||||
|
||||
```swift
|
||||
LocalizationKey.Button.ok
|
||||
LocalizationKey.Button.cancel
|
||||
LocalizationKey.Button.save
|
||||
```
|
||||
|
||||
#### Label Keys
|
||||
|
||||
17 label strings including: search, name, description, value, status, error, warning, info, loading, empty, none, page, item, items, total, from, to
|
||||
|
||||
```swift
|
||||
LocalizationKey.Label.name
|
||||
LocalizationKey.Label.description
|
||||
LocalizationKey.Label.status
|
||||
```
|
||||
|
||||
#### Error Keys
|
||||
|
||||
11 error strings including: invalidInput, requiredField, notFound, accessDenied, networkError, unknown, invalidFormat, operationFailed, timeout, fileNotFound, permissionDenied
|
||||
|
||||
```swift
|
||||
LocalizationKey.Error.invalidInput
|
||||
LocalizationKey.Error.notFound
|
||||
LocalizationKey.Error.timeout
|
||||
```
|
||||
|
||||
#### Placeholder Keys
|
||||
|
||||
6 placeholder strings: search, enterText, enterValue, selectOption, enterName, chooseFile
|
||||
|
||||
```swift
|
||||
LocalizationKey.Placeholder.search
|
||||
LocalizationKey.Placeholder.enterText
|
||||
LocalizationKey.Placeholder.selectOption
|
||||
```
|
||||
|
||||
#### Menu Keys
|
||||
|
||||
8 menu strings: file, edit, view, help, new, open, save, exit
|
||||
|
||||
```swift
|
||||
LocalizationKey.Menu.file
|
||||
LocalizationKey.Menu.edit
|
||||
LocalizationKey.Menu.help
|
||||
```
|
||||
|
||||
#### Dialog Keys
|
||||
|
||||
7 dialog strings: confirm, deleteConfirmation, unsavedChanges, overwriteConfirmation, exitConfirmation, success, error
|
||||
|
||||
```swift
|
||||
LocalizationKey.Dialog.confirm
|
||||
LocalizationKey.Dialog.deleteConfirmation
|
||||
LocalizationKey.Dialog.unsavedChanges
|
||||
```
|
||||
|
||||
#### Validation Keys
|
||||
|
||||
4 validation strings: emailInvalid, passwordTooShort, usernameTaken, fieldRequired
|
||||
|
||||
```swift
|
||||
LocalizationKey.Validation.emailInvalid
|
||||
LocalizationKey.Validation.passwordTooShort
|
||||
LocalizationKey.Validation.usernameTaken
|
||||
```
|
||||
|
||||
## Using Localized Strings
|
||||
|
||||
### LocalizedString View
|
||||
|
||||
Display a localized string as a View component:
|
||||
|
||||
```swift
|
||||
struct MyView: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
LocalizedString(.button(.save))
|
||||
LocalizedString(.error(.invalidInput))
|
||||
LocalizedString(.validation(.emailInvalid))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Text View with Localization
|
||||
|
||||
Use the `Text(localized:)` initializer:
|
||||
|
||||
```swift
|
||||
struct MyControl: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text(localized: .menu(.file))
|
||||
Text(localized: .placeholder(.search))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Direct Service Access
|
||||
|
||||
For advanced use cases, access the service directly:
|
||||
|
||||
```swift
|
||||
let service = LocalizationService.shared
|
||||
let text = service.string(for: .button(.ok))
|
||||
```
|
||||
|
||||
## Language Switching
|
||||
|
||||
### Get Current Language
|
||||
|
||||
```swift
|
||||
let current = AppState.shared.currentLanguage
|
||||
print(current.displayName) // "English", "Deutsch", etc.
|
||||
```
|
||||
|
||||
### Change Language
|
||||
|
||||
```swift
|
||||
// Via AppState
|
||||
AppState.shared.setLanguage(.german)
|
||||
|
||||
// Or directly via service
|
||||
LocalizationService.shared.setLanguage(.french)
|
||||
```
|
||||
|
||||
### Language Persistence
|
||||
|
||||
Language preferences are automatically saved to:
|
||||
- **macOS**: `~/Library/Application Support/tuikit/language`
|
||||
- **Linux**: `~/.config/tuikit/language`
|
||||
|
||||
The saved preference is restored when the app restarts.
|
||||
|
||||
### Fallback Behavior
|
||||
|
||||
String resolution uses a fallback chain:
|
||||
|
||||
1. Try to find the key in the active language
|
||||
2. If not found, fall back to English
|
||||
3. If still not found, return the key itself as-is
|
||||
|
||||
This ensures the UI always has something to display, even with incomplete translations.
|
||||
|
||||
## Environment Access
|
||||
|
||||
`LocalizationService` is available in the view environment:
|
||||
|
||||
```swift
|
||||
struct MyView: View {
|
||||
@Environment(\.localizationService) var localization
|
||||
|
||||
var body: some View {
|
||||
Text(localization.string(for: .button(.ok)))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Adding New Keys
|
||||
|
||||
To add new localized strings to the framework:
|
||||
|
||||
### 1. Update LocalizationKey Enum
|
||||
|
||||
Edit `Sources/TUIkit/Localization/LocalizationKeys.swift`:
|
||||
|
||||
```swift
|
||||
public enum LocalizationKey {
|
||||
public enum Button: String {
|
||||
// ... existing cases
|
||||
case myNewButton = "button.my_new_button"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then add convenient extensions:
|
||||
|
||||
```swift
|
||||
extension LocalizedString {
|
||||
public init(_ key: LocalizationKey.Button) {
|
||||
self.init(key.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
extension Text {
|
||||
public init(localized key: LocalizationKey.Button) {
|
||||
self.init(localized: key.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalizationService {
|
||||
public func string(for key: LocalizationKey.Button) -> String {
|
||||
string(for: key.rawValue)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Add to All Translation Files
|
||||
|
||||
Add the same key to all 5 translation JSON files:
|
||||
|
||||
**en.json**:
|
||||
```json
|
||||
{
|
||||
"button.my_new_button": "My New Button",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**de.json**:
|
||||
```json
|
||||
{
|
||||
"button.my_new_button": "Mein neuer Button",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Similar for `fr.json`, `it.json`, `es.json`.
|
||||
|
||||
### 3. Update Tests
|
||||
|
||||
Add tests in `Tests/TUIkitTests/LocalizationKeyConsistencyTests.swift`:
|
||||
|
||||
```swift
|
||||
@Test("All button keys exist in translations")
|
||||
func allButtonKeysExist() {
|
||||
let keys = [
|
||||
LocalizationKey.Button.ok,
|
||||
LocalizationKey.Button.myNewButton,
|
||||
// ... all other button keys
|
||||
]
|
||||
|
||||
for key in keys {
|
||||
#expect(englishTranslations[key.rawValue] != nil)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Run Consistency Tests
|
||||
|
||||
Verify all keys exist in translation files:
|
||||
|
||||
```bash
|
||||
swift test --filter LocalizationKeyConsistencyTests
|
||||
```
|
||||
|
||||
## Adding New Languages
|
||||
|
||||
To add support for a new language:
|
||||
|
||||
### 1. Update Language Enum
|
||||
|
||||
Edit `Sources/TUIkit/Localization/LocalizationService.swift`:
|
||||
|
||||
```swift
|
||||
public enum Language: String, Codable {
|
||||
case english = "en"
|
||||
case german = "de"
|
||||
case french = "fr"
|
||||
case italian = "it"
|
||||
case spanish = "es"
|
||||
case portuguese = "pt" // NEW
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .english: "English"
|
||||
case .german: "Deutsch"
|
||||
case .french: "Français"
|
||||
case .italian: "Italiano"
|
||||
case .spanish: "Español"
|
||||
case .portuguese: "Português" // NEW
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create Translation File
|
||||
|
||||
Create `Sources/TUIkit/Localization/translations/pt.json` with **all** keys from other language files:
|
||||
|
||||
```json
|
||||
{
|
||||
"button.ok": "OK",
|
||||
"button.cancel": "Cancelar",
|
||||
...
|
||||
// Must include ALL keys (use en.json as reference)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Test the New Language
|
||||
|
||||
```swift
|
||||
let service = LocalizationService()
|
||||
service.setLanguage(.portuguese)
|
||||
let ok = service.string(for: .button(.ok))
|
||||
```
|
||||
|
||||
### 4. Run Consistency Tests
|
||||
|
||||
```bash
|
||||
swift test --filter LocalizationKeyConsistencyTests
|
||||
```
|
||||
|
||||
## Design Principles
|
||||
|
||||
### Dot-Notation Keys
|
||||
|
||||
Keys use `category.key_name` format:
|
||||
|
||||
```
|
||||
button.ok
|
||||
error.not_found
|
||||
validation.email_invalid
|
||||
```
|
||||
|
||||
This organizes related strings, makes them easy to find, and matches JSON structure.
|
||||
|
||||
### Enum Categories
|
||||
|
||||
Each JSON prefix becomes an enum category:
|
||||
- All `button.*` keys → `LocalizationKey.Button`
|
||||
- All `error.*` keys → `LocalizationKey.Error`
|
||||
- All `validation.*` keys → `LocalizationKey.Validation`
|
||||
|
||||
This provides type safety, IDE autocomplete, and compile-time verification.
|
||||
|
||||
### Thread Safety
|
||||
|
||||
``LocalizationService`` uses `NSLock` for thread-safe access:
|
||||
|
||||
```swift
|
||||
// Safe to call from any thread
|
||||
DispatchQueue.global().async {
|
||||
AppState.shared.setLanguage(.german)
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Caching
|
||||
|
||||
Translations are cached per language after first load:
|
||||
|
||||
```swift
|
||||
let text1 = service.string(for: .button(.ok)) // Loads and caches
|
||||
let text2 = service.string(for: .button(.ok)) // Uses cache
|
||||
```
|
||||
|
||||
## JSON File Format
|
||||
|
||||
### Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"category.key": "English text",
|
||||
"category.another_key": "More text",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Flat structure**: No nested objects (all keys are top-level)
|
||||
2. **Dot-separated keys**: `"button.ok"`, not nested objects
|
||||
3. **UTF-8 encoding**: Required for non-Latin characters
|
||||
4. **Valid JSON**: No trailing commas, proper escaping
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"button.ok": "OK",
|
||||
"button.cancel": "Cancel",
|
||||
"error.not_found": "Not found",
|
||||
"error.unicode_test": "Unicode: café, Ñoño, 日本語",
|
||||
"placeholder.enter_name": "Enter name..."
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use type-safe keys**: Use `LocalizationKey` enums instead of raw strings
|
||||
2. **Keep keys organized**: Group related strings by category
|
||||
3. **Use descriptive names**: Short, clear key names
|
||||
4. **Run consistency tests**: Always verify after adding keys
|
||||
5. **Test all languages**: Check translations look correct in each language
|
||||
6. **Handle missing strings**: Rely on the fallback chain for incomplete translations
|
||||
|
||||
## Testing
|
||||
|
||||
TUIkit includes comprehensive localization tests:
|
||||
|
||||
### LocalizationServiceTests
|
||||
Core functionality tests covering:
|
||||
- Bundle loading for all languages
|
||||
- String resolution with dot-notation
|
||||
- Fallback behavior
|
||||
- Language switching
|
||||
- Persistence to disk
|
||||
- Thread safety
|
||||
|
||||
### LocalizationKeyConsistencyTests
|
||||
Validation tests ensuring:
|
||||
- Every enum key exists in all translation files
|
||||
- No extraneous keys in translation files
|
||||
- Expected total key count
|
||||
|
||||
Run after any localization changes:
|
||||
|
||||
```bash
|
||||
swift test --filter LocalizationKeyConsistencyTests
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### String Not Appearing
|
||||
|
||||
- Verify the key exists in `LocalizationKey` enum
|
||||
- Check the key is in all translation JSON files
|
||||
- Run consistency tests to verify
|
||||
|
||||
### Wrong Language Showing
|
||||
|
||||
- Verify language was set: `AppState.shared.currentLanguage`
|
||||
- Confirm language is supported (en, de, fr, it, es)
|
||||
- Check translation file exists for that language
|
||||
|
||||
### JSON Syntax Errors
|
||||
|
||||
If translation files won't load:
|
||||
- Validate JSON syntax
|
||||
- Check for missing commas or quotes
|
||||
- Ensure UTF-8 encoding
|
||||
- No trailing commas in objects
|
||||
|
||||
## See Also
|
||||
|
||||
- <doc:GettingStarted> — Getting started with TUIkit
|
||||
- <doc:AppLifecycle> — Application lifecycle and setup
|
||||
- <doc:Architecture> — Framework architecture
|
||||
Reference in New Issue
Block a user