From 9f0274417d334dde422ddd9f6c7825b15b33a950 Mon Sep 17 00:00:00 2001 From: dave Date: Thu, 16 Apr 2026 08:18:22 +0000 Subject: [PATCH] huskies: merge 579_bug_matrix_bot_messages_render_markdown_headings_without_line_breaks_or_formatting --- .../src/chat/transport/matrix/bot/format.rs | 43 ++++++++++++++ server/src/chat/util.rs | 56 +++++++++++++++++-- 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/server/src/chat/transport/matrix/bot/format.rs b/server/src/chat/transport/matrix/bot/format.rs index 44fbf4ab..b3ec969d 100644 --- a/server/src/chat/transport/matrix/bot/format.rs +++ b/server/src/chat/transport/matrix/bot/format.rs @@ -96,6 +96,49 @@ mod tests { ); } + #[test] + fn markdown_to_html_heading_renders_as_h_tag() { + let html = markdown_to_html("## Section\nContent here."); + assert!( + html.contains("

Section

"), + "expected

heading tag: {html}" + ); + assert!( + html.contains("

Content here.

"), + "expected paragraph after heading: {html}" + ); + } + + #[test] + fn markdown_to_html_heading_with_preceding_prose_renders_correctly() { + let html = markdown_to_html("Intro text.\n## Section\nBody."); + assert!( + html.contains("

Section

"), + "expected

heading tag: {html}" + ); + assert!( + html.contains("

Intro text.

"), + "expected intro paragraph: {html}" + ); + assert!( + html.contains("

Body.

"), + "expected body paragraph: {html}" + ); + } + + #[test] + fn markdown_to_html_multiple_headings_each_render_as_h_tags() { + let html = markdown_to_html("## Section 1\nContent one.\n\n## Section 2\nContent two."); + assert!( + html.contains("

Section 1

"), + "expected first

: {html}" + ); + assert!( + html.contains("

Section 2

"), + "expected second

: {html}" + ); + } + #[test] fn startup_announcement_uses_bot_name() { assert_eq!(format_startup_announcement("Timmy"), "Timmy is online."); diff --git a/server/src/chat/util.rs b/server/src/chat/util.rs index e34c913a..3d1ff245 100644 --- a/server/src/chat/util.rs +++ b/server/src/chat/util.rs @@ -223,12 +223,24 @@ pub fn normalize_line_breaks(text: &str) -> String { let prev_line = lines[i - 1]; - // Insert a blank separator when both the current and previous lines - // are non-empty prose (not inside a code fence, not structured Markdown). + // ATX headings (lines starting with one or more `#` characters) always + // need a blank line before and after them so that Matrix clients render + // the heading with visual separation. Without a blank line, a single + // newline between a heading and adjacent text is swallowed by many + // Matrix clients (including Element X), joining the heading text and + // the following content on the same line without any heading formatting. + let is_cur_heading = line.trim_start().starts_with('#'); + let is_prev_heading = prev_line.trim_start().starts_with('#'); + + // Insert a blank separator when: + // 1. Both lines are non-empty prose (standard prose-to-prose rule). + // 2. The current line is an ATX heading (adds blank line *before* it). + // 3. The previous line was an ATX heading (adds blank line *after* it). let should_double = !line.is_empty() && !prev_line.is_empty() - && !is_structured_line(line) - && !is_structured_line(prev_line); + && ((!is_structured_line(line) && !is_structured_line(prev_line)) + || is_cur_heading + || is_prev_heading); if should_double { result.push(""); @@ -599,10 +611,42 @@ mod tests { } #[test] - fn normalize_heading_single_newline_preserved() { + fn normalize_heading_followed_by_prose_gets_blank_line() { + // A blank line must be inserted after a heading so Matrix clients render + // the heading with visual separation from the following paragraph. let input = "# My Heading\nSome text below."; let output = normalize_line_breaks(input); - assert_eq!(output, "# My Heading\nSome text below."); + assert_eq!(output, "# My Heading\n\nSome text below."); + } + + #[test] + fn normalize_prose_before_heading_gets_blank_line() { + // A blank line must be inserted before a heading when prose precedes it. + let input = "Some intro text.\n## Section"; + let output = normalize_line_breaks(input); + assert_eq!(output, "Some intro text.\n\n## Section"); + } + + #[test] + fn normalize_heading_surrounded_by_prose_gets_blank_lines_both_sides() { + let input = "Intro.\n## Heading\nContent."; + let output = normalize_line_breaks(input); + assert_eq!(output, "Intro.\n\n## Heading\n\nContent."); + } + + #[test] + fn normalize_consecutive_headings_separated_by_blank_lines() { + let input = "## Section 1\n## Section 2"; + let output = normalize_line_breaks(input); + assert_eq!(output, "## Section 1\n\n## Section 2"); + } + + #[test] + fn normalize_heading_already_separated_by_blank_line_unchanged() { + // When there is already a blank line, no extra blank is inserted. + let input = "# Heading\n\nContent."; + let output = normalize_line_breaks(input); + assert_eq!(output, "# Heading\n\nContent."); } #[test]