From beb29dbc0539f944faa2f46b923041bfd01a9475 Mon Sep 17 00:00:00 2001 From: Florian Scholdei Date: Fri, 20 Mar 2026 15:06:28 +0000 Subject: [PATCH] Fix broken relative paths for images or links within Markdown files Paths and revisions containing slashes were not processed correctly. In addition, the readme section within the file tree caused problems due to the missing file extension. Parts of the path (in subfolders) were truncated because they were treated as filenames. --- gradle/changelog/markdown-links.yaml | 2 + .../src/__snapshots__/storyshots.test.ts.snap | 5 +- .../src/markdown/LazyMarkdownView.tsx | 2 +- .../markdown/MarkdownImageRenderer.test.ts | 43 +++--- .../src/markdown/MarkdownImageRenderer.tsx | 51 ++----- .../markdown/MarkdownLinkRenderer.test.tsx | 141 +++++------------ .../src/markdown/MarkdownLinkRenderer.tsx | 41 ++--- .../ui-components/src/markdown/paths.test.ts | 143 ++++++++++++++++++ scm-ui/ui-components/src/markdown/paths.ts | 31 +++- 9 files changed, 258 insertions(+), 201 deletions(-) create mode 100644 gradle/changelog/markdown-links.yaml create mode 100644 scm-ui/ui-components/src/markdown/paths.test.ts 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.

path starting with a '.'

@@ -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 ( {alt} {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)); +};