-// telegramMarkdownEntities 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 telegramMarkdownEntities(markdown string) (string, []TelegramEntity) {
- var out TelegramMarkdownText
- lines := strings.Split(markdown, "\n")
- for index := 0; index < len(lines); index++ {
- if index > 0 {
- out.add("\n")
- }
-
- line := lines[index]
- language, fence := telegramCodeFence(line)
- end := telegramClosingFence(lines, index, fence)
- heading := telegramHeadingText(line)
- switch {
- case end > index:
- start := out.units
- out.add(strings.Join(lines[index+1:end], "\n"))
- out.entity(TelegramEntity{
- Type: "pre",
- Offset: start,
- Length: out.units - start,
- Language: language,
- })
-
- index = end
- case heading != "":
- start := out.units
- out.inline(heading)
- out.entity(TelegramEntity{
- Type: "bold",
- Offset: start,
- Length: out.units - start,
- })
- default:
- out.inline(line)
- }
- }
-
- slices.SortStableFunc(out.entities, func(a, b TelegramEntity) int {
- if a.Offset != b.Offset {
- return a.Offset - b.Offset
- }
-
- return b.Length - a.Length
- })
-
- return out.text.String(), out.entities
-}
-
-// telegramEscapeTags puts a backslash before each < of markdown outside inline
-// code and fenced code blocks, so that rich Markdown shows a tag name as text
-// and reads no raw HTML. A < that a backslash already makes plain stays as it
-// is. A code block that does not close runs to the end, as in Markdown.
-func telegramEscapeTags(markdown string) string {
- var out strings.Builder
- lines := strings.Split(markdown, "\n")
- for index := 0; index < len(lines); index++ {
- if index > 0 {
- out.WriteString("\n")
- }
-
- line := lines[index]
- _, fence := telegramCodeFence(line)
- if fence == "" {
- telegramEscapeLineTags(&out, line)
- continue
- }
-
- end := telegramClosingFence(lines, index, fence)
- if end < 0 {
- end = len(lines) - 1
- }
-
- out.WriteString(strings.Join(lines[index:end+1], "\n"))
- index = end
- }
-
- return out.String()
-}
-
-// 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
- for _, char := range text {
- units++
- if char > 0xFFFF {
- units++
- }
- }
-
- return units
-}