Skip to content

Commit

Permalink
refactor(joinURL): rewrite with clear syntax and relative ../ suppo…
Browse files Browse the repository at this point in the history
…rt (#218)
  • Loading branch information
pi0 authored Mar 11, 2024
1 parent 66c24d2 commit f7d4711
Show file tree
Hide file tree
Showing 3 changed files with 60 additions and 39 deletions.
60 changes: 41 additions & 19 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `../`.
Expand Down Expand Up @@ -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;
}

Expand Down
4 changes: 2 additions & 2 deletions test/base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/" },
Expand Down
35 changes: 17 additions & 18 deletions test/join.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
});
});

0 comments on commit f7d4711

Please sign in to comment.