Skip to content

Commit e93f3f9

Browse files
Added support for JS modules and importmaps
1 parent 0e8d85e commit e93f3f9

File tree

11 files changed

+495
-13
lines changed

11 files changed

+495
-13
lines changed

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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 159 additions & 0 deletions
Large diffs are not rendered by default.

src/AngleSharp.Js.Tests/EcmaTests.cs

Lines changed: 81 additions & 0 deletions
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,83 @@ 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+
}))
88+
.WithDefaultLoader(new LoaderOptions() { IsResourceLoadingEnabled = true });
89+
90+
var context = BrowsingContext.New(config);
91+
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>";
92+
93+
var document1 = await context.OpenAsync(r => r.Content(html));
94+
Assert.IsNull(document1.GetElementById("test1"));
95+
Assert.IsNotNull(document1.GetElementById("test2"));
96+
97+
var document2 = await context.OpenAsync(r => r.Content(html).Address("http://localhost/test/"));
98+
Assert.IsNull(document2.GetElementById("test2"));
99+
Assert.IsNotNull(document2.GetElementById("test1"));
100+
}
20101
}
21102
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using AngleSharp.Io;
2+
using AngleSharp.Io.Network;
3+
using System.Collections.Generic;
4+
using System.IO;
5+
using System.Net;
6+
using System.Text;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
10+
namespace AngleSharp.Js.Tests.Mocks
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/AngleSharp.Js.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
<ItemGroup>
2424
<PackageReference Include="AngleSharp" Version="1.1.0" />
2525
<PackageReference Include="Jint" Version="3.0.1" />
26+
<PackageReference Include="System.Text.Json" Version="8.0.2" />
2627
</ItemGroup>
2728

2829
<PropertyGroup Condition=" '$(OS)' == 'Windows_NT' ">

src/AngleSharp.Js/EngineInstance.cs

Lines changed: 140 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
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;
79
using System;
810
using System.Collections.Generic;
11+
using System.IO;
12+
using System.Linq;
913
using System.Reflection;
14+
using System.Text.Json;
1015

