summaryrefslogtreecommitdiff
path: root/internal/transcoder/markdown.go
diff options
from:
to:
context:
space:
mode:
Diffstat (limited to 'internal/transcoder/markdown.go')
-rw-r--r--internal/transcoder/markdown.go164
1 files changed, 164 insertions, 0 deletions
diff --git a/internal/transcoder/markdown.go b/internal/transcoder/markdown.go
new file mode 100644
index 0000000..975c3bc
--- /dev/null
+++ b/internal/transcoder/markdown.go
@@ -0,0 +1,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, "&", "&amp;")
+ s = strings.ReplaceAll(s, "<", "&lt;")
+ s = strings.ReplaceAll(s, ">", "&gt;")
+ s = strings.ReplaceAll(s, `"`, "&quot;")
+ return s
+}