1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package main
6
7import (
8 "regexp"
9 "strings"
10)
11
12var numberedListRegex = regexp.MustCompile(`^\d+\.\s`)
13
14func formatBody(body string) (string, error) {
15 lines := strings.Split(body, "\n")
16 var result []string
17 var plainTextBuffer []string
18
19 flushPlainText := func() {
20 if len(plainTextBuffer) > 0 {
21 joined := strings.Join(plainTextBuffer, " ")
22 wrapped := wordWrap(joined, 72)
23 result = append(result, wrapped)
24 plainTextBuffer = nil
25 }
26 }
27
28 for _, line := range lines {
29 // Preserve indented lines (4+ spaces or tab) as preformatted code
30 if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
31 result = append(result, line)
32 continue
33 }
34
35 trimmed := strings.TrimSpace(line)
36 if trimmed == "" {
37 flushPlainText()
38 result = append(result, "")
39 continue
40 }
41
42 if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
43 flushPlainText()
44 marker := trimmed[:2]
45 content := trimmed[2:]
46 wrapped := wrapWithHangingIndent(marker, " ", content, 72)
47 result = append(result, wrapped)
48 continue
49 }
50
51 if numberedListRegex.MatchString(trimmed) {
52 flushPlainText()
53 parts := strings.SplitN(trimmed, " ", 2)
54 marker := parts[0] + " "
55 content := ""
56 if len(parts) > 1 {
57 content = parts[1]
58 }
59 indent := strings.Repeat(" ", len(marker))
60 wrapped := wrapWithHangingIndent(marker, indent, content, 72)
61 result = append(result, wrapped)
62 continue
63 }
64
65 plainTextBuffer = append(plainTextBuffer, trimmed)
66 }
67
68 flushPlainText()
69
70 return strings.Join(result, "\n"), nil
71}
72
73func wrapWithHangingIndent(firstPrefix, contPrefix, text string, width int) string {
74 firstWidth := width - len(firstPrefix)
75 contWidth := width - len(contPrefix)
76
77 words := strings.Fields(text)
78 if len(words) == 0 {
79 return firstPrefix
80 }
81
82 var lines []string
83 var currentLine strings.Builder
84 var currentWidth int
85 isFirstLine := true
86
87 for _, word := range words {
88 wordLen := len(word)
89 maxWidth := firstWidth
90 if !isFirstLine {
91 maxWidth = contWidth
92 }
93
94 if currentLine.Len() == 0 {
95 currentLine.WriteString(word)
96 currentWidth = wordLen
97 } else if currentWidth+1+wordLen <= maxWidth {
98 currentLine.WriteString(" ")
99 currentLine.WriteString(word)
100 currentWidth += 1 + wordLen
101 } else {
102 if isFirstLine {
103 lines = append(lines, firstPrefix+currentLine.String())
104 isFirstLine = false
105 } else {
106 lines = append(lines, contPrefix+currentLine.String())
107 }
108 currentLine.Reset()
109 currentLine.WriteString(word)
110 currentWidth = wordLen
111 }
112 }
113
114 if currentLine.Len() > 0 {
115 if isFirstLine {
116 lines = append(lines, firstPrefix+currentLine.String())
117 } else {
118 lines = append(lines, contPrefix+currentLine.String())
119 }
120 }
121
122 return strings.Join(lines, "\n")
123}
124
125func wordWrap(text string, width int) string {
126 words := strings.Fields(text)
127 if len(words) == 0 {
128 return ""
129 }
130
131 var result strings.Builder
132 var currentLine strings.Builder
133 var currentWidth int
134
135 for _, word := range words {
136 wordLen := len(word)
137
138 if currentLine.Len() == 0 {
139 currentLine.WriteString(word)
140 currentWidth = wordLen
141 } else if currentWidth+1+wordLen <= width {
142 currentLine.WriteString(" ")
143 currentLine.WriteString(word)
144 currentWidth += 1 + wordLen
145 } else {
146 if result.Len() > 0 {
147 result.WriteString("\n")
148 }
149 result.WriteString(currentLine.String())
150 currentLine.Reset()
151 currentLine.WriteString(word)
152 currentWidth = wordLen
153 }
154 }
155
156 if currentLine.Len() > 0 {
157 if result.Len() > 0 {
158 result.WriteString("\n")
159 }
160 result.WriteString(currentLine.String())
161 }
162
163 return result.String()
164}