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-338: Javascript Module Support #237

Merged
merged 34 commits into from
Feb 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
b76ce52
Add JsonHelperExtensions with DataAttribute.
sarahelsaig Jan 27, 2024
9897109
Merge remote-tracking branch 'origin/dev' into issue/OSOE-338
sarahelsaig Jan 30, 2024
a288380
Merge remote-tracking branch 'origin/dev' into issue/OSOE-338
sarahelsaig Feb 4, 2024
b97d71f
Add script-module extensions.
sarahelsaig Feb 8, 2024
a8212f7
Add docs.
sarahelsaig Feb 13, 2024
1aa3c14
Add methods for rendering ES modules.
sarahelsaig Feb 13, 2024
3a05d7a
More documentation.
sarahelsaig Feb 13, 2024
d3939fc
Bug fix.
sarahelsaig Feb 13, 2024
69019b8
Add GetScriptModuleImportMap to IOrchardHelper
sarahelsaig Feb 14, 2024
7c160d1
Retain declared attributes.
sarahelsaig Feb 16, 2024
d97411a
Module display through filter.
sarahelsaig Feb 16, 2024
ac541ba
Add CreateAdHocShape.
sarahelsaig Feb 16, 2024
8b30c3a
Add docs.
sarahelsaig Feb 16, 2024
b60cb8b
Bug fix.
sarahelsaig Feb 16, 2024
1a04e01
GetScriptModuleImportMap shortcuts
sarahelsaig Feb 17, 2024
b7a7b16
Add MergeDirectiveValues.
sarahelsaig Feb 18, 2024
253fd2d
Fix xmldoc.
sarahelsaig Feb 18, 2024
9276e32
Spelling.
sarahelsaig Feb 18, 2024
1bd1002
Code cleanup.
sarahelsaig Feb 18, 2024
7f019cb
Temporarily remove all ref comments.
sarahelsaig Feb 18, 2024
004cbb5
Fix
sarahelsaig Feb 18, 2024
ec048e7
dunno
sarahelsaig Feb 19, 2024
52abebd
Revert "Temporarily remove all ref comments."
sarahelsaig Feb 19, 2024
64854ec
Update Lombiq.HelpfulLibraries.AspNetCore/Extensions/JsonHelperExtens…
sarahelsaig Feb 19, 2024
142a768
Elaborate comment as asked by coderabbit.
sarahelsaig Feb 19, 2024
8d2122c
Fix documentation.
sarahelsaig Feb 19, 2024
c91b78d
Fix xmldoc for real this time.
sarahelsaig Feb 19, 2024
a985a8f
Note about weird analyzer behavior.
sarahelsaig Feb 19, 2024
8cbc5af
Permit connecting to fastly.jsdelivr.net
sarahelsaig Feb 20, 2024
67015da
Merge tag 'v8.1.1-alpha.7.osoe-751' into issue/OSOE-338-1.8
sarahelsaig Feb 20, 2024
6c65564
Fix new analyzer warning.
sarahelsaig Feb 20, 2024
55e4588
Update Lombiq.HelpfulLibraries.OrchardCore/ResourceManagement/ScriptM…
sarahelsaig Feb 21, 2024
28b10f0
Merge remote-tracking branch 'origin/issue/OSOE-338-1.8' into issue/O…
sarahelsaig Feb 21, 2024
b69d696
Merge remote-tracking branch 'origin/dev' into issue/OSOE-338
sarahelsaig Feb 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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