33import { useEffect , useRef , useState } from "react" ;
44import { cn } from "@/lib/cn" ;
55
6- // Simple Go syntax highlighter
6+ // HTML-escape a plain text string.
7+ function esc ( s : string ) : string {
8+ return s . replace ( / & / g, "&" ) . replace ( / < / g, "<" ) . replace ( / > / g, ">" ) ;
9+ }
10+
11+ // Tokenize-then-render Go syntax highlighter.
12+ // Pass 1: a single regex with alternation matches tokens in priority order.
13+ // Pass 2: each token maps to an HTML span; unmatched text is plain (escaped).
714function highlightGo ( code : string ) : string {
8- // Order matters: do strings first to avoid highlighting keywords inside strings
9- let result = code ;
10-
11- // Escape HTML first
12- result = result
13- . replace ( / & / g, "&" )
14- . replace ( / < / g, "<" )
15- . replace ( / > / g, ">" ) ;
16-
17- // Comments (single-line)
18- result = result . replace (
19- / ( \/ \/ .* $ ) / gm,
20- '<span className="text-fd-muted-foreground/60 italic">$1</span>' ,
21- ) ;
15+ const goKeywords = new Set ( [
16+ "package" , "import" , "func" , "return" , "if" , "else" , "for" , "range" ,
17+ "var" , "const" , "type" , "struct" , "interface" , "map" , "chan" , "go" ,
18+ "defer" , "select" , "case" , "switch" , "default" , "break" , "continue" ,
19+ "fallthrough" , "nil" , "true" , "false" , "err" ,
20+ ] ) ;
21+ const goTypes = new Set ( [
22+ "string" , "int" , "int64" , "float64" , "bool" , "error" , "byte" , "rune" , "any" ,
23+ ] ) ;
24+
25+ // Groups: 1=comment, 2=string, 3=backtick-string, 4=word, 5=func-call (UpperWord before '(')
26+ const tokenRe =
27+ / ( \/ \/ .* $ ) | ( " (?: [ ^ " \\ ] | \\ .) * " ) | ( ` [ ^ ` ] * ` ) | ( \b [ a - z A - Z _ ] \w * (?: \. \w + ) * \b ) | \b ( [ A - Z ] \w * ) \s * (? = \( ) / gm;
28+
29+ let out = "" ;
30+ let last = 0 ;
31+ let m : RegExpExecArray | null ;
32+
33+ while ( ( m = tokenRe . exec ( code ) ) !== null ) {
34+ // Append any unmatched text before this token.
35+ if ( m . index > last ) {
36+ out += esc ( code . slice ( last , m . index ) ) ;
37+ }
38+ last = m . index + m [ 0 ] . length ;
39+
40+ if ( m [ 1 ] != null ) {
41+ // Comment
42+ out += `<span class="text-fd-muted-foreground/60 italic">${ esc ( m [ 1 ] ) } </span>` ;
43+ } else if ( m [ 2 ] != null ) {
44+ // Double-quoted string
45+ out += `<span class="text-teal-400">${ esc ( m [ 2 ] ) } </span>` ;
46+ } else if ( m [ 3 ] != null ) {
47+ // Backtick string
48+ out += `<span class="text-teal-400">${ esc ( m [ 3 ] ) } </span>` ;
49+ } else if ( m [ 4 ] != null ) {
50+ const word = m [ 4 ] ;
51+ // Check for "context.Context" style compound types
52+ if ( word === "context.Context" ) {
53+ out += `<span class="text-cyan-400">${ esc ( word ) } </span>` ;
54+ } else if ( goKeywords . has ( word ) ) {
55+ out += `<span class="text-purple-400 font-medium">${ esc ( word ) } </span>` ;
56+ } else if ( goTypes . has ( word ) ) {
57+ out += `<span class="text-cyan-400">${ esc ( word ) } </span>` ;
58+ } else if ( / ^ [ A - Z ] / . test ( word ) && code . slice ( last ) . trimStart ( ) . startsWith ( "(" ) ) {
59+ // Uppercase word followed by '(' — function/method call
60+ out += `<span class="text-blue-400">${ esc ( word ) } </span>` ;
61+ } else {
62+ out += esc ( word ) ;
63+ }
64+ } else if ( m [ 5 ] != null ) {
65+ out += `<span class="text-blue-400">${ esc ( m [ 5 ] ) } </span>` ;
66+ }
67+ }
2268
23- // Strings (double-quoted)
24- result = result . replace (
25- / ( " (?: [ ^ " \\ ] | \\ .) * " ) / g,
26- '<span className="text-teal-400">$1</span>' ,
27- ) ;
69+ // Append remaining text.
70+ if ( last < code . length ) {
71+ out += esc ( code . slice ( last ) ) ;
72+ }
2873
29- // Backtick strings
30- result = result . replace (
31- / ( ` [ ^ ` ] * ` ) / g,
32- '<span className="text-teal-400">$1</span>' ,
33- ) ;
74+ return out ;
75+ }
3476
35- // Keywords
36- const keywords = [
37- "package" ,
38- "import" ,
39- "func " ,
40- "return " ,
41- "if " ,
42- "else" ,
43- "for" ,
44- "range" ,
45- "var" ,
46- "const" ,
47- "type" ,
48- "struct" ,
49- "interface" ,
50- "map" ,
51- "chan" ,
52- "go" ,
53- "defer" ,
54- "select" ,
55- "case" ,
56- "switch" ,
57- "default" ,
58- "break" ,
59- "continue" ,
60- "fallthrough" ,
61- "nil" ,
62- "true" ,
63- "false" ,
64- "err" ,
65- ] ;
66- keywords . forEach ( ( kw ) => {
67- const regex = new RegExp ( `\\b( ${ kw } )\\b` , "g" ) ;
68- result = result . replace (
69- regex ,
70- '<span class="text-purple-400 font-medium">$1</span>' ,
71- ) ;
72- } ) ;
73-
74- // Types
75- const types = [
76- "string" ,
77- "int" ,
78- "int64" ,
79- "float64" ,
80- "bool" ,
81- "error" ,
82- "byte" ,
83- "rune" ,
84- "any" ,
85- "context\\.Context" ,
86- ] ;
87- types . forEach ( ( t ) => {
88- const regex = new RegExp ( `\\b( ${ t } )\\b` , "g" ) ;
89- result = result . replace ( regex , '<span class="text-cyan-400">$1</span>' ) ;
90- } ) ;
91-
92- // Function calls
93- result = result . replace (
94- / \b ( [ A - Z ] \w * ) \s * \( / g ,
95- '<span class="text-blue-400">$1</span>(' ,
96- ) ;
77+ // Tokenize-then-render TSX/JSX syntax highlighter.
78+ function highlightTSX ( code : string ) : string {
79+ const tsxKeywords = new Set ( [
80+ "import" , "export" , "from" , "const" , "let" , "var" , "function" , "return" ,
81+ "if" , "else" , "for" , "while" , "default" , "new" , "this" , "class ",
82+ "extends" , "async" , "await" , "typeof" , "instanceof" , "null" , "undefined ",
83+ "true" , "false ",
84+ ] ) ;
85+
86+ // Tokenize: groups in priority order.
87+ // 1=comment, 2=double-string, 3=single-string, 4=backtick-string ,
88+ // 5=arrow (=>), 6=JSX tag (<Tag or </Tag), 7=word, 8=curly block {…}
89+ const tokenRe =
90+ / ( \/ \/ . * $ ) | ( " (?: [ ^ " \\ ] | \\ . ) * " ) | ( ' (?: [ ^ ' \\ ] | \\ . ) * ' ) | ( ` [ ^ ` ] * ` ) | ( = > ) | ( < \/ ? ) ( [ \w . ] + ) | ( \b [ a - z A - Z _ ] [ \w ] * \b ) | ( \{ [ ^ } ] * \} ) / gm ;
91+
92+ let out = "" ;
93+ let last = 0 ;
94+ let m : RegExpExecArray | null ;
95+
96+ // Track whether we're inside a JSX tag (between < and >) for prop detection.
97+ while ( ( m = tokenRe . exec ( code ) ) !== null ) {
98+ if ( m . index > last ) {
99+ out += esc ( code . slice ( last , m . index ) ) ;
100+ }
101+ last = m . index + m [ 0 ] . length ;
102+
103+ if ( m [ 1 ] != null ) {
104+ // Comment
105+ out += `<span class="text-fd-muted-foreground/60 italic"> ${ esc ( m [ 1 ] ) } </span>` ;
106+ } else if ( m [ 2 ] != null ) {
107+ // Double-quoted string
108+ out += `<span class="text-teal-400"> ${ esc ( m [ 2 ] ) } </span>` ;
109+ } else if ( m [ 3 ] != null ) {
110+ // Single-quoted string
111+ out += `<span class="text-teal-400"> ${ esc ( m [ 3 ] ) } </span>` ;
112+ } else if ( m [ 4 ] != null ) {
113+ // Backtick string
114+ out += `<span class="text-teal-400"> ${ esc ( m [ 4 ] ) } </span>` ;
115+ } else if ( m [ 5 ] != null ) {
116+ // Arrow function =>
117+ out += `<span class="text-purple-400"> ${ esc ( m [ 5 ] ) } </span>` ;
118+ } else if ( m [ 6 ] != null && m [ 7 ] != null ) {
119+ // JSX tag: <Tag or </Tag
120+ out += ` ${ esc ( m [ 6 ] ) } <span class="text-blue-400"> ${ esc ( m [ 7 ] ) } </span>` ;
121+ } else if ( m [ 8 ] != null ) {
122+ const word = m [ 8 ] ;
123+ // Check if this word is followed by '=' (JSX prop)
124+ const afterWord = code . slice ( last ) ;
125+ if ( afterWord . startsWith ( "=" ) && ! afterWord . startsWith ( "==" ) ) {
126+ out += `<span class="text-cyan-400"> ${ esc ( word ) } </span>` ;
127+ } else if ( tsxKeywords . has ( word ) ) {
128+ out += `<span class="text-purple-400 font-medium"> ${ esc ( word ) } </span>` ;
129+ } else {
130+ out += esc ( word ) ;
131+ }
132+ } else if ( m [ 9 ] != null ) {
133+ // Curly block {…} — highlight inner content
134+ const block = m [ 9 ] ;
135+ const inner = block . slice ( 1 , - 1 ) ;
136+ out += `{<span class="text-amber-300"> ${ esc ( inner ) } </span>}` ;
137+ }
138+ }
97139
98- // Method calls (after dot)
99- result = result . replace (
100- / \. ( [ A - Z ] \w * ) \s * \( / g,
101- '.<span class="text-blue-400">$1</span>(' ,
102- ) ;
140+ if ( last < code . length ) {
141+ out += esc ( code . slice ( last ) ) ;
142+ }
103143
104- return result ;
144+ return out ;
105145}
106146
107147interface CodeBlockProps {
108148 code : string ;
109149 filename ?: string ;
110150 className ?: string ;
111151 showLineNumbers ?: boolean ;
152+ language ?: "go" | "tsx" ;
112153}
113154
114155export function CodeBlock ( {
115156 code,
116157 filename,
117158 className,
118159 showLineNumbers = true ,
160+ language = "go" ,
119161} : CodeBlockProps ) {
120162 const [ copied , setCopied ] = useState ( false ) ;
121163 const codeRef = useRef < HTMLPreElement > ( null ) ;
@@ -132,8 +174,9 @@ export function CodeBlock({
132174 setCopied ( true ) ;
133175 } ;
134176
177+ const highlighter = language === "tsx" ? highlightTSX : highlightGo ;
135178 const lines = code . split ( "\n" ) ;
136- const highlighted = lines . map ( ( line ) => highlightGo ( line ) ) ;
179+ const highlighted = lines . map ( ( line ) => highlighter ( line ) ) ;
137180
138181 return (
139182 < div
0 commit comments