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

@@ -62,6 +62,17 @@ The following extension points are provided for the frontend:
- Dynamic extension point for custom language-specific renderers
- Overrides the default Syntax Highlighter
- Used by the Markdown Plantuml Plugin
### markdown-renderer.link.protocol
- Define custom protocols and their renderers for links in markdown
Example:
```markdown
[description](myprotocol:somelink)
```
```typescript
binder.bind("markdown-renderer.link.protocol", { protocol: "myprotocol", renderer: MyProtocolRenderer })
```
# Deprecated

View File

@@ -0,0 +1,4 @@
- type: added
description: Add extension point for custom link protocol renderers in markdown ([#1639](https://github.com/scm-manager/scm-manager/pull/1639))
- type: fixed
description: External links and anchor links are now correctly rendered in markdown even if no base path is present ([#1639](https://github.com/scm-manager/scm-manager/pull/1639))

View File

@@ -40,6 +40,10 @@ Anchor Links should be rendered a simple a tag with an href: [anchor link](#samp
Links with a protocol other than http should be rendered a simple a tag with an href e.g.: [mail link](mailto:marvin@hitchhiker.com)
## Custom Protocol
Renderers for custom protocols can be added via the "markdown-renderer.link.protocol" extension point: [description of scw link](scw:marvin@hitchhiker.com)
## Internal
Internal links should be rendered by react-router: [internal link](/buttons)

View File

@@ -47993,6 +47993,8 @@ exports[`Storyshots MarkdownView Commit Links 1`] = `
<p>
<a
href="https://scm-manager.org/"
rel="noopener noreferrer"
target="_blank"
>
hitchhiker/heart-of-gold@42
</a>
@@ -48093,7 +48095,7 @@ exports[`Storyshots MarkdownView Default 1`] = `
<li>
<a
href="#Headings"
href="/#Headings"
>
Headings
</a>
@@ -48102,7 +48104,7 @@ exports[`Storyshots MarkdownView Default 1`] = `
<li>
<a
href="#Paragraphs"
href="/#Paragraphs"
>
Paragraphs
</a>
@@ -48111,7 +48113,7 @@ exports[`Storyshots MarkdownView Default 1`] = `
<li>
<a
href="#Blockquotes"
href="/#Blockquotes"
>
Blockquotes
</a>
@@ -48120,7 +48122,7 @@ exports[`Storyshots MarkdownView Default 1`] = `
<li>
<a
href="#Lists"
href="/#Lists"
>
Lists
</a>
@@ -48129,7 +48131,7 @@ exports[`Storyshots MarkdownView Default 1`] = `
<li>
<a
href="#Horizontal"
href="/#Horizontal"
>
Horizontal rule
</a>
@@ -48138,7 +48140,7 @@ exports[`Storyshots MarkdownView Default 1`] = `
<li>
<a
href="#Table"
href="/#Table"
>
Table
</a>
@@ -48147,7 +48149,7 @@ exports[`Storyshots MarkdownView Default 1`] = `
<li>
<a
href="#Code"
href="/#Code"
>
Code
</a>
@@ -48156,7 +48158,7 @@ exports[`Storyshots MarkdownView Default 1`] = `
<li>
<a
href="#Inline"
href="/#Inline"
>
Inline elements
</a>
@@ -48254,7 +48256,7 @@ exports[`Storyshots MarkdownView Default 1`] = `
<p>
<a
href="#top"
href="/#top"
>
[Top]
</a>
@@ -48289,7 +48291,7 @@ exports[`Storyshots MarkdownView Default 1`] = `
<p>
<a
href="#top"
href="/#top"
>
[Top]
</a>
@@ -48362,7 +48364,7 @@ exports[`Storyshots MarkdownView Default 1`] = `
<p>
<a
href="#top"
href="/#top"
>
[Top]
</a>
@@ -48508,7 +48510,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip
<p>
<a
href="#top"
href="/#top"
>
[Top]
</a>
@@ -48541,7 +48543,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip
<p>
<a
href="#top"
href="/#top"
>
[Top]
</a>
@@ -48908,7 +48910,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip
<p>
<a
href="#top"
href="/#top"
>
[Top]
</a>
@@ -50106,7 +50108,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip
<p>
<a
href="#top"
href="/#top"
>
[Top]
</a>
@@ -50135,7 +50137,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip
</strong>
reprehenderit duis
<a
href="#!"
href="/#!"
>
irure
</a>
@@ -50165,7 +50167,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip
</code>
anim aute reprehenderit id eu ea. Aute
<a
href="#!"
href="/#!"
>
excepteur proident
</a>
@@ -50204,6 +50206,8 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip
<p>
<a
href="https://youtu.be/s6bCmZmy9aQ"
rel="noopener noreferrer"
target="_blank"
>
<img
alt="Manny Pacquiao"
@@ -50216,7 +50220,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip
<p>
Reprehenderit non eu quis in ad elit esse qui aute id
<a
href="#!"
href="/#!"
>
incididunt
</a>
@@ -58105,6 +58109,166 @@ the story is mostly for checking if the links are rendered correct.
</p>
<h2
id="custom-protocol"
>
Custom Protocol
</h2>
<p>
Renderers for custom protocols can be added via the "markdown-renderer.link.protocol" extension point:
<div
style={
Object {
"border": "1px dashed lightgray",
"padding": "2px",
}
}
>
<h4>
Link:
marvin@hitchhiker.com
[Protocol:
scw
]
</h4>
<div>
children:
description of scw link
</div>
</div>
</p>
<h2
id="internal"
>
Internal
</h2>
<p>
Internal links should be rendered by react-router:
<a
href="/scm/buttons"
onClick={[Function]}
>
internal link
</a>
</p>
</div>
</div>
</div>
`;
exports[`Storyshots MarkdownView Links without Base Path 1`] = `
<div
className="MarkdownViewstories__Spacing-sc-1lofakk-0 jNfUaE"
>
<div
className="content is-word-break"
>
<div>
<h1
id="links"
>
Links
</h1>
<p>
Show case for different style of markdown links.
Please note that some of the links may not work in storybook,
the story is mostly for checking if the links are rendered correct.
</p>
<h2
id="external"
>
External
</h2>
<p>
External Links should be opened in a new tab:
<a
href="https://scm-manager.org"
rel="noopener noreferrer"
target="_blank"
>
external link
</a>
</p>
<h2
id="anchor"
>
Anchor
</h2>
<p>
Anchor Links should be rendered a simple a tag with an href:
<a
href="/#sample"
>
anchor link
</a>
</p>
<h2
id="protocol"
>
Protocol
</h2>
<p>
Links with a protocol other than http should be rendered a simple a tag with an href e.g.:
<a
href="mailto:marvin@hitchhiker.com"
>
mail link
</a>
</p>
<h2
id="custom-protocol"
>
Custom Protocol
</h2>
<p>
Renderers for custom protocols can be added via the "markdown-renderer.link.protocol" extension point:
<div
style={
Object {
"border": "1px dashed lightgray",
"padding": "2px",
}
}
>
<h4>
Link:
marvin@hitchhiker.com
[Protocol:
scw
]
</h4>
<div>
children:
description of scw link
</div>
</div>
</p>
<h2
id="internal"
>
@@ -58116,7 +58280,6 @@ the story is mostly for checking if the links are rendered correct.
Internal links should be rendered by react-router:
<a
href="/buttons"
onClick={[Function]}
>
internal link
</a>
@@ -58146,7 +58309,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
<li>
<a
href="#Headings"
href="/#Headings"
>
Headings
</a>
@@ -58155,7 +58318,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
<li>
<a
href="#Paragraphs"
href="/#Paragraphs"
>
Paragraphs
</a>
@@ -58164,7 +58327,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
<li>
<a
href="#Blockquotes"
href="/#Blockquotes"
>
Blockquotes
</a>
@@ -58173,7 +58336,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
<li>
<a
href="#Lists"
href="/#Lists"
>
Lists
</a>
@@ -58182,7 +58345,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
<li>
<a
href="#Horizontal"
href="/#Horizontal"
>
Horizontal rule
</a>
@@ -58191,7 +58354,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
<li>
<a
href="#Table"
href="/#Table"
>
Table
</a>
@@ -58200,7 +58363,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
<li>
<a
href="#Code"
href="/#Code"
>
Code
</a>
@@ -58209,7 +58372,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
<li>
<a
href="#Inline"
href="/#Inline"
>
Inline elements
</a>
@@ -58303,7 +58466,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
<p>
<a
href="#top"
href="/#top"
>
[Top]
</a>
@@ -58334,7 +58497,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
<p>
<a
href="#top"
href="/#top"
>
[Top]
</a>
@@ -58403,7 +58566,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
<p>
<a
href="#top"
href="/#top"
>
[Top]
</a>
@@ -58547,7 +58710,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip
<p>
<a
href="#top"
href="/#top"
>
[Top]
</a>
@@ -58576,7 +58739,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip
<p>
<a
href="#top"
href="/#top"
>
[Top]
</a>
@@ -58986,7 +59149,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip
<p>
<a
href="#top"
href="/#top"
>
[Top]
</a>
@@ -60180,7 +60343,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip
<p>
<a
href="#top"
href="/#top"
>
[Top]
</a>
@@ -60205,7 +60368,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip
</strong>
reprehenderit duis
<a
href="#!"
href="/#!"
>
irure
</a>
@@ -60235,7 +60398,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip
</code>
anim aute reprehenderit id eu ea. Aute
<a
href="#!"
href="/#!"
>
excepteur proident
</a>
@@ -60274,6 +60437,8 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip
<p>
<a
href="https://youtu.be/s6bCmZmy9aQ"
rel="noopener noreferrer"
target="_blank"
>
<img
alt="Manny Pacquiao"
@@ -60286,7 +60451,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip
<p>
Reprehenderit non eu quis in ad elit esse qui aute id
<a
href="#!"
href="/#!"
>
incididunt
</a>

View File

@@ -95,6 +95,7 @@ export * from "./repos";
export * from "./table";
export * from "./toast";
export * from "./popover";
export * from "./markdown/markdownExtensions";
export {
File,

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