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)); +};