diff --git a/README.md b/README.md index 05da79f..2d0e826 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/markdown/convert.go b/internal/markdown/convert.go index 9b09c6e..d6eb0bd 100644 --- a/internal/markdown/convert.go +++ b/internal/markdown/convert.go @@ -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) diff --git a/internal/markdown/listmarker.go b/internal/markdown/listmarker.go new file mode 100644 index 0000000..6c2d714 --- /dev/null +++ b/internal/markdown/listmarker.go @@ -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")) +} diff --git a/internal/markdown/listmarker_test.go b/internal/markdown/listmarker_test.go new file mode 100644 index 0000000..9d8bcbb --- /dev/null +++ b/internal/markdown/listmarker_test.go @@ -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) + } + }) + } +}