fix(renderer): match the table-row guard to the real table grammar

Third gate finding: the table-row predicate accepted ANY pipe-wrapped line, but
the downstream table parser only accepts a run of pipe-lines whose SECOND line
is a separator. So pipe-delimited prose such as '| note<br># heading |' was
treated as a table row and kept its literal <br>, silently losing heading/list
rendering that master renders correctly.

The guard now classifies complete blocks with the parser's own grammar
(row regex + separator check) and converts <br> on every unclassified line.
Verified: all non-table cases now match master byte-for-byte, while genuine
multi-row tables keep <br> in every data cell.

Co-authored-by: JayaMegelar <JayaMegelar@users.noreply.github.com>
This commit is contained in:
nandJayaMegelar committed 2026-09-18 22:33:38 +00:00
1 parent 75d51d5ee2
commit 49d4aab4e2
2 files changed
+58 -7

No files matched your search

+26 -7
View File
@@ -7785,13 +7785,32 @@ function renderMd(raw){
s=s.replace(/<em>([\s\S]*?)<\/em>/gi,(_,t)=>_emphasis(t));
s=s.replace(/<i>([\s\S]*?)<\/i>/gi,(_,t)=>_emphasis(t));
s=s.replace(/<code>([^<]*?)<\/code>/gi,(_,t)=>'`'+t+'`');
// Convert <br> 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(/<br\s*\/?>/gi,'\n')
)).join('\n');
// Convert <br> 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<br># 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<j;k++) _isTableRow[k]=true;
}
i=j;
}
s=_lines.map((line,i)=>_isTableRow[i]?line:line.replace(/<br\s*\/?>/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:
+32
View File
@@ -982,3 +982,35 @@ class TestRendererGateRegressions7618:
out = _render(driver_path, "2 * 3 * 4 = 24")
assert "<em>" not in out
assert "2 * 3 * 4 = 24" in out
def test_pipe_wrapped_prose_keeps_heading_rendering(self, driver_path):
"""`| note<br># 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<br># heading |")
assert "<h1>" in out
def test_pipe_wrapped_prose_keeps_list_rendering(self, driver_path):
out = _render(driver_path, "| prose<br>- item |")
assert "<ul>" in out and "<li>" 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 <br> still converts."""
out = _render(driver_path, "| a | b |\n| c | d |")
assert "<table>" 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<br>API keys<br>tokens | shipped |\n"
"| Cache | LRU<br>60s TTL | in review |"
)
out = _render(driver_path, src)
assert out.count("<tr>") == 3
assert "OAuth<br>API keys<br>tokens" in out
assert "LRU<br>60s TTL" in out