]> git.giacomolli.uk - onechat/blob - markdown.go
Call sendRichMessage of the Bot API
[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 trimmed := strings.TrimSpace(lines[end])
173 if strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == "" {
174 return end
175 }
176 }
177
178 return -1
179 }
180
181 // headingText returns the text of an ATX heading line without its marks, or ""
182 // when line is not a heading or has no text.
183 func headingText(line string) string {
184 trimmed := strings.TrimLeft(line, " ")
185 if len(line)-len(trimmed) > 3 {
186 return ""
187 }
188
189 level := len(trimmed) - len(strings.TrimLeft(trimmed, "#"))
190 if level == 0 || level > 6 {
191 return ""
192 }
193
194 rest := trimmed[level:]
195 if rest != "" && rest[0] != ' ' && rest[0] != '\t' {
196 return ""
197 }
198
199 rest = strings.TrimSpace(rest)
200 closed := strings.TrimRight(rest, "#")
201 if closed == "" || strings.HasSuffix(closed, " ") {
202 rest = strings.TrimSpace(closed)
203 }
204
205 return rest
206 }
207
208 // utf16Units returns the number of UTF-16 code units of text.
209 func utf16Units(text string) int {
210 units := 0
211 for _, char := range text {
212 units++
213 if char > 0xFFFF {
214 units++
215 }
216 }
217
218 return units
219 }
220
221 // markSpan returns the inline mark that starts at index of line and closes in
222 // line. It returns false when no mark starts there or the mark does not close.
223 func markSpan(line string, index int) (markdownSpan, bool) {
224 switch line[index] {
225 case '`':
226 return codeSpan(line, index)
227 case '[':
228 return linkSpan(line, index)
229 case '~':
230 return delimited(line, index, "~~", "strikethrough")
231 case '*', '_':
232 double := line[index : index+1]
233 double += double
234 span, found := delimited(line, index, double, "bold")
235 if found {
236 return span, true
237 }
238
239 return delimited(line, index, line[index:index+1], "italic")
240 default:
241 return markdownSpan{}, false
242 }
243 }
244
245 // codeSpan returns the inline code that starts at index: a run of backticks,
246 // the code, and a run of the same length. One space on each side of the code
247 // is left out, as in Markdown.
248 func codeSpan(line string, index int) (markdownSpan, bool) {
249 run := backticks(line[index:])
250 for search := index + run; search < len(line); {
251 next := strings.IndexByte(line[search:], '`')
252 if next < 0 {
253 break
254 }
255
256 next += search
257 length := backticks(line[next:])
258 if length != run {
259 search = next + length
260 continue
261 }
262
263 content := line[index+run : next]
264 if strings.TrimSpace(content) == "" {
265 break
266 }
267
268 if len(content) > 1 && content[0] == ' ' && content[len(content)-1] == ' ' {
269 content = content[1 : len(content)-1]
270 }
271
272 return markdownSpan{
273 kind: "code",
274 content: content,
275 end: next + run,
276 }, true
277 }
278
279 return markdownSpan{}, false
280 }
281
282 // linkSpan returns the link [text](url) that starts at index. The text can
283 // hold brackets in pairs, and the URL holds no space.
284 func linkSpan(line string, index int) (markdownSpan, bool) {
285 depth := 0
286 for closing := index; closing < len(line); closing++ {
287 switch line[closing] {
288 case '\\':
289 closing++
290 case '[':
291 depth++
292 case ']':
293 depth--
294 }
295
296 if depth > 0 {
297 continue
298 }
299
300 rest := line[closing+1:]
301 if closing == index+1 || !strings.HasPrefix(rest, "(") {
302 return markdownSpan{}, false
303 }
304
305 url, _, found := strings.Cut(rest[1:], ")")
306 if !found || url == "" || strings.ContainsAny(url, " \t") {
307 return markdownSpan{}, false
308 }
309
310 return markdownSpan{
311 kind: "text_link",
312 content: line[index+1 : closing],
313 end: closing + 2 + len(url) + 1,
314 url: url,
315 }, true
316 }
317
318 return markdownSpan{}, false
319 }
320
321 // delimited returns the span of kind between the mark at index and the same
322 // mark later in line. An opening mark is followed by a character that is not a
323 // space, and a closing mark follows one. A single mark is not a part of a
324 // double one. A mark of underscores does not open or close inside a word, so
325 // snake_case stays as text. A code span inside the content is skipped whole.
326 func delimited(line string, index int, mark, kind string) (markdownSpan, bool) {
327 char := mark[0]
328 after := index + len(mark)
329 if !strings.HasPrefix(line[index:], mark) || after >= len(line) || isSpace(line[after]) {
330 return markdownSpan{}, false
331 }
332
333 if len(mark) == 1 && (line[after] == char || index > 0 && line[index-1] == char) {
334 return markdownSpan{}, false
335 }
336
337 if char == '_' && index > 0 && wordCharBefore(line[:index]) {
338 return markdownSpan{}, false
339 }
340
341 for closing := after + 1; closing < len(line); closing++ {
342 switch {
343 case line[closing] == '\\':
344 closing++
345 continue
346 case line[closing] == '`':
347 span, found := codeSpan(line, closing)
348 if found {
349 closing = span.end - 1
350 }
351
352 continue
353 case !strings.HasPrefix(line[closing:], mark) || isSpace(line[closing-1]):
354 continue
355 }
356
357 end := closing + len(mark)
358 if len(mark) == 1 && (line[closing-1] == char || end < len(line) && line[end] == char) {
359 continue
360 }
361
362 if char == '_' && wordChar(line[end:]) {
363 continue
364 }
365
366 return markdownSpan{
367 kind: kind,
368 content: line[after:closing],
369 end: end,
370 }, true
371 }
372
373 return markdownSpan{}, false
374 }
375
376 // backticks returns the length of the run of backticks at the start of text.
377 func backticks(text string) int {
378 return len(text) - len(strings.TrimLeft(text, "`"))
379 }
380
381 // isSpace tells if char is a space, a tab, or a line break.
382 func isSpace(char byte) bool {
383 return char == ' ' || char == '\t' || char == '\n' || char == '\r'
384 }
385
386 // wordCharBefore tells if the character that ends text is a letter or a digit.
387 func wordCharBefore(text string) bool {
388 char, _ := utf8.DecodeLastRuneInString(text)
389 return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
390 }
391
392 // wordChar tells if the character that starts text is a letter or a digit. An
393 // underscore next to one is a part of a word, such as in snake_case.
394 func wordChar(text string) bool {
395 char, _ := utf8.DecodeRuneInString(text)
396 return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
397 }