fluid-telegram/internal/adapter/render.go

98 lines
3 KiB
Go
Raw Normal View History

// Package adapter implements the R-1 connector contract
// (contracts/r1.openapi.yaml): an ordinary HTTP service that renders a
// composed post into a Telegram message and never writes to Telegram beyond
// that.
package adapter
import (
"fmt"
"regexp"
"strings"
)
// ErrUnrenderable marks a post construct the converter cannot represent
// faithfully. Per docs/adapter-contract.md, that fails the publication rather
// than degrading it silently.
type ErrUnrenderable struct {
Reason string
}
func (e *ErrUnrenderable) Error() string {
return fmt.Sprintf("cannot render post: %s", e.Reason)
}
var (
reHeading = regexp.MustCompile(`(?m)^#{1,6}[ \t]+(.+)$`)
reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`)
reItalic = regexp.MustCompile(`_(.+?)_`)
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
reFence = regexp.MustCompile("(?s)```[a-zA-Z0-9]*\n(.*?)```")
reTableRow = regexp.MustCompile(`(?m)^\|.*\|[ \t]*$`)
)
// RenderHTML converts a composed post's Markdown into Telegram's HTML parse
// mode, per the table in docs/adapter-contract.md. It is deterministic: the
// adapter must never compose, rewrite, shorten or embellish the text it is
// given, only translate its markup.
func RenderHTML(body string) (string, error) {
if strings.Contains(body, "<") || strings.Contains(body, ">") {
return "", &ErrUnrenderable{Reason: "body contains raw angle brackets, which HTML parse mode would interpret as tags"}
}
s := body
// Fenced code -> <pre>. Must run before bold/italic so markup inside a
// code block is preserved literally, not reinterpreted.
s = reFence.ReplaceAllStringFunc(s, func(m string) string {
inner := reFence.FindStringSubmatch(m)[1]
return "<pre>" + strings.TrimRight(inner, "\n") + "</pre>"
})
// Tables have no Telegram equivalent; flatten each row to a plain line.
s = reTableRow.ReplaceAllStringFunc(s, func(row string) string {
cells := strings.Split(strings.Trim(strings.TrimSpace(row), "|"), "|")
if isSeparatorRow(cells) {
return ""
}
for i, c := range cells {
cells[i] = strings.TrimSpace(c)
}
return strings.Join(cells, " — ")
})
s = strings.ReplaceAll(s, "\n\n\n", "\n\n") // collapse a blank line left by a stripped separator row
// Headings -> a bold line.
s = reHeading.ReplaceAllString(s, "<b>$1</b>")
// Links before bold/italic, since link text may itself carry emphasis
// markers that must not be double-escaped.
s = reLink.ReplaceAllStringFunc(s, func(m string) string {
g := reLink.FindStringSubmatch(m)
return fmt.Sprintf(`<a href="%s">%s</a>`, escapeAttr(g[2]), g[1])
})
s = reBoldStar.ReplaceAllString(s, "<b>$1</b>")
s = reItalic.ReplaceAllString(s, "<i>$1</i>")
return strings.TrimSpace(s), nil
}
func isSeparatorRow(cells []string) bool {
for _, c := range cells {
c = strings.TrimSpace(c)
if c == "" {
continue
}
if strings.Trim(c, "-: ") != "" {
return false
}
}
return true
}
func escapeAttr(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}