linkify.tsx

 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}