Недавно я столкнулся с проблемой локализации своего приложения и задумался над её решением.

Первым на ум приходить самый очевидный и простой способ - словарь, но он был тут же отвергнут, так как никак не нельзя проверить существует ли строка в словаре на момент компиляции.

Куда более изящное решение - создать иерархию классов типа этой:

public class Locale 
{
	public string Name {get; set;}
  public UI UI {get; set;}
}
public class UI 
{
	public Buttons Buttons {get; set;}
	public Messages Messages {get; set;}
}
public class Buttons 
{
	public string CloseButton {get; set;}
  public string DeleteButton {get; set;}
}
public class Messages 
{
	public string ErrorMessage {get; set;}
}

Дальше можно просто сериализировать/десериализировать xml'ку.

Только есть одно "но". На создание такой иерархии классов может уйти достаточно времени, особенно если проект большой. Так почему бы не генерировать ее из xml файла? Этим мы и займемся.

Приступим

Для начала создадим проект нашего генератора и добавим в него необходимые пакеты

dotnet new classlib -o LocalizationSourceGenerator -f netstandard2.0
dotnet add package Microsoft.CodeAnalysis.CSharp
dotnet add package Microsoft.CodeAnalysis.Analyzers

Важно! Target framework проекта обязательно должен быть netstandard2.0

Далее добавим класс нашего генератора

Он должен реализовать интерфейс ISourceGenerator и быть помеченным атрибутом Generator

Далее добавим интерфейс ILocalizationGenerator и класс XmlLocalizationGenerator, который реализует его:

ILocalizationGenerator.cs
public interface ILocalizationGenerator
{
	string GenerateLocalization(string template);
}
XmlLocalizationGenerator.cs
public class XmlLocalizationGenerator : ILocalizationGenerator
{
	//список сгенерированых классов
	private List<string> classes = new List<string>();
  
  public string GenerateLocalization(string template)
  {
  	//создаем новый xml документ и загружаем шаблон
    XmlDocument document = new XmlDocument();
    document.LoadXml(template);
    var root = document.DocumentElement;
    //Получаем имя пространства имен или задаем стандартное
    string namespaceName = root.HasAttribute("namespace") ? 
    											 root.GetAttribute("namespace") : 
                           "Localization";
    GenClass(root); //Рекурсивно генерируем классы
    var sb = new StringBuilder();
   	sb.AppendLine($"namespace {namespaceName}\n{{");
		//Каждый сгенерированый клас записываем в результат
	  foreach(var item in classes) 
	  {
			sb.AppendLine(item);
		}
    sb.Append('}');
  	return sb.ToString();
  }
  public void GenClass(XmlElement element)
  {
  	var sb = new StringBuilder();
    sb.Append($"public class {element.Name}");
    sb.AppendLine("{");
    //Для всех дочерних узлов генерируем свойства в классе
    foreach (XmlNode item in element.ChildNodes)
    {
    	//если узел не имеет дочерних узлов или 
      //имеет только один текстовый узел - генерируем свойство-строку
    	if (item.ChildNodes.Count == 0 
      || (item.ChildNodes.Count == 1 
      && item.FirstChild.NodeType==XmlNodeType.Text))
      {
      	sb.AppendLine($"public string {item.Name} {{get; set;}}");
      }
      else
      {
      	//Генерируем класс по имени узла
        //и добавляем одноименное свойство
      	sb.AppendLine($"public {item.Name} {item.Name} {{get; set;}}");
        GenClass(item); 
      }
    }
    sb.AppendLine("}");
    classes.Add(sb.ToString());
  }
}

Осталось дело за малым. Необходимо реализовать класс самого генератора

LocalizationSourceGenerator.cs
[Generator]
public class LocalizationSourceGenerator : ISourceGenerator
{
	public void Execute(GeneratorExecutionContext context)
 	{
  	//Загружаем файл шаблона из дополнительных файлов 
  	var templateFile = context
    									 .AdditionalFiles
                       .FirstOrDefault(
                       		x => Path.GetExtension(x.Path) == ".xml")
                          ?.Path;
    if (!string.IsNullOrWhiteSpace(templateFile))
    {
    	ILocalizationGenerator generator = new XmlLocalizationGenerator();
      var s = generator.GenerateLocalization(File.ReadAllText(templateFile));
      //Этот метод и занимается "волшебством"
      //Он встраивает сгенерированый код в процесс компиляции
      context.AddSource("Localization",s );
    }
  }

  public void Initialize(GeneratorInitializationContext context)
  {
  	//В данном случае нам не нужно ничего инициализировать,
    //поэтому оставим реализацию пустой
  }
}

Вот и все! Теперь нужно лишь проверить наш генератор. Для этого создадим проект консольного приложения

dotnet new console -o Test

Добавим файл шаблона и локализации

template.xml
<Locale namespace="Program.Localization">
	<UI>
		<Buttons>
			<SendButton/> 
		</Buttons> 
	</UI> 
	<Name/>
</Locale>
ru.xml
<Locale>
	<UI>
		<Buttons>
			<SendButton>Отправить</SendButton>
		</Buttons>
	</UI>
	<Name>Русский</Name>
</Locale>

Отредактируем файл проекта

Test.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>  
  </PropertyGroup>  
  <ItemGroup>
    <ProjectReference 
					ReferenceOutputAssembly="false"
					OutputItemType="Analyzer" 
					Include="Путь-к-файлу-проекта-генератора" />
		<!--Добавляем файл шаблона локализации в дополнительные файлы-->
    <AdditionalFiles Include="template.xml"/>  
  </ItemGroup>
</Project>

И код программы

Program.cs
using System;
using System.IO; 
using System.Xml.Serialization;   
using Program.Localization; //Сгенерированое пространство имен
namespace Program
{ 
	public class Program
	{
		public static void Main()
		{ 
      //Тип Locale сгенерирован в момент компиляции
			var xs = new XmlSerializer(typeof(Locale));
			var locale = xs.Deserialize(File.OpenRead("ru.xml")) as Locale;
			Console.WriteLine(locale.Name);
			Console.WriteLine(locale.UI.Buttons.SendButton);
			
		}
		
	}
}

Dotnet-And-Happiness/LocalizationSourceGenerator (github.com) - репозиторий генератора