From 4ba4f60b82e7d201340b439d95b9912108a724b9 Mon Sep 17 00:00:00 2001 From: patakuti Date: Sat, 1 Aug 2026 14:42:04 +0900 Subject: [PATCH] Normalize list marker spacing to avoid accidental code blocks Under strict CommonMark/GFM, a list marker followed by 5+ spaces turns the rest of the line into an indented code block instead of plain text (e.g. "1. a" renders as a code block). This is an easy mistake to make when padding markers for visual alignment, and the resulting rendering change is silent and confusing. Add PreprocessListMarkers, which always collapses the whitespace after a list marker to a single space before parsing, skipping fenced code blocks. This is an intentional deviation from GitHub's rendering in favor of predictability. Co-Authored-By: Claude Sonnet 5 --- README.md | 1 + internal/markdown/convert.go | 4 ++ internal/markdown/listmarker.go | 73 +++++++++++++++++++ internal/markdown/listmarker_test.go | 103 +++++++++++++++++++++++++++ 4 files changed, 181 insertions(+) create mode 100644 internal/markdown/listmarker.go create mode 100644 internal/markdown/listmarker_test.go 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) + } + }) + } +}