Skip to content

Commit

Permalink
Merge pull request #237 from Lombiq/issue/OSOE-338
Browse files Browse the repository at this point in the history
OSOE-338: Javascript Module Support
  • Loading branch information
wAsnk authored Feb 21, 2024
2 parents 663a91d + b69d696 commit 774b1df
Show file tree
Hide file tree
Showing 11 changed files with 341 additions and 12 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Razor.TagHelpers;
using System.IO;
using System.Web;

namespace Microsoft.AspNetCore.Mvc.Rendering;

public static class JsonHelperExtensions
{
/// <summary>
/// Returns a full HTML element attribute with the given <paramref name="name"/> prefixed with <c>data-</c> and the
/// value appropriately encoded to prevent XSS attacks.
/// </summary>
public static IHtmlContent DataAttribute(this IJsonHelper helper, string name, object value)
{
using var stringWriter = new StringWriter();
helper.Serialize(value).WriteTo(stringWriter, NullHtmlEncoder.Default);

return new HtmlString($"data-{name}=\"{HttpUtility.HtmlAttributeEncode(stringWriter.ToString())}\"");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public class CdnContentSecurityPolicyProvider : IContentSecurityPolicyProvider
new Uri("https://fonts.googleapis.com/css"),
new Uri("https://fonts.gstatic.com/"),
new Uri("https://cdn.jsdelivr.net/npm"),
new Uri("https://fastly.jsdelivr.net/npm"),
});

/// <summary>
Expand All @@ -31,6 +32,7 @@ public class CdnContentSecurityPolicyProvider : IContentSecurityPolicyProvider
public static ConcurrentBag<Uri> PermittedScriptSources { get; } = new(new[]
{
new Uri("https://cdn.jsdelivr.net/npm"),
new Uri("https://fastly.jsdelivr.net/npm"),
});

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using static Lombiq.HelpfulLibraries.AspNetCore.Security.ContentSecurityPolicyDirectives;

Expand All @@ -22,7 +24,11 @@ public interface IContentSecurityPolicyProvider
/// Returns the first non-empty directive from the <paramref name="names"/> or <see cref="DefaultSrc"/> or an empty
/// string.
/// </summary>
public static string GetDirective(IDictionary<string, string> securityPolicies, params string[] names)
public static string GetDirective(IDictionary<string, string> securityPolicies, params string[] names) =>
GetDirective(securityPolicies, names.AsEnumerable());

