From 26c892b476b3bb5fdcce69a332e106795ba4aabe Mon Sep 17 00:00:00 2001 From: lawrence3699 Date: Sun, 12 Apr 2026 21:26:18 +1000 Subject: [PATCH 1/2] fix: coalesce duplicate class attributes in CodeBlock and Heading When a CodeBlock or Heading node has both a language/special class and a custom class attribute set via the AST, the renderer produced two separate class="..." attributes in the HTML output. Browsers only use the first one, so the custom class was silently dropped. Add coalesceClassAttrs helper that merges multiple class attributes into a single one, and apply it in both CodeBlock and HeadingEnter. Fixes #209. --- html/coalesce_test.go | 64 +++++++++++++++++++++++++++++++++++++++++++ html/renderer.go | 29 ++++++++++++++++++-- html_renderer_test.go | 34 +++++++++++++++++++++++ testdata/mmark.test | 2 +- 4 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 html/coalesce_test.go diff --git a/html/coalesce_test.go b/html/coalesce_test.go new file mode 100644 index 00000000..f5b719f6 --- /dev/null +++ b/html/coalesce_test.go @@ -0,0 +1,64 @@ +package html + +import ( + "reflect" + "testing" +) + +func TestCoalesceClassAttrs(t *testing.T) { + tests := []struct { + name string + in []string + want []string + }{ + { + name: "no class attrs", + in: []string{`id="foo"`}, + want: []string{`id="foo"`}, + }, + { + name: "single class attr unchanged", + in: []string{`class="language-go"`}, + want: []string{`class="language-go"`}, + }, + { + name: "two class attrs merged", + in: []string{`class="language-yml"`, `class="my-class"`}, + want: []string{`class="language-yml my-class"`}, + }, + { + name: "class attrs with other attrs preserved", + in: []string{`class="language-go"`, `id="code1"`, `class="highlight"`}, + want: []string{`class="language-go highlight"`, `id="code1"`}, + }, + { + name: "empty input", + in: nil, + want: nil, + }, + { + name: "no attrs at all", + in: []string{}, + want: []string{}, + }, + { + name: "empty class value stripped", + in: []string{`class=""`, `class="my-class"`}, + want: []string{`class="my-class"`}, + }, + { + name: "multi-word class values preserved", + in: []string{`class="foo bar"`, `class="baz"`}, + want: []string{`class="foo bar baz"`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := coalesceClassAttrs(tt.in) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("coalesceClassAttrs(%v) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} diff --git a/html/renderer.go b/html/renderer.go index 8e2c1d1d..7a12501a 100644 --- a/html/renderer.go +++ b/html/renderer.go @@ -659,7 +659,6 @@ func (r *Renderer) MakeUniqueHeadingID(hdr *ast.Heading) string { func (r *Renderer) HeadingEnter(w io.Writer, hdr *ast.Heading) { var attrs []string var class string - // TODO(miek): add helper functions for coalescing these classes. if hdr.IsTitleblock { class = "title" } @@ -680,6 +679,7 @@ func (r *Renderer) HeadingEnter(w io.Writer, hdr *ast.Heading) { attrs = append(attrs, attrID) } attrs = append(attrs, BlockAttrs(hdr)...) + attrs = coalesceClassAttrs(attrs) r.CR(w) r.OutTag(w, HeadingOpenTagFromLevel(hdr.Level), attrs) } @@ -864,10 +864,9 @@ Parse: // CodeBlock writes ast.CodeBlock node func (r *Renderer) CodeBlock(w io.Writer, codeBlock *ast.CodeBlock) { var attrs []string - // TODO(miek): this can add multiple class= attribute, they should be coalesced into one. - // This is probably true for some other elements as well attrs = appendLanguageAttr(attrs, codeBlock.Info) attrs = append(attrs, BlockAttrs(codeBlock)...) + attrs = coalesceClassAttrs(attrs) r.CR(w) r.Outs(w, "
")
@@ -1338,6 +1337,30 @@ func BlockAttrs(node ast.Node) []string {
 	return s
 }
 
