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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ go build -o markdown-proxy ./cmd/markdown-proxy
- **GitHub/GitLab branch detection**: When accessing a repository root URL, only `main` and `master` branches are tried for README.md auto-detection.
- **No native PDF export**: Use the toolbar's Print link to export via the browser's print-to-PDF feature. Page breaks are automatically avoided inside tables, code blocks, math expressions, images, blockquotes, and list items; headings are kept together with the following content.
- **Hidden files excluded**: Files and directories starting with `.` are not shown in directory listings.
- **List marker spacing is normalized**: Under strict CommonMark/GFM, a list marker (e.g. `1.`) followed by 5 or more spaces turns the rest of the line into an indented code block instead of plain text — an easy mistake to make when padding markers for visual alignment. This proxy always normalizes the spacing after a list marker to a single space, so `1. a` renders the same as `1. a`. This is an intentional deviation from GitHub's rendering. The normalization is applied per line, so deeply nested list items (indented past 3 columns) may not be covered.

## Contributing

Expand Down
4 changes: 4 additions & 0 deletions internal/markdown/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ func Convert(source []byte, plantumlServer string) ([]byte, error) {
// Normalize CRLF to LF so all preprocessors and the parser see consistent line endings.
source = bytes.ReplaceAll(source, []byte("\r\n"), []byte("\n"))

// Pre-process: normalize spacing after list markers to avoid accidental
// indented-code-block interpretation (see PreprocessListMarkers doc comment)
source = PreprocessListMarkers(source)

// Pre-process: expand single-line $$...$$ to multi-line for goldmark-mathjax
source = PreprocessMathBlocks(source)

Expand Down
73 changes: 73 additions & 0 deletions internal/markdown/listmarker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package markdown

import (
"bytes"
"regexp"
)

// listMarkerRe matches a list item marker (bullet or ordered) at the start of
// a line's content, followed by 2 or more spaces before the item's text.
// Capture groups: (1) leading indent (0-3 spaces), (2) marker, (3) content.
// The marker itself may only be preceded by 0-3 spaces, matching CommonMark's
// own rule for what counts as a (possibly nested) list marker rather than
// indented content.
var listMarkerRe = regexp.MustCompile(`^( {0,3})([-*+]|\d{1,9}[.)]) {2,}(\S.*)$`)

// PreprocessListMarkers normalizes the whitespace between a list marker and
// its content to exactly one space.
//
// CommonMark/GFM caps the "content indent" a list item establishes at 4
// columns: if a marker is followed by 5 or more spaces, only the first space
// is treated as the separator and the rest is read as part of the item's
// text, which then renders as an indented code block (4+ columns of leading
// whitespace) instead of plain text. Authors who pad list markers for visual
// alignment (e.g. "1. a") trigger this by accident and get a silently
// different rendering. Normalizing the spacing up front removes the
// ambiguity: a list marker is always followed by exactly one space,
// regardless of how the source was typed.
//
// This only rewrites the marker's own line, so intentional multi-line
// indented content (nested code blocks, continuation paragraphs) on
// following lines is unaffected. Fenced code blocks (``` or ~~~) are left
// untouched, and only markers preceded by 0-3 spaces are rewritten, so
// already-indented code content is never touched.
//
// Deviation from strict CommonMark/GFM: this is an intentional divergence
// from GitHub's rendering, favoring predictability over spec compliance for
// this specific, easily-mistaken construct.
func PreprocessListMarkers(source []byte) []byte {
lines := bytes.Split(source, []byte("\n"))
var inFence bool
var fenceMarker []byte

for i, line := range lines {
bqPrefix, body := splitBlockquotePrefix(line)

if m := fenceStartRe.FindSubmatch(body); m != nil {
marker := m[1]
if inFence {
if marker[0] == fenceMarker[0] && len(marker) >= len(fenceMarker) {
inFence = false
fenceMarker = nil
}
} else {
inFence = true
fenceMarker = marker
}
continue
}
if inFence {
continue
}

if m := listMarkerRe.FindSubmatch(body); m != nil {
normalized := append([]byte{}, m[1]...) // leading indent
normalized = append(normalized, m[2]...) // marker
normalized = append(normalized, ' ')
normalized = append(normalized, m[3]...) // content
lines[i] = prefixed(bqPrefix, normalized)
}
}

return bytes.Join(lines, []byte("\n"))
}
103 changes: 103 additions & 0 deletions internal/markdown/listmarker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package markdown

import (
"testing"
)

func TestPreprocessListMarkers(t *testing.T) {
tests := []struct {
name string
input string
expect string
}{
{
name: "ordered marker with 5 spaces (the reported bug)",
input: "1. a",
expect: "1. a",
},
{
name: "ordered marker with 2 spaces stays normalized",
input: "1. a",
expect: "1. a",
},
{
name: "ordered marker with 1 space is unchanged",
input: "1. a",
expect: "1. a",
},
{
name: "bullet marker with many spaces",
input: "- a",
expect: "- a",
},
{
name: "plus and asterisk bullets",
input: "* a\n+ b",
expect: "* a\n+ b",
},
{
name: "two-digit ordered marker",
input: "10. a",
expect: "10. a",
},
{
name: "paren-style ordered marker",
input: "1) a",
expect: "1) a",
},
{
name: "shallow nested bullet under bullet",
input: "- a\n - b",
expect: "- a\n - b",
},
{
name: "leading indent up to 3 spaces preserved",
input: " - a",
expect: " - a",
},
{
name: "internal spacing within content untouched",
input: "1. a b",
expect: "1. a b",
},
{
name: "marker-only empty item untouched",
input: "- \nfoo",
expect: "- \nfoo",
},
{
name: "content inside fenced code block untouched",
input: "```\n1. a\n```",
expect: "```\n1. a\n```",
},
{
name: "content inside tilde fenced code block untouched",
input: "~~~\n1. a\n~~~",
expect: "~~~\n1. a\n~~~",
},
{
name: "indented (4+ space) code block untouched",
input: " 1. a",
expect: " 1. a",
},
{
name: "list marker inside blockquote normalized",
input: "> 1. a",
expect: "> 1. a",
},
{
name: "thematic break with wide spacing survives as a thematic break",
input: "- - -",
expect: "- - -",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := string(PreprocessListMarkers([]byte(tt.input)))
if got != tt.expect {
t.Errorf("PreprocessListMarkers(%q) = %q, want %q", tt.input, got, tt.expect)
}
})
}
}
Loading