From 0eeddd5103a52ec714a263ab6341165ad1aa2343 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Fri, 19 Jun 2020 11:50:58 +0200 Subject: [PATCH] enrich commit mentions by internal links e. g. "repoNamespace/repoName@commitId" --- CHANGELOG.md | 4 + .../src/MarkdownLinkRenderer.test.tsx | 20 +++- .../src/MarkdownLinkRenderer.tsx | 19 ++-- .../src/MarkdownView.stories.tsx | 4 +- scm-ui/ui-components/src/MarkdownView.tsx | 12 ++- .../__resources__/markdown-commit-link.md.ts | 39 +++++++ .../src/remarkCommitLinksParser.test.ts | 59 ++++++++++ .../src/remarkCommitLinksParser.ts | 101 ++++++++++++++++++ scm-ui/ui-components/src/validation.ts | 2 +- scm-ui/ui-webapp/public/locales/de/repos.json | 3 + scm-ui/ui-webapp/public/locales/en/repos.json | 3 + 11 files changed, 252 insertions(+), 14 deletions(-) create mode 100644 scm-ui/ui-components/src/__resources__/markdown-commit-link.md.ts create mode 100644 scm-ui/ui-components/src/remarkCommitLinksParser.test.ts create mode 100644 scm-ui/ui-components/src/remarkCommitLinksParser.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 82abb84b2e..52ea215aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased +### Added +- enrich commit mentions in markdown viewer by internal links ([#1210](https://github.com/scm-manager/scm-manager/pull/1210)) + ## [2.1.0] - 2020-06-18 ### Added - Option to configure jvm parameter of docker container with env JAVA_OPTS or with arguments ([#1175](https://github.com/scm-manager/scm-manager/pull/1175)) diff --git a/scm-ui/ui-components/src/MarkdownLinkRenderer.test.tsx b/scm-ui/ui-components/src/MarkdownLinkRenderer.test.tsx index 7a5b8e68dc..037bf0f73f 100644 --- a/scm-ui/ui-components/src/MarkdownLinkRenderer.test.tsx +++ b/scm-ui/ui-components/src/MarkdownLinkRenderer.test.tsx @@ -21,7 +21,13 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import { isAnchorLink, isExternalLink, isLinkWithProtocol, createLocalLink } from "./MarkdownLinkRenderer"; +import { + isAnchorLink, + isExternalLink, + isLinkWithProtocol, + createLocalLink, + isInternalScmRepoLink +} from "./MarkdownLinkRenderer"; describe("test isAnchorLink", () => { it("should return true", () => { @@ -67,6 +73,18 @@ describe("test isLinkWithProtocol", () => { }); }); +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"); diff --git a/scm-ui/ui-components/src/MarkdownLinkRenderer.tsx b/scm-ui/ui-components/src/MarkdownLinkRenderer.tsx index 8ae1bc6135..fc38bd388b 100644 --- a/scm-ui/ui-components/src/MarkdownLinkRenderer.tsx +++ b/scm-ui/ui-components/src/MarkdownLinkRenderer.tsx @@ -21,10 +21,10 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, {FC} from "react"; -import {Link, useLocation} from "react-router-dom"; +import React, { FC } from "react"; +import { Link, useLocation } from "react-router-dom"; import ExternalLink from "./navigation/ExternalLink"; -import {withContextPath} from "./urls"; +import { withContextPath } from "./urls"; const externalLinkRegex = new RegExp("^http(s)?://"); export const isExternalLink = (link: string) => { @@ -35,6 +35,10 @@ export const isAnchorLink = (link: string) => { return link.startsWith("#"); }; +export const isInternalScmRepoLink = (link: string) => { + return link.startsWith("/repo/"); +}; + const linkWithProtcolRegex = new RegExp("^[a-z]+:"); export const isLinkWithProtocol = (link: string) => { return linkWithProtcolRegex.test(link); @@ -56,10 +60,10 @@ const normalizePath = (path: string) => { if (part === "..") { stack.pop(); } else if (part !== ".") { - stack.push(part) + stack.push(part); } } - const normalizedPath = stack.join("/") + const normalizedPath = stack.join("/"); if (normalizedPath.startsWith("/")) { return normalizedPath; } @@ -75,6 +79,9 @@ const isSubDirectoryOf = (basePath: string, currentPath: string) => { }; export const createLocalLink = (basePath: string, currentPath: string, link: string) => { + if (isInternalScmRepoLink(link)) { + return link; + } if (isAbsolute(link)) { return join(basePath, link); } @@ -102,7 +109,7 @@ type Props = LinkProps & { base: string; }; -const MarkdownLinkRenderer: FC = ({href, base, children}) => { +const MarkdownLinkRenderer: FC = ({ href, base, children }) => { const location = useLocation(); if (isExternalLink(href)) { return {children}; diff --git a/scm-ui/ui-components/src/MarkdownView.stories.tsx b/scm-ui/ui-components/src/MarkdownView.stories.tsx index 2c2e1cc9f4..54c87cc6f2 100644 --- a/scm-ui/ui-components/src/MarkdownView.stories.tsx +++ b/scm-ui/ui-components/src/MarkdownView.stories.tsx @@ -31,6 +31,7 @@ import MarkdownWithoutLang from "./__resources__/markdown-without-lang.md"; import MarkdownXmlCodeBlock from "./__resources__/markdown-xml-codeblock.md"; import MarkdownInlineXml from "./__resources__/markdown-inline-xml.md"; import MarkdownLinks from "./__resources__/markdown-links.md"; +import MarkdownCommitLinks from "./__resources__/markdown-commit-link.md"; import Title from "./layout/Title"; import { Subtitle } from "./layout"; import { MemoryRouter } from "react-router-dom"; @@ -52,4 +53,5 @@ storiesOf("MarkdownView", module) )) - .add("Links", () => ); + .add("Links", () => ) + .add("Commit Links", () => ); diff --git a/scm-ui/ui-components/src/MarkdownView.tsx b/scm-ui/ui-components/src/MarkdownView.tsx index 546d1610a6..e46c95370a 100644 --- a/scm-ui/ui-components/src/MarkdownView.tsx +++ b/scm-ui/ui-components/src/MarkdownView.tsx @@ -22,7 +22,7 @@ * SOFTWARE. */ import React, { FC } from "react"; -import { withRouter, RouteComponentProps } from "react-router-dom"; +import { RouteComponentProps, withRouter } from "react-router-dom"; // @ts-ignore import Markdown from "react-markdown/with-html"; import { binder } from "@scm-manager/ui-extensions"; @@ -30,10 +30,11 @@ import ErrorBoundary from "./ErrorBoundary"; import SyntaxHighlighter from "./SyntaxHighlighter"; import MarkdownHeadingRenderer from "./MarkdownHeadingRenderer"; import { create } from "./MarkdownLinkRenderer"; -import { useTranslation } from "react-i18next"; +import {useTranslation, WithTranslation, withTranslation} from "react-i18next"; import Notification from "./Notification"; +import { createTransformer } from "./remarkCommitLinksParser"; -type Props = RouteComponentProps & { +type Props = RouteComponentProps & WithTranslation & { content: string; renderContext?: object; renderers?: any; @@ -100,7 +101,7 @@ class MarkdownView extends React.Component { } render() { - const { content, renderers, renderContext, enableAnchorHeadings, skipHtml, basePath } = this.props; + const { content, renderers, renderContext, enableAnchorHeadings, skipHtml, basePath, t } = this.props; const rendererFactory = binder.getExtension("markdown-renderer-factory"); let rendererList = renderers; @@ -134,6 +135,7 @@ class MarkdownView extends React.Component { escapeHtml={skipHtml} source={content} renderers={rendererList} + astPlugins={[createTransformer(t)]} /> @@ -141,4 +143,4 @@ class MarkdownView extends React.Component { } } -export default withRouter(MarkdownView); +export default withRouter(withTranslation("repos")(MarkdownView)); diff --git a/scm-ui/ui-components/src/__resources__/markdown-commit-link.md.ts b/scm-ui/ui-components/src/__resources__/markdown-commit-link.md.ts new file mode 100644 index 0000000000..462179a73d --- /dev/null +++ b/scm-ui/ui-components/src/__resources__/markdown-commit-link.md.ts @@ -0,0 +1,39 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +export default `# Commit Links in Markdown +namespace/name@1a5s4w8a + +Please check for this commit: namespace/name@1a5s4w8a + +hitchhiker/heart-of-gold@c7237cb60689046990dc9dc2a388a517adb3e2b2 + +hitchhiker/heart-of-gold@c7237cb + +hitchhiker/heart-of-gold@42 + +[hitchhiker/heart-of-gold@42](https://scm-manager.org/) + +Prefix hitchhiker/heart-of-gold@42 some text hitchhiker/heart-of-gold@21 suffix + +`; diff --git a/scm-ui/ui-components/src/remarkCommitLinksParser.test.ts b/scm-ui/ui-components/src/remarkCommitLinksParser.test.ts new file mode 100644 index 0000000000..11ca3b7444 --- /dev/null +++ b/scm-ui/ui-components/src/remarkCommitLinksParser.test.ts @@ -0,0 +1,59 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { regExpPattern } from "./remarkCommitLinksParser"; + +describe("Remark Commit Links RegEx Tests", () => { + it("should match simple names", () => { + expect("namespace/name@1a5s4w8a".match(regExpPattern)).toBeTruthy(); + }); + it("should match complex names", () => { + expect("hitchhiker/heart-of-gold@c7237cb60689046990dc9dc2a388a517adb3e2b2".match(regExpPattern)).toBeTruthy(); + }); + it("should replace match", () => { + expect("Prefix namespace/name@42 suffix".replace(regExpPattern, "replaced")).toBe("Prefix replaced suffix"); + }); + it("should match groups", () => { + const match = regExpPattern.exec("namespace/name@42"); + expect(match).toBeTruthy(); + if (match) { + expect(match[1]).toBe("namespace"); + expect(match[2]).toBe("name"); + expect(match[3]).toBe("42"); + } + }); + it("should match multiple links in text", () => { + const text = "Prefix hitchhiker/heart-of-gold@42 some text hitchhiker/heart-of-gold@21 suffix"; + const matches = []; + + let match = regExpPattern.exec(text); + while (match !== null) { + matches.push(match[0]); + match = regExpPattern.exec(text); + } + + expect(matches[0]).toBe("hitchhiker/heart-of-gold@42"); + expect(matches[1]).toBe("hitchhiker/heart-of-gold@21"); + }); +}); diff --git a/scm-ui/ui-components/src/remarkCommitLinksParser.ts b/scm-ui/ui-components/src/remarkCommitLinksParser.ts new file mode 100644 index 0000000000..50b8cc8d3b --- /dev/null +++ b/scm-ui/ui-components/src/remarkCommitLinksParser.ts @@ -0,0 +1,101 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { MarkdownAbstractSyntaxTree, MdastPlugin } from "react-markdown"; +import { nameRegex } from "./validation"; +// @ts-ignore No types available +import visit from "unist-util-visit"; +import { TFunction } from "i18next"; + +const namePartRegex = nameRegex.source.substring(1, nameRegex.source.length - 1); + +// Visible for testing +export const regExpPattern = new RegExp(`(${namePartRegex})\\/(${namePartRegex})@([\\w\\d]+)`, "g"); + +function match(value: string): RegExpMatchArray[] { + const matches = []; + let m = regExpPattern.exec(value); + while (m) { + matches.push(m); + m = regExpPattern.exec(value); + } + return matches; +} + +export const createTransformer = (t: TFunction): MdastPlugin => { + return (tree: MarkdownAbstractSyntaxTree) => { + visit(tree, "text", (node: MarkdownAbstractSyntaxTree, index: number, parent: MarkdownAbstractSyntaxTree) => { + if (parent.type === "link" || !node.value) { + return; + } + + let nodeText = node.value; + const matches = match(nodeText); + + if (matches.length > 0) { + const children = []; + for (const m of matches) { + const i = nodeText.indexOf(m[0]); + if (i > 0) { + children.push({ + type: "text", + value: nodeText.substring(0, i) + }); + } + + children.push({ + type: "link", + url: `/repo/${m[1]}/${m[2]}/code/changeset/${m[3]}`, + title: t("changeset.shortlink.title", { + namespace: m[1], + name: m[2], + id: m[3] + }), + children: [ + { + type: "text", + value: m[0] + } + ] + }); + + nodeText = nodeText.substring(i + m[0].length); + } + + if (nodeText.length > 0) { + children.push({ + type: "text", + value: nodeText + }); + } + + parent.children![index] = { + type: "text", + children + }; + } + }); + return tree; + }; +}; diff --git a/scm-ui/ui-components/src/validation.ts b/scm-ui/ui-components/src/validation.ts index e6502a7ed9..fcdb1a6dfd 100644 --- a/scm-ui/ui-components/src/validation.ts +++ b/scm-ui/ui-components/src/validation.ts @@ -22,7 +22,7 @@ * SOFTWARE. */ -const nameRegex = /^[A-Za-z0-9\.\-_][A-Za-z0-9\.\-_@]*$/; +export const nameRegex = /^[A-Za-z0-9\.\-_][A-Za-z0-9\.\-_@]*$/; export const isNameValid = (name: string) => { return nameRegex.test(name); diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index 882f63a7e6..808bf431e9 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -88,6 +88,9 @@ "shortSummary": "Committet <0/> <1/>", "tags": "Tags", "diffNotSupported": "Diff des Changesets wird von diesem Repositorytyp nicht unterstützt", + "shortlink": { + "title": "Changeset {{id}} aus {{namespace}}/{{name}}" + }, "parents": { "label" : "Parent", "label_plural": "Parents" diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index 7102cff33a..e98052aba3 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -87,6 +87,9 @@ "summary": "Changeset <0/> was committed <1/>", "shortSummary": "Committed <0/> <1/>", "tags": "Tags", + "shortlink": { + "title": "Changeset {{id}} of {{namespace}}/{{name}}" + }, "diffNotSupported": "Diff of changesets is not supported by the type of repository", "buttons": { "details": "Details",