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