Skip to content

Commit a44e9a3

Browse files
committed
feat(terminal): add automatic URL detection in output
- Detect URLs including localhost addresses in terminal output - Support for various URL formats and localhost patterns - Provide utilities to make links clickable Automatically detects when development servers start and offers to open them in the preview pane.
1 parent 444d480 commit a44e9a3

1 file changed

Lines changed: 144 additions & 0 deletions

File tree

src/lib/linkDetector.tsx

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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 = /(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[[0-9a-fA-F:]+\]|(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})(?::[0-9]+)?(?:\/[^\s]*)?/gi;
14+
15+
// More specific localhost pattern for better accuracy
16+
const LOCALHOST_REGEX = /(?:https?:\/\/)?(?:localhost|127\.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(/^https?:\/\//)) {
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

Comments
 (0)