linkify.tsx2.8 KBView on GitHub
import React from 'react';

/** http(s) and www. URLs; trailing punctuation is peeled off after the match. */
const URL_RE = /(https?:\/\/[^\s<>"']+|www\.[^\s<>"']+)/gi;
const TRAILING_PUNCT_RE = /[.,;:!?)\]}'"]+$/;

/**
 * The one anchor style for a URL we found in text the user did not mark up.
 *
 * `action-muted-foreground`, not `primary`: primary is near-black in the light theme and
 * near-white in the dark one — the colour of the words around it — so a link painted with it
 * was a link only to whoever tried clicking. The `action` ramp is the blue that means
 * "actionable" everywhere else in Cedar, and its muted foreground is the shade meant to sit on
 * the page background, readable in both themes. Same token the Slack mention chip uses.
 */
export const LINKIFIED_ANCHOR_CLASS = 'text-action-muted-foreground underline break-all';

export function normalizeHref(raw: string): string {
  return raw.startsWith('http://') || raw.startsWith('https://') ? raw : `https://${raw}`;
}

function splitUrlAndTrailing(raw: string): { url: string; trailing: string } {
  const trailingMatch = raw.match(TRAILING_PUNCT_RE);
  if (!trailingMatch) return { url: raw, trailing: '' };
  return {
    url: raw.slice(0, -trailingMatch[0].length),
    trailing: trailingMatch[0],
  };
}

export type UrlPart = { type: 'text'; value: string } | { type: 'url'; value: string };

/** Split text into alternating plain runs and bare URLs. */
export function splitByUrls(text: string): UrlPart[] {
  const parts: UrlPart[] = [];
  let lastIndex = 0;
  const re = new RegExp(URL_RE.source, URL_RE.flags);

  let match: RegExpExecArray | null;
  while ((match = re.exec(text)) !== null) {
    if (match.index > lastIndex) {
      parts.push({ type: 'text', value: text.slice(lastIndex, match.index) });
    }

    const { url, trailing } = splitUrlAndTrailing(match[0]);
    if (url) parts.push({ type: 'url', value: url });
    if (trailing) parts.push({ type: 'text', value: trailing });
    lastIndex = match.index + match[0].length;
  }

  if (lastIndex < text.length) {
    parts.push({ type: 'text', value: text.slice(lastIndex) });
  }

  return parts.length > 0 ? parts : [{ type: 'text', value: text }];
}

/** True when the text contains at least one bare URL worth linkifying. */
export function hasBareUrl(text: string): boolean {
  return new RegExp(URL_RE.source, URL_RE.flags).test(text);
}

/** Turn bare URLs in plain text into React anchor nodes. */
export function linkifyPlainText(text: string): React.ReactNode[] {
  return splitByUrls(text).map((part, i) => {
    if (part.type === 'text') return part.value;
    return (
      <a
        key=[redacted]
        href={normalizeHref(part.value)}
        target="_blank"
        rel="noopener noreferrer"
        className={LINKIFIED_ANCHOR_CLASS}
        onClick={(e) => e.stopPropagation()}
      >
        {part.value}
      </a>
    );
  });
}