Skip to content

Commit 6cf625d

Browse files
authored
Internationalization part 2 (#475)
Part 2 of 2 for internationalizing Yafc. This extracts all translatable strings into yafc.cfg, and uses the same translation system that Factorio uses. It also updates the Crowdin integration in a way that worked for me in a test project. Code is generated based on the English files, to help prevent using undeclared translation keys or substituting the wrong number of parameters.
2 parents 307a2bc + eae2bae commit 6cf625d

File tree

75 files changed

+2026
-943
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

75 files changed

+2026
-943
lines changed

.github/workflows/crowdin-translation-action.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Crowdin Action
22

33
on:
44
push:
5-
branches: [ main ]
5+
branches: [ master ]
66

77
jobs:
88
synchronize-with-crowdin:
@@ -22,7 +22,8 @@ jobs:
2222
create_pull_request: true
2323
pull_request_title: 'New Crowdin Translations'
2424
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
25-
pull_request_base_branch_name: 'main'
25+
pull_request_base_branch_name: 'master'
26+
skip_untranslated_strings: true
2627
env:
2728
# A classic GitHub Personal Access Token with the 'repo' scope selected (the user should have write access to the repository).
2829
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
## Custom ignores that were not the part of the conventional file
22

33
Build/
4+
*.g.cs
45

56
# Debug launch configuration
67
Y[Aa][Ff][Cc]/Properties/launchSettings.json

Docs/MoreLanguagesSupport.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# YAFC support for more languages
22

3-
You can ask Yafc to display non-English names for Factorio objects from the Welcome screen:
3+
You can ask Yafc to display non-English text from the Welcome screen:
44
- On the Welcome screen, click the language name (probably "English") next to "In-game objects language:"
55
- Select your language from the drop-down that appears.
66
- If your language uses non-European glyphs, it may appear at the bottom of the list.

FactorioCalc.sln

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
2323
exclusion.dic = exclusion.dic
2424
EndProjectSection
2525
EndProject
26+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yafc.I18n", "Yafc.I18n\Yafc.I18n.csproj", "{4FEC38A5-A997-48C9-97F5-87BD12119F44}"
27+
ProjectSection(ProjectDependencies) = postProject
28+
{E8A28A02-99C4-41D3-99E3-E6252BD116B7} = {E8A28A02-99C4-41D3-99E3-E6252BD116B7}
29+
EndProjectSection
30+
EndProject
31+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yafc.I18n.Generator", "Yafc.I18n.Generator\Yafc.I18n.Generator.csproj", "{E8A28A02-99C4-41D3-99E3-E6252BD116B7}"
32+
EndProject
2633
Global
2734
GlobalSection(SolutionConfigurationPlatforms) = preSolution
2835
Debug|Any CPU = Debug|Any CPU
@@ -53,8 +60,19 @@ Global
5360
{66B66728-84F0-4242-B49A-B9D746A3CCA5}.Debug|Any CPU.Build.0 = Debug|Any CPU
5461
{66B66728-84F0-4242-B49A-B9D746A3CCA5}.Release|Any CPU.ActiveCfg = Release|Any CPU
5562
{66B66728-84F0-4242-B49A-B9D746A3CCA5}.Release|Any CPU.Build.0 = Release|Any CPU
63+
{4FEC38A5-A997-48C9-97F5-87BD12119F44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
64+
{4FEC38A5-A997-48C9-97F5-87BD12119F44}.Debug|Any CPU.Build.0 = Debug|Any CPU
65+
{4FEC38A5-A997-48C9-97F5-87BD12119F44}.Release|Any CPU.ActiveCfg = Release|Any CPU
66+
{4FEC38A5-A997-48C9-97F5-87BD12119F44}.Release|Any CPU.Build.0 = Release|Any CPU
67+
{E8A28A02-99C4-41D3-99E3-E6252BD116B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
68+
{E8A28A02-99C4-41D3-99E3-E6252BD116B7}.Debug|Any CPU.Build.0 = Debug|Any CPU
69+
{E8A28A02-99C4-41D3-99E3-E6252BD116B7}.Release|Any CPU.ActiveCfg = Release|Any CPU
70+
{E8A28A02-99C4-41D3-99E3-E6252BD116B7}.Release|Any CPU.Build.0 = Release|Any CPU
5671
EndGlobalSection
5772
GlobalSection(SolutionProperties) = preSolution
5873
HideSolutionNode = FALSE
5974
EndGlobalSection
75+
GlobalSection(ExtensibilityGlobals) = postSolution
76+
SolutionGuid = {643684AA-6CBA-45BE-A603-BDA3020298A9}
77+
EndGlobalSection
6078
EndGlobal
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
using System.Text.RegularExpressions;
2+
3+
namespace Yafc.I18n.Generator;
4+
5+
internal partial class SourceGenerator {
6+
private static readonly Dictionary<string, int> localizationKeys = [];
7+
8+
private static void Main() {
9+
// Find the solution root directory
10+
string rootDirectory = Environment.CurrentDirectory;
11+
while (!Directory.Exists(Path.Combine(rootDirectory, ".git"))) {
12+
rootDirectory = Path.GetDirectoryName(rootDirectory)!;
13+
}
14+
Environment.CurrentDirectory = rootDirectory;
15+
16+
HashSet<string> keys = [];
17+
HashSet<string> referencedKeys = [];
18+
19+
using MemoryStream classesMemory = new(), stringsMemory = new();
20+
using (StreamWriter classes = new(classesMemory, leaveOpen: true), strings = new(stringsMemory, leaveOpen: true)) {
21+
22+
// Always generate the LocalizableString and LocalizableString0 classes
23+
classes.WriteLine("""
24+
using System.Diagnostics.CodeAnalysis;
25+
26+
namespace Yafc.I18n;
27+
28+
#nullable enable
29+
30+
/// <summary>
31+
/// The base class for YAFC's localizable UI strings.
32+
/// </summary>
33+
public abstract class LocalizableString {
34+
private protected readonly string key;
35+
36+
private protected LocalizableString(string key) => this.key = key;
37+
38+
/// <summary>
39+
/// Localize this string using an arbitrary number of parameters. Insufficient parameters will cause the localization to fail,
40+
/// and excess parameters will be ignored.
41+
/// </summary>
42+
/// <param name="args">An array of parameter values.</param>
43+
/// <returns>The localized string</returns>
44+
public string Localize(params object[] args) => LocalisedStringParser.ParseKey(key, args) ?? "Key not found: " + key;
45+
}
46+
47+
/// <summary>
48+
/// A localizable UI string that needs 0 parameters for localization.
49+
/// These strings will implicitly localize when appropriate.
50+
/// </summary>
51+
public sealed class LocalizableString0 : LocalizableString {
52+
internal LocalizableString0(string key) : base(key) { }
53+
54+
/// <summary>
55+
/// Localize this string.
56+
/// </summary>
57+
/// <returns>The localized string</returns>
58+
public string L() => LocalisedStringParser.ParseKey(key, []) ?? "Key not found: " + key;
59+
60+
/// <summary>
61+
/// Implicitly localizes a zero-parameter localizable string.
62+
/// </summary>
63+
/// <param name="lString">The zero-parameter string to be localized</param>
64+
[return: NotNullIfNotNull(nameof(lString))]
65+
public static implicit operator string?(LocalizableString0? lString) => lString?.L();
66+
}
67+
""");
68+
69+
HashSet<int> declaredArities = [0];
70+
71+
// Generate the beginning of the LSs class
72+
strings.WriteLine("""
73+
namespace Yafc.I18n;
74+
75+
/// <summary>
76+
/// A class containing localizable strings for each key defined in the English localization file. This name should be read as
77+
/// <c>LocalizableStrings</c>. It is aggressively abbreviated to help keep lines at a reasonable length.
78+
/// </summary>
79+
/// <remarks>This class is auto-generated. To add new localizable strings, add them to Yafc/Data/locale/en/yafc.cfg
80+
/// and build the solution.</remarks>
81+
public static class LSs {
82+
""");
83+
84+
// For each key in locale/en/*.*
85+
foreach (string file in Directory.EnumerateFiles(Path.Combine(rootDirectory, "Yafc/Data/locale/en"))) {
86+
using Stream stream = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
87+
foreach (var (category, key, v) in FactorioLocalization.Read(stream)) {
88+
string value = v; // iteration variables are read-only; make value writable.
89+
int parameterCount = 0;
90+
foreach (Match match in FindParameters().Matches(value)) {
91+
parameterCount = Math.Max(parameterCount, int.Parse(match.Groups[1].Value));
92+
}
93+
94+
// If we haven't generated it yet, generate the LocalizableString<parameterCount> class
95+
if (declaredArities.Add(parameterCount)) {
96+
classes.WriteLine($$"""
97+
98+
/// <summary>
99+
/// A localizable string that needs {{parameterCount}} parameters for localization.
100+
/// </summary>
101+
public sealed class LocalizableString{{parameterCount}} : LocalizableString {
102+
internal LocalizableString{{parameterCount}}(string key) : base(key) { }
103+
104+
/// <summary>
105+
/// Localize this string.
106+
/// </summary>
107+
{{string.Join(Environment.NewLine, Enumerable.Range(1, parameterCount).Select(n => $" /// <param name=\"p{n}\">The value to use for parameter __{n}__ when localizing this string.</param>"))}}
108+
/// <returns>The localized string</returns>
109+
public string L({{string.Join(", ", Enumerable.Range(1, parameterCount).Select(n => $"object p{n}"))}})
110+
=> LocalisedStringParser.ParseKey(key, [{{string.Join(", ", Enumerable.Range(1, parameterCount).Select(n => $"p{n}"))}}]) ?? "Key not found: " + key;
111+
}
112+
""");
113+
}
114+
115+
string pascalCasedKey = string.Join("", key.Split('-').Select(s => char.ToUpperInvariant(s[0]) + s[1..]));
116+
keys.Add(key);
117+
118+
foreach (Match match in FindReferencedKeys().Matches(value)) {
119+
referencedKeys.Add(match.Groups[1].Value);
120+
}
121+
122+
if (value.Length > 70) {
123+
value = value[..70] + "...";
124+
}
125+
value = value.Replace("&", "&amp;").Replace("<", "&lt;");
126+
127+
// Generate the read-only PascalCasedKeyName field.
128+
strings.WriteLine($$"""
129+
/// <summary>
130+
/// Gets a string that will localize to a value resembling "{{value}}"
131+
/// </summary>
132+
""");
133+
#if DEBUG
134+
strings.WriteLine($" public static LocalizableString{parameterCount} {pascalCasedKey} {{ get; }} = new(\"{category}.{key}\");");
135+
#else
136+
// readonly fields are much smaller than read-only properties, but VS doesn't provide inline reference counts for them.
137+
strings.WriteLine($" public static readonly LocalizableString{parameterCount} {pascalCasedKey} = new(\"{category}.{key}\");");
138+
#endif
139+
}
140+
}
141+
142+
foreach (string? undefinedKey in referencedKeys.Except(keys)) {
143+
strings.WriteLine($"#error Found a reference to __YAFC__{undefinedKey}__, which is not defined.");
144+
}
145+
// end of class LLs
146+
strings.WriteLine("}");
147+
}
148+
149+
ReplaceIfChanged("Yafc.I18n/LocalizableStringClasses.g.cs", classesMemory);
150+
ReplaceIfChanged("Yafc.I18n/LocalizableStrings.g.cs", stringsMemory);
151+
}
152+
153+
// Replace the files only if the new content is different than the old content.
154+
private static void ReplaceIfChanged(string filePath, MemoryStream newContent) {
155+
newContent.Position = 0;
156+
if (!File.Exists(filePath) || File.ReadAllText(filePath) != new StreamReader(newContent, leaveOpen: true).ReadToEnd()) {
157+
File.WriteAllBytes(filePath, newContent.ToArray());
158+
}
159+
}
160+
161+
[GeneratedRegex("__(\\d+)__")]
162+
private static partial Regex FindParameters();
163+
[GeneratedRegex("__YAFC__([a-zA-Z0-9_-]+)__")]
164+
private static partial Regex FindReferencedKeys();
165+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net8.0</TargetFramework>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
</PropertyGroup>
9+
10+
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
11+
<Exec Command="dotnet $(OutputPath)/Yafc.I18n.Generator.dll&#xD;&#xA;" />
12+
</Target>
13+
14+
<ItemGroup>
15+
<EmbeddedResource Include="..\Yafc\Data\locale\en\*.cfg" />
16+
</ItemGroup>
17+
18+
<ItemGroup>
19+
<Compile Include="..\Yafc.I18n\FactorioLocalization.cs" Link="FactorioLocalization.cs" />
20+
</ItemGroup>
21+
22+
</Project>

Yafc.Parser/FactorioLocalization.cs renamed to Yafc.I18n/FactorioLocalization.cs

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,34 @@
1-
using System.Collections.Generic;
2-
using System.IO;
1+
using System.Runtime.CompilerServices;
2+
[assembly: InternalsVisibleTo("Yafc.Model.Tests")]
33

4-
namespace Yafc.Parser;
4+
namespace Yafc.I18n;
55

6-
internal static class FactorioLocalization {
6+
public static class FactorioLocalization {
77
private static readonly Dictionary<string, string> keys = [];
88

99
public static void Parse(Stream stream) {
10+
foreach (var (category, key, value) in Read(stream)) {
11+
keys[$"{category}.{key}"] = CleanupTags(value);
12+
}
13+
}
14+
15+
public static IEnumerable<(string, string, string)> Read(Stream stream) {
1016
using StreamReader reader = new StreamReader(stream);
1117
string category = "";
1218

1319
while (true) {
1420
string? line = reader.ReadLine();
1521

1622
if (line == null) {
17-
return;
23+
break;
1824
}
1925

20-
line = line.Trim();
26+
// Trim spaces before keys and all spaces around [categories], but not trailing spaces in values.
27+
line = line.TrimStart();
28+
string trimmed = line.TrimEnd();
2129

22-
if (line.StartsWith('[') && line.EndsWith(']')) {
23-
category = line[1..^1];
30+
if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) {
31+
category = trimmed[1..^1];
2432
}
2533
else {
2634
int idx = line.IndexOf('=');
@@ -31,9 +39,8 @@ public static void Parse(Stream stream) {
3139

3240
string key = line[..idx];
3341
string val = line[(idx + 1)..];
34-
keys[category + "." + key] = CleanupTags(val);
42+
yield return (category, key, val);
3543
}
36-
3744
}
3845
}
3946

@@ -69,7 +76,7 @@ private static string CleanupTags(string source) {
6976
return null;
7077
}
7178

72-
public static void Initialize(Dictionary<string, string> newKeys) {
79+
internal static void Initialize(Dictionary<string, string> newKeys) {
7380
keys.Clear();
7481
foreach (var (key, value) in newKeys) {
7582
keys[key] = value;

Yafc.I18n/ILocalizable.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
3+
namespace Yafc.I18n;
4+
5+
public interface ILocalizable {
6+
bool Get([NotNullWhen(true)] out string? key, [NotNullWhen(true)] out object[]? parameters);
7+
}

0 commit comments

Comments
 (0)