package main
import (
- "slices"
"strings"
"unicode"
"unicode/utf8"
// markdownPunctuation holds the ASCII characters that a backslash makes plain.
const markdownPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
-// markdownText is the plain text of a Markdown reply and its Telegram entities,
-// while the conversion builds it. Units counts the UTF-16 code units of text,
-// the unit of the entity offsets and lengths.
-type markdownText struct {
- text strings.Builder
- units int
- entities []botEntity
-}
-
-// markdownSpan is an inline mark of a line: the entity type, the content inside
+// MarkdownSpan is an inline mark of a line: the mark kind, the content inside
// the marks, the index after the closing mark, and the URL of a link.
-type markdownSpan struct {
+type MarkdownSpan struct {
kind string
content string
end int
url string
}
-// markdownEntities converts the Markdown of a reply to plain text and Telegram
-// message entities. It knows bold (** and __), italic (* and _),
-// strikethrough (~~), inline code, fenced code blocks with an optional
-// language, links [text](url), and ATX headings, which become bold. Lists,
-// quotes, and tables stay as their text. A mark that does not close stays as
-// text with no entity, and code holds no entity.
-//
-// Inline marks do not cross a line break, so a mark that does not close cannot
-// change the rest of the reply.
-func markdownEntities(markdown string) (string, []botEntity) {
- var out markdownText
- lines := strings.Split(markdown, "\n")
- for index := 0; index < len(lines); index++ {
- if index > 0 {
- out.add("\n")
- }
-
- line := lines[index]
- language, fence := codeFence(line)
- end := closingFence(lines, index, fence)
- heading := headingText(line)
- switch {
- case end > index:
- start := out.units
- out.add(strings.Join(lines[index+1:end], "\n"))
- out.entity(botEntity{
- Type: "pre",
- Offset: start,
- Length: out.units - start,
- Language: language,
- })
-
- index = end
- case heading != "":
- start := out.units
- out.inline(heading)
- out.entity(botEntity{
- Type: "bold",
- Offset: start,
- Length: out.units - start,
- })
- default:
- out.inline(line)
- }
- }
-
- slices.SortStableFunc(out.entities, func(a, b botEntity) int {
- if a.Offset != b.Offset {
- return a.Offset - b.Offset
- }
-
- return b.Length - a.Length
- })
-
- return out.text.String(), out.entities
-}
-
-// add appends text.
-func (m *markdownText) add(text string) {
- m.text.WriteString(text)
- m.units += utf16Units(text)
-}
-
-// entity keeps entity, unless it is empty, because Telegram refuses an entity
-// of length 0.
-func (m *markdownText) entity(entity botEntity) {
- if entity.Length > 0 {
- m.entities = append(m.entities, entity)
- }
-}
-
-// inline appends one line of Markdown with its inline marks as entities. A
-// backslash before an ASCII punctuation character makes the character plain
-// text.
-func (m *markdownText) inline(line string) {
- plain := 0
- index := 0
- for index < len(line) {
- if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 {
- m.add(line[plain:index])
- plain = index + 1
- index += 2
-
- continue
- }
-
- span, found := markSpan(line, index)
- if !found {
- index++
- continue
- }
-
- m.add(line[plain:index])
- start := m.units
- if span.kind == "code" {
- m.add(span.content)
- } else {
- m.inline(span.content)
- }
-
- m.entity(botEntity{
- Type: span.kind,
- Offset: start,
- Length: m.units - start,
- URL: span.url,
- })
-
- index = span.end
- plain = span.end
- }
-
- m.add(line[plain:])
-}
-
-// codeFence returns the language of a line that opens a fenced code block, and
-// the fence. The fence is "" when line does not open a block.
-func codeFence(line string) (string, string) {
+// markdownCodeFence returns the language of a line that opens a fenced code
+// block, and the fence. The fence is "" when line does not open a block.
+func markdownCodeFence(line string) (string, string) {
trimmed := strings.TrimLeft(line, " ")
if len(line)-len(trimmed) > 3 || !strings.HasPrefix(trimmed, "```") {
return "", ""
return language, trimmed[:run]
}
-// closingFence returns the index of the line after index that closes the code
-// block of fence, or -1 when fence is "" or no line closes it.
-func closingFence(lines []string, index int, fence string) int {
+// markdownClosingFence returns the index of the line after index that closes
+// the code block of fence, or -1 when fence is "" or no line closes it.
+func markdownClosingFence(lines []string, index int, fence string) int {
if fence == "" {
return -1
}
for end := index + 1; end < len(lines); end++ {
- if closesFence(lines[end], fence) {
+ if markdownClosesFence(lines[end], fence) {
return end
}
}
return -1
}
-// closesFence tells if line closes the code block of fence.
-func closesFence(line, fence string) bool {
+// markdownClosesFence tells if line closes the code block of fence.
+func markdownClosesFence(line, fence string) bool {
trimmed := strings.TrimSpace(line)
return strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == ""
}
-// headingText returns the text of an ATX heading line without its marks, or ""
-// when line is not a heading or has no text.
-func headingText(line string) string {
+// markdownHeadingText returns the text of an ATX heading line without its
+// marks, or "" when line is not a heading or has no text.
+func markdownHeadingText(line string) string {
trimmed := strings.TrimLeft(line, " ")
if len(line)-len(trimmed) > 3 {
return ""
return rest
}
-// utf16Units returns the number of UTF-16 code units of text.
-func utf16Units(text string) int {
- units := 0
- for _, char := range text {
- units++
- if char > 0xFFFF {
- units++
- }
- }
-
- return units
-}
-
-// markSpan returns the inline mark that starts at index of line and closes in
-// line. It returns false when no mark starts there or the mark does not close.
-func markSpan(line string, index int) (markdownSpan, bool) {
+// markdownMarkSpan returns the inline mark that starts at index of line and
+// closes in line. It returns false when no mark starts there or the mark does
+// not close.
+func markdownMarkSpan(line string, index int) (MarkdownSpan, bool) {
switch line[index] {
case '`':
- return codeSpan(line, index)
+ return markdownCodeSpan(line, index)
case '[':
- return linkSpan(line, index)
+ return markdownLinkSpan(line, index)
case '~':
- return delimited(line, index, "~~", "strikethrough")
+ return markdownDelimited(line, index, "~~", "strikethrough")
case '*', '_':
double := line[index : index+1]
double += double
- span, found := delimited(line, index, double, "bold")
+ span, found := markdownDelimited(line, index, double, "bold")
if found {
return span, true
}
- return delimited(line, index, line[index:index+1], "italic")
+ return markdownDelimited(line, index, line[index:index+1], "italic")
default:
- return markdownSpan{}, false
+ return MarkdownSpan{}, false
}
}
-// codeSpan returns the inline code that starts at index: a run of backticks,
-// the code, and a run of the same length. One space on each side of the code
-// is left out, as in Markdown.
-func codeSpan(line string, index int) (markdownSpan, bool) {
- run := backticks(line[index:])
+// markdownCodeSpan returns the inline code that starts at index: a run of
+// backticks, the code, and a run of the same length. One space on each side of
+// the code is left out, as in Markdown.
+func markdownCodeSpan(line string, index int) (MarkdownSpan, bool) {
+ run := markdownBackticks(line[index:])
for search := index + run; search < len(line); {
next := strings.IndexByte(line[search:], '`')
if next < 0 {
}
next += search
- length := backticks(line[next:])
+ length := markdownBackticks(line[next:])
if length != run {
search = next + length
continue
content = content[1 : len(content)-1]
}
- return markdownSpan{
+ return MarkdownSpan{
kind: "code",
content: content,
end: next + run,
}, true
}
- return markdownSpan{}, false
+ return MarkdownSpan{}, false
}
-// linkSpan returns the link [text](url) that starts at index. The text can
-// hold brackets in pairs, and the URL holds no space.
-func linkSpan(line string, index int) (markdownSpan, bool) {
+// markdownLinkSpan returns the link [text](url) that starts at index. The text
+// can hold brackets in pairs, and the URL holds no space.
+func markdownLinkSpan(line string, index int) (MarkdownSpan, bool) {
depth := 0
for closing := index; closing < len(line); closing++ {
switch line[closing] {
rest := line[closing+1:]
if closing == index+1 || !strings.HasPrefix(rest, "(") {
- return markdownSpan{}, false
+ return MarkdownSpan{}, false
}
url, _, found := strings.Cut(rest[1:], ")")
if !found || url == "" || strings.ContainsAny(url, " \t") {
- return markdownSpan{}, false
+ return MarkdownSpan{}, false
}
- return markdownSpan{
+ return MarkdownSpan{
kind: "text_link",
content: line[index+1 : closing],
end: closing + 2 + len(url) + 1,
}, true
}
- return markdownSpan{}, false
+ return MarkdownSpan{}, false
}
-// delimited returns the span of kind between the mark at index and the same
-// mark later in line. An opening mark is followed by a character that is not a
-// space, and a closing mark follows one. A single mark is not a part of a
+// markdownDelimited returns the span of kind between the mark at index and the
+// same mark later in line. An opening mark is followed by a character that is
+// not a space, and a closing mark follows one. A single mark is not a part of a
// double one. A mark of underscores does not open or close inside a word, so
// snake_case stays as text. A code span inside the content is skipped whole.
-func delimited(line string, index int, mark, kind string) (markdownSpan, bool) {
+func markdownDelimited(line string, index int, mark, kind string) (MarkdownSpan, bool) {
char := mark[0]
after := index + len(mark)
- if !strings.HasPrefix(line[index:], mark) || after >= len(line) || isSpace(line[after]) {
- return markdownSpan{}, false
+ if !strings.HasPrefix(line[index:], mark) || after >= len(line) || markdownIsSpace(line[after]) {
+ return MarkdownSpan{}, false
}
if len(mark) == 1 && (line[after] == char || index > 0 && line[index-1] == char) {
- return markdownSpan{}, false
+ return MarkdownSpan{}, false
}
- if char == '_' && index > 0 && wordCharBefore(line[:index]) {
- return markdownSpan{}, false
+ if char == '_' && index > 0 && markdownWordCharBefore(line[:index]) {
+ return MarkdownSpan{}, false
}
for closing := after + 1; closing < len(line); closing++ {
closing++
continue
case line[closing] == '`':
- span, found := codeSpan(line, closing)
+ span, found := markdownCodeSpan(line, closing)
if found {
closing = span.end - 1
}
continue
- case !strings.HasPrefix(line[closing:], mark) || isSpace(line[closing-1]):
+ case !strings.HasPrefix(line[closing:], mark) || markdownIsSpace(line[closing-1]):
continue
}
continue
}
- if char == '_' && wordChar(line[end:]) {
+ if char == '_' && markdownWordChar(line[end:]) {
continue
}
- return markdownSpan{
+ return MarkdownSpan{
kind: kind,
content: line[after:closing],
end: end,
}, true
}
- return markdownSpan{}, false
+ return MarkdownSpan{}, false
}
-// backticks returns the length of the run of backticks at the start of text.
-func backticks(text string) int {
+// markdownBackticks returns the length of the run of backticks at the start of
+// text.
+func markdownBackticks(text string) int {
return len(text) - len(strings.TrimLeft(text, "`"))
}
-// isSpace tells if char is a space, a tab, or a line break.
-func isSpace(char byte) bool {
+// markdownIsSpace tells if char is a space, a tab, or a line break.
+func markdownIsSpace(char byte) bool {
return char == ' ' || char == '\t' || char == '\n' || char == '\r'
}
-// wordCharBefore tells if the character that ends text is a letter or a digit.
-func wordCharBefore(text string) bool {
+// markdownWordCharBefore tells if the character that ends text is a letter or a
+// digit.
+func markdownWordCharBefore(text string) bool {
char, _ := utf8.DecodeLastRuneInString(text)
return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
}
-// wordChar tells if the character that starts text is a letter or a digit. An
-// underscore next to one is a part of a word, such as in snake_case.
-func wordChar(text string) bool {
+// markdownWordChar tells if the character that starts text is a letter or a
+// digit. An underscore next to one is a part of a word, such as in snake_case.
+func markdownWordChar(text string) bool {
char, _ := utf8.DecodeRuneInString(text)
return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
}