Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OSOE-837: Support localized HTML strings in Lombiq.VueJs #133

Merged
merged 13 commits into from
Apr 30, 2024
25 changes: 25 additions & 0 deletions Lombiq.VueJs.Samples/Assets/Scripts/VueComponents/demo-sfc.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,31 @@
<!-- Here you can see a non-standard expression. The "[[ ... ]]" is unique to this OC
module: it performs localization via IStringLocalizer at runtime. -->
<demo-repeater :data="repeaterData">[[ Hello! ]]</demo-repeater>

<!-- You can use different converters inside the "[[ ... ]]" to achieve different results. For example the
the "[[{ ... }]]" expression runs the contents through IHtmlLocalizer, and treats it like encoded HTML. -->
<p>[[{ Does HTML localization escape HTML? <span class="not-html" hidden>YES!</span> <span class="encoded-html">NO!</span> }]]</p>

<!-- Besides that special case, you can use custom services that implement IVueTemplateConverter. For example
here we use the built-in Liquid converter via the "[[{liquid} ... ]]" format. Note that both here and the
special case for HTML, you must not have a space between the "[[" and the "{" characters. This ensures,
that older well-formatted strings won't be affected.
Note that these expressions are substituted server-side, so including "{{ ... }}" from Liquid won't cause
problems. -->
[[{liquid}
<div class="{{ "A Liquid Example" | html_class }}">
<h2>{{ "Liquid example! (localized)" | t }}</h2>
The current time is: {{ "now" | utc | date: "%c" }}
</div> ]]

<!-- Example of the other built-in converter, for Markdown. -->
[[{markdown}
## Markdown Example

Here is some _Markdown_ content. For more info, see [the docs](https://docs.orchardcore.net/en/main/docs/reference/modules/Markdown/).
]]

<!-- By the way HTML comments are also stripped out both to save on bandwidth and for security. -->
</div>
</template>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ public static async Task TestVueSfcASync(this UITestContext context)

// Verify localizer HTML escaping.
context.Missing(By.ClassName("not-html"));
context.Exists(By.ClassName("encoded-html"));

// Verify Liquid and Markdown working.
context.Get(By.CssSelector(".a-liquid-example h2")).Text.ShouldContain("Liquid example! (localized)");
context.Get(By.Id("markdown-example")).Text.ShouldContain("Markdown Example");
context
.Get(By.LinkText("the docs"))
.GetAttribute("href")
.ShouldBe("https://docs.orchardcore.net/en/main/docs/reference/modules/Markdown/");
}

public static async Task TestVueSfcEnhancedListAsync(this UITestContext context)
Expand Down
22 changes: 22 additions & 0 deletions Lombiq.VueJs/Services/IVueTemplateExpressionConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using OrchardCore.DisplayManagement.Implementation;
using System.Threading.Tasks;

namespace Lombiq.VueJs.Services;

/// <summary>
/// A service that handles <c>[[{name} input ]]</c> expressions in Vue SFC templates. Used by <see
/// cref="VueSingleFileComponentShapeTemplateViewEngine"/>.
/// </summary>
public interface IVueTemplateExpressionConverter
{
/// <summary>
/// Returns a value indicating whether this converter should handle the provided <paramref name="input"/>, typically
/// based on the <paramref name="name"/>.
/// </summary>
bool IsApplicable(string name, string input, DisplayContext displayContext);

/// <summary>
/// Returns the output that should be substituted instead of the provided expression.
/// </summary>
ValueTask<string> ConvertAsync(string name, string input, DisplayContext displayContext);
}
24 changes: 24 additions & 0 deletions Lombiq.VueJs/Services/LiquidVueTemplateExpressionConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Fluid;
using OrchardCore.DisplayManagement.Implementation;
using OrchardCore.Liquid;
using System;
using System.Threading.Tasks;

namespace Lombiq.VueJs.Services;

public class LiquidVueTemplateExpressionConverter : IVueTemplateExpressionConverter
{
private readonly ILiquidTemplateManager _liquidTemplateManager;

public LiquidVueTemplateExpressionConverter(ILiquidTemplateManager liquidTemplateManager) =>
_liquidTemplateManager = liquidTemplateManager;

public bool IsApplicable(string name, string input, DisplayContext displayContext) =>
"liquid".EqualsOrdinalIgnoreCase(name);

public async ValueTask<string> ConvertAsync(string name, string input, DisplayContext displayContext) =>
await _liquidTemplateManager.RenderStringAsync(
input,
NullEncoder.Default,
displayContext);
}
20 changes: 20 additions & 0 deletions Lombiq.VueJs/Services/MarkdownVueTemplateExpressionConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using OrchardCore.DisplayManagement.Implementation;
using OrchardCore.Markdown.Services;
using System;
using System.Threading.Tasks;

