From ca05d69be2ad6fe56d5ec7d6bee8745e50e1f15c Mon Sep 17 00:00:00 2001 From: yv01p Date: Fri, 12 Dec 2025 23:52:41 +0000 Subject: [PATCH] feat(strings): implement CharacterMappingLoader for JSON-based character mappings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Strings/CharacterMappingFile.cs | 27 ++++ .../Strings/CharacterMappingLoader.cs | 147 ++++++++++++++++++ .../Strings/CharacterMappingLoaderTests.cs | 90 +++++++++++ 3 files changed, 264 insertions(+) create mode 100644 src/Umbraco.Core/Strings/CharacterMappingFile.cs create mode 100644 src/Umbraco.Core/Strings/CharacterMappingLoader.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/CharacterMappingLoaderTests.cs diff --git a/src/Umbraco.Core/Strings/CharacterMappingFile.cs b/src/Umbraco.Core/Strings/CharacterMappingFile.cs new file mode 100644 index 0000000000..c5e26b3a73 --- /dev/null +++ b/src/Umbraco.Core/Strings/CharacterMappingFile.cs @@ -0,0 +1,27 @@ +namespace Umbraco.Cms.Core.Strings; + +/// +/// Represents a character mapping JSON file. +/// +internal sealed class CharacterMappingFile +{ + /// + /// Name of the mapping set. + /// + public required string Name { get; init; } + + /// + /// Optional description. + /// + public string? Description { get; init; } + + /// + /// Priority for override ordering. Higher values override lower. + /// + public int Priority { get; init; } + + /// + /// Character to string mappings. + /// + public required Dictionary Mappings { get; init; } +} diff --git a/src/Umbraco.Core/Strings/CharacterMappingLoader.cs b/src/Umbraco.Core/Strings/CharacterMappingLoader.cs new file mode 100644 index 0000000000..fa859dd03e --- /dev/null +++ b/src/Umbraco.Core/Strings/CharacterMappingLoader.cs @@ -0,0 +1,147 @@ +using System.Collections.Frozen; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Umbraco.Cms.Core.Strings; + +/// +/// Loads character mappings from embedded JSON files and user configuration. +/// +public sealed class CharacterMappingLoader : ICharacterMappingLoader +{ + private static readonly string[] BuiltInFiles = + ["ligatures.json", "special-latin.json", "cyrillic.json"]; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip + }; + + private readonly IHostEnvironment _hostEnvironment; + private readonly ILogger _logger; + + public CharacterMappingLoader( + IHostEnvironment hostEnvironment, + ILogger logger) + { + _hostEnvironment = hostEnvironment; + _logger = logger; + } + + /// + public FrozenDictionary LoadMappings() + { + var allMappings = new List<(int Priority, string Name, Dictionary Mappings)>(); + + // 1. Load built-in mappings from embedded resources + foreach (var file in BuiltInFiles) + { + var mapping = LoadEmbeddedMapping(file); + if (mapping != null) + { + allMappings.Add((mapping.Priority, mapping.Name, mapping.Mappings)); + _logger.LogDebug( + "Loaded built-in character mappings: {Name} ({Count} entries)", + mapping.Name, mapping.Mappings.Count); + } + } + + // 2. Load user mappings from config directory + var userPath = Path.Combine( + _hostEnvironment.ContentRootPath, + "config", + "character-mappings"); + + if (Directory.Exists(userPath)) + { + foreach (var file in Directory.GetFiles(userPath, "*.json")) + { + var mapping = LoadJsonFile(file); + if (mapping != null) + { + allMappings.Add((mapping.Priority, mapping.Name, mapping.Mappings)); + _logger.LogInformation( + "Loaded user character mappings: {Name} ({Count} entries, priority {Priority})", + mapping.Name, mapping.Mappings.Count, mapping.Priority); + } + } + } + + // 3. Merge by priority (higher priority wins) + return MergeMappings(allMappings); + } + + private static FrozenDictionary MergeMappings( + List<(int Priority, string Name, Dictionary Mappings)> allMappings) + { + var merged = new Dictionary(); + + foreach (var (_, _, mappings) in allMappings.OrderBy(m => m.Priority)) + { + foreach (var (key, value) in mappings) + { + if (key.Length == 1) + { + merged[key[0]] = value; + } + } + } + + return merged.ToFrozenDictionary(); + } + + private CharacterMappingFile? LoadEmbeddedMapping(string fileName) + { + var assembly = typeof(CharacterMappingLoader).Assembly; + var resourceName = $"Umbraco.Cms.Core.Strings.CharacterMappings.{fileName}"; + + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream == null) + { + _logger.LogWarning( + "Built-in character mapping file not found: {ResourceName}", + resourceName); + return null; + } + + try + { + return JsonSerializer.Deserialize(stream, JsonOptions); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to parse embedded mapping: {ResourceName}", resourceName); + return null; + } + } + + private CharacterMappingFile? LoadJsonFile(string path) + { + try + { + var json = File.ReadAllText(path); + var mapping = JsonSerializer.Deserialize(json, JsonOptions); + + if (mapping?.Mappings == null) + { + _logger.LogWarning( + "Invalid mapping file {Path}: missing 'mappings' property", path); + return null; + } + + return mapping; + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to parse character mappings from {Path}", path); + return null; + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to read character mappings from {Path}", path); + return null; + } + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/CharacterMappingLoaderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/CharacterMappingLoaderTests.cs new file mode 100644 index 0000000000..916239710f --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/CharacterMappingLoaderTests.cs @@ -0,0 +1,90 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Strings; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Strings; + +[TestFixture] +public class CharacterMappingLoaderTests +{ + [Test] + public void LoadMappings_LoadsBuiltInMappings() + { + // Arrange + var hostEnv = new Mock(); + hostEnv.Setup(h => h.ContentRootPath).Returns("/nonexistent"); + + var loader = new CharacterMappingLoader( + hostEnv.Object, + NullLogger.Instance); + + // Act + var mappings = loader.LoadMappings(); + + // Assert + Assert.IsNotNull(mappings); + Assert.That(mappings.Count, Is.GreaterThan(0), "Should have loaded mappings"); + } + + [Test] + public void LoadMappings_ContainsLigatures() + { + // Arrange + var hostEnv = new Mock(); + hostEnv.Setup(h => h.ContentRootPath).Returns("/nonexistent"); + + var loader = new CharacterMappingLoader( + hostEnv.Object, + NullLogger.Instance); + + // Act + var mappings = loader.LoadMappings(); + + // Assert + Assert.AreEqual("OE", mappings['Œ']); + Assert.AreEqual("ae", mappings['æ']); + Assert.AreEqual("ss", mappings['ß']); + } + + [Test] + public void LoadMappings_ContainsCyrillic() + { + // Arrange + var hostEnv = new Mock(); + hostEnv.Setup(h => h.ContentRootPath).Returns("/nonexistent"); + + var loader = new CharacterMappingLoader( + hostEnv.Object, + NullLogger.Instance); + + // Act + var mappings = loader.LoadMappings(); + + // Assert + Assert.AreEqual("Shch", mappings['Щ']); + Assert.AreEqual("zh", mappings['ж']); + Assert.AreEqual("Ya", mappings['Я']); + } + + [Test] + public void LoadMappings_ContainsSpecialLatin() + { + // Arrange + var hostEnv = new Mock(); + hostEnv.Setup(h => h.ContentRootPath).Returns("/nonexistent"); + + var loader = new CharacterMappingLoader( + hostEnv.Object, + NullLogger.Instance); + + // Act + var mappings = loader.LoadMappings(); + + // Assert + Assert.AreEqual("L", mappings['Ł']); + Assert.AreEqual("O", mappings['Ø']); + Assert.AreEqual("TH", mappings['Þ']); + } +}