]> git.giacomolli.uk - onechat/blob - markdown.go
Capitalize type names so variables can use them
[onechat] / markdown.go
1 package main
2
3 import (
4         "slices"
5         "strings"
6         "unicode"
7         "unicode/utf8"
8 )
9
10 // markdownPunctuation holds the ASCII characters that a backslash makes plain.
11 const markdownPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
12
13 // markdownText is the plain text of a Markdown reply and its Telegram entities,
14 // while the conversion builds it. Units counts the UTF-16 code units of text,
15 // the unit of the entity offsets and lengths.
16 type markdownText struct {
17         text     strings.Builder
18         units    int
19         entities []botEntity
20 }
21
22 // markdownSpan is an inline mark of a line: the entity type, the content inside
23 // the marks, the index after the closing mark, and the URL of a link.
24 type markdownSpan struct {
25         kind    string
26         content string
27         end     int
28         url     string
29 }
30
31 // markdownEntities converts the Markdown of a reply to plain text and Telegram
32 // message entities. It knows bold (** and __), italic (* and _),
33 // strikethrough (~~), inline code, fenced code blocks with an optional
34 // language, links [text](url), and ATX headings, which become bold. Lists,
35 // quotes, and tables stay as their text. A mark that does not close stays as
36 // text with no entity, and code holds no entity.
37 //
38 // Inline marks do not cross a line break, so a mark that does not close cannot
39 // change the rest of the reply.
40 func markdownEntities(markdown string) (string, []botEntity) {
41         var out markdownText
42         lines := strings.Split(markdown, "\n")
43         for index := 0; index < len(lines); index++ {
44                 if index > 0 {
45                         out.add("\n")
46                 }
47
48                 line := lines[index]
49                 language, fence := codeFence(line)
50                 end := closingFence(lines, index, fence)
51                 heading := headingText(line)
52                 switch {
53                 case end > index:
54                         start := out.units
55                         out.add(strings.Join(lines[index+1:end], "\n"))
56                         out.entity(botEntity{
57                                 Type:     "pre",
58                                 Offset:   start,
59                                 Length:   out.units - start,
60                                 Language: language,
61                         })
62
63                         index = end
64                 case heading != "":
65                         start := out.units
66                         out.inline(heading)
67                         out.entity(botEntity{
68                                 Type:   "bold",
69                                 Offset: start,
70                                 Length: out.units - start,
71                         })
72                 default:
73                         out.inline(line)
74                 }
75         }
76
77         slices.SortStableFunc(out.entities, func(a, b botEntity) int {
78                 if a.Offset != b.Offset {
79                         return a.Offset - b.Offset
80                 }
81
82                 return b.Length - a.Length
83         })
84
85         return out.text.String(), out.entities
86 }
87
88 // add appends text.
89 func (m *markdownText) add(text string) {
90         m.text.WriteString(text)
91         m.units += utf16Units(text)
92 }
93
94 // entity keeps entity, unless it is empty, because Telegram refuses an entity
95 // of length 0.
96 func (m *markdownText) entity(entity botEntity) {
97         if entity.Length > 0 {
98                 m.entities = append(m.entities, entity)
99         }
100 }
101
102 // inline appends one line of Markdown with its inline marks as entities. A
103 // backslash before an ASCII punctuation character makes the character plain
104 // text.
105 func (m *markdownText) inline(line string) {
106         plain := 0
107         index := 0
108         for index < len(line) {
109                 if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 {
110                         m.add(line[plain:index])
111                         plain = index + 1
112                         index += 2
113
114                         continue
115                 }
116
117                 span, found := markSpan(line, index)
118                 if !found {
119                         index++
120                         continue
121                 }
122
123                 m.add(line[plain:index])
124                 start := m.units
125                 if span.kind == "code" {
126                         m.add(span.content)
127                 } else {
128                         m.inline(span.content)
129                 }
130
131                 m.entity(botEntity{
132                         Type:   span.kind,
133                         Offset: start,
134                         Length: m.units - start,
135                         URL:    span.url,
136                 })
137
138                 index = span.end
139                 plain = span.end
140         }
141
142         m.add(line[plain:])
143 }
144
145 // codeFence returns the language of a line that opens a fenced code block, and
146 // the fence. The fence is "" when line does not open a block.
147 func codeFence(line string) (string, string) {
148         trimmed := strings.TrimLeft(line, " ")
149         if len(line)-len(trimmed) > 3 || !strings.HasPrefix(trimmed, "```") {
150                 return "", ""
151         }
152
153         run := len(trimmed) - len(strings.TrimLeft(trimmed, "`"))
154         info := strings.TrimSpace(trimmed[run:])
155         if strings.Contains(info, "`") {
156                 return "", ""
157         }
158
159         language, _, _ := strings.Cut(info, " ")
160
161         return language, trimmed[:run]
162 }
163
164 // closingFence returns the index of the line after index that closes the code
165 // block of fence, or -1 when fence is "" or no line closes it.
166 func closingFence(lines []string, index int, fence string) int {
167         if fence == "" {
168                 return -1
169         }
170
171         for end := index + 1; end < len(lines); end++ {
172                 if closesFence(lines[end], fence) {
173                         return end
174                 }
175         }
176
177         return -1
178 }
179
180 // closesFence tells if line closes the code block of fence.
181 func closesFence(line, fence string) bool {
182         trimmed := strings.TrimSpace(line)
183         return strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == ""
184 }
185
186 // headingText returns the text of an ATX heading line without its marks, or ""
187 // when line is not a heading or has no text.
188 func headingText(line string) string {
189         trimmed := strings.TrimLeft(line, " ")
190         if len(line)-len(trimmed) > 3 {
191                 return ""
192         }
193
194         level := len(trimmed) - len(strings.TrimLeft(trimmed, "#"))
195         if level == 0 || level > 6 {
196                 return ""
197         }
198
199         rest := trimmed[level:]
200         if rest != "" && rest[0] != ' ' && rest[0] != '\t' {
201                 return ""
202         }
203
204         rest = strings.TrimSpace(rest)
205         closed := strings.TrimRight(rest, "#")
206         if closed == "" || strings.HasSuffix(closed, " ") {
207                 rest = strings.TrimSpace(closed)
208         }
209
210         return rest
211 }
212
213 // utf16Units returns the number of UTF-16 code units of text.
214 func utf16Units(text string) int {
215         units := 0
216         for _, char := range text {
217                 units++
218                 if char > 0xFFFF {
219                         units++
220                 }
221         }
222
223         return units
224 }
225
226 // markSpan returns the inline mark that starts at index of line and closes in
227 // line. It returns false when no mark starts there or the mark does not close.
228 func markSpan(line string, index int) (markdownSpan, bool) {
229         switch line[index] {
230         case '`':
231                 return codeSpan(line, index)
232         case '[':
233                 return linkSpan(line, index)
234         case '~':
235                 return delimited(line, index, "~~", "strikethrough")
236         case '*', '_':
237                 double := line[index : index+1]
238                 double += double
239                 span, found := delimited(line, index, double, "bold")
240                 if found {
241                         return span, true
242                 }
243
244                 return delimited(line, index, line[index:index+1], "italic")
245         default:
246                 return markdownSpan{}, false
247         }
248 }
249
250 // codeSpan returns the inline code that starts at index: a run of backticks,
251 // the code, and a run of the same length. One space on each side of the code
252 // is left out, as in Markdown.
253 func codeSpan(line string, index int) (markdownSpan, bool) {
254         run := backticks(line[index:])
255         for search := index + run; search < len(line); {
256                 next := strings.IndexByte(line[search:], '`')
257                 if next < 0 {
258                         break
259                 }
260
261                 next += search
262                 length := backticks(line[next:])
263                 if length != run {
264                         search = next + length
265                         continue
266                 }
267
268                 content := line[index+run : next]
269                 if strings.TrimSpace(content) == "" {
270                         break
271                 }
272
273                 if len(content) > 1 && content[0] == ' ' && content[len(content)-1] == ' ' {
274                         content = content[1 : len(content)-1]
275                 }
276
277                 return markdownSpan{
278                         kind:    "code",
279                         content: content,
280                         end:     next + run,
281                 }, true
282         }
283
284         return markdownSpan{}, false
285 }
286
287 // linkSpan returns the link [text](url) that starts at index. The text can
288 // hold brackets in pairs, and the URL holds no space.
289 func linkSpan(line string, index int) (markdownSpan, bool) {
290         depth := 0
291         for closing := index; closing < len(line); closing++ {
292                 switch line[closing] {
293                 case '\\':
294                         closing++
295                 case '[':
296                         depth++
297                 case ']':
298                         depth--
299                 }
300
301                 if depth > 0 {
302                         continue
303                 }
304
305                 rest := line[closing+1:]
306                 if closing == index+1 || !strings.HasPrefix(rest, "(") {
307                         return markdownSpan{}, false
308                 }
309
310                 url, _, found := strings.Cut(rest[1:], ")")
311                 if !found || url == "" || strings.ContainsAny(url, " \t") {
312                         return markdownSpan{}, false
313                 }
314
315                 return markdownSpan{
316                         kind:    "text_link",
317                         content: line[index+1 : closing],
318                         end:     closing + 2 + len(url) + 1,
319                         url:     url,
320                 }, true
321         }
322
323         return markdownSpan{}, false
324 }
325
326 // delimited returns the span of kind between the mark at index and the same
327 // mark later in line. An opening mark is followed by a character that is not a
328 // space, and a closing mark follows one. A single mark is not a part of a
329 // double one. A mark of underscores does not open or close inside a word, so
330 // snake_case stays as text. A code span inside the content is skipped whole.
331 func delimited(line string, index int, mark, kind string) (markdownSpan, bool) {
332         char := mark[0]
333         after := index + len(mark)
334         if !strings.HasPrefix(line[index:], mark) || after >= len(line) || isSpace(line[after]) {
335                 return markdownSpan{}, false
336         }
337
338         if len(mark) == 1 && (line[after] == char || index > 0 && line[index-1] == char) {
339                 return markdownSpan{}, false
340         }
341
342         if char == '_' && index > 0 && wordCharBefore(line[:index]) {
343                 return markdownSpan{}, false
344         }
345
346         for closing := after + 1; closing < len(line); closing++ {
347                 switch {
348                 case line[closing] == '\\':
349                         closing++
350                         continue
351                 case line[closing] == '`':
352                         span, found := codeSpan(line, closing)
353                         if found {
354                                 closing = span.end - 1
355                         }
356
357                         continue
358                 case !strings.HasPrefix(line[closing:], mark) || isSpace(line[closing-1]):
359                         continue
360                 }
361
362                 end := closing + len(mark)
363                 if len(mark) == 1 && (line[closing-1] == char || end < len(line) && line[end] == char) {
364                         continue
365                 }
366
367                 if char == '_' && wordChar(line[end:]) {
368                         continue
369                 }
370
371                 return markdownSpan{
372                         kind:    kind,
373                         content: line[after:closing],
374                         end:     end,
375                 }, true
376         }
377
378         return markdownSpan{}, false
379 }
380
381 // backticks returns the length of the run of backticks at the start of text.
382 func backticks(text string) int {
383         return len(text) - len(strings.TrimLeft(text, "`"))
384 }
385
386 // isSpace tells if char is a space, a tab, or a line break.
387 func isSpace(char byte) bool {
388         return char == ' ' || char == '\t' || char == '\n' || char == '\r'
389 }
390
391 // wordCharBefore tells if the character that ends text is a letter or a digit.
392 func wordCharBefore(text string) bool {
393         char, _ := utf8.DecodeLastRuneInString(text)
394         return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
395 }
396
397 // wordChar tells if the character that starts text is a letter or a digit. An
398 // underscore next to one is a part of a word, such as in snake_case.
399 func wordChar(text string) bool {
400         char, _ := utf8.DecodeRuneInString(text)
401         return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
402 }