Feat: Phase 1 i18n Infrastructure - LocalizationService and translation files

- Add LocalizationService singleton for managing language selection
- Implement XDG-compatible persistent language storage (macOS/Linux)
- Create translation JSON files for 5 languages (EN, DE, FR, IT, ES)
- Add LocalizationService to EnvironmentValues
- Support dot-notation keys for string resolution
- Implement fallback to English for missing keys
- Update Package.swift to include translation resources
- All 1069 tests pass
This commit is contained in:
phranck
2026-02-14 18:15:33 +01:00
parent cab942c1cc
commit 978c15993e
8 changed files with 561 additions and 1 deletions
+5 -1
View File
@@ -41,7 +41,11 @@ let package = Package(
.target(name: "TUIkitImage", dependencies: ["CSTBImage", "TUIkitStyling"]),
// High-level (aggregates all)
.target(name: "TUIkit", dependencies: ["TUIkitCore", "TUIkitStyling", "TUIkitImage", "TUIkitView"]),
.target(
name: "TUIkit",
dependencies: ["TUIkitCore", "TUIkitStyling", "TUIkitImage", "TUIkitView"],
resources: [.copy("Localization/translations")]
),
// App & Tests
.executableTarget(
@@ -4,6 +4,13 @@
// Created by LAYERED.work
// License: MIT
// MARK: - Localization Service
/// EnvironmentKey for the localization service.
private struct LocalizationServiceKey: EnvironmentKey {
static let defaultValue: LocalizationService = LocalizationService.shared
}
// MARK: - Lifecycle Manager
/// EnvironmentKey for view lifecycle tracking (appear/disappear/task).
@@ -57,6 +64,17 @@ private struct ActiveFocusSectionKey: EnvironmentKey {
extension EnvironmentValues {
/// The localization service for retrieving translated strings.
var localizationService: LocalizationService {
get { self[LocalizationServiceKey.self] }
set { self[LocalizationServiceKey.self] = newValue }
}
/// The currently active language.
var currentLanguage: LocalizationService.Language {
localizationService.currentLanguage
}
/// View lifecycle tracking (appear, disappear, task management).
var lifecycle: LifecycleManager? {
get { self[LifecycleKey.self] }
@@ -0,0 +1,238 @@
// 🖥 TUIKit Terminal UI Kit for Swift
// LocalizationService.swift
//
// Created by LAYERED.work
// License: MIT
import Foundation
// MARK: - Localization Service
/// Manages language selection and string localization.
///
/// Loads translations from JSON files, resolves localized strings using
/// dot-notation keys, and persists language preference to disk.
public final class LocalizationService: @unchecked Sendable {
/// Supported languages
public enum Language: String, Codable {
case english = "en"
case german = "de"
case french = "fr"
case italian = "it"
case spanish = "es"
/// Human-readable name
public var displayName: String {
switch self {
case .english: "English"
case .german: "Deutsch"
case .french: "Français"
case .italian: "Italiano"
case .spanish: "Español"
}
}
}
/// The shared localization service instance.
public static let shared = LocalizationService()
/// Currently active language
private(set) public var currentLanguage: Language {
didSet {
saveLanguagePreference(currentLanguage)
AppState.shared.setNeedsRender()
}
}
/// Cached translations: [languageCode: [dotPath: localizedString]]
private var translationCache: [String: [String: String]] = [:]
/// Lock for thread-safe access
private let lock = NSLock()
/// Creates and initializes the localization service.
///
/// Loads the stored language preference, falling back to system locale
/// or English if unavailable.
public init() {
self.currentLanguage = .english
// Try to load stored preference
if let stored = Self.loadLanguagePreference() {
self.currentLanguage = stored
} else if let systemLocale = Self.systemPreferredLanguage() {
self.currentLanguage = systemLocale
}
// Preload current language translations
_ = translations(for: currentLanguage)
}
/// Changes the active language and persists the preference.
///
/// - Parameter language: The new language to activate.
public func setLanguage(_ language: Language) {
lock.lock()
defer { lock.unlock() }
currentLanguage = language
}
/// Resolves a localized string using a dot-notation key.
///
/// Falls back to English if the key is missing in the current language,
/// then to the key itself if not found in English.
///
/// - Parameter key: Dot-notation path (e.g., "button.ok", "error.invalid_input")
/// - Returns: The localized string, or the key if not found.
public func string(for key: String) -> String {
lock.lock()
defer { lock.unlock() }
// Try current language
if let value = translationValue(key, in: currentLanguage) {
return value
}
// Fall back to English
if currentLanguage != .english,
let value = translationValue(key, in: .english) {
return value
}
// Return key as last resort
return key
}
// MARK: - Private Helpers
/// Gets translations dictionary for a language, loading from JSON if needed.
private func translations(for language: Language) -> [String: String] {
lock.lock()
defer { lock.unlock() }
let code = language.rawValue
// Return cached if available
if let cached = translationCache[code] {
return cached
}
// Load from bundled JSON
if let loaded = loadTranslationsFromBundle(language: code) {
translationCache[code] = loaded
return loaded
}
// No translations available, return empty
return [:]
}
/// Retrieves a value from translations using dot-notation path.
private func translationValue(_ key: String, in language: Language) -> String? {
let trans = translations(for: language)
return trans[key]
}
/// Loads translations from bundled JSON file.
private func loadTranslationsFromBundle(language: String) -> [String: String]? {
guard let url = Bundle.module.url(
forResource: language,
withExtension: "json",
subdirectory: "Localization/translations"
) else {
return nil
}
do {
let data = try Data(contentsOf: url)
let dict = try JSONSerialization.jsonObject(
with: data,
options: .fragmentsAllowed
) as? [String: String]
return dict
} catch {
return nil
}
}
/// Returns the system-preferred language if supported.
private static func systemPreferredLanguage() -> Language? {
let preferredLanguages = NSLocale.preferredLanguages
for langCode in preferredLanguages {
let base = langCode.prefix(2).lowercased()
if let language = Language(rawValue: base) {
return language
}
}
return nil
}
/// Loads stored language preference from config file.
private static func loadLanguagePreference() -> Language? {
let path = configFilePath()
guard FileManager.default.fileExists(atPath: path) else {
return nil
}
do {
let content = try String(contentsOfFile: path, encoding: .utf8).trimmingCharacters(
in: .whitespacesAndNewlines
)
return Language(rawValue: content)
} catch {
return nil
}
}
/// Saves language preference to config file.
private func saveLanguagePreference(_ language: Language) {
let path = Self.configFilePath()
let dirPath = (path as NSString).deletingLastPathComponent
// Create directory if needed
do {
try FileManager.default.createDirectory(
atPath: dirPath,
withIntermediateDirectories: true,
attributes: nil
)
} catch {
// Ignore creation errors, will fail on write anyway
}
// Write language code
do {
try language.rawValue.write(
toFile: path,
atomically: true,
encoding: .utf8
)
} catch {
// Silently fail if write unsuccessful
}
}
/// Returns the XDG-compatible config file path for language preference.
private static func configFilePath() -> String {
#if os(macOS)
let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first?.path ?? NSHomeDirectory()
return (appSupport as NSString).appendingPathComponent("tuikit/language")
#else
let configHome = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"]
?? ((NSHomeDirectory() as NSString).appendingPathComponent(".config"))
return (configHome as NSString).appendingPathComponent("tuikit/language")
#endif
}
}
// MARK: - Convenience
/// A localized string retrieved using a dot-notation key.
///
/// Example: `LocalizedString("button.ok")`
public func LocalizedString(_ key: String) -> String {
LocalizationService.shared.string(for: key)
}
@@ -0,0 +1,60 @@
{
"button.ok": "OK",
"button.cancel": "Abbrechen",
"button.yes": "Ja",
"button.no": "Nein",
"button.save": "Speichern",
"button.delete": "Löschen",
"button.close": "Schließen",
"button.apply": "Anwenden",
"button.reset": "Zurücksetzen",
"button.submit": "Absenden",
"button.search": "Suchen",
"button.clear": "Löschen",
"button.add": "Hinzufügen",
"button.remove": "Entfernen",
"button.edit": "Bearbeiten",
"button.done": "Fertig",
"button.next": "Weiter",
"button.previous": "Zurück",
"button.back": "Zurück",
"button.forward": "Vorwärts",
"button.refresh": "Aktualisieren",
"label.search": "Suche",
"label.name": "Name",
"label.description": "Beschreibung",
"label.value": "Wert",
"label.status": "Status",
"label.error": "Fehler",
"label.warning": "Warnung",
"label.info": "Information",
"label.loading": "Lädt",
"label.empty": "Leer",
"label.none": "Keine",
"error.invalid_input": "Ungültige Eingabe",
"error.required_field": "Dieses Feld ist erforderlich",
"error.not_found": "Nicht gefunden",
"error.access_denied": "Zugriff verweigert",
"error.network_error": "Netzwerkfehler",
"error.unknown": "Unbekannter Fehler",
"error.invalid_format": "Ungültiges Format",
"error.operation_failed": "Operation fehlgeschlagen",
"placeholder.search": "Suchen...",
"placeholder.enter_text": "Text eingeben...",
"placeholder.enter_value": "Wert eingeben...",
"placeholder.select_option": "Option auswählen...",
"placeholder.enter_name": "Name eingeben...",
"menu.file": "Datei",
"menu.edit": "Bearbeiten",
"menu.view": "Ansicht",
"menu.help": "Hilfe",
"dialog.confirm": "Bestätigung",
"dialog.delete_confirmation": "Sind Sie sicher, dass Sie das löschen möchten?",
"dialog.unsaved_changes": "Sie haben ungespeicherte Änderungen. Möchten Sie diese speichern?",
"dialog.overwrite_confirmation": "Diese Datei existiert bereits. Möchten Sie sie überschreiben?"
}
@@ -0,0 +1,60 @@
{
"button.ok": "OK",
"button.cancel": "Cancel",
"button.yes": "Yes",
"button.no": "No",
"button.save": "Save",
"button.delete": "Delete",
"button.close": "Close",
"button.apply": "Apply",
"button.reset": "Reset",
"button.submit": "Submit",
"button.search": "Search",
"button.clear": "Clear",
"button.add": "Add",
"button.remove": "Remove",
"button.edit": "Edit",
"button.done": "Done",
"button.next": "Next",
"button.previous": "Previous",
"button.back": "Back",
"button.forward": "Forward",
"button.refresh": "Refresh",
"label.search": "Search",
"label.name": "Name",
"label.description": "Description",
"label.value": "Value",
"label.status": "Status",
"label.error": "Error",
"label.warning": "Warning",
"label.info": "Info",
"label.loading": "Loading",
"label.empty": "Empty",
"label.none": "None",
"error.invalid_input": "Invalid input",
"error.required_field": "This field is required",
"error.not_found": "Not found",
"error.access_denied": "Access denied",
"error.network_error": "Network error",
"error.unknown": "Unknown error",
"error.invalid_format": "Invalid format",
"error.operation_failed": "Operation failed",
"placeholder.search": "Search...",
"placeholder.enter_text": "Enter text...",
"placeholder.enter_value": "Enter value...",
"placeholder.select_option": "Select an option...",
"placeholder.enter_name": "Enter name...",
"menu.file": "File",
"menu.edit": "Edit",
"menu.view": "View",
"menu.help": "Help",
"dialog.confirm": "Confirm",
"dialog.delete_confirmation": "Are you sure you want to delete this?",
"dialog.unsaved_changes": "You have unsaved changes. Do you want to save them?",
"dialog.overwrite_confirmation": "This file already exists. Do you want to overwrite it?"
}
@@ -0,0 +1,60 @@
{
"button.ok": "OK",
"button.cancel": "Cancelar",
"button.yes": "Sí",
"button.no": "No",
"button.save": "Guardar",
"button.delete": "Eliminar",
"button.close": "Cerrar",
"button.apply": "Aplicar",
"button.reset": "Restablecer",
"button.submit": "Enviar",
"button.search": "Buscar",
"button.clear": "Limpiar",
"button.add": "Añadir",
"button.remove": "Eliminar",
"button.edit": "Editar",
"button.done": "Hecho",
"button.next": "Siguiente",
"button.previous": "Anterior",
"button.back": "Atrás",
"button.forward": "Adelante",
"button.refresh": "Actualizar",
"label.search": "Buscar",
"label.name": "Nombre",
"label.description": "Descripción",
"label.value": "Valor",
"label.status": "Estado",
"label.error": "Error",
"label.warning": "Advertencia",
"label.info": "Información",
"label.loading": "Cargando",
"label.empty": "Vacío",
"label.none": "Ninguno",
"error.invalid_input": "Entrada no válida",
"error.required_field": "Este campo es obligatorio",
"error.not_found": "No encontrado",
"error.access_denied": "Acceso denegado",
"error.network_error": "Error de red",
"error.unknown": "Error desconocido",
"error.invalid_format": "Formato no válido",
"error.operation_failed": "Operación fallida",
"placeholder.search": "Buscar...",
"placeholder.enter_text": "Ingrese texto...",
"placeholder.enter_value": "Ingrese valor...",
"placeholder.select_option": "Seleccione una opción...",
"placeholder.enter_name": "Ingrese nombre...",
"menu.file": "Archivo",
"menu.edit": "Editar",
"menu.view": "Ver",
"menu.help": "Ayuda",
"dialog.confirm": "Confirmar",
"dialog.delete_confirmation": "¿Está seguro de que desea eliminar esto?",
"dialog.unsaved_changes": "Tiene cambios sin guardar. ¿Desea guardarlos?",
"dialog.overwrite_confirmation": "Este archivo ya existe. ¿Desea sobrescribirlo?"
}
@@ -0,0 +1,60 @@
{
"button.ok": "OK",
"button.cancel": "Annuler",
"button.yes": "Oui",
"button.no": "Non",
"button.save": "Enregistrer",
"button.delete": "Supprimer",
"button.close": "Fermer",
"button.apply": "Appliquer",
"button.reset": "Réinitialiser",
"button.submit": "Soumettre",
"button.search": "Rechercher",
"button.clear": "Effacer",
"button.add": "Ajouter",
"button.remove": "Supprimer",
"button.edit": "Modifier",
"button.done": "Fait",
"button.next": "Suivant",
"button.previous": "Précédent",
"button.back": "Retour",
"button.forward": "Avancer",
"button.refresh": "Actualiser",
"label.search": "Recherche",
"label.name": "Nom",
"label.description": "Description",
"label.value": "Valeur",
"label.status": "Statut",
"label.error": "Erreur",
"label.warning": "Avertissement",
"label.info": "Information",
"label.loading": "Chargement",
"label.empty": "Vide",
"label.none": "Aucun",
"error.invalid_input": "Entrée invalide",
"error.required_field": "Ce champ est obligatoire",
"error.not_found": "Non trouvé",
"error.access_denied": "Accès refusé",
"error.network_error": "Erreur réseau",
"error.unknown": "Erreur inconnue",
"error.invalid_format": "Format invalide",
"error.operation_failed": "Opération échouée",
"placeholder.search": "Rechercher...",
"placeholder.enter_text": "Entrez le texte...",
"placeholder.enter_value": "Entrez la valeur...",
"placeholder.select_option": "Sélectionnez une option...",
"placeholder.enter_name": "Entrez le nom...",
"menu.file": "Fichier",
"menu.edit": "Modifier",
"menu.view": "Affichage",
"menu.help": "Aide",
"dialog.confirm": "Confirmer",
"dialog.delete_confirmation": "Êtes-vous sûr de vouloir supprimer ceci?",
"dialog.unsaved_changes": "Vous avez des modifications non enregistrées. Voulez-vous les enregistrer?",
"dialog.overwrite_confirmation": "Ce fichier existe déjà. Voulez-vous le remplacer?"
}
@@ -0,0 +1,60 @@
{
"button.ok": "OK",
"button.cancel": "Annulla",
"button.yes": "Sì",
"button.no": "No",
"button.save": "Salva",
"button.delete": "Elimina",
"button.close": "Chiudi",
"button.apply": "Applica",
"button.reset": "Ripristina",
"button.submit": "Invia",
"button.search": "Cerca",
"button.clear": "Cancella",
"button.add": "Aggiungi",
"button.remove": "Rimuovi",
"button.edit": "Modifica",
"button.done": "Fatto",
"button.next": "Avanti",
"button.previous": "Indietro",
"button.back": "Indietro",
"button.forward": "Avanti",
"button.refresh": "Aggiorna",
"label.search": "Ricerca",
"label.name": "Nome",
"label.description": "Descrizione",
"label.value": "Valore",
"label.status": "Stato",
"label.error": "Errore",
"label.warning": "Avvertenza",
"label.info": "Informazione",
"label.loading": "Caricamento",
"label.empty": "Vuoto",
"label.none": "Nessuno",
"error.invalid_input": "Input non valido",
"error.required_field": "Questo campo è obbligatorio",
"error.not_found": "Non trovato",
"error.access_denied": "Accesso negato",
"error.network_error": "Errore di rete",
"error.unknown": "Errore sconosciuto",
"error.invalid_format": "Formato non valido",
"error.operation_failed": "Operazione non riuscita",
"placeholder.search": "Cerca...",
"placeholder.enter_text": "Inserisci testo...",
"placeholder.enter_value": "Inserisci valore...",
"placeholder.select_option": "Seleziona un'opzione...",
"placeholder.enter_name": "Inserisci nome...",
"menu.file": "File",
"menu.edit": "Modifica",
"menu.view": "Visualizza",
"menu.help": "Aiuto",
"dialog.confirm": "Conferma",
"dialog.delete_confirmation": "Sei sicuro di voler eliminare questo?",
"dialog.unsaved_changes": "Hai modifiche non salvate. Vuoi salvarle?",
"dialog.overwrite_confirmation": "Questo file esiste già. Vuoi sovrascriverlo?"
}