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, "", tag) case *xast.Strikethrough: b.WriteString("") renderNodes(b, node, source) b.WriteString("") case *ast.CodeSpan: 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 }