import { Fragment, type ReactNode } from "react";
// Bare http(s) URLs and Markdown `[label](url)` links in plain agent-written
// text (question prompts, help text, option descriptions).
const URL_PATTERN = /https?:\/\/[^\s<>"'`]+/g;
// Sentence punctuation that follows a URL is not part of it.
const TRAILING_PUNCTUATION = /[.,;:!?]+$/;
export const LINKIFIED_LINK_CLASS = "font-medium og-link [overflow-wrap:anywhere]";
type Segment =
| { kind: "text"; value: string }
/** `value` is the href; `label` is the visible text of a Markdown link. */
| { kind: "link"; value: string; label?: string };
type PositionedSegment = { segment: Segment; start: number };
function trimUrl(raw: string): string {
let url = raw.replace(TRAILING_PUNCTUATION, "");
// Drop a closing paren that wraps the URL rather than belonging to it.
while (url.endsWith(")") && countOf(url, "(") < countOf(url, ")")) {
url = url.slice(0, -1).replace(TRAILING_PUNCTUATION, "");
}
return url;
}
function countOf(value: string, char: string): number {
let count = 0;
for (const current of value) if (current === char) count += 1;
return count;
}
function isValidUrl(value: string): boolean {
try {
return /^https?:$/u.test(new URL(value).protocol);
} catch {
return false;
}
}
/**
* Parse a Markdown inline link `[label](http(s)://url)` starting at `open`
* (the `[`). The URL may contain balanced parentheses. Returns null when the
* text there is not a well-formed http(s) link, so it stays plain text.
*/
function parseMarkdownLink(
text: string,
open: number,
): { label: string; url: string; end: number } | null {
const close = text.indexOf("]", open + 1);
if (close < 0 || text[close + 1] !== "(") return null;
const label = text.slice(open + 1, close);
if (!label.trim() || /[[\n]/u.test(label)) return null;
let depth = 0;
for (let index = close + 2; index < text.length; index += 1) {
const char = text[index]!;
if (/\s/u.test(char)) return null;
if (char === "(") depth += 1;
else if (char === ")") {
if (depth === 0) {
const url = text.slice(close + 2, index);
return isValidUrl(url) ? { label: label.trim(), url, end: index + 1 } : null;
}
depth -= 1;
}
}
return null;
}
function bareUrlSegments(text: string, offset: number, into: PositionedSegment[]): void {
let cursor = 0;
const pushText = (value: string, start: number) => {
if (value) into.push({ segment: { kind: "text", value }, start: offset + start });
};
for (const match of text.matchAll(URL_PATTERN)) {
const start = match.index ?? 0;
const url = trimUrl(match[0]);
if (!isValidUrl(url)) continue;
pushText(text.slice(cursor, start), cursor);
into.push({ segment: { kind: "link", value: url }, start: offset + start });
cursor = start + url.length;
}
pushText(text.slice(cursor), cursor);
}
function positionedSegments(text: string): PositionedSegment[] {
const segments: PositionedSegment[] = [];
let cursor = 0;
let search = 0;
while (search < text.length) {
const open = text.indexOf("[", search);
if (open < 0) break;
const link = parseMarkdownLink(text, open);
if (!link) {
search = open + 1;
continue;
}
bareUrlSegments(text.slice(cursor, open), cursor, segments);
segments.push({ segment: { kind: "link", value: link.url, label: link.label }, start: open });
cursor = link.end;
search = link.end;
}
bareUrlSegments(text.slice(cursor), cursor, segments);
// Merge adjacent text pieces so callers see one run between links.
const merged: PositionedSegment[] = [];
for (const entry of segments) {
const previous = merged.at(-1);
if (previous?.segment.kind === "text" && entry.segment.kind === "text") {
previous.segment = { kind: "text", value: previous.segment.value + entry.segment.value };
} else {
merged.push({ segment: { ...entry.segment }, start: entry.start });
}
}
return merged;
}
/** Split plain text into text, bare http(s) URL, and Markdown link segments. */
export function linkifySegments(text: string): Segment[] {
return positionedSegments(text).map((entry) => entry.segment);
}
/**
* Render plain text with bare http(s) URLs and Markdown `[label](url)` links as
* links that open in a new tab and wrap anywhere, so a long sign-in or approval
* URL stays clickable and inside its card. Non-string nodes pass through
* unchanged.
*/
export function linkifyText(node: ReactNode): ReactNode {
if (typeof node !== "string") return node;
const segments = positionedSegments(node);
if (!segments.some((entry) => entry.segment.kind === "link")) return node;
// Each segment's source offset is a stable unique key.
return segments.map(({ segment, start }) =>
segment.kind === "link" ? (
{segment.label ?? segment.value}
) : (
{segment.value}
),
);
}