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("") renderNodes(b, node, source) b.WriteString("\n\n") case *ast.Blockquote: b.WriteString("
") renderNodes(b, node, source) trimTrailingNewlines(b) b.WriteString("\n\n") case *ast.List: renderList(b, node, source) b.WriteString("\n") case *ast.FencedCodeBlock, *ast.CodeBlock: b.WriteString("
")
writeRawLines(b, n, source)
b.WriteString("\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("")
renderNodes(b, node, source)
b.WriteString("")
case *ast.Link:
fmt.Fprintf(b, ``, escapeHTML(string(node.Destination)))
renderNodes(b, node, source)
b.WriteString("")
case *ast.AutoLink:
url := string(node.URL(source))
fmt.Fprintf(b, `%s`, 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
}