]> git.giacomolli.uk - onechat/commitdiff
Share the Markdown marks and block parser
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Tue, 10 Nov 2026 02:26:46 +0000 (23:26 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Tue, 10 Nov 2026 02:26:46 +0000 (23:26 -0300)
The inline and block parser supplies the same closed marks to each
renderer. Telegram keeps its existing text and entities while terminal
rendering can use the same rules for unclosed marks and code fences.

markdown.go [new file with mode: 0644]
telegram.go
telegram_markdown.go

diff --git a/markdown.go b/markdown.go
new file mode 100644 (file)
index 0000000..0f5f941
--- /dev/null
@@ -0,0 +1,268 @@
+package main
+
+import (
+       "strings"
+       "unicode"
+       "unicode/utf8"
+)
+
+// markdownPunctuation holds the ASCII characters that a backslash makes plain.
+const markdownPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
+
+// 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 {
+       kind    string
+       content string
+       end     int
+       url     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 "", ""
+       }
+
+       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]
+}
+
+// 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 markdownClosesFence(lines[end], fence) {
+                       return end
+               }
+       }
+
+       return -1
+}
+
+// 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, "`") == ""
+}
+
+// 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 ""
+       }
+
+       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
+}
+
+// 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 markdownCodeSpan(line, index)
+       case '[':
+               return markdownLinkSpan(line, index)
+       case '~':
+               return markdownDelimited(line, index, "~~", "strikethrough")
+       case '*', '_':
+               double := line[index : index+1]
+               double += double
+               span, found := markdownDelimited(line, index, double, "bold")
+               if found {
+                       return span, true
+               }
+
+               return markdownDelimited(line, index, line[index:index+1], "italic")
+       default:
+               return MarkdownSpan{}, false
+       }
+}
+
+// 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 {
+                       break
+               }
+
+               next += search
+               length := markdownBackticks(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
+}
+
+// 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] {
+               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
+}
+
+// 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 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) || markdownIsSpace(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 && markdownWordCharBefore(line[:index]) {
+               return MarkdownSpan{}, false
+       }
+
+       for closing := after + 1; closing < len(line); closing++ {
+               switch {
+               case line[closing] == '\\':
+                       closing++
+                       continue
+               case line[closing] == '`':
+                       span, found := markdownCodeSpan(line, closing)
+                       if found {
+                               closing = span.end - 1
+                       }
+
+                       continue
+               case !strings.HasPrefix(line[closing:], mark) || markdownIsSpace(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 == '_' && markdownWordChar(line[end:]) {
+                       continue
+               }
+
+               return MarkdownSpan{
+                       kind:    kind,
+                       content: line[after:closing],
+                       end:     end,
+               }, true
+       }
+
+       return MarkdownSpan{}, false
+}
+
+// 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, "`"))
+}
+
+// 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'
+}
+
+// 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))
+}
+
+// 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))
+}
index 4e2bf7cd3dbf92736c201650ad087043a2502c4e..f2c687ad967a9956d482d983fadee252ae924ed8 100644 (file)
@@ -721,10 +721,10 @@ func telegramRichCut(markdown string, limit int) (int, string, string) {
 
                end += start + 1
                text := strings.TrimRight(markdown[start:end], "\r\n")
-               _, opens := telegramCodeFence(text)
+               _, opens := markdownCodeFence(text)
                switch {
                case fence != "":
-                       if telegramClosesFence(text, fence) {
+                       if markdownClosesFence(text, fence) {
                                fence = ""
                        } else if end > len(opening)+1 && end+len(fence)+1 <= limit {
                                code, inFence, inOpening = end, fence, opening
index 712cf2802c43a5b7c2c054048ca783ac995d75a3..a978036e2848acaa61db758ff94a0c9f994d6f8b 100644 (file)
@@ -3,14 +3,8 @@ package main
 import (
        "slices"
        "strings"
-       "unicode"
-       "unicode/utf8"
 )
 
-// telegramMarkdownPunctuation holds the ASCII characters that a backslash makes
-// plain.
-const telegramMarkdownPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
-
 var (
        // telegramTagEscaper writes the characters that rich Markdown reads as HTML
        // as HTML entities.
@@ -39,9 +33,9 @@ func telegramMarkdownEntities(markdown string) (string, []TelegramEntity) {
                }
 
                line := lines[index]
-               language, fence := telegramCodeFence(line)
-               end := telegramClosingFence(lines, index, fence)
-               heading := telegramHeadingText(line)
+               language, fence := markdownCodeFence(line)
+               end := markdownClosingFence(lines, index, fence)
+               heading := markdownHeadingText(line)
                switch {
                case end > index:
                        start := out.units
@@ -95,31 +89,6 @@ func telegramUnescapeTags(markdown string) string {
        return telegramRewriteProse(markdown, telegramTagUnescaper.Replace)
 }
 
-// telegramCodeFence 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 telegramCodeFence(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]
-}
-
-// telegramClosesFence tells if line closes the code block of fence.
-func telegramClosesFence(line, fence string) bool {
-       trimmed := strings.TrimSpace(line)
-       return strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == ""
-}
-
 // telegramUTF16Units returns the number of UTF-16 code units of text.
 func telegramUTF16Units(text string) int {
        units := 0
@@ -164,7 +133,7 @@ func (m *TelegramMarkdownText) inline(line string) {
        plain := 0
        index := 0
        for index < len(line) {
-               if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(telegramMarkdownPunctuation, line[index+1]) >= 0 {
+               if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 {
                        m.add(line[plain:index])
 
                        plain = index + 1
@@ -173,7 +142,7 @@ func (m *TelegramMarkdownText) inline(line string) {
                        continue
                }
 
-               span, found := telegramMarkSpan(line, index)
+               span, found := markdownMarkSpan(line, index)
                if !found {
                        index++
                        continue
@@ -202,59 +171,6 @@ func (m *TelegramMarkdownText) inline(line string) {
        m.add(line[plain:])
 }
 
-// TelegramMarkdownSpan 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 TelegramMarkdownSpan struct {
-       kind    string
-       content string
-       end     int
-       url     string
-}
-
-// telegramClosingFence 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 telegramClosingFence(lines []string, index int, fence string) int {
-       if fence == "" {
-               return -1
-       }
-
-       for end := index + 1; end < len(lines); end++ {
-               if telegramClosesFence(lines[end], fence) {
-                       return end
-               }
-       }
-
-       return -1
-}
-
-// telegramHeadingText returns the text of an ATX heading line without its
-// marks, or "" when line is not a heading or has no text.
-func telegramHeadingText(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
-}
-
 // telegramRewriteProse returns markdown with rewrite applied to each part of a
 // line outside inline code and fenced code blocks. A code block that does not
 // close runs to the end, as in Markdown.
@@ -268,14 +184,14 @@ func telegramRewriteProse(markdown string, rewrite func(string) string) string {
                }
 
                line := lines[index]
-               _, fence := telegramCodeFence(line)
+               _, fence := markdownCodeFence(line)
                if fence == "" {
                        telegramRewriteLineProse(&out, line, rewrite)
 
                        continue
                }
 
-               end := telegramClosingFence(lines, index, fence)
+               end := markdownClosingFence(lines, index, fence)
                if end < 0 {
                        end = len(lines) - 1
                }
@@ -296,13 +212,13 @@ func telegramRewriteLineProse(out *strings.Builder, line string, rewrite func(st
        for index := 0; index < len(line); {
                switch line[index] {
                case '\\':
-                       if index+1 < len(line) && strings.IndexByte(telegramMarkdownPunctuation, line[index+1]) >= 0 {
+                       if index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 {
                                index += 2
                                continue
                        }
                case '`':
-                       end := index + telegramBackticks(line[index:])
-                       span, found := telegramCodeSpan(line, index)
+                       end := index + markdownBackticks(line[index:])
+                       span, found := markdownCodeSpan(line, index)
                        if found {
                                end = span.end
                        }
@@ -321,184 +237,3 @@ func telegramRewriteLineProse(out *strings.Builder, line string, rewrite func(st
 
        out.WriteString(rewrite(line[prose:]))
 }
-
-// telegramMarkSpan 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 telegramMarkSpan(line string, index int) (TelegramMarkdownSpan, bool) {
-       switch line[index] {
-       case '`':
-               return telegramCodeSpan(line, index)
-       case '[':
-               return telegramLinkSpan(line, index)
-       case '~':
-               return telegramDelimited(line, index, "~~", "strikethrough")
-       case '*', '_':
-               double := line[index : index+1]
-               double += double
-               span, found := telegramDelimited(line, index, double, "bold")
-               if found {
-                       return span, true
-               }
-
-               return telegramDelimited(line, index, line[index:index+1], "italic")
-       default:
-               return TelegramMarkdownSpan{}, false
-       }
-}
-
-// telegramCodeSpan 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 telegramCodeSpan(line string, index int) (TelegramMarkdownSpan, bool) {
-       run := telegramBackticks(line[index:])
-       for search := index + run; search < len(line); {
-               next := strings.IndexByte(line[search:], '`')
-               if next < 0 {
-                       break
-               }
-
-               next += search
-               length := telegramBackticks(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 TelegramMarkdownSpan{
-                       kind:    "code",
-                       content: content,
-                       end:     next + run,
-               }, true
-       }
-
-       return TelegramMarkdownSpan{}, false
-}
-
-// telegramLinkSpan returns the link [text](url) that starts at index. The text
-// can hold brackets in pairs, and the URL holds no space.
-func telegramLinkSpan(line string, index int) (TelegramMarkdownSpan, 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 TelegramMarkdownSpan{}, false
-               }
-
-               url, _, found := strings.Cut(rest[1:], ")")
-               if !found || url == "" || strings.ContainsAny(url, " \t") {
-                       return TelegramMarkdownSpan{}, false
-               }
-
-               return TelegramMarkdownSpan{
-                       kind:    "text_link",
-                       content: line[index+1 : closing],
-                       end:     closing + 2 + len(url) + 1,
-                       url:     url,
-               }, true
-       }
-
-       return TelegramMarkdownSpan{}, false
-}
-
-// telegramDelimited 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 telegramDelimited(line string, index int, mark, kind string) (TelegramMarkdownSpan, bool) {
-       char := mark[0]
-       after := index + len(mark)
-       if !strings.HasPrefix(line[index:], mark) || after >= len(line) || telegramIsSpace(line[after]) {
-               return TelegramMarkdownSpan{}, false
-       }
-
-       if len(mark) == 1 && (line[after] == char || index > 0 && line[index-1] == char) {
-               return TelegramMarkdownSpan{}, false
-       }
-
-       if char == '_' && index > 0 && telegramWordCharBefore(line[:index]) {
-               return TelegramMarkdownSpan{}, false
-       }
-
-       for closing := after + 1; closing < len(line); closing++ {
-               switch {
-               case line[closing] == '\\':
-                       closing++
-                       continue
-               case line[closing] == '`':
-                       span, found := telegramCodeSpan(line, closing)
-                       if found {
-                               closing = span.end - 1
-                       }
-
-                       continue
-               case !strings.HasPrefix(line[closing:], mark) || telegramIsSpace(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 == '_' && telegramWordChar(line[end:]) {
-                       continue
-               }
-
-               return TelegramMarkdownSpan{
-                       kind:    kind,
-                       content: line[after:closing],
-                       end:     end,
-               }, true
-       }
-
-       return TelegramMarkdownSpan{}, false
-}
-
-// telegramBackticks returns the length of the run of backticks at the start of
-// text.
-func telegramBackticks(text string) int {
-       return len(text) - len(strings.TrimLeft(text, "`"))
-}
-
-// telegramIsSpace tells if char is a space, a tab, or a line break.
-func telegramIsSpace(char byte) bool {
-       return char == ' ' || char == '\t' || char == '\n' || char == '\r'
-}
-
-// telegramWordCharBefore tells if the character that ends text is a letter or a
-// digit.
-func telegramWordCharBefore(text string) bool {
-       char, _ := utf8.DecodeLastRuneInString(text)
-       return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
-}
-
-// telegramWordChar 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 telegramWordChar(text string) bool {
-       char, _ := utf8.DecodeRuneInString(text)
-       return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
-}