From f65f4886357ac0c363d5d393569d060c1c9c28eb Mon Sep 17 00:00:00 2001 From: Brian Date: Wed, 29 Apr 2026 23:15:59 +0800 Subject: [PATCH] fix(renderer): render h4-h6 markdown headings (####, #####, ######) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-stream renderMd() in static/ui.js only handled #, ##, ### — lines starting with #### through ###### fell through and emitted as literal text after streaming finalized. Extend the heading replacer chain to cover h4-h6, ordered longest-first, so ###### cannot be partially captured by the shorter ### rule. Add the matching .msg-body h4/h5/h6 CSS rules (and data-font-size variants) so the new tags inherit the same visual rhythm as h1-h3. Adds 3 node-driven tests in test_renderer_js_behaviour.py pinning all six heading levels and the longest-first replacer order. Closes #1258 --- static/style.css | 9 ++++++++- static/ui.js | 2 +- tests/test_renderer_js_behaviour.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/static/style.css b/static/style.css index cb037168..2b0b017a 100644 --- a/static/style.css +++ b/static/style.css @@ -35,6 +35,12 @@ :root[data-font-size="large"] .msg-body h2 { font-size: 19px; } :root[data-font-size="small"] .msg-body h3 { font-size: 12px; } :root[data-font-size="large"] .msg-body h3 { font-size: 16px; } + :root[data-font-size="small"] .msg-body h4 { font-size: 11px; } + :root[data-font-size="large"] .msg-body h4 { font-size: 15px; } + :root[data-font-size="small"] .msg-body h5 { font-size: 11px; } + :root[data-font-size="large"] .msg-body h5 { font-size: 14px; } + :root[data-font-size="small"] .msg-body h6 { font-size: 10px; } + :root[data-font-size="large"] .msg-body h6 { font-size: 13px; } :root[data-font-size="small"] .msg-body code { font-size: 10.5px; } :root[data-font-size="large"] .msg-body code { font-size: 14.5px; } :root[data-font-size="small"] .msg-body pre code { font-size: 11px; } @@ -602,8 +608,9 @@ .msg-body{font-size:14px;line-height:1.75;color:var(--text);padding-left:30px;max-width:680px;overflow-wrap:anywhere;} .msg-body p{margin-bottom:10px;}.msg-body p:last-child{margin-bottom:0;} .msg-body ul,.msg-body ol{margin:6px 0 10px 20px;}.msg-body li{margin-bottom:3px;} - .msg-body h1,.msg-body h2,.msg-body h3{margin:16px 0 6px;font-weight:600;} + .msg-body h1,.msg-body h2,.msg-body h3,.msg-body h4,.msg-body h5,.msg-body h6{margin:16px 0 6px;font-weight:600;} .msg-body h1{font-size:18px;}.msg-body h2{font-size:16px;}.msg-body h3{font-size:14px;} + .msg-body h4{font-size:13px;}.msg-body h5{font-size:12px;}.msg-body h6{font-size:11px;color:var(--muted);} .msg-body strong{color:var(--strong);font-weight:600;}.msg-body em{color:var(--em);font-style:italic;} .msg-body code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12.5px;background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);} .msg-body pre{background:var(--code-bg);border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;margin:10px 0;} diff --git a/static/ui.js b/static/ui.js index 7513ea37..e9351820 100644 --- a/static/ui.js +++ b/static/ui.js @@ -966,7 +966,7 @@ function renderMd(raw){ s=s.replace(/\*([^*\n]+)\*/g,(_,t)=>`${esc(t)}`); s=s.replace(/~~(.+?)~~/g,(_,t)=>`${esc(t)}`); s=s.replace(/\x00O(\d+)\x00/g,(_,i)=>_ob_stash[+i]); - s=s.replace(/^### (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`).replace(/^## (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`).replace(/^# (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`); + s=s.replace(/^###### (.+)$/gm,(_,t)=>`
${inlineMd(t)}
`).replace(/^##### (.+)$/gm,(_,t)=>`
${inlineMd(t)}
`).replace(/^#### (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`).replace(/^### (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`).replace(/^## (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`).replace(/^# (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`); s=s.replace(/^---+$/gm,'
'); // (Blockquotes are handled by the pre-pass at the top of renderMd, before // fence_stash. The per-line passes below never see > prefixes.) diff --git a/tests/test_renderer_js_behaviour.py b/tests/test_renderer_js_behaviour.py index ecd558f1..c12fe624 100644 --- a/tests/test_renderer_js_behaviour.py +++ b/tests/test_renderer_js_behaviour.py @@ -525,3 +525,32 @@ class TestRawPreCodePreservation: f" content inside
 must not be rewritten to backticks: {out!r}"
         )
         assert "After paragraph." in out and "Done." in out
+
+
+class TestHeadingLevelsH1ThroughH6:
+    """Issue #1258 — `####`, `#####`, `######` previously fell through the
+    heading pass and emitted as literal text starting with `#`.  Pin all six
+    levels so a future edit cannot silently regress h4–h6 again."""
+
+    def test_all_six_heading_levels_render(self, driver_path):
+        src = "# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6"
+        out = _render(driver_path, src)
+        assert "

H1

" in out, f"h1 missing: {out!r}" + assert "

H2

" in out, f"h2 missing: {out!r}" + assert "

H3

" in out, f"h3 missing: {out!r}" + assert "

H4

" in out, f"h4 missing: {out!r}" + assert "
H5
" in out, f"h5 missing: {out!r}" + assert "
H6
" in out, f"h6 missing: {out!r}" + + def test_h6_does_not_partial_match_as_lower_level(self, driver_path): + """Replacers must run longest-first; otherwise `###### H6` could be + captured by the `^### ` rule and emit `

### H6

`.""" + out = _render(driver_path, "###### H6") + assert "
H6
" in out, f"h6 must not be partial-matched: {out!r}" + assert "

" not in out and "###" not in out + + def test_h4_inline_markdown_still_processes(self, driver_path): + out = _render(driver_path, "#### **bold** in h4") + assert "

bold in h4

" in out, ( + f"inline markdown inside h4 must still render: {out!r}" + )