diff --git a/Lombiq.VueJs.Samples/Assets/Scripts/VueComponents/demo-sfc.vue b/Lombiq.VueJs.Samples/Assets/Scripts/VueComponents/demo-sfc.vue index c63f550..29cfa34 100644 --- a/Lombiq.VueJs.Samples/Assets/Scripts/VueComponents/demo-sfc.vue +++ b/Lombiq.VueJs.Samples/Assets/Scripts/VueComponents/demo-sfc.vue @@ -18,6 +18,31 @@ [[ Hello! ]] + + +

[[{ Does HTML localization escape HTML? NO! }]]

+ + + [[{liquid} +
+

{{ "Liquid example! (localized)" | t }}

+ The current time is: {{ "now" | utc | date: "%c" }} +
]] + + + [[{markdown} +## Markdown Example + +Here is some _Markdown_ content. For more info, see [the docs](https://docs.orchardcore.net/en/main/docs/reference/modules/Markdown/). + ]] + + diff --git a/Lombiq.VueJs.Tests.UI/Extensions/TestCaseUITestContextExtensions.cs b/Lombiq.VueJs.Tests.UI/Extensions/TestCaseUITestContextExtensions.cs index 28ceb14..2dd8a7d 100644 --- a/Lombiq.VueJs.Tests.UI/Extensions/TestCaseUITestContextExtensions.cs +++ b/Lombiq.VueJs.Tests.UI/Extensions/TestCaseUITestContextExtensions.cs @@ -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) diff --git a/Lombiq.VueJs/Services/IVueTemplateExpressionConverter.cs b/Lombiq.VueJs/Services/IVueTemplateExpressionConverter.cs new file mode 100644 index 0000000..c53afe8 --- /dev/null +++ b/Lombiq.VueJs/Services/IVueTemplateExpressionConverter.cs @@ -0,0 +1,22 @@ +using OrchardCore.DisplayManagement.Implementation; +using System.Threading.Tasks; + +namespace Lombiq.VueJs.Services; + +/// +/// A service that handles [[{name} input ]] expressions in Vue SFC templates. Used by . +/// +public interface IVueTemplateExpressionConverter +{ + /// + /// Returns a value indicating whether this converter should handle the provided , typically + /// based on the . + /// + bool IsApplicable(string name, string input, DisplayContext displayContext); + + /// + /// Returns the output that should be substituted instead of the provided expression. + /// + ValueTask ConvertAsync(string name, string input, DisplayContext displayContext); +} diff --git a/Lombiq.VueJs/Services/LiquidVueTemplateExpressionConverter.cs b/Lombiq.VueJs/Services/LiquidVueTemplateExpressionConverter.cs new file mode 100644 index 0000000..69f0f1d --- /dev/null +++ b/Lombiq.VueJs/Services/LiquidVueTemplateExpressionConverter.cs @@ -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 ConvertAsync(string name, string input, DisplayContext displayContext) => + await _liquidTemplateManager.RenderStringAsync( + input, + NullEncoder.Default, + displayContext); +} diff --git a/Lombiq.VueJs/Services/MarkdownVueTemplateExpressionConverter.cs b/Lombiq.VueJs/Services/MarkdownVueTemplateExpressionConverter.cs new file mode 100644 index 0000000..b7297f2 --- /dev/null +++ b/Lombiq.VueJs/Services/MarkdownVueTemplateExpressionConverter.cs @@ -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 ConvertAsync(string name, string input, DisplayContext displayContext) => + ValueTask.FromResult(_markdownService.ToHtml(input)); +} diff --git a/Lombiq.VueJs/Services/VueSingleFileComponentShapeTemplateViewEngine.cs b/Lombiq.VueJs/Services/VueSingleFileComponentShapeTemplateViewEngine.cs index 22c3a2d..90fc690 100644 --- a/Lombiq.VueJs/Services/VueSingleFileComponentShapeTemplateViewEngine.cs +++ b/Lombiq.VueJs/Services/VueSingleFileComponentShapeTemplateViewEngine.cs @@ -1,7 +1,8 @@ -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; @@ -9,6 +10,7 @@ using System.IO; using System.Linq; using System.Net; +using System.Text; using System.Threading.Tasks; namespace Lombiq.VueJs.Services; @@ -20,7 +22,10 @@ public class VueSingleFileComponentShapeTemplateViewEngine : IShapeTemplateViewE private readonly IShapeTemplateFileProviderAccessor _fileProviderAccessor; private readonly IMemoryCache _memoryCache; private readonly IStringLocalizerFactory _stringLocalizerFactory; + private readonly IHtmlLocalizerFactory _htmlLocalizerFactory; + private readonly ILogger _logger; private readonly IEnumerable _amenders; + private readonly IEnumerable _converters; public IEnumerable TemplateFileExtensions { get; } = new[] { ".vue" }; @@ -28,46 +33,117 @@ public VueSingleFileComponentShapeTemplateViewEngine( IShapeTemplateFileProviderAccessor fileProviderAccessor, IMemoryCache memoryCache, IStringLocalizerFactory stringLocalizerFactory, - IEnumerable amenders) + IHtmlLocalizerFactory htmlLocalizerFactory, + ILogger logger, + IEnumerable amenders, + IEnumerable converters) { _fileProviderAccessor = fileProviderAccessor; _memoryCache = memoryCache; _stringLocalizerFactory = stringLocalizerFactory; + _htmlLocalizerFactory = htmlLocalizerFactory; + _logger = logger; _amenders = amenders; + _converters = converters; } public async Task 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($""); + builder.Append(""); var entries = new List(); 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 localizationRanges, + DisplayContext context) + { + var shapeName = context.Value.Metadata.Type; + var stringLocalizerLazy = new Lazy(() => _stringLocalizerFactory.Create("Vue.js SFC", shapeName)); + var htmlLocalizerLazy = new Lazy(() => _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 GetTemplateAsync(string relativePath) { var cacheName = CachePrefix + relativePath; diff --git a/Lombiq.VueJs/Startup.cs b/Lombiq.VueJs/Startup.cs index c894ea8..acadf61 100644 --- a/Lombiq.VueJs/Startup.cs +++ b/Lombiq.VueJs/Startup.cs @@ -27,5 +27,8 @@ public override void ConfigureServices(IServiceCollection services) services.AddTransient, ResourceManagementOptionsConfiguration>(); services.AddAsyncResultFilter(); + + services.AddScoped(); + services.AddScoped(); } } diff --git a/Readme.md b/Readme.md index 484c5f3..f2cfe67 100644 --- a/Readme.md +++ b/Readme.md @@ -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 `