@@ -42,60 +42,97 @@ function getToolAbbreviation(toolName: string): string {
4242 . join ( "" )
4343}
4444
45- // Escape HTML to prevent XSS
46- function escapeHtml ( text : string ) : string {
47- return text
48- . replace ( / & / g, "&" )
49- . replace ( / < / g, "<" )
50- . replace ( / > / g, ">" )
51- . replace ( / " / g, """ )
52- . replace ( / ' / g, "'" )
45+ // Pattern definitions for syntax highlighting
46+ type HighlightPattern = {
47+ pattern : RegExp
48+ className : string
49+ // If true, wraps the entire match; if a number, wraps that capture group
50+ wrapGroup ?: number
5351}
5452
55- // Format log content with basic highlighting (XSS-safe)
56- function formatLogContent ( log : string ) : React . ReactNode [ ] {
57- const lines = log . split ( "\n" )
58- return lines . map ( ( line , index ) => {
59- // First escape the entire line to prevent XSS
60- let formattedLine = escapeHtml ( line )
61-
62- // Highlight timestamps [YYYY-MM-DDTHH:MM:SS.sssZ]
63- formattedLine = formattedLine . replace (
64- / \[ ( \d { 4 } - \d { 2 } - \d { 2 } T \d { 2 } : \d { 2 } : \d { 2 } \. \d { 3 } Z ) \] / g,
65- '<span class="text-blue-400">[$1]</span>' ,
66- )
53+ const HIGHLIGHT_PATTERNS : HighlightPattern [ ] = [
54+ // Timestamps [YYYY-MM-DDTHH:MM:SS.sssZ]
55+ { pattern : / \[ ( \d { 4 } - \d { 2 } - \d { 2 } T \d { 2 } : \d { 2 } : \d { 2 } \. \d { 3 } Z ) \] / g, className : "text-blue-400" } ,
56+ // Log levels
57+ { pattern : / \| \s * ( I N F O ) \s * \| / g, className : "text-green-400" , wrapGroup : 1 } ,
58+ { pattern : / \| \s * ( W A R N | W A R N I N G ) \s * \| / g, className : "text-yellow-400" , wrapGroup : 1 } ,
59+ { pattern : / \| \s * ( E R R O R ) \s * \| / g, className : "text-red-400" , wrapGroup : 1 } ,
60+ { pattern : / \| \s * ( D E B U G ) \s * \| / g, className : "text-gray-400" , wrapGroup : 1 } ,
61+ // Task identifiers
62+ { pattern : / ( t a s k C r e a t e d | t a s k F o c u s e d | t a s k S t a r t e d | t a s k C o m p l e t e d | E v a l P a s s | E v a l F a i l ) / g, className : "text-purple-400" } ,
63+ // Message arrows
64+ { pattern : / → / g, className : "text-cyan-400" } ,
65+ ]
66+
67+ // Format a single line with syntax highlighting using React elements (XSS-safe)
68+ function formatLine ( line : string ) : React . ReactNode [ ] {
69+ // Find all matches with their positions
70+ type Match = { start : number ; end : number ; text : string ; className : string }
71+ const matches : Match [ ] = [ ]
72+
73+ for ( const { pattern, className, wrapGroup } of HIGHLIGHT_PATTERNS ) {
74+ // Reset regex state
75+ pattern . lastIndex = 0
76+ let regexMatch
77+ while ( ( regexMatch = pattern . exec ( line ) ) !== null ) {
78+ const capturedText = wrapGroup !== undefined ? regexMatch [ wrapGroup ] : regexMatch [ 0 ]
79+ // Skip if capture group didn't match
80+ if ( ! capturedText ) continue
81+ const start =
82+ wrapGroup !== undefined ? regexMatch . index + regexMatch [ 0 ] . indexOf ( capturedText ) : regexMatch . index
83+ matches . push ( {
84+ start,
85+ end : start + capturedText . length ,
86+ text : capturedText ,
87+ className,
88+ } )
89+ }
90+ }
91+
92+ // Sort matches by position and filter overlapping ones
93+ matches . sort ( ( a , b ) => a . start - b . start )
94+ const filteredMatches : Match [ ] = [ ]
95+ for ( const m of matches ) {
96+ const lastMatch = filteredMatches [ filteredMatches . length - 1 ]
97+ if ( ! lastMatch || m . start >= lastMatch . end ) {
98+ filteredMatches . push ( m )
99+ }
100+ }
67101
68- // Highlight log levels
69- formattedLine = formattedLine . replace ( / \| \s * ( I N F O ) \s * \| / g, '| <span class="text-green-400">$1</span> |' )
70- formattedLine = formattedLine . replace (
71- / \| \s * ( W A R N | W A R N I N G ) \s * \| / g,
72- '| <span class="text-yellow-400">$1</span> |' ,
73- )
74- formattedLine = formattedLine . replace ( / \| \s * ( E R R O R ) \s * \| / g, '| <span class="text-red-400">$1</span> |' )
75- formattedLine = formattedLine . replace ( / \| \s * ( D E B U G ) \s * \| / g, '| <span class="text-gray-400">$1</span> |' )
102+ // Build result with highlighted spans
103+ const result : React . ReactNode [ ] = [ ]
104+ let currentPos = 0
76105
77- // Highlight task identifiers like taskCreated, taskFocused, etc.
78- formattedLine = formattedLine . replace (
79- / ( t a s k C r e a t e d | t a s k F o c u s e d | t a s k S t a r t e d | t a s k C o m p l e t e d | E v a l P a s s | E v a l F a i l ) / g,
80- '<span class="text-purple-400">$1</span>' ,
106+ for ( const [ i , m ] of filteredMatches . entries ( ) ) {
107+ // Add text before this match
108+ if ( m . start > currentPos ) {
109+ result . push ( line . slice ( currentPos , m . start ) )
110+ }
111+ // Add highlighted match
112+ result . push (
113+ < span key = { `${ i } -${ m . start } ` } className = { m . className } >
114+ { m . text }
115+ </ span > ,
81116 )
117+ currentPos = m . end
118+ }
82119
83- // Highlight message arrows (escaped as → after escapeHtml)
84- formattedLine = formattedLine . replace ( / → / g , '<span class="text-cyan-400">→</span>' )
85-
86- return (
87- < div
88- key = { index }
89- className = "hover:bg-white/5"
90- < div
91- key = { index }
92- className = "hover:bg-white/5"
93- >
94- { formattedLine || " " }
95- </ div >
96- / >
97- )
98- } )
120+ // Add remaining text
121+ if ( currentPos < line . length ) {
122+ result . push ( line . slice ( currentPos ) )
123+ }
124+
125+ return result . length > 0 ? result : [ line ]
126+ }
127+
128+ // Format log content with basic highlighting (XSS-safe - no dangerouslySetInnerHTML)
129+ function formatLogContent ( log : string ) : React . ReactNode [ ] {
130+ const lines = log . split ( "\n" )
131+ return lines . map ( ( line , index ) => (
132+ < div key = { index } className = "hover:bg-white/5" >
133+ { line ? formatLine ( line ) : " " }
134+ </ div >
135+ ) )
99136}
100137
101138export function Run ( { run } : { run : Run } ) {
0 commit comments