@@ -8,22 +8,37 @@ import (
88)
99
1010var (
11- reFenceOpen = regexp .MustCompile ("^```(\\ w*)" )
12- reInlineCode = regexp .MustCompile ("`([^`\n ]+)`" )
13- reBoldStar = regexp .MustCompile (`\*\*(.+?)\*\*` )
14- reBoldUnder = regexp .MustCompile (`__(.+?)__` )
15- reItalicStar = regexp .MustCompile (`\*([^*\n]+)\*` )
16- reItalicUnder = regexp .MustCompile (`_([^_\n]+)_` )
17- reStrike = regexp .MustCompile (`~~(.+?)~~` )
18- reLink = regexp .MustCompile (`\[([^\]]+)\]\(([^)\s]+)\)` )
19- reHeader = regexp .MustCompile (`^#{1,6}\s+(.+)$` )
20- reBulletItem = regexp .MustCompile (`^(\s*)[-*]\s+(.+)$` )
21- reNumItem = regexp .MustCompile (`^(\s*\d+\.)\s+(.+)$` )
22- reCheckbox = regexp .MustCompile (`^\[[ ]\]\s*` )
11+ reFenceOpen = regexp .MustCompile ("^```(\\ w*)" )
12+ reInlineCode = regexp .MustCompile ("`([^`\n ]+)`" )
13+ reBoldStar = regexp .MustCompile (`\*\*(.+?)\*\*` )
14+ reBoldUnder = regexp .MustCompile (`__(.+?)__` )
15+ reItalicStar = regexp .MustCompile (`\*([^*\n]+)\*` )
16+ reItalicUnder = regexp .MustCompile (`_([^_\n]+)_` )
17+ reStrike = regexp .MustCompile (`~~(.+?)~~` )
18+ reSpoiler = regexp .MustCompile (`\|\|(.+?)\|\|` )
19+ reLink = regexp .MustCompile (`\[([^\]]+)\]\(([^)\s]+)\)` )
20+ reHeader = regexp .MustCompile (`^(#{1,6})\s+(.+)$` )
21+ reBulletItem = regexp .MustCompile (`^(\s*)[-*]\s+(.+)$` )
22+ reNumItem = regexp .MustCompile (`^(\s*\d+\.)\s+(.+)$` )
23+ reCheckbox = regexp .MustCompile (`^\[[ ]\]\s*` )
2324 reCheckboxDone = regexp .MustCompile (`^\[[xX]\]\s*` )
24- rePlaceholder = regexp .MustCompile (`\x00(\d+)\x00` )
25+ reHR = regexp .MustCompile (`^\s*(?:(?:-\s*){3,}|(?:\*\s*){3,}|(?:_\s*){3,})$` )
26+ reBlockquote = regexp .MustCompile (`^>\s?(.*)$` )
27+ reTableSep = regexp .MustCompile (`^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$` )
28+ reTableRow = regexp .MustCompile (`^\s*\|.*\|\s*$` )
29+ reFootnoteRef = regexp .MustCompile (`\[\^([\w-]+)\]` )
30+ reFootnoteDef = regexp .MustCompile (`^\[\^([\w-]+)\]:\s*(.+)$` )
31+ reEscape = regexp .MustCompile ("\\ \\ ([\\ \\ *_~`\\ [\\ ]|])" )
32+ rePlaceholder = regexp .MustCompile (`\x00(\d+)\x00` )
2533)
2634
35+ const hrLine = "──────────"
36+
37+ var supDigits = map [rune ]rune {
38+ '0' : '⁰' , '1' : '¹' , '2' : '²' , '3' : '³' , '4' : '⁴' ,
39+ '5' : '⁵' , '6' : '⁶' , '7' : '⁷' , '8' : '⁸' , '9' : '⁹' ,
40+ }
41+
2742// markdownToTelegramHTML converts LLM Markdown output to Telegram-compatible HTML.
2843func markdownToTelegramHTML (src string ) string {
2944 var sb strings.Builder
@@ -53,6 +68,52 @@ func markdownToTelegramHTML(src string) string {
5368 continue
5469 }
5570
71+ // Horizontal rule
72+ if reHR .MatchString (line ) {
73+ sb .WriteString (hrLine + "\n " )
74+ i ++
75+ continue
76+ }
77+
78+ // Markdown table: row line followed by separator line
79+ if reTableRow .MatchString (line ) && i + 1 < len (lines ) && reTableSep .MatchString (lines [i + 1 ]) {
80+ var rows [][]string
81+ rows = append (rows , splitTableRow (line ))
82+ i += 2 // skip header + separator
83+ for i < len (lines ) && reTableRow .MatchString (lines [i ]) {
84+ rows = append (rows , splitTableRow (lines [i ]))
85+ i ++
86+ }
87+ sb .WriteString (renderTable (rows ))
88+ sb .WriteByte ('\n' )
89+ continue
90+ }
91+
92+ // Blockquote: one or more consecutive > lines
93+ if reBlockquote .MatchString (line ) {
94+ var quoteLines []string
95+ for i < len (lines ) && reBlockquote .MatchString (lines [i ]) {
96+ m := reBlockquote .FindStringSubmatch (lines [i ])
97+ quoteLines = append (quoteLines , processInline (m [1 ]))
98+ i ++
99+ }
100+ content := strings .Join (quoteLines , "\n " )
101+ openTag := "<blockquote>"
102+ if len (content ) > 300 || len (quoteLines ) > 5 {
103+ openTag = "<blockquote expandable>"
104+ }
105+ sb .WriteString (openTag + content + "</blockquote>\n " )
106+ continue
107+ }
108+
109+ // Footnote definition: [^id]: text
110+ if m := reFootnoteDef .FindStringSubmatch (line ); m != nil {
111+ label := toSuper (m [1 ])
112+ sb .WriteString ("<i>" + label + " " + processInline (m [2 ]) + "</i>\n " )
113+ i ++
114+ continue
115+ }
116+
56117 sb .WriteString (processLine (line ))
57118 sb .WriteByte ('\n' )
58119 i ++
@@ -62,7 +123,16 @@ func markdownToTelegramHTML(src string) string {
62123
63124func processLine (line string ) string {
64125 if m := reHeader .FindStringSubmatch (line ); m != nil {
65- return "<b>" + processInline (m [1 ]) + "</b>"
126+ level := len (m [1 ])
127+ inner := processInline (m [2 ])
128+ switch level {
129+ case 1 :
130+ return "<b><u>" + inner + "</u></b>"
131+ case 2 :
132+ return "<b>" + inner + "</b>"
133+ default :
134+ return "<b><i>" + inner + "</i></b>"
135+ }
66136 }
67137 if m := reBulletItem .FindStringSubmatch (line ); m != nil {
68138 indent := strings .Repeat (" " , len (m [1 ])/ 2 )
@@ -84,19 +154,32 @@ func processLine(line string) string {
84154}
85155
86156func processInline (text string ) string {
87- // Extract inline code spans first (protect their content)
88157 var spans []string
158+ push := func (s string ) string {
159+ spans = append (spans , s )
160+ return fmt .Sprintf ("\x00 %d\x00 " , len (spans )- 1 )
161+ }
162+
163+ // 1) Backslash escapes → placeholder with the literal char.
164+ // Done first so \*, \_, etc. are not consumed by later regexes.
165+ text = reEscape .ReplaceAllStringFunc (text , func (m string ) string {
166+ return push (html .EscapeString (m [1 :]))
167+ })
168+
169+ // 2) Inline code → placeholder (content is HTML-escaped).
89170 text = reInlineCode .ReplaceAllStringFunc (text , func (m string ) string {
90171 inner := html .EscapeString (m [1 : len (m )- 1 ])
91- spans = append (spans , "<code>" + inner + "</code>" )
92- return fmt .Sprintf ("\x00 %d\x00 " , len (spans )- 1 )
172+ return push ("<code>" + inner + "</code>" )
93173 })
94174
95- // HTML-escape remaining text (*, _, ~, [, ], (, ) are not HTML special chars)
175+ // 3) HTML-escape the remaining raw text without touching placeholders.
96176 text = escapeNonPlaceholders (text )
97177
98- // Apply formatting (order matters)
178+ // 4) Inline formatting. Order: links → spoiler → strike → bold → italic.
99179 text = reLink .ReplaceAllString (text , `<a href="$2">$1</a>` )
180+ text = reSpoiler .ReplaceAllStringFunc (text , func (m string ) string {
181+ return "<tg-spoiler>" + m [2 :len (m )- 2 ] + "</tg-spoiler>"
182+ })
100183 text = reStrike .ReplaceAllStringFunc (text , func (m string ) string {
101184 return "<s>" + m [2 :len (m )- 2 ] + "</s>"
102185 })
@@ -113,7 +196,12 @@ func processInline(text string) string {
113196 return "<i>" + m [1 :len (m )- 1 ] + "</i>"
114197 })
115198
116- // Restore code spans
199+ // 5) Footnote references [^N] → superscript.
200+ text = reFootnoteRef .ReplaceAllStringFunc (text , func (m string ) string {
201+ return toSuper (m [2 : len (m )- 1 ])
202+ })
203+
204+ // 6) Restore placeholders (code spans + escaped literals).
117205 text = rePlaceholder .ReplaceAllStringFunc (text , func (m string ) string {
118206 var idx int
119207 fmt .Sscanf (m [1 :len (m )- 1 ], "%d" , & idx )
@@ -141,3 +229,82 @@ func escapeNonPlaceholders(text string) string {
141229 sb .WriteString (html .EscapeString (text [last :]))
142230 return sb .String ()
143231}
232+
233+ func toSuper (s string ) string {
234+ var sb strings.Builder
235+ for _ , r := range s {
236+ if sup , ok := supDigits [r ]; ok {
237+ sb .WriteRune (sup )
238+ } else {
239+ sb .WriteRune (r )
240+ }
241+ }
242+ return sb .String ()
243+ }
244+
245+ func splitTableRow (line string ) []string {
246+ line = strings .TrimSpace (line )
247+ line = strings .Trim (line , "|" )
248+ cells := strings .Split (line , "|" )
249+ for i , c := range cells {
250+ cells [i ] = strings .TrimSpace (c )
251+ }
252+ return cells
253+ }
254+
255+ // renderTable emits a Telegram <pre> block with space-padded columns and
256+ // box-drawing separators. Telegram renders <pre> in a monospace font so
257+ // single-character widths align correctly.
258+ func renderTable (rows [][]string ) string {
259+ if len (rows ) == 0 {
260+ return ""
261+ }
262+ ncols := 0
263+ for _ , r := range rows {
264+ if len (r ) > ncols {
265+ ncols = len (r )
266+ }
267+ }
268+ widths := make ([]int , ncols )
269+ for _ , r := range rows {
270+ for i , c := range r {
271+ if w := runeCount (c ); w > widths [i ] {
272+ widths [i ] = w
273+ }
274+ }
275+ }
276+ var sb strings.Builder
277+ sb .WriteString ("<pre>" )
278+ for rowIdx , r := range rows {
279+ for i := 0 ; i < ncols ; i ++ {
280+ var cell string
281+ if i < len (r ) {
282+ cell = r [i ]
283+ }
284+ if i > 0 {
285+ sb .WriteString (" │ " )
286+ }
287+ sb .WriteString (html .EscapeString (cell ))
288+ sb .WriteString (strings .Repeat (" " , widths [i ]- runeCount (cell )))
289+ }
290+ sb .WriteByte ('\n' )
291+ if rowIdx == 0 {
292+ for i := 0 ; i < ncols ; i ++ {
293+ if i > 0 {
294+ sb .WriteString ("─┼─" )
295+ }
296+ sb .WriteString (strings .Repeat ("─" , widths [i ]))
297+ }
298+ sb .WriteByte ('\n' )
299+ }
300+ }
301+ return strings .TrimRight (sb .String (), "\n " ) + "</pre>"
302+ }
303+
304+ func runeCount (s string ) int {
305+ n := 0
306+ for range s {
307+ n ++
308+ }
309+ return n
310+ }
0 commit comments