mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 01:25:53 -04:00
Improve ImporMap
This is driven by import-maps WPT tests. It generally does 3 high level additions, plus various small compliance tweaks. 1 - Entries with a trailing match are used for prefix matching 2 - Supports scopes (which are entries group into a specific route-like prefix) 3 - Because of the above, resolves in correct order with fallback Resolution is based on longest-match wins, so it doesn't require a fancy data structures. We use ordered (by length) slices, and just iterate until we find a match. Neither our parsing nor our matching is super efficient. While a page might have hundreds of scripts, it likely has only 0-1 import maps and relatively few values. ImportMap.resolve always returns the final URL. So even if a match isn't found based on the parsed JSON, it'll return the URL.resolve(base, url). Just to make WPT tests pass, we do have to track invalid entries in the ImportMap, e.g. "key": "not-a-url". In the previous version, we'd fallback to URL.resolve(base, url). Now we return null and leave it to the caller to decide.
This commit is contained in:
535
src/browser/ImportMap.zig
Normal file
535
src/browser/ImportMap.zig
Normal file
@@ -0,0 +1,535 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Parsed <script type="importmap"> content. Stored on the frame's
|
||||
// ScriptManager and used by `resolveSpecifier` to map module specifiers
|
||||
// to URLs per https://html.spec.whatwg.org/multipage/webappapis.html#import-maps
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
|
||||
const URL = @import("URL.zig");
|
||||
|
||||
const log = lp.log;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const SpecifierMap = std.json.ArrayHashMap(?[]const u8);
|
||||
|
||||
const ImportMap = @This();
|
||||
|
||||
/// Sorted by specifier length descending so the longest match wins.
|
||||
imports: []const Entry = &.{},
|
||||
|
||||
/// Sorted by prefix length descending.
|
||||
scopes: []const Scope = &.{},
|
||||
|
||||
const Entry = struct {
|
||||
specifier: []const u8,
|
||||
resolved: ?[:0]const u8,
|
||||
};
|
||||
|
||||
const Scope = struct {
|
||||
prefix: []const u8,
|
||||
imports: []const Entry,
|
||||
};
|
||||
|
||||
pub const empty: ImportMap = .{};
|
||||
|
||||
/// Parse `json_content` and merge it into `self`. Multiple <script type="importmap">
|
||||
/// elements on a page combine with first-wins semantics: any specifier already
|
||||
/// defined in `self` keeps its existing resolution, and existing scopes absorb
|
||||
/// only the new keys from a same-prefix incoming scope.
|
||||
pub fn merge(self: *ImportMap, arena: Allocator, base: [:0]const u8, json_content: []const u8) !void {
|
||||
const incoming = try parse(arena, base, json_content);
|
||||
self.imports = try mergeEntries(arena, self.imports, incoming.imports);
|
||||
self.scopes = try mergeScopes(arena, self.scopes, incoming.scopes);
|
||||
}
|
||||
|
||||
fn mergeEntries(arena: Allocator, existing: []const Entry, incoming: []const Entry) ![]const Entry {
|
||||
if (incoming.len == 0) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
var list: std.ArrayList(Entry) = try .initCapacity(arena, existing.len + incoming.len);
|
||||
list.appendSliceAssumeCapacity(existing);
|
||||
for (incoming) |new_entry| {
|
||||
if (findEntry(existing, new_entry.specifier) != null) {
|
||||
continue;
|
||||
}
|
||||
list.appendAssumeCapacity(new_entry);
|
||||
}
|
||||
|
||||
std.sort.pdq(Entry, list.items, {}, struct {
|
||||
fn lessThan(_: void, a: Entry, b: Entry) bool {
|
||||
return a.specifier.len > b.specifier.len;
|
||||
}
|
||||
}.lessThan);
|
||||
return list.items;
|
||||
}
|
||||
|
||||
fn findEntry(entries: []const Entry, specifier: []const u8) ?usize {
|
||||
for (entries, 0..) |e, i| {
|
||||
if (std.mem.eql(u8, e.specifier, specifier)) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn mergeScopes(arena: Allocator, existing: []const Scope, incoming: []const Scope) ![]const Scope {
|
||||
if (incoming.len == 0) {
|
||||
return existing;
|
||||
}
|
||||
var list: std.ArrayList(Scope) = try .initCapacity(arena, existing.len + incoming.len);
|
||||
|
||||
// Existing scopes: if the incoming map has the same prefix, merge the
|
||||
// inner imports (existing entries win); otherwise carry through unchanged.
|
||||
for (existing) |ex| {
|
||||
if (findScope(incoming, ex.prefix)) |inc| {
|
||||
list.appendAssumeCapacity(.{
|
||||
.prefix = ex.prefix,
|
||||
.imports = try mergeEntries(arena, ex.imports, inc.imports),
|
||||
});
|
||||
} else {
|
||||
list.appendAssumeCapacity(ex);
|
||||
}
|
||||
}
|
||||
|
||||
// Incoming scopes with prefixes the existing map didn't have.
|
||||
for (incoming) |inc| {
|
||||
if (findScope(existing, inc.prefix) == null) {
|
||||
list.appendAssumeCapacity(inc);
|
||||
}
|
||||
}
|
||||
|
||||
std.sort.pdq(Scope, list.items, {}, struct {
|
||||
fn lessThan(_: void, a: Scope, b: Scope) bool {
|
||||
return a.prefix.len > b.prefix.len;
|
||||
}
|
||||
}.lessThan);
|
||||
return list.items;
|
||||
}
|
||||
|
||||
fn findScope(scopes: []const Scope, prefix: []const u8) ?Scope {
|
||||
for (scopes) |s| {
|
||||
if (std.mem.eql(u8, s.prefix, prefix)) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn parse(arena: Allocator, base: [:0]const u8, json_content: []const u8) !ImportMap {
|
||||
const parsed = std.json.parseFromSliceLeaky(struct {
|
||||
imports: ?SpecifierMap = null,
|
||||
scopes: ?std.json.ArrayHashMap(SpecifierMap) = null,
|
||||
}, arena, json_content, .{ .ignore_unknown_fields = true }) catch |err| {
|
||||
log.warn(.js, "importmap json parse", .{ .err = err });
|
||||
return error.InvalidImportMap;
|
||||
};
|
||||
|
||||
var im: ImportMap = .{};
|
||||
if (parsed.imports) |obj| {
|
||||
im.imports = try sortedNormalizedSpecifierMap(arena, base, obj);
|
||||
}
|
||||
if (parsed.scopes) |obj| {
|
||||
im.scopes = try sortedNormalizedScopes(arena, base, obj);
|
||||
}
|
||||
return im;
|
||||
}
|
||||
|
||||
fn sortedNormalizedSpecifierMap(arena: Allocator, base: [:0]const u8, obj: SpecifierMap) ![]const Entry {
|
||||
const map = obj.map; // the JSON object is a thin wrapper over an ArrayHashMap
|
||||
|
||||
var list: std.ArrayList(Entry) = try .initCapacity(arena, map.count());
|
||||
|
||||
var it = map.iterator();
|
||||
while (it.next()) |kv| {
|
||||
const key = kv.key_ptr.*;
|
||||
const normalized_key = (try normalizeSpecifierKey(arena, base, key)) orelse continue;
|
||||
|
||||
// we specifically track null so that, on match, we return an error
|
||||
// rather than falling back to the next possible match.
|
||||
const resolved: ?[:0]const u8 = blk: {
|
||||
const url = kv.value_ptr.* orelse break :blk null;
|
||||
const resolved_url = parseIfLikeURL(arena, base, url) orelse {
|
||||
log.warn(.js, "importmap bad address", .{ .specifier = key, .address = url });
|
||||
break :blk null;
|
||||
};
|
||||
|
||||
// Spec: if the key ends with "/" the address must end with "/" too.
|
||||
if (endsWithSlash(normalized_key) and !endsWithSlash(resolved_url)) {
|
||||
log.warn(.js, "importmap slash mismatch", .{ .specifier = key, .address = url });
|
||||
break :blk null;
|
||||
}
|
||||
break :blk resolved_url;
|
||||
};
|
||||
|
||||
list.appendAssumeCapacity(.{ .specifier = normalized_key, .resolved = resolved });
|
||||
}
|
||||
std.sort.pdq(Entry, list.items, {}, struct {
|
||||
fn lessThan(_: void, a: Entry, b: Entry) bool {
|
||||
return a.specifier.len > b.specifier.len;
|
||||
}
|
||||
}.lessThan);
|
||||
|
||||
return list.items;
|
||||
}
|
||||
|
||||
fn sortedNormalizedScopes(arena: Allocator, base: [:0]const u8, obj: std.json.ArrayHashMap(SpecifierMap)) ![]const Scope {
|
||||
const map = obj.map; // the JSON object is a thin wrapper over an ArrayHashMap
|
||||
|
||||
var list: std.ArrayList(Scope) = try .initCapacity(arena, map.count());
|
||||
|
||||
var it = map.iterator();
|
||||
while (it.next()) |kv| {
|
||||
const scope_key = kv.key_ptr.*;
|
||||
// Scope keys parse as ordinary URLs (relative against base), not as
|
||||
// URL-like specifiers — bare strings without ./, ../, /, or a scheme
|
||||
// are still allowed if they resolve against the base.
|
||||
const prefix = parseScopeKey(arena, base, scope_key) catch |err| {
|
||||
log.warn(.js, "importmap bad scope key", .{ .scope = scope_key, .err = err });
|
||||
continue;
|
||||
};
|
||||
|
||||
list.appendAssumeCapacity(.{
|
||||
.prefix = prefix,
|
||||
.imports = try sortedNormalizedSpecifierMap(arena, base, kv.value_ptr.*),
|
||||
});
|
||||
}
|
||||
std.sort.pdq(Scope, list.items, {}, struct {
|
||||
fn lessThan(_: void, a: Scope, b: Scope) bool {
|
||||
return a.prefix.len > b.prefix.len;
|
||||
}
|
||||
}.lessThan);
|
||||
|
||||
return list.items;
|
||||
}
|
||||
|
||||
fn normalizeSpecifierKey(arena: Allocator, base: [:0]const u8, key: []const u8) !?[]const u8 {
|
||||
if (key.len == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parseIfLikeURL(arena, base, key)) |url| {
|
||||
return url;
|
||||
}
|
||||
|
||||
return try arena.dupe(u8, key);
|
||||
}
|
||||
|
||||
fn parseScopeKey(arena: Allocator, base: [:0]const u8, key: []const u8) ![]const u8 {
|
||||
if (key.len == 0) {
|
||||
return base;
|
||||
}
|
||||
return URL.resolve(arena, base, key, .{ .always_dupe = true, .encoding = "UTF-8" });
|
||||
}
|
||||
|
||||
/// Returns the parsed URL if `specifier` looks like a URL. Else returns null;
|
||||
fn parseIfLikeURL(arena: Allocator, base: [:0]const u8, specifier: []const u8) ?[:0]const u8 {
|
||||
if (specifier.len == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (specifier[0] == '/' or
|
||||
std.mem.startsWith(u8, specifier, "./") or
|
||||
std.mem.startsWith(u8, specifier, "../") or
|
||||
hasScheme(specifier))
|
||||
{
|
||||
return URL.resolve(arena, base, specifier, .{ .always_dupe = true, .encoding = "UTF-8" }) catch return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn hasScheme(s: []const u8) bool {
|
||||
if (s.len == 0 or !std.ascii.isAlphabetic(s[0])) return false;
|
||||
for (s[1..]) |c| {
|
||||
if (c == ':') return true;
|
||||
if (!std.ascii.isAlphanumeric(c) and c != '+' and c != '-' and c != '.') return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn endsWithSlash(s: []const u8) bool {
|
||||
return s.len > 0 and s[s.len - 1] == '/';
|
||||
}
|
||||
|
||||
/// Returns the resolved URL on success. Returns `null` when the specifier is
|
||||
/// bare and no entry matches — the caller decides whether that's an error.
|
||||
pub fn resolve(
|
||||
self: *const ImportMap,
|
||||
arena: Allocator,
|
||||
base: [:0]const u8,
|
||||
specifier: [:0]const u8,
|
||||
) !?[:0]const u8 {
|
||||
const as_url = parseIfLikeURL(arena, base, specifier);
|
||||
|
||||
const normalized: []const u8 = if (as_url) |u| u else specifier;
|
||||
|
||||
for (self.scopes) |scope| {
|
||||
if (scopeMatches(scope.prefix, base) == false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (try resolveImportsMatch(arena, normalized, as_url, scope.imports)) |r| {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
if (try resolveImportsMatch(arena, normalized, as_url, self.imports)) |r| {
|
||||
return r;
|
||||
}
|
||||
|
||||
return as_url;
|
||||
}
|
||||
|
||||
fn scopeMatches(prefix: []const u8, base: []const u8) bool {
|
||||
if (std.mem.eql(u8, prefix, base)) {
|
||||
return true;
|
||||
}
|
||||
return endsWithSlash(prefix) and std.mem.startsWith(u8, base, prefix);
|
||||
}
|
||||
|
||||
fn resolveImportsMatch(
|
||||
arena: Allocator,
|
||||
normalized: []const u8,
|
||||
as_url: ?[:0]const u8,
|
||||
imports: []const Entry,
|
||||
) !?[:0]const u8 {
|
||||
for (imports) |entry| {
|
||||
if (std.mem.eql(u8, entry.specifier, normalized)) {
|
||||
return entry.resolved orelse return error.SpecifierResolutionFailed;
|
||||
}
|
||||
|
||||
if (endsWithSlash(entry.specifier) == false) {
|
||||
continue;
|
||||
}
|
||||
if (!std.mem.startsWith(u8, normalized, entry.specifier)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Per spec, trailing-slash prefix matching only applies when the
|
||||
// specifier is bare or its scheme is "special" (http(s), ws(s),
|
||||
// file, ftp). data:/blob:/about: don't match prefixes.
|
||||
if (as_url) |u| {
|
||||
if (isSpecialUrl(u) == false) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const base_addr = entry.resolved orelse return error.SpecifierResolutionFailed;
|
||||
const after = normalized[entry.specifier.len..];
|
||||
const url = URL.resolve(arena, base_addr, after, .{ .always_dupe = true, .encoding = "UTF-8" }) catch {
|
||||
return error.SpecifierResolutionFailed;
|
||||
};
|
||||
|
||||
// Backtracking prevention — the resolved URL must remain under the
|
||||
// address (`../` etc. is not allowed to escape).
|
||||
if (!std.mem.startsWith(u8, url, base_addr)) {
|
||||
return error.SpecifierResolutionFailed;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
fn isSpecialUrl(url: []const u8) bool {
|
||||
const colon = std.mem.indexOfScalarPos(u8, url, 0, ':') orelse return false;
|
||||
const scheme = url[0..colon];
|
||||
inline for (.{ "https", "http", "ws", "wss", "file", "ftp" }) |s| {
|
||||
if (std.ascii.eqlIgnoreCase(scheme, s)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "ImportMap: exact match" {
|
||||
defer testing.reset();
|
||||
|
||||
const im = try testParse(
|
||||
\\{ "imports": { "moment": "/node_modules/moment/index.js" } }
|
||||
, "https://example.com/app/index.html");
|
||||
|
||||
const r = try testResolve(&im, "https://example.com/app.mjs", "moment");
|
||||
try testing.expectString("https://example.com/node_modules/moment/index.js", r.?);
|
||||
}
|
||||
|
||||
test "ImportMap: trailing slash prefix match" {
|
||||
defer testing.reset();
|
||||
|
||||
const im = try testParse(
|
||||
\\{ "imports": { "moment/": "/node_modules/moment/src/" } }
|
||||
, "https://example.com/app/index.html");
|
||||
|
||||
const r = try testResolve(&im, "https://example.com/app.mjs", "moment/foo");
|
||||
try testing.expectString("https://example.com/node_modules/moment/src/foo", r.?);
|
||||
}
|
||||
|
||||
test "ImportMap: specificity — longest match wins" {
|
||||
defer testing.reset();
|
||||
|
||||
const im = try testParse(
|
||||
\\{ "imports": {
|
||||
\\ "a": "/1",
|
||||
\\ "a/": "/2/",
|
||||
\\ "a/b": "/3",
|
||||
\\ "a/b/": "/4/"
|
||||
\\} }
|
||||
, "https://example.com/app/index.html");
|
||||
|
||||
const r1 = try testResolve(&im, "https://example.com/app.mjs", "a");
|
||||
try testing.expectString("https://example.com/1", r1.?);
|
||||
|
||||
const r2 = try testResolve(&im, "https://example.com/app.mjs", "a/");
|
||||
try testing.expectString("https://example.com/2/", r2.?);
|
||||
|
||||
const r3 = try testResolve(&im, "https://example.com/app.mjs", "a/x");
|
||||
try testing.expectString("https://example.com/2/x", r3.?);
|
||||
|
||||
const r4 = try testResolve(&im, "https://example.com/app.mjs", "a/b");
|
||||
try testing.expectString("https://example.com/3", r4.?);
|
||||
|
||||
const r5 = try testResolve(&im, "https://example.com/app.mjs", "a/b/");
|
||||
try testing.expectString("https://example.com/4/", r5.?);
|
||||
|
||||
const r6 = try testResolve(&im, "https://example.com/app.mjs", "a/b/c");
|
||||
try testing.expectString("https://example.com/4/c", r6.?);
|
||||
}
|
||||
|
||||
test "ImportMap: scopes — most specific scope wins" {
|
||||
defer testing.reset();
|
||||
|
||||
const im = try testParse(
|
||||
\\{
|
||||
\\ "imports": { "a": "/a-1.mjs", "b": "/b-1.mjs", "d": "/d-1.mjs" },
|
||||
\\ "scopes": {
|
||||
\\ "/scope2/": { "a": "/a-2.mjs", "d": "/d-2.mjs" },
|
||||
\\ "/scope2/scope3/": { "b": "/b-3.mjs", "d": "/d-3.mjs" }
|
||||
\\ }
|
||||
\\}
|
||||
, "https://example.com/app/index.html");
|
||||
|
||||
// From scope2/scope3 base
|
||||
const a = try testResolve(&im, "https://example.com/scope2/scope3/foo.mjs", "a");
|
||||
try testing.expectString("https://example.com/a-2.mjs", a.?);
|
||||
|
||||
const b = try testResolve(&im, "https://example.com/scope2/scope3/foo.mjs", "b");
|
||||
try testing.expectString("https://example.com/b-3.mjs", b.?);
|
||||
|
||||
const d = try testResolve(&im, "https://example.com/scope2/scope3/foo.mjs", "d");
|
||||
try testing.expectString("https://example.com/d-3.mjs", d.?);
|
||||
|
||||
// Falls back to scope2 for things not in scope3
|
||||
const a2 = try testResolve(&im, "https://example.com/scope2/foo.mjs", "a");
|
||||
try testing.expectString("https://example.com/a-2.mjs", a2.?);
|
||||
|
||||
const b2 = try testResolve(&im, "https://example.com/scope2/foo.mjs", "b");
|
||||
try testing.expectString("https://example.com/b-1.mjs", b2.?);
|
||||
}
|
||||
|
||||
test "ImportMap: bare specifier with no match returns null" {
|
||||
defer testing.reset();
|
||||
|
||||
const im = try testParse(
|
||||
\\{ "imports": { "moment": "/m.js" } }
|
||||
, "https://example.com/app/index.html");
|
||||
|
||||
const r = try testResolve(&im, "https://example.com/app.mjs", "nope");
|
||||
try testing.expectEqual(null, r);
|
||||
}
|
||||
|
||||
test "ImportMap: URL-like specifier falls back to itself" {
|
||||
defer testing.reset();
|
||||
|
||||
const im: ImportMap = .empty;
|
||||
|
||||
const r = try testResolve(&im, "https://example.com/app.mjs", "./foo.js");
|
||||
try testing.expectString("https://example.com/foo.js", r.?);
|
||||
}
|
||||
|
||||
test "ImportMap: null entry throws (no fallback)" {
|
||||
defer testing.reset();
|
||||
|
||||
const im = try testParse(
|
||||
\\{ "imports": { "blocked": null } }
|
||||
, "https://example.com/app/index.html");
|
||||
|
||||
try testing.expectError(error.SpecifierResolutionFailed, testResolve(&im, "https://example.com/app.mjs", "blocked"));
|
||||
}
|
||||
|
||||
test "ImportMap: backtracking out of prefix throws" {
|
||||
defer testing.reset();
|
||||
|
||||
const im = try testParse(
|
||||
\\{ "imports": { "moment/": "/node_modules/moment/src/" } }
|
||||
, "https://example.com/app/index.html");
|
||||
|
||||
try testing.expectError(error.SpecifierResolutionFailed, testResolve(&im, "https://example.com/app.mjs", "moment/../backtrack"));
|
||||
}
|
||||
|
||||
test "ImportMap: merge — first-wins on imports, new keys added" {
|
||||
defer testing.reset();
|
||||
const base: [:0]const u8 = "https://example.com/app/index.html";
|
||||
|
||||
var im = try testParse(
|
||||
\\{ "imports": { "a": "/a-first.mjs", "b": "/b-first.mjs" } }
|
||||
, base);
|
||||
|
||||
try im.merge(testing.arena_allocator, base,
|
||||
\\{ "imports": { "a": "/a-second.mjs", "c": "/c-second.mjs" } }
|
||||
);
|
||||
|
||||
// First-wins: "a" keeps the original mapping.
|
||||
const a = try testResolve(&im, "https://example.com/app.mjs", "a");
|
||||
try testing.expectString("https://example.com/a-first.mjs", a.?);
|
||||
// New key from the second map shows up.
|
||||
const c = try testResolve(&im, "https://example.com/app.mjs", "c");
|
||||
try testing.expectString("https://example.com/c-second.mjs", c.?);
|
||||
}
|
||||
|
||||
test "ImportMap: merge — same-prefix scopes merge their imports" {
|
||||
defer testing.reset();
|
||||
const base: [:0]const u8 = "https://example.com/app/index.html";
|
||||
|
||||
var im = try testParse(
|
||||
\\{ "scopes": { "/s/": { "a": "/a-first.mjs" } } }
|
||||
, base);
|
||||
|
||||
try im.merge(testing.arena_allocator, base,
|
||||
\\{ "scopes": { "/s/": { "a": "/a-second.mjs", "b": "/b-second.mjs" }, "/t/": { "x": "/x.mjs" } } }
|
||||
);
|
||||
|
||||
// "a" within /s/ keeps its original value.
|
||||
const a = try testResolve(&im, "https://example.com/s/foo.mjs", "a");
|
||||
try testing.expectString("https://example.com/a-first.mjs", a.?);
|
||||
// "b" was added to /s/ from the second map.
|
||||
const b = try testResolve(&im, "https://example.com/s/foo.mjs", "b");
|
||||
try testing.expectString("https://example.com/b-second.mjs", b.?);
|
||||
// New scope /t/ landed too.
|
||||
const x = try testResolve(&im, "https://example.com/t/foo.mjs", "x");
|
||||
try testing.expectString("https://example.com/x.mjs", x.?);
|
||||
}
|
||||
|
||||
fn testParse(content: []const u8, base: [:0]const u8) !ImportMap {
|
||||
return parse(testing.arena_allocator, base, content);
|
||||
}
|
||||
|
||||
fn testResolve(im: *const ImportMap, base: [:0]const u8, specifier: [:0]const u8) !?[:0]const u8 {
|
||||
return im.resolve(testing.arena_allocator, base, specifier);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const HttpClient = @import("HttpClient.zig");
|
||||
const js = @import("js/js.zig");
|
||||
const URL = @import("URL.zig");
|
||||
const Frame = @import("Frame.zig");
|
||||
const ImportMap = @import("ImportMap.zig");
|
||||
const ScriptManagerBase = @import("ScriptManagerBase.zig");
|
||||
|
||||
const Element = @import("webapi/Element.zig");
|
||||
@@ -305,36 +306,6 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
script.eval();
|
||||
}
|
||||
|
||||
pub 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.frame.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.resolve(
|
||||
self.frame.arena,
|
||||
self.frame.base(),
|
||||
entry.value_ptr.*,
|
||||
.{},
|
||||
);
|
||||
|
||||
try self.base.importmap.put(self.frame.arena, entry.key_ptr.*, resolved_url);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn staticScriptsDone(self: *ScriptManager) void {
|
||||
self.base.staticScriptsDone();
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ const js = @import("js/js.zig");
|
||||
const URL = @import("URL.zig");
|
||||
const Session = @import("Session.zig");
|
||||
const Frame = @import("Frame.zig");
|
||||
const ImportMap = @import("ImportMap.zig");
|
||||
const WorkerGlobalScope = @import("webapi/WorkerGlobalScope.zig");
|
||||
|
||||
const Element = @import("webapi/Element.zig");
|
||||
@@ -118,11 +119,8 @@ allocator: Allocator,
|
||||
// See ScriptManager.zig for the type's documentation.
|
||||
imported_modules: std.StringHashMapUnmanaged(ImportedModule),
|
||||
|
||||
// Mapping between module specifier and resolution.
|
||||
// see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap
|
||||
// For workers this stays empty (only Frame authors importmaps via
|
||||
// ScriptManager.parseImportmap).
|
||||
importmap: std.StringHashMapUnmanaged([:0]const u8),
|
||||
// For workers this stays empty
|
||||
importmap: ImportMap,
|
||||
|
||||
// Called at the end of evaluate() after all Base-owned work has run. Frame
|
||||
// wrapper uses this to drain defer_scripts and fire documentIsLoaded /
|
||||
@@ -150,8 +148,6 @@ pub fn deinit(self: *ScriptManagerBase) void {
|
||||
self.reset();
|
||||
|
||||
self.imported_modules.deinit(self.allocator);
|
||||
// we don't deinit self.importmap b/c we use the owner's arena for its
|
||||
// allocations.
|
||||
}
|
||||
|
||||
pub fn reset(self: *ScriptManagerBase) void {
|
||||
@@ -164,9 +160,8 @@ pub fn reset(self: *ScriptManagerBase) void {
|
||||
}
|
||||
self.imported_modules.clearRetainingCapacity();
|
||||
|
||||
// The importmap's keys/values were allocated from the owner's arena, which
|
||||
// has been reset. Can't use clearAndRetainCapacity — that space is no
|
||||
// longer ours.
|
||||
// The importmap's contents were allocated from the owner's arena, which
|
||||
// has been reset, so just zero the struct.
|
||||
self.importmap = .empty;
|
||||
|
||||
clearList(&self.defer_scripts);
|
||||
@@ -209,13 +204,12 @@ pub fn scriptList(self: *ScriptManagerBase, script: *const Script) *std.DoublyLi
|
||||
|
||||
// Resolve a module specifier to a valid URL.
|
||||
pub fn resolveSpecifier(self: *ScriptManagerBase, arena: Allocator, base: [:0]const u8, specifier: [:0]const u8) ![:0]const u8 {
|
||||
// If the specifier is mapped in the importmap, return the pre-resolved
|
||||
// value. For workers this map is empty.
|
||||
if (self.importmap.get(specifier)) |s| {
|
||||
return s;
|
||||
if (try self.importmap.resolve(arena, base, specifier)) |url| {
|
||||
return url;
|
||||
}
|
||||
|
||||
return URL.resolve(arena, base, specifier, .{ .always_dupe = true });
|
||||
// The importmap _always_ resolves specifies if they're valid, falling back
|
||||
// to the base + specifier itself. So we can only be here on something invalid.
|
||||
return error.SpecifierResolutionFailed;
|
||||
}
|
||||
|
||||
pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []const u8) !void {
|
||||
@@ -736,10 +730,10 @@ pub const Script = struct {
|
||||
|
||||
const local = &ls.local;
|
||||
|
||||
// Handle importmap special case here: the content is a JSON containing
|
||||
// imports.
|
||||
// Handle importmap special case here: the content is a JSON containing imports.
|
||||
// Multiple <script type="importmap"> elements merge with first-wins semantics.
|
||||
if (fe.kind == .importmap) {
|
||||
frame._script_manager.parseImportmap(self) catch |err| {
|
||||
self.manager.importmap.merge(frame.arena, frame.base(), self.source.content()) catch |err| {
|
||||
log.err(.browser, "parse importmap script", .{
|
||||
.err = err,
|
||||
.src = url,
|
||||
|
||||
@@ -138,21 +138,23 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, source_path: anytype, o
|
||||
const path_start = std.mem.indexOfAnyPos(u8, base, authority_start, "/?#") orelse base.len;
|
||||
const path_end = std.mem.indexOfAnyPos(u8, base, path_start, "?#") orelse base.len;
|
||||
|
||||
var out: []u8 = undefined;
|
||||
if (path[0] == '/') {
|
||||
const result = try std.mem.joinZ(allocator, "", &.{ base[0..path_start], path });
|
||||
return processResolved(allocator, result, opts);
|
||||
}
|
||||
|
||||
var normalized_base: []const u8 = base[0..path_start];
|
||||
if (path_start < path_end) {
|
||||
if (std.mem.lastIndexOfScalar(u8, base[path_start + 1 .. path_end], '/')) |pos| {
|
||||
normalized_base = base[0 .. path_start + 1 + pos];
|
||||
// Absolute path — keep base authority, replace path. Two trailing
|
||||
// spaces give us safe lookahead for the dot-segment loop below.
|
||||
out = try std.mem.join(allocator, "", &.{ base[0..path_start], path, " " });
|
||||
} else {
|
||||
var normalized_base: []const u8 = base[0..path_start];
|
||||
if (path_start < path_end) {
|
||||
if (std.mem.lastIndexOfScalar(u8, base[path_start + 1 .. path_end], '/')) |pos| {
|
||||
normalized_base = base[0 .. path_start + 1 + pos];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// trailing space so that we always have space to append the null terminator
|
||||
// and so that we can compare the next two characters without needing to length check
|
||||
var out = try std.mem.join(allocator, "", &.{ normalized_base, "/", path, " " });
|
||||
// trailing space so that we always have space to append the null terminator
|
||||
// and so that we can compare the next two characters without needing to length check
|
||||
out = try std.mem.join(allocator, "", &.{ normalized_base, "/", path, " " });
|
||||
}
|
||||
|
||||
const end = out.len - 2;
|
||||
|
||||
@@ -170,8 +172,10 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, source_path: anytype, o
|
||||
in_i += 2;
|
||||
continue;
|
||||
}
|
||||
if (out[in_i + 1] == '.' and out[in_i + 2] == '/') { // always safe, because we added two whitespaces
|
||||
// /../
|
||||
if (out[in_i + 1] == '.' and (out[in_i + 2] == '/' or in_i + 2 == end)) {
|
||||
// /../ or trailing /.. — both step up one segment. The
|
||||
// trailing slash stays implicit (out_i ends up right after
|
||||
// the previous '/'), matching `new URL("..", base)`.
|
||||
if (out_i > path_marker) {
|
||||
// go back before the /
|
||||
out_i -= 2;
|
||||
@@ -1179,6 +1183,36 @@ test "URL: resolve" {
|
||||
.path = "../../../../example/about",
|
||||
.expected = "https://www.example.com/example/about",
|
||||
},
|
||||
.{
|
||||
.base = "https://example.com/a/b/c/",
|
||||
.path = "..",
|
||||
.expected = "https://example.com/a/b/",
|
||||
},
|
||||
.{
|
||||
.base = "https://example.com/a/b/c",
|
||||
.path = "..",
|
||||
.expected = "https://example.com/a/",
|
||||
},
|
||||
.{
|
||||
.base = "https://example.com/js/app.mjs",
|
||||
.path = "/test/..",
|
||||
.expected = "https://example.com/",
|
||||
},
|
||||
.{
|
||||
.base = "https://example.com/js/app.mjs",
|
||||
.path = "/a/b/../c",
|
||||
.expected = "https://example.com/a/c",
|
||||
},
|
||||
.{
|
||||
.base = "https://example.com/js/app.mjs",
|
||||
.path = "/../../foo/bar",
|
||||
.expected = "https://example.com/foo/bar",
|
||||
},
|
||||
.{
|
||||
.base = "https://example.com/js/app.mjs",
|
||||
.path = "/../foo/../bar",
|
||||
.expected = "https://example.com/bar",
|
||||
},
|
||||
};
|
||||
|
||||
for (cases) |case| {
|
||||
|
||||
@@ -502,11 +502,16 @@ fn postCompileModule(self: *Context, mod: js.Module, url: [:0]const u8, local: *
|
||||
const script_manager = self.script_manager;
|
||||
for (0..request_len) |i| {
|
||||
const specifier = requests.get(i).specifier(local);
|
||||
const normalized_specifier = try script_manager.resolveSpecifier(
|
||||
const normalized_specifier = script_manager.resolveSpecifier(
|
||||
self.call_arena,
|
||||
url,
|
||||
try specifier.toSliceZ(),
|
||||
);
|
||||
) catch |err| switch (err) {
|
||||
error.SpecifierResolutionFailed => {
|
||||
_ = self.isolate.throwException(self.isolate.createTypeError("Failed to resolve module specifier"));
|
||||
return err;
|
||||
},
|
||||
};
|
||||
const nested_gop = try self.module_cache.getOrPut(self.arena, normalized_specifier);
|
||||
if (!nested_gop.found_existing) {
|
||||
const owned_specifier = try self.arena.dupeZ(u8, normalized_specifier);
|
||||
@@ -560,6 +565,9 @@ fn resolveModuleCallback(
|
||||
const referrer = js.Module{ .local = &local, .handle = c_referrer.? };
|
||||
|
||||
return self._resolveModuleCallback(referrer, specifier, &local) catch |err| {
|
||||
if (err == error.SpecifierResolutionFailed) {
|
||||
_ = self.isolate.throwException(self.isolate.createTypeError("Failed to resolve module specifier"));
|
||||
}
|
||||
log.err(.js, "resolve module", .{
|
||||
.err = err,
|
||||
.specifier = specifier,
|
||||
@@ -609,9 +617,10 @@ pub fn dynamicModuleCallback(
|
||||
self.arena, // might need to survive until the module is loaded
|
||||
resource,
|
||||
specifier,
|
||||
) catch |err| {
|
||||
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback3" });
|
||||
return @constCast(local.rejectPromise(.{ .generic_error = "Out of memory" }).handle);
|
||||
) catch |err| switch (err) {
|
||||
error.SpecifierResolutionFailed => {
|
||||
return @constCast(local.rejectPromise(.{ .type_error = "Failed to resolve module specifier" }).handle);
|
||||
},
|
||||
};
|
||||
|
||||
const promise = self._dynamicModuleCallback(normalized_specifier, resource, &local) catch |err| blk: {
|
||||
|
||||
27
src/browser/tests/element/html/base.html
Normal file
27
src/browser/tests/element/html/base.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../../testing.js"></script>
|
||||
|
||||
<base href="https://www.example.com">
|
||||
<a id=a href=spice></a>
|
||||
|
||||
<script id=base>
|
||||
{
|
||||
const b0 = $('base');
|
||||
|
||||
// initial condition
|
||||
const a = $('#a');
|
||||
testing.expectEqual('https://www.example.com/spice', a.href);
|
||||
|
||||
// add base AFTER the existing one
|
||||
const b1 = document.createElement('base');
|
||||
b0.after(b1);
|
||||
b1.href = 'https://www.example.com/1/';
|
||||
testing.expectEqual('https://www.example.com/spice', a.href);
|
||||
|
||||
// add base BEFORE existing one
|
||||
const b2 = document.createElement('base');
|
||||
b0.before(b2);
|
||||
b2.href = 'https://www.example.com/2/';
|
||||
testing.expectEqual('https://www.example.com/2/spice', a.href);
|
||||
}
|
||||
</script>
|
||||
@@ -1,4 +1,7 @@
|
||||
const js = @import("../../../js/js.zig");
|
||||
const URL = @import("../../../URL.zig");
|
||||
const Frame = @import("../../../Frame.zig");
|
||||
|
||||
const Node = @import("../../Node.zig");
|
||||
const Element = @import("../../Element.zig");
|
||||
const HtmlElement = @import("../Html.zig");
|
||||
@@ -14,6 +17,44 @@ pub fn asNode(self: *Base) *Node {
|
||||
return self.asElement().asNode();
|
||||
}
|
||||
|
||||
pub fn getHref(self: *Base, frame: *Frame) ![]const u8 {
|
||||
const element = self.asElement();
|
||||
const href = element.getAttributeSafe(comptime .wrap("href")) orelse return "";
|
||||
if (href.len == 0) {
|
||||
return "";
|
||||
}
|
||||
return URL.resolve(frame.call_arena, frame.url, href, .{});
|
||||
}
|
||||
|
||||
pub fn setHref(self: *Base, value: []const u8, frame: *Frame) !void {
|
||||
const element = self.asElement();
|
||||
try element.setAttributeSafe(comptime .wrap("href"), .wrap(value), frame);
|
||||
|
||||
// Per HTML spec, the document's base URL is the href of the FIRST <base>
|
||||
// element in tree order that has an href attribute — not necessarily this
|
||||
// one. Re-derive from scratch so that setting href on a non-authoritative
|
||||
// <base>, or clearing href on the authoritative one, both work correctly.
|
||||
const node = element.asNode();
|
||||
if (!node.isConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const owner = node.ownerFrame(frame);
|
||||
const first = (try owner.document.querySelector(comptime .wrap("base[href]"), owner)) orelse {
|
||||
owner.base_url = null;
|
||||
return;
|
||||
};
|
||||
const href = first.getAttributeSafe(comptime .wrap("href")) orelse {
|
||||
owner.base_url = null;
|
||||
return;
|
||||
};
|
||||
if (href.len == 0) {
|
||||
owner.base_url = null;
|
||||
return;
|
||||
}
|
||||
owner.base_url = try URL.resolve(owner.arena, owner.url, href, .{});
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Base);
|
||||
|
||||
@@ -22,4 +63,11 @@ pub const JsApi = struct {
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const href = bridge.accessor(Base.getHref, Base.setHref, .{ .ce_reactions = true });
|
||||
};
|
||||
|
||||
const testing = @import("../../../../testing.zig");
|
||||
test "WebApi: HTML.Base" {
|
||||
try testing.htmlRunner("element/html/base.html", .{});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user