namespace Lombiq.VueJs.Services;

public class MarkdownVueTemplateExpressionConverter : IVueTemplateExpressionConverter
{
private readonly IMarkdownService _markdownService;

public MarkdownVueTemplateExpressionConverter(IMarkdownService markdownService) =>
_markdownService = markdownService;

public bool IsApplicable(string name, string input, DisplayContext displayContext) =>
"markdown".EqualsOrdinalIgnoreCase(name);

public ValueTask<string> ConvertAsync(string name, string input, DisplayContext displayContext) =>
ValueTask.FromResult(_markdownService.ToHtml(input));
}
108 changes: 92 additions & 16 deletions Lombiq.VueJs/Services/VueSingleFileComponentShapeTemplateViewEngine.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
using Lombiq.HelpfulLibraries.Common.Utilities;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
using OrchardCore.DisplayManagement.Descriptors.ShapeTemplateStrategy;
using OrchardCore.DisplayManagement.Implementation;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;

namespace Lombiq.VueJs.Services;
Expand All @@ -20,54 +22,128 @@ public class VueSingleFileComponentShapeTemplateViewEngine : IShapeTemplateViewE
private readonly IShapeTemplateFileProviderAccessor _fileProviderAccessor;
private readonly IMemoryCache _memoryCache;
private readonly IStringLocalizerFactory _stringLocalizerFactory;
private readonly IHtmlLocalizerFactory _htmlLocalizerFactory;
private readonly ILogger<VueSingleFileComponentShapeTemplateViewEngine> _logger;
private readonly IEnumerable<IVueSingleFileComponentShapeAmender> _amenders;
private readonly IEnumerable<IVueTemplateExpressionConverter> _converters;

public IEnumerable<string> TemplateFileExtensions { get; } = new[] { ".vue" };

public VueSingleFileComponentShapeTemplateViewEngine(
IShapeTemplateFileProviderAccessor fileProviderAccessor,
IMemoryCache memoryCache,
IStringLocalizerFactory stringLocalizerFactory,
IEnumerable<IVueSingleFileComponentShapeAmender> amenders)
IHtmlLocalizerFactory htmlLocalizerFactory,
ILogger<VueSingleFileComponentShapeTemplateViewEngine> logger,
IEnumerable<IVueSingleFileComponentShapeAmender> amenders,
IEnumerable<IVueTemplateExpressionConverter> converters)
{
_fileProviderAccessor = fileProviderAccessor;
_memoryCache = memoryCache;
_stringLocalizerFactory = stringLocalizerFactory;
_htmlLocalizerFactory = htmlLocalizerFactory;
_logger = logger;
_amenders = amenders;
_converters = converters;
}

public async Task<IHtmlContent> RenderAsync(string relativePath, DisplayContext displayContext)
{
var template = await GetTemplateAsync(relativePath);

var localizationRanges = template
.AllIndexesOf("[[")
.Where(index => template[(index + 2)..].Contains("]]"))
.Select(index => new Range(
index,
template.IndexOfOrdinal(value: "]]", startIndex: index + 2) + 2))
.WithoutOverlappingRanges(isSortedByStart: true);
// Remove all HTML comments. This is done first, because HTML comments take precedence over everything else.
// This way the contents of comments are guaranteed to not be evaluated.
template = template
.GetParenthesisRanges("<!--", "-->")
.InvertRanges(template.Length)
.Join(template);

var shapeName = displayContext.Value.Metadata.Type;
var stringLocalizer = _stringLocalizerFactory.Create("Vue.js SFC", shapeName);
var builder = new StringBuilder($"<script type=\"x-template\" class=\"{shapeName}\">");

foreach (var range in localizationRanges.OrderByDescending(range => range.End.Value))
var localizationRanges = template.GetParenthesisRanges("[[", "]]");
if (localizationRanges.Count > 0)
{
var (before, expression, after) = template.Partition(range);
var text = expression[2..^2].Trim();
template = before + WebUtility.HtmlEncode(stringLocalizer[text]) + after;
await LocalizeRangesAsync(builder, template, localizationRanges, displayContext);
}
else
{
builder.Append(template);
}

template = StringHelper.CreateInvariant($"<script type=\"x-template\" class=\"{shapeName}\">{template}</script>");
builder.Append("</script>");

var entries = new List<object>();
foreach (var amender in _amenders) entries.AddRange(await amender.PrependAsync(shapeName));
entries.Add(new HtmlString(template));
entries.Add(new HtmlString(builder.ToString()));
foreach (var amender in _amenders) entries.AddRange(await amender.AppendAsync(shapeName));

return new HtmlContentBuilder(entries);
}

