Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **GitHub Metadata Cache** (#43): Reuse successful GitHub repository metadata requests for ten minutes and coalesce concurrent requests

### Fixed

- **HTML De-indenting in Code Fences**: `deIndentHTMLBlocks` no longer strips indentation from HTML-looking lines inside fenced code blocks, which mangled indented HTML code samples

## [0.3.1] - 2026-02-27

### Fixed
Expand Down
55 changes: 55 additions & 0 deletions renderers/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,67 @@ func createGoldmarkConverter() goldmark.Markdown {
// (optionally preceded by up to 3 spaces) and ends at a blank line.
var htmlBlockStartRE = regexp.MustCompile(`(?i)^\s{0,3}</?(?:address|article|aside|blockquote|details|dialog|dd|div|dl|dt|fieldset|figcaption|figure|footer|form|h[1-6]|header|hgroup|hr|li|main|nav|ol|p|pre|section|summary|table|ul)\b`)

// codeFenceDelimiter returns the marker character, length, and trailing
// content of a fenced code block delimiter. Fenced code blocks are left
// untouched, so that HTML-looking code samples keep their indentation.
func codeFenceDelimiter(line []byte) (byte, int, []byte, bool) {
indent := 0
for indent < len(line) && indent < 4 && line[indent] == ' ' {
indent++
}
if indent == len(line) || indent == 4 {
return 0, 0, nil, false
}

marker := line[indent]
if marker != '`' && marker != '~' {
return 0, 0, nil, false
}

end := indent
for end < len(line) && line[end] == marker {
end++
}
length := end - indent
if length < 3 {
return 0, 0, nil, false
}
return marker, length, line[end:], true
}

func isCodeFenceClosingSuffix(suffix []byte) bool {
if len(suffix) > 0 && suffix[len(suffix)-1] == '\r' {
suffix = suffix[:len(suffix)-1]
}
return len(bytes.Trim(suffix, " \t")) == 0
}

func deIndentHTMLBlocks(md []byte) []byte {
lines := bytes.Split(md, []byte("\n"))
result := make([][]byte, 0, len(lines))
inHTMLBlock := false
inCodeFence := false
var fenceMarker byte
fenceLength := 0

for _, line := range lines {
marker, length, suffix, isFence := codeFenceDelimiter(line)
if inCodeFence {
if isFence && marker == fenceMarker && length >= fenceLength && isCodeFenceClosingSuffix(suffix) {
inCodeFence = false
fenceMarker = 0
fenceLength = 0
}
result = append(result, line)
continue
}
if isFence {
inCodeFence = true
fenceMarker = marker
fenceLength = length
result = append(result, line)
continue
}
if !inHTMLBlock {
if htmlBlockStartRE.Match(line) {
inHTMLBlock = true
Expand Down
59 changes: 59 additions & 0 deletions renderers/markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,56 @@ func TestDeIndentHTMLBlocks(t *testing.T) {
require.Contains(t, result, " outside")
},
},
{
name: "HTML inside backtick code fence untouched",
input: "```\n<ul>\n <li>item</li>\n</ul>\n```\n",
check: func(t *testing.T, result string) {
require.Contains(t, result, " <li>item</li>")
},
},
{
name: "HTML inside tilde code fence untouched",
input: "~~~\n<div>\n indented\n</div>\n~~~\n",
check: func(t *testing.T, result string) {
require.Contains(t, result, " indented")
},
},
{
name: "longer outer fence containing shorter fence lines",
input: "````html\n```\n<ul>\n <li>item</li>\n</ul>\n```\n````\n",
check: func(t *testing.T, result string) {
require.Contains(t, result, " <li>item</li>")
},
},
{
name: "backtick fence containing tilde fence lines",
input: "```\n~~~\n<ul>\n <li>item</li>\n</ul>\n~~~\n```\n",
check: func(t *testing.T, result string) {
require.Contains(t, result, " <li>item</li>")
},
},
{
name: "fence-like content with trailing text does not close",
input: "```\n```not-a-close\n<ul>\n <li>item</li>\n</ul>\n```\n",
check: func(t *testing.T, result string) {
require.Contains(t, result, " <li>item</li>")
},
},
{
name: "closing fence allows whitespace",
input: "```\r\ncode\r\n``` \t\r\n<ul>\r\n <li>item</li>\r\n</ul>\r\n",
check: func(t *testing.T, result string) {
require.Contains(t, result, "\r\n<li>item</li>")
},
},
{
name: "HTML block after code fence still de-indented",
input: "```\n<p> code</p>\n```\n\n<ul>\n <li>item</li>\n</ul>\n",
check: func(t *testing.T, result string) {
require.Contains(t, result, "\n<li>item</li>")
require.Contains(t, result, "<p> code</p>")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -102,6 +152,15 @@ func TestDeIndentHTMLBlocks(t *testing.T) {
}
}

func TestRenderMarkdownCodeFenceIndentation(t *testing.T) {
// HTML code samples inside fenced code blocks must keep their
// indentation; deIndentHTMLBlocks previously stripped it.
input := "```html\n<ul>\n <li>item</li>\n</ul>\n```\n"
result := mustMarkdownString(input)
require.Contains(t, result, " &lt;li&gt;item&lt;/li&gt;",
"code sample should keep its indentation")
}

func TestRenderMarkdownVoidElements(t *testing.T) {
// Issue #66: <br> tags inside markdown="1" blocks should not cause EOF errors.
// Void elements like <br>, <hr>, <img> don't have end tags, so the depth
Expand Down