diff --git a/.github/workflows/dotnet-core.yml b/.github/workflows/dotnet-core.yml new file mode 100644 index 0000000..5f3e48c --- /dev/null +++ b/.github/workflows/dotnet-core.yml @@ -0,0 +1,17 @@ +name: .NET Core + +on: [push] + +jobs: + build: + + runs-on: windows-latest + + steps: + - uses: actions/checkout@v1 + + - name: Build with dotnet + run: dotnet build --configuration Release + + - name: Test + run: dotnet test --configuration Release \ No newline at end of file diff --git a/.github/workflows/nuget-master-publish.yml b/.github/workflows/nuget-master-publish.yml new file mode 100644 index 0000000..9b5e85c --- /dev/null +++ b/.github/workflows/nuget-master-publish.yml @@ -0,0 +1,40 @@ +# 去掉注释可以合并 master 分支自动打包 +# 为什么不期望推送 master 自动打包?原因是打出来的 CBB 没有 Tag 不利于回滚找到代码 + +# name: publish nuget + +# on: +# push: +# branches: +# - master + +# jobs: +# build: + +# runs-on: windows-latest + +# steps: +# - uses: actions/checkout@v1 + +# - name: Setup .NET Core +# uses: actions/setup-dotnet@v1 +# with: +# dotnet-version: 3.1.300 + +# - name: Build with dotnet +# run: | +# dotnet build --configuration Release +# dotnet pack --configuration Release --no-build + +# - name: Install Nuget +# uses: nuget/setup-nuget@v1 +# with: +# nuget-version: '5.x' + +# - name: Add private GitHub registry to NuGet +# run: | +# nuget sources add -name github -Source https://nuget.pkg.github.com/dotnet-campus/index.json -Username dotnet-campus -Password ${{ secrets.GITHUB_TOKEN }} +# - name: Push generated package to GitHub registry +# run: | +# nuget push .\bin\Release\*.nupkg -Source github -SkipDuplicate +# nuget push .\bin\Release\*.nupkg -Source https://api.nuget.org/v3/index.json -SkipDuplicate -ApiKey ${{ secrets.NugetKey }} diff --git a/.github/workflows/nuget-tag-publish.yml b/.github/workflows/nuget-tag-publish.yml new file mode 100644 index 0000000..c2a1095 --- /dev/null +++ b/.github/workflows/nuget-tag-publish.yml @@ -0,0 +1,42 @@ +name: publish nuget + +on: + push: + tags: + - '*' + +jobs: + build: + + runs-on: windows-latest + + steps: + - uses: actions/checkout@v1 + + - name: Setup .NET Core + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 8.0.x + + - name: Install dotnet tool + run: dotnet tool install -g dotnetCampus.TagToVersion + + - name: Set tag to version + run: dotnet TagToVersion -t ${{ github.ref }} + + - name: Build with dotnet + run: dotnet build -c Release + + - name: Install Nuget + uses: nuget/setup-nuget@v1 + with: + nuget-version: '6.x' + + - name: Add private GitHub registry to NuGet + run: | + nuget sources add -name github -Source https://nuget.pkg.github.com/dotnet-campus/index.json -Username dotnet-campus -Password ${{ secrets.GITHUB_TOKEN }} + + - name: Push generated package to GitHub registry + run: | + nuget push .\artifacts\package\release\*.nupkg -Source github -SkipDuplicate + nuget push .\artifacts\package\release\*.nupkg -Source https://api.nuget.org/v3/index.json -SkipDuplicate -ApiKey ${{ secrets.NugetKey }} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..3d63a39 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,25 @@ + + + + + + + latest + enable + enable + $(MSBuildThisFileDirectory)artifacts + $(MSBuildThisFileDirectory) + + + + + 使用源生成器为你的项目增加本地化源代码,使得你可以利用 IDE 的智能感知来使用多语言。 + dotnet-campus + dotnet campus(.NET 职业技术学院) + Copyright $([System.DateTime]::Now.ToString(`yyyy`)) © dotnet campus, All Rights Reserved. + git + https://github.com/dotnet-campus/dotnetCampus.SourceLocalizations + https://github.com/dotnet-campus/dotnetCampus.SourceLocalizations + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..85d6d8e --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# dotnetCampus.SourceLocalizations + +| Build | NuGet | +|--|--| +|![](https://github.com/dotnet-campus/dotnetCampus.SourceLocalizations/workflows/.NET%20Core/badge.svg)|[![](https://img.shields.io/nuget/v/dotnetCampus.SourceLocalizations.svg)](https://www.nuget.org/packages/dotnetCampus.SourceLocalizations)| diff --git a/build/Version.props b/build/Version.props new file mode 100644 index 0000000..f1d2d98 --- /dev/null +++ b/build/Version.props @@ -0,0 +1,5 @@ + + + 0.1.0-alpha01 + + diff --git a/dotnetCampus.SourceLocalizations.sln b/dotnetCampus.SourceLocalizations.sln new file mode 100644 index 0000000..7f50212 --- /dev/null +++ b/dotnetCampus.SourceLocalizations.sln @@ -0,0 +1,57 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dotnetCampus.Localizations", "src\dotnetCampus.Localizations\dotnetCampus.Localizations.csproj", "{9A8FEE27-F589-48E4-BF31-CC0D5CD4A3F0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dotnetCampus.Localizations.Analyzer", "src\dotnetCampus.Localizations.Analyzer\dotnetCampus.Localizations.Analyzer.csproj", "{48BE5845-BF2F-48CD-8214-2418E72A9BFF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{E407F54C-8E41-4F4E-B9BF-9864B6178E5F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LocalizationSample", "samples\LocalizationSample\LocalizationSample.csproj", "{F330175F-FC20-4D42-921A-747AC296D1B9}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4239BEE3-D480-4874-83E1-353B35F1BD86}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dotnetCampus.Localizations.Tests", "tests\dotnetCampus.Localizations.Tests\dotnetCampus.Localizations.Tests.csproj", "{3170B151-6A25-4321-BF09-EFC516E32F8D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{6E0F9A76-51AE-49A7-B74C-CA194F6CD37C}" + ProjectSection(SolutionItems) = preProject + .gitattributes = .gitattributes + .gitignore = .gitignore + README.md = README.md + Directory.Build.props = Directory.Build.props + build\Version.props = build\Version.props + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {9A8FEE27-F589-48E4-BF31-CC0D5CD4A3F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9A8FEE27-F589-48E4-BF31-CC0D5CD4A3F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9A8FEE27-F589-48E4-BF31-CC0D5CD4A3F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9A8FEE27-F589-48E4-BF31-CC0D5CD4A3F0}.Release|Any CPU.Build.0 = Release|Any CPU + {48BE5845-BF2F-48CD-8214-2418E72A9BFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {48BE5845-BF2F-48CD-8214-2418E72A9BFF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {48BE5845-BF2F-48CD-8214-2418E72A9BFF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {48BE5845-BF2F-48CD-8214-2418E72A9BFF}.Release|Any CPU.Build.0 = Release|Any CPU + {F330175F-FC20-4D42-921A-747AC296D1B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F330175F-FC20-4D42-921A-747AC296D1B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F330175F-FC20-4D42-921A-747AC296D1B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F330175F-FC20-4D42-921A-747AC296D1B9}.Release|Any CPU.Build.0 = Release|Any CPU + {3170B151-6A25-4321-BF09-EFC516E32F8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3170B151-6A25-4321-BF09-EFC516E32F8D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3170B151-6A25-4321-BF09-EFC516E32F8D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3170B151-6A25-4321-BF09-EFC516E32F8D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {F330175F-FC20-4D42-921A-747AC296D1B9} = {E407F54C-8E41-4F4E-B9BF-9864B6178E5F} + {3170B151-6A25-4321-BF09-EFC516E32F8D} = {4239BEE3-D480-4874-83E1-353B35F1BD86} + EndGlobalSection +EndGlobal diff --git a/samples/LocalizationSample/LocalizationSample.csproj b/samples/LocalizationSample/LocalizationSample.csproj new file mode 100644 index 0000000..30de8fd --- /dev/null +++ b/samples/LocalizationSample/LocalizationSample.csproj @@ -0,0 +1,16 @@ + + + + WinExe + net8.0 + + + + + + + + + + + diff --git a/samples/LocalizationSample/Localizations/en.yaml b/samples/LocalizationSample/Localizations/en.yaml new file mode 100644 index 0000000..c5e80fa --- /dev/null +++ b/samples/LocalizationSample/Localizations/en.yaml @@ -0,0 +1,3 @@ +A.A1: "Words" +A.A2: "Error code: {errorCode:int}" +A.A3: "Error: {error}" diff --git a/samples/LocalizationSample/Localizations/zh-hans.yaml b/samples/LocalizationSample/Localizations/zh-hans.yaml new file mode 100644 index 0000000..b38d5cd --- /dev/null +++ b/samples/LocalizationSample/Localizations/zh-hans.yaml @@ -0,0 +1,3 @@ +A.A1: "文本" +A.A2: "错误码:{errorCode:int}" +A.A3: "错误:{error}" diff --git a/samples/LocalizationSample/Program.cs b/samples/LocalizationSample/Program.cs new file mode 100644 index 0000000..7ffbdf9 --- /dev/null +++ b/samples/LocalizationSample/Program.cs @@ -0,0 +1,61 @@ +using System.Collections.Frozen; +using System.ComponentModel; +using dotnetCampus.Localizations; + +namespace LocalizationSample; + +internal class Program +{ + public static void Main(string[] args) + { + } +} + +[LocalizedConfiguration(Default = "zh-hans", Current = "en")] +internal partial class Lang; + +[EditorBrowsable(EditorBrowsableState.Never)] +public interface ILocalized_Root : ILocalizedStringProvider +{ + ILocalized_Root_A A => (ILocalized_Root_A)this; +} + +[EditorBrowsable(EditorBrowsableState.Never)] +public interface ILocalized_Root_A : ILocalizedStringProvider +{ + LocalizedString A1 => this.Get0("A.A1"); + + LocalizedString A2 => this.Get1("A.A2"); + + LocalizedString A3 => this.Get1("A.A3"); +} + +public class Lang_ZhHans(ILocalized_Root? fallback) : ILocalized_Root, + ILocalized_Root_A +{ + private readonly FrozenDictionary _strings = new Dictionary + { + { "A.A1", "文字" }, + { "A.A2", "错误码:{0}" }, + { "A.A3", "错误:{0}" }, + }.ToFrozenDictionary(); + + public string this[string key] => _strings[key] ?? fallback![key]; + + public string IetfLanguageTag => "zh-hans"; +} + +public class Lang_En(ILocalized_Root? fallback) : ILocalized_Root, + ILocalized_Root_A +{ + private readonly FrozenDictionary _strings = new Dictionary + { + { "A.A1", "Words" }, + { "A.A2", "Error code: {0}" }, + { "A.A3", "Error: {0}" }, + }.ToFrozenDictionary(); + + public string this[string key] => _strings[key] ?? fallback![key]; + + public string IetfLanguageTag => "en"; +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Assets/Analyzers/LspPlaceholder.cs b/src/dotnetCampus.Localizations.Analyzer/Assets/Analyzers/LspPlaceholder.cs new file mode 100644 index 0000000..ceb162e --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Assets/Analyzers/LspPlaceholder.cs @@ -0,0 +1,18 @@ +global using dotnetCampus.Localizations.Assets.Analyzers; +using dotnetCampus.Localizations.Assets.Templates; + +namespace dotnetCampus.Localizations.Assets.Analyzers; + +/// +/// 为生成 的分部类的部分待填充代码提供占位符。 +/// +/// +/// +public class LspPlaceholder(string ietfLanguageTag, ILocalized_Root? fallback) : ILocalized_Root +{ + /// + public string IetfLanguageTag => ietfLanguageTag; + + /// + public string this[string key] => fallback![key]; +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/ILocalized_Root.g.cs b/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/ILocalized_Root.g.cs new file mode 100644 index 0000000..fdf627f --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/ILocalized_Root.g.cs @@ -0,0 +1,15 @@ +#nullable enable + +using ILocalizedStringProvider = global::dotnetCampus.Localizations.ILocalizedStringProvider; +using LocalizedString = global::dotnetCampus.Localizations.LocalizedString; + +namespace dotnetCampus.Localizations.Assets.Templates; + +[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] +public interface ILocalized_Root : ILocalizedStringProvider +{ + // + // ILocalized_Root_A A => (ILocalized_Root_A)this; + // LocalizedString A1 => this.Get0("A.A1"); + // +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/Localization.g.cs b/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/Localization.g.cs new file mode 100644 index 0000000..31c737f --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/Localization.g.cs @@ -0,0 +1,16 @@ +#nullable enable + +namespace dotnetCampus.Localizations.Assets.Templates; + +partial class Localization +{ + /// + /// 获取默认的本地化字符串集。 + /// + public static ILocalized_Root Default { get; } = new LspPlaceholder("default", null); + + /// + /// 获取当前的本地化字符串集。 + /// + public static ILocalized_Root Current { get; private set; } = new LspPlaceholder("current", null); +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/LocalizationValues.g.cs b/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/LocalizationValues.g.cs new file mode 100644 index 0000000..38d242c --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/LocalizationValues.g.cs @@ -0,0 +1,25 @@ +#nullable enable + +using global::System.Collections.Frozen; + +namespace dotnetCampus.Localizations.Assets.Templates; + +/// +[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] +public class LocalizationValues(ILocalized_Root? fallback) : ILocalized_Root +{ + /// + public string IetfLanguageTag => "default"; + + /// + public string this[string key] => _strings[key] ?? fallback![key]; + + private readonly FrozenDictionary _strings = new Dictionary + { + // + { "A.A1", "文字" }, + { "A.A2", "错误码:{0}" }, + { "A.A3", "错误:{0}" }, + // + }.ToFrozenDictionary(); +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Diagnostics.cs b/src/dotnetCampus.Localizations.Analyzer/Diagnostics.cs new file mode 100644 index 0000000..268337a --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Diagnostics.cs @@ -0,0 +1,65 @@ +using Microsoft.CodeAnalysis; +using static dotnetCampus.Localizations.Properties.Localizations; + +// ReSharper disable InconsistentNaming + +namespace dotnetCampus.Localizations; + +/// +/// 包含日志库中的所有诊断。 +/// +public class Diagnostics +{ + public static DiagnosticDescriptor DL0000_UnknownError { get; } = new( + nameof(DLA000), + Localize(nameof(DLA000)), + Localize(nameof(DLA000_Message)), + Categories.Useless, + DiagnosticSeverity.Error, + true); + + private static class Categories + { + /// + /// 可能产生 bug,则报告此诊断。 + /// + public const string AvoidBugs = "dotnetCampus.AvoidBugs"; + + /// + /// 为了提供代码生成能力,则报告此诊断。 + /// + public const string CodeFixOnly = "dotnetCampus.CodeFixOnly"; + + /// + /// 因编译要求而必须满足的条件没有满足,则报告此诊断。 + /// + public const string Compiler = "dotnetCampus.Compiler"; + + /// + /// 因库内的机制限制,必须满足此要求后库才可正常工作,则报告此诊断。 + /// + public const string Mechanism = "dotnetCampus.Mechanism"; + + /// + /// 为了代码可读性,使之更易于理解、方便调试,则报告此诊断。 + /// + public const string Readable = "dotnetCampus.Readable"; + + /// + /// 为了提升性能,或避免性能问题,则报告此诊断。 + /// + public const string Performance = "dotnetCampus.Performance"; + + /// + /// 能写得出来正常编译,但会引发运行时异常,则报告此诊断。 + /// + public const string RuntimeException = "dotnetCampus.RuntimeException"; + + /// + /// 编写了无法生效的代码,则报告此诊断。 + /// + public const string Useless = "dotnetCampus.Useless"; + } + + private static LocalizableString Localize(string key) => new LocalizableResourceString(key, ResourceManager, typeof(Properties.Localizations)); +} diff --git a/src/dotnetCampus.Localizations.Analyzer/GeneratorInfo.cs b/src/dotnetCampus.Localizations.Analyzer/GeneratorInfo.cs new file mode 100644 index 0000000..020997f --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/GeneratorInfo.cs @@ -0,0 +1,18 @@ +using dotnetCampus.Localizations.Utils.IO; + +namespace dotnetCampus.Localizations; + +internal static class GeneratorInfo +{ + public static readonly string RootNamespace = typeof(GeneratorInfo).Namespace!; + + public static EmbeddedSourceFile GetEmbeddedTemplateFile() + { + var typeName = typeof(TReferenceType).Name; + var templateNamespace = typeof(TReferenceType).Namespace!; + var templatesFolder = templateNamespace.AsSpan().Slice(GeneratorInfo.RootNamespace.Length + 1).ToString(); + var embeddedFile = EmbeddedSourceFiles.Enumerate(templatesFolder) + .Single(x => x.TypeName == typeName); + return embeddedFile; + } +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/CodeTransforming/ILocalizationFileReader.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/CodeTransforming/ILocalizationFileReader.cs new file mode 100644 index 0000000..4ef096e --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Generators/CodeTransforming/ILocalizationFileReader.cs @@ -0,0 +1,16 @@ +using System.Collections.Immutable; + +namespace dotnetCampus.Localizations.Generators.CodeTransforming; + +/// +/// 将不同格式的本地化文件转换为统一的本地化项。 +/// +public interface ILocalizationFileReader +{ + /// + /// 读取本地化文件。 + /// + /// 文件内容。 + /// 此文件的所有本地化项。 + ImmutableArray Read(string content); +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/CodeTransforming/LocalizationCodeTransformer.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/CodeTransforming/LocalizationCodeTransformer.cs new file mode 100644 index 0000000..15a8ef0 --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Generators/CodeTransforming/LocalizationCodeTransformer.cs @@ -0,0 +1,209 @@ +using System.Collections.Immutable; +using dotnetCampus.Localizations.Assets.Templates; +using dotnetCampus.Localizations.Utils.CodeAnalysis; +using static dotnetCampus.Localizations.Generators.ModelProviding.IetfLanguageTagExtensions; + +namespace dotnetCampus.Localizations.Generators.CodeTransforming; + +/// +/// 提供一个与语言文件格式无关的本地化项到 C# 代码的转换器。 +/// +public class LocalizationCodeTransformer +{ + /// + /// 获取所有的本地化项。 + /// + public ImmutableArray LocalizationItems { get; } + + /// + /// 以树形式表示的所有本地化项。 + /// + /// + /// 这个属性所表示的节点是根节点,其键和值都是 null,但是其子节点包含了所有的本地化项。 + /// + private LocalizationTreeNode Tree { get; } + + /// + /// 创建 的新实例。 + /// + /// 语言文件的内容。 + /// 语言文件读取器。 + public LocalizationCodeTransformer(string content, ILocalizationFileReader reader) + { + LocalizationItems = reader.Read(content); + Tree = LocalizationTreeNode.FromList(LocalizationItems); + } + + #region Language Key Interfaces + + public string ToInterfaceCodeText(string rootNamespace) => $""" +#nullable enable + +using global::dotnetCampus.Localizations; + +using ILocalizedStringProvider = global::dotnetCampus.Localizations.ILocalizedStringProvider; +using LocalizedString = global::dotnetCampus.Localizations.LocalizedString; + +namespace {rootNamespace}.Localizations; +{RecursiveConvertLocalizationTreeNodeToKeyInterfaceCode(Tree, 0)} +"""; + + private string RecursiveConvertLocalizationTreeNodeToKeyInterfaceCode(LocalizationTreeNode node, int depth) + { + if (node.Children.Count is 0) + { + return ""; + } + + var nodeTypeName = depth is 0 + ? "Root" + : "Root_" + string.Join("_", node.FullIdentifierKey); + var propertyLines = node.Children.Select(x => + { + var identifierKey = string.Join("_", x.FullIdentifierKey); + if (x.Children.Count is 0) + { + if (x.Item.ValueArgumentTypes.Length is 0) + { + return $""" + /// + /// {x.Item.SampleValue} + /// + LocalizedString {x.IdentifierKey} => this.Get0("{x.Item.Key}"); +"""; + } + else + { + var genericTypes = string.Join(", ", x.Item.ValueArgumentTypes); + return $""" + /// + /// {x.Item.SampleValue} + /// + LocalizedString<{genericTypes}> {x.IdentifierKey} => this.Get{x.Item.ValueArgumentTypes.Length}<{genericTypes}>("{x.Item.Key}"); +"""; + } + } + else + { + return $" ILocalized_Root_{identifierKey} {x.IdentifierKey} => (ILocalized_Root_{identifierKey})this;"; + } + }); + return $$""" + +[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] +public interface ILocalized_{{nodeTypeName}} : ILocalizedStringProvider +{ +{{string.Join("\n", propertyLines)}} +} +{{string.Concat(node.Children.Select(x => RecursiveConvertLocalizationTreeNodeToKeyInterfaceCode(x, depth + 1)))}} +"""; + } + + #endregion + + #region Language Value Implementations + + public string ToImplementationCodeText(string rootNamespace, string ietfLanguageTag) + { + var typeName = IetfLanguageTagToIdentifier(ietfLanguageTag); + var template = GeneratorInfo.GetEmbeddedTemplateFile(); + var code = template.Content + .Replace($"namespace {template.Namespace};", $"namespace {rootNamespace}.Localizations;") + .Replace($"class {nameof(LocalizationValues)}", $"class {nameof(LocalizationValues)}_{typeName}") + .Replace("""IetfLanguageTag => "default";""", $"""IetfLanguageTag => "{ietfLanguageTag}";"""); + var lines = LocalizationItems.Select(x => ConvertKeyValueToValueCodeLine(x.Key, x.Value)); + code = TemplateRegexes.FlagRegex.Replace(code, string.Concat(lines)); + return code; + } + + private string ConvertKeyValueToValueCodeLine(string key, string value) + { + return $"\n {{ \"{key}\", \"{value}\" }},"; + } + + #endregion + + #region Helpers + + /// + /// 格式无关的本地化项树节点。 + /// + /// 本地化项。 + /// 适用于 C# 标识符的当前节点的键。 + /// 适用于 C# 标识符的当前节点的完整键(包含从根到此节点的完整键路径,以“_”分隔)。 + private class LocalizationTreeNode(LocalizationItem item, string identifierKey, string fullIdentifierKey) + { + /// + /// 本地化项。 + /// + public LocalizationItem Item => item; + + /// + /// 适用于 C# 标识符的当前节点的键。 + /// + public string IdentifierKey => identifierKey; + + /// + /// 适用于 C# 标识符的当前节点的完整键(包含从根到此节点的完整键路径,以“_”分隔)。 + /// + public string FullIdentifierKey => fullIdentifierKey; + + /// + /// 子节点。 + /// + public List Children { get; } = []; + + /// + /// 寻找本地化项在树中的节点,如果不存在则创建。 + /// + /// 本地化项。 + /// 本地化项在树中的节点。 + public LocalizationTreeNode GetOrCreateDescendant(LocalizationItem localizationItem) + { + var parts = localizationItem.Key.Split(['.'], StringSplitOptions.RemoveEmptyEntries); + var current = this; + for (var i = 0; i < parts.Length; i++) + { + var part = parts[i]; + var child = current.Children.FirstOrDefault(x => x.IdentifierKey == part); + if (child is null) + { + child = new LocalizationTreeNode(localizationItem, part, string.Join("_", parts.Take(i + 1))); + current.Children.Add(child); + } + current = child; + } + return current; + } + + /// + /// 从本地化项列表创建树。 + /// + /// 本地化项列表。 + /// 树的根节点。 + public static LocalizationTreeNode FromList(IReadOnlyList localizationItemList) + { + var root = new LocalizationTreeNode(default, default!, default!); + foreach (var item in localizationItemList) + { + var keyParts = item.Key.Split(['.'], StringSplitOptions.RemoveEmptyEntries); + + if (keyParts.Length is 0) + { + continue; + } + + if (keyParts.Length is 1) + { + root.Children.Add(new LocalizationTreeNode(item, item.Key, item.Key)); + continue; + } + + _ = root.GetOrCreateDescendant(item); + } + return root; + } + } + + #endregion +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/CodeTransforming/LocalizationItem.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/CodeTransforming/LocalizationItem.cs new file mode 100644 index 0000000..8f8e13e --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Generators/CodeTransforming/LocalizationItem.cs @@ -0,0 +1,22 @@ +using System.Collections.Immutable; + +namespace dotnetCampus.Localizations.Generators.CodeTransforming; + +/// +/// 本地化项。 +/// +/// 本地化项的键。 +/// 本地化项的值。 +/// +/// 有可能 是格式化字符串,此时需要提供一个示例值,用于给开发者提供参考。 +/// +/// +/// 当 是格式化字符串时,此值表示格式化字符串中的参数类型。 +/// +/// 此本地化项的注释。 +public readonly record struct LocalizationItem( + string Key, + string Value, + string? SampleValue, + ImmutableArray ValueArgumentTypes, + string? Comments); diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/CodeTransforming/YamlLocalizationFileReader.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/CodeTransforming/YamlLocalizationFileReader.cs new file mode 100644 index 0000000..c543e9b --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Generators/CodeTransforming/YamlLocalizationFileReader.cs @@ -0,0 +1,63 @@ +using System.Collections.Immutable; +using System.Text.RegularExpressions; +using YamlDotNet.RepresentationModel; + +namespace dotnetCampus.Localizations.Generators.CodeTransforming; + +/// +/// 读取单一层级 YAML 格式语言文件。 +/// +public class YamlLocalizationFileReader : ILocalizationFileReader +{ + /// + public ImmutableArray Read(string content) + { + List keyValues = []; + var yaml = new YamlStream(); + yaml.Load(new StringReader(content)); + if (yaml.Documents[0].RootNode is not YamlMappingNode node) + { + return []; + } + + keyValues.AddRange(node.Children + .Where(x => x is { Key: YamlScalarNode, Value: YamlScalarNode }) + .Select(x => CreateItem( + ((YamlScalarNode)x.Key).Value!, + ((YamlScalarNode)x.Value).Value!))); + + return [..keyValues]; + } + + /// + /// 从 YAML 中读取到的键值对创建本地化项。 + /// + /// YAML 文件中的键。 + /// YAML 文件中的值。 + /// 本地化项。 + private static LocalizationItem CreateItem(string yamlKey, string yamlValue) + { + var (valueTypes, value) = ConvertYamlValueToCodeValue(yamlValue); + return new LocalizationItem(yamlKey, value, yamlValue, valueTypes, null); + } + + /// + /// 将 YAML 文件中的语言项值转换为适用于 C# 代码中可格式化字符串的值。 + /// + /// + /// 从 YAML 文件中读取的语言项值为 "{name:string} is {age:int} years old.",转换为 ([string, int],"{0} is {1} years old.")。 + /// + /// YAML 文件中的语言项值。 + /// 适用于 C# 代码中可格式化字符串的值。 + private static (ImmutableArray Types, string Value) ConvertYamlValueToCodeValue(string value) + { + var regex = new Regex(@"\{(?[^{}:]+)(?::(?[^{}:]+))?\}"); + var matches = regex.Matches(value); + ImmutableArray types = matches.Count is 0 + ? [] + : [..matches.OfType().Select(x => x.Groups["type"].Success ? x.Groups["type"].Value : "object")]; + var index = 0; + var v = regex.Replace(value, _ => $"{{{index++}}}"); + return (types, v); + } +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationCurrentGenerator.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationCurrentGenerator.cs new file mode 100644 index 0000000..48466f1 --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationCurrentGenerator.cs @@ -0,0 +1,62 @@ +using System.Collections.Immutable; +using System.Text; +using dotnetCampus.Localizations.Assets.Templates; +using dotnetCampus.Localizations.Generators.ModelProviding; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +using static dotnetCampus.Localizations.Generators.ModelProviding.IetfLanguageTagExtensions; + +namespace dotnetCampus.Localizations.Generators; + +/// +/// 为静态的本地化中心类生成设置当前语言的方法。 +/// +[Generator] +public class LocalizationCurrentGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var localizationFilesProvider = context.AdditionalTextsProvider.SelectLocalizationFileModels(); + var localizationTypeprovider = context.SyntaxProvider.SelectGeneratingModels(); + context.RegisterSourceOutput(localizationTypeprovider.Combine(localizationFilesProvider.Collect()), Execute); + } + + private void Execute(SourceProductionContext context, (LocalizationGeneratingModel Left, ImmutableArray Right) modelTuple) + { + var ((typeNamespace, typeName, _, _), models) = modelTuple; + + var code = GenerateSetCurrentMethod(typeNamespace, typeName, models); + + context.AddSource($"{typeName}.g.cs", SourceText.From(code, Encoding.UTF8)); + } + + private string GenerateSetCurrentMethod(string typeNamespace, string typeName, ImmutableArray models) => $$""" +#nullable enable + +namespace {{typeNamespace}}; + +partial class {{typeName}} +{ + /// + /// 设置当前的本地化字符串集。 + /// + /// 要设置的 IETF 语言标签。 + public static void SetCurrent(string ietfLanguageTag) + { + Current = ietfLanguageTag switch + { +{{string.Join("\n", models.Select(x => ConvertModelToPatternMatch(typeNamespace, x)))}} + _ => throw new global::System.ArgumentException($"The language tag {ietfLanguageTag} is not supported.", nameof(ietfLanguageTag)), + }; + } +} + +"""; + + private string ConvertModelToPatternMatch(string typeNamespace, LocalizationFileModel model) + { + // "zh-hans" => new Lang_ZhHans(Default), + return $" \"{model.IetfLanguageTag}\" => new global::{typeNamespace}.Localizations.{nameof(LocalizationValues)}_{IetfLanguageTagToIdentifier(model.IetfLanguageTag)}(Default),"; + } +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationFilesGenerator.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationFilesGenerator.cs new file mode 100644 index 0000000..05c2401 --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationFilesGenerator.cs @@ -0,0 +1,39 @@ +using System.Collections.Immutable; +using System.Text; +using dotnetCampus.Localizations.Assets.Templates; +using dotnetCampus.Localizations.Generators.CodeTransforming; +using dotnetCampus.Localizations.Generators.ModelProviding; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace dotnetCampus.Localizations.Generators; + +/// +/// 为所有通过 LocalizationFile 指定的文件生成对应的 C# 代码。 +/// +[Generator] +public class LocalizationFilesGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var localizationFilesProvider = context.AdditionalTextsProvider.SelectLocalizationFileModels(); + var localizationTypeprovider = context.SyntaxProvider.SelectGeneratingModels(); + context.RegisterSourceOutput(localizationFilesProvider.Combine(localizationTypeprovider.Collect()), Execute); + } + + private void Execute(SourceProductionContext context, (LocalizationFileModel Left, ImmutableArray Right) modelTuple) + { + var ((_, ietfLanguageTag, textContent), localizationGeneratingModels) = modelTuple; + var options = localizationGeneratingModels.FirstOrDefault(); + + var transformer = new LocalizationCodeTransformer(textContent, new YamlLocalizationFileReader()); + var code = transformer.ToImplementationCodeText(options.Namespace, ietfLanguageTag); + context.AddSource($"{nameof(LocalizationValues)}.{ietfLanguageTag}.g.cs", SourceText.From(code, Encoding.UTF8)); + + if (ietfLanguageTag == options.DefaultLanguage) + { + var keyCode = transformer.ToInterfaceCodeText(options.Namespace); + context.AddSource($"{nameof(ILocalized_Root)}.g.cs", SourceText.From(keyCode, Encoding.UTF8)); + } + } +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationGenerator.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationGenerator.cs new file mode 100644 index 0000000..4533bff --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationGenerator.cs @@ -0,0 +1,78 @@ +using System.Text; +using dotnetCampus.Localizations.Assets.Templates; +using dotnetCampus.Localizations.Generators.ModelProviding; +using dotnetCampus.Localizations.Utils.CodeAnalysis; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +using static dotnetCampus.Localizations.Generators.ModelProviding.IetfLanguageTagExtensions; + +namespace dotnetCampus.Localizations.Generators; + +/// +/// 为静态的本地化中心类生成分部实现。 +/// +/// +/// 本生成器会为下方示例中的类型生成分部实现: +/// +/// [LocalizedConfiguration(Default = "zh-hans", Current = "zh-hans")] +/// public static partial class Lang; +/// +/// +[Generator] +public class LocalizationGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var localizationTypeprovider = context.SyntaxProvider.SelectGeneratingModels(); + context.RegisterSourceOutput(localizationTypeprovider, Execute); + } + + private void Execute(SourceProductionContext context, LocalizationGeneratingModel model) + { + var (typeNamespace, typeName, defaultLanguage, currentLanguage) = model; + var defaultLanguageIdentifier = IetfLanguageTagToIdentifier(defaultLanguage); + var currentLanguageIdentifier = IetfLanguageTagToIdentifier(currentLanguage); + + var localizationFile = GeneratorInfo.GetEmbeddedTemplateFile(); + var originalText = ReplaceNamespaceAndTypeName(localizationFile.Content, typeNamespace, typeName); + var code = originalText + .Replace("""ILocalized_Root Default { get; } = new LspPlaceholder("default", null)""", + $"global::{typeNamespace}.Localizations.ILocalized_Root Default {{ get; }} = new global::{typeNamespace}.Localizations.{nameof(LocalizationValues)}_{defaultLanguageIdentifier}(null)") + .Replace("""ILocalized_Root Current { get; private set; } = new LspPlaceholder("current", null)""", defaultLanguage == currentLanguage + ? $"global::{typeNamespace}.Localizations.ILocalized_Root Current {{ get; private set; }} = Default" + : $"global::{typeNamespace}.Localizations.ILocalized_Root Current {{ get; private set; }} = new global::{typeNamespace}.Localizations.{nameof(LocalizationValues)}_{currentLanguageIdentifier}(Default)"); + + context.AddSource($"{typeName}.g.cs", SourceText.From(code, Encoding.UTF8)); + } + + private static string ReplaceNamespaceAndTypeName(string sourceText, string rootNamespace, string? typeName) + { + var sourceSpan = sourceText.AsSpan(); + + var namespaceKeywordIndex = sourceText.IndexOf("namespace", StringComparison.Ordinal); + var namespaceStartIndex = namespaceKeywordIndex + "namespace".Length + 1; + var namespaceEndIndex = sourceText.IndexOf(";", namespaceStartIndex, StringComparison.Ordinal); + var typeKeywordMatch = TemplateRegexes.TypeRegex.Match(sourceText); + + if (typeName is null || !typeKeywordMatch.Success) + { + return string.Concat( + sourceSpan.Slice(0, namespaceStartIndex).ToString(), + rootNamespace, + sourceSpan.Slice(namespaceEndIndex, sourceSpan.Length - namespaceEndIndex).ToString() + ); + } + + var typeNameIndex = typeKeywordMatch.Groups[1].Index; + var typeNameLength = typeKeywordMatch.Groups[1].Length; + + return string.Concat( + sourceSpan.Slice(0, namespaceStartIndex).ToString(), + rootNamespace, + sourceSpan.Slice(namespaceEndIndex, typeNameIndex - namespaceEndIndex).ToString(), + typeName, + sourceSpan.Slice(typeNameIndex + typeNameLength, sourceSpan.Length - typeNameIndex - typeNameLength).ToString() + ); + } +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/ModelProviding/IetfLanguageTagExtensions.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/ModelProviding/IetfLanguageTagExtensions.cs new file mode 100644 index 0000000..9af0b15 --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Generators/ModelProviding/IetfLanguageTagExtensions.cs @@ -0,0 +1,40 @@ +namespace dotnetCampus.Localizations.Generators.ModelProviding; + +/// +/// IETF 语言标签扩展方法。 +/// +public static class IetfLanguageTagExtensions +{ + /// + /// 将 IETF 语言标签转换为适用于 C# 标识符的字符串。 + /// + /// IETF 语言标签。 + /// 适用于 C# 标识符的字符串。 + public static string IetfLanguageTagToIdentifier(string ietfLanguageTag) + { + Span identifier = stackalloc char[ietfLanguageTag.Length]; + var isPartStart = true; + var identifierIndex = 0; + + foreach (var c in ietfLanguageTag) + { + if (c is '-') + { + isPartStart = true; + continue; + } + + if (isPartStart) + { + identifier[identifierIndex++] = char.ToUpperInvariant(c); + isPartStart = false; + } + else + { + identifier[identifierIndex++] = char.ToLowerInvariant(c); + } + } + + return identifier.Slice(0, identifierIndex).ToString(); + } +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/ModelProviding/LocalizationFileModel.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/ModelProviding/LocalizationFileModel.cs new file mode 100644 index 0000000..f9620b4 --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Generators/ModelProviding/LocalizationFileModel.cs @@ -0,0 +1,9 @@ +namespace dotnetCampus.Localizations.Generators.ModelProviding; + +/// +/// 本地化语言文件模型。 +/// +/// 本地化语言文件的类型。 +/// 此本地化语言文件所对应的 IETF 语言标签。 +/// 本地化语言文件的内容。 +public readonly record struct LocalizationFileModel(string Type, string IetfLanguageTag, string Content); diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/ModelProviding/LocalizationGeneratingModel.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/ModelProviding/LocalizationGeneratingModel.cs new file mode 100644 index 0000000..04feed7 --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Generators/ModelProviding/LocalizationGeneratingModel.cs @@ -0,0 +1,10 @@ +namespace dotnetCampus.Localizations.Generators.ModelProviding; + +/// +/// 开发者在代码中指定应基于某个类型生成本地化文件时,此模型表示了开发者所指定的所有需要的生成参数。 +/// +/// 类型的命名空间。 +/// 类型的名称。 +/// 默认语言的 IETF 语言标签。 +/// 当前语言的 IETF 语言标签。 +public readonly record struct LocalizationGeneratingModel(string Namespace, string TypeName, string DefaultLanguage, string CurrentLanguage); diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/ModelProviding/LocalizationGeneratingModelExtensions.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/ModelProviding/LocalizationGeneratingModelExtensions.cs new file mode 100644 index 0000000..5686809 --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Generators/ModelProviding/LocalizationGeneratingModelExtensions.cs @@ -0,0 +1,62 @@ +using System.Collections.Immutable; +using dotnetCampus.Localizations.Assets.Templates; +using dotnetCampus.Localizations.Utils.CodeAnalysis; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace dotnetCampus.Localizations.Generators.ModelProviding; + +/// +/// 为 提供扩展方法。 +/// +public static class LocalizationGeneratingModelExtensions +{ + public static IncrementalValuesProvider SelectLocalizationFileModels(this IncrementalValuesProvider provider) => + provider.Where(x => + x.Path.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) + || x.Path.EndsWith(".yml", StringComparison.OrdinalIgnoreCase)) + .Select((x, ct) => + { + var name = Path.GetFileNameWithoutExtension(x.Path); + var text = x.GetText(ct)!.ToString(); + return new LocalizationFileModel("yaml", name, text); + }); + + /// + /// 从增量源生成器的语法值提供器中挑选出所有的 。 + /// + /// 语法值提供器。 + /// 增量值提供器。 + public static IncrementalValuesProvider SelectGeneratingModels(this SyntaxValueProvider syntaxValueProvider) => + syntaxValueProvider.ForAttributeWithMetadataName(typeof(LocalizedConfigurationAttribute).FullName!, (node, ct) => + { + if (node is not ClassDeclarationSyntax cds) + { + // 必须是类型。 + return false; + } + + if (!cds.Modifiers.Any(SyntaxKind.PartialKeyword)) + { + // 必须是分部类。 + return false; + } + + return true; + }, (c, ct) => + { + var typeSymbol = c.TargetSymbol; + var rootNamespace = typeSymbol.ContainingNamespace.ToDisplayString(); + var typeName = typeSymbol.Name; + var attribute = typeSymbol.GetAttributes() + .FirstOrDefault(a => a.AttributeClass!.IsAttributeOf()); + var x = attribute!.ConstructorArguments.ToImmutableArray(); + var namedArguments = attribute!.NamedArguments.ToImmutableDictionary(); + var defaultLanguage = namedArguments.GetValueOrDefault(nameof(Localization.Default)).Value?.ToString()!; + var currentLanguage = namedArguments.GetValueOrDefault(nameof(Localization.Current)).Value?.ToString()!; + + // 创建模型时,分析器确保了这些值不为空。 + return new LocalizationGeneratingModel(rootNamespace, typeName, defaultLanguage, currentLanguage); + }); +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Properties/Localizations.Designer.cs b/src/dotnetCampus.Localizations.Analyzer/Properties/Localizations.Designer.cs new file mode 100644 index 0000000..4b6ba01 --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Properties/Localizations.Designer.cs @@ -0,0 +1,77 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace dotnetCampus.Localizations.Properties { + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Localizations { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Localizations() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("dotnetCampus.Logger.Properties.Localizations", typeof(Localizations).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Unknown Error. + /// + internal static string DLA000 { + get { + return ResourceManager.GetString("DLA000", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to An unknown error occurred.. + /// + internal static string DLA000_Message { + get { + return ResourceManager.GetString("DLA000_Message", resourceCulture); + } + } + } +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Properties/Localizations.resx b/src/dotnetCampus.Localizations.Analyzer/Properties/Localizations.resx new file mode 100644 index 0000000..e4ca074 --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Properties/Localizations.resx @@ -0,0 +1,32 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + Unknown Error + + + An unknown error occurred. {0} + + diff --git a/src/dotnetCampus.Localizations.Analyzer/Properties/Localizations.zh-hans.resx b/src/dotnetCampus.Localizations.Analyzer/Properties/Localizations.zh-hans.resx new file mode 100644 index 0000000..b795d88 --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Properties/Localizations.zh-hans.resx @@ -0,0 +1,24 @@ + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + 未知错误 + + + 发生了未知错误。{0} + + diff --git a/src/dotnetCampus.Localizations.Analyzer/Properties/Localizations.zh-hant.resx b/src/dotnetCampus.Localizations.Analyzer/Properties/Localizations.zh-hant.resx new file mode 100644 index 0000000..ef711e6 --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Properties/Localizations.zh-hant.resx @@ -0,0 +1,24 @@ + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + 未知錯誤 + + + 發生了未知錯誤。{0} + + diff --git a/src/dotnetCampus.Localizations.Analyzer/Utils/CodeAnalysis/AnalyzerConfigOptionsExtensions.cs b/src/dotnetCampus.Localizations.Analyzer/Utils/CodeAnalysis/AnalyzerConfigOptionsExtensions.cs new file mode 100644 index 0000000..3dbfc89 --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Utils/CodeAnalysis/AnalyzerConfigOptionsExtensions.cs @@ -0,0 +1,86 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace dotnetCampus.Localizations.Utils.CodeAnalysis; + +internal static class AnalyzerConfigOptionsExtensions +{ + public static AnalyzerConfigOptionResult TryGetValue( + this AnalyzerConfigOptions options, + string key, + out T value) + where T : notnull + { + if (options.TryGetValue($"build_property.{key}", out var stringValue)) + { + value = ConvertFromString(stringValue); + return new AnalyzerConfigOptionResult(options, true) + { + UnsetPropertyNames = [], + }; + } + + value = default!; + return new AnalyzerConfigOptionResult(options, false) + { + UnsetPropertyNames = [key], + }; + } + + public static AnalyzerConfigOptionResult TryGetValue( + this AnalyzerConfigOptionResult builder, + string key, + out T value) + where T : notnull + { + var options = builder.Options; + + if (options.TryGetValue($"build_property.{key}", out var stringValue)) + { + value = ConvertFromString(stringValue); + return builder.Link(true, key); + } + + value = default!; + return builder.Link(false, key); + } + + private static T ConvertFromString(string value) + { + if (typeof(T) == typeof(string)) + { + return (T)(object)value; + } + if (typeof(T) == typeof(bool)) + { + return (T)(object)value.Equals("true", StringComparison.OrdinalIgnoreCase); + } + return default!; + } +} + +public readonly record struct AnalyzerConfigOptionResult(AnalyzerConfigOptions Options, bool GotValue) +{ + public required ImmutableList UnsetPropertyNames { get; init; } + + public AnalyzerConfigOptionResult Link(bool result, string propertyName) + { + if (result) + { + return this; + } + + if (propertyName is null) + { + throw new ArgumentNullException(nameof(propertyName), @"The property name must be specified if the result is false."); + } + + return this with + { + GotValue = false, + UnsetPropertyNames = UnsetPropertyNames.Add(propertyName), + }; + } + + public static implicit operator bool(AnalyzerConfigOptionResult result) => result.GotValue; +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Utils/CodeAnalysis/AttributeExtensions.cs b/src/dotnetCampus.Localizations.Analyzer/Utils/CodeAnalysis/AttributeExtensions.cs new file mode 100644 index 0000000..24ca75e --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Utils/CodeAnalysis/AttributeExtensions.cs @@ -0,0 +1,48 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace dotnetCampus.Localizations.Utils.CodeAnalysis; + +public static class AttributeExtensions +{ + public static bool IsAttributeOf(this AttributeSyntax attribute) + { + var codeName = attribute.Name.ToString(); + var compareName = typeof(TAttribute).Name; + if (codeName == compareName) + { + return true; + } + + if (compareName.EndsWith("Attribute")) + { + compareName = compareName.Substring(0, compareName.Length - "Attribute".Length); + if (codeName == compareName) + { + return true; + } + } + + return false; + } + + public static bool IsAttributeOf(this INamedTypeSymbol attribute) + { + var compareName = typeof(TAttribute).Name; + if (attribute.Name == compareName) + { + return true; + } + + if (compareName.EndsWith("Attribute")) + { + compareName = compareName.Substring(0, compareName.Length - "Attribute".Length); + if (attribute.Name == compareName) + { + return true; + } + } + + return false; + } +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Utils/CodeAnalysis/DiagnosticExtensions.cs b/src/dotnetCampus.Localizations.Analyzer/Utils/CodeAnalysis/DiagnosticExtensions.cs new file mode 100644 index 0000000..b4ab4f0 --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Utils/CodeAnalysis/DiagnosticExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.CodeAnalysis; + +namespace dotnetCampus.Localizations.Utils.CodeAnalysis; + +public static class DiagnosticExtensions +{ + public static void ReportUnknownError(this SourceProductionContext context, string message) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.DL0000_UnknownError, + null, + message)); + } +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Utils/CodeAnalysis/RecordImmutableArray.cs b/src/dotnetCampus.Localizations.Analyzer/Utils/CodeAnalysis/RecordImmutableArray.cs new file mode 100644 index 0000000..22fa5f6 --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Utils/CodeAnalysis/RecordImmutableArray.cs @@ -0,0 +1,105 @@ +using System.Collections; +using System.Collections.Immutable; + +namespace dotnetCampus.Localizations.Utils.CodeAnalysis; + +public readonly record struct RecordImmutableArray : IReadOnlyList, IList, IEqualityComparer>, IEqualityComparer +{ + public static RecordImmutableArray Empty { get; } = new(ImmutableArray.Empty); + + public readonly ImmutableArray _array; + + public RecordImmutableArray(ImmutableArray array) => _array = array; + + public RecordImmutableArray(IEnumerable array) => _array = [..array]; + + public T this[int index] => _array[index]; + + public int Count => _array.Length; + + public bool Contains(T item) => _array.Contains(item); + + public void CopyTo(T[] array, int arrayIndex) => _array.CopyTo(array, arrayIndex); + + public int IndexOf(T item) => _array.IndexOf(item); + + bool ICollection.IsReadOnly => true; + + public IEnumerator GetEnumerator() => ((IEnumerable)_array).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + void ICollection.Add(T item) => throw new NotSupportedException(); + + void ICollection.Clear() => throw new NotSupportedException(); + + void IList.Insert(int index, T item) => throw new NotSupportedException(); + + bool ICollection.Remove(T item) => throw new NotSupportedException(); + + void IList.RemoveAt(int index) => throw new NotSupportedException(); + + T IList.this[int index] + { + get => this[index]; + set => throw new NotSupportedException(); + } + + public bool Equals(RecordImmutableArray x, RecordImmutableArray y) + { + if (x.Count != y.Count) + { + return false; + } + + if (x.Count is 0 && y.Count is 0) + { + return true; + } + + for (var i = 0; i < x.Count; i++) + { + var xItem = x[i]; + var yItem = y[i]; + if (xItem is null && yItem is null) + { + continue; + } + if (xItem is null || yItem is null) + { + return false; + } + if (!xItem.Equals(yItem)) + { + return false; + } + } + + return true; + } + + public int GetHashCode(RecordImmutableArray obj) + { + return _array.GetHashCode(); + } + + bool IEqualityComparer.Equals(object x, object y) + { + if (x is RecordImmutableArray xArray && y is RecordImmutableArray yArray) + { + return Equals(xArray, yArray); + } + + return false; + } + + int IEqualityComparer.GetHashCode(object obj) + { + if (obj is RecordImmutableArray array) + { + return GetHashCode(array); + } + + return obj?.GetHashCode() ?? 0; + } +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Utils/CodeAnalysis/TemplateRegexes.cs b/src/dotnetCampus.Localizations.Analyzer/Utils/CodeAnalysis/TemplateRegexes.cs new file mode 100644 index 0000000..069cc2f --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Utils/CodeAnalysis/TemplateRegexes.cs @@ -0,0 +1,31 @@ +using System.Text.RegularExpressions; + +namespace dotnetCampus.Localizations.Utils.CodeAnalysis; + +/// +/// 为生成源代码提供正则表达式。 +/// +public static class TemplateRegexes +{ + private static Regex? _typeRegex; + private static Regex? _flagRegex; + + /// + /// 匹配类型名称。 + /// + /// + /// 对于 class MyClass,匹配到 MyClass。 + /// + /// + /// 类型可以是 classrecordstructenuminterface。 + /// + public static Regex TypeRegex => _typeRegex ??= GetTypeRegex(); + + /// + /// 匹配代码中的 // ... 注释。这些注释出现在代码中用于指示即将在这里生成一些代码。 + /// + public static Regex FlagRegex => _flagRegex ??= GetFlagRegex(); + + private static Regex GetFlagRegex() => _flagRegex ??= new Regex(@"\s+// .+?", RegexOptions.Compiled | RegexOptions.Singleline); + private static Regex GetTypeRegex() => _typeRegex ??= new Regex(@"\b(?:class|record|struct|enum|interface)\s([\w_]+)\b", RegexOptions.Compiled); +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Utils/IO/EmbeddedSourceFile.cs b/src/dotnetCampus.Localizations.Analyzer/Utils/IO/EmbeddedSourceFile.cs new file mode 100644 index 0000000..63c39fa --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Utils/IO/EmbeddedSourceFile.cs @@ -0,0 +1,14 @@ +namespace dotnetCampus.Localizations.Utils.IO; + +/// +/// 嵌入的文本资源的数据。 +/// +/// 文件的名称(含扩展名)。 +/// 文件的名称(不含扩展名),或者也很可能是类型名称。 +/// 文件的命名空间。 +/// 文件的文本内容。 +internal readonly record struct EmbeddedSourceFile( + string FileName, + string TypeName, + string Namespace, + string Content); diff --git a/src/dotnetCampus.Localizations.Analyzer/Utils/IO/EmbeddedSourceFiles.cs b/src/dotnetCampus.Localizations.Analyzer/Utils/IO/EmbeddedSourceFiles.cs new file mode 100644 index 0000000..c76bfcd --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Utils/IO/EmbeddedSourceFiles.cs @@ -0,0 +1,54 @@ +using System.Reflection; + +namespace dotnetCampus.Localizations.Utils.IO; + +/// +/// 从嵌入的资源中寻找源代码。 +/// +internal static class EmbeddedSourceFiles +{ + /// + /// 寻找 文件夹下的源代码名称和内容。 + /// + /// 资源文件夹名称。请以“/”或“\”分隔文件夹。 + /// + internal static IEnumerable Enumerate(string folderName) + { + // 资源字符串格式为:"{Namespace}.{Folder}.{filename}.{Extension}" + var desiredFolder = $"{GeneratorInfo.RootNamespace}.{folderName}"; + var assembly = Assembly.GetExecutingAssembly(); + foreach (var resourceName in assembly.GetManifestResourceNames()) + { + var prefix = desiredFolder.Replace('/', '.').Replace('\\', '.') + "."; + if (resourceName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + using var stream = assembly.GetManifestResourceStream(resourceName)!; + using var reader = new StreamReader(stream); + var contentText = reader.ReadToEnd(); + + var fileName = resourceName.AsSpan().Slice(prefix.Length).ToString(); + var fileNameWithoutExtension = fileName.Replace(".g.cs", "").Replace(".cs", ""); + var fileNameIndex = fileNameWithoutExtension.LastIndexOf('.'); + if (fileNameIndex < 0) + { + yield return new EmbeddedSourceFile( + fileName, + fileNameWithoutExtension, + desiredFolder, + contentText); + } + else + { + var typeName = fileNameWithoutExtension.Substring(fileNameIndex + 1); + var @namespace = $"{desiredFolder}.{fileNameWithoutExtension.Substring(0, fileNameIndex)}"; + + yield return new EmbeddedSourceFile( + fileName, + typeName, + @namespace, + contentText); + } + } + } + } +} diff --git a/src/dotnetCampus.Localizations.Analyzer/dotnetCampus.Localizations.Analyzer.csproj b/src/dotnetCampus.Localizations.Analyzer/dotnetCampus.Localizations.Analyzer.csproj new file mode 100644 index 0000000..8b85bc3 --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/dotnetCampus.Localizations.Analyzer.csproj @@ -0,0 +1,28 @@ + + + + netstandard2.0 + false + true + true + dotnetCampus.Localizations + + + + + + + + + + + + + + + + + + + + diff --git a/src/dotnetCampus.Localizations/ILocalizedStringProvider.cs b/src/dotnetCampus.Localizations/ILocalizedStringProvider.cs new file mode 100644 index 0000000..c871f9d --- /dev/null +++ b/src/dotnetCampus.Localizations/ILocalizedStringProvider.cs @@ -0,0 +1,19 @@ +namespace dotnetCampus.Localizations; + +/// +/// 为源生成器生成的本地化字符串提供统一的访问接口。 +/// +public interface ILocalizedStringProvider +{ + /// + /// 获取符合 IETF 规范的语言标签。 + /// + public string IetfLanguageTag { get; } + + /// + /// 获取指定键的本地化字符串。 + /// + /// 要获取的本地化字符串的键。 + /// 一个无参的本地化字符串。 + string this[string key] { get; } +} diff --git a/src/dotnetCampus.Localizations/LocalizedConfigurationAttribute.cs b/src/dotnetCampus.Localizations/LocalizedConfigurationAttribute.cs new file mode 100644 index 0000000..0617718 --- /dev/null +++ b/src/dotnetCampus.Localizations/LocalizedConfigurationAttribute.cs @@ -0,0 +1,18 @@ +namespace dotnetCampus.Localizations; + +/// +/// 在一个分部静态类上标记,可以为此静态类生成本地化语言项。 +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public class LocalizedConfigurationAttribute : Attribute +{ + /// + /// 指定默认语言。当任何一个语言项未找到时,将使用此语言项。 + /// + public required string Default { get; init; } + + /// + /// 指定开发时所用的当前语言。可以在语言项的文档注释中查看此语言项的文本。 + /// + public required string Current { get; init; } +} diff --git a/src/dotnetCampus.Localizations/LocalizedString.cs b/src/dotnetCampus.Localizations/LocalizedString.cs new file mode 100644 index 0000000..1307f51 --- /dev/null +++ b/src/dotnetCampus.Localizations/LocalizedString.cs @@ -0,0 +1,97 @@ +namespace dotnetCampus.Localizations; + +/// +/// 表示一个本地化字符串,可隐式转换为字符串。 +/// +/// 多语言键。 +/// 多语言值。 +public readonly record struct LocalizedString(string Key, string Value) +{ + /// + /// 隐式转换为字符串。 + /// + /// 要转换的本地化字符串。 + public static implicit operator string(LocalizedString localizedString) => localizedString.Value; + + /// + /// 将本地化字符串转换为字符串。 + /// + /// 转换的字符串。 + public override string ToString() => Value; +} + +/// +/// 表示一个带有一个参数的本地化字符串。 +/// +/// 参数类型。 +/// 多语言键。 +/// 多语言值。 +public readonly record struct LocalizedString(string Key, string Value) +{ + /// + /// 格式化本地化字符串。 + /// + /// 要格式化的参数。 + /// 格式化后的字符串。 + public string Format(T1 arg1) => string.Format(Value, arg1); +} + +/// +/// 表示一个带有两个参数的本地化字符串。 +/// +/// 第一个参数类型。 +/// 第二个参数类型。 +/// 多语言键。 +/// 多语言值。 +public readonly record struct LocalizedString(string Key, string Value) +{ + /// + /// 格式化本地化字符串。 + /// + /// 要格式化的参数。 + /// 要格式化的参数。 + /// 格式化后的字符串。 + public string Format(T1 arg1, T2 arg2) => string.Format(Value, arg1, arg2); +} + +/// +/// 表示一个带有三个参数的本地化字符串。 +/// +/// 第一个参数类型。 +/// 第二个参数类型。 +/// 第三个参数类型。 +/// 多语言键。 +/// 多语言值。 +public readonly record struct LocalizedString(string Key, string Value) +{ + /// + /// 格式化本地化字符串。 + /// + /// 要格式化的参数。 + /// 要格式化的参数。 + /// 要格式化的参数。 + /// 格式化后的字符串。 + public string Format(T1 arg1, T2 arg2, T3 arg3) => string.Format(Value, arg1, arg2, arg3); +} + +/// +/// 表示一个带有四个参数的本地化字符串。 +/// +/// 第一个参数类型。 +/// 第二个参数类型。 +/// 第三个参数类型。 +/// 第四个参数类型。 +/// 多语言键。 +/// 多语言值。 +public readonly record struct LocalizedString(string Key, string Value) +{ + /// + /// 格式化本地化字符串。 + /// + /// 要格式化的参数。 + /// 要格式化的参数。 + /// 要格式化的参数。 + /// 要格式化的参数。 + /// 格式化后的字符串。 + public string Format(T1 arg1, T2 arg2, T3 arg3, T4 arg4) => string.Format(Value, arg1, arg2, arg3, arg4); +} diff --git a/src/dotnetCampus.Localizations/LocalizedStringProviderExtensions.cs b/src/dotnetCampus.Localizations/LocalizedStringProviderExtensions.cs new file mode 100644 index 0000000..bf35954 --- /dev/null +++ b/src/dotnetCampus.Localizations/LocalizedStringProviderExtensions.cs @@ -0,0 +1,62 @@ +namespace dotnetCampus.Localizations; + +/// +/// 为 提供带有本地化类型返回值的扩展方法。 +/// +public static class LocalizedStringProviderExtensions +{ + /// + /// 获取指定键的本地化字符串。 + /// + /// 本地化字符串提供者。 + /// 要获取的本地化字符串的键。 + /// 一个无参的本地化字符串。 + public static LocalizedString Get0(this ILocalizedStringProvider provider, string key) => + new LocalizedString(key, provider[key]); + + /// + /// 获取指定键的有一个参数的本地化字符串。 + /// + /// 参数类型。 + /// 本地化字符串提供者。 + /// 要获取的本地化字符串的键。 + /// 有一个参数的本地化字符串。 + public static LocalizedString Get1(this ILocalizedStringProvider provider, string key) => + new LocalizedString(key, provider[key]); + + /// + /// 获取指定键的有两个参数的本地化字符串。 + /// + /// 第一个参数类型。 + /// 第二个参数类型。 + /// 本地化字符串提供者。 + /// 要获取的本地化字符串的键。 + /// 有两个参数的本地化字符串。 + public static LocalizedString Get2(this ILocalizedStringProvider provider, string key) => + new LocalizedString(key, provider[key]); + + /// + /// 获取指定键的有三个参数的本地化字符串。 + /// + /// 第一个参数类型。 + /// 第二个参数类型。 + /// 第三个参数类型。 + /// 本地化字符串提供者。 + /// 要获取的本地化字符串的键。 + /// 有三个参数的本地化字符串。 + public static LocalizedString Get3(this ILocalizedStringProvider provider, string key) => + new LocalizedString(key, provider[key]); + + /// + /// 获取指定键的有四个参数的本地化字符串。 + /// + /// 第一个参数类型。 + /// 第二个参数类型。 + /// 第三个参数类型。 + /// 第四个参数类型。 + /// 本地化字符串提供者。 + /// 要获取的本地化字符串的键。 + /// 有四个参数的本地化字符串。 + public static LocalizedString Get4(this ILocalizedStringProvider provider, string key) => + new LocalizedString(key, provider[key]); +} diff --git a/src/dotnetCampus.Localizations/Package/build/Package.props b/src/dotnetCampus.Localizations/Package/build/Package.props new file mode 100644 index 0000000..44d225a --- /dev/null +++ b/src/dotnetCampus.Localizations/Package/build/Package.props @@ -0,0 +1,7 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + + + diff --git a/src/dotnetCampus.Localizations/Package/build/Package.targets b/src/dotnetCampus.Localizations/Package/build/Package.targets new file mode 100644 index 0000000..eb9a440 --- /dev/null +++ b/src/dotnetCampus.Localizations/Package/build/Package.targets @@ -0,0 +1,26 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + + + + $(MSBuildThisFileDirectory)..\tasks\netstandard2.0\dotnetCampus.Localizations.Tasks.dll + + + + + + <_DLangRootNamespace>$(RootNamespace) + <_DLangRootNamespace Condition=" '$(_DLangRootNamespace)' == '' ">$(MSBuildProjectName.Replace(" ", "_")) + + + + + + + + + + + diff --git a/src/dotnetCampus.Localizations/dotnetCampus.Localizations.csproj b/src/dotnetCampus.Localizations/dotnetCampus.Localizations.csproj new file mode 100644 index 0000000..4d2b07b --- /dev/null +++ b/src/dotnetCampus.Localizations/dotnetCampus.Localizations.csproj @@ -0,0 +1,52 @@ + + + + + net8.0 + + + + + true + dotnetCampus.SourceLocalizations + true + true + + $(NoWarn);NU5100 + + + + + true + true + true + true + snupkg + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/dotnetCampus.Localizations.Tests/UnitTest1.cs b/tests/dotnetCampus.Localizations.Tests/UnitTest1.cs new file mode 100644 index 0000000..438b253 --- /dev/null +++ b/tests/dotnetCampus.Localizations.Tests/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace dotnetCampus.Localizations.Tests; + +[TestClass] +public class UnitTest1 +{ + [TestMethod("Add test description here")] + public void TestMethod1() + { + } +} diff --git a/tests/dotnetCampus.Localizations.Tests/dotnetCampus.Localizations.Tests.csproj b/tests/dotnetCampus.Localizations.Tests/dotnetCampus.Localizations.Tests.csproj new file mode 100644 index 0000000..3309c93 --- /dev/null +++ b/tests/dotnetCampus.Localizations.Tests/dotnetCampus.Localizations.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + false + true + dotnetCampus.Localizations.Tests + + + + + + + + + + + + + + + + + + +