+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
+// the marks, the index after the closing mark, and the URL of a link.
+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) {
+ trimmed := strings.TrimLeft(line, " ")
+ if len(line)-len(trimmed) > 3 || !strings.HasPrefix(trimmed, "```") {
+ return "", ""
+ }
+
+ run := len(trimmed) - len(strings.TrimLeft(trimmed, "`"))
+ info := strings.TrimSpace(trimmed[run:])
+ if strings.Contains(info, "`") {
+ return "", ""
+ }
+
+ language, _, _ := strings.Cut(info, " ")
+
+ 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 {
+ if fence == "" {
+ return -1
+ }
+
+ for end := index + 1; end < len(lines); end++ {
+ trimmed := strings.TrimSpace(lines[end])
+ if strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == "" {
+ return end
+ }
+ }
+
+ return -1
+}
+
+// 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 {
+ trimmed := strings.TrimLeft(line, " ")
+ if len(line)-len(trimmed) > 3 {
+ return ""
+ }
+
+ level := len(trimmed) - len(strings.TrimLeft(trimmed, "#"))
+ if level == 0 || level > 6 {
+ return ""
+ }
+
+ rest := trimmed[level:]
+ if rest != "" && rest[0] != ' ' && rest[0] != '\t' {
+ return ""
+ }
+
+ rest = strings.TrimSpace(rest)
+ closed := strings.TrimRight(rest, "#")
+ if closed == "" || strings.HasSuffix(closed, " ") {
+ rest = strings.TrimSpace(closed)
+ }
+
+ 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) {
+ switch line[index] {
+ case '`':
+ return codeSpan(line, index)
+ case '[':
+ return linkSpan(line, index)
+ case '~':
+ return delimited(line, index, "~~", "strikethrough")
+ case '*', '_':
+ double := line[index : index+1]
+ double += double
+ span, found := delimited(line, index, double, "bold")
+ if found {
+ return span, true
+ }
+
+ return delimited(line, index, line[index:index+1], "italic")
+ default:
+ 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:])
+ for search := index + run; search < len(line); {
+ next := strings.IndexByte(line[search:], '`')
+ if next < 0 {
+ break
+ }
+
+ next += search
+ length := backticks(line[next:])
+ if length != run {
+ search = next + length
+ continue
+ }
+
+ content := line[index+run : next]
+ if strings.TrimSpace(content) == "" {
+ break
+ }
+
+ if len(content) > 1 && content[0] == ' ' && content[len(content)-1] == ' ' {
+ content = content[1 : len(content)-1]
+ }
+
+ return markdownSpan{
+ kind: "code",
+ content: content,
+ end: next + run,
+ }, true
+ }
+
+ 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) {
+ depth := 0
+ for closing := index; closing < len(line); closing++ {
+ switch line[closing] {
+ case '\\':
+ closing++
+ case '[':
+ depth++
+ case ']':
+ depth--
+ }
+
+ if depth > 0 {
+ continue
+ }
+
+ rest := line[closing+1:]
+ if closing == index+1 || !strings.HasPrefix(rest, "(") {
+ return markdownSpan{}, false
+ }
+
+ url, _, found := strings.Cut(rest[1:], ")")
+ if !found || url == "" || strings.ContainsAny(url, " \t") {
+ return markdownSpan{}, false
+ }
+
+ return markdownSpan{
+ kind: "text_link",
+ content: line[index+1 : closing],
+ end: closing + 2 + len(url) + 1,
+ url: url,
+ }, true
+ }
+
+ 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
+// 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) {
+ char := mark[0]
+ after := index + len(mark)
+ if !strings.HasPrefix(line[index:], mark) || after >= len(line) || isSpace(line[after]) {
+ return markdownSpan{}, false
+ }
+
+ if len(mark) == 1 && (line[after] == char || index > 0 && line[index-1] == char) {
+ return markdownSpan{}, false
+ }
+
+ if char == '_' && index > 0 && wordCharBefore(line[:index]) {
+ return markdownSpan{}, false
+ }
+
+ for closing := after + 1; closing < len(line); closing++ {
+ switch {
+ case line[closing] == '\\':
+ closing++
+ continue
+ case line[closing] == '`':
+ span, found := codeSpan(line, closing)
+ if found {
+ closing = span.end - 1
+ }
+
+ continue
+ case !strings.HasPrefix(line[closing:], mark) || isSpace(line[closing-1]):
+ continue
+ }
+
+ end := closing + len(mark)
+ if len(mark) == 1 && (line[closing-1] == char || end < len(line) && line[end] == char) {
+ continue
+ }
+
+ if char == '_' && wordChar(line[end:]) {
+ continue
+ }
+
+ return markdownSpan{
+ kind: kind,
+ content: line[after:closing],
+ end: end,
+ }, true
+ }
+
+ return markdownSpan{}, false
+}
+
+// backticks returns the length of the run of backticks at the start of text.
+func backticks(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 {
+ 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 {
+ 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 {
+ char, _ := utf8.DecodeRuneInString(text)
+ return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
+}