Create api for markdown ast plugins (#1578)

Currently we only have the option to adjust rendering of the markdown output, but no option to change the generated ast tree before rendering takes place (i.e. to adjust the structure like when replacing text with links). This PR adds a new api to create ast plugins that can be integrated with the markdown view component. This is intended to be backwards-compatible and work independently from the underlying implementation.

Co-authored-by: Sebastian Sdorra <sebastian.sdorra@cloudogu.com>
This commit is contained in:
Konstantin Schaper
2021-03-11 10:28:18 +01:00
committed by GitHub
parent 0d3339b0cb
commit e66553705f
17 changed files with 69503 additions and 147320 deletions

View File

@@ -0,0 +1,46 @@
/*
* 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 React, { FC } from "react";
import SyntaxHighlighter from "../SyntaxHighlighter";
import { ExtensionPoint, useBinder } from "@scm-manager/ui-extensions";
import { useIndexLinks } from "@scm-manager/ui-api";
type Props = {
language?: string;
value: string;
};
const MarkdownCodeRenderer: FC<Props> = props => {
const binder = useBinder();
const indexLinks = useIndexLinks();
const { language } = props;
const extensionKey = `markdown-renderer.code.${language}`;
if (binder.hasExtension(extensionKey, props)) {
return <ExtensionPoint name={extensionKey} props={{ ...props, indexLinks }} />;
}
return <SyntaxHighlighter {...props} />;
};
export default MarkdownCodeRenderer;

View File

@@ -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.
*/
import React from "react";
import { headingToAnchorId } from "./MarkdownHeadingRenderer";
describe("headingToAnchorId tests", () => {
it("should lower case the text", () => {
expect(headingToAnchorId("Hello")).toBe("hello");
expect(headingToAnchorId("HeLlO")).toBe("hello");
expect(headingToAnchorId("HELLO")).toBe("hello");
});
it("should replace spaces with hyphen", () => {
expect(headingToAnchorId("awesome stuff")).toBe("awesome-stuff");
expect(headingToAnchorId("a b c d e f")).toBe("a-b-c-d-e-f");
});
});

View File

@@ -0,0 +1,115 @@
/*
* 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 React, { FC, ReactNode, useState } from "react";
import { useHistory, useLocation } from "react-router-dom";
import { urls } from "@scm-manager/ui-api";
import styled from "styled-components";
import Icon from "../Icon";
import Tooltip from "../Tooltip";
import { useTranslation } from "react-i18next";
import copyToClipboard from "../CopyToClipboard";
/**
* Adds anchor links to markdown headings.
*
* @see <a href="https://github.com/rexxars/react-markdown/issues/69">Headings are missing anchors / ids</a>
*/
const Link = styled.a`
i {
font-size: 1rem;
visibility: hidden;
margin-left: 10px;
}
i:hover {
cursor: pointer;
}
&:hover i {
visibility: visible;
}
`;
type Props = {
children: ReactNode;
level: number;
permalink: string;
};
function flatten(text: string, child: any): any {
return typeof child === "string" ? text + child : React.Children.toArray(child.props.children).reduce(flatten, text);
}
/**
* Turns heading text into a anchor id
*
* @VisibleForTesting
*/
export function headingToAnchorId(heading: string) {
return heading.toLowerCase().replace(/\W/g, "-");
}
const MarkdownHeadingRenderer: FC<Props> = ({ children, level, permalink }) => {
const [copying, setCopying] = useState(false);
const [t] = useTranslation("repos");
const location = useLocation();
const history = useHistory();
const reactChildren = React.Children.toArray(children);
const heading = reactChildren.reduce(flatten, "");
const anchorId = headingToAnchorId(heading);
const copyPermalink = (event: React.MouseEvent) => {
event.preventDefault();
setCopying(true);
copyToClipboard(permalinkHref)
.then(() => history.replace("#" + anchorId))
.finally(() => setCopying(false));
};
const CopyButton = copying ? (
<Icon name="spinner fa-spin" />
) : (
<Tooltip message={t("sources.content.copyPermalink")}>
<Icon name="link" onClick={copyPermalink} />
</Tooltip>
);
const headingElement = React.createElement("h" + level, {}, [...reactChildren, CopyButton]);
const href = urls.withContextPath(location.pathname + "#" + anchorId);
const permalinkHref =
window.location.protocol +
"//" +
window.location.host +
urls.withContextPath((permalink || location.pathname) + "#" + anchorId);
return (
<Link id={`${anchorId}`} className="anchor" href={href}>
{headingElement}
</Link>
);
};
export const create = (permalink: string): FC<Props> => {
return props => <MarkdownHeadingRenderer {...props} permalink={permalink} />;
};
export default MarkdownHeadingRenderer;

View File

@@ -0,0 +1,141 @@
/*
* 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 {
isAnchorLink,
isExternalLink,
isLinkWithProtocol,
createLocalLink,
isInternalScmRepoLink
} from "./MarkdownLinkRenderer";
describe("test isAnchorLink", () => {
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("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")).toBe(true);
expect(isLinkWithProtocol("mailto:trillian@hitchhiker.com")).toBe(true);
expect(isLinkWithProtocol("tel:+1-816-555-1212")).toBe(true);
expect(isLinkWithProtocol("urn:oasis:names:specification:docbook:dtd:xml:4.1.2")).toBe(true);
expect(isLinkWithProtocol("about:config")).toBe(true);
expect(isLinkWithProtocol("http://cloudogu.com")).toBe(true);
expect(isLinkWithProtocol("file:///srv/git/project.git")).toBe(true);
expect(isLinkWithProtocol("ssh://trillian@server/project.git")).toBe(true);
});
it("should return false", () => {
expect(isLinkWithProtocol("some/path/link")).toBe(false);
expect(isLinkWithProtocol("/some/path/link")).toBe(false);
expect(isLinkWithProtocol("#some-anchor")).toBe(false);
});
});
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);
};
});

View File

@@ -0,0 +1,132 @@
/*
* 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 React, { FC } from "react";
import { Link, useLocation } from "react-router-dom";
import ExternalLink from "../navigation/ExternalLink";
import { urls } from "@scm-manager/ui-api";
const externalLinkRegex = new RegExp("^http(s)?://");
export const isExternalLink = (link: string) => {
return externalLinkRegex.test(link);
};
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);
};
const join = (left: string, right: string) => {
if (left.endsWith("/") && right.startsWith("/")) {
return left + right.substring(1);
} else if (!left.endsWith("/") && !right.startsWith("/")) {
return left + "/" + right;
}
return left + right;
};
const normalizePath = (path: string) => {
const stack = [];
const parts = path.split("/");
for (const part of parts) {
if (part === "..") {
stack.pop();
} else if (part !== ".") {
stack.push(part);
}
}
const normalizedPath = stack.join("/");
if (normalizedPath.startsWith("/")) {
return normalizedPath;
}
return "/" + normalizedPath;
};
const isAbsolute = (link: string) => {
return link.startsWith("/");
};
const isSubDirectoryOf = (basePath: string, currentPath: string) => {
return currentPath.startsWith(basePath);
};
export const createLocalLink = (basePath: 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));
};
type LinkProps = {
href: string;
};
type Props = LinkProps & {
base: string;
};
const MarkdownLinkRenderer: FC<Props> = ({ href, base, children }) => {
const location = useLocation();
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 {
const localLink = createLocalLink(base, location.pathname, href);
return <Link to={localLink}>{children}</Link>;
}
};
// we use a factory method, because react-markdown does not pass
// base as prop down to our link component.
export const create = (base: string): FC<LinkProps> => {
return props => <MarkdownLinkRenderer base={base} {...props} />;
};
export default MarkdownLinkRenderer;

View File

@@ -0,0 +1,89 @@
/*
* 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 React, { FC } from "react";
import { storiesOf } from "@storybook/react";
import MarkdownView from "./MarkdownView";
import styled from "styled-components";
import TestPage from "../__resources__/test-page.md";
import MarkdownWithoutLang from "../__resources__/markdown-without-lang.md";
import MarkdownXmlCodeBlock from "../__resources__/markdown-xml-codeblock.md";
import MarkdownUmlCodeBlock from "../__resources__/markdown-uml-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 MarkdownXss from "../__resources__/markdown-xss.md";
import Title from "../layout/Title";
import { Subtitle } from "../layout";
import { MemoryRouter } from "react-router-dom";
import { Binder, BinderContext } from "@scm-manager/ui-extensions";
const Spacing = styled.div`
padding: 2em;
`;
storiesOf("MarkdownView", module)
.addDecorator(story => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
.addDecorator(story => <Spacing>{story()}</Spacing>)
.add("Default", () => <MarkdownView content={TestPage} skipHtml={false} />)
.add("Skip Html", () => <MarkdownView content={TestPage} skipHtml={true} />)
.add("Code without Lang", () => <MarkdownView content={MarkdownWithoutLang} skipHtml={false} />)
.add("Xml Code Block", () => <MarkdownView content={MarkdownXmlCodeBlock} />)
.add("Inline Xml", () => (
<>
<Title title="Inline Xml" />
<Subtitle subtitle="Inline xml outside of a code block is not supported" />
<MarkdownView content={MarkdownInlineXml} />
</>
))
.add("Links", () => <MarkdownView content={MarkdownLinks} basePath="/" />)
.add("Header Anchor Links", () => (
<MarkdownView
content={TestPage}
basePath={"/"}
permalink={"/?path=/story/markdownview--header-anchor-links"}
enableAnchorHeadings={true}
/>
))
.add("Commit Links", () => <MarkdownView content={MarkdownCommitLinks} />)
.add("Custom code renderer", () => {
const binder = new Binder("custom code renderer");
const Container: FC<{ value: string }> = ({ value }) => {
return (
<div>
<h4 style={{ border: "1px dashed lightgray", padding: "2px" }}>
To render plantuml as images within markdown, please install the scm-markdown-plantuml-plguin
</h4>
<pre>{value}</pre>
</div>
);
};
binder.bind("markdown-renderer.code.uml", Container);
return (
<BinderContext.Provider value={binder}>
<MarkdownView content={MarkdownUmlCodeBlock} />
</BinderContext.Provider>
);
})
.add("XSS Prevention", () => <MarkdownView content={MarkdownXss} skipHtml={false} />);

View File

@@ -0,0 +1,178 @@
/*
* 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 React, { FC } from "react";
import { RouteComponentProps, withRouter } from "react-router-dom";
import Markdown from "react-markdown";
import MarkdownWithHtml from "react-markdown/with-html";
import gfm from "remark-gfm";
import { binder } from "@scm-manager/ui-extensions";
import ErrorBoundary from "../ErrorBoundary";
import { create as createMarkdownHeadingRenderer } from "./MarkdownHeadingRenderer";
import { create as createMarkdownLinkRenderer } from "./MarkdownLinkRenderer";
import { useTranslation, WithTranslation, withTranslation } from "react-i18next";
import Notification from "../Notification";
import { createTransformer } from "./remarkChangesetShortLinkParser";
import MarkdownCodeRenderer from "./MarkdownCodeRenderer";
import { AstPlugin } from "./PluginApi";
import createMdastPlugin from "./createMdastPlugin";
type Props = RouteComponentProps &
WithTranslation & {
content: string;
renderContext?: object;
renderers?: any;
skipHtml?: boolean;
enableAnchorHeadings?: boolean;
// basePath for markdown links
basePath?: string;
permalink?: string;
mdastPlugins?: AstPlugin[];
};
type State = {
contentRef: HTMLDivElement | null | undefined;
};
const xmlMarkupSample = `\`\`\`xml
<your>
<xml>
<content/>
</xml>
</your>
\`\`\``;
const MarkdownErrorNotification: FC = () => {
const [t] = useTranslation("commons");
return (
<Notification type="danger">
<div className="content">
<p className="subtitle">{t("markdownErrorNotification.title")}</p>
<p>{t("markdownErrorNotification.description")}</p>
<pre>
<code>{xmlMarkupSample}</code>
</pre>
<p>
{t("markdownErrorNotification.spec")}:{" "}
<a href="https://github.github.com/gfm/" target="_blank">
GitHub Flavored Markdown Spec
</a>
</p>
</div>
</Notification>
);
};
class MarkdownView extends React.Component<Props, State> {
static defaultProps: Partial<Props> = {
enableAnchorHeadings: false,
skipHtml: false
};
constructor(props: Props) {
super(props);
this.state = {
contentRef: null
};
}
shouldComponentUpdate(nextProps: Readonly<Props>, nextState: Readonly<State>): boolean {
// We have check if the contentRef changed and update afterwards so the page can scroll to the anchor links.
// Otherwise it can happen that componentDidUpdate is never executed depending on how fast the markdown got rendered
// We also have to check if props have changed, because we also want to rerender if one of our props has changed
return this.state.contentRef !== nextState.contentRef || this.props !== nextProps;
}
componentDidUpdate() {
const { contentRef } = this.state;
// we have to use componentDidUpdate, because we have to wait until all
// children are rendered and componentDidMount is called before the
// markdown content was rendered.
const hash = this.props.location.hash;
if (contentRef && hash) {
// we query only child elements, to avoid strange scrolling with multiple
// markdown elements on one page.
const element = contentRef.querySelector(hash);
if (element && element.scrollIntoView) {
element.scrollIntoView();
}
}
}
render() {
const {
content,
renderers,
renderContext,
enableAnchorHeadings,
skipHtml,
basePath,
permalink,
t,
mdastPlugins = []
} = this.props;
const rendererFactory = binder.getExtension("markdown-renderer-factory");
let rendererList = renderers;
if (rendererFactory) {
rendererList = rendererFactory(renderContext);
}
if (!rendererList) {
rendererList = {};
}
if (enableAnchorHeadings && permalink && !rendererList.heading) {
rendererList.heading = createMarkdownHeadingRenderer(permalink);
}
if (basePath && !rendererList.link) {
rendererList.link = createMarkdownLinkRenderer(basePath);
}
if (!rendererList.code) {
rendererList.code = MarkdownCodeRenderer;
}
const plugins = [...mdastPlugins, createTransformer(t)].map(createMdastPlugin);
const baseProps = {
className: "content is-word-break",
renderers: rendererList,
plugins: [gfm],
astPlugins: plugins,
children: content
};
return (
<ErrorBoundary fallback={MarkdownErrorNotification}>
<div ref={el => this.setState({ contentRef: el })}>
{skipHtml ? <Markdown {...baseProps} /> : <MarkdownWithHtml {...baseProps} allowDangerousHtml={true} />}
</div>
</ErrorBoundary>
);
}
}
export default withRouter(withTranslation("repos")(MarkdownView));

View File

@@ -0,0 +1,9 @@
import { Node, Parent } from "unist";
export type Visitor = (node: Node, index: number, parent?: Parent) => void;
export type AstPluginContext = {
visit: (type: string, visitor: Visitor) => void;
};
export type AstPlugin = (context: AstPluginContext) => void;

View File

@@ -0,0 +1,12 @@
import { AstPlugin } from "./PluginApi";
// @ts-ignore No types available
import visit from "unist-util-visit";
export default function createMdastPlugin(plugin: AstPlugin): any {
return (tree: any) => {
plugin({
visit: (type, visitor) => visit(tree, type, visitor)
});
return tree;
};
}

View File

@@ -0,0 +1,64 @@
/*
* 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 "./remarkChangesetShortLinkParser";
describe("Remark Commit Links RegEx Tests", () => {
it("should match simple names", () => {
const regExp = new RegExp(regExpPattern, "g");
expect("namespace/name@1a5s4w8a".match(regExp)).toBeTruthy();
});
it("should match complex names", () => {
const regExp = new RegExp(regExpPattern, "g");
expect("hitchhiker/heart-of-gold@c7237cb60689046990dc9dc2a388a517adb3e2b2".match(regExp)).toBeTruthy();
});
it("should replace match", () => {
const regExp = new RegExp(regExpPattern, "g");
expect("Prefix namespace/name@42 suffix".replace(regExp, "replaced")).toBe("Prefix replaced suffix");
});
it("should match groups", () => {
const regExp = new RegExp(regExpPattern, "g");
const match = regExp.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 regExp = new RegExp(regExpPattern, "g");
const text = "Prefix hitchhiker/heart-of-gold@42 some text hitchhiker/heart-of-gold@21 suffix";
const matches = [];
let match = regExp.exec(text);
while (match !== null) {
matches.push(match[0]);
match = regExp.exec(text);
}
expect(matches[0]).toBe("hitchhiker/heart-of-gold@42");
expect(matches[1]).toBe("hitchhiker/heart-of-gold@21");
});
});

View File

@@ -0,0 +1,99 @@
/*
* 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 { nameRegex } from "../validation";
import { TFunction } from "i18next";
import { AstPlugin } from "./PluginApi";
import { Node, Parent } from "unist";
const namePartRegex = nameRegex.source.substring(1, nameRegex.source.length - 1);
export const regExpPattern = `(${namePartRegex})\\/(${namePartRegex})@([\\w\\d]+)`;
function match(value: string): RegExpMatchArray[] {
const regExp = new RegExp(regExpPattern, "g");
const matches = [];
let m = regExp.exec(value);
while (m) {
matches.push(m);
m = regExp.exec(value);
}
return matches;
}
export const createTransformer = (t: TFunction): AstPlugin => {
return ({ visit }) => {
visit("text", (node: Node, index: number, parent?: Parent) => {
if (!parent || parent.type === "link" || !node.value) {
return;
}
let nodeText = node.value as string;
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
};
}
});
};
};