diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index bec5dfdac..a9a46c905 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -15,6 +15,7 @@ import ( "time" "github.com/gomarkdown/markdown" + mdast "github.com/gomarkdown/markdown/ast" mdhtml "github.com/gomarkdown/markdown/html" "github.com/gomarkdown/markdown/parser" "maunium.net/go/mautrix" @@ -41,6 +42,36 @@ const ( var matrixMentionHrefRegexp = regexp.MustCompile(`(?i)]+href=["']([^"']+)["']`) +// matrixHTMLRenderer wraps the standard gomarkdown HTML renderer and suppresses

+// wrappers throughout the document. Matrix clients apply default browser paragraph +// margins to

elements, which stack with the margins of surrounding block elements +// (lists, code blocks, headings) and produce excessive vertical spacing. Instead we +// emit a single
between consecutive paragraphs for visual separation; adjacent +// block-level elements already carry their own CSS margin. +type matrixHTMLRenderer struct { + *mdhtml.Renderer +} + +func (r *matrixHTMLRenderer) RenderNode(w io.Writer, node mdast.Node, entering bool) mdast.WalkStatus { + if _, ok := node.(*mdast.Paragraph); ok { + if !entering { + // Between consecutive paragraphs emit a line break for separation. + // Before block siblings (lists, headings, code blocks) their own margin suffices. + siblings := node.GetParent().GetChildren() + for i, child := range siblings { + if child == node && i+1 < len(siblings) { + if _, ok := siblings[i+1].(*mdast.Paragraph); ok { + io.WriteString(w, "
") //nolint:errcheck + } + break + } + } + } + return mdast.GoToNext + } + return r.Renderer.RenderNode(w, node, entering) +} + type roomKindCacheEntry struct { isGroup bool expiresAt time.Time @@ -273,8 +304,9 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { } func markdownToHTML(md string) string { - p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs) - renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags}) + extensions := (parser.CommonExtensions | parser.AutoHeadingIDs) &^ parser.DefinitionLists + p := parser.NewWithExtensions(extensions) + renderer := &matrixHTMLRenderer{mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags})} return strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer))) } diff --git a/pkg/channels/matrix/matrix_test.go b/pkg/channels/matrix/matrix_test.go index 07a35c021..e188922ea 100644 --- a/pkg/channels/matrix/matrix_test.go +++ b/pkg/channels/matrix/matrix_test.go @@ -340,23 +340,82 @@ func TestMatrixOutboundContent(t *testing.T) { } func TestMarkdownToHTML(t *testing.T) { - tests := []struct { + cases := []struct { name string - input string - contains string + md string + rendered string }{ - {"bold", "**hello**", "hello"}, - {"italic", "_world_", "world"}, - {"header", "### Title", ""}, - {"inline code", "`x`", "x"}, - {"plain text", "just text", "just text"}, + { + name: "bold", + md: "**hello**", + rendered: `hello`, + }, + { + name: "italic", + md: "_world_", + rendered: `world`, + }, + { + name: "heading", + md: "### Title", + rendered: `

Title

`, + }, + { + name: "fenced code block", + md: "```\nfoo()\n```", + rendered: "
foo()\n
", + }, + { + name: "inline code", + md: "`x`", + rendered: `x`, + }, + { + name: "plain text has no block wrapper", + md: "just text", + rendered: `just text`, + }, + { + name: "loose list has no

wrapper around items", + md: "- Item one\n\n- Item two\n", + rendered: `

`, + }, + { + name: "list item with nested sublist has no

wrapper", + md: "1. Steps overview:\n\n - Step A\n - Step B\n", + rendered: `

    +
  1. Steps overview: +
      +
    • Step A
    • +
    • Step B
    • +
  2. +
`, + }, + { + name: "tight list has no

wrapper", + md: "- Alpha\n- Beta\n", + rendered: `

`, + }, + { + name: "paragraph before list has no

wrapper", + md: "Introduction text.\n\n- Point one\n", + rendered: `Introduction text.

`, + }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := markdownToHTML(tt.input) - if !strings.Contains(got, tt.contains) { - t.Fatalf("markdownToHTML(%q) = %q, want it to contain %q", tt.input, got, tt.contains) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := markdownToHTML(tc.md); got != tc.rendered { + t.Fatalf("markdownToHTML(%q)\n got: %q\nwant: %q", tc.md, got, tc.rendered) } }) }