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);
}