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

Added support for JS modules and importmaps #93

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions src/AngleSharp.Js.Tests/AngleSharp.Js.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="AngleSharp.Io" Version="1.0.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
159 changes: 159 additions & 0 deletions src/AngleSharp.Js.Tests/Constants.cs

Large diffs are not rendered by default.

102 changes: 102 additions & 0 deletions src/AngleSharp.Js.Tests/EcmaTests.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
namespace AngleSharp.Js.Tests
{
using AngleSharp.Io;
using AngleSharp.Js.Tests.Mocks;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

[TestFixture]
Expand All @@ -17,5 +20,104 @@ public async Task BootstrapVersionFive()
.ConfigureAwait(false);
Assert.AreNotEqual("", result);
}

[Test]
public async Task ModuleScriptShouldRun()
{
var config =
Configuration.Default
.WithJs()
.With(new MockHttpClientRequester(new Dictionary<string, string>()
{
{ "/example-module.js", "import { $ } from '/jquery_4_0_0_esm.js'; $('#test').remove();" },
{ "/jquery_4_0_0_esm.js", Constants.Jquery4_0_0_ESM }
}))
.WithDefaultLoader(new LoaderOptions() { IsResourceLoadingEnabled = true });
var context = BrowsingContext.New(config);
var html = "<!doctype html><div id=test>Test</div><script type=module src=/example-module.js></script>";
var document = await context.OpenAsync(r => r.Content(html));
Assert.IsNull(document.GetElementById("test"));
}

[Test]
public async Task InlineModuleScriptShouldRun()
{
var config =
Configuration.Default
.WithJs()
.With(new MockHttpClientRequester(new Dictionary<string, string>()
{
{ "/jquery_4_0_0_esm.js", Constants.Jquery4_0_0_ESM }
}))
.WithDefaultLoader(new LoaderOptions() { IsResourceLoadingEnabled = true });
var context = BrowsingContext.New(config);
var html = "<!doctype html><div id=test>Test</div><script type=module>import { $ } from '/jquery_4_0_0_esm.js'; $('#test').remove();</script>";
var document = await context.OpenAsync(r => r.Content(html));
Assert.IsNull(document.GetElementById("test"));
}

[Test]
public async Task ModuleScriptWithImportMapShouldRun()
{
var config =
Configuration.Default
.WithJs()
.With(new MockHttpClientRequester(new Dictionary<string, string>()
{
{ "/jquery_4_0_0_esm.js", Constants.Jquery4_0_0_ESM }
}))
.WithDefaultLoader(new LoaderOptions() { IsResourceLoadingEnabled = true });

var context = BrowsingContext.New(config);
var html = "<!doctype html><div id=test>Test</div><script type=importmap>{ \"imports\": { \"jquery\": \"/jquery_4_0_0_esm.js\" } }</script><script type=module>import { $ } from 'jquery'; $('#test').remove();</script>";
var document = await context.OpenAsync(r => r.Content(html));
Assert.IsNull(document.GetElementById("test"));
}

[Test]
public async Task ModuleScriptWithScopedImportMapShouldRunCorrectScript()
{
var config =
Configuration.Default
.WithJs()
.With(new MockHttpClientRequester(new Dictionary<string, string>()
{
{ "/example-module-1.js", "export function test() { document.getElementById('test1').remove(); }" },
{ "/example-module-2.js", "export function test() { document.getElementById('test2').remove(); }" },
{ "/test.js", "import { test } from 'example-module'; test();" },
{ "/test/test.js", "import { test } from 'example-module'; test();" }
}))
.WithDefaultLoader(new LoaderOptions() { IsResourceLoadingEnabled = true });

var context = BrowsingContext.New(config);

var html1 = "<!doctype html><div id=test1>Test</div><div id=test2>Test</div><script type=importmap>{ \"imports\": { \"example-module\": \"/example-module-1.js\" }, \"scopes\": { \"/test/\": { \"example-module\": \"/example-module-2.js\" } } }</script><script type=module src=/test.js></script>";
var document1 = await context.OpenAsync(r => r.Content(html1));
Assert.IsNull(document1.GetElementById("test1"));
Assert.IsNotNull(document1.GetElementById("test2"));

var html2 = "<!doctype html><div id=test1>Test</div><div id=test2>Test</div><script type=importmap>{ \"imports\": { \"example-module\": \"/example-module-1.js\" }, \"scopes\": { \"/test/\": { \"example-module\": \"/example-module-2.js\" } } }</script><script type=module src=/test/test.js></script>";
var document2 = await context.OpenAsync(r => r.Content(html2));
Assert.IsNull(document2.GetElementById("test2"));
Assert.IsNotNull(document2.GetElementById("test1"));
}

