/** * Streaming markdown table stabilizer (Telegram-style space reservation). * * During SSE streaming the full accumulated markdown is re-parsed by `marked` * on every chunk. For GFM tables that produces two jarring jolts: * * 1. The paragraph→table flip. GFM only recognizes a table once the *delimiter* * row (`| --- | --- |`) has arrived, so a freshly-streamed header line first * renders as a plain paragraph and then snaps into a ``. * 2. Partial-row flicker. The in-flight last row grows cell-by-cell. * * This module rewrites table-in-progress regions so a real `
` renders * from the first row onward with a stable column count: it completes the * delimiter row as soon as it starts streaming and pads the trailing partial * row to the header's column count. Combined with `table-layout: fixed` while * streaming (see `.persona-content-streaming table` in widget.css), columns lock * to even widths so rows append vertically without horizontal reflow. * * It runs ONLY while a message is streaming; the final render uses the real, * untouched `marked` output, so correctness is never affected. */ /** * A GFM delimiter row, full or still streaming in: only delimiter characters * (`-`, `:`, `|`, whitespace) and at least one dash. Matches `|`-led partials * like `| -`, `|--`, `| :--`, and complete rows like `| --- | :--: |`. */ const DELIMITER_RE = /^\s*\|?[\s:|-]*-[\s:|-]*$/; /** A candidate table row contains at least one pipe. */ const hasPipe = (line: string): boolean => line.includes("|"); /** Split a markdown table row into trimmed cell strings, ignoring outer pipes. */ const splitCells = (line: string): string[] => { let s = line.trim(); if (s.startsWith("|")) s = s.slice(1); if (s.endsWith("|")) s = s.slice(0, -1); return s.split("|").map((cell) => cell.trim()); }; /** Render cells back into a normalized, pipe-delimited row. */ const buildRow = (cells: string[]): string => `| ${cells.join(" | ")} |`; /** Build a complete delimiter row with the given column count. */ const buildDelimiter = (cols: number): string => `| ${Array.from({ length: cols }, () => "---").join(" | ")} |`; /** Pad (or trim) a row's cells to exactly `cols` columns. */ const fitCells = (cells: string[], cols: number): string[] => { if (cells.length >= cols) return cells.slice(0, cols); return cells.concat(Array.from({ length: cols - cells.length }, () => "")); }; /** * Normalize any streaming-in-progress GFM tables in `markdown` so they render as * complete tables with a stable column count. Returns the input unchanged when * there is nothing to stabilize (cheap fast-path for the common no-table case). */ export const stabilizeStreamingTables = (markdown: string): string => { if (!markdown || !markdown.includes("|")) return markdown; const lines = markdown.split("\n"); let changed = false; for (let i = 0; i < lines.length - 1; i++) { const header = lines[i]; const delimiter = lines[i + 1]; // A table starts at a header line (has a pipe, is not itself a delimiter) // immediately followed by a delimiter row that is full or still streaming. if (!hasPipe(header) || DELIMITER_RE.test(header)) continue; if (!DELIMITER_RE.test(delimiter)) continue; const cols = splitCells(header).length; if (cols < 1) continue; // Complete the delimiter to match the header's column count so `marked` // recognizes the table immediately instead of waiting for it to finish. const fullDelimiter = buildDelimiter(cols); if (lines[i + 1] !== fullDelimiter) { lines[i + 1] = fullDelimiter; changed = true; } // Normalize body rows (including a partial trailing one) to `cols` columns // so each row occupies its slot instead of growing cell-by-cell. The region // ends at the first blank or pipe-less line. let j = i + 2; for (; j < lines.length; j++) { const row = lines[j]; if (row.trim() === "" || !hasPipe(row)) break; const normalized = buildRow(fitCells(splitCells(row), cols)); if (lines[j] !== normalized) { lines[j] = normalized; changed = true; } } i = j - 1; // resume scanning after this table region } return changed ? lines.join("\n") : markdown; };