diff --git a/gradle/changelog/markdown-links.yaml b/gradle/changelog/markdown-links.yaml
new file mode 100644
index 0000000000..cb5ea15120
--- /dev/null
+++ b/gradle/changelog/markdown-links.yaml
@@ -0,0 +1,2 @@
+- type: fixed
+ description: Broken relative paths for images or links within Markdown files
diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap
index 0070482a67..5725c35ec0 100644
--- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap
+++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap
@@ -14392,7 +14392,7 @@ the story is mostly for checking if the src links are rendered correct.
@@ -14868,8 +14868,7 @@ the story is mostly for checking if the links are rendered correct.
Internal links should be rendered by react-router:
internal link
diff --git a/scm-ui/ui-components/src/markdown/LazyMarkdownView.tsx b/scm-ui/ui-components/src/markdown/LazyMarkdownView.tsx
index d7a6f109bc..d78b4b2547 100644
--- a/scm-ui/ui-components/src/markdown/LazyMarkdownView.tsx
+++ b/scm-ui/ui-components/src/markdown/LazyMarkdownView.tsx
@@ -171,7 +171,7 @@ class LazyMarkdownView extends React.Component {
remarkRendererList.heading = createMarkdownHeadingRenderer(permalink);
}
- remarkRendererList.image = createMarkdownImageRenderer(basePath);
+ remarkRendererList.image = createMarkdownImageRenderer(basePath, permalink);
let protocolLinkRendererExtensions: ProtocolLinkRendererExtensionMap = {};
if (!remarkRendererList.link) {
diff --git a/scm-ui/ui-components/src/markdown/MarkdownImageRenderer.test.ts b/scm-ui/ui-components/src/markdown/MarkdownImageRenderer.test.ts
index 5180ebf144..f3bf753e18 100644
--- a/scm-ui/ui-components/src/markdown/MarkdownImageRenderer.test.ts
+++ b/scm-ui/ui-components/src/markdown/MarkdownImageRenderer.test.ts
@@ -14,36 +14,33 @@
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
-import React from "react";
import { createLocalLink } from "./MarkdownImageRenderer";
-describe("createLocalLink tests", () => {
- const revision = "revision";
- const basePath = `/repo/namespace/name/code/sources/${revision}/`;
- const contentLink = "http://localhost:8081/scm/api/v2/repositories/namespace/name/content/{revision}/{path}";
- const currentPath = basePath + "README.md/";
- const link = "image.png";
+describe("MarkdownImageRenderer createLocalLink tests", () => {
+ const contentLinkBase = "http://localhost:8081/scm/api/v2/repositories/ns/name/content/";
+ const contentLink = contentLinkBase + "{revision}/{path}";
+ const revision = "main";
+ const currentPath = "/repo/ns/name/code/sources/main/folder/README.md/";
- it("should return link for internal scm repo link", () => {
- const internalScmLink = "/repo/namespace/name/code/sources/develop/myImg.png";
- expect(createLocalLink(basePath, contentLink, revision, currentPath, internalScmLink)).toBe(internalScmLink);
+ it("should return link unchanged for internal scm repo links", () => {
+ const internalScmLink = "/repo/ns/name/code/sources/develop/myImg.png";
+ expect(createLocalLink(contentLink, revision, currentPath, internalScmLink)).toBe(internalScmLink);
});
- it("should return modified contentLink for absolute link", () => {
- expect(createLocalLink(basePath, contentLink, revision, currentPath, "/path/anotherImg.jpg")).toBe(
- "http://localhost:8081/scm/api/v2/repositories/namespace/name/content/revision/path/anotherImg.jpg"
+ it("should return normalized absolute path starting with slash", () => {
+ const absoluteLink = "/path/anotherImg.jpg";
+ expect(createLocalLink(contentLink, revision, currentPath, absoluteLink)).toBe(
+ `${contentLinkBase}${revision}${absoluteLink}`
);
});
- it("should URI encode branch", () => {
- expect(
- createLocalLink(
- "/repo/namespace/name/code/sources/feature/awesome/",
- contentLink,
- "feature/awesome",
- currentPath,
- link
- )
- ).toContain("feature%2Fawesome");
+ it("should URI encode revision", () => {
+ expect(createLocalLink(contentLink, "feature/awesome", currentPath, "image.png")).toContain("feature%2Fawesome");
+ });
+
+ it("should inject the resolved path into {path} placeholder", () => {
+ const link = "image.png";
+ const result = createLocalLink(contentLink, revision, currentPath, link);
+ expect(result).toBe(`${contentLinkBase}${revision}/folder/image.png`);
});
});
diff --git a/scm-ui/ui-components/src/markdown/MarkdownImageRenderer.tsx b/scm-ui/ui-components/src/markdown/MarkdownImageRenderer.tsx
index f4c44eaab0..f8f83148e3 100644
--- a/scm-ui/ui-components/src/markdown/MarkdownImageRenderer.tsx
+++ b/scm-ui/ui-components/src/markdown/MarkdownImageRenderer.tsx
@@ -17,47 +17,16 @@
import React, { FC } from "react";
import { useLocation } from "react-router-dom";
import { Link } from "@scm-manager/ui-types";
-import {
- isAbsolute,
- isExternalLink,
- isInternalScmRepoLink,
- isLinkWithProtocol,
- isSubDirectoryOf,
- join,
- normalizePath,
-} from "./paths";
+import { isExternalLink, isInternalScmRepoLink, isLinkWithProtocol, resolveInternalPath } from "./paths";
import { useRepositoryContext, useRepositoryRevisionContext } from "@scm-manager/ui-api";
-export const createLocalLink = (
- basePath: string,
- contentLink: string,
- revision: string,
- currentPath: string,
- link: string
-) => {
- const apiBasePath = contentLink.replace("{revision}", encodeURIComponent(revision));
+export const createLocalLink = (contentLink: string, revision: string, currentPath: string, link: string) => {
if (isInternalScmRepoLink(link)) {
return link;
}
- if (isAbsolute(link)) {
- return apiBasePath.replace("{path}", link.substring(1));
- }
- const decodedCurrentPath = currentPath.replace(encodeURIComponent(revision), revision);
- if (!isSubDirectoryOf(basePath, decodedCurrentPath)) {
- return apiBasePath.replace("{path}", link);
- }
- const relativePath = decodedCurrentPath.substring(basePath.length);
- let path = relativePath;
- if (decodedCurrentPath.endsWith("/")) {
- path = relativePath.substring(0, relativePath.length - 1);
- }
- const lastSlash = path.lastIndexOf("/");
- if (lastSlash < 0) {
- path = "";
- } else {
- path = path.substring(0, lastSlash);
- }
- return apiBasePath.replace("{path}", normalizePath(join(path, link)));
+ const apiBasePath = contentLink.replace("{revision}", encodeURIComponent(revision));
+ const path = resolveInternalPath(currentPath, revision, link);
+ return apiBasePath.replace("{path}", path);
};
type LinkProps = {
@@ -67,13 +36,15 @@ type LinkProps = {
type Props = LinkProps & {
base?: string;
+ permalink?: string;
contentLink?: string;
};
-const MarkdownImageRenderer: FC = ({ src = "", alt = "", base, contentLink, children, ...props }) => {
+const MarkdownImageRenderer: FC = ({ src = "", alt = "", base, contentLink, children, permalink, ...props }) => {
const location = useLocation();
const repository = useRepositoryContext();
const revision = useRepositoryRevisionContext();
+ const pathname = permalink || location.pathname;
if (isExternalLink(src) || isLinkWithProtocol(src)) {
return (
@@ -82,7 +53,7 @@ const MarkdownImageRenderer: FC = ({ src = "", alt = "", base, contentLin
);
} else if (base && repository && revision) {
- const localLink = createLocalLink(base, (repository._links.content as Link).href, revision, location.pathname, src);
+ const localLink = createLocalLink((repository._links.content as Link).href, revision, pathname, src);
return (
{children}
@@ -101,9 +72,9 @@ const MarkdownImageRenderer: FC = ({ src = "", alt = "", base, contentLin
// we use a factory method, because react-markdown does not pass
// base as prop down to our link component.
-export const create = (base: string | undefined): FC => {
+export const create = (base: string | undefined, permalink?: string): FC => {
return (props) => {
- return ;
+ return ;
};
};
diff --git a/scm-ui/ui-components/src/markdown/MarkdownLinkRenderer.test.tsx b/scm-ui/ui-components/src/markdown/MarkdownLinkRenderer.test.tsx
index 4f78babe5e..a642dd4f43 100644
--- a/scm-ui/ui-components/src/markdown/MarkdownLinkRenderer.test.tsx
+++ b/scm-ui/ui-components/src/markdown/MarkdownLinkRenderer.test.tsx
@@ -14,116 +14,43 @@
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
-import { isAnchorLink, isExternalLink, isLinkWithProtocol, isInternalScmRepoLink } from "./paths";
+import { Repository } from "@scm-manager/ui-types";
import { createLocalLink } from "./MarkdownLinkRenderer";
-describe("test isAnchorLink", () => {
- it("should return true", () => {
- expect(isAnchorLink("#some-thing")).toBe(true);
- expect(isAnchorLink("#/some/more/complicated-link")).toBe(true);
+describe("MarkdownLinkRenderer createLocalLink tests", () => {
+ const basePath = "/repo/ns/name/code/sources/";
+ const repository: Repository = { _links: {}, name: "name", namespace: "ns", type: "" };
+ const revision = "main";
+ const currentPath = "/repo/ns/name/code/sources/main/folder/README.md/";
+
+ it("should return link unchanged for internal scm repo links", () => {
+ const internalScmLink = "/repo/ns/name/code/changeset/12345";
+ expect(createLocalLink(repository, revision, currentPath, internalScmLink)).toBe(internalScmLink);
});
- it("should return false", () => {
- expect(isAnchorLink("https://cloudogu.com")).toBe(false);
- expect(isAnchorLink("/some/path/link")).toBe(false);
+ it("should return normalized absolute path starting with slash", () => {
+ const absoluteLink = "/docs/CONTRIBUTE.md";
+ expect(createLocalLink(repository, revision, currentPath, absoluteLink)).toBe(
+ basePath + revision + "/docs/CONTRIBUTE.md"
+ );
+ });
+
+ it("should handle absolute links with redundant segments", () => {
+ const messyAbsoluteLink = "//docs/./CONTRIBUTE.md";
+ expect(createLocalLink(repository, revision, currentPath, messyAbsoluteLink)).toBe(
+ basePath + revision + "/docs/CONTRIBUTE.md"
+ );
+ });
+
+ it("should return internal link starting with slash for relative paths", () => {
+ const relativeLink = "img/image.png";
+ const result = createLocalLink(repository, revision, currentPath, relativeLink);
+ expect(result).toBe(basePath + revision + "/folder/img/image.png");
+ });
+
+ it("should handle relative links navigating up with ..", () => {
+ const parentLink = "../docs/CHANGELOG.md";
+ const result = createLocalLink(repository, revision, currentPath, parentLink);
+ expect(result).toBe(basePath + revision + "/docs/CHANGELOG.md");
});
});
-
-describe("test isExternalLink", () => {
- it("should return true", () => {
- expect(isExternalLink("https://cloudogu.com")).toBe(true);
- expect(isExternalLink("http://cloudogu.com")).toBe(true);
- });
-
- it("should return false", () => {
- expect(isExternalLink("some/path/link")).toBe(false);
- expect(isExternalLink("/some/path/link")).toBe(false);
- expect(isExternalLink("#some-anchor")).toBe(false);
- expect(isExternalLink("mailto:trillian@hitchhiker.com")).toBe(false);
- });
-});
-
-describe("test isLinkWithProtocol", () => {
- it("should return true", () => {
- expect(isLinkWithProtocol("ldap://[2001:db8::7]/c=GB?objectClass?one")).toBeTruthy();
- expect(isLinkWithProtocol("mailto:trillian@hitchhiker.com")).toBeTruthy();
- expect(isLinkWithProtocol("tel:+1-816-555-1212")).toBeTruthy();
- expect(isLinkWithProtocol("urn:oasis:names:specification:docbook:dtd:xml:4.1.2")).toBeTruthy();
- expect(isLinkWithProtocol("about:config")).toBeTruthy();
- expect(isLinkWithProtocol("http://cloudogu.com")).toBeTruthy();
- expect(isLinkWithProtocol("file:///srv/git/project.git")).toBeTruthy();
- expect(isLinkWithProtocol("ssh://trillian@server/project.git")).toBeTruthy();
- });
- it("should return false", () => {
- expect(isLinkWithProtocol("some/path/link")).toBeFalsy();
- expect(isLinkWithProtocol("/some/path/link")).toBeFalsy();
- expect(isLinkWithProtocol("#some-anchor")).toBeFalsy();
- });
-});
-
-describe("test isInternalScmRepoLink", () => {
- it("should return true", () => {
- expect(isInternalScmRepoLink("/repo/scmadmin/git/code/changeset/1234567")).toBe(true);
- expect(isInternalScmRepoLink("/repo/scmadmin/git")).toBe(true);
- });
- it("should return false", () => {
- expect(isInternalScmRepoLink("repo/path/link")).toBe(false);
- expect(isInternalScmRepoLink("/some/path/link")).toBe(false);
- expect(isInternalScmRepoLink("#some-anchor")).toBe(false);
- });
-});
-
-describe("test createLocalLink", () => {
- it("should handle relative links", () => {
- expectLocalLink("/src", "/src/README.md", "docs/Home.md", "/src/docs/Home.md");
- });
-
- it("should handle absolute links", () => {
- expectLocalLink("/src", "/src/README.md", "/docs/CHANGELOG.md", "/src/docs/CHANGELOG.md");
- });
-
- it("should handle relative links from locations with trailing slash", () => {
- expectLocalLink("/src", "/src/README.md/", "/docs/LICENSE.md", "/src/docs/LICENSE.md");
- });
-
- it("should handle relative links from location outside of base", () => {
- expectLocalLink("/src", "/info/readme", "docs/index.md", "/src/docs/index.md");
- });
-
- it("should handle absolute links from location outside of base", () => {
- expectLocalLink("/src", "/info/readme", "/info/index.md", "/src/info/index.md");
- });
-
- it("should handle relative links from sub directories", () => {
- expectLocalLink("/src", "/src/docs/index.md", "installation/linux.md", "/src/docs/installation/linux.md");
- });
-
- it("should handle absolute links from sub directories", () => {
- expectLocalLink("/src", "/src/docs/index.md", "/docs/CONTRIBUTIONS.md", "/src/docs/CONTRIBUTIONS.md");
- });
-
- it("should resolve .. with in path", () => {
- expectLocalLink("/src", "/src/docs/installation/index.md", "../../README.md", "/src/README.md");
- });
-
- it("should resolve .. to / if we reached the end", () => {
- expectLocalLink("/", "/index.md", "../../README.md", "/README.md");
- });
-
- it("should resolve . with in path", () => {
- expectLocalLink("/src", "/src/README.md", "./SHAPESHIPS.md", "/src/SHAPESHIPS.md");
- });
-
- it("should resolve . with the current directory", () => {
- expectLocalLink("/", "/README.md", "././HITCHHIKER.md", "/HITCHHIKER.md");
- });
-
- it("should handle complex path", () => {
- expectLocalLink("/src", "/src/docs/installation/index.md", "./.././../docs/index.md", "/src/docs/index.md");
- });
-
- const expectLocalLink = (basePath: string, currentPath: string, link: string, expected: string) => {
- const localLink = createLocalLink(basePath, currentPath, link);
- expect(localLink).toBe(expected);
- };
-});
diff --git a/scm-ui/ui-components/src/markdown/MarkdownLinkRenderer.tsx b/scm-ui/ui-components/src/markdown/MarkdownLinkRenderer.tsx
index c2795fa150..7acd93ebe3 100644
--- a/scm-ui/ui-components/src/markdown/MarkdownLinkRenderer.tsx
+++ b/scm-ui/ui-components/src/markdown/MarkdownLinkRenderer.tsx
@@ -17,39 +17,25 @@
import React, { FC } from "react";
import { Link, useLocation } from "react-router-dom";
import ExternalLink from "../navigation/ExternalLink";
-import { urls } from "@scm-manager/ui-api";
+import { urls, useRepositoryContext, useRepositoryRevisionContext } from "@scm-manager/ui-api";
+import { Repository } from "@scm-manager/ui-types";
import { ProtocolLinkRendererExtensionMap } from "./markdownExtensions";
import {
- isAbsolute, isAnchorLink,
+ isAnchorLink,
isExternalLink,
isInternalScmRepoLink,
isLinkWithProtocol,
- isSubDirectoryOf,
join,
- normalizePath
+ resolveInternalPath,
} from "./paths";
-export const createLocalLink = (basePath: string, currentPath: string, link: string) => {
+export const createLocalLink = (repository: Repository, revision: string, currentPath: string, link: string) => {
if (isInternalScmRepoLink(link)) {
return link;
}
- if (isAbsolute(link)) {
- return join(basePath, link);
- }
- if (!isSubDirectoryOf(basePath, currentPath)) {
- return join(basePath, link);
- }
- let path = currentPath;
- if (currentPath.endsWith("/")) {
- path = currentPath.substring(0, currentPath.length - 2);
- }
- const lastSlash = path.lastIndexOf("/");
- if (lastSlash < 0) {
- path = "";
- } else {
- path = path.substring(0, lastSlash);
- }
- return "/" + normalizePath(join(path, link));
+ const basePath = `/repo/${repository.namespace}/${repository.name}/code/sources/${revision}/`;
+ const internalPath = resolveInternalPath(currentPath, revision, link);
+ return join(basePath, internalPath);
};
type LinkProps = {
@@ -58,18 +44,23 @@ type LinkProps = {
type Props = LinkProps & {
base?: string;
+ permalink?: string;
};
-const MarkdownLinkRenderer: FC = ({ href = "", base, children, ...props }) => {
+const MarkdownLinkRenderer: FC = ({ href = "", base, children, permalink, ...props }) => {
const location = useLocation();
+ const repository = useRepositoryContext();
+ const revision = useRepositoryRevisionContext();
+ const pathname = permalink || location.pathname;
+
if (isExternalLink(href)) {
return {children};
} else if (isLinkWithProtocol(href)) {
return {children};
} else if (isAnchorLink(href)) {
return {children};
- } else if (base) {
- const localLink = createLocalLink(base, location.pathname, href);
+ } else if (base && repository && revision) {
+ const localLink = createLocalLink(repository, revision, pathname, href);
return {children};
} else if (href) {
return (
diff --git a/scm-ui/ui-components/src/markdown/paths.test.ts b/scm-ui/ui-components/src/markdown/paths.test.ts
new file mode 100644
index 0000000000..30e1ab0d73
--- /dev/null
+++ b/scm-ui/ui-components/src/markdown/paths.test.ts
@@ -0,0 +1,143 @@
+/*
+ * Copyright (c) 2020 - present Cloudogu GmbH
+ *
+ * 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, version 3.
+ *
+ * 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/.
+ */
+
+import { isAnchorLink, isExternalLink, isLinkWithProtocol, isInternalScmRepoLink, resolveInternalPath } from "./paths";
+
+describe("isExternalLink tests", () => {
+ it("should return true", () => {
+ expect(isExternalLink("https://cloudogu.com")).toBe(true);
+ expect(isExternalLink("http://cloudogu.com")).toBe(true);
+ });
+
+ it("should return false", () => {
+ expect(isExternalLink("some/path/link")).toBe(false);
+ expect(isExternalLink("/some/path/link")).toBe(false);
+ expect(isExternalLink("#some-anchor")).toBe(false);
+ expect(isExternalLink("mailto:trillian@hitchhiker.com")).toBe(false);
+ });
+});
+
+describe("isAnchorLink tests", () => {
+ it("should return true", () => {
+ expect(isAnchorLink("#some-thing")).toBe(true);
+ expect(isAnchorLink("#/some/more/complicated-link")).toBe(true);
+ });
+
+ it("should return false", () => {
+ expect(isAnchorLink("https://cloudogu.com")).toBe(false);
+ expect(isAnchorLink("/some/path/link")).toBe(false);
+ });
+});
+
+describe("isInternalScmRepoLink tests", () => {
+ it("should return true", () => {
+ expect(isInternalScmRepoLink("/repo/scmadmin/git/code/changeset/1234567")).toBe(true);
+ expect(isInternalScmRepoLink("/repo/scmadmin/git")).toBe(true);
+ });
+
+ it("should return false", () => {
+ expect(isInternalScmRepoLink("repo/path/link")).toBe(false);
+ expect(isInternalScmRepoLink("/some/path/link")).toBe(false);
+ expect(isInternalScmRepoLink("#some-anchor")).toBe(false);
+ });
+});
+
+describe("isLinkWithProtocol tests", () => {
+ it("should return true", () => {
+ expect(isLinkWithProtocol("ldap://[2001:db8::7]/c=GB?objectClass?one")).toBeTruthy();
+ expect(isLinkWithProtocol("mailto:trillian@hitchhiker.com")).toBeTruthy();
+ expect(isLinkWithProtocol("tel:+1-816-555-1212")).toBeTruthy();
+ expect(isLinkWithProtocol("urn:oasis:names:specification:docbook:dtd:xml:4.1.2")).toBeTruthy();
+ expect(isLinkWithProtocol("about:config")).toBeTruthy();
+ expect(isLinkWithProtocol("http://cloudogu.com")).toBeTruthy();
+ expect(isLinkWithProtocol("file:///srv/git/project.git")).toBeTruthy();
+ expect(isLinkWithProtocol("ssh://trillian@server/project.git")).toBeTruthy();
+ });
+
+ it("should return false", () => {
+ expect(isLinkWithProtocol("some/path/link")).toBeFalsy();
+ expect(isLinkWithProtocol("/some/path/link")).toBeFalsy();
+ expect(isLinkWithProtocol("#some-anchor")).toBeFalsy();
+ });
+});
+
+describe("resolveInternalPath tests", () => {
+ const revision = "main";
+ const repoPath = "/repo/ns/name/code/sources/main/";
+
+ it("should resolve . within the path", () => {
+ const currentPath = repoPath + "README.md";
+ const link = "./SHAPESHIPS.md";
+ const result = resolveInternalPath(currentPath, revision, link);
+ expect(result).toBe("SHAPESHIPS.md");
+ });
+
+ it("should resolve . with the current directory", () => {
+ const currentPath = repoPath + "README.md";
+ const link = "././HITCHHIKER.md";
+ const result = resolveInternalPath(currentPath, revision, link);
+ expect(result).toBe("HITCHHIKER.md");
+ });
+
+ it("should resolve .. within the path", () => {
+ const currentPath = repoPath + "docs/gui/index.md";
+ const link = "../../img/image.png";
+ const result = resolveInternalPath(currentPath, revision, link);
+ expect(result).toBe("img/image.png");
+ });
+
+ it("should handle complex redundant segments (./.././..)", () => {
+ const currentPath = repoPath + "docs/installation/index.md";
+ const link = "./.././../docs/index.md";
+ const result = resolveInternalPath(currentPath, revision, link);
+ expect(result).toBe("docs/index.md");
+ });
+
+ it("should resolve .. to root if we reach the end", () => {
+ const currentPath = repoPath + "index.md";
+ const link = "../../README.md";
+ const result = resolveInternalPath(currentPath, revision, link);
+ expect(result).toBe("README.md");
+ });
+
+ it("should resolve root link within root path", () => {
+ const currentPath = repoPath + "README.md";
+ const link = "/SHAPESHIPS.md";
+ const result = resolveInternalPath(currentPath, revision, link);
+ expect(result).toBe("SHAPESHIPS.md");
+ });
+
+ it("should resolve root link within folder path", () => {
+ const currentPath = repoPath + "dir/README.md";
+ const link = "/SHAPESHIPS.md";
+ const result = resolveInternalPath(currentPath, revision, link);
+ expect(result).toBe("SHAPESHIPS.md");
+ });
+
+ it("should resolve relative link within root path", () => {
+ const currentPath = repoPath + "README.md";
+ const link = "SHAPESHIPS.md";
+ const result = resolveInternalPath(currentPath, revision, link);
+ expect(result).toBe("SHAPESHIPS.md");
+ });
+
+ it("should resolve relative link within folder path", () => {
+ const currentPath = repoPath + "dir/README.md";
+ const link = "SHAPESHIPS.md";
+ const result = resolveInternalPath(currentPath, revision, link);
+ expect(result).toBe("dir/SHAPESHIPS.md");
+ });
+});
diff --git a/scm-ui/ui-components/src/markdown/paths.ts b/scm-ui/ui-components/src/markdown/paths.ts
index 93deafd5d2..c0ce428192 100644
--- a/scm-ui/ui-components/src/markdown/paths.ts
+++ b/scm-ui/ui-components/src/markdown/paths.ts
@@ -27,8 +27,8 @@ export const isInternalScmRepoLink = (link: string) => {
return link.startsWith("/repo/");
};
-const linkWithProtocolRegex = new RegExp("^([a-z]+):(.+)");
export const isLinkWithProtocol = (link: string) => {
+ const linkWithProtocolRegex = new RegExp("^([a-z]+):(.+)");
const match = link.match(linkWithProtocolRegex);
return match && { protocol: match[1], link: match[2] };
};
@@ -47,8 +47,10 @@ export const normalizePath = (path: string) => {
const parts = path.split("/");
for (const part of parts) {
if (part === "..") {
+ // Go up
stack.pop();
- } else if (part !== ".") {
+ } else if (part !== "." && part !== "") {
+ // Skip current dir and empty parts
stack.push(part);
}
}
@@ -66,3 +68,28 @@ export const isAbsolute = (link: string) => {
export const isSubDirectoryOf = (basePath: string, currentPath: string) => {
return currentPath.startsWith(basePath);
};
+
+export const resolveInternalPath = (currentPath: string, revision: string, link: string) => {
+ // Extract path relative to revision
+ const pathForMatching = currentPath.replace(encodeURIComponent(revision), revision);
+ const revisionWithSlashes = `/${revision}/`;
+ const revIndex = pathForMatching.indexOf(revisionWithSlashes);
+ let internalPath = "";
+ if (revIndex !== -1) {
+ internalPath = pathForMatching.substring(revIndex + revisionWithSlashes.length);
+ }
+
+ // Determine if path is file or directory
+ let directoryPath = internalPath.endsWith("/") ? internalPath.slice(0, -1) : internalPath;
+ if (directoryPath.toLowerCase().endsWith(".md")) {
+ const parts = directoryPath.split("/");
+ parts.pop(); // Removes filename
+ directoryPath = parts.join("/");
+ }
+
+ // Normalize path
+ if (isAbsolute(link)) {
+ return normalizePath(link);
+ }
+ return normalizePath(join(directoryPath, link));
+};