diff --git a/Directory.Build.props b/Directory.Build.props index 544d9346..bee62dff 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,6 @@ latest enable enable - git WeihanLi Copyright 2017-$([System.DateTime]::Now.Year) (c) WeihanLi $(NoWarn);NU5048;CS1591;NETSDK1057 diff --git a/Directory.Packages.props b/Directory.Packages.props index f01ffd0b..df472839 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,7 +5,7 @@ 6.0.0 7.0.0 8.0.0 - 9.0.0-preview.7.24405.7 + 9.0.0-rc.1.24431.7 @@ -17,13 +17,13 @@ - + - + - - + + diff --git a/README.md b/README.md index 0b15fc71..c96b1c36 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ - Logging Framework(结合Serilog/微软日志框架实现的日志框架) - Dapper-like Ado.Net extensions(类似 Dapper 的 Ado.Net 扩展) - TOTP implement(TOTP算法实现) +- Template Engine(自定义模板引擎) - and more ... ## Release Notes diff --git a/build/version.props b/build/version.props index 249bcd7c..e2759345 100644 --- a/build/version.props +++ b/build/version.props @@ -2,7 +2,7 @@ 1 0 - 69 + 70 $(VersionMajor).$(VersionMinor).$(VersionPatch) diff --git a/samples/DotNetCoreSample/Program.cs b/samples/DotNetCoreSample/Program.cs index 3d07a9a2..9bcfc384 100644 --- a/samples/DotNetCoreSample/Program.cs +++ b/samples/DotNetCoreSample/Program.cs @@ -343,7 +343,9 @@ // await InvokeHelper.TryInvokeAsync(EventTest.MainTest); -InvokeHelper.TryInvoke(CommandExecutorTest.MainTest); +// InvokeHelper.TryInvoke(CommandExecutorTest.MainTest); + +await InvokeHelper.TryInvokeAsync(TemplatingSample.MainTest); ConsoleHelper.ReadKeyWithPrompt("Press any key to exit"); diff --git a/samples/DotNetCoreSample/TemplatingSample.cs b/samples/DotNetCoreSample/TemplatingSample.cs index b8bad829..a4382229 100644 --- a/samples/DotNetCoreSample/TemplatingSample.cs +++ b/samples/DotNetCoreSample/TemplatingSample.cs @@ -15,6 +15,8 @@ public static async Task MainTest() var engine = TemplateEngine.CreateDefault(); var result = await engine.RenderAsync("Hello {{Name}}", new { Name = ".NET" }); Console.WriteLine(result); + Console.WriteLine(await engine.RenderAsync("Hello {{Name | toTitle }}", new { Name = "mike" })); + Console.WriteLine(await engine.RenderAsync("Today is {{ date | format:yyyy-MM-dd }}", new { date = DateTime.Today })); } { diff --git a/src/WeihanLi.Common/Extensions/CoreExtension.cs b/src/WeihanLi.Common/Extensions/CoreExtension.cs index 13c68193..e2c6e279 100644 --- a/src/WeihanLi.Common/Extensions/CoreExtension.cs +++ b/src/WeihanLi.Common/Extensions/CoreExtension.cs @@ -1,8 +1,9 @@ -using System.Collections.Concurrent; +// Copyright (c) Weihan Li. All rights reserved. +// Licensed under the Apache license. + +using System.Collections.Concurrent; using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; using System.Globalization; -using System.Net; using System.Reflection; using System.Text; using System.Text.RegularExpressions; @@ -1638,8 +1639,8 @@ public static bool ToBoolean(this string? value, bool defaultValue = false) return value switch { null => defaultValue, - "" or "1" => true, - "0" => false, + "" or "1" or "y" or "yes" => true, + "0" or "n" or "no" => false, _ => bool.TryParse(value, out var val) ? val : defaultValue }; } diff --git a/src/WeihanLi.Common/Helpers/ApplicationHelper.cs b/src/WeihanLi.Common/Helpers/ApplicationHelper.cs index 7e2d6230..5a39fec5 100644 --- a/src/WeihanLi.Common/Helpers/ApplicationHelper.cs +++ b/src/WeihanLi.Common/Helpers/ApplicationHelper.cs @@ -2,6 +2,7 @@ // Licensed under the Apache license. using System.Reflection; +using System.Runtime; using System.Runtime.InteropServices; using WeihanLi.Extensions; @@ -42,6 +43,7 @@ public static LibraryInfo GetLibraryInfo(Assembly assembly) var informationalVersionSplit = assemblyInformation.InformationalVersion.Split('+'); return new LibraryInfo() { + VersionWithHash = assemblyInformation.InformationalVersion, LibraryVersion = informationalVersionSplit[0], LibraryHash = informationalVersionSplit.Length > 1 ? informationalVersionSplit[1] : string.Empty, RepositoryUrl = repositoryUrl @@ -55,8 +57,8 @@ public static LibraryInfo GetLibraryInfo(Assembly assembly) }; } - private static readonly Lazy _runtimeInfoLazy = new(GetRuntimeInfo); - public static RuntimeInfo RuntimeInfo => _runtimeInfoLazy.Value; + private static readonly Lazy LazyRuntimeInfo = new(GetRuntimeInfo); + public static RuntimeInfo RuntimeInfo => LazyRuntimeInfo.Value; /// /// Get dotnet executable path @@ -140,9 +142,6 @@ private static RuntimeInfo GetRuntimeInfo() var runtimeInfo = new RuntimeInfo() { Version = Environment.Version.ToString(), - ProcessorCount = Environment.ProcessorCount, - FrameworkDescription = RuntimeInformation.FrameworkDescription, - WorkingDirectory = Environment.CurrentDirectory, #if NET6_0_OR_GREATER ProcessId = Environment.ProcessId, @@ -152,18 +151,25 @@ private static RuntimeInfo GetRuntimeInfo() ProcessId = currentProcess.Id, ProcessPath = currentProcess.MainModule?.FileName ?? string.Empty, #endif + + ProcessorCount = Environment.ProcessorCount, + FrameworkDescription = RuntimeInformation.FrameworkDescription, + WorkingDirectory = Environment.CurrentDirectory, OSArchitecture = RuntimeInformation.OSArchitecture.ToString(), OSDescription = RuntimeInformation.OSDescription, OSVersion = Environment.OSVersion.ToString(), MachineName = Environment.MachineName, UserName = Environment.UserName, + IsServerGC = GCSettings.IsServerGC, + IsInContainer = IsInContainer(), IsInKubernetes = IsInKubernetesCluster(), KubernetesNamespace = GetKubernetesNamespace(), LibraryVersion = libInfo.LibraryVersion, LibraryHash = libInfo.LibraryHash, + VersionWithHash = libInfo.VersionWithHash, RepositoryUrl = libInfo.RepositoryUrl, }; return runtimeInfo; @@ -230,9 +236,11 @@ private static bool IsInKubernetesCluster() public class LibraryInfo { + private string? _versionWithHash; public required string LibraryVersion { get; init; } public required string LibraryHash { get; init; } public required string RepositoryUrl { get; init; } + public string VersionWithHash { get => _versionWithHash ?? LibraryVersion; init => _versionWithHash = value; } } public class RuntimeInfo : LibraryInfo @@ -250,6 +258,12 @@ public class RuntimeInfo : LibraryInfo public required string RuntimeIdentifier { get; init; } #endif + // GC + /// Gets a value that indicates whether server garbage collection is enabled. + /// + /// if server garbage collection is enabled; otherwise, . + public required bool IsServerGC { get; init; } + public required string WorkingDirectory { get; init; } public required int ProcessId { get; init; } public required string ProcessPath { get; init; } diff --git a/src/WeihanLi.Common/Helpers/EnvHelper.cs b/src/WeihanLi.Common/Helpers/EnvHelper.cs index f4c05f6e..54735061 100644 --- a/src/WeihanLi.Common/Helpers/EnvHelper.cs +++ b/src/WeihanLi.Common/Helpers/EnvHelper.cs @@ -1,7 +1,7 @@ // Copyright (c) Weihan Li. All rights reserved. // Licensed under the Apache license. -using System.Diagnostics.CodeAnalysis; +using WeihanLi.Extensions; namespace WeihanLi.Common.Helpers; @@ -12,4 +12,10 @@ public static class EnvHelper { return Environment.GetEnvironmentVariable(envName) ?? defaultValue; } + + public static bool BooleanVal(string envName, bool defaultValue = false) + { + var val = Environment.GetEnvironmentVariable(envName); + return val.ToBoolean(defaultValue); + } } diff --git a/src/WeihanLi.Common/Template/ConfigurationRenderMiddleare.cs b/src/WeihanLi.Common/Template/ConfigurationRenderMiddleare.cs index 25ac9d0d..8ace6771 100644 --- a/src/WeihanLi.Common/Template/ConfigurationRenderMiddleare.cs +++ b/src/WeihanLi.Common/Template/ConfigurationRenderMiddleare.cs @@ -5,16 +5,20 @@ namespace WeihanLi.Common.Template; -internal sealed class ConfigurationRenderMiddleware(IConfiguration? configuration = null) : IRenderMiddleware +internal sealed class ConfigurationRenderMiddleware(IConfiguration? configuration = null) + : IRenderMiddleware { - private const string Prefix = "$config "; + private const string Prefix = "$config"; public Task InvokeAsync(TemplateRenderContext context, Func next) { - if (configuration != null) + if (configuration is not null) { - foreach (var variable in context.Variables.Where(x => x.StartsWith(Prefix) && !context.Parameters.ContainsKey(x))) + foreach (var pair in context.Inputs + .Where(x => x.Key.Prefix is Prefix + && x.Value is null) + ) { - context.Parameters[variable] = configuration[variable[Prefix.Length..]]; + context.Inputs[pair.Key] = configuration[pair.Key.VariableName]; } } diff --git a/src/WeihanLi.Common/Template/DefaultRenderMiddleware.cs b/src/WeihanLi.Common/Template/DefaultRenderMiddleware.cs index 11bd4f98..6f27d306 100644 --- a/src/WeihanLi.Common/Template/DefaultRenderMiddleware.cs +++ b/src/WeihanLi.Common/Template/DefaultRenderMiddleware.cs @@ -3,10 +3,26 @@ namespace WeihanLi.Common.Template; -internal sealed class DefaultRenderMiddleware : IRenderMiddleware +internal sealed class DefaultRenderMiddleware(Dictionary pipes) : IRenderMiddleware { public async Task InvokeAsync(TemplateRenderContext context, Func next) { await next(context); + foreach (var input in context.Inputs.Keys) + { + var value = context.Inputs[input]; + if (input.Pipes is { Length: > 0 }) + { + foreach (var pipeInput in input.Pipes) + { + if (pipes.TryGetValue(pipeInput.PipeName, out var pipe)) + { + value = pipe.Convert(value, pipeInput.Arguments); + } + } + } + // replace input with value + context.RenderedText = context.RenderedText.Replace(input.Input, value as string ?? value?.ToString()); + } } } diff --git a/src/WeihanLi.Common/Template/DefaultTemplateParser.cs b/src/WeihanLi.Common/Template/DefaultTemplateParser.cs index a9f752ee..0e28601b 100644 --- a/src/WeihanLi.Common/Template/DefaultTemplateParser.cs +++ b/src/WeihanLi.Common/Template/DefaultTemplateParser.cs @@ -7,19 +7,79 @@ namespace WeihanLi.Common.Template; internal sealed class DefaultTemplateParser : ITemplateParser { - private const string VariableRegexExp = @"\{\{(?[\w\$\s:]+)\}\}"; - private static readonly Regex VariableRegex = new(VariableRegexExp, RegexOptions.Compiled); + private const string VariableGroupRegexExp = @"\{\{(?[\w\$\s:\.]+)(?|[^\{\}]*)\}\}"; + private static readonly Regex VariableRegex = new(VariableGroupRegexExp, RegexOptions.Compiled); public Task ParseAsync(string text) { - var variables = new HashSet(); + List inputs = []; var match = VariableRegex.Match(text); while (match.Success) { - var variable = match.Groups["Variable"].Value; - variables.Add(variable); + var pipes = Array.Empty(); + var variableInput = match.Groups["Variable"].Value; + var variableName = variableInput.Trim(); + string? prefix = null; + + var prefixIndex = variableName.IndexOf('$'); // prefix start + if (prefixIndex >= 0) + { + var nameIndex = variableName.IndexOf(' ', prefixIndex); // name start + prefix = variableName[..nameIndex].Trim(); + variableName = variableName[nameIndex..].Trim(); + } + + var pipeValue = match.Groups["Pipe"]?.Value.Trim(); + if (!string.IsNullOrEmpty(pipeValue)) + { + var pipeIndex = pipeValue.IndexOf('|'); + if (pipeIndex < 0) + { + match = match.NextMatch(); + continue; + } + + // exact pipes + pipeValue = pipeValue[pipeIndex..].Trim(); + var pipeInputs = pipeValue!.Split(['|'], StringSplitOptions.RemoveEmptyEntries); + pipes = pipeInputs.Select(p => + { + var pipeName = p.Trim(); + var arguments = Array.Empty(); + var sep = pipeName.IndexOf(':'); + if (sep >= 0) + { + if (sep + 1 < pipeName.Length) + { + var argumentsText = pipeName[(sep + 1)..].Trim(); + arguments = +#if NET + argumentsText.Split([':'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +#else + argumentsText.Split([':'], StringSplitOptions.RemoveEmptyEntries) + .Select(x=> x.Trim()).ToArray(); +#endif + } + + pipeName = pipeName[..sep].Trim(); + } + + return new TemplatePipeInput() { PipeName = pipeName, Arguments = arguments }; + }).ToArray(); + } + + var input = new TemplateInput + { + Input = match.Value, + Prefix = prefix, + VariableName = variableName, + Pipes = pipes + }; + inputs.Add(input); + match = match.NextMatch(); } - var context = new TemplateRenderContext(text, variables); + var context = new TemplateRenderContext(text, inputs); return Task.FromResult(context); } } diff --git a/src/WeihanLi.Common/Template/DefaultTemplateRenderer.cs b/src/WeihanLi.Common/Template/DefaultTemplateRenderer.cs index 82a96fdf..955f8aef 100644 --- a/src/WeihanLi.Common/Template/DefaultTemplateRenderer.cs +++ b/src/WeihanLi.Common/Template/DefaultTemplateRenderer.cs @@ -5,21 +5,26 @@ namespace WeihanLi.Common.Template; -internal sealed class DefaultTemplateRenderer(Func renderFunc) : ITemplateRenderer +internal sealed class DefaultTemplateRenderer(Func renderFunc) + : ITemplateRenderer { public async Task RenderAsync(TemplateRenderContext context, object? globals) { - if (context.Text.IsNullOrWhiteSpace() || context.Variables.IsNullOrEmpty()) + if (context.Text.IsNullOrWhiteSpace() || context.Inputs.IsNullOrEmpty()) return context.Text; - - context.Parameters = globals.ParseParamDictionary(); - await renderFunc.Invoke(context).ConfigureAwait(false); - foreach (var parameter in context.Parameters) + + var parameters = globals.ParseParamDictionary(); + if (parameters is { Count: > 0 }) { - context.RenderedText = context.RenderedText.Replace( - $"{{{{{parameter.Key}}}}}", parameter.Value?.ToString() - ); + foreach (var input in context.Inputs.Keys.Where(x => x.Prefix is null)) + { + if (parameters.TryGetValue(input.VariableName, out var value)) + { + context.Inputs[input] = value; + } + } } + await renderFunc.Invoke(context).ConfigureAwait(false); return context.RenderedText; } } diff --git a/src/WeihanLi.Common/Template/DependencyInjectionExtensions.cs b/src/WeihanLi.Common/Template/DependencyInjectionExtensions.cs index 0b9301d0..85acefce 100644 --- a/src/WeihanLi.Common/Template/DependencyInjectionExtensions.cs +++ b/src/WeihanLi.Common/Template/DependencyInjectionExtensions.cs @@ -11,7 +11,10 @@ namespace WeihanLi.Common.Template; public static class DependencyInjectionExtensions { - public static IServiceCollection AddTemplateEngine(this IServiceCollection services, Action? optionsConfigure = null) + public static ITemplateEngineServiceBuilder AddTemplateEngine( + this IServiceCollection services, + Action? optionsConfigure = null + ) { Guard.NotNull(services); if (services.Any(x => x.ServiceType == typeof(ITemplateEngine))) @@ -20,20 +23,33 @@ public static IServiceCollection AddTemplateEngine(this IServiceCollection servi if (optionsConfigure != null) services.AddOptions().Configure(optionsConfigure); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); + + var serviceBuilder = new TemplateEngineServiceBuilder(services); + + serviceBuilder.AddPipe() + .AddPipe() + .AddPipe() + .AddPipe() + ; - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.AddSingleton(sp => - { - var configuration = sp.GetService>()?.Value.Configuration - ?? sp.GetService(); - return new ConfigurationRenderMiddleware(configuration); - }); + serviceBuilder.AddRenderMiddleware(sp => + { + var pipes = sp.GetServices().ToDictionary(p => p.Name, p => p); + return new DefaultRenderMiddleware(pipes); + }) + .AddRenderMiddleware() + .AddRenderMiddleware(sp => + { + var configuration = sp.GetService>()?.Value.Configuration + ?? sp.GetService(); + return new ConfigurationRenderMiddleware(configuration); + }) + ; - services.TryAddSingleton(sp => + services.AddSingleton(sp => { var pipelineBuilder = PipelineBuilder.CreateAsync(); foreach (var middleware in sp.GetServices()) @@ -43,6 +59,27 @@ public static IServiceCollection AddTemplateEngine(this IServiceCollection servi return pipelineBuilder.Build(); }); - return services; + return serviceBuilder; + } + + public static ITemplateEngineServiceBuilder AddRenderMiddleware( + this ITemplateEngineServiceBuilder serviceBuilder, + Func? middlewareFactory = null + ) where TMiddleware : class, IRenderMiddleware + { + var serviceDescriptor = middlewareFactory is null + ? ServiceDescriptor.Singleton() + : ServiceDescriptor.Singleton(middlewareFactory) + ; + serviceBuilder.Services.Add(serviceDescriptor); + return serviceBuilder; + } + + public static ITemplateEngineServiceBuilder AddPipe + (this ITemplateEngineServiceBuilder serviceBuilder) + where TPipe : class, ITemplatePipe + { + serviceBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + return serviceBuilder; } } diff --git a/src/WeihanLi.Common/Template/EnvRenderMiddleware.cs b/src/WeihanLi.Common/Template/EnvRenderMiddleware.cs index b52e9c7c..5bc11793 100644 --- a/src/WeihanLi.Common/Template/EnvRenderMiddleware.cs +++ b/src/WeihanLi.Common/Template/EnvRenderMiddleware.cs @@ -5,12 +5,16 @@ namespace WeihanLi.Common.Template; internal sealed class EnvRenderMiddleware : IRenderMiddleware { - private const string Prefix = "$env "; + private const string Prefix = "$env"; public Task InvokeAsync(TemplateRenderContext context, Func next) { - foreach (var variable in context.Variables.Where(x => x.StartsWith(Prefix) && !context.Parameters.ContainsKey(x))) + foreach (var pair in context.Inputs + .Where(x => x.Key.Prefix is Prefix + && x.Value is null) + ) { - context.Parameters[variable] = Environment.GetEnvironmentVariable(variable[Prefix.Length..]); + var variable = pair.Key.VariableName; + context.Inputs[pair.Key] = Environment.GetEnvironmentVariable(variable); } return next(context); } diff --git a/src/WeihanLi.Common/Template/ITemplatePipe.cs b/src/WeihanLi.Common/Template/ITemplatePipe.cs new file mode 100644 index 00000000..ed766aee --- /dev/null +++ b/src/WeihanLi.Common/Template/ITemplatePipe.cs @@ -0,0 +1,10 @@ +// Copyright (c) Weihan Li. All rights reserved. +// Licensed under the Apache license. + +namespace WeihanLi.Common.Template; + +public interface ITemplatePipe +{ + string Name { get; } + object? Convert(object? value, params ReadOnlySpan args); +} diff --git a/src/WeihanLi.Common/Template/ITemplateRendererBuilder.cs b/src/WeihanLi.Common/Template/ITemplateRendererBuilder.cs index ffaca99e..6643ab86 100644 --- a/src/WeihanLi.Common/Template/ITemplateRendererBuilder.cs +++ b/src/WeihanLi.Common/Template/ITemplateRendererBuilder.cs @@ -5,6 +5,9 @@ namespace WeihanLi.Common.Template; public interface ITemplateRendererBuilder { + ITemplateRendererBuilder UseTemplatePipe(TPipe pipe) + where TPipe : class, ITemplatePipe; + ITemplateRendererBuilder UseRenderMiddleware(TMiddleware middleware) where TMiddleware : class, IRenderMiddleware; } diff --git a/src/WeihanLi.Common/Template/TemplateEngine.cs b/src/WeihanLi.Common/Template/TemplateEngine.cs index 5bad2148..e63e7165 100644 --- a/src/WeihanLi.Common/Template/TemplateEngine.cs +++ b/src/WeihanLi.Common/Template/TemplateEngine.cs @@ -27,6 +27,10 @@ public async Task RenderAsync(string text, object? parameters = null) public static TemplateEngine CreateDefault(Action? builderConfigure = null) { var builder = new TemplateEngineBuilder(); + builder.UseTemplatePipe(new TextFormatTemplatePipe()); + builder.UseTemplatePipe(new UpperCaseTemplatePipe()); + builder.UseTemplatePipe(new LowerCaseTemplatePipe()); + builder.UseTemplatePipe(new TitleCaseTemplatePipe()); builderConfigure?.Invoke(builder); return new TemplateEngine(builder.BuildParser(), builder.BuildRenderer()); } diff --git a/src/WeihanLi.Common/Template/TemplateEngineOptions.cs b/src/WeihanLi.Common/Template/TemplateEngineOptions.cs index 1db2d0cf..d7897dbe 100644 --- a/src/WeihanLi.Common/Template/TemplateEngineOptions.cs +++ b/src/WeihanLi.Common/Template/TemplateEngineOptions.cs @@ -7,4 +7,6 @@ namespace WeihanLi.Common.Template; public sealed class TemplateEngineOptions { public IConfiguration? Configuration { get; set; } + + internal Dictionary Pipes { get; init; } = new(); } diff --git a/src/WeihanLi.Common/Template/TemplateEngineServiceBuilder.cs b/src/WeihanLi.Common/Template/TemplateEngineServiceBuilder.cs new file mode 100644 index 00000000..8e67d33e --- /dev/null +++ b/src/WeihanLi.Common/Template/TemplateEngineServiceBuilder.cs @@ -0,0 +1,17 @@ +// Copyright (c) Weihan Li. All rights reserved. +// Licensed under the Apache license. + +using Microsoft.Extensions.DependencyInjection; + +namespace WeihanLi.Common.Template; + +public interface ITemplateEngineServiceBuilder +{ + IServiceCollection Services { get; } +} + +internal sealed class TemplateEngineServiceBuilder(IServiceCollection services) + : ITemplateEngineServiceBuilder +{ + public IServiceCollection Services { get; } = services; +} diff --git a/src/WeihanLi.Common/Template/TemplatePipes.cs b/src/WeihanLi.Common/Template/TemplatePipes.cs new file mode 100644 index 00000000..4da864da --- /dev/null +++ b/src/WeihanLi.Common/Template/TemplatePipes.cs @@ -0,0 +1,82 @@ +// Copyright (c) Weihan Li. All rights reserved. +// Licensed under the Apache license. + +using System.Globalization; +using WeihanLi.Extensions; + +namespace WeihanLi.Common.Template; + +public abstract class TemplatePipeBase : ITemplatePipe +{ + protected virtual int? ParameterCount => 1; + + public abstract string Name { get; } + public object? Convert(object? value, params ReadOnlySpan args) + { + if (ParameterCount.HasValue && ParameterCount.Value != args.Length) + { + throw new InvalidOperationException($"The number of arguments {args.Length} must be equal to the parameter count({ParameterCount})."); + } + + return ConvertInternal(value, args); + } + + protected abstract object? ConvertInternal(object? value, params ReadOnlySpan args); +} + +public sealed class TextFormatTemplatePipe : TemplatePipeBase +{ + protected override object? ConvertInternal(object? value, params ReadOnlySpan args) + => FormatText(value, args[0]); + + public override string Name => "format"; + private string? FormatText(object? value, string format) + { + return value switch + { + null => null, + IFormattable text => text.ToString(format, CultureInfo.InvariantCulture), + _ => value.ToString() + }; + } +} + +public abstract class TextTransformTemplatePipe : TemplatePipeBase +{ + protected override int? ParameterCount => 0; + + protected override object? ConvertInternal(object? value, params ReadOnlySpan args) + { + var str = value as string ?? value?.ToString(); + return str is null ? null : ConvertText(str); + } + + protected abstract string? ConvertText(string value); +} + +public sealed class UpperCaseTemplatePipe : TextTransformTemplatePipe +{ + public override string Name => "toUpper"; + protected override string ConvertText(string value) + { + return value.ToUpperInvariant(); + } +} + +public sealed class LowerCaseTemplatePipe : TextTransformTemplatePipe +{ + public override string Name => "toLower"; + protected override string ConvertText(string value) + { + return value.ToLowerInvariant(); + } +} + +public sealed class TitleCaseTemplatePipe : TextTransformTemplatePipe +{ + public override string Name => "toTitle"; + protected override string ConvertText(string value) + { + return value.ToTitleCase(); + } +} diff --git a/src/WeihanLi.Common/Template/TemplateRenderContext.cs b/src/WeihanLi.Common/Template/TemplateRenderContext.cs index 09032696..b1f1cdd5 100644 --- a/src/WeihanLi.Common/Template/TemplateRenderContext.cs +++ b/src/WeihanLi.Common/Template/TemplateRenderContext.cs @@ -1,14 +1,36 @@ // Copyright (c) Weihan Li. All rights reserved. // Licensed under the Apache license. +using System.Diagnostics; using WeihanLi.Common.Abstractions; namespace WeihanLi.Common.Template; -public sealed class TemplateRenderContext(string text, HashSet variables) : IProperties + +public sealed class TemplateRenderContext(string text, IReadOnlyCollection inputs) + : IProperties { public string Text { get; } = text; - public HashSet Variables { get; } = variables; + public Dictionary Inputs { get; } = + inputs.ToDictionary(x=> x, _ => (object?)null); public string RenderedText { get; set; } = text; - public IDictionary Parameters { get; set; } = new Dictionary(); public IDictionary Properties { get; } = new Dictionary(); } + +[DebuggerDisplay("{Input,nq}")] +public sealed class TemplateInput : IEquatable +{ + public required string Input { get; init; } + public required string? Prefix { get; init; } + public required string VariableName { get; init; } + public required TemplatePipeInput[] Pipes { get; init; } + public bool Equals(TemplateInput? other) => other is not null && other.Input == Input; + public override bool Equals(object? obj) => obj is TemplateInput input && Equals(input); + public override int GetHashCode() => Input.GetHashCode(); +} + +[DebuggerDisplay("{PipeName,nq}")] +public sealed class TemplatePipeInput +{ + public required string PipeName { get; init; } + public required string[] Arguments { get; init; } +} diff --git a/src/WeihanLi.Common/Template/TemplateRendererBuilder.cs b/src/WeihanLi.Common/Template/TemplateRendererBuilder.cs index 43455c37..a2516e42 100644 --- a/src/WeihanLi.Common/Template/TemplateRendererBuilder.cs +++ b/src/WeihanLi.Common/Template/TemplateRendererBuilder.cs @@ -10,6 +10,13 @@ internal sealed class TemplateEngineBuilder : ITemplateEngineBuilder private readonly IAsyncPipelineBuilder _pipelineBuilder = PipelineBuilder.CreateAsync(); private Action? _optionsConfigure; + private readonly Dictionary _pipes = new(); + + public ITemplateRendererBuilder UseTemplatePipe(TPipe pipe) where TPipe : class, ITemplatePipe + { + _pipes[pipe.Name] = pipe; + return this; + } public ITemplateRendererBuilder UseRenderMiddleware(TMiddleware middleware) where TMiddleware : class, IRenderMiddleware { @@ -27,10 +34,14 @@ public ITemplateRendererBuilder ConfigureOptions(Action o public ITemplateRenderer BuildRenderer() { - var options = new TemplateEngineOptions(); + var options = new TemplateEngineOptions + { + Pipes = _pipes + }; _optionsConfigure?.Invoke(options); + _pipelineBuilder - .UseMiddleware(new DefaultRenderMiddleware()) + .UseMiddleware(new DefaultRenderMiddleware(options.Pipes)) .UseMiddleware(new EnvRenderMiddleware()) .UseMiddleware(new ConfigurationRenderMiddleware(options.Configuration)) ; diff --git a/test/WeihanLi.Common.Test/TemplateTest/TemplateParserTest.cs b/test/WeihanLi.Common.Test/TemplateTest/TemplateParserTest.cs index eaeed566..53d7d764 100644 --- a/test/WeihanLi.Common.Test/TemplateTest/TemplateParserTest.cs +++ b/test/WeihanLi.Common.Test/TemplateTest/TemplateParserTest.cs @@ -8,6 +8,23 @@ namespace WeihanLi.Common.Test.TemplateTest; public class TemplateParserTest { + [Fact] + public async Task PipeParseTest() + { + var text = "Hello {{Name | upper}}"; + var parser = new DefaultTemplateParser(); + var context = await parser.ParseAsync(text); + Assert.Single(context.Inputs); + Assert.Single(context.Inputs.Keys.First().Pipes); + Assert.Single(context.Inputs.Keys.First().Pipes); + var input = context.Inputs.Keys.First().Input; + var pipe = context.Inputs.Keys.First().Pipes.First(); + Assert.Equal("{{Name | upper}}", input); + Assert.Equal("upper", pipe.PipeName); + Assert.NotNull(pipe.Arguments); + Assert.Empty(pipe.Arguments); + } + [Fact] public async Task ParseTest() { @@ -15,18 +32,36 @@ public async Task ParseTest() Build Status: {{Status}} Chart: {{$env CHART_NAME}} - {{$env VERSION}} AppVersion: {{$env APP_VERSION}} + HostEnv: {{$env HOST | toUpper }} Config: {{$config AppSettings:Host}} Config: {{$config AppSettings--Host}} Config: {{$config AppSettings__Host}} """; var result = await new DefaultTemplateParser() .ParseAsync(template); - Assert.Equal(6, result.Variables.Count); - Assert.Contains("Status", result.Variables); - Assert.Contains("$env CHART_NAME", result.Variables); - Assert.Contains("$env VERSION", result.Variables); - Assert.Contains("$env APP_VERSION", result.Variables); - Assert.Contains("$config AppSettings:Host", result.Variables); - Assert.Contains("$config AppSettings__Host", result.Variables); + var inputs = result.Inputs.Select(x => x.Key.Input) + .ToHashSet(); + Assert.Equal(7, result.Inputs.Count); + Assert.Contains("{{Status}}", inputs); + Assert.Contains("{{$env CHART_NAME}}", inputs); + Assert.Contains("{{$env VERSION}}", inputs); + Assert.Contains("{{$env APP_VERSION}}", inputs); + Assert.Contains("{{$env HOST | toUpper }}", inputs); + Assert.Contains("{{$config AppSettings:Host}}", inputs); + Assert.Contains("{{$config AppSettings__Host}}", inputs); + + var variableNames = result.Inputs.Select(x => x.Key.VariableName).ToHashSet(); + Assert.Contains("Status", variableNames); + Assert.Contains("CHART_NAME", variableNames); + Assert.Contains("VERSION", variableNames); + Assert.Contains("APP_VERSION", variableNames); + Assert.Contains("AppSettings:Host", variableNames); + Assert.Contains("AppSettings__Host", variableNames); + Assert.Contains("HOST", variableNames); + + var pipes = result.Inputs.SelectMany(x => x.Key.Pipes) + .Select(x=> x.PipeName) + .ToArray(); + Assert.Contains("toUpper", pipes); } } diff --git a/test/WeihanLi.Common.Test/TemplateTest/TemplateRendererTest.cs b/test/WeihanLi.Common.Test/TemplateTest/TemplateRendererTest.cs new file mode 100644 index 00000000..93c2a971 --- /dev/null +++ b/test/WeihanLi.Common.Test/TemplateTest/TemplateRendererTest.cs @@ -0,0 +1,82 @@ +// Copyright (c) Weihan Li. All rights reserved. +// Licensed under the Apache license. + +using Microsoft.Extensions.Configuration; +using WeihanLi.Common.Template; +using WeihanLi.Extensions; +using Xunit; + +namespace WeihanLi.Common.Test.TemplateTest; + +public class TemplateRendererTest +{ + private readonly TemplateEngine _templateEngine = TemplateEngine.CreateDefault(builder => + { + builder.ConfigureOptions(options => + { + options.Configuration = new ConfigurationBuilder() + .AddInMemoryCollection( + [ + new KeyValuePair("Name", "test") + ]) + .Build(); + }); + builder.UseTemplatePipe(new SubstringTemplatePipe()); + }); + + [Fact] + public async Task VariableRenderTest() + { + var name = "mike"; + var text = "Hello {{ Name | toTitle }}"; + var renderedText = await _templateEngine.RenderAsync(text, new { Name = name }); + Assert.Equal($"Hello {name.ToTitleCase()}", renderedText); + } + + [Fact] + public async Task ConfigRenderTest() + { + var text = "Hello {{ $config Name | toTitle }}"; + var renderedText = await _templateEngine.RenderAsync(text, new { Name = "mike" }); + Assert.Equal($"Hello {"test".ToTitleCase()}", renderedText); + } + + [Fact] + public async Task EnvRenderTest() + { + var text = "Hello {{$env hostname}}"; + var renderedText = await _templateEngine.RenderAsync(text); + Assert.Equal($"Hello {Environment.GetEnvironmentVariable("hostname")}", renderedText); + } + + [Fact] + public async Task CustomPipeRenderTest() + { + var name = "mike"; + var text = "Hello {{ Name | substr:2 | toTitle }}"; + var renderedText = await _templateEngine.RenderAsync(text, new { Name = name }); + Assert.Equal($"Hello {name[2..].ToTitleCase()}", renderedText); + } +} + +file sealed class SubstringTemplatePipe : TemplatePipeBase +{ + protected override int? ParameterCount => null; + public override string Name => "substr"; + protected override string? ConvertInternal(object? value, params ReadOnlySpan args) + { + if (args.Length is not 1 or 2) + { + throw new InvalidOperationException("Arguments count must be 1 or 2"); + } + + var str = value as string ?? value?.ToString() ?? string.Empty; + var start = int.Parse(args[0]); + if (args.Length is 1) + { + return str[start..]; + } + var len = int.Parse(args[1]); + return str[start..len]; + } +}