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