diff --git a/html/coalesce_test.go b/html/coalesce_test.go new file mode 100644 index 0000000..f5b719f --- /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 8e2c1d1..9f33525 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,44 @@ 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
+	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)
+			}
+		}
+	}
+	if firstClassIdx == -1 {
+		return attrs
+	}
+
+	merged := fmt.Sprintf(`class="%s"`, strings.Join(classes, " "))
+	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
 func TagWithAttributes(name string, attrs []string) string {
 	s := name
diff --git a/html_renderer_test.go b/html_renderer_test.go
index 0936ecd..f96a858 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 2731069..e460c3c 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