Add Localization

This commit is contained in:
MSWS
2025-07-30 03:32:31 -07:00
parent 642745b156
commit ffc09cc3f4
15 changed files with 260 additions and 0 deletions

1
.gitignore vendored
View File

@@ -59,3 +59,4 @@ build
GitVersionInformation.g.cs
gitversion.json
build_output
**/lang/*.json

6
Locale/IMsg.cs Normal file
View File

@@ -0,0 +1,6 @@
namespace TTT.Locale;
public interface IMsg {
string Key { get; }
object[] Args { get; }
}

View File

@@ -0,0 +1,29 @@
using CounterStrikeSharp.API.Core.Translations;
using Microsoft.Extensions.Localization;
namespace TTT.Locale;
public class JsonLocalizerFactory : IStringLocalizerFactory {
private readonly string langPath;
public JsonLocalizerFactory() {
// Lang folder is in the root of the project
// keep moving up until we find it
var current = Directory.GetCurrentDirectory();
while (!Directory.Exists(Path.Combine(current, "lang"))) {
current = Directory.GetParent(current)?.FullName;
if (current == null)
throw new DirectoryNotFoundException("Could not find lang folder");
}
langPath = Path.Combine(current, "lang");
}
public IStringLocalizer Create(Type resourceSource) {
return new JsonStringLocalizer(langPath);
}
public IStringLocalizer Create(string baseName, string location) {
return new JsonStringLocalizer(langPath);
}
}

17
Locale/Locale.csproj Normal file
View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>TTT.Locale</RootNamespace>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.328"/>
<PackageReference Include="Microsoft.Extensions.Localization.Abstractions" Version="8.0.3"/>
<PackageReference Include="YamlDotNet" Version="16.3.0"/>
</ItemGroup>
</Project>

8
Locale/MsgFactory.cs Normal file
View File

@@ -0,0 +1,8 @@
namespace TTT.Locale;
public static class MsgFactory {
public static IMsg Create(string key, params object[] args)
=> new Msg(key, args);
private sealed record Msg(string Key, object[] Args) : IMsg;
}

39
Locale/Program.cs Normal file
View File

@@ -0,0 +1,39 @@
using System.Text.Json;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace TTT.Locale;
public static class Program {
private static readonly JsonSerializerOptions opts =
new() { WriteIndented = true };
public static void Main(string[] args) {
if (args.Length is < 1 or > 2) {
Console.Error.WriteLine("Usage: YamlToJson <input.yml> [output.json]");
Environment.Exit(1);
}
var inputPath = args[0];
var outputPath = args.Length == 2 ?
args[1] :
Path.ChangeExtension(inputPath, ".json");
if (!File.Exists(inputPath)) {
Console.Error.WriteLine($"Error: File not found - {inputPath}");
Environment.Exit(2);
}
var yaml = File.ReadAllText(inputPath);
var deserializer = new DeserializerBuilder()
.WithNamingConvention(NullNamingConvention.Instance)
.Build();
var data = deserializer.Deserialize<Dictionary<string, string>>(yaml);
var json = JsonSerializer.Serialize(data, options: opts);
File.WriteAllText(outputPath, json);
}
}

1
Locale/README.md Normal file
View File

@@ -0,0 +1 @@
# TTT - Localizer

115
Locale/StringLocalizer.cs Normal file
View File

@@ -0,0 +1,115 @@
using System.Reflection;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Localization;
namespace TTT.Locale;
/// <summary>
/// A custom implementation of <see cref="IStringLocalizer"/> that adds support
/// for in-string placeholders like %key% and grammatical pluralization with %s%.
/// </summary>
public partial class StringLocalizer : IStringLocalizer {
public static readonly StringLocalizer Instance =
new(new JsonLocalizerFactory());
private readonly IStringLocalizer localizer;
public StringLocalizer(IStringLocalizerFactory factory) {
var type = typeof(StringLocalizer);
var assemblyName =
new AssemblyName(type.GetTypeInfo().Assembly.FullName ?? string.Empty);
localizer = factory.Create(string.Empty, assemblyName.FullName);
}
public LocalizedString this[string name] => getString(name);
public LocalizedString this[string name, params object[] arguments]
=> getString(name, arguments);
public IEnumerable<LocalizedString>
GetAllStrings(bool includeParentCultures) {
return localizer.GetAllStrings(includeParentCultures)
.Select(str => getString(str.Name));
}
[GeneratedRegex("%.*?%")]
private static partial Regex percentRegex();
[GeneratedRegex(@"\b(\w+)%s%")]
private static partial Regex pluralRegex();
private LocalizedString getString(string name, params object[] arguments) {
// Get the localized value
var value = localizer[name].Value;
// Replace placeholders like %key% with their respective values
var matches = percentRegex().Matches(value);
foreach (Match match in matches) {
var key = match.Value;
var trimmedKey = key[1..^1]; // Trim % symbols
// NullReferenceException catch block if key does not exist
try {
// CS# forces a space before a chat color if the entirety
// of the strong is a color code. This is undesired
// in our case, so we trim the value when we have a prefix.
var replacement = getString(trimmedKey).Value;
value = value.Replace(key,
trimmedKey == "prefix" ? replacement : replacement.Trim());
} catch (NullReferenceException) {
// Key doesn't exist, move on
}
}
// Format with arguments if provided
if (arguments.Length > 0) value = string.Format(value, arguments);
// Handle pluralization
value = HandlePluralization(value);
return new LocalizedString(name, value);
}
public static string HandlePluralization(string value) {
var pluralMatches = pluralRegex().Matches(value);
foreach (Match match in pluralMatches) {
var word = match.Groups[1].Value.ToLower();
var index = match.Index;
var prefix = value[..index].Trim();
var lastWords = prefix.Split(' ')
.Select(w
=> w.Where(c => char.IsLetterOrDigit(c) || c == '-').ToArray());
var previousNumber = lastWords.LastOrDefault(w => int.TryParse(w, out _));
if (previousNumber != null)
value = value[..index] + value[index..]
.Replace("%s%", int.Parse(previousNumber) == 1 ? "" : "s");
else
value = value[..index] + value[index..]
.Replace("%s%", word.EndsWith('s') ? "" : "s");
}
value = value.Replace("%s%", "s");
var trailingIndex = -1;
// We have to do this chicanery due to supporting colors in the string
while ((trailingIndex =
value.IndexOf("'s", trailingIndex + 1, StringComparison.Ordinal)) != -1) {
var startingWordBoundary = value[..trailingIndex].LastIndexOf(' ');
var endingWordBoundary = value.IndexOf(' ', trailingIndex + 2);
var word = value[(startingWordBoundary + 1)..endingWordBoundary];
var filteredWord = word.Where(c => char.IsLetterOrDigit(c) || c == '\'')
.ToArray();
if (new string(filteredWord).EndsWith("s's",
StringComparison.OrdinalIgnoreCase))
value = value[..(trailingIndex + 1)] + " "
+ value[(trailingIndex + 4)..];
}
return value;
}
}

View File

@@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game", "TTT\Game\Game.cspro
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Versioning", "Versioning\Versioning.csproj", "{7AABCDC7-14BE-437C-BD41-C765CAB82F0E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Locale", "Locale\Locale.csproj", "{B5F91489-CD1B-42F2-9CF0-889604BD7C7E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -48,6 +50,10 @@ Global
{7AABCDC7-14BE-437C-BD41-C765CAB82F0E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7AABCDC7-14BE-437C-BD41-C765CAB82F0E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7AABCDC7-14BE-437C-BD41-C765CAB82F0E}.Release|Any CPU.Build.0 = Release|Any CPU
{B5F91489-CD1B-42F2-9CF0-889604BD7C7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B5F91489-CD1B-42F2-9CF0-889604BD7C7E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5F91489-CD1B-42F2-9CF0-889604BD7C7E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5F91489-CD1B-42F2-9CF0-889604BD7C7E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
EndGlobalSection

View File

@@ -0,0 +1,14 @@
using Microsoft.Extensions.Localization;
using Xunit;
namespace TTT.Test.Locale;
public class LocaleTest(IStringLocalizer localizer) {
[Fact]
public void Locale_BasicTest() {
var msg = localizer["BASIC_TEST"];
Assert.NotNull(msg);
Assert.Equal("Foobar", msg.Value);
}
}

View File

@@ -0,0 +1,10 @@
using TTT.Locale;
namespace TTT.Test.Locale;
public static class TestMsgs {
public static IMsg BASIC_TEST => MsgFactory.Create(nameof(BASIC_TEST));
public static IMsg PLACEHOLDER_TEST(string name)
=> MsgFactory.Create(nameof(PLACEHOLDER_TEST), name);
}

View File

@@ -1,3 +1,4 @@
using Microsoft.Extensions.Localization;
using TTT.API.Events;
using TTT.API.Messages;
using TTT.API.Player;

View File

@@ -1,5 +1,6 @@
using System.Reactive.Concurrency;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using Microsoft.Reactive.Testing;
using TTT.API;
using TTT.API.Events;
@@ -10,6 +11,7 @@ using TTT.API.Player;
using TTT.API.Role;
using TTT.Game;
using TTT.Game.Roles;
using TTT.Locale;
using TTT.Test.Abstract;
using TTT.Test.Fakes;
@@ -26,6 +28,8 @@ public class Startup {
services.AddScoped<TestScheduler>();
services.AddScoped<IScheduler>(s => s.GetRequiredService<TestScheduler>());
services.AddScoped<IGameManager, GameManager>();
services.AddScoped<IStringLocalizerFactory, JsonLocalizerFactory>();
services.AddTransient<IStringLocalizer, StringLocalizer>();
services.AddModBehavior<GenericInitTester>();
services.AddModBehavior<PluginInitTester>();

View File

@@ -9,7 +9,9 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Locale\Locale.csproj"/>
<ProjectReference Include="..\API\API.csproj"/>
<ProjectReference Include="..\Locale\Locale.csproj"/>
<ProjectReference Include="..\Plugin\Plugin.csproj"/>
</ItemGroup>
@@ -31,4 +33,9 @@
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
</PropertyGroup>
<Target Name="PreprocessYaml" BeforeTargets="BeforeBuild">
<Exec Command="dotnet build $(MSBuildThisFileDirectory)..\..\Locale\Locale.csproj"/>
<Exec Command="$(MSBuildThisFileDirectory)..\..\Locale\bin\Debug\net8.0\Locale.exe lang/en.yml"/>
</Target>
</Project>

2
TTT/Test/lang/en.yml Normal file
View File

@@ -0,0 +1,2 @@
"BASIC_TEST": "Foobar"
"PLACEHOLDER_TEST": "Placeholder: {0}"