feat: implement body sanitisation and formatting

Amolith and Crush created

Implements: bug-4fbe222
Co-authored-by: Crush <crush@charm.land>

Change summary

go.mod      |   4 +
go.sum      |  10 +++
justfile    |   4 
main.go     |  21 +++++++
wrapBody.go | 148 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 184 insertions(+), 3 deletions(-)

Detailed changes

go.mod 🔗

@@ -8,10 +8,12 @@ go 1.25.3
 
 require (
 	github.com/charmbracelet/fang v0.4.3
+	github.com/microcosm-cc/bluemonday v1.0.27
 	github.com/spf13/cobra v1.10.1
 )
 
 require (
+	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/charmbracelet/colorprofile v0.3.2 // indirect
 	github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea // indirect
 	github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef // indirect
@@ -21,6 +23,7 @@ require (
 	github.com/charmbracelet/x/term v0.2.1 // indirect
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
+	github.com/gorilla/css v1.0.1 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
 	github.com/mattn/go-runewidth v0.0.16 // indirect
@@ -32,6 +35,7 @@ require (
 	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/spf13/pflag v1.0.9 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+	golang.org/x/net v0.38.0 // indirect
 	golang.org/x/sync v0.17.0 // indirect
 	golang.org/x/sys v0.36.0 // indirect
 	golang.org/x/text v0.24.0 // indirect

go.sum 🔗

@@ -1,5 +1,7 @@
 github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
 github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
+github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
+github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
 github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
 github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
 github.com/charmbracelet/fang v0.4.3 h1:qXeMxnL4H6mSKBUhDefHu8NfikFbP/MBNTfqTrXvzmY=
@@ -25,12 +27,16 @@ github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soH
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
+github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
 github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
+github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
@@ -57,6 +63,10 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
+golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
+golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
 golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
 golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=

justfile 🔗

@@ -37,11 +37,11 @@ reuse:
 
 build:
     # Building formatted-commit
-    CGO_ENABLED=0 GOOS={{GOOS}} GOARCH={{GOARCH}} go build -o formatted-commit -ldflags "-s -w -X main.version={{VERSION}}" ./main.go
+    CGO_ENABLED=0 GOOS={{GOOS}} GOARCH={{GOARCH}} go build -o formatted-commit -ldflags "-s -w -X main.version={{VERSION}}"
 
 run *FLAGS:
     # Running formatted-commit
-    CGO_ENABLED=0 GOOS={{GOOS}} GOARCH={{GOARCH}} go run -ldflags "-s -w -X main.version={{VERSION}}" ./main.go {{FLAGS}}
+    CGO_ENABLED=0 GOOS={{GOOS}} GOARCH={{GOARCH}} go run -ldflags "-s -w -X main.version={{VERSION}}" . {{FLAGS}}
 
 pack:
     # Packing formatted-commit

main.go 🔗

@@ -54,8 +54,27 @@ formatted-commit -t refactor -s "web/git-bug" -m "fancy shmancy" \
 			return err
 		}
 
-		_ = subject
+		var commitMsg strings.Builder
+		commitMsg.WriteString(subject)
+
+		if body != "" {
+			formattedBody, err := formatBody(body)
+			if err != nil {
+				return fmt.Errorf("failed to format body: %w", err)
+			}
+			commitMsg.WriteString("\n\n")
+			commitMsg.WriteString(formattedBody)
+		}
+
+		if len(trailers) > 0 {
+			commitMsg.WriteString("\n\n")
+			for _, trailer := range trailers {
+				commitMsg.WriteString(trailer)
+				commitMsg.WriteString("\n")
+			}
+		}
 
+		fmt.Print(commitMsg.String())
 		return nil
 	},
 }

wrapBody.go 🔗

@@ -0,0 +1,148 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package main
+
+import (
+	"regexp"
+	"strings"
+
+	"github.com/microcosm-cc/bluemonday"
+)
+
+var numberedListRegex = regexp.MustCompile(`^\d+\.\s`)
+
+func formatBody(body string) (string, error) {
+	p := bluemonday.UGCPolicy()
+	sanitized := p.Sanitize(body)
+
+	lines := strings.Split(sanitized, "\n")
+	var result []string
+
+	for _, line := range lines {
+		trimmed := strings.TrimSpace(line)
+		if trimmed == "" {
+			result = append(result, "")
+			continue
+		}
+
+		if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
+			marker := trimmed[:2]
+			content := trimmed[2:]
+			wrapped := wrapWithHangingIndent(marker, "  ", content, 72)
+			result = append(result, wrapped)
+			continue
+		}
+
+		if numberedListRegex.MatchString(trimmed) {
+			parts := strings.SplitN(trimmed, " ", 2)
+			marker := parts[0] + " "
+			content := ""
+			if len(parts) > 1 {
+				content = parts[1]
+			}
+			indent := strings.Repeat(" ", len(marker))
+			wrapped := wrapWithHangingIndent(marker, indent, content, 72)
+			result = append(result, wrapped)
+			continue
+		}
+
+		result = append(result, wordWrap(trimmed, 72))
+	}
+
+	return strings.Join(result, "\n"), nil
+}
+
+func wrapWithHangingIndent(firstPrefix, contPrefix, text string, width int) string {
+	firstWidth := width - len(firstPrefix)
+	contWidth := width - len(contPrefix)
+
+	words := strings.Fields(text)
+	if len(words) == 0 {
+		return firstPrefix
+	}
+
+	var lines []string
+	var currentLine strings.Builder
+	var currentWidth int
+	isFirstLine := true
+
+	for _, word := range words {
+		wordLen := len(word)
+		maxWidth := firstWidth
+		if !isFirstLine {
+			maxWidth = contWidth
+		}
+
+		if currentLine.Len() == 0 {
+			currentLine.WriteString(word)
+			currentWidth = wordLen
+		} else if currentWidth+1+wordLen <= maxWidth {
+			currentLine.WriteString(" ")
+			currentLine.WriteString(word)
+			currentWidth += 1 + wordLen
+		} else {
+			if isFirstLine {
+				lines = append(lines, firstPrefix+currentLine.String())
+				isFirstLine = false
+			} else {
+				lines = append(lines, contPrefix+currentLine.String())
+			}
+			currentLine.Reset()
+			currentLine.WriteString(word)
+			currentWidth = wordLen
+		}
+	}
+
+	if currentLine.Len() > 0 {
+		if isFirstLine {
+			lines = append(lines, firstPrefix+currentLine.String())
+		} else {
+			lines = append(lines, contPrefix+currentLine.String())
+		}
+	}
+
+	return strings.Join(lines, "\n")
+}
+
+func wordWrap(text string, width int) string {
+	words := strings.Fields(text)
+	if len(words) == 0 {
+		return ""
+	}
+
+	var result strings.Builder
+	var currentLine strings.Builder
+	var currentWidth int
+
+	for _, word := range words {
+		wordLen := len(word)
+
+		if currentLine.Len() == 0 {
+			currentLine.WriteString(word)
+			currentWidth = wordLen
+		} else if currentWidth+1+wordLen <= width {
+			currentLine.WriteString(" ")
+			currentLine.WriteString(word)
+			currentWidth += 1 + wordLen
+		} else {
+			if result.Len() > 0 {
+				result.WriteString("\n")
+			}
+			result.WriteString(currentLine.String())
+			currentLine.Reset()
+			currentLine.WriteString(word)
+			currentWidth = wordLen
+		}
+	}
+
+	if currentLine.Len() > 0 {
+		if result.Len() > 0 {
+			result.WriteString("\n")
+		}
+		result.WriteString(currentLine.String())
+	}
+
+	return result.String()
+}