diff --git a/src/AngleSharp.Js.Tests/EcmaTests.cs b/src/AngleSharp.Js.Tests/EcmaTests.cs index 35c8929..4ca5cff 100644 --- a/src/AngleSharp.Js.Tests/EcmaTests.cs +++ b/src/AngleSharp.Js.Tests/EcmaTests.cs @@ -84,19 +84,40 @@ public async Task ModuleScriptWithScopedImportMapShouldRunCorrectScript() { { "/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 html = "
Test
Test
"; - var document1 = await context.OpenAsync(r => r.Content(html)); + var html1 = "
Test
Test
"; + var document1 = await context.OpenAsync(r => r.Content(html1)); Assert.IsNull(document1.GetElementById("test1")); Assert.IsNotNull(document1.GetElementById("test2")); - var document2 = await context.OpenAsync(r => r.Content(html).Address("http://localhost/test/")); + var html2 = "
Test
Test
"; + 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() + { + { "/jquery_4_0_0_esm.js", Constants.Jquery4_0_0_ESM } + })) + .WithDefaultLoader(new LoaderOptions() { IsResourceLoadingEnabled = true }); + + var context = BrowsingContext.New(config); + var html = "
Test
"; + var document = await context.OpenAsync(r => r.Content(html)); + Assert.IsNull(document.GetElementById("test")); + } } } diff --git a/src/AngleSharp.Js.Tests/Mocks/MockHttpClientRequester.cs b/src/AngleSharp.Js.Tests/Mocks/MockHttpClientRequester.cs index 80413f0..65277d5 100644 --- a/src/AngleSharp.Js.Tests/Mocks/MockHttpClientRequester.cs +++ b/src/AngleSharp.Js.Tests/Mocks/MockHttpClientRequester.cs @@ -1,14 +1,14 @@ -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 { + 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; + /// /// Mock HttpClientRequester which returns content for a specific request from a local dictionary. /// diff --git a/src/AngleSharp.Js/EngineInstance.cs b/src/AngleSharp.Js/EngineInstance.cs index cff0b6f..3848ebb 100644 --- a/src/AngleSharp.Js/EngineInstance.cs +++ b/src/AngleSharp.Js/EngineInstance.cs @@ -8,8 +8,6 @@ namespace AngleSharp.Js using Jint.Native.Object; using System; using System.Collections.Generic; - using System.IO; - using System.Linq; using System.Reflection; sealed class EngineInstance @@ -21,9 +19,7 @@ sealed class EngineInstance private readonly ReferenceCache _references; private readonly IEnumerable _libs; private readonly DomNodeInstance _window; - private readonly IResourceLoader _resourceLoader; - private readonly IElement _scriptElement; - private readonly string _documentUrl; + private readonly JsImportMap _importMap; #endregion @@ -31,15 +27,11 @@ sealed class EngineInstance public EngineInstance(IWindow window, IDictionary assignments, IEnumerable libs) { - _resourceLoader = window.Document.Context.GetService(); - - _scriptElement = window.Document.CreateElement(TagNames.Script); - - _documentUrl = window.Document.Url; + _importMap = new JsImportMap(); _engine = new Engine((options) => { - options.EnableModules(new JsModuleLoader(this, _documentUrl, false)); + options.EnableModules(new JsModuleLoader(this, window.Document, false)); }); _prototypes = new PrototypeCache(_engine); _references = new ReferenceCache(); @@ -81,6 +73,8 @@ public EngineInstance(IWindow window, IDictionary assignments, I public Engine Jint => _engine; + public JsImportMap ImportMap => _importMap; + #endregion #region Methods @@ -89,7 +83,7 @@ public EngineInstance(IWindow window, IDictionary assignments, I public ObjectInstance GetDomPrototype(Type type) => _prototypes.GetOrCreate(type, CreatePrototype); - public JsValue RunScript(String source, String type, JsValue context) + public JsValue RunScript(String source, String type, String sourceUrl, JsValue context) { if (string.IsNullOrEmpty(type)) { @@ -109,7 +103,7 @@ public JsValue RunScript(String source, String type, JsValue context) else if (type.Isi("module")) { // use a unique specifier to import the module into Jint - var specifier = Guid.NewGuid().ToString(); + var specifier = sourceUrl ?? Guid.NewGuid().ToString(); return ImportModule(specifier, source); } @@ -124,34 +118,34 @@ private JsValue LoadImportMap(String source) { var importMap = _engine.Evaluate($"JSON.parse('{source}')").AsObject(); - // get list of imports based on any scoped imports for the current document path, and any global imports - var moduleImports = new Dictionary(); - var documentPathName = Url.Create(_documentUrl).PathName.ToLower(); - if (importMap.TryGetValue("scopes", out var scopes)) { var scopesObj = scopes.AsObject(); - var scopePaths = scopesObj.GetOwnPropertyKeys().Select(k => k.AsString()).OrderByDescending(k => k.Length); - - foreach (var scopePath in scopePaths) + foreach (var scopeProperty in scopesObj.GetOwnProperties()) { - if (!documentPathName.Contains(scopePath.ToLower())) + var scopePath = scopeProperty.Key.AsString(); + + if (_importMap.Scopes.ContainsKey(scopePath)) { continue; } - var scopeImports = scopesObj[scopePath].AsObject(); + var scopeValue = new Dictionary(); - var scopeImportImportSpecifiers = scopeImports.GetOwnPropertyKeys().Select(k => k.AsString()); + var scopeImports = scopesObj[scopePath].AsObject(); - foreach (var scopeImportSpecifier in scopeImportImportSpecifiers) + foreach (var scopeImportProperty in scopeImports.GetOwnProperties()) { - if (!moduleImports.ContainsKey(scopeImportSpecifier)) + var scopeImportSpecifier = scopeImportProperty.Key.AsString(); + + if (!scopeValue.ContainsKey(scopeImportSpecifier)) { - moduleImports.Add(scopeImportSpecifier, scopeImports[scopeImportSpecifier].AsString()); + scopeValue.Add(scopeImportSpecifier, new Uri(scopeImports[scopeImportSpecifier].AsString(), UriKind.RelativeOrAbsolute)); } } + + _importMap.Scopes.Add(scopePath, scopeValue); } } @@ -159,24 +153,17 @@ private JsValue LoadImportMap(String source) { var importsObj = imports.AsObject(); - var importSpecifiers = importsObj.GetOwnPropertyKeys().Select(k => k.AsString()); - - foreach (var importSpecifier in importSpecifiers) + foreach (var importProperty in importsObj.GetOwnProperties()) { - if (!moduleImports.ContainsKey(importSpecifier)) + var importSpecifier = importProperty.Key.AsString(); + + if (!_importMap.Imports.ContainsKey(importSpecifier)) { - moduleImports.Add(importSpecifier, importsObj[importSpecifier].AsString()); + _importMap.Imports.Add(importSpecifier, new Uri(importsObj[importSpecifier].AsString(), UriKind.RelativeOrAbsolute)); } } } - foreach (var import in moduleImports) - { - var moduleContent = FetchModule(new Uri(import.Value, UriKind.RelativeOrAbsolute)); - - ImportModule(import.Key, moduleContent); - } - return JsValue.Undefined; } @@ -188,34 +175,6 @@ private JsValue ImportModule(String specifier, String source) 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 #region Helpers diff --git a/src/AngleSharp.Js/Extensions/EngineExtensions.cs b/src/AngleSharp.Js/Extensions/EngineExtensions.cs index 2b90984..12cf37e 100644 --- a/src/AngleSharp.Js/Extensions/EngineExtensions.cs +++ b/src/AngleSharp.Js/Extensions/EngineExtensions.cs @@ -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, String type) => - engine.RunScript(source, type, 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, String type, INode context) => - engine.RunScript(source, type, 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) { diff --git a/src/AngleSharp.Js/JsApiExtensions.cs b/src/AngleSharp.Js/JsApiExtensions.cs index 12815ed..b055e11 100644 --- a/src/AngleSharp.Js/JsApiExtensions.cs +++ b/src/AngleSharp.Js/JsApiExtensions.cs @@ -16,14 +16,15 @@ public static class JsApiExtensions /// The document as context. /// The script to run. /// The type of the script to run (defaults to "text/javascript"). + /// The URL of the script. /// The result of running the script, if any. - public static Object ExecuteScript(this IDocument document, String scriptCode, String scriptType = null) + 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(); - return service?.EvaluateScript(document, scriptCode, scriptType ?? MimeTypeNames.DefaultJavaScript); + return service?.EvaluateScript(document, scriptCode, scriptType ?? MimeTypeNames.DefaultJavaScript, sourceUrl); } } } diff --git a/src/AngleSharp.Js/JsImportMap.cs b/src/AngleSharp.Js/JsImportMap.cs new file mode 100644 index 0000000..247c99b --- /dev/null +++ b/src/AngleSharp.Js/JsImportMap.cs @@ -0,0 +1,29 @@ +namespace AngleSharp.Js +{ + using System; + using System.Collections.Generic; + + /// + /// https://html.spec.whatwg.org/multipage/webappapis.html#import-map + /// + sealed class JsImportMap + { + public JsImportMap() + { + Imports = new Dictionary(); + Scopes = new Dictionary>(); + } + + /// + /// 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. + /// + public Dictionary Imports { get; set; } + + /// + /// 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. + /// + public Dictionary> Scopes { get; set; } + } +} diff --git a/src/AngleSharp.Js/JsModuleLoader.cs b/src/AngleSharp.Js/JsModuleLoader.cs index 7fa9a05..1863ebd 100644 --- a/src/AngleSharp.Js/JsModuleLoader.cs +++ b/src/AngleSharp.Js/JsModuleLoader.cs @@ -1,29 +1,106 @@ -using AngleSharp.Io; -using AngleSharp.Text; -using Jint; -using Jint.Runtime.Modules; - namespace AngleSharp.Js { + using AngleSharp.Dom; + using AngleSharp.Io; + using AngleSharp.Text; + using Jint; + using Jint.Runtime.Modules; + using System.IO; + using System; + using System.Linq; + internal class JsModuleLoader : DefaultModuleLoader { private readonly EngineInstance _instance; + private readonly IResourceLoader _resourceLoader; + private readonly IElement _scriptElement; + private readonly string _documentUrl; - public JsModuleLoader(EngineInstance instance, string basePath, bool restrictToBasePath = true) : base (basePath, restrictToBasePath) + public JsModuleLoader(EngineInstance instance, IDocument document, bool restrictToBasePath = true) : base (document.Url, restrictToBasePath) { _instance = instance; + _resourceLoader = document.Context.GetService(); + _scriptElement = document.CreateElement(TagNames.Script); + _documentUrl = document.Url; + } + + public override ResolvedSpecifier Resolve(string referencingModuleLocation, ModuleRequest moduleRequest) + { + if (referencingModuleLocation != null && _instance.ImportMap.Scopes.Count > 0) + { + foreach (var scopePath in _instance.ImportMap.Scopes.Keys.OrderByDescending(k => k.Length)) + { + if (referencingModuleLocation.Contains(scopePath)) + { + var scopeImports = _instance.ImportMap.Scopes[scopePath]; + + if (scopeImports.TryGetValue(moduleRequest.Specifier, out var scopeModuleUrl)) + { + if (!scopeModuleUrl.IsAbsoluteUri) + { + scopeModuleUrl = new Uri(new Uri(_documentUrl), scopeModuleUrl); + } + + return new ResolvedSpecifier( + moduleRequest, + moduleRequest.Specifier, + scopeModuleUrl, + SpecifierType.RelativeOrAbsolute); + } + } + } + } + + if (_instance.ImportMap.Imports.TryGetValue(moduleRequest.Specifier, out var moduleUrl)) + { + if (!moduleUrl.IsAbsoluteUri) + { + moduleUrl = new Uri(new Uri(_documentUrl), moduleUrl); + } + + return new ResolvedSpecifier( + moduleRequest, + moduleRequest.Specifier, + moduleUrl, + SpecifierType.RelativeOrAbsolute); + } + + return base.Resolve(referencingModuleLocation, moduleRequest); } protected override string LoadModuleContents(Engine engine, ResolvedSpecifier resolved) { if (resolved.Uri?.Scheme.IsOneOf(ProtocolNames.Http, ProtocolNames.Https) == true) { - return _instance.FetchModule(resolved.Uri); + return FetchModule(resolved.Uri); } else { return base.LoadModuleContents(engine, resolved); } } + + private string FetchModule(Uri moduleUrl) + { + if (_resourceLoader == null) + { + return string.Empty; + } + + 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; + } } } diff --git a/src/AngleSharp.Js/JsScriptingService.cs b/src/AngleSharp.Js/JsScriptingService.cs index 90eb989..add873e 100644 --- a/src/AngleSharp.Js/JsScriptingService.cs +++ b/src/AngleSharp.Js/JsScriptingService.cs @@ -84,7 +84,7 @@ public async Task EvaluateScriptAsync(IResponse response, ScriptOptions options, { var content = await reader.ReadToEndAsync().ConfigureAwait(false); await options.EventLoop.EnqueueAsync(_ => - EvaluateScript(options.Document, content, options.Element?.Type), TaskPriority.Critical).ConfigureAwait(false); + EvaluateScript(options.Document, content, options.Element?.Type, options.Element?.Source), TaskPriority.Critical).ConfigureAwait(false); } } @@ -94,11 +94,12 @@ await options.EventLoop.EnqueueAsync(_ => /// The context of the evaluation. /// The source of the script. /// The type of the script. + /// The URL of the script. /// The result of the evaluation. - public Object EvaluateScript(IDocument document, String source, String type) + public Object EvaluateScript(IDocument document, String source, String type, String sourceUrl) { document = document ?? throw new ArgumentNullException(nameof(document)); - return GetOrCreateInstance(document).RunScript(source, type).FromJsValue(); + return GetOrCreateInstance(document).RunScript(source, type, sourceUrl).FromJsValue(); } #endregion