Skip to content

Commit 4ecbd42

Browse files
authored
Merge pull request #93 from tomvanenckevort/module-importmap-support
Added support for JS modules and importmaps
2 parents 0e8d85e + 8aba70b commit 4ecbd42

10 files changed

+566
-13
lines changed

src/AngleSharp.Js.Tests/AngleSharp.Js.Tests.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
</ItemGroup>
1919

2020
<ItemGroup>
21+
<PackageReference Include="AngleSharp.Io" Version="1.0.0" />
2122
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.3">
2223
<PrivateAssets>all</PrivateAssets>
2324
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

src/AngleSharp.Js.Tests/Constants.cs

+159
Large diffs are not rendered by default.

src/AngleSharp.Js.Tests/EcmaTests.cs

+102
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
namespace AngleSharp.Js.Tests
22
{
3+
using AngleSharp.Io;
4+
using AngleSharp.Js.Tests.Mocks;
35
using NUnit.Framework;
46
using System;
7+
using System.Collections.Generic;
58
using System.Threading.Tasks;
69

710
[TestFixture]
@@ -17,5 +20,104 @@ public async Task BootstrapVersionFive()
1720
.ConfigureAwait(false);
1821
Assert.AreNotEqual("", result);
1922
}
23+
24+
[Test]
25+
public async Task ModuleScriptShouldRun()
26+
{
27+
var config =
28+
Configuration.Default
29+
.WithJs()
30+
.With(new MockHttpClientRequester(new Dictionary<string, string>()
31+
{
32+
{ "/example-module.js", "import { $ } from '/jquery_4_0_0_esm.js'; $('#test').remove();" },
33+
{ "/jquery_4_0_0_esm.js", Constants.Jquery4_0_0_ESM }
34+
}))
35+
.WithDefaultLoader(new LoaderOptions() { IsResourceLoadingEnabled = true });
36+
var context = BrowsingContext.New(config);
37+
var html = "<!doctype html><div id=test>Test</div><script type=module src=/example-module.js></script>";
38+
var document = await context.OpenAsync(r => r.Content(html));
39+
Assert.IsNull(document.GetElementById("test"));
40+
}
41+
42+
[Test]
43+
public async Task InlineModuleScriptShouldRun()
44+
{
45+
var config =
46+
Configuration.Default
47+
.WithJs()
48+
.With(new MockHttpClientRequester(new Dictionary<string, string>()
49+
{
50+
{ "/jquery_4_0_0_esm.js", Constants.Jquery4_0_0_ESM }
51+
}))
52+
.WithDefaultLoader(new LoaderOptions() { IsResourceLoadingEnabled = true });
53+
var context = BrowsingContext.New(config);
54+
var html = "<!doctype html><div id=test>Test</div><script type=module>import { $ } from '/jquery_4_0_0_esm.js'; $('#test').remove();</script>";
55+
var document = await context.OpenAsync(r => r.Content(html));
56+
Assert.IsNull(document.GetElementById("test"));
57+
}
58+
59+
[Test]
60+
public async Task ModuleScriptWithImportMapShouldRun()
61+
{
62+
var config =
63+
Configuration.Default
64+
.WithJs()
65+
.With(new MockHttpClientRequester(new Dictionary<string, string>()
66+
{
67+
{ "/jquery_4_0_0_esm.js", Constants.Jquery4_0_0_ESM }
68+
}))
69+
.WithDefaultLoader(new LoaderOptions() { IsResourceLoadingEnabled = true });
70+
71+
var context = BrowsingContext.New(config);
72+
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>";
73+
var document = await context.OpenAsync(r => r.Content(html));
74+
Assert.IsNull(document.GetElementById("test"));
75+
}
76+
77+
[Test]
78+
public async Task ModuleScriptWithScopedImportMapShouldRunCorrectScript()
79+
{
80+
var config =
81+
Configuration.Default
82+
.WithJs()
83+
.With(new MockHttpClientRequester(new Dictionary<string, string>()
84+
{
85+
{ "/example-module-1.js", "export function test() { document.getElementById('test1').remove(); }" },
86+
{ "/example-module-2.js", "export function test() { document.getElementById('test2').remove(); }" },
87+
{ "/test.js", "import { test } from 'example-module'; test();" },
88+
{ "/test/test.js", "import { test } from 'example-module'; test();" }
89+
}))
90+
.WithDefaultLoader(new LoaderOptions() { IsResourceLoadingEnabled = true });
91+
92+
var context = BrowsingContext.New(config);
93+
94+
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>";
95+
var document1 = await context.OpenAsync(r => r.Content(html1));
96+
Assert.IsNull(document1.GetElementById("test1"));
97+
Assert.IsNotNull(document1.GetElementById("test2"));
98+
99+
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>";
100+
var document2 = await context.OpenAsync(r => r.Content(html2));
101+
Assert.IsNull(document2.GetElementById("test2"));
102+
Assert.IsNotNull(document2.GetElementById("test1"));
103+
}
104+
105+
[Test]
106+
public async Task ModuleScriptWithAbsoluteUrlImportMapShouldRun()
107+
{
108+
var config =
109+
Configuration.Default
110+
.WithJs()
111+
.With(new MockHttpClientRequester(new Dictionary<string, string>()
112+
{
113+
{ "/jquery_4_0_0_esm.js", Constants.Jquery4_0_0_ESM }
114+
}))
115+
.WithDefaultLoader(new LoaderOptions() { IsResourceLoadingEnabled = true });
116+
117+
var context = BrowsingContext.New(config);
118+
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>";
119+
var document = await context.OpenAsync(r => r.Content(html));
120+
Assert.IsNull(document.GetElementById("test"));
121+
}
20122
}
21123
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
namespace AngleSharp.Js.Tests.Mocks
2+
{
3+
using AngleSharp.Io;
4+
using AngleSharp.Io.Network;
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using System.Net;
8+
using System.Text;
9+
using System.Threading;
10+
using System.Threading.Tasks;
11+
12+
/// <summary>
13+
/// Mock HttpClientRequester which returns content for a specific request from a local dictionary.
14+
/// </summary>
15+
internal class MockHttpClientRequester : HttpClientRequester
16+
{
17+
private readonly Dictionary<string, string> _mockResponses;
18+
19+
public MockHttpClientRequester(Dictionary<string, string> mockResponses) : base()
20+
{
21+
_mockResponses = mockResponses;
22+
}
23+
24+
protected override async Task<IResponse> PerformRequestAsync(Request request, CancellationToken cancel)
25+
{
26+
var response = new DefaultResponse();
27+
28+
if (_mockResponses.TryGetValue(request.Address.PathName, out var responseContent))
29+
{
30+
response.StatusCode = HttpStatusCode.OK;
31+
response.Content = new MemoryStream(Encoding.UTF8.GetBytes(responseContent));
32+
}
33+
else
34+
{
35+
response.StatusCode = HttpStatusCode.NotFound;
36+
response.Content = new MemoryStream(Encoding.UTF8.GetBytes(string.Empty));
37+
}
38+
39+
return response;
40+
}
41+
}
42+
}

