From 20bc8483abb15454d249aeb3797473a870ca2ca7 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Thu, 28 May 2026 10:05:32 -0700 Subject: [PATCH] fix(issues): Render ANSI colors in issue text ANSI escape codes were showing up literally in exception values, messages, and breadcrumb text. Add a shared renderer that parses ANSI at display time and keeps URLs going through Sentry's external-link modal. Also replace the old ansi-to-react attachment viewer path with the vendored renderer so we do not keep raw anchor/linkify behavior around. Refs #116110 Co-Authored-By: Codex --- package.json | 2 +- pnpm-lock.yaml | 30 +- .../app/components/ansiText/ansiToReact.tsx | 266 ++++++++++++++++++ static/app/components/ansiText/index.spec.tsx | 94 +++++++ static/app/components/ansiText/index.tsx | 22 ++ .../ansiText/normalizeTerminalText.ts | 41 +++ .../attachmentViewers/logFileViewer.tsx | 47 +--- .../breadcrumbs/breadcrumbItemContent.tsx | 19 +- static/app/components/events/eventMessage.tsx | 4 +- .../crashContent/exception/utils.tsx | 62 +--- 10 files changed, 460 insertions(+), 127 deletions(-) create mode 100644 static/app/components/ansiText/ansiToReact.tsx create mode 100644 static/app/components/ansiText/index.spec.tsx create mode 100644 static/app/components/ansiText/index.tsx create mode 100644 static/app/components/ansiText/normalizeTerminalText.ts diff --git a/package.json b/package.json index 06bc2427081e21..c7c88de5938971 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,7 @@ "@types/reflux": "0.4.1", "@types/scroll-to-element": "^2.0.2", "@types/webpack-env": "1.18.8", - "ansi-to-react": "^6.1.6", + "anser": "2.3.5", "base64-arraybuffer": "^1.0.1", "buffer": "^6.0.3", "cbor2": "^1.12.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d80432c215e0e5..07dea3b616a0c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -310,9 +310,9 @@ importers: '@types/webpack-env': specifier: 1.18.8 version: 1.18.8 - ansi-to-react: - specifier: ^6.1.6 - version: 6.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + anser: + specifier: 2.3.5 + version: 2.3.5 base64-arraybuffer: specifier: ^1.0.1 version: 1.0.2 @@ -4289,8 +4289,8 @@ packages: algoliasearch@4.13.1: resolution: {integrity: sha512-dtHUSE0caWTCE7liE1xaL+19AFf6kWEcyn76uhcitWpntqvicFHXKFoZe5JJcv9whQOTRM6+B8qJz6sFj+rDJA==} - anser@1.4.10: - resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} + anser@2.3.5: + resolution: {integrity: sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==} ansi-align@3.0.1: resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} @@ -4319,12 +4319,6 @@ packages: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} - ansi-to-react@6.1.6: - resolution: {integrity: sha512-+HWn72GKydtupxX9TORBedqOMsJRiKTqaLUKW8txSBZw9iBpzPKLI8KOu4WzwD4R7hSv1zEspobY6LwlWvwZ6Q==} - peerDependencies: - react: ^16.3.2 || ^17.0.0 - react-dom: ^16.3.2 || ^17.0.0 - anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -5253,9 +5247,6 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - escape-carriage@1.3.0: - resolution: {integrity: sha512-ATWi5MD8QlAGQOeMgI8zTp671BG8aKvAC0M7yenlxU4CRLGO/sKthxVUyjiOFKjHdIo+6dZZUNFgHFeVEaKfGQ==} - escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -13148,7 +13139,7 @@ snapshots: '@algolia/requester-node-http': 4.13.1 '@algolia/transporter': 4.13.1 - anser@1.4.10: {} + anser@2.3.5: {} ansi-align@3.0.1: dependencies: @@ -13170,13 +13161,6 @@ snapshots: ansi-styles@6.2.1: {} - ansi-to-react@6.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3): - dependencies: - anser: 1.4.10 - escape-carriage: 1.3.0 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -14258,8 +14242,6 @@ snapshots: escalade@3.2.0: {} - escape-carriage@1.3.0: {} - escape-string-regexp@1.0.5: {} escape-string-regexp@2.0.0: {} diff --git a/static/app/components/ansiText/ansiToReact.tsx b/static/app/components/ansiText/ansiToReact.tsx new file mode 100644 index 00000000000000..657b9f6c9859d6 --- /dev/null +++ b/static/app/components/ansiText/ansiToReact.tsx @@ -0,0 +1,266 @@ +/** + * This is vendored from ansi-to-react v6.2.6 + * + * The link rendering was changed to use Sentry's external-link modal. + * + * Copyright (c) 2016, nteract contributors + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of nteract nor the names of its contributors may be used + * to endorse or promote products derived from this software without specific + * prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +import type {CSSProperties, ReactElement, ReactNode} from 'react'; +import {Fragment, useMemo} from 'react'; +import {type Theme, useTheme} from '@emotion/react'; +import styled from '@emotion/styled'; +import Anser from 'anser'; +import type {AnserJsonEntry} from 'anser'; + +import {ExternalLink} from '@sentry/scraps/link'; + +import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal'; +import {IconOpen} from 'sentry/icons'; +import {isValidUrl} from 'sentry/utils/string/isValidUrl'; + +type AnsiToReactProps = { + text: string; +}; + +const ESCAPE_CHARACTER = '\u001B'; + +export function AnsiToReact({text}: AnsiToReactProps): ReactElement { + if (!text.includes(ESCAPE_CHARACTER)) { + return {renderLinksInPlainText(text, 'plain')}; + } + + return ; +} + +function AnsiSpans({text}: AnsiToReactProps): ReactElement { + const theme = useTheme(); + const bundles = useMemo( + () => + Anser.ansiToJson(text, { + json: true, + remove_empty: true, + use_classes: true, + }), + [text] + ); + + return ( + + {bundles.map((bundle, index) => ( + + {renderLinksInPlainText(bundle.content, `ansi-${index}`)} + + ))} + + ); +} + +// https?: Matches both "http" and "https" +// :\/\/: This is a literal match for "://" +// (?:www\.)?: Matches URLs with or without "www." +// [-a-zA-Z0-9@:%._\+~#=]{1,256}: Matches the domain name +// It allows for a range of characters (letters, digits, and special characters) +// The {1,256} specifies that these characters can occur anywhere from 1 to 256 times, which covers the range of typical domain name lengths +// \.: Matches the dot before the top-level domain (like ".com") +// [a-zA-Z0-9]{1,6}: Matches the top-level domain (like "com" or "org"). It's limited to letters and digits and can be between 1 and 6 characters long +// (?:[-a-zA-Z0-9@:%_\+~#?&\/=,\[\].]*[-a-zA-Z0-9@:%_\+~#?&\/=,\[\]])?: Matches the path, query parameters, or fragments that can follow the domain in a URL +// It now includes periods within the character set to allow for file extensions (e.g., ".html") and other period-containing segments in the URL path +// This pattern matches a wide range of characters typically found in paths, query strings, and fragments, including periods +// The final character set ensures that the URL ends with a character typically allowed in a path, query string, or fragment, excluding special characters like a trailing period not part of the URL +// /gi: The regex will match all occurrences in the string, not just the first one +// The "i" modifier makes the regex match both upper and lower case characters +// Links are rendered as React nodes so ANSI text never needs HTML injection. +const URL_REGEX = + /https?:\/\/(?:www\.)?[-\w@:%.+~#=]{1,256}\.[a-z0-9]{1,6}(?:[-\w@:%+~#?&/=,[\].]*[-\w@:%+~#?&/=,[\]])?/gi; + +function renderLinksInPlainText(text: string, keyPrefix: string): ReactNode { + if (!text.includes('http://') && !text.includes('https://')) { + return text; + } + + const elements: ReactNode[] = []; + let lastIndex = 0; + let hasLink = false; + + for (const match of text.matchAll(URL_REGEX)) { + const url = match[0]; + const index = match.index ?? 0; + hasLink = true; + + if (index > lastIndex) { + elements.push( + + {text.slice(lastIndex, index)} + + ); + } + + if (isValidUrl(url)) { + elements.push( + { + event.preventDefault(); + openNavigateToExternalLinkModal({linkText: url}); + }} + > + {url} + + + ); + } else { + elements.push({url}); + } + + lastIndex = index + url.length; + } + + if (!hasLink) { + return text; + } + + if (lastIndex < text.length) { + elements.push( + {text.slice(lastIndex)} + ); + } + + return elements; +} + +function getAnsiStyle(bundle: AnserJsonEntry, theme: Theme): CSSProperties | undefined { + const style: CSSProperties = {}; + const color = getAnsiColor(bundle.fg, bundle.fg_truecolor, theme); + const backgroundColor = getAnsiColor(bundle.bg, bundle.bg_truecolor, theme); + + if (bundle.decoration === 'reverse') { + if (backgroundColor) { + style.color = backgroundColor; + } + if (color) { + style.backgroundColor = color; + } + } else { + if (color) { + style.color = color; + } + if (backgroundColor) { + style.backgroundColor = backgroundColor; + } + } + + switch (bundle.decoration) { + case 'bold': + style.fontWeight = 'bold'; + break; + case 'dim': + style.opacity = 0.65; + break; + case 'italic': + style.fontStyle = 'italic'; + break; + case 'underline': + style.textDecorationLine = 'underline'; + break; + case 'hidden': + style.visibility = 'hidden'; + break; + case 'strikethrough': + style.textDecorationLine = 'line-through'; + break; + case 'blink': + case 'reverse': + case null: + break; + default: + break; + } + + return Object.keys(style).length > 0 ? style : undefined; +} + +function getAnsiColor( + colorClass: string | null, + trueColor: string | null, + theme: Theme +): string | undefined { + if (!colorClass) { + return undefined; + } + + if (colorClass === 'ansi-truecolor' && trueColor) { + return `rgb(${trueColor})`; + } + + if (colorClass.startsWith('ansi-bright-')) { + return getThemeAnsiColor(colorClass.replace('ansi-bright-', ''), theme, true); + } + + if (colorClass.startsWith('ansi-')) { + return getThemeAnsiColor(colorClass.replace('ansi-', ''), theme); + } + + return undefined; +} + +function getThemeAnsiColor( + colorName: string, + theme: Theme, + bright = false +): string | undefined { + if (colorName === 'black' || colorName === 'white') { + return theme.colors[colorName]; + } + + const themeColorName = COLOR_MAP[colorName as keyof typeof COLOR_MAP]; + if (!themeColorName) { + return undefined; + } + + const themeColorWeight = bright ? '200' : '500'; + return theme.colors[`${themeColorName}${themeColorWeight}`]; +} + +/** + * Maps ANSI color names -> theme.tsx color names + */ +const COLOR_MAP = { + red: 'red', + green: 'green', + blue: 'blue', + yellow: 'yellow', + magenta: 'pink', + cyan: 'blue', +} as const; + +const IconPlacement = styled(IconOpen)` + display: inline-block; + margin-left: 5px; + vertical-align: center; +`; diff --git a/static/app/components/ansiText/index.spec.tsx b/static/app/components/ansiText/index.spec.tsx new file mode 100644 index 00000000000000..cbe26881b25bad --- /dev/null +++ b/static/app/components/ansiText/index.spec.tsx @@ -0,0 +1,94 @@ +import {ThemeFixture} from 'sentry-fixture/theme'; + +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {AnsiText} from 'sentry/components/ansiText'; + +describe('AnsiText', () => { + it('renders ANSI standard and bright colors without escape codes', () => { + render(); + + expect(screen.getByText('red')).toHaveStyle({color: ThemeFixture().colors.red500}); + expect(screen.getByText('bright')).toHaveStyle({ + color: ThemeFixture().colors.red200, + }); + expect(screen.queryByText(text => text.includes('\u001B'))).not.toBeInTheDocument(); + }); + + it('renders nested ANSI foreground and background colors with reset behavior', () => { + render(); + + expect(screen.getByText('wo')).toHaveStyle({ + color: ThemeFixture().colors.green500, + }); + expect(screen.getByText('rl')).toHaveStyle({ + backgroundColor: ThemeFixture().colors.yellow500, + color: ThemeFixture().colors.green500, + }); + expect(screen.getByText('d')).not.toHaveStyle({ + color: ThemeFixture().colors.green500, + }); + }); + + it('renders ANSI text decorations', () => { + render(); + + expect(screen.getByText('world')).toHaveStyle({ + color: ThemeFixture().colors.green500, + fontWeight: 'bold', + }); + expect(screen.getByText('!')).not.toHaveStyle({ + fontWeight: 'bold', + }); + }); + + it('strips ANSI 256-color codes and renders truecolor values', () => { + render( + + ); + + expect(screen.getByText('indexed')).toBeInTheDocument(); + expect(screen.getByText('truecolor')).toHaveStyle({color: 'rgb(1, 2, 3)'}); + expect(screen.queryByText(text => text.includes('\u001B'))).not.toBeInTheDocument(); + }); + + it('keeps URLs clickable inside ANSI colored text', () => { + const url = 'https://docs.sentry.io'; + + render(); + + const link = screen.getByText(url).closest('a'); + expect(link).toBeInTheDocument(); + expect(link?.closest('span')).toHaveStyle({color: ThemeFixture().colors.green500}); + }); + + it('can normalize terminal backspaces and carriage returns', () => { + const {container} = render( + + ); + + expect(container).toHaveTextContent('abde progress 20%'); + }); + + it('handles terminal carriage returns across lines', () => { + const {container} = render( + + ); + + expect(container).toHaveTextContent('that sentence\nwill make you pause', { + normalizeWhitespace: false, + }); + }); + + it('does not linkify URL-ish text', () => { + render(); + + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(screen.getByText(' (normalizeTerminalSequences ? normalizeTerminalText(text) : text), + [normalizeTerminalSequences, text] + ); + + return ; +} diff --git a/static/app/components/ansiText/normalizeTerminalText.ts b/static/app/components/ansiText/normalizeTerminalText.ts new file mode 100644 index 00000000000000..3841d268df1f74 --- /dev/null +++ b/static/app/components/ansiText/normalizeTerminalText.ts @@ -0,0 +1,41 @@ +export function normalizeTerminalText(text: string): string { + return applyCarriageReturns(removeBackspaces(text)); +} + +function removeBackspaces(text: string): string { + const characters: string[] = []; + + for (const character of text) { + if (character === '\b') { + if (characters.length > 0 && characters[characters.length - 1] !== '\n') { + characters.pop(); + } + continue; + } + + characters.push(character); + } + + return characters.join(''); +} + +function applyCarriageReturns(text: string): string { + return text + .replace(/\r+\n/g, '\n') + .split('\n') + .map(applyLineCarriageReturns) + .join('\n'); +} + +function applyLineCarriageReturns(line: string): string { + if (!line.includes('\r')) { + return line; + } + + const [firstSegment = '', ...segments] = line.split('\r'); + + return segments.reduce( + (currentLine, segment) => segment + currentLine.slice(segment.length), + firstSegment + ); +} diff --git a/static/app/components/events/attachmentViewers/logFileViewer.tsx b/static/app/components/events/attachmentViewers/logFileViewer.tsx index db52a6dc91eba1..0a2f198f4b854c 100644 --- a/static/app/components/events/attachmentViewers/logFileViewer.tsx +++ b/static/app/components/events/attachmentViewers/logFileViewer.tsx @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; -import Ansi from 'ansi-to-react'; +import {AnsiText} from 'sentry/components/ansiText'; import {PreviewPanelItem} from 'sentry/components/events/attachmentViewers/previewPanelItem'; import type {ViewerProps} from 'sentry/components/events/attachmentViewers/utils'; import {getAttachmentUrl} from 'sentry/components/events/attachmentViewers/utils'; @@ -31,49 +31,14 @@ export function LogFileViewer(props: ViewerProps) { return data ? ( - {data} + + + ) : null; } -/** - * Maps ANSI color names -> theme.tsx color names - */ -const COLOR_MAP = { - red: 'red', - green: 'green', - blue: 'blue', - yellow: 'yellow', - magenta: 'pink', - cyan: 'blue', -} as const; - -const SentryStyleAnsi = styled(Ansi)` - ${p => - Object.entries(COLOR_MAP).map( - ([ansiColor, themeColor]) => ` - .ansi-${ansiColor}-bg { - background-color: ${p.theme.colors[`${themeColor}500`]}; - } - .ansi-${ansiColor}-fg { - color: ${p.theme.colors[`${themeColor}500`]}; - } - .ansi-bright-${ansiColor}-fg { - color: ${p.theme.colors[`${themeColor}200`]}; - }` - )} - - .ansi-black-fg, - .ansi-bright-black-fg { - color: ${p => p.theme.colors.black}; - } - .ansi-white-fg, - .ansi-bright-white-fg { - color: ${p => p.theme.colors.white}; - } -`; - const CodeWrapper = styled('pre')` padding: ${p => p.theme.space.md} ${p => p.theme.space.xl}; width: 100%; @@ -82,3 +47,7 @@ const CodeWrapper = styled('pre')` content: ''; } `; + +const Code = styled('code')` + display: block; +`; diff --git a/static/app/components/events/breadcrumbs/breadcrumbItemContent.tsx b/static/app/components/events/breadcrumbs/breadcrumbItemContent.tsx index 93ec63d790dfe7..38753a80ed92f7 100644 --- a/static/app/components/events/breadcrumbs/breadcrumbItemContent.tsx +++ b/static/app/components/events/breadcrumbs/breadcrumbItemContent.tsx @@ -2,6 +2,7 @@ import {Fragment} from 'react'; import styled from '@emotion/styled'; import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal'; +import {AnsiText} from 'sentry/components/ansiText'; import {AnnotatedText} from 'sentry/components/events/meta/annotatedText'; import {StructuredData} from 'sentry/components/structuredEventData'; import {Timeline} from 'sentry/components/timeline'; @@ -44,7 +45,11 @@ export function BreadcrumbItemContent({ const defaultMessage = defined(bc.message) ? ( - + {meta?.message ? ( + + ) : ( + + )} ) : null; @@ -201,12 +206,22 @@ function ExceptionCrumbContent({ const hasValue = value !== null && value !== undefined && value !== ''; const formattedValue = hasValue ? formatValue(value) : ''; + let renderedValue: React.ReactNode = null; + + if (hasValue) { + renderedValue = meta?.data?.value ? ( + formattedValue + ) : ( + + ); + } return ( {type ? type : null} - {type && hasValue ? `: ${formattedValue}` : hasValue ? formattedValue : null} + {type && hasValue ? ': ' : null} + {renderedValue} {children} {Object.keys(otherData).length > 0 ? ( diff --git a/static/app/components/events/eventMessage.tsx b/static/app/components/events/eventMessage.tsx index c18c4ad7a579b3..b207bf48ec80bf 100644 --- a/static/app/components/events/eventMessage.tsx +++ b/static/app/components/events/eventMessage.tsx @@ -4,6 +4,7 @@ import {ErrorLevel} from 'sentry/components/events/errorLevel'; import {UnhandledTag} from 'sentry/components/group/inboxBadges/unhandledTag'; import {t} from 'sentry/locale'; import type {EventOrGroupType, Level} from 'sentry/types/event'; +import {stripAnsi} from 'sentry/utils/ansiEscapeCodes'; import {eventTypeHasLogLevel} from 'sentry/utils/events'; import {Divider} from 'sentry/views/issueDetails/divider'; @@ -23,8 +24,9 @@ export function EventMessage({ showUnhandled = false, }: Props) { const showEventLevel = level && eventTypeHasLogLevel(type); + const displayMessage = typeof message === 'string' ? stripAnsi(message) : message; const renderedMessage = message ? ( - {message} + {displayMessage} ) : ( ({t('No error message')}) ); diff --git a/static/app/components/events/interfaces/crashContent/exception/utils.tsx b/static/app/components/events/interfaces/crashContent/exception/utils.tsx index 163331d150747e..2853a5cceec254 100644 --- a/static/app/components/events/interfaces/crashContent/exception/utils.tsx +++ b/static/app/components/events/interfaces/crashContent/exception/utils.tsx @@ -1,12 +1,6 @@ import type {ReactElement} from 'react'; -import {Fragment} from 'react'; -import styled from '@emotion/styled'; -import {ExternalLink} from '@sentry/scraps/link'; - -import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal'; -import {IconOpen} from 'sentry/icons'; -import {isValidUrl} from 'sentry/utils/string/isValidUrl'; +import {AnsiText} from 'sentry/components/ansiText'; interface RenderLinksInTextProps { exceptionText: string; @@ -15,53 +9,7 @@ interface RenderLinksInTextProps { export const renderLinksInText = ({ exceptionText, }: RenderLinksInTextProps): ReactElement => { - // https?: Matches both "http" and "https" - // :\/\/: This is a literal match for "://" - // (?:www\.)?: Matches URLs with or without "www." - // [-a-zA-Z0-9@:%._\+~#=]{1,256}: Matches the domain name - // It allows for a range of characters (letters, digits, and special characters) - // The {1,256} specifies that these characters can occur anywhere from 1 to 256 times, which covers the range of typical domain name lengths - // \.: Matches the dot before the top-level domain (like ".com") - // [a-zA-Z0-9]{1,6}: Matches the top-level domain (like "com" or "org"). It's limited to letters and digits and can be between 1 and 6 characters long - // (?:[-a-zA-Z0-9@:%_\+~#?&\/=,\[\].]*[-a-zA-Z0-9@:%_\+~#?&\/=,\[\]])?: Matches the path, query parameters, or fragments that can follow the domain in a URL - // It now includes periods within the character set to allow for file extensions (e.g., ".html") and other period-containing segments in the URL path - // This pattern matches a wide range of characters typically found in paths, query strings, and fragments, including periods - // The final character set ensures that the URL ends with a character typically allowed in a path, query string, or fragment, excluding special characters like a trailing period not part of the URL - // /gi: The regex will match all occurrences in the string, not just the first one - // The "i" modifier makes the regex match both upper and lower case characters - - const urlRegex = - /https?:\/\/(?:www\.)?[-\w@:%.+~#=]{1,256}\.[a-z0-9]{1,6}(?:[-\w@:%+~#?&/=,[\].]*[-\w@:%+~#?&/=,[\]])?/gi; - - const parts = exceptionText.split(urlRegex); - const urls = exceptionText.match(urlRegex) || []; - - const elements = parts.flatMap((part, index) => { - const url = urls[index]!; - const linkIsValid = isValidUrl(url); - - let link: ReactElement | undefined; - if (linkIsValid) { - link = ( - { - e.preventDefault(); - openNavigateToExternalLinkModal({linkText: url}); - }} - > - {url} - - - ); - } else if (url) { - link = {url}; - } - - return [{part}, link]; - }); - - return {elements}; + return ; }; // Maps the SDK name to the url token for docs @@ -94,9 +42,3 @@ export const sourceMapSdkDocsMap: Record = { 'sentry.javascript.react-native': 'react-native', 'sentry.javascript.astro': 'astro', }; - -const IconPlacement = styled(IconOpen)` - display: inline-block; - margin-left: 5px; - vertical-align: center; -`;