Browse Source

Render the lang and the dir of the current culture in email templates

pull/25974/head
maliming 2 days ago
parent
commit
c092e1b4dc
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 6
      docs/en/framework/infrastructure/emailing.md
  2. 19
      docs/en/framework/infrastructure/text-templating/razor.md
  3. 19
      docs/en/framework/infrastructure/text-templating/scriban.md
  4. 2
      framework/src/Volo.Abp.Emailing/Volo/Abp/Emailing/Templates/Layout.tpl
  5. 28
      framework/src/Volo.Abp.TextTemplating.Core/Volo/Abp/TextTemplating/TemplateRenderingEngineBase.cs
  6. 13
      framework/src/Volo.Abp.TextTemplating.Razor/Volo/Abp/TextTemplating/Razor/RazorTemplateRenderingEngine.cs
  7. 13
      framework/src/Volo.Abp.TextTemplating.Scriban/Volo/Abp/TextTemplating/Scriban/ScribanTemplateRenderingEngine.cs
  8. 1
      framework/test/Volo.Abp.Emailing.Tests/Volo.Abp.Emailing.Tests.csproj
  9. 2
      framework/test/Volo.Abp.Emailing.Tests/Volo/Abp/Emailing/AbpEmailingTestModule.cs
  10. 44
      framework/test/Volo.Abp.Emailing.Tests/Volo/Abp/Emailing/Templates/StandardEmailTemplates_Tests.cs
  11. 65
      framework/test/Volo.Abp.TextTemplating.Razor.Tests/Volo/Abp/TextTemplating/Razor/RazorTemplateRenderingEngine_CultureContext_Tests.cs
  12. 4
      framework/test/Volo.Abp.TextTemplating.Razor.Tests/Volo/Abp/TextTemplating/Razor/RazorTestTemplateDefinitionProvider.cs
  13. 2
      framework/test/Volo.Abp.TextTemplating.Razor.Tests/Volo/Abp/TextTemplating/Razor/SampleTemplates/CultureContext.cshtml
  14. 1
      framework/test/Volo.Abp.TextTemplating.Razor.Tests/Volo/Abp/TextTemplating/Razor/SampleTemplates/RazorTestTemplates.cs
  15. 1
      framework/test/Volo.Abp.TextTemplating.Scriban.Tests/Volo/Abp/TextTemplating/Scriban/SampleTemplates/CultureContext.tpl
  16. 108
      framework/test/Volo.Abp.TextTemplating.Scriban.Tests/Volo/Abp/TextTemplating/Scriban/ScribanTemplateRenderingEngine_CultureContext_Tests.cs
  17. 5
      framework/test/Volo.Abp.TextTemplating.Scriban.Tests/Volo/Abp/TextTemplating/Scriban/ScribanTestTemplateDefinitionProvider.cs

6
docs/en/framework/infrastructure/emailing.md

