Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions html/coalesce_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
43 changes: 40 additions & 3 deletions html/renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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, "<pre>")
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions html_renderer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +83 to +89
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AST walk in this test sets cb.Attribute on both the entering and exiting visits because it doesn't check the entering flag. Adding if !entering { return ast.GoToNext } (or equivalent) would avoid redundant mutation and make the intent clearer.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

})

renderer := html.NewRenderer(html.RendererOptions{})
got := string(Render(doc, renderer))

// Before the fix, this produced two separate class= attributes:
// <code class="language-yml" class="my-class">
// After the fix, they should be coalesced:
// <code class="language-yml my-class">
expected := `<pre><code class="language-yml my-class">text: something
</code></pre>
`
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")`,
Expand Down
2 changes: 1 addition & 1 deletion testdata/mmark.test
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ A> This is an aside
{.myclass3}
.# Preface section
+++
<h1 class="special" class="myclass3">Preface section</h1>
<h1 class="special myclass3">Preface section</h1>
+++
{.myclass4}
A> hello
Expand Down
Loading