From 684d0b6a57d2eb79730ade63baccdc6e59bebc29 Mon Sep 17 00:00:00 2001 From: Saya Andy Date: Sat, 1 Aug 2026 19:58:57 +0700 Subject: feat: repurpose metadata parser as article transcoder for saya.uz and telegram --- internal/transcoder/markdown.go | 164 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 internal/transcoder/markdown.go (limited to 'internal/transcoder/markdown.go') 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("") + 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 +} -- cgit v1.3.1+13