diff --git a/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.spec.tsx b/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.spec.tsx index f47188cf3..79f398813 100644 --- a/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.spec.tsx +++ b/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.spec.tsx @@ -1,5 +1,6 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ import '@testing-library/jest-dom' +import type { ElementType } from 'react' import { readFileSync } from 'fs' import { render, screen } from '@testing-library/react' import remarkBreaks from 'remark-breaks' @@ -9,6 +10,9 @@ import { MarkdownContent } from './MarkdownContent' interface MarkdownRendererProps { children: string + components: { + a: ElementType + } remarkPlugins: unknown[] skipHtml: boolean } @@ -65,6 +69,28 @@ describe('MarkdownContent', () => { })) }) + it('opens GFM links in a safe new tab', () => { + const markdown = 'https://www.topcoder-dev.com/challenges' + + render() + const markdownProps = mockReactMarkdown.mock.calls[0][0] as MarkdownRendererProps + const MarkdownLink = markdownProps.components.a + + render( + + {markdown} + , + ) + const link = screen.getByRole('link', { name: markdown }) + + expect(link) + .toHaveAttribute('href', markdown) + expect(link) + .toHaveAttribute('target', '_blank') + expect(link) + .toHaveAttribute('rel', 'noopener noreferrer') + }) + it('keeps Markdown formatting visible after the platform style reset', () => { expect(markdownStyles) .toMatch(/a,[\s\S]*a:hover \{[\s\S]*color: \$link-blue-dark;[\s\S]*text-decoration: underline;/) diff --git a/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.tsx b/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.tsx index 8a0d1fb31..ce0c367d7 100644 --- a/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.tsx +++ b/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.tsx @@ -1,6 +1,6 @@ /** Safe Markdown renderer for user-authored support content. */ -import { FC } from 'react' -import ReactMarkdown from 'react-markdown' +import type { FC } from 'react' +import ReactMarkdown, { type Components } from 'react-markdown' import remarkBreaks from 'remark-breaks' import remarkGfm from 'remark-gfm' @@ -10,8 +10,28 @@ export interface MarkdownContentProps { markdown: string } +const markdownComponents: Components = { + /** + * Renders a Markdown link in a new tab without exposing the opener page. + * + * @param props anchor attributes and AST metadata produced by ReactMarkdown. + * @returns a safely targeted anchor used for links in support conversations. + * @throws Does not throw. + */ + a: props => ( + + {props.children} + + ), +} + /** - * Renders GFM and line breaks while dropping raw HTML. + * Renders GFM and line breaks with links opening in new tabs while dropping raw HTML. * * @param props untrusted Markdown source. * @returns safely rendered Markdown. @@ -20,6 +40,7 @@ export interface MarkdownContentProps { export const MarkdownContent: FC = props => (