fix(javascript): keep empty URL path segments significant (#3792)

* fix(javascript): keep empty URL path segments significant

ESM imports and HTML script references were joined with POSIX path
rules, which collapse '//' before applying '..'. URL resolution removes
the empty segment instead, so './assets//../app.js' names assets/app.js,
not app.js, and the graph linked the importer and renderer to the wrong
file. URL references refused every '//' even when dot segments cancel it.

Resolve ESM, HTML (including document base) and URL references through
one URL dot-segment helper. A remaining empty segment stays unavailable,
references above the artifact root are still rejected, and CommonJS keeps
filesystem path semantics.

Fixes #3307

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(javascript): resolve HTML base and reference as one URL path

The base href was turned into a canonical path before the script
reference was applied. A base empty segment that the reference's '..'
would remove was rejected early, and an above-root '..' from the base
became a removable segment that the reference's '..' could cancel.

Keep one URL path state through base and reference resolution, with
above-root escapes held apart from removable segments, and reject empty
segments only in the final path. Remove the superseded htmlBaseDirectory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-14 19:12:49 -07:00
1 parent 3ded799bc7
commit 90c6922905
4 files changed
+176 -29

No files matched your search

@@ -2495,20 +2495,61 @@ describe("url-reference directory and empty-segment semantics", () => {
});
});
it.each(["./nested//worker.js", "./nested//../worker.js"])(
"does not collapse empty URL path segments: %s",
(declaredPath) => {
it.each([
["./nested//worker.js", null],
["./nested//../worker.js", "dist/nested/worker.js"],
])(
"keeps empty URL path segments significant: %s",
(declaredPath, resolvedPath) => {
// A dot segment removes the preceding empty segment, never `nested`.
expect(new URL(declaredPath, "file:///root/dist/app.mjs").pathname).toBe(
resolvedPath === null
? "/root/dist/nested//worker.js"
: `/root/${resolvedPath}`,
);
for (const context of ["url-reference", "html-reference"] as const)
expect(
resolve({
declaredPath,
sourcePath: "dist/app.mjs",
context,
files: fileMap([
file("dist/worker.js", "root"),
file("dist/nested/worker.js", "root"),
]),
}),
context,
).toMatchObject({ resolved_path: resolvedPath });
expect(
resolve({
declaredPath,
sourcePath: "dist/app.mjs",
context: "url-reference",
context: "module-specifier",
moduleKind: "import",
files: fileMap([
file("dist/worker.js", "root"),
file("dist/nested/worker.js", "root"),
]),
}),
).toMatchObject({ resolved_path: null });
).toMatchObject({ resolved_path: resolvedPath });
// CommonJS keeps filesystem path semantics.
expect(
resolve({
declaredPath,
sourcePath: "dist/app.mjs",
context: "module-specifier",
moduleKind: "require",
files: fileMap([
file("dist/worker.js", "root"),
file("dist/nested/worker.js", "root"),
]),
}),
).toMatchObject({
resolved_path:
declaredPath === "./nested//worker.js"
? "dist/nested/worker.js"
: "dist/worker.js",
});
},
);