| | | 1 | | using System.Collections.Concurrent; |
| | | 2 | | using System.Globalization; |
| | | 3 | | |
| | | 4 | | namespace Kestrun.Localization; |
| | | 5 | | |
| | | 6 | | /// <summary> |
| | | 7 | | /// Provides cached access to localized string tables with culture fallback support. |
| | | 8 | | /// </summary> |
| | | 9 | | public sealed class KestrunLocalizationStore |
| | | 10 | | { |
| | 1 | 11 | | private static readonly IReadOnlyDictionary<string, string> EmptyStrings = |
| | 1 | 12 | | new Dictionary<string, string>(StringComparer.Ordinal); |
| | | 13 | | |
| | | 14 | | private readonly KestrunLocalizationOptions options; |
| | | 15 | | /// <summary> |
| | | 16 | | /// Gets the logger instance. |
| | | 17 | | /// </summary> |
| | 36 | 18 | | public Serilog.ILogger Logger { get; } |
| | | 19 | | private readonly string resourcesRoot; |
| | | 20 | | private readonly ConcurrentDictionary<string, IReadOnlyDictionary<string, string>> cache; |
| | | 21 | | private readonly HashSet<string> availableCultures; |
| | | 22 | | private readonly string defaultCulture; |
| | | 23 | | |
| | | 24 | | /// <summary> |
| | | 25 | | /// Initializes a new instance of the <see cref="KestrunLocalizationStore"/> class. |
| | | 26 | | /// </summary> |
| | | 27 | | /// <param name="options">The localization options.</param> |
| | | 28 | | /// <param name="contentRootPath">The content root path used to resolve resources.</param> |
| | | 29 | | /// <param name="logger">The logger instance.</param> |
| | 16 | 30 | | public KestrunLocalizationStore( |
| | 16 | 31 | | KestrunLocalizationOptions options, |
| | 16 | 32 | | string contentRootPath, |
| | 16 | 33 | | Serilog.ILogger logger) |
| | | 34 | | { |
| | 16 | 35 | | ArgumentNullException.ThrowIfNull(options); |
| | 16 | 36 | | ArgumentNullException.ThrowIfNull(contentRootPath); |
| | 16 | 37 | | ArgumentNullException.ThrowIfNull(logger); |
| | | 38 | | |
| | 16 | 39 | | this.options = options; |
| | 16 | 40 | | Logger = logger; |
| | 16 | 41 | | cache = new ConcurrentDictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.OrdinalIgnoreCase); |
| | 16 | 42 | | availableCultures = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | 16 | 43 | | defaultCulture = NormalizeCulture(options.DefaultCulture) ?? options.DefaultCulture; |
| | | 44 | | |
| | 16 | 45 | | resourcesRoot = Path.IsPathRooted(this.options.ResourcesBasePath) |
| | 16 | 46 | | ? this.options.ResourcesBasePath |
| | 16 | 47 | | : Path.Combine(contentRootPath, this.options.ResourcesBasePath); |
| | | 48 | | |
| | 16 | 49 | | DiscoverAvailableCultures(); |
| | 16 | 50 | | } |
| | | 51 | | |
| | | 52 | | /// <summary> |
| | | 53 | | /// Gets the set of available cultures discovered at startup (read-only). |
| | | 54 | | /// </summary> |
| | 1 | 55 | | public IReadOnlyCollection<string> AvailableCultures => [.. availableCultures]; |
| | | 56 | | |
| | | 57 | | /// <summary> |
| | | 58 | | /// Resolves the requested culture to the closest available culture or the default culture. |
| | | 59 | | /// </summary> |
| | | 60 | | /// <param name="requestedCulture">The requested culture name.</param> |
| | | 61 | | /// <returns>The resolved culture name.</returns> |
| | | 62 | | public string ResolveCulture(string? requestedCulture) => |
| | 11 | 63 | | ResolveCulture(requestedCulture, allowDefaultFallback: true) ?? defaultCulture; |
| | | 64 | | |
| | | 65 | | /// <summary> |
| | | 66 | | /// Gets the localized strings for the requested culture with fallback resolution. |
| | | 67 | | /// </summary> |
| | | 68 | | /// <param name="requestedCulture">The requested culture name.</param> |
| | | 69 | | /// <returns>A dictionary of localized strings.</returns> |
| | 13 | 70 | | public IReadOnlyDictionary<string, string> GetStringsForCulture(string? requestedCulture) => new CompositeStringTabl |
| | | 71 | | |
| | | 72 | | /// <summary> |
| | | 73 | | /// Gets the localized strings for the already-resolved culture. |
| | | 74 | | /// </summary> |
| | | 75 | | /// <param name="resolvedCulture">The resolved culture name.</param> |
| | | 76 | | /// <returns>A dictionary of localized strings.</returns> |
| | | 77 | | public IReadOnlyDictionary<string, string> GetStringsForResolvedCulture(string resolvedCulture) |
| | | 78 | | { |
| | 1 | 79 | | return string.IsNullOrWhiteSpace(resolvedCulture) |
| | 1 | 80 | | ? EmptyStrings |
| | 1 | 81 | | : (IsCultureAvailable(resolvedCulture) || |
| | 1 | 82 | | string.Equals(resolvedCulture, defaultCulture, StringComparison.OrdinalIgnoreCase) |
| | 1 | 83 | | ? cache.GetOrAdd(resolvedCulture, LoadStringsForCulture) |
| | 1 | 84 | | : EmptyStrings); |
| | | 85 | | } |
| | | 86 | | |
| | | 87 | | private sealed class CompositeStringTable : IReadOnlyDictionary<string, string> |
| | | 88 | | { |
| | | 89 | | private readonly KestrunLocalizationStore _store; |
| | | 90 | | private readonly List<string> _candidates; |
| | | 91 | | private readonly Lazy<HashSet<string>> _keyCache; |
| | | 92 | | |
| | 13 | 93 | | public CompositeStringTable(KestrunLocalizationStore store, string? requestedCulture) |
| | | 94 | | { |
| | 13 | 95 | | _store = store ?? throw new ArgumentNullException(nameof(store)); |
| | 13 | 96 | | _candidates = []; |
| | | 97 | | |
| | | 98 | | // Build candidate list from requested culture up the parent chain |
| | 13 | 99 | | var baseCandidates = _store.BuildCultureCandidates(requestedCulture); |
| | 13 | 100 | | if (baseCandidates.Count > 0) |
| | | 101 | | { |
| | 11 | 102 | | _candidates.Add(baseCandidates[0]); |
| | | 103 | | } |
| | | 104 | | |
| | | 105 | | // Add the specific-culture sibling fallback before walking the parent chain |
| | 13 | 106 | | var specific = GetSpecificCultureFallback(requestedCulture); |
| | 13 | 107 | | if (!string.IsNullOrWhiteSpace(specific) && !_candidates.Contains(specific, StringComparer.OrdinalIgnoreCase |
| | | 108 | | { |
| | 1 | 109 | | _candidates.Add(specific); |
| | | 110 | | } |
| | | 111 | | |
| | 48 | 112 | | for (var i = 1; i < baseCandidates.Count; i++) |
| | | 113 | | { |
| | 11 | 114 | | var candidate = baseCandidates[i]; |
| | 11 | 115 | | if (!_candidates.Contains(candidate, StringComparer.OrdinalIgnoreCase)) |
| | | 116 | | { |
| | 11 | 117 | | _candidates.Add(candidate); |
| | | 118 | | } |
| | | 119 | | } |
| | | 120 | | |
| | | 121 | | // Finally add the default culture as last resort (if not already present) |
| | 13 | 122 | | if (!string.IsNullOrWhiteSpace(_store.defaultCulture) && !_candidates.Contains(_store.defaultCulture, String |
| | | 123 | | { |
| | 10 | 124 | | _candidates.Add(_store.defaultCulture); |
| | | 125 | | } |
| | | 126 | | |
| | 13 | 127 | | _keyCache = new Lazy<HashSet<string>>(BuildKeySet, LazyThreadSafetyMode.ExecutionAndPublication); |
| | 13 | 128 | | } |
| | | 129 | | |
| | 5 | 130 | | public string this[string key] => TryGetValue(key, out var value) ? value : throw new KeyNotFoundException(key); |
| | | 131 | | |
| | 2 | 132 | | public IEnumerable<string> Keys => _keyCache.Value; |
| | | 133 | | |
| | | 134 | | public IEnumerable<string> Values |
| | | 135 | | { |
| | | 136 | | get |
| | | 137 | | { |
| | 0 | 138 | | foreach (var key in Keys) |
| | | 139 | | { |
| | 0 | 140 | | yield return this[key]; |
| | | 141 | | } |
| | 0 | 142 | | } |
| | | 143 | | } |
| | | 144 | | |
| | 1 | 145 | | public int Count => _keyCache.Value.Count; |
| | | 146 | | |
| | 0 | 147 | | public bool ContainsKey(string key) => TryGetValue(key, out _); |
| | | 148 | | |
| | | 149 | | public IEnumerator<KeyValuePair<string, string>> GetEnumerator() |
| | | 150 | | { |
| | 2 | 151 | | foreach (var key in Keys) |
| | | 152 | | { |
| | 0 | 153 | | yield return new KeyValuePair<string, string>(key, this[key]); |
| | | 154 | | } |
| | 1 | 155 | | } |
| | | 156 | | |
| | 0 | 157 | | System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); |
| | | 158 | | |
| | | 159 | | /// <summary> |
| | | 160 | | /// Tries to get the localized value for the specified key using culture fallback. |
| | | 161 | | /// </summary> |
| | | 162 | | /// <param name="key">The key to look up.</param> |
| | | 163 | | /// <param name="value">The localized value if found; otherwise, an empty string.</param> |
| | | 164 | | /// <returns>True if the key was found; otherwise, false.</returns> |
| | | 165 | | public bool TryGetValue(string key, out string value) |
| | | 166 | | { |
| | 36 | 167 | | foreach (var candidate in _candidates) |
| | | 168 | | { |
| | 13 | 169 | | if (!IsUsableCandidate(candidate)) |
| | | 170 | | { |
| | | 171 | | continue; |
| | | 172 | | } |
| | | 173 | | |
| | 12 | 174 | | var dict = _store.cache.GetOrAdd(candidate, _store.LoadStringsForCulture); |
| | | 175 | | |
| | 12 | 176 | | if (dict.TryGetValue(key, out var v)) |
| | | 177 | | { |
| | 8 | 178 | | value = v; |
| | 8 | 179 | | return true; |
| | | 180 | | } |
| | | 181 | | } |
| | | 182 | | |
| | 1 | 183 | | value = string.Empty; // or throw / fallback marker |
| | 1 | 184 | | return false; |
| | 8 | 185 | | } |
| | | 186 | | |
| | | 187 | | private bool IsUsableCandidate(string candidate) |
| | | 188 | | { |
| | | 189 | | // Prevent unbounded cache growth from arbitrary culture tags. |
| | | 190 | | // Only cache/load cultures that were discovered as available, plus the configured default. |
| | 19 | 191 | | return _store.IsCultureAvailable(candidate) || |
| | 19 | 192 | | string.Equals(candidate, _store.defaultCulture, StringComparison.OrdinalIgnoreCase); |
| | | 193 | | } |
| | | 194 | | |
| | | 195 | | private HashSet<string> BuildKeySet() |
| | | 196 | | { |
| | 2 | 197 | | var set = new HashSet<string>(StringComparer.Ordinal); |
| | 16 | 198 | | foreach (var candidate in _candidates) |
| | | 199 | | { |
| | 6 | 200 | | if (!IsUsableCandidate(candidate)) |
| | | 201 | | { |
| | | 202 | | continue; |
| | | 203 | | } |
| | | 204 | | |
| | 3 | 205 | | var dict = _store.cache.GetOrAdd(candidate, _store.LoadStringsForCulture); |
| | 14 | 206 | | foreach (var k in dict.Keys) |
| | | 207 | | { |
| | 4 | 208 | | _ = set.Add(k); |
| | | 209 | | } |
| | | 210 | | } |
| | | 211 | | |
| | 2 | 212 | | return set; |
| | | 213 | | } |
| | | 214 | | } |
| | | 215 | | |
| | | 216 | | /// <summary> |
| | | 217 | | /// Gets the localized strings for the specified culture using fallback resolution. |
| | | 218 | | /// </summary> |
| | | 219 | | /// <param name="culture">The culture name to resolve.</param> |
| | 0 | 220 | | public IReadOnlyDictionary<string, string> this[string culture] => GetStringsForCulture(culture); |
| | | 221 | | |
| | | 222 | | internal string? ResolveCulture(string? requestedCulture, bool allowDefaultFallback) |
| | | 223 | | { |
| | 11 | 224 | | var candidates = BuildCultureCandidates(requestedCulture); |
| | | 225 | | |
| | | 226 | | // Prefer an exact match for the requested culture first (typically the first candidate). |
| | 11 | 227 | | if (candidates.Count > 0 && IsCultureAvailable(candidates[0])) |
| | | 228 | | { |
| | 6 | 229 | | return candidates[0]; |
| | | 230 | | } |
| | | 231 | | |
| | | 232 | | // Then attempt a sibling-specific fallback (e.g., fr-FR when resolving fr-CA) before walking parents. |
| | 5 | 233 | | var specificFallback = GetSpecificCultureFallback(requestedCulture); |
| | 5 | 234 | | if (specificFallback is not null && IsCultureAvailable(specificFallback)) |
| | | 235 | | { |
| | 0 | 236 | | return specificFallback; |
| | | 237 | | } |
| | | 238 | | |
| | | 239 | | // Finally, walk the remaining candidates (typically the parent chain). |
| | 12 | 240 | | for (var i = 1; i < candidates.Count; i++) |
| | | 241 | | { |
| | 2 | 242 | | var candidate = candidates[i]; |
| | 2 | 243 | | if (IsCultureAvailable(candidate)) |
| | | 244 | | { |
| | 1 | 245 | | return candidate; |
| | | 246 | | } |
| | | 247 | | } |
| | | 248 | | |
| | 4 | 249 | | return allowDefaultFallback |
| | 4 | 250 | | ? defaultCulture |
| | 4 | 251 | | : null; |
| | | 252 | | } |
| | | 253 | | |
| | | 254 | | private IReadOnlyList<string> BuildCultureCandidates(string? requestedCulture) |
| | | 255 | | { |
| | 24 | 256 | | var list = new List<string>(); |
| | 24 | 257 | | var normalized = NormalizeCulture(requestedCulture); |
| | 24 | 258 | | if (!string.IsNullOrWhiteSpace(normalized)) |
| | | 259 | | { |
| | 20 | 260 | | var culture = CultureInfo.GetCultureInfo(normalized); |
| | 59 | 261 | | while (!string.IsNullOrWhiteSpace(culture.Name)) |
| | | 262 | | { |
| | 39 | 263 | | list.Add(culture.Name); |
| | 39 | 264 | | culture = culture.Parent; |
| | | 265 | | } |
| | | 266 | | } |
| | | 267 | | |
| | 24 | 268 | | return list; |
| | | 269 | | } |
| | | 270 | | |
| | | 271 | | private static string? GetSpecificCultureFallback(string? requestedCulture) |
| | | 272 | | { |
| | 18 | 273 | | if (string.IsNullOrWhiteSpace(requestedCulture)) |
| | | 274 | | { |
| | 4 | 275 | | return null; |
| | | 276 | | } |
| | | 277 | | |
| | | 278 | | CultureInfo culture; |
| | | 279 | | try |
| | | 280 | | { |
| | 14 | 281 | | culture = CultureInfo.GetCultureInfo(requestedCulture); |
| | 14 | 282 | | } |
| | 0 | 283 | | catch (CultureNotFoundException) |
| | | 284 | | { |
| | 0 | 285 | | return null; |
| | | 286 | | } |
| | | 287 | | |
| | 14 | 288 | | var neutral = culture.IsNeutralCulture ? culture : culture.Parent; |
| | 14 | 289 | | if (neutral is null || string.IsNullOrWhiteSpace(neutral.Name)) |
| | | 290 | | { |
| | 0 | 291 | | return null; |
| | | 292 | | } |
| | | 293 | | |
| | | 294 | | try |
| | | 295 | | { |
| | 14 | 296 | | var specific = CultureInfo.CreateSpecificCulture(neutral.Name).Name; |
| | 14 | 297 | | return string.Equals(specific, culture.Name, StringComparison.OrdinalIgnoreCase) |
| | 14 | 298 | | ? null |
| | 14 | 299 | | : specific; |
| | | 300 | | } |
| | 0 | 301 | | catch (CultureNotFoundException) |
| | | 302 | | { |
| | 0 | 303 | | return null; |
| | | 304 | | } |
| | 14 | 305 | | } |
| | | 306 | | |
| | | 307 | | private void DiscoverAvailableCultures() |
| | | 308 | | { |
| | 16 | 309 | | if (!Directory.Exists(resourcesRoot)) |
| | | 310 | | { |
| | 0 | 311 | | if (Logger.IsEnabled(Serilog.Events.LogEventLevel.Debug)) |
| | | 312 | | { |
| | 0 | 313 | | Logger.Debug("Localization resources root '{BasePath}' does not exist; no cultures discovered.", resourc |
| | | 314 | | } |
| | 0 | 315 | | return; |
| | | 316 | | } |
| | | 317 | | |
| | 92 | 318 | | foreach (var dir in Directory.GetDirectories(resourcesRoot)) |
| | | 319 | | { |
| | 30 | 320 | | var name = Path.GetFileName(dir); |
| | 30 | 321 | | if (string.IsNullOrWhiteSpace(name)) |
| | | 322 | | { |
| | | 323 | | continue; |
| | | 324 | | } |
| | | 325 | | |
| | 30 | 326 | | var filePath = Path.Combine(dir, options.FileName); |
| | 30 | 327 | | if (File.Exists(filePath) || HasJsonFallback(filePath)) |
| | | 328 | | { |
| | 29 | 329 | | _ = availableCultures.Add(name); |
| | | 330 | | } |
| | | 331 | | } |
| | | 332 | | |
| | 16 | 333 | | if (Logger.IsEnabled(Serilog.Events.LogEventLevel.Debug)) |
| | | 334 | | { |
| | 9 | 335 | | Logger.Debug("Discovered {Count} localization cultures in {BasePath}.", availableCultures.Count, resourcesRo |
| | | 336 | | } |
| | 16 | 337 | | } |
| | | 338 | | |
| | 33 | 339 | | private bool IsCultureAvailable(string culture) => availableCultures.Contains(culture); |
| | | 340 | | |
| | | 341 | | private static bool HasJsonFallback(string filePath) |
| | | 342 | | { |
| | 3 | 343 | | var extension = Path.GetExtension(filePath); |
| | 3 | 344 | | if (!string.Equals(extension, ".psd1", StringComparison.OrdinalIgnoreCase)) |
| | | 345 | | { |
| | 0 | 346 | | return false; |
| | | 347 | | } |
| | | 348 | | |
| | 3 | 349 | | var jsonPath = Path.ChangeExtension(filePath, ".json"); |
| | 3 | 350 | | return File.Exists(jsonPath); |
| | | 351 | | } |
| | | 352 | | |
| | | 353 | | private IReadOnlyDictionary<string, string> LoadStringsForCulture(string culture) |
| | | 354 | | { |
| | 11 | 355 | | var filePath = Path.Combine(resourcesRoot, culture, options.FileName); |
| | 11 | 356 | | var extension = Path.GetExtension(filePath); |
| | 11 | 357 | | var primaryPath = filePath; |
| | 11 | 358 | | var jsonFallbackPath = string.Equals(extension, ".psd1", StringComparison.OrdinalIgnoreCase) |
| | 11 | 359 | | ? Path.ChangeExtension(filePath, ".json") |
| | 11 | 360 | | : null; |
| | | 361 | | |
| | 11 | 362 | | if (!File.Exists(primaryPath)) |
| | | 363 | | { |
| | 3 | 364 | | if (!string.IsNullOrWhiteSpace(jsonFallbackPath) && File.Exists(jsonFallbackPath)) |
| | | 365 | | { |
| | 1 | 366 | | return LoadJsonStrings(jsonFallbackPath); |
| | | 367 | | } |
| | 2 | 368 | | if (Logger.IsEnabled(Serilog.Events.LogEventLevel.Debug)) |
| | | 369 | | { |
| | 1 | 370 | | Logger.Debug( |
| | 1 | 371 | | "Localization file missing for culture '{Culture}'. Tried '{PrimaryPath}'{JsonPathMessage}.", |
| | 1 | 372 | | culture, |
| | 1 | 373 | | primaryPath, |
| | 1 | 374 | | jsonFallbackPath is null ? string.Empty : $" and '{jsonFallbackPath}'"); |
| | | 375 | | } |
| | 2 | 376 | | return EmptyStrings; |
| | | 377 | | } |
| | | 378 | | |
| | 8 | 379 | | return string.Equals(extension, ".json", StringComparison.OrdinalIgnoreCase) |
| | 8 | 380 | | ? LoadJsonStrings(primaryPath) |
| | 8 | 381 | | : LoadPsStringTable(primaryPath); |
| | | 382 | | } |
| | | 383 | | |
| | | 384 | | private IReadOnlyDictionary<string, string> LoadPsStringTable(string path) |
| | | 385 | | { |
| | | 386 | | try |
| | | 387 | | { |
| | 8 | 388 | | return StringTableParser.ParseFile(path); |
| | | 389 | | } |
| | 0 | 390 | | catch (Exception ex) |
| | | 391 | | { |
| | 0 | 392 | | Logger.Warning(ex, "Failed to parse localization file '{Path}'.", path); |
| | 0 | 393 | | return EmptyStrings; |
| | | 394 | | } |
| | 8 | 395 | | } |
| | | 396 | | |
| | | 397 | | private IReadOnlyDictionary<string, string> LoadJsonStrings(string path) |
| | | 398 | | { |
| | | 399 | | try |
| | | 400 | | { |
| | 1 | 401 | | return StringTableParser.ParseJsonFile(path); |
| | | 402 | | } |
| | 0 | 403 | | catch (Exception ex) |
| | | 404 | | { |
| | 0 | 405 | | Logger.Warning(ex, "Failed to parse localization JSON file '{Path}'.", path); |
| | 0 | 406 | | return EmptyStrings; |
| | | 407 | | } |
| | 1 | 408 | | } |
| | | 409 | | |
| | | 410 | | private static string? NormalizeCulture(string? culture) |
| | | 411 | | { |
| | 40 | 412 | | if (string.IsNullOrWhiteSpace(culture)) |
| | | 413 | | { |
| | 4 | 414 | | return null; |
| | | 415 | | } |
| | | 416 | | |
| | | 417 | | try |
| | | 418 | | { |
| | 36 | 419 | | return CultureInfo.GetCultureInfo(culture).Name; |
| | | 420 | | } |
| | 0 | 421 | | catch (CultureNotFoundException) |
| | | 422 | | { |
| | 0 | 423 | | return null; |
| | | 424 | | } |
| | 36 | 425 | | } |
| | | 426 | | } |