add extension point for custom link protocol renderers in markdown (#1639)

This PR allows for custom link protocols to be declared and rendered in markdown.
A new extension point markdown-renderer.link.protocol allows for renderers to hook into the api and implement any custom protocol.

Example:

[description](myprotocol:somelink)
binder.bind("markdown-renderer.link.protocol", { protocol: "myprotocol", renderer: MyProtocolRenderer })
This renderer functions similar to link renderers and receives the href and the description. The latter as the children property.

This PR also fixes two bugs where external- and anchor links were not correctly rendered in pull requests by the review-plugin.

Co-authored-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
This commit is contained in:
Konstantin Schaper
2021-04-29 13:15:22 +02:00
committed by GitHub
parent 8f91c217fc
commit 32b268e6f5
11 changed files with 339 additions and 65 deletions

View File

@@ -57,19 +57,19 @@ describe("test isExternalLink", () => {
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);
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")).toBe(false);
expect(isLinkWithProtocol("/some/path/link")).toBe(false);
expect(isLinkWithProtocol("#some-anchor")).toBe(false);
expect(isLinkWithProtocol("some/path/link")).toBeFalsy();
expect(isLinkWithProtocol("/some/path/link")).toBeFalsy();
expect(isLinkWithProtocol("#some-anchor")).toBeFalsy();
});
});

View File