/// <inheritdoc cref="GetDirective(System.Collections.Generic.IDictionary{string,string},string[])"/>
public static string GetDirective(IDictionary<string, string> securityPolicies, IEnumerable<string> names)
{
foreach (var name in names)
{
Expand All @@ -34,4 +40,17 @@ public static string GetDirective(IDictionary<string, string> securityPolicies,

return securityPolicies.GetMaybe(DefaultSrc) ?? string.Empty;
}

/// <summary>
/// Updates the directive (the first entry of the <paramref name="directiveNameChain"/>) by merging its space
/// separated values with the values from <paramref name="otherValues"/>.
/// </summary>
public static void MergeDirectiveValues(
IDictionary<string, string> securityPolicies,
IEnumerable<string> directiveNameChain,
params string[] otherValues)
{
var nameChain = directiveNameChain.AsList();
securityPolicies[nameChain[0]] = GetDirective(securityPolicies, nameChain).MergeWordSets(otherValues);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ public class ResourceFilters : IResourceFilterProvider
}
```

## Javascript Module Support

The `ScriptModuleResourceFilter` makes it possible to register JS modules in a way that they can be imported by name, so no bundling or importing by URL is necessary. Once you've added it to your service collection (`services.AddAsyncResultFilter<ScriptModuleResourceFilter>();`) you can register modules with the `ResourceManifest.DefineScriptModule(resourceName)` extension method and require them using the `IResourceManager.RegisterScriptModule(resourceName)` extension method.

You don't even have to register dependencies, because thanks to the [importmap script](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) generated by the `ScriptModuleResourceFilter`, you can import any resource using the `import * from 'resourceName'` syntax. You can see an example in Lombiq.VueJs where Vue 3 support is added ahead of Orchard Core by [importing Vue 3 as a JS module](https://github.com/Lombiq/Orchard-Vue.js/blob/dev/Lombiq.VueJs/Assets/Scripts/vue-component-app.mjs#L1C4-L1C4).

## Extensions

- `ApplicationBuilderExtensions`: Shortcut extensions for application setup, such as `UseResourceFilters()` (see above).
Expand Down
2 changes: 1 addition & 1 deletion Lombiq.HelpfulLibraries.OrchardCore/Docs/Shapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

- `LayoutExtensions`: Adds features for adding shapes to the layout via `ILayoutAccessor`.
- `ServiceCollectionExtensions`: Allows adding `ShapeRenderer` to the service collection via `AddShapeRenderer()`.
- `ShapeExtensions`: Some shortcuts for managing shapes.
- `ShapeExtensions`: Some shortcuts for managing shapes and a pair of extensions for creating ad-hoc shapes and injecting them into the shape table.

## Shape rendering

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
using AngleSharp.Common;
using Lombiq.HelpfulLibraries.OrchardCore.ResourceManagement;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using OrchardCore.ResourceManagement.TagHelpers;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;

namespace OrchardCore.ResourceManagement;

Expand Down Expand Up @@ -45,6 +55,154 @@ public static HtmlString RenderAndTransformHeader(
return new HtmlString(headerHtml);
}

/// <summary>
/// Adds a <c>script-module</c> resource to the manifest. All of these resources are mapped using <see
/// cref="GetScriptModuleImportMap(IOrchardHelper)"/> so they can be imported by module type scripts using the
/// <c>import ResourceName from 'resourceName'</c> statement.
/// </summary>
public static ResourceDefinition DefineScriptModule(this ResourceManifest manifest, string name) =>
manifest.DefineResource(ResourceTypes.ScriptModule, name);

/// <summary>
/// Registers a <c>script-module</c> resource to be used on the current page. These can be rendered using <see
/// cref="GetRequiredScriptModuleTags"/> as <c>&lt;script src="..." type="module"&gt;</c> elements.
/// </summary>
public static RequireSettings RegisterScriptModule(this IResourceManager resourceManager, string name) =>
resourceManager.RegisterResource(ResourceTypes.ScriptModule, name);

/// <summary>
/// Turns the required <c>script-module</c> resources into <c>&lt;script src="..." type="module"&gt;</c> elements.
/// </summary>
/// <param name="basePath">
/// The path that's used to resolve <c>~</c> in the resource URLs. Typically <see
/// cref="ResourceManagementOptions.ContentBasePath"/> should be used..
/// </param>
/// <param name="filter">
/// If not <see langword="null"/> it's used to select which required resources should be considered.
/// </param>
public static IEnumerable<TagBuilder> GetRequiredScriptModuleTags(
this IResourceManager resourceManager,
string basePath = null,
Func<ResourceRequiredContext, bool> filter = null)
{
var contexts = resourceManager.GetRequiredResources(ResourceTypes.ScriptModule);
if (filter != null) contexts = contexts.Where(filter);

return contexts.Select(context =>
{
var builder = new TagBuilder("script")
{
TagRenderMode = TagRenderMode.Normal,
Attributes =
{
["type"] = "module",
["src"] = context.Resource.GetResourceUrl(
context.FileVersionProvider,
context.Settings.DebugMode,
context.Settings.CdnMode,
basePath),
},
};
builder.MergeAttributes(context.Resource.Attributes, replaceExisting: true);

return builder;
});
}

/// <summary>
/// Turns the required <c>script-module</c> resource with the <paramref name="resourceName"/> into a
/// <c>&lt;script src="..." type="module"&gt;</c> element.
/// </summary>
/// <param name="basePath">
/// The path that's used to resolve <c>~</c> in the resource URLs. Typically <see
/// cref="ResourceManagementOptions.ContentBasePath"/> should be used..
/// </param>
/// <param name="resourceName">The expected value of <see cref="ResourceDefinition.Name"/>.</param>
public static TagBuilder GetRequiredScriptModuleTag(
this IResourceManager resourceManager,
string basePath,
string resourceName) =>
resourceManager
.GetRequiredScriptModuleTags(
basePath,
context => context.Resource.Name == resourceName)
.FirstOrDefault();

/// <summary>
/// Returns a <c>&lt;script type="importmap"&gt;</c> element that maps all the registered module resources by
/// resource name to their respective URLs so you can import these resources in your module type scripts using
/// <c>import someModule from 'resourceName'</c> instead of using the full resource URL. This way import will work
/// regardless of your CDN configuration.
/// </summary>
public static IHtmlContent GetScriptModuleImportMap(
this ResourceManagementOptions resourceOptions,
IEnumerable<ResourceManifest> resourceManifests,
IFileVersionProvider fileVersionProvider)
{
var imports = (resourceManifests ?? resourceOptions.ResourceManifests)
.SelectMany(manifest => manifest.GetResources(ResourceTypes.ScriptModule).Values)
.SelectMany(list => list)
.ToDictionary(
resource => resource.Name,
resource => resource.GetResourceUrl(
fileVersionProvider,
resourceOptions.DebugMode,
resourceOptions.UseCdn,
resourceOptions.ContentBasePath));

var tagBuilder = new TagBuilder("script")
{
TagRenderMode = TagRenderMode.Normal,
Attributes = { ["type"] = "importmap" },
};

tagBuilder.InnerHtml.AppendHtml(JsonSerializer.Serialize(new { imports }));
return tagBuilder;
}

/// <inheritdoc cref="GetScriptModuleImportMap(ResourceManagementOptions, IEnumerable{ResourceManifest}, IFileVersionProvider)"/>
internal static IHtmlContent GetScriptModuleImportMap(this IServiceProvider serviceProvider)
{
var options = serviceProvider.GetRequiredService<IOptions<ResourceManagementOptions>>().Value;
var resourceManager = serviceProvider.GetRequiredService<IResourceManager>();
var fileVersionProvider = serviceProvider.GetRequiredService<IFileVersionProvider>();

return options.GetScriptModuleImportMap(
options.ResourceManifests.Concat(resourceManager.InlineManifest),
fileVersionProvider);
}

/// <inheritdoc cref="GetScriptModuleImportMap(ResourceManagementOptions, IEnumerable{ResourceManifest}, IFileVersionProvider)"/>
public static IHtmlContent GetScriptModuleImportMap(this IOrchardHelper helper) =>
helper.HttpContext.RequestServices.GetScriptModuleImportMap();

private static string GetResourceUrl(
this ResourceDefinition definition,
IFileVersionProvider fileVersionProvider,
bool isDebug,
bool isCdn,
PathString basePath)
{
static string Coalesce(params string[] strings) => strings.Find(str => !string.IsNullOrEmpty(str));

var url = (isDebug, isCdn) switch
{
(true, true) => Coalesce(definition.UrlCdnDebug, definition.UrlDebug, definition.UrlCdn, definition.Url),
(true, false) => Coalesce(definition.UrlDebug, definition.Url, definition.UrlCdnDebug, definition.UrlCdn),
(false, true) => Coalesce(definition.UrlCdn, definition.Url, definition.UrlCdnDebug, definition.UrlDebug),
(false, false) => Coalesce(definition.Url, definition.UrlDebug, definition.UrlCdn, definition.UrlCdnDebug),
};

if (string.IsNullOrEmpty(url)) return url;

if (url.StartsWith("~/", StringComparison.Ordinal))
{
url = basePath.Value?.TrimEnd('/') + url[1..];
}

return fileVersionProvider.AddFileVersionToPath(basePath, url);
}

private static RequireSettings SetVersionIfAny(RequireSettings requireSettings, string version)
{
if (!string.IsNullOrEmpty(version)) requireSettings.UseVersion(version);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Lombiq.HelpfulLibraries.OrchardCore.ResourceManagement;

public static class ResourceTypes
{
public const string ScriptModule = "script-module";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using Lombiq.HelpfulLibraries.OrchardCore.Contents;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using OrchardCore.DisplayManagement.Implementation;
using OrchardCore.DisplayManagement.Layout;
using OrchardCore.ResourceManagement;
using System;
using System.Linq;
using System.Threading.Tasks;

namespace Lombiq.HelpfulLibraries.OrchardCore.ResourceManagement;

// Don't replace the "script-module" there with <c>script-module</c> as that will cause the DOC105UseParamref analyzer
// to throw NullReferenceException. The same doesn't seem to happen in other files, for example the
// ResourceManagerExtensions.cs in this directory.

/// <summary>
/// A filter that looks for the required "script-module" resources. If there were any, it injects the input map
/// (used for mapping module names to URLs) of all registered module resources and the script blocks of the currently
/// required resource.
/// </summary>
public record ScriptModuleResourceFilter(ILayoutAccessor LayoutAccessor) : IAsyncResultFilter
{
public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
{
var shape = await context.HttpContext.RequestServices.CreateAdHocShapeForCurrentThemeAsync(
nameof(ScriptModuleResourceFilter),
displayContext => Task.FromResult(DisplayScriptModuleResources(displayContext.ServiceProvider)));

await LayoutAccessor.AddShapeToZoneAsync("Content", shape, "After");
await next();
}

// We can't safely inject resources from the constructor because some resources may get disposed by the time this
// display action takes place, leading to potential access of disposed objects. Instead, the DisplayContext's
// service provider is used.
private static IHtmlContent DisplayScriptModuleResources(IServiceProvider serviceProvider)
{
// Won't work correctly with injected resources, the scriptElements below will be empty. Possibly related to the
// IResourceManager.InlineManifest being different.
var resourceManager = serviceProvider.GetRequiredService<IResourceManager>();
var options = serviceProvider.GetRequiredService<IOptions<ResourceManagementOptions>>().Value;

var scriptElements = resourceManager.GetRequiredScriptModuleTags(options.ContentBasePath).ToList();
if (scriptElements.Count == 0) return null;

var importMap = serviceProvider.GetScriptModuleImportMap();
var content = new HtmlContentBuilder(capacity: scriptElements.Count + 1).AppendHtml(importMap);
foreach (var script in scriptElements) content.AppendHtml(script);

return content;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,10 @@ public ValueTask UpdateAsync(IDictionary<string, string> securityPolicies, HttpC
{
foreach (var attribute in actionDescriptor.MethodInfo.GetCustomAttributes<ContentSecurityPolicyAttribute>())
{
securityPolicies[ScriptSrc] = IContentSecurityPolicyProvider
.GetDirective(securityPolicies, attribute.DirectiveNames)
.MergeWordSets(attribute.DirectiveValue);
IContentSecurityPolicyProvider.MergeDirectiveValues(
securityPolicies,
attribute.DirectiveNames,
attribute.DirectiveValue);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using Lombiq.HelpfulLibraries.AspNetCore.Security;
using Microsoft.AspNetCore.Http;
using OrchardCore.ResourceManagement;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
Expand Down Expand Up @@ -32,12 +31,7 @@ public ValueTask UpdateAsync(IDictionary<string, string> securityPolicies, HttpC

if (resourceExists)
{
// False positive, see: https://github.com/SonarSource/sonar-dotnet/issues/8510.
#pragma warning disable S3878 // Arrays should not be created for params parameters
securityPolicies[DirectiveName] = IContentSecurityPolicyProvider
.GetDirective(securityPolicies, [.. DirectiveNameChain])
.MergeWordSets(DirectiveValue);
#pragma warning restore S3878 // Arrays should not be created for params parameters
IContentSecurityPolicyProvider.MergeDirectiveValues(securityPolicies, DirectiveNameChain, DirectiveValue);
}

return ThenUpdateAsync(securityPolicies, context, resourceExists);
Expand Down
Loading

0 comments on commit 774b1df

Please sign in to comment.