1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
|
package transcoder
import (
"fmt"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
xast "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/text"
)
// telegramMD parses CommonMark once and is reused across calls.
var telegramMD = goldmark.New(goldmark.WithExtensions(extension.Strikethrough, extension.Linkify))
// markdownToTelegramHTML converts CommonMark source into the limited HTML
// subset Telegram accepts (parse_mode=HTML). Telegram supports only
// b/i/u/s/a/code/pre/blockquote — block constructs with no Telegram tag
// (paragraphs, headings, lists) are flattened to text + newlines. Anything
// outside the subset is dropped rather than emitted, so a message is never
// rejected for an unsupported tag.
func markdownToTelegramHTML(src string) string {
source := []byte(src)
doc := telegramMD.Parser().Parse(text.NewReader(source))
var b strings.Builder
renderNodes(&b, doc, source)
// Collapse the runs of blank lines block rendering can leave behind.
out := strings.TrimSpace(b.String())
for strings.Contains(out, "\n\n\n") {
out = strings.ReplaceAll(out, "\n\n\n", "\n\n")
}
return out
}
func renderNodes(b *strings.Builder, parent ast.Node, source []byte) {
for n := parent.FirstChild(); n != nil; n = n.NextSibling() {
renderNode(b, n, source)
}
}
func renderNode(b *strings.Builder, n ast.Node, source []byte) {
switch node := n.(type) {
case *ast.Document:
renderNodes(b, node, source)
case *ast.Paragraph, *ast.TextBlock:
renderNodes(b, node, source)
b.WriteString("\n\n")
case *ast.Heading:
// Telegram has no headings; render the line in bold.
b.WriteString("<b>")
renderNodes(b, node, source)
b.WriteString("</b>\n\n")
case *ast.Blockquote:
b.WriteString("<blockquote>")
renderNodes(b, node, source)
trimTrailingNewlines(b)
b.WriteString("</blockquote>\n\n")
case *ast.List:
renderList(b, node, source)
b.WriteString("\n")
case *ast.FencedCodeBlock, *ast.CodeBlock:
b.WriteString("<pre>")
writeRawLines(b, n, source)
b.WriteString("</pre>\n\n")
case *ast.ThematicBreak:
// horizontal rule — nothing meaningful in a Telegram message
// --- inline ---
case *ast.Text:
b.WriteString(escapeHTML(string(node.Segment.Value(source))))
if node.HardLineBreak() || node.SoftLineBreak() {
b.WriteByte('\n')
}
case *ast.String:
b.WriteString(escapeHTML(string(node.Value)))
case *ast.Emphasis:
tag := "i"
if node.Level == 2 {
tag = "b"
}
fmt.Fprintf(b, "<%s>", tag)
renderNodes(b, node, source)
fmt.Fprintf(b, "</%s>", tag)
case *xast.Strikethrough:
b.WriteString("<s>")
renderNodes(b, node, source)
b.WriteString("</s>")
case *ast.CodeSpan:
b.WriteString("<code>")
renderNodes(b, node, source)
b.WriteString("</code>")
case *ast.Link:
fmt.Fprintf(b, `<a href="%s">`, escapeHTML(string(node.Destination)))
renderNodes(b, node, source)
b.WriteString("</a>")
case *ast.AutoLink:
url := string(node.URL(source))
fmt.Fprintf(b, `<a href="%s">%s</a>`, escapeHTML(url), escapeHTML(url))
case *ast.Image:
// Images can't render inline in text; keep the alt text only.
renderNodes(b, node, source)
case *ast.RawHTML, *ast.HTMLBlock:
// Drop raw HTML — it is almost certainly not in Telegram's tag subset.
default:
// Unknown node: recurse so inline text inside it is not lost.
renderNodes(b, n, source)
}
}
func renderList(b *strings.Builder, list *ast.List, source []byte) {
i := list.Start
for item := list.FirstChild(); item != nil; item = item.NextSibling() {
if list.IsOrdered() {
fmt.Fprintf(b, "%d. ", i)
i++
} else {
b.WriteString("• ")
}
renderNodes(b, item, source)
trimTrailingNewlines(b)
b.WriteByte('\n')
}
}
func writeRawLines(b *strings.Builder, n ast.Node, source []byte) {
lines := n.Lines()
for i := 0; i < lines.Len(); i++ {
seg := lines.At(i)
b.WriteString(escapeHTML(string(seg.Value(source))))
}
}
func trimTrailingNewlines(b *strings.Builder) {
s := strings.TrimRight(b.String(), "\n")
b.Reset()
b.WriteString(s)
}
// escapeHTML escapes the three characters Telegram's HTML parser treats as
// markup. Quotes are escaped too so the value is safe inside an href="...".
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&")
s = strings.ReplaceAll(s, "<", "<")
s = strings.ReplaceAll(s, ">", ">")
s = strings.ReplaceAll(s, `"`, """)
return s
}
|