/** * Renders the light formatting the admin message fields allow: * * *bold* _italic_ __underline__ ~strike~ [label](https://…) * * plus bare http(s) URLs. The rules mirror pkg/richtext in the BFF, which * renders the same markers into email — so the preview shown here is what * the recipient's inbox gets. * * Output is React nodes, never an HTML string: React escapes all text, so * author input cannot become markup, and links are limited to http(s). */ import type { ReactNode } from "react"; const LINK_RE = /\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<]+)/g; const TRAILING_PUNCT = /[.,;:!?)]+$/; type Marker = { re: RegExp; wrap: (n: ReactNode, k: string) => ReactNode }; // Order matters: __underline__ before _italic_, so the double underscore // isn't read as two italic markers. const MARKERS: Marker[] = [ { re: /__([^_\n]+?)__/, wrap: (n, k) => {n} }, { re: /\*([^*\n]+?)\*/, wrap: (n, k) => {n} }, { re: /~([^~\n]+?)~/, wrap: (n, k) => {n} }, { re: /_([^_\n]+?)_/, wrap: (n, k) => {n} }, ]; const isWord = (c: string | undefined) => !!c && /\w/.test(c); /** Markers within a run of text that contains no links. */ function renderMarkers(text: string, keyBase: string): ReactNode[] { // Earliest match across all markers wins, so nesting resolves outside-in. let best: { idx: number; len: number; inner: string; m: Marker } | null = null; for (const m of MARKERS) { const re = new RegExp(m.re.source, "g"); let hit: RegExpExecArray | null; while ((hit = re.exec(text))) { // Italic needs a non-word character either side, so snake_case and // handles keep their underscores. if (m.re.source.startsWith("_(")) { const before = text[hit.index - 1]; const after = text[hit.index + hit[0].length]; if (isWord(before) || isWord(after)) continue; } if (!best || hit.index < best.idx) { best = { idx: hit.index, len: hit[0].length, inner: hit[1], m }; } break; } } if (!best) return text ? [text] : []; const before = text.slice(0, best.idx); const after = text.slice(best.idx + best.len); const k = `${keyBase}-${best.idx}`; return [ ...(before ? [before] : []), best.m.wrap(renderMarkers(best.inner, `${k}i`), k), ...renderMarkers(after, `${k}a`), ]; } /** Renders formatted text, newlines included. */ export function RichText({ text }: { text: string }) { const lines = text.replace(/\r\n/g, "\n").split("\n"); return ( <> {lines.map((line, li) => { const out: ReactNode[] = []; let last = 0; for (const m of line.matchAll(LINK_RE)) { const idx = m.index ?? 0; out.push(...renderMarkers(line.slice(last, idx), `${li}-${last}`)); let href = m[2] ?? m[3]; let label = m[1] ?? href; let tail = ""; if (!m[1]) { // Sentence punctuation after a bare URL isn't part of it. tail = href.match(TRAILING_PUNCT)?.[0] ?? ""; href = href.slice(0, href.length - tail.length); label = href; } out.push( {label} , ); if (tail) out.push(tail); last = idx + m[0].length; } out.push(...renderMarkers(line.slice(last), `${li}-${last}`)); return ( {li > 0 &&
} {out}
); })} ); }