@ -193,7 +193,7 @@ The resulting email body will be shown below:
````html
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<html lang="en" dir="ltr" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
</head>
@ -217,7 +217,7 @@ This template uses the "Abp.StandardEmailTemplates.Layout" as its layout.
````html
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<html lang="{%{{{abp_culture}}}%}" dir="{%{{{abp_dir}}}%}" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
</head>
@ -227,6 +227,8 @@ This template uses the "Abp.StandardEmailTemplates.Layout" as its layout.
</html>
````
`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.

19
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
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<html lang="@GlobalContext["abp_culture"]" dir="@GlobalContext["abp_dir"]" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
</head>
@ -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
<html lang="@GlobalContext["abp_culture"]" dir="@GlobalContext["abp_dir"]">
````
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
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<html lang="@GlobalContext["abp_culture"]" dir="@GlobalContext["abp_dir"]" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
</head>

19
docs/en/framework/infrastructure/text-templating/scriban.md

@ -302,7 +302,7 @@ First, create a template file just like before:
````xml
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<html lang="{%{{{abp_culture}}}%}" dir="{%{{{abp_dir}}}%}" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
</head>
@ -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
<html lang="{%{{{abp_culture}}}%}" dir="{%{{{abp_dir}}}%}">
````
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
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<html lang="{%{{{abp_culture}}}%}" dir="{%{{{abp_dir}}}%}" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
</head>

2
framework/src/Volo.Abp.Emailing/Volo/Abp/Emailing/Templates/Layout.tpl

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<html lang="{{abp_culture}}" dir="{{abp_dir}}" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
</head>

28
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<string> RenderAsync(string templateName, object? model = null, string? cultureName = null, Dictionary<string, object>? globalContext = null);
/// <summary>
/// Must be called inside the culture scope of the rendering. Values set by the caller are kept.
/// </summary>
protected virtual void SetCultureContext(Dictionary<string, object> 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<string?> GetContentOrNullAsync(TemplateDefinition templateDefinition)
{
return await TemplateContentProvider.GetContentOrNullAsync(templateDefinition);

13
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<string, object>();
}
// 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<string, object>()
: new Dictionary<string, object>(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,

13
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<string, object>();
}
// 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<string, object>()
: new Dictionary<string, object>(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,

1
framework/test/Volo.Abp.Emailing.Tests/Volo.Abp.Emailing.Tests.csproj

@ -9,6 +9,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Volo.Abp.Emailing\Volo.Abp.Emailing.csproj" />
<ProjectReference Include="..\..\src\Volo.Abp.TextTemplating.Scriban\Volo.Abp.TextTemplating.Scriban.csproj" />
<ProjectReference Include="..\..\src\Volo.Abp.Autofac\Volo.Abp.Autofac.csproj" />
<ProjectReference Include="..\AbpTestBase\AbpTestBase.csproj" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />

2
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

44
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<TStartupModule> : AbpIntegratedTest<TStartupModule>
where TStartupModule : IAbpModule
{
protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options)
{
options.UseAutofac();
}
}
public class StandardEmailTemplates_Tests : AbpEmailingTestBase<AbpEmailingTestModule>
{
private readonly ITemplateRenderer _templateRenderer;
public StandardEmailTemplates_Tests()
{
_templateRenderer = GetRequiredService<ITemplateRenderer>();
}
[Theory]
[InlineData("en", "<html lang=\"en\" dir=\"ltr\"")]
[InlineData("fr", "<html lang=\"fr\" dir=\"ltr\"")]
[InlineData("ar", "<html lang=\"ar\" dir=\"rtl\"")]
public async Task Should_Declare_The_Language_And_The_Direction_Of_The_Rendering_Culture(
string cultureName, string expectedHtmlTag)
{
var result = await _templateRenderer.RenderAsync(
StandardEmailTemplates.Message,
model: new { message = "This is email body..." },
cultureName: cultureName
);
result.ShouldContain(expectedHtmlTag);
result.ShouldContain("This is email body...");
}
}

65
framework/test/Volo.Abp.TextTemplating.Razor.Tests/Volo/Abp/TextTemplating/Razor/RazorTemplateRenderingEngine_CultureContext_Tests.cs

@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Shouldly;
using Volo.Abp.TextTemplating.Razor.SampleTemplates;
using Xunit;
namespace Volo.Abp.TextTemplating.Razor;
public class RazorTemplateRenderingEngine_CultureContext_Tests : AbpTextTemplatingTestBase<RazorTextTemplatingTestModule>
{
private readonly ITemplateRenderer _templateRenderer;
public RazorTemplateRenderingEngine_CultureContext_Tests()
{
_templateRenderer = GetRequiredService<ITemplateRenderer>();
}
[Theory]
[InlineData("en", "<html lang=\"en\" dir=\"ltr\">")]
[InlineData("fr", "<html lang=\"fr\" dir=\"ltr\">")]
[InlineData("ar", "<html lang=\"ar\" dir=\"rtl\">")]
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<string, object>();
(await _templateRenderer.RenderAsync(
RazorTestTemplates.CultureContext,
cultureName: "en",
globalContext: globalContext
)).Trim().ShouldBe("<html lang=\"en\" dir=\"ltr\">");
(await _templateRenderer.RenderAsync(
RazorTestTemplates.CultureContext,
cultureName: "ar",
globalContext: globalContext
)).Trim().ShouldBe("<html lang=\"ar\" dir=\"rtl\">");
globalContext.ShouldBeEmpty();
}
[Fact]
public async Task Should_Keep_The_Comparer_Of_The_Context_Of_The_Caller()
{
var globalContext = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase)
{
["ABP_CULTURE"] = "custom"
};
(await _templateRenderer.RenderAsync(
RazorTestTemplates.CultureContext,
cultureName: "ar",
globalContext: globalContext
)).Trim().ShouldBe("<html lang=\"custom\" dir=\"rtl\">");
}
}

4
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());
}
}

2
framework/test/Volo.Abp.TextTemplating.Razor.Tests/Volo/Abp/TextTemplating/Razor/SampleTemplates/CultureContext.cshtml

@ -0,0 +1,2 @@
@inherits Volo.Abp.TextTemplating.Razor.RazorTemplatePageBase
<html lang="@GlobalContext["abp_culture"]" dir="@GlobalContext["abp_dir"]">

1
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";
}

1
framework/test/Volo.Abp.TextTemplating.Scriban.Tests/Volo/Abp/TextTemplating/Scriban/SampleTemplates/CultureContext.tpl

@ -0,0 +1 @@
<html lang="{{abp_culture}}" dir="{{abp_dir}}">

108
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<ScribanTextTemplatingTestModule>
{
private readonly ITemplateRenderer _templateRenderer;
public ScribanTemplateRenderingEngine_CultureContext_Tests()
{
_templateRenderer = GetRequiredService<ITemplateRenderer>();
}
[Theory]
[InlineData("en", "<html lang=\"en\" dir=\"ltr\">")]
[InlineData("fr", "<html lang=\"fr\" dir=\"ltr\">")]
[InlineData("ar", "<html lang=\"ar\" dir=\"rtl\">")]
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<string, object>
{
[TemplateRenderingEngineBase.CultureContextKey] = "custom"
}
)).ShouldBe("<html lang=\"custom\" dir=\"rtl\">");
}
[Fact]
public async Task Should_Not_Leak_The_Culture_Of_A_Rendering_Into_The_Next_One()
{
var globalContext = new Dictionary<string, object>();
(await _templateRenderer.RenderAsync(
ScribanTestTemplateDefinitionProvider.CultureContext,
cultureName: "en",
globalContext: globalContext
)).ShouldBe("<html lang=\"en\" dir=\"ltr\">");
(await _templateRenderer.RenderAsync(
ScribanTestTemplateDefinitionProvider.CultureContext,
cultureName: "ar",
globalContext: globalContext
)).ShouldBe("<html lang=\"ar\" dir=\"rtl\">");
globalContext.ShouldBeEmpty();
}
[Fact]
public async Task Should_Not_Write_The_Layout_Content_Into_The_Context_Of_The_Caller()
{
var globalContext = new Dictionary<string, object>();
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<string, object>(StringComparer.OrdinalIgnoreCase)
{
["ABP_CULTURE"] = "custom"
};
(await _templateRenderer.RenderAsync(
ScribanTestTemplateDefinitionProvider.CultureContext,
cultureName: "ar",
globalContext: globalContext
)).ShouldBe("<html lang=\"\" dir=\"rtl\">");
}
[Fact]
public async Task Should_Fall_Back_To_English_For_The_Invariant_Culture()
{
using (CultureHelper.Use(CultureInfo.InvariantCulture))
{
(await _templateRenderer.RenderAsync(
ScribanTestTemplateDefinitionProvider.CultureContext
)).ShouldBe("<html lang=\"en\" dir=\"ltr\">");
}
}
}

5
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());
}
}

Loading…
Cancel
Save