diff --git a/docs/en/development/plugins/extension-points.md b/docs/en/development/plugins/extension-points.md index 0359f5837e..2430327e9d 100644 --- a/docs/en/development/plugins/extension-points.md +++ b/docs/en/development/plugins/extension-points.md @@ -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 diff --git a/gradle/changelog/link-renderer.yaml b/gradle/changelog/link-renderer.yaml new file mode 100644 index 0000000000..1af474b840 --- /dev/null +++ b/gradle/changelog/link-renderer.yaml @@ -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)) diff --git a/scm-ui/ui-components/src/__resources__/markdown-links.md.ts b/scm-ui/ui-components/src/__resources__/markdown-links.md.ts index 46be993a3a..78ad883348 100644 --- a/scm-ui/ui-components/src/__resources__/markdown-links.md.ts +++ b/scm-ui/ui-components/src/__resources__/markdown-links.md.ts @@ -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) diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap index 801964a316..2f196db6b1 100644 --- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap +++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap @@ -47993,6 +47993,8 @@ exports[`Storyshots MarkdownView Commit Links 1`] = `

hitchhiker/heart-of-gold@42 @@ -48093,7 +48095,7 @@ exports[`Storyshots MarkdownView Default 1`] = `

  • Headings @@ -48102,7 +48104,7 @@ exports[`Storyshots MarkdownView Default 1`] = `
  • Paragraphs @@ -48111,7 +48113,7 @@ exports[`Storyshots MarkdownView Default 1`] = `
  • Blockquotes @@ -48120,7 +48122,7 @@ exports[`Storyshots MarkdownView Default 1`] = `
  • Lists @@ -48129,7 +48131,7 @@ exports[`Storyshots MarkdownView Default 1`] = `
  • Horizontal rule @@ -48138,7 +48140,7 @@ exports[`Storyshots MarkdownView Default 1`] = `
  • Table @@ -48147,7 +48149,7 @@ exports[`Storyshots MarkdownView Default 1`] = `
  • Code @@ -48156,7 +48158,7 @@ exports[`Storyshots MarkdownView Default 1`] = `
  • Inline elements @@ -48254,7 +48256,7 @@ exports[`Storyshots MarkdownView Default 1`] = `

    [Top] @@ -48289,7 +48291,7 @@ exports[`Storyshots MarkdownView Default 1`] = `

    [Top] @@ -48362,7 +48364,7 @@ exports[`Storyshots MarkdownView Default 1`] = `

    [Top] @@ -48508,7 +48510,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip

    [Top] @@ -48541,7 +48543,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip

    [Top] @@ -48908,7 +48910,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip

    [Top] @@ -50106,7 +50108,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip

    [Top] @@ -50135,7 +50137,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip reprehenderit duis irure @@ -50165,7 +50167,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip anim aute reprehenderit id eu ea. Aute excepteur proident @@ -50204,6 +50206,8 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip

    Manny Pacquiao Reprehenderit non eu quis in ad elit esse qui aute id incididunt @@ -58105,6 +58109,166 @@ the story is mostly for checking if the links are rendered correct.

    +

    + Custom Protocol +

    + + +

    + Renderers for custom protocols can be added via the "markdown-renderer.link.protocol" extension point: +

    +

    + Link: + marvin@hitchhiker.com + [Protocol: + scw + ] +

    +
    + children: + description of scw link +
    +
    +

    + + +

    + Internal +

    + + +

    + Internal links should be rendered by react-router: + + internal link + +

    + + + +`; + +exports[`Storyshots MarkdownView Links without Base Path 1`] = ` +
    +
    +
    +

    + Links +

    + + +

    + 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. +

    + + +

    + External +

    + + +

    + External Links should be opened in a new tab: + + external link + +

    + + +

    + Anchor +

    + + +

    + Anchor Links should be rendered a simple a tag with an href: + + anchor link + +

    + + +

    + Protocol +

    + + +

    + Links with a protocol other than http should be rendered a simple a tag with an href e.g.: + + mail link + +

    + + +

    + Custom Protocol +

    + + +

    + Renderers for custom protocols can be added via the "markdown-renderer.link.protocol" extension point: +

    +

    + Link: + marvin@hitchhiker.com + [Protocol: + scw + ] +

    +
    + children: + description of scw link +
    +
    +

    + +

    @@ -58116,7 +58280,6 @@ the story is mostly for checking if the links are rendered correct. Internal links should be rendered by react-router: internal link @@ -58146,7 +58309,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
  • Headings @@ -58155,7 +58318,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
  • Paragraphs @@ -58164,7 +58327,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
  • Blockquotes @@ -58173,7 +58336,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
  • Lists @@ -58182,7 +58345,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
  • Horizontal rule @@ -58191,7 +58354,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
  • Table @@ -58200,7 +58363,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
  • Code @@ -58209,7 +58372,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `
  • Inline elements @@ -58303,7 +58466,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `

    [Top] @@ -58334,7 +58497,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `

    [Top] @@ -58403,7 +58566,7 @@ exports[`Storyshots MarkdownView Skip Html 1`] = `

    [Top] @@ -58547,7 +58710,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip

    [Top] @@ -58576,7 +58739,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip

    [Top] @@ -58986,7 +59149,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip

    [Top] @@ -60180,7 +60343,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip

    [Top] @@ -60205,7 +60368,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip reprehenderit duis irure @@ -60235,7 +60398,7 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip anim aute reprehenderit id eu ea. Aute excepteur proident @@ -60274,6 +60437,8 @@ Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip

    Manny Pacquiao Reprehenderit non eu quis in ad elit esse qui aute id incididunt diff --git a/scm-ui/ui-components/src/index.ts b/scm-ui/ui-components/src/index.ts index 8878e85636..43df49cdeb 100644 --- a/scm-ui/ui-components/src/index.ts +++ b/scm-ui/ui-components/src/index.ts @@ -95,6 +95,7 @@ export * from "./repos"; export * from "./table"; export * from "./toast"; export * from "./popover"; +export * from "./markdown/markdownExtensions"; export { File, diff --git a/scm-ui/ui-components/src/markdown/MarkdownLinkRenderer.test.tsx b/scm-ui/ui-components/src/markdown/MarkdownLinkRenderer.test.tsx index 037bf0f73f..0789b05000 100644 --- a/scm-ui/ui-components/src/markdown/MarkdownLinkRenderer.test.tsx +++ b/scm-ui/ui-components/src/markdown/MarkdownLinkRenderer.test.tsx @@ -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(); }); }); diff --git a/scm-ui/ui-components/src/markdown/MarkdownLinkRenderer.tsx b/scm-ui/ui-components/src/markdown/MarkdownLinkRenderer.tsx index b285d91d8c..4144a41dfe 100644 --- a/scm-ui/ui-components/src/markdown/MarkdownLinkRenderer.tsx +++ b/scm-ui/ui-components/src/markdown/MarkdownLinkRenderer.tsx @@ -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 = ({ href, base, children }) => { +const MarkdownLinkRenderer: FC = ({ href = "", base, children, ...props }) => { const location = useLocation(); if (isExternalLink(href)) { return {children}; @@ -117,16 +119,39 @@ const MarkdownLinkRenderer: FC = ({ href, base, children }) => { return {children}; } else if (isAnchorLink(href)) { return {children}; - } else { + } else if (base) { const localLink = createLocalLink(base, location.pathname, href); return {children}; + } else if (href) { + return ( + + {children} + + ); + } else { + return {children}; } }; // 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 => { - return props => ; +export const create = (base?: string, protocolExtensions: ProtocolLinkRendererExtensionMap = {}): FC => { + return props => { + const protocolLinkContext = isLinkWithProtocol(props.href || ""); + if (protocolLinkContext) { + const { link, protocol } = protocolLinkContext; + const ProtocolRenderer = protocolExtensions[protocol]; + if (ProtocolRenderer) { + return ( + + {props.children} + + ); + } + } + + return ; + }; }; export default MarkdownLinkRenderer; diff --git a/scm-ui/ui-components/src/markdown/MarkdownView.stories.tsx b/scm-ui/ui-components/src/markdown/MarkdownView.stories.tsx index fbb39cf966..211dbac552 100644 --- a/scm-ui/ui-components/src/markdown/MarkdownView.stories.tsx +++ b/scm-ui/ui-components/src/markdown/MarkdownView.stories.tsx @@ -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) )) - .add("Links", () => ) + .add("Links", () => { + const binder = new Binder("custom protocol link renderer"); + binder.bind("markdown-renderer.link.protocol", { + protocol: "scw", + renderer: ProtocolLinkRenderer + } as ProtocolLinkRendererExtension); + return ( + + + + ); + }) + .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 ( + + + + ); + }) .add("Header Anchor Links", () => ( ); + +export const ProtocolLinkRenderer: FC = ({ protocol, href, children }) => { + return ( +

    +

    + Link: {href} [Protocol: {protocol}] +

    +
    children: {children}
    +
    + ); +}; diff --git a/scm-ui/ui-components/src/markdown/MarkdownView.tsx b/scm-ui/ui-components/src/markdown/MarkdownView.tsx index 10963db023..c057391380 100644 --- a/scm-ui/ui-components/src/markdown/MarkdownView.tsx +++ b/scm-ui/ui-components/src/markdown/MarkdownView.tsx @@ -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 { + static contextType = BinderContext; + static defaultProps: Partial = { enableAnchorHeadings: false, skipHtml: false @@ -143,7 +146,7 @@ class MarkdownView extends React.Component { 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 { 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( + (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 { 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, { diff --git a/scm-ui/ui-components/src/markdown/markdownExtensions.ts b/scm-ui/ui-components/src/markdown/markdownExtensions.ts new file mode 100644 index 0000000000..e7bf3fa218 --- /dev/null +++ b/scm-ui/ui-components/src/markdown/markdownExtensions.ts @@ -0,0 +1,15 @@ +import { FC } from "react"; + +export type ProtocolLinkRendererProps = { + protocol: string; + href: string; +}; + +export type ProtocolLinkRendererExtension = { + protocol: string; + renderer: FC; +}; + +export type ProtocolLinkRendererExtensionMap = { + [protocol: string]: FC; +} diff --git a/scm-ui/ui-components/src/markdown/remarkToRehypeRendererAdapters.ts b/scm-ui/ui-components/src/markdown/remarkToRehypeRendererAdapters.ts index 4f4c029873..be686987e1 100644 --- a/scm-ui/ui-components/src/markdown/remarkToRehypeRendererAdapters.ts +++ b/scm-ui/ui-components/src/markdown/remarkToRehypeRendererAdapters.ts @@ -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); }; };