mirror of
				https://github.com/scm-manager/scm-manager.git
				synced 2025-10-31 18:46:07 +01:00 
			
		
		
		
	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:
		
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			
						parent
						
							8f91c217fc
						
					
				
				
					commit
					32b268e6f5
				
			| @@ -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 | ||||
|  | ||||
|   | ||||
							
								
								
									
										4
									
								
								gradle/changelog/link-renderer.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								gradle/changelog/link-renderer.yaml
									
									
									
									
									
										Normal 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)) | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -95,6 +95,7 @@ export * from "./repos"; | ||||
| export * from "./table"; | ||||
| export * from "./toast"; | ||||
| export * from "./popover"; | ||||
| export * from "./markdown/markdownExtensions"; | ||||
|  | ||||
| export { | ||||
|   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(); | ||||
|   }); | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -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, { | ||||
|   | ||||
							
								
								
									
										15
									
								
								scm-ui/ui-components/src/markdown/markdownExtensions.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								scm-ui/ui-components/src/markdown/markdownExtensions.ts
									
									
									
									
									
										Normal 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>; | ||||
| } | ||||
| @@ -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); | ||||
|   }; | ||||
| }; | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user