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..03c026cccb 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,25 @@ 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;
+
+ if (!globalContext.ContainsKey(CultureContextKey))
+ {
+ globalContext[CultureContextKey] = cultureName.IsNullOrWhiteSpace() ? "en" : cultureName;
+ }
+
+ if (!globalContext.ContainsKey(TextDirectionContextKey))
+ {
+ globalContext[TextDirectionContextKey] = CultureHelper.IsRtl ? "rtl" : "ltr";
+ }
+ }
+
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..ccaa0aad00
--- /dev/null
+++ b/framework/test/Volo.Abp.TextTemplating.Scriban.Tests/Volo/Abp/TextTemplating/Scriban/ScribanTemplateRenderingEngine_CultureContext_Tests.cs
@@ -0,0 +1,108 @@
+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()
+ {
+ // ABP_CULTURE already covers the key here, so nothing is added; losing the comparer on the copy
+ // would add a second, lowercase entry and render "ar". Scriban itself looks names up case
+ // sensitively, hence the empty culture in the output.
+ 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());
}
}