1+ /**
2+ * URL Detection utility for terminal output
3+ * Detects various URL formats including localhost addresses
4+ */
5+
6+ import React from 'react' ;
7+
8+ // URL regex pattern that matches:
9+ // - http:// and https:// URLs
10+ // - localhost URLs with ports
11+ // - IP addresses with ports
12+ // - URLs with paths and query parameters
13+ const URL_REGEX = / (?: h t t p s ? : \/ \/ ) ? (?: l o c a l h o s t | 1 2 7 \. 0 \. 0 \. 1 | 0 \. 0 \. 0 \. 0 | \[ [ 0 - 9 a - f A - F : ] + \] | (?: [ a - z A - Z 0 - 9 - ] + \. ) + [ a - z A - Z ] { 2 , } ) (?: : [ 0 - 9 ] + ) ? (?: \/ [ ^ \s ] * ) ? / gi;
14+
15+ // More specific localhost pattern for better accuracy
16+ const LOCALHOST_REGEX = / (?: h t t p s ? : \/ \/ ) ? (?: l o c a l h o s t | 1 2 7 \. 0 \. 0 \. 1 | 0 \. 0 \. 0 \. 0 | \[ : : 1 \] ) (?: : [ 0 - 9 ] + ) ? (?: \/ [ ^ \s ] * ) ? / gi;
17+
18+ export interface DetectedLink {
19+ url : string ;
20+ fullUrl : string ; // URL with protocol
21+ isLocalhost : boolean ;
22+ startIndex : number ;
23+ endIndex : number ;
24+ }
25+
26+ /**
27+ * Detects URLs in the given text
28+ * @param text - The text to search for URLs
29+ * @returns Array of detected links
30+ */
31+ export function detectLinks ( text : string ) : DetectedLink [ ] {
32+ const links : DetectedLink [ ] = [ ] ;
33+ const seenUrls = new Set < string > ( ) ;
34+
35+ // Reset regex lastIndex
36+ URL_REGEX . lastIndex = 0 ;
37+
38+ let match ;
39+ while ( ( match = URL_REGEX . exec ( text ) ) !== null ) {
40+ const url = match [ 0 ] ;
41+
42+ // Skip if we've already seen this URL
43+ if ( seenUrls . has ( url ) ) continue ;
44+ seenUrls . add ( url ) ;
45+
46+ // Ensure the URL has a protocol
47+ let fullUrl = url ;
48+ if ( ! url . match ( / ^ h t t p s ? : \/ \/ / ) ) {
49+ // Default to http for localhost, https for others
50+ const isLocalhost = LOCALHOST_REGEX . test ( url ) ;
51+ fullUrl = `${ isLocalhost ? 'http' : 'https' } ://${ url } ` ;
52+ }
53+
54+ // Validate the URL
55+ try {
56+ new URL ( fullUrl ) ;
57+ } catch {
58+ // Invalid URL, skip
59+ continue ;
60+ }
61+
62+ links . push ( {
63+ url,
64+ fullUrl,
65+ isLocalhost : LOCALHOST_REGEX . test ( url ) ,
66+ startIndex : match . index ,
67+ endIndex : match . index + url . length
68+ } ) ;
69+ }
70+
71+ return links ;
72+ }
73+
74+ /**
75+ * Checks if a text contains any URLs
76+ * @param text - The text to check
77+ * @returns True if URLs are found
78+ */
79+ export function hasLinks ( text : string ) : boolean {
80+ URL_REGEX . lastIndex = 0 ;
81+ return URL_REGEX . test ( text ) ;
82+ }
83+
84+ /**
85+ * Extracts the first URL from text
86+ * @param text - The text to search
87+ * @returns The first detected link or null
88+ */
89+ export function getFirstLink ( text : string ) : DetectedLink | null {
90+ const links = detectLinks ( text ) ;
91+ return links . length > 0 ? links [ 0 ] : null ;
92+ }
93+
94+ /**
95+ * Makes URLs in text clickable by wrapping them in a callback
96+ * @param text - The text containing URLs
97+ * @param onLinkClick - Callback when a link is clicked
98+ * @returns React elements with clickable links
99+ */
100+ export function makeLinksClickable (
101+ text : string ,
102+ onLinkClick : ( url : string ) => void
103+ ) : React . ReactNode [ ] {
104+ const links = detectLinks ( text ) ;
105+
106+ if ( links . length === 0 ) {
107+ return [ text ] ;
108+ }
109+
110+ const elements : React . ReactNode [ ] = [ ] ;
111+ let lastIndex = 0 ;
112+
113+ links . forEach ( ( link , index ) => {
114+ // Add text before the link
115+ if ( link . startIndex > lastIndex ) {
116+ elements . push ( text . substring ( lastIndex , link . startIndex ) ) ;
117+ }
118+
119+ // Add the clickable link
120+ elements . push (
121+ < a
122+ key = { `link-${ index } ` }
123+ href = { link . fullUrl }
124+ onClick = { ( e ) => {
125+ e . preventDefault ( ) ;
126+ onLinkClick ( link . fullUrl ) ;
127+ } }
128+ className = "text-primary underline hover:text-primary/80 cursor-pointer"
129+ title = { link . fullUrl }
130+ >
131+ { link . url }
132+ </ a >
133+ ) ;
134+
135+ lastIndex = link . endIndex ;
136+ } ) ;
137+
138+ // Add remaining text
139+ if ( lastIndex < text . length ) {
140+ elements . push ( text . substring ( lastIndex ) ) ;
141+ }
142+
143+ return elements ;
144+ }
0 commit comments