src/AngleSharp.Js/EngineInstance.cs

+97-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
namespace AngleSharp.Js
22
{
33
using AngleSharp.Dom;
4+
using AngleSharp.Io;
5+
using AngleSharp.Text;
46
using Jint;
57
using Jint.Native;
68
using Jint.Native.Object;
@@ -17,14 +19,20 @@ sealed class EngineInstance
1719
private readonly ReferenceCache _references;
1820
private readonly IEnumerable<Assembly> _libs;
1921
private readonly DomNodeInstance _window;
22+
private readonly JsImportMap _importMap;
2023

2124
#endregion
2225

2326
#region ctor
2427

2528
public EngineInstance(IWindow window, IDictionary<String, Object> assignments, IEnumerable<Assembly> libs)
2629
{
27-
_engine = new Engine();
30+
_importMap = new JsImportMap();
31+
32+
_engine = new Engine((options) =>
33+
{
34+
options.EnableModules(new JsModuleLoader(this, window.Document, false));
35+
});
2836
_prototypes = new PrototypeCache(_engine);
2937
_references = new ReferenceCache();
3038
_libs = libs;
@@ -65,6 +73,8 @@ public EngineInstance(IWindow window, IDictionary<String, Object> assignments, I
6573

6674
public Engine Jint => _engine;
6775

76+
public JsImportMap ImportMap => _importMap;
77+
6878
#endregion
6979

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

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

76-
public JsValue RunScript(String source, JsValue context)
86+
public JsValue RunScript(String source, String type, String sourceUrl, JsValue context)
7787
{
88+
if (string.IsNullOrEmpty(type))
89+
{
90+
type = MimeTypeNames.DefaultJavaScript;
91+
}
92+
7893
lock (_engine)
7994
{
80-
return _engine.Evaluate(source);
95+
if (MimeTypeNames.IsJavaScript(type))
96+
{
97+
return _engine.Evaluate(source);
98+
}
99+
else if (type.Isi("importmap"))
100+
{
101+
return LoadImportMap(source);
102+
}
103+
else if (type.Isi("module"))
104+
{
105+
// use a unique specifier to import the module into Jint
106+
var specifier = sourceUrl ?? Guid.NewGuid().ToString();
107+
108+
return ImportModule(specifier, source);
109+
}
110+
else
111+
{
112+
return JsValue.Undefined;
113+
}
81114
}
82115
}
83116

117+
private JsValue LoadImportMap(String source)
118+
{
119+
var importMap = _engine.Evaluate($"JSON.parse('{source}')").AsObject();
120+
121+
if (importMap.TryGetValue("scopes", out var scopes))
122+
{
123+
var scopesObj = scopes.AsObject();
124+
125+
foreach (var scopeProperty in scopesObj.GetOwnProperties())
126+
{
127+
var scopePath = scopeProperty.Key.AsString();
128+
129+
if (_importMap.Scopes.ContainsKey(scopePath))
130+
{
131+
continue;
132+
}
133+
134+
var scopeValue = new Dictionary<string, Uri>();
135+
136+
var scopeImports = scopesObj[scopePath].AsObject();
137+
138+
foreach (var scopeImportProperty in scopeImports.GetOwnProperties())
139+
{
140+
var scopeImportSpecifier = scopeImportProperty.Key.AsString();
141+
142+
if (!scopeValue.ContainsKey(scopeImportSpecifier))
143+
{
144+
scopeValue.Add(scopeImportSpecifier, new Uri(scopeImports[scopeImportSpecifier].AsString(), UriKind.RelativeOrAbsolute));
145+
}
146+
}
147+
148+
_importMap.Scopes.Add(scopePath, scopeValue);
149+
}
150+
}
151+
152+
if (importMap.TryGetValue("imports", out var imports))
153+
{
154+
var importsObj = imports.AsObject();
155+
156+
foreach (var importProperty in importsObj.GetOwnProperties())
157+
{
158+
var importSpecifier = importProperty.Key.AsString();
159+
160+
if (!_importMap.Imports.ContainsKey(importSpecifier))
161+
{
162+
_importMap.Imports.Add(importSpecifier, new Uri(importsObj[importSpecifier].AsString(), UriKind.RelativeOrAbsolute));
163+
}
164+
}
165+
}
166+
167+
return JsValue.Undefined;
168+
}
169+
170+
private JsValue ImportModule(String specifier, String source)
171+
{
172+
_engine.Modules.Add(specifier, source);
173+
_engine.Modules.Import(specifier);
174+
175+
return JsValue.Undefined;
176+
}
177+
84178
#endregion
85179

86180
#region Helpers

src/AngleSharp.Js/Extensions/EngineExtensions.cs

+4-4
Original file line numberDiff line numberDiff line change
@@ -198,11 +198,11 @@ public static void AddInstance(this EngineInstance engine, ObjectInstance obj, T
198198
apply.Invoke(engine, obj);
199199
}
200200

201-
public static JsValue RunScript(this EngineInstance engine, String source) =>
202-
engine.RunScript(source, engine.Window);
201+
public static JsValue RunScript(this EngineInstance engine, String source, String type, String sourceUrl) =>
202+
engine.RunScript(source, type, sourceUrl, engine.Window);
203203

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

207207
public static JsValue Call(this EngineInstance instance, MethodInfo method, JsValue thisObject, JsValue[] arguments)
208208
{

src/AngleSharp.Js/JsApiExtensions.cs

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
namespace AngleSharp.Js
22
{
33
using AngleSharp.Dom;
4+
using AngleSharp.Io;
45
using AngleSharp.Scripting;
56
using System;
67

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

2326
var service = document?.Context.GetService<JsScriptingService>();
24-
return service?.EvaluateScript(document, scriptCode);
27+
return service?.EvaluateScript(document, scriptCode, scriptType ?? MimeTypeNames.DefaultJavaScript, sourceUrl);
2528
}
2629
}
2730
}

src/AngleSharp.Js/JsImportMap.cs

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
namespace AngleSharp.Js
2+
{
3+
using System;
4+
using System.Collections.Generic;
5+
6+
/// <summary>
7+
/// https://html.spec.whatwg.org/multipage/webappapis.html#import-map
8+
/// </summary>
9+
sealed class JsImportMap
10+
{
11+
public JsImportMap()
12+
{
13+
Imports = new Dictionary<string, Uri>();
14+
Scopes = new Dictionary<string, Dictionary<string, Uri>>();
15+
}
16+
17+
/// <summary>
18+
/// Provides the mappings between module specifier text that might appear in an import statement or import() operator,
19+
/// and the text that will replace it when the specifier is resolved.
20+
/// </summary>
21+
public Dictionary<string, Uri> Imports { get; set; }
22+
23+
/// <summary>
24+
/// Mappings that are only used if the script importing the module contains a particular URL path.
25+
/// If the URL of the loading script matches the supplied path, the mapping associated with the scope will be used.
26+
/// </summary>
27+
public Dictionary<string, Dictionary<string, Uri>> Scopes { get; set; }
28+
}
29+
}

0 commit comments

Comments
 (0)