diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 06b6ca1d4..735a2eab8 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -70,6 +70,11 @@ async_module_pool: std.heap.MemoryPool(AsyncModule), // and can be requested as needed. sync_modules: std.StringHashMapUnmanaged(*SyncModule), +// Mapping between module specifier and resolution. +// see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap +// importmap contains resolved urls. +importmap: std.StringHashMapUnmanaged([:0]const u8), + const OrderList = std.DoublyLinkedList; pub fn init(browser: *Browser, page: *Page) ScriptManager { @@ -80,6 +85,7 @@ pub fn init(browser: *Browser, page: *Page) ScriptManager { .asyncs = .{}, .scripts = .{}, .deferreds = .{}, + .importmap = .empty, .sync_modules = .empty, .is_evaluating = false, .allocator = allocator, @@ -106,6 +112,8 @@ pub fn deinit(self: *ScriptManager) void { self.async_module_pool.deinit(); self.sync_modules.deinit(self.allocator); + // we don't deinit self.importmap b/c we use the page's arena for its + // allocations. } pub fn reset(self: *ScriptManager) void { @@ -115,6 +123,9 @@ pub fn reset(self: *ScriptManager) void { self.sync_module_pool.destroy(value_ptr.*); } self.sync_modules.clearRetainingCapacity(); + // Our allocator is the page arena, it's been reset. We cannot use + // clearAndRetainCapacity, since that space is no longer ours + self.importmap = .empty; self.clearList(&self.asyncs); self.clearList(&self.scripts); @@ -164,6 +175,9 @@ pub fn addFromElement(self: *ScriptManager, element: *parser.Element, comptime c if (std.ascii.eqlIgnoreCase(script_type, "module")) { break :blk .module; } + if (std.ascii.eqlIgnoreCase(script_type, "importmap")) { + break :blk .importmap; + } // "type" could be anything, but only the above are ones we need to process. // Common other ones are application/json, application/ld+json, text/template @@ -248,6 +262,21 @@ pub fn addFromElement(self: *ScriptManager, element: *parser.Element, comptime c }); } +// Resolve a module specifier to an valid URL. +pub fn resolveSpecifier(self: *ScriptManager, arena: Allocator, specifier: []const u8, base: []const u8) ![:0]const u8 { + // If the specifier is mapped in the importmap, return the pre-resolved value. + if (self.importmap.get(specifier)) |s| { + return s; + } + + return URL.stitch( + arena, + specifier, + base, + .{ .alloc = .if_needed, .null_terminated = true }, + ); +} + pub fn getModule(self: *ScriptManager, url: [:0]const u8, referrer: []const u8) !void { const gop = try self.sync_modules.getOrPut(self.allocator, url); if (gop.found_existing) { @@ -452,6 +481,38 @@ fn errorCallback(ctx: *anyopaque, err: anyerror) void { script.errorCallback(err); } +fn parseImportmap(self: *ScriptManager, script: *const Script) !void { + const content = script.source.content(); + + const Imports = struct { + imports: std.json.ArrayHashMap([]const u8), + }; + + const imports = try std.json.parseFromSliceLeaky( + Imports, + self.page.arena, + content, + .{ .allocate = .alloc_always }, + ); + + var iter = imports.imports.map.iterator(); + while (iter.next()) |entry| { + // > Relative URLs are resolved to absolute URL addresses using the + // > base URL of the document containing the import map. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#importing_modules_using_import_maps + const resolved_url = try URL.stitch( + self.page.arena, + entry.value_ptr.*, + self.page.url.raw, + .{ .alloc = .if_needed, .null_terminated = true }, + ); + + try self.importmap.put(self.page.arena, entry.key_ptr.*, resolved_url); + } + + return; +} + // A script which is pending execution. // It could be pending because: // (a) we're still downloading its content or @@ -581,6 +642,7 @@ const Script = struct { const Kind = enum { module, javascript, + importmap, }; const Callback = union(enum) { @@ -621,6 +683,23 @@ const Script = struct { .cacheable = cacheable, }); + // Handle importmap special case here: the content is a JSON containing + // imports. + if (self.kind == .importmap) { + page.script_manager.parseImportmap(self) catch |err| { + log.err(.browser, "parse importmap script", .{ + .err = err, + .src = url, + .kind = self.kind, + .cacheable = cacheable, + }); + self.executeCallback("onerror", page); + return; + }; + self.executeCallback("onload", page); + return; + } + const js_context = page.js; var try_catch: js.TryCatch = undefined; try_catch.init(js_context); @@ -634,6 +713,7 @@ const Script = struct { // We don't care about waiting for the evaluation here. js_context.module(false, content, url, cacheable) catch break :blk false; }, + .importmap => unreachable, // handled before the try/catch. } break :blk true; }; diff --git a/src/browser/html/elements.zig b/src/browser/html/elements.zig index 0fc5502f6..e9e95fdfc 100644 --- a/src/browser/html/elements.zig +++ b/src/browser/html/elements.zig @@ -1353,6 +1353,7 @@ test "Browser: HTML.HtmlScriptElement" { try testing.htmlRunner("html/script/inline_defer.html"); try testing.htmlRunner("html/script/import.html"); try testing.htmlRunner("html/script/dynamic_import.html"); + try testing.htmlRunner("html/script/importmap.html"); } test "Browser: HTML.HtmlSlotElement" { diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 8d298f0d6..46c6261fd 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -251,11 +251,10 @@ pub fn module(self: *Context, comptime want_result: bool, src: []const u8, url: for (0..requests.length()) |i| { const req = requests.get(v8_context, @intCast(i)).castTo(v8.ModuleRequest); const specifier = try self.jsStringToZig(req.getSpecifier(), .{}); - const normalized_specifier = try @import("../../url.zig").stitch( + const normalized_specifier = try self.script_manager.?.resolveSpecifier( self.call_arena, specifier, owned_url, - .{ .alloc = .if_needed, .null_terminated = true }, ); const gop = try self.module_cache.getOrPut(self.arena, normalized_specifier); if (!gop.found_existing) { @@ -1127,11 +1126,10 @@ pub fn dynamicModuleCallback( return @constCast(self.rejectPromise("Out of memory").handle); }; - const normalized_specifier = @import("../../url.zig").stitch( + const normalized_specifier = self.script_manager.?.resolveSpecifier( self.arena, // might need to survive until the module is loaded specifier, resource, - .{ .alloc = .if_needed, .null_terminated = true }, ) catch |err| { log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback3" }); return @constCast(self.rejectPromise("Out of memory").handle); @@ -1171,11 +1169,10 @@ fn _resolveModuleCallback(self: *Context, referrer: v8.Module, specifier: []cons return error.UnknownModuleReferrer; }; - const normalized_specifier = try @import("../../url.zig").stitch( + const normalized_specifier = try self.script_manager.?.resolveSpecifier( self.call_arena, specifier, referrer_path, - .{ .alloc = .if_needed, .null_terminated = true }, ); const gop = try self.module_cache.getOrPut(self.arena, normalized_specifier); diff --git a/src/tests/html/script/importmap.html b/src/tests/html/script/importmap.html new file mode 100644 index 000000000..973d50806 --- /dev/null +++ b/src/tests/html/script/importmap.html @@ -0,0 +1,24 @@ + + + + + + + + + +