diff --git a/docs/en/framework/infrastructure/emailing.md b/docs/en/framework/infrastructure/emailing.md index ff7ab152df..f2eb897a10 100644 --- a/docs/en/framework/infrastructure/emailing.md +++ b/docs/en/framework/infrastructure/emailing.md @@ -193,7 +193,7 @@ The resulting email body will be shown below: ````html - + @@ -217,7 +217,7 @@ This template uses the "Abp.StandardEmailTemplates.Layout" as its layout. ````html - + @@ -227,6 +227,8 @@ This template uses the "Abp.StandardEmailTemplates.Layout" as its layout. ```` +`abp_culture` and `abp_dir` are provided by the rendering engine, so the document declares the language and the text direction of the culture it was rendered with. See [the text templating documentation](./text-templating/scriban.md) for the details. + The final rendered message was shown above. > These template names are contants defined in the `Volo.Abp.Emailing.Templates.StandardEmailTemplates` class. diff --git a/docs/en/framework/infrastructure/text-templating/razor.md b/docs/en/framework/infrastructure/text-templating/razor.md index a8bc1c55a6..73b939ed10 100644 --- a/docs/en/framework/infrastructure/text-templating/razor.md +++ b/docs/en/framework/infrastructure/text-templating/razor.md @@ -330,7 +330,7 @@ First, create a template file just like before: ````html @inherits Volo.Abp.TextTemplating.Razor.RazorTemplatePageBase - + @@ -404,6 +404,21 @@ The rendering result will be: A global object value: TEST VALUE ```` +### Built-In Global Context Values + +The Razor and Scriban engines add the following values to the global context, so a template can declare the language and the text direction of the document it renders: + +| Key | Value | +|-----|-------| +| `abp_culture` | Name of the culture the template is rendered with, `en` when it is the invariant culture. | +| `abp_dir` | `rtl` for a right-to-left culture, `ltr` otherwise. | + +````html + +```` + +A value you pass yourself under the same key is kept. The rendering works on a copy of the dictionary you pass, so you can reuse the same instance for several renderings. + ## Replacing the Existing Templates It is possible to replace a template defined by a module that used in your application. In this way, you can customize the templates based on your requirements without changing the module code. @@ -427,7 +442,7 @@ Do the following steps to replace the template file with your own; ````html @inherits Volo.Abp.TextTemplating.Razor.RazorTemplatePageBase - + diff --git a/docs/en/framework/infrastructure/text-templating/scriban.md b/docs/en/framework/infrastructure/text-templating/scriban.md index d2bb8e4a26..eb4171b45d 100644 --- a/docs/en/framework/infrastructure/text-templating/scriban.md +++ b/docs/en/framework/infrastructure/text-templating/scriban.md @@ -302,7 +302,7 @@ First, create a template file just like before: ````xml - + @@ -375,6 +375,21 @@ The rendering result will be: A global object value: TEST VALUE ```` +### Built-In Global Context Values + +The Scriban and Razor engines add the following values to the global context, so a template can declare the language and the text direction of the document it renders: + +| Key | Value | +|-----|-------| +| `abp_culture` | Name of the culture the template is rendered with, `en` when it is the invariant culture. | +| `abp_dir` | `rtl` for a right-to-left culture, `ltr` otherwise. | + +````html + +```` + +A value you pass yourself under the same key is kept. The rendering works on a copy of the dictionary you pass, so you can reuse the same instance for several renderings. + ## Replacing the Existing Templates It is possible to replace a template defined by a module that used in your application. In this way, you can customize the templates based on your requirements without changing the module code. @@ -397,7 +412,7 @@ Do the following steps to replace the template file with your own; ````html - + diff --git a/framework/src/Volo.Abp.Emailing/Volo/Abp/Emailing/Templates/Layout.tpl b/framework/src/Volo.Abp.Emailing/Volo/Abp/Emailing/Templates/Layout.tpl index 4c64588666..b74712cde1 100644 --- a/framework/src/Volo.Abp.Emailing/Volo/Abp/Emailing/Templates/Layout.tpl +++ b/framework/src/Volo.Abp.Emailing/Volo/Abp/Emailing/Templates/Layout.tpl @@ -1,5 +1,5 @@ - + diff --git a/framework/src/Volo.Abp.TextTemplating.Core/Volo/Abp/TextTemplating/TemplateRenderingEngineBase.cs b/framework/src/Volo.Abp.TextTemplating.Core/Volo/Abp/TextTemplating/TemplateRenderingEngineBase.cs index 7e5e10d86c..5ad6e0afc8 100644 --- a/framework/src/Volo.Abp.TextTemplating.Core/Volo/Abp/TextTemplating/TemplateRenderingEngineBase.cs +++ b/framework/src/Volo.Abp.TextTemplating.Core/Volo/Abp/TextTemplating/TemplateRenderingEngineBase.cs @@ -1,11 +1,18 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Globalization; using System.Threading.Tasks; using Microsoft.Extensions.Localization; +using Volo.Abp.Localization; namespace Volo.Abp.TextTemplating; public abstract class TemplateRenderingEngineBase : ITemplateRenderingEngine { + public const string CultureContextKey = "abp_culture"; + + public const string TextDirectionContextKey = "abp_dir"; + public abstract string Name { get; } public virtual bool IsSandboxed => false; @@ -26,6 +33,32 @@ public abstract class TemplateRenderingEngineBase : ITemplateRenderingEngine public abstract Task RenderAsync(string templateName, object? model = null, string? cultureName = null, Dictionary? globalContext = null); + /// + /// Must be called inside the culture scope of the rendering. Values set by the caller are kept. + /// + protected virtual void SetCultureContext(Dictionary globalContext) + { + // The invariant culture has an empty name, which is not a valid value for a lang attribute. + var cultureName = CultureInfo.CurrentUICulture.Name; + + SetCultureContextValue(globalContext, CultureContextKey, cultureName.IsNullOrWhiteSpace() ? "en" : cultureName); + SetCultureContextValue(globalContext, TextDirectionContextKey, CultureHelper.IsRtl ? "rtl" : "ltr"); + } + + protected virtual void SetCultureContextValue(Dictionary globalContext, string key, string value) + { + if (!globalContext.TryGetValue(key, out var callerValue)) + { + globalContext.Add(key, value); + return; + } + + // A case insensitive context can hold the value under another casing, while templates look the key + // up as it is written here, so it is re-inserted with the canonical one. + globalContext.Remove(key); + globalContext.Add(key, callerValue); + } + protected virtual async Task GetContentOrNullAsync(TemplateDefinition templateDefinition) { return await TemplateContentProvider.GetContentOrNullAsync(templateDefinition); diff --git a/framework/src/Volo.Abp.TextTemplating.Razor/Volo/Abp/TextTemplating/Razor/RazorTemplateRenderingEngine.cs b/framework/src/Volo.Abp.TextTemplating.Razor/Volo/Abp/TextTemplating/Razor/RazorTemplateRenderingEngine.cs index b6c120fe9e..af5d51bbe0 100644 --- a/framework/src/Volo.Abp.TextTemplating.Razor/Volo/Abp/TextTemplating/Razor/RazorTemplateRenderingEngine.cs +++ b/framework/src/Volo.Abp.TextTemplating.Razor/Volo/Abp/TextTemplating/Razor/RazorTemplateRenderingEngine.cs @@ -39,13 +39,16 @@ public class RazorTemplateRenderingEngine : TemplateRenderingEngineBase, ITransi { Check.NotNullOrWhiteSpace(templateName, nameof(templateName)); - if (globalContext == null) - { - globalContext = new Dictionary(); - } + // The rendering writes the culture context into this dictionary, so it works on a copy: a caller + // reusing one instance would carry the values of a rendering into the next. + globalContext = globalContext == null + ? new Dictionary() + : new Dictionary(globalContext, globalContext.Comparer); if (cultureName == null) { + SetCultureContext(globalContext); + return await RenderInternalAsync( templateName, null, @@ -57,6 +60,8 @@ public class RazorTemplateRenderingEngine : TemplateRenderingEngineBase, ITransi { using (CultureHelper.Use(cultureName)) { + SetCultureContext(globalContext); + return await RenderInternalAsync( templateName, null, diff --git a/framework/src/Volo.Abp.TextTemplating.Scriban/Volo/Abp/TextTemplating/Scriban/ScribanTemplateRenderingEngine.cs b/framework/src/Volo.Abp.TextTemplating.Scriban/Volo/Abp/TextTemplating/Scriban/ScribanTemplateRenderingEngine.cs index 9b7b3393a6..bcd6a0bc95 100644 --- a/framework/src/Volo.Abp.TextTemplating.Scriban/Volo/Abp/TextTemplating/Scriban/ScribanTemplateRenderingEngine.cs +++ b/framework/src/Volo.Abp.TextTemplating.Scriban/Volo/Abp/TextTemplating/Scriban/ScribanTemplateRenderingEngine.cs @@ -33,13 +33,16 @@ public class ScribanTemplateRenderingEngine : TemplateRenderingEngineBase, ITran { Check.NotNullOrWhiteSpace(templateName, nameof(templateName)); - if (globalContext == null) - { - globalContext = new Dictionary(); - } + // The rendering writes the culture context and the layout content into this dictionary, so it works + // on a copy: a caller reusing one instance would carry the values of a rendering into the next. + globalContext = globalContext == null + ? new Dictionary() + : new Dictionary(globalContext, globalContext.Comparer); if (cultureName == null) { + SetCultureContext(globalContext); + return await RenderInternalAsync( templateName, globalContext, @@ -50,6 +53,8 @@ public class ScribanTemplateRenderingEngine : TemplateRenderingEngineBase, ITran { using (CultureHelper.Use(cultureName)) { + SetCultureContext(globalContext); + return await RenderInternalAsync( templateName, globalContext, diff --git a/framework/test/Volo.Abp.Emailing.Tests/Volo.Abp.Emailing.Tests.csproj b/framework/test/Volo.Abp.Emailing.Tests/Volo.Abp.Emailing.Tests.csproj index ad679faffa..b9486fd766 100644 --- a/framework/test/Volo.Abp.Emailing.Tests/Volo.Abp.Emailing.Tests.csproj +++ b/framework/test/Volo.Abp.Emailing.Tests/Volo.Abp.Emailing.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/framework/test/Volo.Abp.Emailing.Tests/Volo/Abp/Emailing/AbpEmailingTestModule.cs b/framework/test/Volo.Abp.Emailing.Tests/Volo/Abp/Emailing/AbpEmailingTestModule.cs index d4b5449386..6ac65b194f 100644 --- a/framework/test/Volo.Abp.Emailing.Tests/Volo/Abp/Emailing/AbpEmailingTestModule.cs +++ b/framework/test/Volo.Abp.Emailing.Tests/Volo/Abp/Emailing/AbpEmailingTestModule.cs @@ -1,11 +1,13 @@ using Volo.Abp.Autofac; using Volo.Abp.Modularity; +using Volo.Abp.TextTemplating.Scriban; using Volo.Abp.VirtualFileSystem; namespace Volo.Abp.Emailing; [DependsOn( typeof(AbpEmailingModule), + typeof(AbpTextTemplatingScribanModule), typeof(AbpAutofacModule), typeof(AbpTestBaseModule))] public class AbpEmailingTestModule : AbpModule diff --git a/framework/test/Volo.Abp.Emailing.Tests/Volo/Abp/Emailing/Templates/StandardEmailTemplates_Tests.cs b/framework/test/Volo.Abp.Emailing.Tests/Volo/Abp/Emailing/Templates/StandardEmailTemplates_Tests.cs new file mode 100644 index 0000000000..f48d8b58f4 --- /dev/null +++ b/framework/test/Volo.Abp.Emailing.Tests/Volo/Abp/Emailing/Templates/StandardEmailTemplates_Tests.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.Modularity; +using Volo.Abp.Testing; +using Volo.Abp.TextTemplating; +using Xunit; + +namespace Volo.Abp.Emailing.Templates; + +public abstract class AbpEmailingTestBase : AbpIntegratedTest + where TStartupModule : IAbpModule +{ + protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) + { + options.UseAutofac(); + } +} + +public class StandardEmailTemplates_Tests : AbpEmailingTestBase +{ + private readonly ITemplateRenderer _templateRenderer; + + public StandardEmailTemplates_Tests() + { + _templateRenderer = GetRequiredService(); + } + + [Theory] + [InlineData("en", " +{ + private readonly ITemplateRenderer _templateRenderer; + + public RazorTemplateRenderingEngine_CultureContext_Tests() + { + _templateRenderer = GetRequiredService(); + } + + [Theory] + [InlineData("en", "")] + [InlineData("fr", "")] + [InlineData("ar", "")] + public async Task Should_Render_Culture_And_Text_Direction_Of_The_Rendering_Culture(string cultureName, string expected) + { + (await _templateRenderer.RenderAsync( + RazorTestTemplates.CultureContext, + cultureName: cultureName + )).Trim().ShouldBe(expected); + } + + [Fact] + public async Task Should_Not_Leak_The_Culture_Of_A_Rendering_Into_The_Next_One() + { + var globalContext = new Dictionary(); + + (await _templateRenderer.RenderAsync( + RazorTestTemplates.CultureContext, + cultureName: "en", + globalContext: globalContext + )).Trim().ShouldBe(""); + + (await _templateRenderer.RenderAsync( + RazorTestTemplates.CultureContext, + cultureName: "ar", + globalContext: globalContext + )).Trim().ShouldBe(""); + + globalContext.ShouldBeEmpty(); + } + + [Fact] + public async Task Should_Keep_The_Comparer_Of_The_Context_Of_The_Caller() + { + var globalContext = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ABP_CULTURE"] = "custom" + }; + + (await _templateRenderer.RenderAsync( + RazorTestTemplates.CultureContext, + cultureName: "ar", + globalContext: globalContext + )).Trim().ShouldBe(""); + } +} diff --git a/framework/test/Volo.Abp.TextTemplating.Razor.Tests/Volo/Abp/TextTemplating/Razor/RazorTestTemplateDefinitionProvider.cs b/framework/test/Volo.Abp.TextTemplating.Razor.Tests/Volo/Abp/TextTemplating/Razor/RazorTestTemplateDefinitionProvider.cs index 835ada1cfa..f250b5bbff 100644 --- a/framework/test/Volo.Abp.TextTemplating.Razor.Tests/Volo/Abp/TextTemplating/Razor/RazorTestTemplateDefinitionProvider.cs +++ b/framework/test/Volo.Abp.TextTemplating.Razor.Tests/Volo/Abp/TextTemplating/Razor/RazorTestTemplateDefinitionProvider.cs @@ -23,5 +23,9 @@ public class RazorTestTemplateDefinitionProvider : TemplateDefinitionProvider .WithRazorEngine(); context.Add(new TemplateDefinition(RazorTestTemplates.TestTemplate).WithVirtualFilePath("/SampleTemplates/TestTemplate.cshtml", true)); + + context.Add(new TemplateDefinition(RazorTestTemplates.CultureContext) + .WithVirtualFilePath("/SampleTemplates/CultureContext.cshtml", true) + .WithRazorEngine()); } } diff --git a/framework/test/Volo.Abp.TextTemplating.Razor.Tests/Volo/Abp/TextTemplating/Razor/SampleTemplates/CultureContext.cshtml b/framework/test/Volo.Abp.TextTemplating.Razor.Tests/Volo/Abp/TextTemplating/Razor/SampleTemplates/CultureContext.cshtml new file mode 100644 index 0000000000..e3079774c7 --- /dev/null +++ b/framework/test/Volo.Abp.TextTemplating.Razor.Tests/Volo/Abp/TextTemplating/Razor/SampleTemplates/CultureContext.cshtml @@ -0,0 +1,2 @@ +@inherits Volo.Abp.TextTemplating.Razor.RazorTemplatePageBase + diff --git a/framework/test/Volo.Abp.TextTemplating.Razor.Tests/Volo/Abp/TextTemplating/Razor/SampleTemplates/RazorTestTemplates.cs b/framework/test/Volo.Abp.TextTemplating.Razor.Tests/Volo/Abp/TextTemplating/Razor/SampleTemplates/RazorTestTemplates.cs index 684eee1f3e..eef182ae7d 100644 --- a/framework/test/Volo.Abp.TextTemplating.Razor.Tests/Volo/Abp/TextTemplating/Razor/SampleTemplates/RazorTestTemplates.cs +++ b/framework/test/Volo.Abp.TextTemplating.Razor.Tests/Volo/Abp/TextTemplating/Razor/SampleTemplates/RazorTestTemplates.cs @@ -3,4 +3,5 @@ public static class RazorTestTemplates { public const string TestTemplate = "TestTemplate"; + public const string CultureContext = "CultureContext"; } diff --git a/framework/test/Volo.Abp.TextTemplating.Scriban.Tests/Volo/Abp/TextTemplating/Scriban/SampleTemplates/CultureContext.tpl b/framework/test/Volo.Abp.TextTemplating.Scriban.Tests/Volo/Abp/TextTemplating/Scriban/SampleTemplates/CultureContext.tpl new file mode 100644 index 0000000000..92d8778050 --- /dev/null +++ b/framework/test/Volo.Abp.TextTemplating.Scriban.Tests/Volo/Abp/TextTemplating/Scriban/SampleTemplates/CultureContext.tpl @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/framework/test/Volo.Abp.TextTemplating.Scriban.Tests/Volo/Abp/TextTemplating/Scriban/ScribanTemplateRenderingEngine_CultureContext_Tests.cs b/framework/test/Volo.Abp.TextTemplating.Scriban.Tests/Volo/Abp/TextTemplating/Scriban/ScribanTemplateRenderingEngine_CultureContext_Tests.cs new file mode 100644 index 0000000000..063e203866 --- /dev/null +++ b/framework/test/Volo.Abp.TextTemplating.Scriban.Tests/Volo/Abp/TextTemplating/Scriban/ScribanTemplateRenderingEngine_CultureContext_Tests.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.Localization; +using Xunit; + +namespace Volo.Abp.TextTemplating.Scriban; + +public class ScribanTemplateRenderingEngine_CultureContext_Tests : AbpTextTemplatingTestBase +{ + private readonly ITemplateRenderer _templateRenderer; + + public ScribanTemplateRenderingEngine_CultureContext_Tests() + { + _templateRenderer = GetRequiredService(); + } + + [Theory] + [InlineData("en", "")] + [InlineData("fr", "")] + [InlineData("ar", "")] + public async Task Should_Render_Culture_And_Text_Direction_Of_The_Rendering_Culture(string cultureName, string expected) + { + (await _templateRenderer.RenderAsync( + ScribanTestTemplateDefinitionProvider.CultureContext, + cultureName: cultureName + )).ShouldBe(expected); + } + + [Fact] + public async Task Should_Keep_The_Values_Passed_By_The_Caller() + { + (await _templateRenderer.RenderAsync( + ScribanTestTemplateDefinitionProvider.CultureContext, + cultureName: "ar", + globalContext: new Dictionary + { + [TemplateRenderingEngineBase.CultureContextKey] = "custom" + } + )).ShouldBe(""); + } + + [Fact] + public async Task Should_Not_Leak_The_Culture_Of_A_Rendering_Into_The_Next_One() + { + var globalContext = new Dictionary(); + + (await _templateRenderer.RenderAsync( + ScribanTestTemplateDefinitionProvider.CultureContext, + cultureName: "en", + globalContext: globalContext + )).ShouldBe(""); + + (await _templateRenderer.RenderAsync( + ScribanTestTemplateDefinitionProvider.CultureContext, + cultureName: "ar", + globalContext: globalContext + )).ShouldBe(""); + + globalContext.ShouldBeEmpty(); + } + + [Fact] + public async Task Should_Not_Write_The_Layout_Content_Into_The_Context_Of_The_Caller() + { + var globalContext = new Dictionary(); + + await _templateRenderer.RenderAsync( + TestTemplates.ForgotPasswordEmail, + model: new { link = "http://abp.io" }, + cultureName: "en", + globalContext: globalContext + ); + + globalContext.ShouldBeEmpty(); + } + + [Fact] + public async Task Should_Keep_The_Comparer_Of_The_Context_Of_The_Caller() + { + // Scriban looks the key up as the template writes it, so the value of a case insensitive context + // has to be re-inserted under the canonical casing to stay reachable. + var globalContext = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ABP_CULTURE"] = "custom" + }; + + (await _templateRenderer.RenderAsync( + ScribanTestTemplateDefinitionProvider.CultureContext, + cultureName: "ar", + globalContext: globalContext + )).ShouldBe(""); + } + + [Fact] + public async Task Should_Fall_Back_To_English_For_The_Invariant_Culture() + { + using (CultureHelper.Use(CultureInfo.InvariantCulture)) + { + (await _templateRenderer.RenderAsync( + ScribanTestTemplateDefinitionProvider.CultureContext + )).ShouldBe(""); + } + } +} diff --git a/framework/test/Volo.Abp.TextTemplating.Scriban.Tests/Volo/Abp/TextTemplating/Scriban/ScribanTestTemplateDefinitionProvider.cs b/framework/test/Volo.Abp.TextTemplating.Scriban.Tests/Volo/Abp/TextTemplating/Scriban/ScribanTestTemplateDefinitionProvider.cs index 006f2db450..2169ed42bf 100644 --- a/framework/test/Volo.Abp.TextTemplating.Scriban.Tests/Volo/Abp/TextTemplating/Scriban/ScribanTestTemplateDefinitionProvider.cs +++ b/framework/test/Volo.Abp.TextTemplating.Scriban.Tests/Volo/Abp/TextTemplating/Scriban/ScribanTestTemplateDefinitionProvider.cs @@ -6,6 +6,7 @@ public class ScribanTestTemplateDefinitionProvider : TemplateDefinitionProvider public const string ReflectionEscapeChain = "ReflectionEscapeChain"; public const string MethodInvocationAttempt = "MethodInvocationAttempt"; public const string NestedPropertyAccess = "NestedPropertyAccess"; + public const string CultureContext = "CultureContext"; public override void Define(ITemplateDefinitionContext context) { @@ -40,5 +41,9 @@ public class ScribanTestTemplateDefinitionProvider : TemplateDefinitionProvider context.Add(new TemplateDefinition(NestedPropertyAccess) .WithVirtualFilePath("/SampleTemplates/NestedPropertyAccess.tpl", true) .WithScribanEngine()); + + context.Add(new TemplateDefinition(CultureContext) + .WithVirtualFilePath("/SampleTemplates/CultureContext.tpl", true) + .WithScribanEngine()); } }