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]