mirror of
https://github.com/MSWS/TTT.git
synced 2025-12-05 22:20:25 -08:00
Add Localization
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -59,3 +59,4 @@ build
|
||||
GitVersionInformation.g.cs
|
||||
gitversion.json
|
||||
build_output
|
||||
**/lang/*.json
|
||||
6
Locale/IMsg.cs
Normal file
6
Locale/IMsg.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace TTT.Locale;
|
||||
|
||||
public interface IMsg {
|
||||
string Key { get; }
|
||||
object[] Args { get; }
|
||||
}
|
||||
29
Locale/JsonLocalizerFactory.cs
Normal file
29
Locale/JsonLocalizerFactory.cs
Normal 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
17
Locale/Locale.csproj
Normal 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
8
Locale/MsgFactory.cs
Normal 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
39
Locale/Program.cs
Normal 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
1
Locale/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# TTT - Localizer
|
||||
115
Locale/StringLocalizer.cs
Normal file
115
Locale/StringLocalizer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
6
TTT.sln
6
TTT.sln
@@ -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
|
||||
|
||||
14
TTT/Test/Locale/LocaleTest.cs
Normal file
14
TTT/Test/Locale/LocaleTest.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
10
TTT/Test/Locale/TestMsgs.cs
Normal file
10
TTT/Test/Locale/TestMsgs.cs
Normal 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);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Localization;
|
||||
using TTT.API.Events;
|
||||
using TTT.API.Messages;
|
||||
using TTT.API.Player;
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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
2
TTT/Test/lang/en.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
"BASIC_TEST": "Foobar"
|
||||
"PLACEHOLDER_TEST": "Placeholder: {0}"
|
||||
Reference in New Issue
Block a user