import type { ReactNode } from 'react'; import { highlightMentions } from './mentions'; /** * WhatsApp-style lightweight markup for message bodies. * * *bold* _italic_ ~strike~ `code` ```code block``` plus bare URLs * * Messages stay PLAIN TEXT on the wire — this only affects rendering, so nothing already stored * breaks and a client without this build just shows the marker characters. * * Security: this returns a ReactNode tree and never touches dangerouslySetInnerHTML, so message * text can never inject markup. The one genuinely dangerous surface is links, where an attacker * could otherwise smuggle `javascript:` — {@link safeHref} allows only http/https/mailto. * * Precedence (deliberate): code is tokenized first and its contents are left completely alone, so * `*not bold*` inside backticks stays literal. Everything else may nest (*bold with _italic_*). */ /** Only protocols that cannot execute script. Anything else renders as plain text, not a link. */ export function safeHref(raw: string): string | null { try { const url = new URL(raw); return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : null; } catch { return null; } } // A bare URL: stops before trailing punctuation so "see https://x.com." doesn't swallow the period. const URL_RE = /\bhttps?:\/\/[^\s<>()]+[^\s<>().,;:!?'"]|\bwww\.[^\s<>()]+[^\s<>().,;:!?'"]/g; interface Rule { /** The wrapping marker, e.g. '*' for bold. */ marker: string; tag: 'strong' | 'em' | 'del'; } const RULES: Rule[] = [ { marker: '*', tag: 'strong' }, { marker: '_', tag: 'em' }, { marker: '~', tag: 'del' }, ]; /** Escape a marker so it is literal inside a RegExp. */ function esc(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Find the first inline span (`*bold*`, `_italic_`, `~strike~`) in `text`. * * Two guards keep everyday prose from being mangled: * - the content must not start or end with whitespace, so "5 * 3 = 15" and a lone `*` are inert; * - the markers must sit on word boundaries, so `snake_case_name` is NOT italicised (the bug * every naive markdown renderer ships with). */ function firstSpan(text: string): { start: number; end: number; rule: Rule; inner: string } | null { let best: { start: number; end: number; rule: Rule; inner: string } | null = null; for (const rule of RULES) { const m = esc(rule.marker); // (lead)(marker)(inner)(marker) — `lead` keeps the boundary char out of the match itself. const re = new RegExp(`(^|[^\\w${m}])${m}(?=\\S)([^${m}]*[^\\s${m}])${m}(?![\\w${m}])`); const found = re.exec(text); if (!found) continue; const start = found.index + (found[1]?.length ?? 0); if (best === null || start < best.start) { best = { start, end: found.index + found[0].length, rule, inner: found[2] ?? '' }; } } return best; } /** Linkify + mention-highlight a run of text that carries no other markup. */ function plain(text: string, memberNames: string[], keyed: () => string): ReactNode[] { const out: ReactNode[] = []; let last = 0; let m: RegExpExecArray | null; URL_RE.lastIndex = 0; while ((m = URL_RE.exec(text)) !== null) { if (m.index > last) out.push(...highlightMentions(text.slice(last, m.index), memberNames)); const raw = m[0]; const href = safeHref(raw.startsWith('www.') ? `https://${raw}` : raw); out.push( href ? ( {raw} ) : ( raw ), ); last = m.index + raw.length; } if (last < text.length) out.push(...highlightMentions(text.slice(last), memberNames)); return out; } /** Tokenize one segment that is known to contain no code, recursing so styles can nest. */ function inline(text: string, memberNames: string[], keyed: () => string): ReactNode[] { const span = firstSpan(text); if (!span) return plain(text, memberNames, keyed); const { start, end, rule, inner } = span; const Tag = rule.tag; return [ ...(start > 0 ? inline(text.slice(0, start), memberNames, keyed) : []), {inline(inner, memberNames, keyed)}, ...(end < text.length ? inline(text.slice(end), memberNames, keyed) : []), ]; } /** * Render message text as a safe ReactNode tree: code first (its contents stay literal), then * nested bold/italic/strike, then links and @mentions. */ export function renderRichText(text: string, memberNames: string[] = []): ReactNode[] { let n = 0; const keyed = (): string => `rt${n++}`; const out: ReactNode[] = []; // ```block``` or `inline` — matched together so the longer fence wins. const CODE_RE = /```([\s\S]+?)```|`([^`\n]+)`/g; let last = 0; let m: RegExpExecArray | null; while ((m = CODE_RE.exec(text)) !== null) { if (m.index > last) out.push(...inline(text.slice(last, m.index), memberNames, keyed)); if (m[1] !== undefined) { out.push(
          {m[1].replace(/^\n/, '')}
        
, ); } else { out.push( {m[2]} , ); } last = m.index + m[0].length; } if (last < text.length) out.push(...inline(text.slice(last), memberNames, keyed)); return out.length > 0 ? out : [text]; }