Skip to content

Commit

Permalink
Merge pull request #133 from Lombiq/issue/OSOE-837
Browse files Browse the repository at this point in the history
OSOE-837: Support localized HTML strings in Lombiq.VueJs
  • Loading branch information
wAsnk authored Apr 30, 2024
2 parents df20339 + 3b8c756 commit 8b0e6fa
Show file tree
Hide file tree
Showing 8 changed files with 204 additions and 17 deletions.
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 " +
"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

0 comments on commit 8b0e6fa

Please sign in to comment.