1116
sealed class EngineInstance
1217
{
@@ -17,14 +22,26 @@ sealed class EngineInstance
1722
private readonly ReferenceCache _references;
1823
private readonly IEnumerable<Assembly> _libs;
1924
private readonly DomNodeInstance _window;
25+
private readonly IResourceLoader _resourceLoader;
26+
private readonly IElement _scriptElement;
27+
private readonly string _documentUrl;
2028

2129
#endregion
2230

2331
#region ctor
2432

2533
public EngineInstance(IWindow window, IDictionary<String, Object> assignments, IEnumerable<Assembly> libs)
2634
{
27-
_engine = new Engine();
35+
_resourceLoader = window.Document.Context.GetService<IResourceLoader>();
36+
37+
_scriptElement = window.Document.CreateElement(TagNames.Script);
38+
39+
_documentUrl = window.Document.Url;
40+
41+
_engine = new Engine((options) =>
42+
{
43+
options.EnableModules(new JsModuleLoader(this, _documentUrl, false));
44+
});
2845
_prototypes = new PrototypeCache(_engine);
2946
_references = new ReferenceCache();
3047
_libs = libs;
@@ -73,12 +90,132 @@ public EngineInstance(IWindow window, IDictionary<String, Object> assignments, I
7390

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

76-
public JsValue RunScript(String source, JsValue context)
93+
public JsValue RunScript(String source, String type, JsValue context)
7794
{
95+
if (string.IsNullOrEmpty(type))
96+
{
97+
type = MimeTypeNames.DefaultJavaScript;
98+
}
99+
78100
lock (_engine)
79101
{
80-
return _engine.Evaluate(source);
102+
if (MimeTypeNames.IsJavaScript(type))
103+
{
104+
return _engine.Evaluate(source);
105+
}
106+
else if (type.Isi("importmap"))
107+
{
108+
return LoadImportMap(source);
109+
}
110+
else if (type.Isi("module"))
111+
{
112+
return RunModule(source);
113+
}
114+
else
115+
{
116+
return JsValue.Undefined;
117+
}
118+
}
119+
}
120+
121+
private JsValue LoadImportMap(String source)
122+
{
123+
JsImportMap importMap;
124+
125+
try
126+
{
127+
importMap = JsonSerializer.Deserialize<JsImportMap>(source);
128+
}
129+
catch (JsonException)
130+
{
131+
importMap = null;
132+
}
133+
134+
// get list of imports based on any scoped imports for the current document path, and any global imports
135+
var imports = new Dictionary<string, Uri>();
136+
var documentPathName = Url.Create(_documentUrl).PathName.ToLower();
137+
138+
if (importMap?.Scopes?.Count > 0)
139+
{
140+
var scopePaths = importMap.Scopes.Keys.OrderByDescending(k => k.Length);
141+
142+
foreach (var scopePath in scopePaths)
143+
{
144+
if (!documentPathName.Contains(scopePath.ToLower()))
145+
{
146+
continue;
147+
}
148+
149+
var scopeImports = importMap.Scopes[scopePath];
150+
151+
foreach (var scopeImport in scopeImports)
152+
{
153+
if (!imports.ContainsKey(scopeImport.Key))
154+
{
155+
imports.Add(scopeImport.Key, scopeImport.Value);
156+
}
157+
}
158+
}
159+
}
160+
161+
if (importMap?.Imports?.Count > 0)
162+
{
163+
foreach (var globalImport in importMap.Imports)
164+
{
165+
if (!imports.ContainsKey(globalImport.Key))
166+
{
167+
imports.Add(globalImport.Key, globalImport.Value);
168+
}
169+
}
170+
}
171+
172+
foreach (var import in imports)
173+
{
174+
var moduleContent = FetchModule(import.Value);
175+
176+
_engine.Modules.Add(import.Key, moduleContent);
177+
_engine.Modules.Import(import.Key);
178+
}
179+
180+
return JsValue.Undefined;
181+
}
182+
183+
private JsValue RunModule(String source)
184+
{
185+
var moduleIdentifier = Guid.NewGuid().ToString();
186+
187+
_engine.Modules.Add(moduleIdentifier, source);
188+
_engine.Modules.Import(moduleIdentifier);
189+
190+
return JsValue.Undefined;
191+
}
192+
193+
public string FetchModule(Uri moduleUrl)
194+
{
195+
if (_resourceLoader == null)
196+
{
197+
return string.Empty;
198+
}
199+
200+
if (!moduleUrl.IsAbsoluteUri)
201+
{
202+
moduleUrl = new Uri(new Uri(_documentUrl), moduleUrl);
203+
}
204+
205+
var importUrl = Url.Convert(moduleUrl);
206+
207+
var request = new ResourceRequest(_scriptElement, importUrl);
208+
209+
var response = _resourceLoader.FetchAsync(request).Task.Result;
210+
211+
string content;
212+
213+
using (var streamReader = new StreamReader(response.Content))
214+
{
215+
content = streamReader.ReadToEnd();
81216
}
217+
218+
return content;
82219
}
83220

84221
#endregion

src/AngleSharp.Js/Extensions/EngineExtensions.cs

Lines changed: 4 additions & 4 deletions
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) =>
202+
engine.RunScript(source, type, 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, INode context) =>
205+
engine.RunScript(source, type, context.ToJsValue(engine));
206206

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

src/AngleSharp.Js/JsApiExtensions.cs

Lines changed: 4 additions & 2 deletions
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,15 @@ 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>
1719
/// <returns>The result of running the script, if any.</returns>
18-
public static Object ExecuteScript(this IDocument document, String scriptCode)
20+
public static Object ExecuteScript(this IDocument document, String scriptCode, String scriptType = null)
1921
{
2022
if (document == null)
2123
throw new ArgumentNullException(nameof(document));
2224

2325
var service = document?.Context.GetService<JsScriptingService>();
24-
return service?.EvaluateScript(document, scriptCode);
26+
return service?.EvaluateScript(document, scriptCode, scriptType ?? MimeTypeNames.DefaultJavaScript);
2527
}
2628
}
2729
}

0 commit comments

Comments
 (0)