diff --git a/static/ui.js b/static/ui.js index 66061b57..595f0b59 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7785,13 +7785,32 @@ function renderMd(raw){ s=s.replace(/([\s\S]*?)<\/em>/gi,(_,t)=>_emphasis(t)); s=s.replace(/([\s\S]*?)<\/i>/gi,(_,t)=>_emphasis(t)); s=s.replace(/([^<]*?)<\/code>/gi,(_,t)=>'`'+t+'`'); - // Convert
to a newline ONLY outside markdown table rows — inside a row a - // newline would split the row and destroy the table. No sentinel token is used - // here on purpose: any fixed placeholder is attacker-suppliable in message text - // and would be rewritten on the way out. - s=s.split('\n').map(line=>( - /^\s*\|.*\|\s*$/.test(line) ? line : line.replace(//gi,'\n') - )).join('\n'); + // Convert
to a newline, EXCEPT inside genuine markdown table rows — there a + // newline would split the row and destroy the table. No sentinel token is used on + // purpose: any fixed placeholder is attacker-suppliable in message text and would be + // rewritten on the way out. + // The "is this a table row" test must match the DOWNSTREAM table parser's grammar + // exactly (a run of pipe-lines whose SECOND line is a separator). A looser per-line + // test would treat pipe-wrapped prose like `| note
# heading |` as a table row and + // silently strip its heading/list rendering. + { + const _rowRe=/^ {0,3}\|.+\|[ \t]*$/; + const _sepRe=/^\|[\s|:-]+\|$/; + const _lines=s.split('\n'); + const _isTableRow=new Array(_lines.length).fill(false); + for(let i=0;i<_lines.length;){ + if(!_rowRe.test(_lines[i])){ i++; continue; } + let j=i; + while(j<_lines.length && _rowRe.test(_lines[j])) j++; + // A block qualifies only if the parser would accept it: >=2 rows and a separator + // in the second position. + if(j-i>=2 && _sepRe.test(_lines[i+1].trim())){ + for(let k=i;k_isTableRow[i]?line:line.replace(//gi,'\n')).join('\n'); + } // ── Glued-bold-heading lift (issue #1446) ──────────────────────────────── // LLMs in thinking/reasoning mode frequently emit a "section header" glued // to the end of the previous paragraph with no whitespace, like: diff --git a/tests/test_renderer_js_behaviour.py b/tests/test_renderer_js_behaviour.py index 0793fbaa..80bfecda 100644 --- a/tests/test_renderer_js_behaviour.py +++ b/tests/test_renderer_js_behaviour.py @@ -982,3 +982,35 @@ class TestRendererGateRegressions7618: out = _render(driver_path, "2 * 3 * 4 = 24") assert "" not in out assert "2 * 3 * 4 = 24" in out + + def test_pipe_wrapped_prose_keeps_heading_rendering(self, driver_path): + """`| note
# heading |` is NOT a table — it must keep heading rendering. + + The table-row guard must use the same grammar as the downstream table + parser (a pipe-line run whose SECOND line is a separator). A naive + per-line "looks pipe-wrapped" test silently stripped heading/list + rendering from pipe-delimited prose. + """ + out = _render(driver_path, "| prose
# heading |") + assert "

" in out + + def test_pipe_wrapped_prose_keeps_list_rendering(self, driver_path): + out = _render(driver_path, "| prose
- item |") + assert "
    " in out and "
  • " in out + + def test_pipe_rows_without_separator_are_not_treated_as_table(self, driver_path): + """Two pipe lines with no separator row are prose, so
    still converts.""" + out = _render(driver_path, "| a | b |\n| c | d |") + assert "" not in out + + def test_multi_row_table_preserves_br_in_every_data_row(self, driver_path): + src = ( + "| Feature | Notes | Status |\n" + "|---|---|---|\n" + "| Auth | OAuth
    API keys
    tokens | shipped |\n" + "| Cache | LRU
    60s TTL | in review |" + ) + out = _render(driver_path, src) + assert out.count("") == 3 + assert "OAuth
    API keys
    tokens" in out + assert "LRU
    60s TTL" in out