/** * Pure, DOM-free WhatsApp markup helpers. * * WhatsApp messages are plain text with lightweight inline markers — *not* HTML. * The string the user types (and that you send to the WhatsApp API) IS the * source of truth. These helpers only: * * - `toPreviewHTML(text)` — render a faithful preview of how WhatsApp displays * the markers (bold/italic/strikethrough/monospace, quotes, lists, breaks). * - `toggleMarker(value, start, end, marker)` — wrap/unwrap a textarea selection * with an inline marker (powers the toolbar + keyboard shortcuts). * * Supported syntax (per WhatsApp's "How to format your messages"): * - `*bold*` → * - `_italic_` → * - `~strikethrough~` → * - `` `inline mono` `` → * - ```` ```block``` ```` →

 * - `> quote`           → 
* - `- ` / `* ` bullet →
  • * - `1. ` numbered →
    1. */ /** Inline marker identifiers exposed to the toolbar. */ export type WhatsappMarker = 'bold' | 'italic' | 'strikethrough' | 'monospace'; /** Line-prefix block identifiers exposed to the toolbar. */ export type WhatsappBlock = 'blockquote' | 'bullet' | 'ordered'; /** The literal characters each inline marker wraps the selection with. */ export const WHATSAPP_MARKERS: Record = { bold: '*', italic: '_', strikethrough: '~', monospace: '`', }; /** Result of a {@link toggleMarker} operation. */ export interface WrapResult { /** The new full text value. */ value: string; /** New selection start, so the caller can restore the textarea selection. */ selectionStart: number; /** New selection end. */ selectionEnd: number; } const NUL = '\u0000'; function escapeHtml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } /** * Apply inline formatting (bold, italic, strikethrough) to already * HTML-escaped text. Markers must hug non-space content, mirroring WhatsApp * (e.g. `* not bold *` stays literal). Markers nest: `*_x_*` → bold+italic. */ function applyInline(escaped: string): string { return escaped .replace(/\*([^\s*][^*]*?[^\s*]|[^\s*])\*/g, '$1') .replace(/_([^\s_][^_]*?[^\s_]|[^\s_])_/g, '$1') .replace(/~([^\s~][^~]*?[^\s~]|[^\s~])~/g, '$1'); } /** * Render WhatsApp markup to preview HTML. Output is safe: all user text is * HTML-escaped before markers are interpreted. */ export function toPreviewHTML(text: string): string { if (!text) { return ''; } // 1. Protect code spans so their contents are never treated as markup. const codes: string[] = []; const protect = (raw: string, wrap: (inner: string) => string): string => { const token = `${NUL}${codes.length}${NUL}`; codes.push(wrap(escapeHtml(raw))); return token; }; const working = text // Fenced ```block``` (may span multiple lines). .replace(/```\n?([\s\S]*?)\n?```/g, (_m, inner: string) => protect(inner, (h) => `
      ${h}
      `), ) // Inline `mono`. .replace(/`([^`\n]+?)`/g, (_m, inner: string) => protect(inner, (h) => `${h}`)); // 2. Line-based block parsing (quotes + lists), inline formatting per line. const lines = working.split('\n'); const out: string[] = []; let listType: 'ul' | 'ol' | null = null; const closeList = (): void => { if (listType) { out.push(``); listType = null; } }; const isToken = (line: string): boolean => new RegExp(`^${NUL}\\d+${NUL}$`).test(line.trim()); for (const line of lines) { // A protected code block on its own line: emit as-is. if (isToken(line)) { closeList(); out.push(line.trim()); continue; } const bullet = /^[*-] (.+)$/.exec(line); const numbered = /^\d+\. (.+)$/.exec(line); const quote = /^> ?(.*)$/.exec(line); if (bullet) { if (listType !== 'ul') { closeList(); out.push('
        '); listType = 'ul'; } out.push(`
      • ${applyInline(escapeHtml(bullet[1]))}
      • `); } else if (numbered) { if (listType !== 'ol') { closeList(); out.push('
          '); listType = 'ol'; } out.push(`
        1. ${applyInline(escapeHtml(numbered[1]))}
        2. `); } else if (quote) { closeList(); out.push(`
          ${applyInline(escapeHtml(quote[1]))}
          `); } else { closeList(); out.push(applyInline(escapeHtml(line))); } } closeList(); // 3. Join lines with
          , but never insert a break around block elements. let html = out .map((chunk, i) => { if (i === 0) { return chunk; } const prev = out[i - 1]; const blockBoundary = /(<\/(?:ul|ol|li|blockquote|pre)>|<(?:ul|ol|blockquote|pre)>)$/.test(prev) || /^(<\/(?:ul|ol)>|<(?:ul|ol|li|blockquote|pre))/.test(chunk); return (blockBoundary ? '' : '
          ') + chunk; }) .join(''); // 4. Restore protected code spans. html = html.replace(new RegExp(`${NUL}(\\d+)${NUL}`, 'g'), (_m, i: string) => codes[Number(i)]); return html; } /** * Toggle an inline marker around a textarea selection. If the selection (or the * characters immediately surrounding it) is already wrapped with `marker`, it is * unwrapped; otherwise the selection is wrapped. Returns the new value and the * selection range to restore. */ export function toggleMarker( value: string, start: number, end: number, marker: string, ): WrapResult { const m = marker.length; const selected = value.slice(start, end); // Case A: selection itself is wrapped (e.g. "*bold*" selected). if ( selected.length >= 2 * m && selected.startsWith(marker) && selected.endsWith(marker) ) { const inner = selected.slice(m, selected.length - m); return { value: value.slice(0, start) + inner + value.slice(end), selectionStart: start, selectionEnd: start + inner.length, }; } // Case B: markers sit just outside the selection (e.g. *|bold|*). if (value.slice(start - m, start) === marker && value.slice(end, end + m) === marker) { return { value: value.slice(0, start - m) + selected + value.slice(end + m), selectionStart: start - m, selectionEnd: end - m, }; } // Case C: wrap. If the selection is empty, place the caret between markers. return { value: value.slice(0, start) + marker + selected + marker + value.slice(end), selectionStart: start + m, selectionEnd: end + m, }; } /** * Whether the current selection is already wrapped with `marker` — true when the * selection itself includes the markers (`*bold*` selected) or when they sit * immediately outside it (`*|bold|*`). Mirrors {@link toggleMarker}'s unwrap * cases so a toolbar can reflect the active state of a button. Pure. */ export function isMarkerActive(value: string, start: number, end: number, marker: string): boolean { if (end <= start) { return false; } const m = marker.length; const selected = value.slice(start, end); // Case A: the selection itself is wrapped. if (selected.length >= 2 * m && selected.startsWith(marker) && selected.endsWith(marker)) { return true; } // Case B: markers sit just outside the selection. return value.slice(start - m, start) === marker && value.slice(end, end + m) === marker; } /** Regex that detects (and strips) each block's line prefix. */ const BLOCK_PREFIX_RE: Record = { blockquote: /^> /, bullet: /^[-*] /, ordered: /^\d+\. /, }; /** Matches any known block prefix, so switching families never stacks them. */ const ANY_BLOCK_PREFIX = /^(?:> |[-*] |\d+\. )/; /** * Toggle a line-prefix block (blockquote, bullet or numbered list) across every * line spanned by the selection. If all non-empty lines already carry the * prefix it is removed; otherwise it is added (numbered lists are renumbered * from 1). Returns the new value with the selection spanning the affected block. */ export function toggleLinePrefix( value: string, start: number, end: number, kind: WhatsappBlock, ): WrapResult { const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1; let lineEnd = value.indexOf('\n', end); if (lineEnd === -1) { lineEnd = value.length; } const block = value.slice(lineStart, lineEnd); const lines = block.split('\n'); const re = BLOCK_PREFIX_RE[kind]; const nonEmpty = lines.filter((l) => l.trim().length > 0); const allHave = nonEmpty.length > 0 && nonEmpty.every((l) => re.test(l)); const newLines = lines.map((line, i) => { if (allHave) { return line.replace(re, ''); } // Strip any existing block prefix (any family) first, then add the new one. const bare = line.replace(ANY_BLOCK_PREFIX, ''); if (kind === 'ordered') { return `${i + 1}. ${bare}`; } return kind === 'bullet' ? `- ${bare}` : `> ${bare}`; }); const newBlock = newLines.join('\n'); return { value: value.slice(0, lineStart) + newBlock + value.slice(lineEnd), selectionStart: lineStart, selectionEnd: lineStart + newBlock.length, }; } /** * Strip every WhatsApp marker from `text`, returning the plain underlying * content: removes inline bold/italic/strikethrough/monospace (including nested * combinations), fenced code blocks, and line-prefix blocks (quote/bullet/ * numbered). Pure and idempotent. */ export function stripFormatting(text: string): string { // Fenced code first, so its inner content survives the inline passes. let out = text.replace(/```\n?([\s\S]*?)\n?```/g, '$1'); // Repeatedly unwrap inline markers until stable (handles nesting like *_x_*). let prev: string; do { prev = out; out = out .replace(/\*([^\s*][^*]*?[^\s*]|[^\s*])\*/g, '$1') .replace(/_([^\s_][^_]*?[^\s_]|[^\s_])_/g, '$1') .replace(/~([^\s~][^~]*?[^\s~]|[^\s~])~/g, '$1') .replace(/`([^`\n]+?)`/g, '$1'); } while (out !== prev); // Drop line-prefix blocks (quote / bullet / numbered). return out .split('\n') .map((line) => line.replace(ANY_BLOCK_PREFIX, '')) .join('\n'); }