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 2 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.

81 changes: 81 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,83 @@ 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(); }" },
}))
.WithDefaultLoader(new LoaderOptions() { IsResourceLoadingEnabled = true });

var context = BrowsingContext.New(config);
var html = "<!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>import { test } from 'example-module'; test();</script>";

var document1 = await context.OpenAsync(r => r.Content(html));
Assert.IsNull(document1.GetElementById("test1"));
Assert.IsNotNull(document1.GetElementById("test2"));

var document2 = await context.OpenAsync(r => r.Content(html).Address("http://localhost/test/"));
Assert.IsNull(document2.GetElementById("test2"));
Assert.IsNotNull(document2.GetElementById("test1"));
}
}
}
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 @@
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;

namespace AngleSharp.Js.Tests.Mocks
{
/// <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;
}
}
}
1 change: 1 addition & 0 deletions src/AngleSharp.Js/AngleSharp.Js.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.1.0" />
<PackageReference Include="Jint" Version="3.0.1" />
<PackageReference Include="System.Text.Json" Version="8.0.2" />
tomvanenckevort marked this conversation as resolved.
Show resolved Hide resolved
</ItemGroup>

<PropertyGroup Condition=" '$(OS)' == 'Windows_NT' ">
Expand Down
143 changes: 140 additions & 3 deletions src/AngleSharp.Js/EngineInstance.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
namespace AngleSharp.Js
{
using AngleSharp.Dom;
using AngleSharp.Io;
using AngleSharp.Text;
using Jint;
using Jint.Native;
using Jint.Native.Object;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;

sealed class EngineInstance
{
Expand All @@ -17,14 +22,26 @@ sealed class EngineInstance
private readonly ReferenceCache _references;
private readonly IEnumerable<Assembly> _libs;
private readonly DomNodeInstance _window;
private readonly IResourceLoader _resourceLoader;
private readonly IElement _scriptElement;
private readonly string _documentUrl;

#endregion

#region ctor

public EngineInstance(IWindow window, IDictionary<String, Object> assignments, IEnumerable<Assembly> libs)
{
_engine = new Engine();
_resourceLoader = window.Document.Context.GetService<IResourceLoader>();

_scriptElement = window.Document.CreateElement(TagNames.Script);

_documentUrl = window.Document.Url;

_engine = new Engine((options) =>
{
options.EnableModules(new JsModuleLoader(this, _documentUrl, false));
});
_prototypes = new PrototypeCache(_engine);
_references = new ReferenceCache();
_libs = libs;
Expand Down Expand Up @@ -73,12 +90,132 @@ 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, 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 = Guid.NewGuid().ToString();

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

private JsValue LoadImportMap(String source)
{
JsImportMap importMap;

try
{
importMap = JsonSerializer.Deserialize<JsImportMap>(source);
}
catch (JsonException)
{
importMap = null;
}

// get list of imports based on any scoped imports for the current document path, and any global imports
var imports = new Dictionary<string, Uri>();
var documentPathName = Url.Create(_documentUrl).PathName.ToLower();

if (importMap?.Scopes?.Count > 0)
{
var scopePaths = importMap.Scopes.Keys.OrderByDescending(k => k.Length);

foreach (var scopePath in scopePaths)
{
if (!documentPathName.Contains(scopePath.ToLower()))
{
continue;
}

var scopeImports = importMap.Scopes[scopePath];

foreach (var scopeImport in scopeImports)
{
if (!imports.ContainsKey(scopeImport.Key))
{
imports.Add(scopeImport.Key, scopeImport.Value);
}
}
}
}

if (importMap?.Imports?.Count > 0)
{
foreach (var globalImport in importMap.Imports)
{
if (!imports.ContainsKey(globalImport.Key))
{
imports.Add(globalImport.Key, globalImport.Value);
}
}
}

foreach (var import in imports)
{
var moduleContent = FetchModule(import.Value);

ImportModule(import.Key, moduleContent);
}

return JsValue.Undefined;
}

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

return JsValue.Undefined;
}

public string FetchModule(Uri moduleUrl)
{
if (_resourceLoader == null)
{
return string.Empty;
}

if (!moduleUrl.IsAbsoluteUri)
{
moduleUrl = new Uri(new Uri(_documentUrl), moduleUrl);
}

var importUrl = Url.Convert(moduleUrl);

var request = new ResourceRequest(_scriptElement, importUrl);

var response = _resourceLoader.FetchAsync(request).Task.Result;

string content;

using (var streamReader = new StreamReader(response.Content))
{
content = streamReader.ReadToEnd();
}

return content;
}

#endregion
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) =>
engine.RunScript(source, type, 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, INode context) =>
engine.RunScript(source, type, context.ToJsValue(engine));

public static JsValue Call(this EngineInstance instance, MethodInfo method, JsValue thisObject, JsValue[] arguments)
{
Expand Down
6 changes: 4 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,15 @@ 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>
/// <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)
{
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);
}
}
}
Loading
Loading