diff --git a/src/utils.ts b/src/utils.ts index 9e3345e0..e5d3708b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -13,8 +13,6 @@ const PROTOCOL_REGEX = /^[\s\w\0+.-]{2,}:([/\\]{2})?/; const PROTOCOL_RELATIVE_REGEX = /^([/\\]\s*){2,}[^/\\]/; const PROTOCOL_SCRIPT_RE = /^[\s\0]*(blob|data|javascript|vbscript):$/i; const TRAILING_SLASH_RE = /\/$|\/\?|\/#/; -const JOIN_LEADING_SLASH_RE = /^\.?\//; -const JOIN_LAST_SEGMENT_RE = /(?:^|\/)[^/]*\/?$/; /** * Check if a path starts with `./` or `../`. @@ -319,27 +317,51 @@ export function isNonEmptyURL(url: string) { * * @group utils */ -export function joinURL(base: string, ...input: string[]): string { - let url = base || ""; - - for (const segment of input.filter((url) => isNonEmptyURL(url))) { - if (url) { - const hasAbsoluteBase = url.startsWith("/"); - let _segment = segment; - _segment = _segment.replace(JOIN_LEADING_SLASH_RE, ""); - while (url.length > 0 && _segment.startsWith("../")) { - url = url.replace(JOIN_LAST_SEGMENT_RE, ""); - _segment = _segment.slice(3).replace(JOIN_LEADING_SLASH_RE, ""); +export function joinURL(..._input: string[]): string { + const input = _input.filter(Boolean); + + const segments: string[] = []; + + let segmentsDepth = 0; + + for (const i of input) { + if (!i || i === "/") { + continue; + } + for (const s of i.split("/")) { + if (!s || s === ".") { + continue; + } + if (s === "..") { + segments.pop(); + segmentsDepth--; + continue; } - url = - !url && (!hasAbsoluteBase || _segment.startsWith("../")) - ? _segment - : withTrailingSlash(url) + _segment; - } else { - url = segment; + segments.push(s); + segmentsDepth++; } } + let url = segments.join("/"); + + if (segmentsDepth >= 0) { + // Preserve leading slash + if (input[0]?.startsWith("/") && !url.startsWith("/")) { + url = "/" + url; + } else if (input[0]?.startsWith("./") && !url.startsWith("./")) { + url = "./" + url; + } + } else { + // Add relative prefix + url = "../".repeat(-1 * segmentsDepth) + url; + } + + // Preserve trailing slash + // eslint-disable-next-line unicorn/prefer-at + if (input[input.length - 1]?.endsWith("/") && !url.endsWith("/")) { + url += "/"; + } + return url; } diff --git a/test/base.test.ts b/test/base.test.ts index 1c5e838e..0cd85daf 100644 --- a/test/base.test.ts +++ b/test/base.test.ts @@ -4,8 +4,8 @@ import { withBase, withoutBase } from "../src"; describe("withBase", () => { const tests = [ { base: "/", input: "/", out: "/" }, - { base: "/foo", input: "/", out: "/foo" }, - { base: "/foo/", input: "/", out: "/foo" }, + { base: "/foo", input: "", out: "/foo" }, + { base: "/foo/", input: "/", out: "/foo/" }, { base: "/foo", input: "/bar", out: "/foo/bar" }, { base: "/base/", input: "/base", out: "/base" }, { base: "/base", input: "/base/", out: "/base/" }, diff --git a/test/join.test.ts b/test/join.test.ts index 0135200a..9bf5459a 100644 --- a/test/join.test.ts +++ b/test/join.test.ts @@ -5,38 +5,37 @@ describe("joinURL", () => { const tests = [ { input: [], out: "" }, { input: ["/"], out: "/" }, - // eslint-disable-next-line unicorn/no-null - { input: [null, "./"], out: "./" }, + { input: [undefined, "./"], out: "./" }, { input: ["./", "a"], out: "./a" }, { input: ["./a", "./b"], out: "./a/b" }, { input: ["/a"], out: "/a" }, { input: ["a", "b"], out: "a/b" }, { input: ["/", "/b"], out: "/b" }, - { input: ["/a", "../b"], out: "/b" }, - { input: ["../a", "../b"], out: "../b" }, - { input: ["../a", "./../b"], out: "../b" }, - { input: ["../a", "./../../b"], out: "b" }, - { input: ["../a", "../../../b"], out: "../b" }, - { input: ["../a", "../../../../b"], out: "../../b" }, - { input: ["../a/", "../b"], out: "../b" }, - { input: ["/a/b/c", "../../d"], out: "/a/d" }, - { input: ["/c", "../../d"], out: "../d" }, - { input: ["/c", ".././../d"], out: "../d" }, { input: ["a", "b/", "c"], out: "a/b/c" }, { input: ["a", "b/", "/c"], out: "a/b/c" }, { input: ["/", "./"], out: "/" }, { input: ["/", "./foo"], out: "/foo" }, { input: ["/", "./foo/"], out: "/foo/" }, { input: ["/", "./foo", "bar"], out: "/foo/bar" }, + + // Relative with ../ + { input: ["/a", "../b"], out: "/b" }, + { input: ["/a/b/c", "../../d"], out: "/a/d" }, + { input: ["/a", "../../d"], out: "/d" }, + { input: ["/a", ".././../d"], out: "/d" }, + { input: ["/a", "../../../d"], out: "../d" }, + { input: ["/a/b", "../../../d"], out: "/d" }, + { input: ["../a", "../b"], out: "b" }, + { input: ["../a", "./../b"], out: "b" }, + { input: ["../a", "./../../b"], out: "../b" }, + { input: ["../a", "../../../b"], out: "../../b" }, + { input: ["../a", "../../../../b"], out: "../../../b" }, + { input: ["../a/", "../b"], out: "b" }, ]; for (const t of tests) { - test(JSON.stringify(t.input), () => { - expect(joinURL(...t.input)).toBe(t.out); + test(`joinURL(${t.input.map((i) => JSON.stringify(i)).join(", ")}) === ${JSON.stringify(t.out)}`, () => { + expect(joinURL(...(t.input as string[]))).toBe(t.out); }); } - - test("no arguments", () => { - expect(joinURL()).toBe(""); - }); });