cell-links.ts4.3 KBView on GitHub
/**
 * The links inside a cell's text — found, not declared.
 *
 * A `url` column holds a URL and an `email` column holds an address, but so does half the prose
 * in a `text` or `long_text` cell an agent filled: "book at https://cal.com/x", a contact's
 * address typed into a notes column. Rendering those as plain grey text is the grid quietly
 * being worse than every other surface in Cedar — the kanban card already links a url field
 * (`CardFieldValue`), and a link in a document's prose is a link.
 *
 * So the rule here is TEXTUAL, not columnar: any run that looks like a link becomes one,
 * whatever column it is in. That is what makes it "automatic" — nobody has to have picked the
 * right column type a week ago for the address in front of you to be clickable today.
 *
 * Pure string work, no React: `CellSegments` renders what this returns, and the same function
 * is what a test can assert against without a DOM.
 */

/**
 * A URL (`https://…`, `http://…`, a bare `www.…`) or an email address.
 *
 * Deliberately conservative at the right edge — the run stops at whitespace or at any of
 * `<>()[]{}"'` — because a cell is prose as often as it is a bare value, and "(see https://x)"
 * must not swallow the closing paren into the href. Trailing sentence punctuation is trimmed
 * after the match for the same reason; see `trimTrailing`.
 *
 * Conservative at the LEFT edge too: a bare domain with no scheme and no `www.` (`cal.com/x`)
 * is left as text. Matching it would also match `v2.1/beta`, a filename and half the version
 * strings in a notes column — and a false link in a cell is worse than a missing one, because
 * the reader cannot tell it is false until they have already clicked it.
 */
const LINK_RE =
  /(?:https?:\/\/|www\.)[^\s<>()[\]{}"']+|[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g;

/** Punctuation that ends a SENTENCE rather than a URL, stripped back off the match. */
const TRAILING_PUNCTUATION = /[.,;:!?'"]+$/;

export interface CellTextRun {
  kind: 'plain' | 'link';
  text: string;
  /** Absolute href for a link run — `mailto:` for an address, `https://` added to a bare `www.`. */
  href?: string;
}

/** True when the run is an email address rather than a URL (no scheme, has an `@`). */
function isEmail(text: string): boolean {
  return !/^https?:\/\//i.test(text) && text.includes('@');
}

/** `www.x.com` → `https://www.x.com`; `<email>` → `mailto:<email>`; anything else unchanged. */
export function linkHref(text: string): string {
  if (isEmail(text)) return `mailto:${text}`;
  return /^https?:\/\//i.test(text) ? text : `https://${text}`;
}

/** Trim trailing sentence punctuation off a match, without letting the whole match vanish. */
function trimTrailing(match: string): string {
  const trimmed = match.replace(TRAILING_PUNCTUATION, '');
  return trimmed || match;
}

/**
 * Split one text run into plain and link runs, in order. A run with no links comes back as a
 * single `plain` entry, which is the overwhelmingly common case and costs one regex pass.
 */
export function linkifyText(text: string): CellTextRun[] {
  if (!text) return [];
  const runs: CellTextRun[] = [];
  let cursor = 0;
  // `lastIndex` is mutated by `exec`, so the regex is re-created per call rather than shared —
  // a module-level /g regex reused across calls resumes mid-string and drops matches.
  const pattern = new RegExp(LINK_RE.source, 'g');
  let match: RegExpExecArray | null;
  while ((match = pattern.exec(text)) !== null) {
    const raw = trimTrailing(match[0]);
    // An email whose local part was matched off the end of a URL ("x.com/a@b") is left alone:
    // the URL branch already consumed it, so only a standalone address reaches here.
    if (match.index > cursor) runs.push({ kind: 'plain', text: text.slice(cursor, match.index) });
    runs.push({ kind: 'link', text: raw, href: linkHref(raw) });
    cursor = match.index + raw.length;
    // Re-anchor the scan at the trimmed end, so punctuation we gave back is still scanned.
    pattern.lastIndex = cursor;
  }
  if (cursor < text.length) runs.push({ kind: 'plain', text: text.slice(cursor) });
  return runs;
}

/** True when the text holds at least one link — for callers that only need the question. */
export function hasCellLinks(text: string): boolean {
  return new RegExp(LINK_RE.source).test(text);
}