mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-03-22 03:51:36 +01:00
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.
This commit is contained in:
committed by
Thomas Zerr
parent
a72d965b56
commit
beb29dbc05
2
gradle/changelog/markdown-links.yaml
Normal file
2
gradle/changelog/markdown-links.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
- type: fixed
|
||||
description: Broken relative paths for images or links within Markdown files
|
||||
@@ -14392,7 +14392,7 @@ the story is mostly for checking if the src links are rendered correct.
|
||||
<p>
|
||||
<img
|
||||
alt="path starting with a '.'"
|
||||
src="https://my.scm/scm/api/v2/some/repository/content/42/./some_image.jpg"
|
||||
src="https://my.scm/scm/api/v2/some/repository/content/42/some_image.jpg"
|
||||
/>
|
||||
</p>
|
||||
|
||||
@@ -14868,8 +14868,7 @@ the story is mostly for checking if the links are rendered correct.
|
||||
<p>
|
||||
Internal links should be rendered by react-router:
|
||||
<a
|
||||
href="/scm/buttons"
|
||||
onClick={[Function]}
|
||||
href="/buttons"
|
||||
>
|
||||
internal link
|
||||
</a>
|
||||
|
||||
@@ -171,7 +171,7 @@ class LazyMarkdownView extends React.Component<Props, State> {
|
||||
remarkRendererList.heading = createMarkdownHeadingRenderer(permalink);
|
||||
}
|
||||
|
||||
remarkRendererList.image = createMarkdownImageRenderer(basePath);
|
||||
remarkRendererList.image = createMarkdownImageRenderer(basePath, permalink);
|
||||
|
||||
let protocolLinkRendererExtensions: ProtocolLinkRendererExtensionMap = {};
|
||||
if (!remarkRendererList.link) {
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Props> = ({ src = "", alt = "", base, contentLink, children, ...props }) => {
|
||||
const MarkdownImageRenderer: FC<Props> = ({ 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<Props> = ({ src = "", alt = "", base, contentLin
|
||||
</img>
|
||||
);
|
||||
} 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 (
|
||||
<img src={localLink} alt={alt}>
|
||||
{children}
|
||||
@@ -101,9 +72,9 @@ const MarkdownImageRenderer: FC<Props> = ({ 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<LinkProps> => {
|
||||
export const create = (base: string | undefined, permalink?: string): FC<LinkProps> => {
|
||||
return (props) => {
|
||||
return <MarkdownImageRenderer base={base} {...props} />;
|
||||
return <MarkdownImageRenderer base={base} permalink={permalink} {...props} />;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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<Props> = ({ href = "", base, children, ...props }) => {
|
||||
const MarkdownLinkRenderer: FC<Props> = ({ href = "", base, children, permalink, ...props }) => {
|
||||
const location = useLocation();
|
||||
const repository = useRepositoryContext();
|
||||
const revision = useRepositoryRevisionContext();
|
||||
const pathname = permalink || location.pathname;
|
||||
|
||||
if (isExternalLink(href)) {
|
||||
return <ExternalLink to={href}>{children}</ExternalLink>;
|
||||
} else if (isLinkWithProtocol(href)) {
|
||||
return <a href={href}>{children}</a>;
|
||||
} else if (isAnchorLink(href)) {
|
||||
return <a href={urls.withContextPath(location.pathname) + href}>{children}</a>;
|
||||
} else if (base) {
|
||||
const localLink = createLocalLink(base, location.pathname, href);
|
||||
} else if (base && repository && revision) {
|
||||
const localLink = createLocalLink(repository, revision, pathname, href);
|
||||
return <Link to={localLink}>{children}</Link>;
|
||||
} else if (href) {
|
||||
return (
|
||||
|
||||
143
scm-ui/ui-components/src/markdown/paths.test.ts
Normal file
143
scm-ui/ui-components/src/markdown/paths.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user