1import React from "react";
2
3// Regex for matching URLs. Only matches http:// and https:// URLs.
4// Avoids matching trailing punctuation that's likely not part of the URL.
5// eslint-disable-next-line no-useless-escape
6const URL_REGEX = /https?:\/\/[^\s<>"'`\]\)*]+[^\s<>"'`\]\).,:;!?*]/g;
7
8export interface LinkifyResult {
9 type: "text" | "link";
10 content: string;
11 href?: string;
12}
13
14/**
15 * Parse text and extract URLs as separate segments.
16 * Returns an array of text and link segments.
17 */
18export function parseLinks(text: string): LinkifyResult[] {
19 const results: LinkifyResult[] = [];
20 let lastIndex = 0;
21
22 // Reset regex state
23 URL_REGEX.lastIndex = 0;
24
25 let match;
26 while ((match = URL_REGEX.exec(text)) !== null) {
27 // Add text before the match
28 if (match.index > lastIndex) {
29 results.push({
30 type: "text",
31 content: text.slice(lastIndex, match.index),
32 });
33 }
34
35 // Add the link
36 const url = match[0];
37 results.push({
38 type: "link",
39 content: url,
40 href: url,
41 });
42
43 lastIndex = match.index + url.length;
44 }
45
46 // Add remaining text after last match
47 if (lastIndex < text.length) {
48 results.push({
49 type: "text",
50 content: text.slice(lastIndex),
51 });
52 }
53
54 return results;
55}
56
57/**
58 * Convert text containing URLs into React elements with clickable links.
59 * URLs are rendered as <a> tags that open in new tabs.
60 * Text is HTML-escaped by React's default behavior.
61 */
62export function linkifyText(text: string): React.ReactNode {
63 const segments = parseLinks(text);
64
65 if (segments.length === 0) {
66 return text;
67 }
68
69 // If there's only one text segment with no links, return plain text
70 if (segments.length === 1 && segments[0].type === "text") {
71 return text;
72 }
73
74 return segments.map((segment, index) => {
75 if (segment.type === "link") {
76 return (
77 <a
78 key={index}
79 href={segment.href}
80 target="_blank"
81 rel="noopener noreferrer"
82 className="text-link"
83 >
84 {segment.content}
85 </a>
86 );
87 }
88 return <React.Fragment key={index}>{segment.content}</React.Fragment>;
89 });
90}