@@ -25,6 +25,7 @@ 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 { ProtocolLinkRendererExtensionMap } from "./markdownExtensions";
const externalLinkRegex = new RegExp("^http(s)?://");
export const isExternalLink = (link: string) => {
@@ -39,9 +40,10 @@ export const isInternalScmRepoLink = (link: string) => {
return link.startsWith("/repo/");
};
const linkWithProtcolRegex = new RegExp("^[a-z]+:");
const linkWithProtocolRegex = new RegExp("^([a-z]+):(.+)");
export const isLinkWithProtocol = (link: string) => {
return linkWithProtcolRegex.test(link);
const match = link.match(linkWithProtocolRegex);
return match && { protocol: match[1], link: match[2] };
};
const join = (left: string, right: string) => {
@@ -106,10 +108,10 @@ type LinkProps = {
};
type Props = LinkProps & {
base: string;
base?: string;
};
const MarkdownLinkRenderer: FC<Props> = ({ href, base, children }) => {
const MarkdownLinkRenderer: FC<Props> = ({ href = "", base, children, ...props }) => {
const location = useLocation();
if (isExternalLink(href)) {
return <ExternalLink to={href}>{children}</ExternalLink>;
@@ -117,16 +119,39 @@ const MarkdownLinkRenderer: FC<Props> = ({ href, base, children }) => {
return <a href={href}>{children}</a>;
} else if (isAnchorLink(href)) {
return <a href={urls.withContextPath(location.pathname) + href}>{children}</a>;
} else {
} else if (base) {
const localLink = createLocalLink(base, location.pathname, href);
return <Link to={localLink}>{children}</Link>;
} else if (href) {
return (
<a href={href} {...props}>
{children}
</a>
);
} else {
return <a {...props}>{children}</a>;
}
};
// 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 const create = (base?: string, protocolExtensions: ProtocolLinkRendererExtensionMap = {}): FC<LinkProps> => {
return props => {
const protocolLinkContext = isLinkWithProtocol(props.href || "");
if (protocolLinkContext) {
const { link, protocol } = protocolLinkContext;
const ProtocolRenderer = protocolExtensions[protocol];
if (ProtocolRenderer) {
return (
<ProtocolRenderer protocol={protocol} href={link}>
{props.children}
</ProtocolRenderer>
);
}
}
return <MarkdownLinkRenderer base={base} {...props} />;
};
};
export default MarkdownLinkRenderer;

View File

@@ -39,6 +39,7 @@ import Title from "../layout/Title";
import { Subtitle } from "../layout";
import { MemoryRouter } from "react-router-dom";
import { Binder, BinderContext } from "@scm-manager/ui-extensions";
import { ProtocolLinkRendererExtension, ProtocolLinkRendererProps } from "./markdownExtensions";
const Spacing = styled.div`
padding: 2em;
@@ -58,7 +59,30 @@ storiesOf("MarkdownView", module)
<MarkdownView content={MarkdownInlineXml} />
</>
))
.add("Links", () => <MarkdownView content={MarkdownLinks} basePath="/" />)
.add("Links", () => {
const binder = new Binder("custom protocol link renderer");
binder.bind("markdown-renderer.link.protocol", {
protocol: "scw",
renderer: ProtocolLinkRenderer
} as ProtocolLinkRendererExtension);
return (
<BinderContext.Provider value={binder}>
<MarkdownView content={MarkdownLinks} basePath="/scm/" />
</BinderContext.Provider>
);
})
.add("Links without Base Path", () => {
const binder = new Binder("custom protocol link renderer");
binder.bind("markdown-renderer.link.protocol", {
protocol: "scw",
renderer: ProtocolLinkRenderer
} as ProtocolLinkRendererExtension);
return (
<BinderContext.Provider value={binder}>
<MarkdownView content={MarkdownLinks} />
</BinderContext.Provider>
);
})
.add("Header Anchor Links", () => (
<MarkdownView
content={MarkdownChangelog}
@@ -88,3 +112,14 @@ storiesOf("MarkdownView", module)
);
})
.add("XSS Prevention", () => <MarkdownView content={MarkdownXss} skipHtml={false} />);
export const ProtocolLinkRenderer: FC<ProtocolLinkRendererProps> = ({ protocol, href, children }) => {
return (
<div style={{ border: "1px dashed lightgray", padding: "2px" }}>
<h4>
Link: {href} [Protocol: {protocol}]
</h4>
<div>children: {children}</div>
</div>
);
};

View File

@@ -29,7 +29,7 @@ import sanitize from "rehype-sanitize";
import remark2rehype from "remark-rehype";
import rehype2react from "rehype-react";
import gfm from "remark-gfm";
import { binder } from "@scm-manager/ui-extensions";
import { BinderContext } from "@scm-manager/ui-extensions";
import ErrorBoundary from "../ErrorBoundary";
import { create as createMarkdownHeadingRenderer } from "./MarkdownHeadingRenderer";
import { create as createMarkdownLinkRenderer } from "./MarkdownLinkRenderer";
@@ -46,6 +46,7 @@ import raw from "rehype-raw";
import slug from "rehype-slug";
import merge from "deepmerge";
import { createComponentList } from "./createComponentList";
import { ProtocolLinkRendererExtension, ProtocolLinkRendererExtensionMap } from "./markdownExtensions";
type Props = RouteComponentProps &
WithTranslation & {
@@ -94,6 +95,8 @@ const MarkdownErrorNotification: FC = () => {
};
class MarkdownView extends React.Component<Props, State> {
static contextType = BinderContext;
static defaultProps: Partial<Props> = {
enableAnchorHeadings: false,
skipHtml: false
@@ -143,7 +146,7 @@ class MarkdownView extends React.Component<Props, State> {
mdastPlugins = []
} = this.props;
const rendererFactory = binder.getExtension("markdown-renderer-factory");
const rendererFactory = this.context.getExtension("markdown-renderer-factory");
let remarkRendererList = renderers;
if (rendererFactory) {
@@ -158,8 +161,19 @@ class MarkdownView extends React.Component<Props, State> {
remarkRendererList.heading = createMarkdownHeadingRenderer(permalink);
}
if (basePath && !remarkRendererList.link) {
remarkRendererList.link = createMarkdownLinkRenderer(basePath);
let protocolLinkRendererExtensions: ProtocolLinkRendererExtensionMap = {};
if (!remarkRendererList.link) {
const extensionPoints = this.context.getExtensions(
"markdown-renderer.link.protocol"
) as ProtocolLinkRendererExtension[];
protocolLinkRendererExtensions = extensionPoints.reduce<ProtocolLinkRendererExtensionMap>(
(prev, { protocol, renderer }) => {
prev[protocol] = renderer;
return prev;
},
{}
);
remarkRendererList.link = createMarkdownLinkRenderer(basePath, protocolLinkRendererExtensions);
}
if (!remarkRendererList.code) {
@@ -188,7 +202,10 @@ class MarkdownView extends React.Component<Props, State> {
attributes: {
code: ["className"] // Allow className for code elements, this is necessary to extract the code language
},
clobberPrefix: "" // Do not prefix user-provided ids and class names
clobberPrefix: "", // Do not prefix user-provided ids and class names,
protocols: {
href: Object.keys(protocolLinkRendererExtensions)
}
})
)
.use(rehype2react, {

View File

@@ -0,0 +1,15 @@
import { FC } from "react";
export type ProtocolLinkRendererProps = {
protocol: string;
href: string;
};
export type ProtocolLinkRendererExtension = {
protocol: string;
renderer: FC<ProtocolLinkRendererProps>;
};
export type ProtocolLinkRendererExtensionMap = {
[protocol: string]: FC<ProtocolLinkRendererProps>;
}

View File

@@ -48,11 +48,8 @@ export const createRemark2RehypeCodeRendererAdapter = (remarkRenderer: any) => {
export const createRemark2RehypeLinkRendererAdapter = (remarkRenderer: any) => {
return ({ node, children }: any) => {
const renderProps = {
href: node.properties.href || ""
};
children = children || [];
return React.createElement(remarkRenderer, renderProps, ...children);
return React.createElement(remarkRenderer, node.properties, ...children);
};
};