diff --git a/docs/plans/2025-11-27-utf8-to-ascii-converter-implementation.md b/docs/plans/2025-11-27-utf8-to-ascii-converter-implementation.md
new file mode 100644
index 0000000000..f5c5f7a7d8
--- /dev/null
+++ b/docs/plans/2025-11-27-utf8-to-ascii-converter-implementation.md
@@ -0,0 +1,1910 @@
+# Utf8ToAsciiConverter Refactor Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Refactor Utf8ToAsciiConverter from 3,631-line switch statement to SIMD-optimized, extensible implementation with JSON-based character mappings.
+
+**Architecture:** SIMD ASCII detection via SearchValues, Unicode normalization for accented chars, FrozenDictionary for special cases (ligatures, Cyrillic). JSON files for mappings loaded at startup. Interface + DI for extensibility. Golden file testing ensures behavioral equivalence.
+
+**Tech Stack:** .NET 9, System.Buffers.SearchValues, FrozenDictionary, System.Text.Json, xUnit, BenchmarkDotNet
+
+> **Implementation Note:** This plan was written before analyzing the original Umbraco implementation. The actual Cyrillic mappings use simplified transliterations for backward compatibility with existing URLs (e.g., Щ→"Sh" instead of Щ→"Shch", Ц→"F" instead of Ц→"Ts"). See `cyrillic.json` for the actual mappings used.
+
+---
+
+## Task 0: Establish Performance Baseline
+
+**Files:**
+- Create: `tests/Umbraco.Tests.Benchmarks/Utf8ToAsciiConverterBaselineBenchmarks.cs`
+- Create: `tests/Umbraco.Tests.Benchmarks/BenchmarkTextGenerator.cs`
+- Create: `docs/benchmarks/utf8-converter-baseline-2025-11-27.md`
+
+### Step 0.1: Create BenchmarkTextGenerator
+
+**File:** `tests/Umbraco.Tests.Benchmarks/BenchmarkTextGenerator.cs`
+
+```csharp
+using System.Text;
+
+namespace Umbraco.Tests.Benchmarks;
+
+public static class BenchmarkTextGenerator
+{
+ private const int Seed = 42;
+
+ private static readonly char[] AsciiAlphaNum =
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".ToCharArray();
+
+ private static readonly char[] AsciiPunctuation =
+ " .,;:!?-_'\"()".ToCharArray();
+
+ private static readonly char[] LatinAccented =
+ "àáâãäåæèéêëìíîïñòóôõöøùúûüýÿÀÁÂÃÄÅÆÈÉÊËÌÍÎÏÑÒÓÔÕÖØÙÚÛÜÝŸœŒßðÐþÞ".ToCharArray();
+
+ private static readonly char[] Cyrillic =
+ "АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдеёжзийклмнопрстуфхцчшщъыьэюя".ToCharArray();
+
+ private static readonly char[] Symbols =
+ "©®™€£¥°±×÷§¶†‡•".ToCharArray();
+
+ private static readonly char[] WorstCaseCyrillic =
+ "ЩЮЯЖЧШщюяжчш".ToCharArray();
+
+ public static string GeneratePureAscii(int length) =>
+ GenerateFromCharset(length, AsciiAlphaNum);
+
+ public static string GenerateMixed(int length)
+ {
+ var random = new Random(Seed);
+ var sb = new StringBuilder(length);
+
+ for (int i = 0; i < length; i++)
+ {
+ var roll = random.Next(100);
+ var charset = roll switch
+ {
+ < 70 => AsciiAlphaNum,
+ < 85 => AsciiPunctuation,
+ < 95 => LatinAccented,
+ < 99 => Cyrillic,
+ _ => Symbols
+ };
+ sb.Append(charset[random.Next(charset.Length)]);
+ }
+
+ return sb.ToString();
+ }
+
+ public static string GenerateWorstCase(int length) =>
+ GenerateFromCharset(length, WorstCaseCyrillic);
+
+ private static string GenerateFromCharset(int length, char[] charset)
+ {
+ var random = new Random(Seed);
+ var sb = new StringBuilder(length);
+ for (int i = 0; i < length; i++)
+ sb.Append(charset[random.Next(charset.Length)]);
+ return sb.ToString();
+ }
+}
+```
+
+### Step 0.2: Create baseline benchmarks
+
+**File:** `tests/Umbraco.Tests.Benchmarks/Utf8ToAsciiConverterBaselineBenchmarks.cs`
+
+```csharp
+using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Columns;
+using BenchmarkDotNet.Jobs;
+using Umbraco.Cms.Core.Strings;
+
+namespace Umbraco.Tests.Benchmarks;
+
+[MemoryDiagnoser]
+[SimpleJob(RuntimeMoniker.Net90)]
+[RankColumn]
+[StatisticalTestColumn]
+public class Utf8ToAsciiConverterBaselineBenchmarks
+{
+ private static readonly string TinyAscii = BenchmarkTextGenerator.GeneratePureAscii(10);
+ private static readonly string TinyMixed = BenchmarkTextGenerator.GenerateMixed(10);
+ private static readonly string SmallAscii = BenchmarkTextGenerator.GeneratePureAscii(100);
+ private static readonly string SmallMixed = BenchmarkTextGenerator.GenerateMixed(100);
+ private static readonly string MediumAscii = BenchmarkTextGenerator.GeneratePureAscii(1024);
+ private static readonly string MediumMixed = BenchmarkTextGenerator.GenerateMixed(1024);
+ private static readonly string LargeAscii = BenchmarkTextGenerator.GeneratePureAscii(100 * 1024);
+ private static readonly string LargeMixed = BenchmarkTextGenerator.GenerateMixed(100 * 1024);
+ private static readonly string LargeWorstCase = BenchmarkTextGenerator.GenerateWorstCase(100 * 1024);
+
+ [Benchmark]
+ public string Tiny_Ascii() => Utf8ToAsciiConverter.ToAsciiString(TinyAscii);
+
+ [Benchmark]
+ public string Tiny_Mixed() => Utf8ToAsciiConverter.ToAsciiString(TinyMixed);
+
+ [Benchmark]
+ public string Small_Ascii() => Utf8ToAsciiConverter.ToAsciiString(SmallAscii);
+
+ [Benchmark]
+ public string Small_Mixed() => Utf8ToAsciiConverter.ToAsciiString(SmallMixed);
+
+ [Benchmark]
+ public string Medium_Ascii() => Utf8ToAsciiConverter.ToAsciiString(MediumAscii);
+
+ [Benchmark]
+ public string Medium_Mixed() => Utf8ToAsciiConverter.ToAsciiString(MediumMixed);
+
+ [Benchmark]
+ public string Large_Ascii() => Utf8ToAsciiConverter.ToAsciiString(LargeAscii);
+
+ [Benchmark]
+ public string Large_Mixed() => Utf8ToAsciiConverter.ToAsciiString(LargeMixed);
+
+ [Benchmark]
+ public string Large_WorstCase() => Utf8ToAsciiConverter.ToAsciiString(LargeWorstCase);
+
+ [Benchmark]
+ public char[] CharArray_Medium_Mixed() => Utf8ToAsciiConverter.ToAsciiCharArray(MediumMixed);
+}
+```
+
+### Step 0.3: Run baseline benchmarks
+
+```bash
+cd /home/yv01p/Umbraco-CMS
+dotnet run -c Release --project tests/Umbraco.Tests.Benchmarks -- --filter "*Baseline*" --exporters markdown
+```
+
+Expected: Benchmark completes and outputs results table with Mean, Allocated columns.
+
+### Step 0.4: Save baseline results
+
+Copy the generated markdown table to `docs/benchmarks/utf8-converter-baseline-2025-11-27.md`:
+
+```markdown
+# Utf8ToAsciiConverter Baseline Benchmarks
+
+**Date:** 2025-11-27
+**Implementation:** Original 3,631-line switch statement
+**Runtime:** .NET 9
+
+## Results
+
+| Method | Mean | Error | StdDev | Gen0 | Allocated |
+|--------|------|-------|--------|------|-----------|
+| ... (paste results) ... |
+
+## Notes
+
+- Baseline before SIMD refactor
+- Used as comparison target for Task 7
+```
+
+### Step 0.5: Commit
+
+```bash
+git add tests/Umbraco.Tests.Benchmarks/BenchmarkTextGenerator.cs \
+ tests/Umbraco.Tests.Benchmarks/Utf8ToAsciiConverterBaselineBenchmarks.cs \
+ docs/benchmarks/utf8-converter-baseline-2025-11-27.md
+git commit -m "perf(strings): establish Utf8ToAsciiConverter baseline benchmarks"
+```
+
+---
+
+## Task 1: Create Interfaces
+
+**Files:**
+- Create: `src/Umbraco.Core/Strings/IUtf8ToAsciiConverter.cs`
+- Create: `src/Umbraco.Core/Strings/ICharacterMappingLoader.cs`
+- Create: `tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/Utf8ToAsciiConverterInterfaceTests.cs`
+
+### Step 1.1: Write test for IUtf8ToAsciiConverter interface existence
+
+**File:** `tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/Utf8ToAsciiConverterInterfaceTests.cs`
+
+```csharp
+using Umbraco.Cms.Core.Strings;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Strings;
+
+public class Utf8ToAsciiConverterInterfaceTests
+{
+ [Fact]
+ public void IUtf8ToAsciiConverter_HasConvertStringMethod()
+ {
+ var type = typeof(IUtf8ToAsciiConverter);
+ var method = type.GetMethod("Convert", new[] { typeof(string), typeof(char) });
+
+ Assert.NotNull(method);
+ Assert.Equal(typeof(string), method.ReturnType);
+ }
+
+ [Fact]
+ public void IUtf8ToAsciiConverter_HasConvertSpanMethod()
+ {
+ var type = typeof(IUtf8ToAsciiConverter);
+ var methods = type.GetMethods().Where(m => m.Name == "Convert").ToList();
+
+ Assert.True(methods.Count >= 2, "Should have at least 2 Convert overloads");
+ }
+}
+```
+
+### Step 1.2: Run test to verify it fails
+
+```bash
+cd /home/yv01p/Umbraco-CMS
+dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~Utf8ToAsciiConverterInterfaceTests" --no-build 2>&1 | head -20
+```
+
+Expected: Build failure - `IUtf8ToAsciiConverter` does not exist
+
+### Step 1.3: Create IUtf8ToAsciiConverter interface
+
+**File:** `src/Umbraco.Core/Strings/IUtf8ToAsciiConverter.cs`
+
+```csharp
+namespace Umbraco.Cms.Core.Strings;
+
+///
+/// Converts UTF-8 text to ASCII, handling accented characters and transliteration.
+///
+public interface IUtf8ToAsciiConverter
+{
+ ///
+ /// Converts text to ASCII, returning a new string.
+ ///
+ /// The text to convert.
+ /// Character to use for unmappable characters. Default '?'.
+ /// The ASCII-converted string.
+ string Convert(string? text, char fallback = '?');
+
+ ///
+ /// Converts text to ASCII, writing to output span.
+ /// Zero-allocation for callers who provide buffer.
+ ///
+ /// The input text span.
+ /// The output buffer. Must be at least input.Length * 4.
+ /// Character to use for unmappable characters. Default '?'.
+ /// Number of characters written to output.
+ int Convert(ReadOnlySpan input, Span output, char fallback = '?');
+}
+```
+
+### Step 1.4: Create ICharacterMappingLoader interface
+
+**File:** `src/Umbraco.Core/Strings/ICharacterMappingLoader.cs`
+
+```csharp
+using System.Collections.Frozen;
+
+namespace Umbraco.Cms.Core.Strings;
+
+///
+/// Loads character mappings from JSON files.
+///
+public interface ICharacterMappingLoader
+{
+ ///
+ /// Loads all mapping files and returns combined FrozenDictionary.
+ /// Higher priority mappings override lower priority.
+ ///
+ /// Frozen dictionary of character to string mappings.
+ FrozenDictionary LoadMappings();
+}
+```
+
+### Step 1.5: Run test to verify it passes
+
+```bash
+cd /home/yv01p/Umbraco-CMS
+dotnet build src/Umbraco.Core
+dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~Utf8ToAsciiConverterInterfaceTests"
+```
+
+Expected: PASS
+
+### Step 1.6: Commit
+
+```bash
+git add src/Umbraco.Core/Strings/IUtf8ToAsciiConverter.cs \
+ src/Umbraco.Core/Strings/ICharacterMappingLoader.cs \
+ tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/Utf8ToAsciiConverterInterfaceTests.cs
+git commit -m "feat(strings): add IUtf8ToAsciiConverter and ICharacterMappingLoader interfaces"
+```
+
+---
+
+## Task 2: Extract Golden Mappings and Create JSON Files
+
+**Files:**
+- Create: `tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/TestData/golden-mappings.json`
+- Create: `src/Umbraco.Core/Strings/CharacterMappings/ligatures.json`
+- Create: `src/Umbraco.Core/Strings/CharacterMappings/special-latin.json`
+- Create: `src/Umbraco.Core/Strings/CharacterMappings/cyrillic.json`
+- Modify: `src/Umbraco.Core/Umbraco.Core.csproj`
+
+### Step 2.1: Extract all mappings from original switch statement
+
+Parse `src/Umbraco.Core/Strings/Utf8ToAsciiConverter.cs` and extract every case mapping.
+
+**File:** `tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/TestData/golden-mappings.json`
+
+```json
+{
+ "source": "Extracted from Utf8ToAsciiConverter.cs switch statement",
+ "extracted_date": "2025-11-27",
+ "total_mappings": 1317,
+ "mappings": {
+ "À": "A",
+ "Á": "A",
+ "Â": "A",
+ "Ã": "A",
+ "Ä": "A",
+ "Å": "A",
+ "Æ": "AE",
+ "Ç": "C",
+ "È": "E",
+ "É": "E",
+ "Ê": "E",
+ "Ë": "E",
+ "Ì": "I",
+ "Í": "I",
+ "Î": "I",
+ "Ï": "I",
+ "Ð": "D",
+ "Ñ": "N",
+ "Ò": "O",
+ "Ó": "O",
+ "Ô": "O",
+ "Õ": "O",
+ "Ö": "O",
+ "Ø": "O",
+ "Ù": "U",
+ "Ú": "U",
+ "Û": "U",
+ "Ü": "U",
+ "Ý": "Y",
+ "Þ": "TH",
+ "ß": "ss",
+ "à": "a",
+ "á": "a",
+ "â": "a",
+ "ã": "a",
+ "ä": "a",
+ "å": "a",
+ "æ": "ae",
+ "ç": "c",
+ "è": "e",
+ "é": "e",
+ "ê": "e",
+ "ë": "e",
+ "ì": "i",
+ "í": "i",
+ "î": "i",
+ "ï": "i",
+ "ð": "d",
+ "ñ": "n",
+ "ò": "o",
+ "ó": "o",
+ "ô": "o",
+ "õ": "o",
+ "ö": "o",
+ "ø": "o",
+ "ù": "u",
+ "ú": "u",
+ "û": "u",
+ "ü": "u",
+ "ý": "y",
+ "þ": "th",
+ "ÿ": "y",
+ "Œ": "OE",
+ "œ": "oe",
+ "Ł": "L",
+ "ł": "l",
+ "Ħ": "H",
+ "ħ": "h",
+ "Đ": "D",
+ "đ": "d",
+ "Ŧ": "T",
+ "ŧ": "t",
+ "Ŀ": "L",
+ "ŀ": "l",
+ "А": "A",
+ "а": "a",
+ "Б": "B",
+ "б": "b",
+ "В": "V",
+ "в": "v",
+ "Г": "G",
+ "г": "g",
+ "Д": "D",
+ "д": "d",
+ "Е": "E",
+ "е": "e",
+ "Ё": "Yo",
+ "ё": "yo",
+ "Ж": "Zh",
+ "ж": "zh",
+ "З": "Z",
+ "з": "z",
+ "И": "I",
+ "и": "i",
+ "Й": "Y",
+ "й": "y",
+ "К": "K",
+ "к": "k",
+ "Л": "L",
+ "л": "l",
+ "М": "M",
+ "м": "m",
+ "Н": "N",
+ "н": "n",
+ "О": "O",
+ "о": "o",
+ "П": "P",
+ "п": "p",
+ "Р": "R",
+ "р": "r",
+ "С": "S",
+ "с": "s",
+ "Т": "T",
+ "т": "t",
+ "У": "U",
+ "у": "u",
+ "Ф": "F",
+ "ф": "f",
+ "Х": "Kh",
+ "х": "kh",
+ "Ц": "Ts",
+ "ц": "ts",
+ "Ч": "Ch",
+ "ч": "ch",
+ "Ш": "Sh",
+ "ш": "sh",
+ "Щ": "Shch",
+ "щ": "shch",
+ "Ъ": "",
+ "ъ": "",
+ "Ы": "Y",
+ "ы": "y",
+ "Ь": "",
+ "ь": "",
+ "Э": "E",
+ "э": "e",
+ "Ю": "Yu",
+ "ю": "yu",
+ "Я": "Ya",
+ "я": "ya",
+ "ff": "ff",
+ "fi": "fi",
+ "fl": "fl",
+ "ffi": "ffi",
+ "ffl": "ffl",
+ "ſt": "st",
+ "st": "st",
+ "IJ": "IJ",
+ "ij": "ij"
+ }
+}
+```
+
+**Note:** The above is a representative subset. The full extraction should include all 1,317 mappings from the original switch statement. Use a script or manual extraction to complete.
+
+### Step 2.2: Create CharacterMappings directory
+
+```bash
+mkdir -p src/Umbraco.Core/Strings/CharacterMappings
+```
+
+### Step 2.3: Create ligatures.json
+
+**File:** `src/Umbraco.Core/Strings/CharacterMappings/ligatures.json`
+
+```json
+{
+ "name": "Ligatures",
+ "description": "Ligature characters expanded to component letters",
+ "priority": 0,
+ "mappings": {
+ "Æ": "AE",
+ "æ": "ae",
+ "Œ": "OE",
+ "œ": "oe",
+ "IJ": "IJ",
+ "ij": "ij",
+ "ß": "ss",
+ "ff": "ff",
+ "fi": "fi",
+ "fl": "fl",
+ "ffi": "ffi",
+ "ffl": "ffl",
+ "ſt": "st",
+ "st": "st"
+ }
+}
+```
+
+### Step 2.4: Create special-latin.json
+
+**File:** `src/Umbraco.Core/Strings/CharacterMappings/special-latin.json`
+
+```json
+{
+ "name": "Special Latin",
+ "description": "Latin characters that do not decompose via Unicode normalization",
+ "priority": 0,
+ "mappings": {
+ "Ð": "D",
+ "ð": "d",
+ "Đ": "D",
+ "đ": "d",
+ "Ħ": "H",
+ "ħ": "h",
+ "Ł": "L",
+ "ł": "l",
+ "Ŀ": "L",
+ "ŀ": "l",
+ "Ø": "O",
+ "ø": "o",
+ "Þ": "TH",
+ "þ": "th",
+ "Ŧ": "T",
+ "ŧ": "t"
+ }
+}
+```
+
+### Step 2.5: Create cyrillic.json
+
+**File:** `src/Umbraco.Core/Strings/CharacterMappings/cyrillic.json`
+
+```json
+{
+ "name": "Cyrillic",
+ "description": "Russian Cyrillic to Latin transliteration",
+ "priority": 0,
+ "mappings": {
+ "А": "A",
+ "а": "a",
+ "Б": "B",
+ "б": "b",
+ "В": "V",
+ "в": "v",
+ "Г": "G",
+ "г": "g",
+ "Д": "D",
+ "д": "d",
+ "Е": "E",
+ "е": "e",
+ "Ё": "Yo",
+ "ё": "yo",
+ "Ж": "Zh",
+ "ж": "zh",
+ "З": "Z",
+ "з": "z",
+ "И": "I",
+ "и": "i",
+ "Й": "Y",
+ "й": "y",
+ "К": "K",
+ "к": "k",
+ "Л": "L",
+ "л": "l",
+ "М": "M",
+ "м": "m",
+ "Н": "N",
+ "н": "n",
+ "О": "O",
+ "о": "o",
+ "П": "P",
+ "п": "p",
+ "Р": "R",
+ "р": "r",
+ "С": "S",
+ "с": "s",
+ "Т": "T",
+ "т": "t",
+ "У": "U",
+ "у": "u",
+ "Ф": "F",
+ "ф": "f",
+ "Х": "Kh",
+ "х": "kh",
+ "Ц": "Ts",
+ "ц": "ts",
+ "Ч": "Ch",
+ "ч": "ch",
+ "Ш": "Sh",
+ "ш": "sh",
+ "Щ": "Shch",
+ "щ": "shch",
+ "Ъ": "",
+ "ъ": "",
+ "Ы": "Y",
+ "ы": "y",
+ "Ь": "",
+ "ь": "",
+ "Э": "E",
+ "э": "e",
+ "Ю": "Yu",
+ "ю": "yu",
+ "Я": "Ya",
+ "я": "ya"
+ }
+}
+```
+
+### Step 2.6: Update csproj to embed JSON files
+
+**File:** `src/Umbraco.Core/Umbraco.Core.csproj` - Add this ItemGroup:
+
+```xml
+
+
+
+```
+
+### Step 2.7: Verify embedded resources
+
+```bash
+cd /home/yv01p/Umbraco-CMS
+dotnet build src/Umbraco.Core
+unzip -l src/Umbraco.Core/bin/Debug/net9.0/Umbraco.Cms.Core.dll | grep -i json
+```
+
+Expected: Should show the three embedded JSON files.
+
+### Step 2.8: Commit
+
+```bash
+git add src/Umbraco.Core/Strings/CharacterMappings/*.json \
+ src/Umbraco.Core/Umbraco.Core.csproj \
+ tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/TestData/golden-mappings.json
+git commit -m "feat(strings): add character mapping JSON files and golden test data"
+```
+
+---
+
+## Task 3: Implement CharacterMappingLoader
+
+**Files:**
+- Create: `src/Umbraco.Core/Strings/CharacterMappingLoader.cs`
+- Create: `src/Umbraco.Core/Strings/CharacterMappingFile.cs`
+- Create: `tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/CharacterMappingLoaderTests.cs`
+
+### Step 3.1: Write failing test for CharacterMappingLoader
+
+**File:** `tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/CharacterMappingLoaderTests.cs`
+
+```csharp
+using System.Collections.Frozen;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Umbraco.Cms.Core.Strings;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Strings;
+
+public class CharacterMappingLoaderTests
+{
+ [Fact]
+ 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.NotNull(mappings);
+ Assert.True(mappings.Count > 0, "Should have loaded mappings");
+ }
+
+ [Fact]
+ 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.Equal("OE", mappings['Œ']);
+ Assert.Equal("ae", mappings['æ']);
+ Assert.Equal("ss", mappings['ß']);
+ }
+
+ [Fact]
+ 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.Equal("Shch", mappings['Щ']);
+ Assert.Equal("zh", mappings['ж']);
+ Assert.Equal("Ya", mappings['Я']);
+ }
+
+ [Fact]
+ 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.Equal("L", mappings['Ł']);
+ Assert.Equal("O", mappings['Ø']);
+ Assert.Equal("TH", mappings['Þ']);
+ }
+}
+```
+
+### Step 3.2: Run test to verify it fails
+
+```bash
+cd /home/yv01p/Umbraco-CMS
+dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~CharacterMappingLoaderTests" --no-build 2>&1 | head -20
+```
+
+Expected: Build failure - `CharacterMappingLoader` does not exist
+
+### Step 3.3: Create CharacterMappingFile model
+
+**File:** `src/Umbraco.Core/Strings/CharacterMappingFile.cs`
+
+```csharp
+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; }
+}
+```
+
+### Step 3.4: Implement CharacterMappingLoader
+
+**File:** `src/Umbraco.Core/Strings/CharacterMappingLoader.cs`
+
+```csharp
+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;
+ }
+ }
+}
+```
+
+### Step 3.5: Run tests to verify they pass
+
+```bash
+cd /home/yv01p/Umbraco-CMS
+dotnet build src/Umbraco.Core
+dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~CharacterMappingLoaderTests"
+```
+
+Expected: All tests PASS
+
+### Step 3.6: Commit
+
+```bash
+git add src/Umbraco.Core/Strings/CharacterMappingFile.cs \
+ src/Umbraco.Core/Strings/CharacterMappingLoader.cs \
+ tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/CharacterMappingLoaderTests.cs
+git commit -m "feat(strings): implement CharacterMappingLoader for JSON-based character mappings"
+```
+
+---
+
+## Task 4: Implement Utf8ToAsciiConverterNew with Golden File Tests
+
+**Files:**
+- Create: `src/Umbraco.Core/Strings/Utf8ToAsciiConverterNew.cs`
+- Create: `tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/Utf8ToAsciiConverterNewTests.cs`
+- Create: `tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/Utf8ToAsciiConverterGoldenTests.cs`
+
+### Step 4.1: Write failing unit tests for converter
+
+**File:** `tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/Utf8ToAsciiConverterNewTests.cs`
+
+```csharp
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Umbraco.Cms.Core.Strings;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Strings;
+
+public class Utf8ToAsciiConverterNewTests
+{
+ private readonly IUtf8ToAsciiConverter _converter;
+
+ public Utf8ToAsciiConverterNewTests()
+ {
+ var hostEnv = new Mock();
+ hostEnv.Setup(h => h.ContentRootPath).Returns("/nonexistent");
+
+ var loader = new CharacterMappingLoader(
+ hostEnv.Object,
+ NullLogger.Instance);
+
+ _converter = new Utf8ToAsciiConverterNew(loader);
+ }
+
+ // === Null/Empty ===
+
+ [Fact]
+ public void Convert_Null_ReturnsEmpty()
+ => Assert.Equal(string.Empty, _converter.Convert(null));
+
+ [Fact]
+ public void Convert_Empty_ReturnsEmpty()
+ => Assert.Equal(string.Empty, _converter.Convert(string.Empty));
+
+ // === ASCII Fast Path ===
+
+ [Theory]
+ [InlineData("hello world", "hello world")]
+ [InlineData("ABC123", "ABC123")]
+ [InlineData("The quick brown fox", "The quick brown fox")]
+ public void Convert_AsciiOnly_ReturnsSameString(string input, string expected)
+ => Assert.Equal(expected, _converter.Convert(input));
+
+ // === Normalization (Accented Characters) ===
+
+ [Theory]
+ [InlineData("cafe", "cafe")]
+ [InlineData("naive", "naive")]
+ [InlineData("resume", "resume")]
+ public void Convert_AccentedChars_NormalizesCorrectly(string input, string expected)
+ => Assert.Equal(expected, _converter.Convert(input));
+
+ // === Ligatures ===
+
+ [Theory]
+ [InlineData("Œuvre", "OEuvre")]
+ [InlineData("Ærodynamic", "AErodynamic")]
+ [InlineData("straße", "strasse")]
+ public void Convert_Ligatures_ExpandsCorrectly(string input, string expected)
+ => Assert.Equal(expected, _converter.Convert(input));
+
+ // === Cyrillic ===
+
+ [Theory]
+ [InlineData("Москва", "Moskva")]
+ [InlineData("Борщ", "Borshch")]
+ [InlineData("Щука", "Shchuka")]
+ [InlineData("Привет", "Privet")]
+ public void Convert_Cyrillic_TransliteratesCorrectly(string input, string expected)
+ => Assert.Equal(expected, _converter.Convert(input));
+
+ // === Special Latin ===
+
+ [Theory]
+ [InlineData("Łódź", "Lodz")]
+ [InlineData("Ørsted", "Orsted")]
+ [InlineData("Þórr", "Thorr")]
+ public void Convert_SpecialLatin_ConvertsCorrectly(string input, string expected)
+ => Assert.Equal(expected, _converter.Convert(input));
+
+ // === Span API ===
+
+ [Fact]
+ public void Convert_SpanApi_WritesToOutputBuffer()
+ {
+ ReadOnlySpan input = "cafe";
+ Span output = stackalloc char[20];
+
+ var written = _converter.Convert(input, output);
+
+ Assert.Equal(4, written);
+ Assert.Equal("cafe", new string(output[..written]));
+ }
+
+ [Fact]
+ public void Convert_SpanApi_HandlesExpansion()
+ {
+ ReadOnlySpan input = "Щ"; // Expands to "Shch" (4 chars)
+ Span output = stackalloc char[20];
+
+ var written = _converter.Convert(input, output);
+
+ Assert.Equal(4, written);
+ Assert.Equal("Shch", new string(output[..written]));
+ }
+
+ // === Mixed Content ===
+
+ [Fact]
+ public void Convert_MixedContent_HandlesCorrectly()
+ {
+ var input = "Cafe Muller in Moskva";
+ var expected = "Cafe Muller in Moskva";
+
+ Assert.Equal(expected, _converter.Convert(input));
+ }
+}
+```
+
+### Step 4.2: Write golden file tests
+
+**File:** `tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/Utf8ToAsciiConverterGoldenTests.cs`
+
+```csharp
+using System.Text.Json;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Umbraco.Cms.Core.Strings;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Strings;
+
+public class Utf8ToAsciiConverterGoldenTests
+{
+ private readonly IUtf8ToAsciiConverter _newConverter;
+ private static readonly Dictionary GoldenMappings;
+
+ static Utf8ToAsciiConverterGoldenTests()
+ {
+ var testDataPath = Path.Combine(
+ AppContext.BaseDirectory,
+ "Umbraco.Core",
+ "Strings",
+ "TestData",
+ "golden-mappings.json");
+
+ if (File.Exists(testDataPath))
+ {
+ var json = File.ReadAllText(testDataPath);
+ var doc = JsonDocument.Parse(json);
+ GoldenMappings = doc.RootElement
+ .GetProperty("mappings")
+ .EnumerateObject()
+ .ToDictionary(p => p.Name, p => p.Value.GetString() ?? "");
+ }
+ else
+ {
+ GoldenMappings = new Dictionary();
+ }
+ }
+
+ public Utf8ToAsciiConverterGoldenTests()
+ {
+ var hostEnv = new Mock();
+ hostEnv.Setup(h => h.ContentRootPath).Returns("/nonexistent");
+
+ var loader = new CharacterMappingLoader(
+ hostEnv.Object,
+ NullLogger.Instance);
+
+ _newConverter = new Utf8ToAsciiConverterNew(loader);
+ }
+
+ public static IEnumerable