using System.Text.Json; using System.Text.RegularExpressions; namespace Agent.Core.Localization; public sealed partial class LocaleCatalog { private readonly IReadOnlyDictionary> _locales; private readonly IReadOnlyDictionary _fallback; private LocaleCatalog(string selectedLocale, IReadOnlyDictionary> locales, IReadOnlyDictionary fallback) { SelectedLocale = selectedLocale; _locales = locales; _fallback = fallback; } public string SelectedLocale { get; } public IReadOnlyCollection Keys => _fallback.Keys.ToArray(); public static LocaleCatalog Load(string directory, string selectedLocale, string fallbackLocale = "en") { if (!Directory.Exists(directory)) throw new LocaleException($"Locale directory does not exist: {directory}"); var locales = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (string file in Directory.EnumerateFiles(directory, "*.json", SearchOption.TopDirectoryOnly)) { string locale = Path.GetFileNameWithoutExtension(file); locales.Add(locale, ReadFlatObject(file)); } if (!locales.TryGetValue(fallbackLocale, out IReadOnlyDictionary? fallback)) throw new LocaleException($"Fallback locale '{fallbackLocale}' is missing."); HashSet baselineKeys = fallback.Keys.ToHashSet(StringComparer.Ordinal); Dictionary> baselinePlaceholders = fallback.ToDictionary(static p => p.Key, static p => Placeholders(p.Value), StringComparer.Ordinal); foreach ((string locale, IReadOnlyDictionary values) in locales) { string? unknown = values.Keys.FirstOrDefault(key => !baselineKeys.Contains(key)); if (unknown is not null) throw new LocaleException($"Locale '{locale}' contains unknown key '{unknown}'."); foreach ((string key, string message) in values) { if (!baselinePlaceholders[key].SetEquals(Placeholders(message))) throw new LocaleException($"Locale '{locale}' has mismatched placeholders for key '{key}'."); } } if (!locales.ContainsKey(selectedLocale)) throw new LocaleException($"Selected locale '{selectedLocale}' is missing."); return new LocaleCatalog(selectedLocale, locales, fallback); } public string Get(string key) { if (_locales[SelectedLocale].TryGetValue(key, out string? selected)) return selected; return _fallback.TryGetValue(key, out string? fallback) ? fallback : key; } public string Format(string key, IReadOnlyDictionary values) { string template = Get(key); return PlaceholderRegex().Replace(template, match => { string name = match.Groups[1].Value; if (!values.TryGetValue(name, out object? value)) throw new LocaleException($"Missing placeholder value '{name}' for key '{key}'."); return Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; }); } private static Dictionary ReadFlatObject(string file) { try { using JsonDocument document = JsonDocument.Parse(File.ReadAllText(file)); if (document.RootElement.ValueKind != JsonValueKind.Object) throw new LocaleException($"Locale file must contain a JSON object: {file}"); var result = new Dictionary(StringComparer.Ordinal); foreach (JsonProperty property in document.RootElement.EnumerateObject()) { if (property.Value.ValueKind != JsonValueKind.String) throw new LocaleException($"Locale value '{property.Name}' must be a string."); if (!result.TryAdd(property.Name, property.Value.GetString()!)) throw new LocaleException($"Duplicate locale key '{property.Name}'."); } return result; } catch (LocaleException) { throw; } catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) { throw new LocaleException($"Could not load locale file '{file}': {ex.Message}", ex); } } private static HashSet Placeholders(string text) => PlaceholderRegex().Matches(text).Select(static m => m.Groups[1].Value).ToHashSet(StringComparer.Ordinal); [GeneratedRegex("\\{([A-Za-z][A-Za-z0-9_]*)\\}", RegexOptions.CultureInvariant)] private static partial Regex PlaceholderRegex(); }