[Test]
public async Task ModuleScriptWithAbsoluteUrlImportMapShouldRun()
{
var config =
Configuration.Default
.WithJs()
.With(new MockHttpClientRequester(new Dictionary<string, string>()
{
{ "/jquery_4_0_0_esm.js", Constants.Jquery4_0_0_ESM }
}))
.WithDefaultLoader(new LoaderOptions() { IsResourceLoadingEnabled = true });

var context = BrowsingContext.New(config);
var html = "<!doctype html><div id=test>Test</div><script type=importmap>{ \"imports\": { \"https://example.com/jquery.js\": \"/jquery_4_0_0_esm.js\" } }</script><script type=module>import { $ } from 'https://example.com/jquery.js'; $('#test').remove();</script>";
var document = await context.OpenAsync(r => r.Content(html));
Assert.IsNull(document.GetElementById("test"));
}
}
}
42 changes: 42 additions & 0 deletions src/AngleSharp.Js.Tests/Mocks/MockHttpClientRequester.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace AngleSharp.Js.Tests.Mocks
{
using AngleSharp.Io;
using AngleSharp.Io.Network;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

/// <summary>
/// Mock HttpClientRequester which returns content for a specific request from a local dictionary.
/// </summary>
internal class MockHttpClientRequester : HttpClientRequester
{
private readonly Dictionary<string, string> _mockResponses;

public MockHttpClientRequester(Dictionary<string, string> mockResponses) : base()
{
_mockResponses = mockResponses;
}

protected override async Task<IResponse> PerformRequestAsync(Request request, CancellationToken cancel)
{
var response = new DefaultResponse();

if (_mockResponses.TryGetValue(request.Address.PathName, out var responseContent))
{
response.StatusCode = HttpStatusCode.OK;
response.Content = new MemoryStream(Encoding.UTF8.GetBytes(responseContent));
}
else
{
response.StatusCode = HttpStatusCode.NotFound;
response.Content = new MemoryStream(Encoding.UTF8.GetBytes(string.Empty));
}

return response;
}
}
}
100 changes: 97 additions & 3 deletions src/AngleSharp.Js/EngineInstance.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
namespace AngleSharp.Js
{
using AngleSharp.Dom;
using AngleSharp.Io;
using AngleSharp.Text;
using Jint;
using Jint.Native;
using Jint.Native.Object;
Expand All @@ -17,14 +19,20 @@ sealed class EngineInstance
private readonly ReferenceCache _references;
private readonly IEnumerable<Assembly> _libs;
private readonly DomNodeInstance _window;
private readonly JsImportMap _importMap;

#endregion

#region ctor

public EngineInstance(IWindow window, IDictionary<String, Object> assignments, IEnumerable<Assembly> libs)
{
_engine = new Engine();
_importMap = new JsImportMap();

_engine = new Engine((options) =>
{
options.EnableModules(new JsModuleLoader(this, window.Document, false));
});
_prototypes = new PrototypeCache(_engine);
_references = new ReferenceCache();
_libs = libs;
Expand Down Expand Up @@ -65,6 +73,8 @@ public EngineInstance(IWindow window, IDictionary<String, Object> assignments, I

public Engine Jint => _engine;

public JsImportMap ImportMap => _importMap;

#endregion

#region Methods
Expand All @@ -73,14 +83,98 @@ public EngineInstance(IWindow window, IDictionary<String, Object> assignments, I

public ObjectInstance GetDomPrototype(Type type) => _prototypes.GetOrCreate(type, CreatePrototype);

public JsValue RunScript(String source, JsValue context)
public JsValue RunScript(String source, String type, String sourceUrl, JsValue context)
{
if (string.IsNullOrEmpty(type))
{
type = MimeTypeNames.DefaultJavaScript;
}

lock (_engine)
{
return _engine.Evaluate(source);
if (MimeTypeNames.IsJavaScript(type))
{
return _engine.Evaluate(source);
}
else if (type.Isi("importmap"))
{
return LoadImportMap(source);
}
else if (type.Isi("module"))
{
// use a unique specifier to import the module into Jint
var specifier = sourceUrl ?? Guid.NewGuid().ToString();

return ImportModule(specifier, source);
}
else
{
return JsValue.Undefined;
}
}
}

private JsValue LoadImportMap(String source)
{
var importMap = _engine.Evaluate($"JSON.parse('{source}')").AsObject();

if (importMap.TryGetValue("scopes", out var scopes))
{
var scopesObj = scopes.AsObject();

foreach (var scopeProperty in scopesObj.GetOwnProperties())
{
var scopePath = scopeProperty.Key.AsString();

if (_importMap.Scopes.ContainsKey(scopePath))
{
continue;
}

var scopeValue = new Dictionary<string, Uri>();

var scopeImports = scopesObj[scopePath].AsObject();

foreach (var scopeImportProperty in scopeImports.GetOwnProperties())
{
var scopeImportSpecifier = scopeImportProperty.Key.AsString();

if (!scopeValue.ContainsKey(scopeImportSpecifier))
{
scopeValue.Add(scopeImportSpecifier, new Uri(scopeImports[scopeImportSpecifier].AsString(), UriKind.RelativeOrAbsolute));
}
}

_importMap.Scopes.Add(scopePath, scopeValue);
}
}

if (importMap.TryGetValue("imports", out var imports))
{
var importsObj = imports.AsObject();

foreach (var importProperty in importsObj.GetOwnProperties())
{
var importSpecifier = importProperty.Key.AsString();

if (!_importMap.Imports.ContainsKey(importSpecifier))
{
_importMap.Imports.Add(importSpecifier, new Uri(importsObj[importSpecifier].AsString(), UriKind.RelativeOrAbsolute));
}
}
}

return JsValue.Undefined;
}

private JsValue ImportModule(String specifier, String source)
{
_engine.Modules.Add(specifier, source);
_engine.Modules.Import(specifier);

return JsValue.Undefined;
}

#endregion

#region Helpers
Expand Down
8 changes: 4 additions & 4 deletions src/AngleSharp.Js/Extensions/EngineExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,11 +198,11 @@ public static void AddInstance(this EngineInstance engine, ObjectInstance obj, T
apply.Invoke(engine, obj);
}

public static JsValue RunScript(this EngineInstance engine, String source) =>
engine.RunScript(source, engine.Window);
public static JsValue RunScript(this EngineInstance engine, String source, String type, String sourceUrl) =>
engine.RunScript(source, type, sourceUrl, engine.Window);

public static JsValue RunScript(this EngineInstance engine, String source, INode context) =>
engine.RunScript(source, context.ToJsValue(engine));
public static JsValue RunScript(this EngineInstance engine, String source, String type, String sourceUrl, INode context) =>
engine.RunScript(source, type, sourceUrl, context.ToJsValue(engine));

public static JsValue Call(this EngineInstance instance, MethodInfo method, JsValue thisObject, JsValue[] arguments)
{
Expand Down
7 changes: 5 additions & 2 deletions src/AngleSharp.Js/JsApiExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
namespace AngleSharp.Js
{
using AngleSharp.Dom;
using AngleSharp.Io;
using AngleSharp.Scripting;
using System;

Expand All @@ -14,14 +15,16 @@ public static class JsApiExtensions
/// </summary>
/// <param name="document">The document as context.</param>
/// <param name="scriptCode">The script to run.</param>
/// <param name="scriptType">The type of the script to run (defaults to "text/javascript").</param>
/// <param name="sourceUrl">The URL of the script.</param>
/// <returns>The result of running the script, if any.</returns>
public static Object ExecuteScript(this IDocument document, String scriptCode)
public static Object ExecuteScript(this IDocument document, String scriptCode, String scriptType = null, String sourceUrl = null)
{
if (document == null)
throw new ArgumentNullException(nameof(document));

var service = document?.Context.GetService<JsScriptingService>();
return service?.EvaluateScript(document, scriptCode);
return service?.EvaluateScript(document, scriptCode, scriptType ?? MimeTypeNames.DefaultJavaScript, sourceUrl);
}
}
}
29 changes: 29 additions & 0 deletions src/AngleSharp.Js/JsImportMap.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace AngleSharp.Js
{
using System;
using System.Collections.Generic;

/// <summary>
/// https://html.spec.whatwg.org/multipage/webappapis.html#import-map
/// </summary>
sealed class JsImportMap
{
public JsImportMap()
{
Imports = new Dictionary<string, Uri>();
Scopes = new Dictionary<string, Dictionary<string, Uri>>();
}

/// <summary>
/// Provides the mappings between module specifier text that might appear in an import statement or import() operator,
/// and the text that will replace it when the specifier is resolved.
/// </summary>
public Dictionary<string, Uri> Imports { get; set; }

/// <summary>
/// Mappings that are only used if the script importing the module contains a particular URL path.
/// If the URL of the loading script matches the supplied path, the mapping associated with the scope will be used.
/// </summary>
public Dictionary<string, Dictionary<string, Uri>> Scopes { get; set; }
}
}
Loading
Loading