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