private async Task LocalizeRangesAsync(
StringBuilder builder,
string template,
IList<Range> localizationRanges,
DisplayContext context)
{
var shapeName = context.Value.Metadata.Type;
var stringLocalizerLazy = new Lazy<IStringLocalizer>(() => _stringLocalizerFactory.Create("Vue.js SFC", shapeName));
var htmlLocalizerLazy = new Lazy<IHtmlLocalizer>(() => _htmlLocalizerFactory.Create("Vue.js SFC HTML", shapeName));

var startIndex = new Index(0);
foreach (var range in localizationRanges)
{
// Insert content before this range.
builder.Append(template[startIndex..range.Start]);
startIndex = range.End;

var expression = template[range];
string html;

// Include a logger warning if the inner spacing is missing. This will cause failures e.g. during UI tests,
// and so ensure correct formatting.
if (expression[2] is not '{' and not ' ')
{
_logger.LogWarning(
"Vue SFC localization strings should follow the following formats: [[ text ]], [[{{ html }}]] or " +
"[[{{converter}} input ]]. Please include the inner spacing to ensure future compatibility. Your " +
wAsnk marked this conversation as resolved.
Show resolved Hide resolved
"expression was: \"{Expression}\".",
expression);
}

// Handle HTML localization.
if (expression[2] == '{' && expression[^3] == '}')
{
var value = expression[3..^3].Trim();
html = htmlLocalizerLazy.Value[value].Html();
}
else if (expression[2] == '{')
{
var (name, _, input) = expression[3..^2].Partition("}");
name = name.Trim();
input = input.Trim();

if (_converters.FirstOrDefault(converter => converter.IsApplicable(name, input, context)) is not { } converter)
{
throw new InvalidOperationException($"Unknown converter type \"{name}\".");
}

html = await converter.ConvertAsync(name, input, context) ?? string.Empty;
}
else
{
var value = expression[2..^2].Trim();
html = WebUtility.HtmlEncode(stringLocalizerLazy.Value[value]);
}

builder.Append(html);
}

// Insert leftover content after the last range.
builder.Append(template[localizationRanges[^1].End..]);
}

private async Task<string> GetTemplateAsync(string relativePath)
{
var cacheName = CachePrefix + relativePath;
Expand Down
3 changes: 3 additions & 0 deletions Lombiq.VueJs/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,8 @@ public override void ConfigureServices(IServiceCollection services)

services.AddTransient<IConfigureOptions<ResourceManagementOptions>, ResourceManagementOptionsConfiguration>();
services.AddAsyncResultFilter<ScriptModuleResourceFilter>();

services.AddScoped<IVueTemplateExpressionConverter, LiquidVueTemplateExpressionConverter>();
services.AddScoped<IVueTemplateExpressionConverter, MarkdownVueTemplateExpressionConverter>();
}
}
10 changes: 9 additions & 1 deletion Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,15 @@ Here we excluded `vue` and packages starting with `vuetify` (e.g. `vuetify/compo

## Using Vue.js Single File Components

The module identifies Single File Components in the _Assets/Scripts/VueComponents_ directory and harvests them as shapes. They have a custom _.vue_ file renderer that displays the content of the `<template>` element after applying localization for the custom `[[ ... ]]` expression that calls `IStringLocalizer`. Besides that, it's pure Vue, yet you can still make use of shape overriding if needed.
The module identifies Single File Components in the _Assets/Scripts/VueComponents_ directory and harvests them as shapes. They have a custom _.vue_ file renderer that displays the content of the `<template>` element after applying server-side substitution. Besides that, it's pure Vue, yet you can still make use of shape overriding if needed.

The following types of server-side substitution are available:

- `[[ ... ]]`: Uses `IStringLocalizer` and encodes the result so it's safe to use in HTML.
- `[[{ ... }]]`: Uses `IHtmlLocalizer` and results in raw HTML just like the `@T["..."]` in cshtml views. You can use this for localizing HTML that may contain formatting or structure.
- `[[{converter_name} input ]]`: Finds an `IVueTemplateExpressionConverter` implementation to generate the replacement text. This module contains two implementations by default, but you can create your own as well.
- `[[{liquid} some Liquid expression ]]`: Uses [Liquid](https://docs.orchardcore.net/en/main/docs/reference/modules/Liquid/), including of the filters registered by Orchard Core.
- `[[{markdown} some Markdown expression ]]`: Uses [Markdown](https://docs.orchardcore.net/en/main/docs/reference/modules/Markdown/).

See a demo video of using Vue.js Single File Components [here](https://www.youtube.com/watch?v=L0qjpQ6THZU).

Expand Down