+// coalesceClassAttrs merges multiple class="..." attributes into a single one.
+// For example, class="language-go" and class="my-class" become class="language-go my-class".
+func coalesceClassAttrs(attrs []string) []string {
+	const prefix = `class="`
+	var classes []string
+	var other []string
+	for _, a := range attrs {
+		if strings.HasPrefix(a, prefix) && strings.HasSuffix(a, `"`) {
+			// extract the value between class=" and trailing "
+			val := a[len(prefix) : len(a)-1]
+			if val != "" {
+				classes = append(classes, val)
+			}
+		} else {
+			other = append(other, a)
+		}
+	}
+	if len(classes) == 0 {
+		return attrs
+	}
+	merged := fmt.Sprintf(`class="%s"`, strings.Join(classes, " "))
+	return append([]string{merged}, other...)
+}
+
 // TagWithAttributes creates a HTML tag with a given name and attributes
 func TagWithAttributes(name string, attrs []string) string {
 	s := name
diff --git a/html_renderer_test.go b/html_renderer_test.go
index 0936ecd6..f96a858c 100644
--- a/html_renderer_test.go
+++ b/html_renderer_test.go
@@ -70,6 +70,40 @@ func TestTagParagraphCode(t *testing.T) {
 	doTestsParam(t, tests, params)
 }
 
+// TestCodeBlockClassCoalescing verifies that when a code block has both
+// a language annotation and a custom class attribute, they are merged into
+// a single class attribute. See https://github.com/gomarkdown/markdown/issues/209.
+func TestCodeBlockClassCoalescing(t *testing.T) {
+	input := "``` yml\ntext: something\n```\n"
+
+	p := parser.NewWithExtensions(parser.FencedCode)
+	doc := p.Parse([]byte(input))
+
+	// Walk the AST and add a custom class to the CodeBlock node.
+	ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
+		if cb, ok := node.(*ast.CodeBlock); ok {
+			cb.Attribute = &ast.Attribute{
+				Classes: [][]byte{[]byte("my-class")},
+			}
+		}
+		return ast.GoToNext
+	})
+
+	renderer := html.NewRenderer(html.RendererOptions{})
+	got := string(Render(doc, renderer))
+
+	// Before the fix, this produced two separate class= attributes:
+	//   
+	// After the fix, they should be coalesced:
+	//   
+	expected := `
text: something
+
+` + if got != expected { + t.Errorf("CodeBlock class coalescing failed.\nExpected:\n%s\nGot:\n%s", expected, got) + } +} + func TestRenderNodeHookLinkAttrs(t *testing.T) { tests := []string{ `[Click Me](gopher://foo.bar "Click Me")`, diff --git a/testdata/mmark.test b/testdata/mmark.test index 27310691..e460c3c1 100644 --- a/testdata/mmark.test +++ b/testdata/mmark.test @@ -140,7 +140,7 @@ A> This is an aside {.myclass3} .# Preface section +++ -

Preface section

+

Preface section

+++ {.myclass4} A> hello From 0a7155a20dceb7b867b58889d2e9e345f366c434 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Sun, 12 Apr 2026 13:34:48 +0200 Subject: [PATCH 2/2] Update html/renderer.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- html/renderer.go | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/html/renderer.go b/html/renderer.go index 7a12501a..9f335252 100644 --- a/html/renderer.go +++ b/html/renderer.go @@ -1342,23 +1342,37 @@ func BlockAttrs(node ast.Node) []string { func coalesceClassAttrs(attrs []string) []string { const prefix = `class="` var classes []string - var other []string - for _, a := range attrs { + firstClassIdx := -1 + for i, a := range attrs { if strings.HasPrefix(a, prefix) && strings.HasSuffix(a, `"`) { + if firstClassIdx == -1 { + firstClassIdx = i + } // extract the value between class=" and trailing " val := a[len(prefix) : len(a)-1] if val != "" { classes = append(classes, val) } - } else { - other = append(other, a) } } - if len(classes) == 0 { + if firstClassIdx == -1 { return attrs } + merged := fmt.Sprintf(`class="%s"`, strings.Join(classes, " ")) - return append([]string{merged}, other...) + out := make([]string, 0, len(attrs)) + seenClass := false + for i, a := range attrs { + if strings.HasPrefix(a, prefix) && strings.HasSuffix(a, `"`) { + if !seenClass && i == firstClassIdx { + out = append(out, merged) + seenClass = true + } + continue + } + out = append(out, a) + } + return out } // TagWithAttributes creates a HTML tag with a given name and attributes