Skip to content

Commit

Permalink
Added support for JS modules and importmaps
Browse files Browse the repository at this point in the history
  • Loading branch information
tomvanenckevort committed Mar 8, 2024
1 parent 0e8d85e commit e93f3f9
Show file tree
Hide file tree
Showing 11 changed files with 495 additions and 13 deletions.
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" />
</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"))
{
return RunModule(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);

_engine.Modules.Add(import.Key, moduleContent);
_engine.Modules.Import(import.Key);
}

return JsValue.Undefined;
}

private JsValue RunModule(String source)
{
var moduleIdentifier = Guid.NewGuid().ToString();

_engine.Modules.Add(moduleIdentifier, source);
_engine.Modules.Import(moduleIdentifier);

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

0 comments on commit e93f3f9

Please sign in to comment.