(new): rich message support
Golang lint / lint (pull_request) Successful in 1m20s
Golang lint / lint (push) Successful in 4m8s

(fix): runtime reliability
(tests): regression coverage
(doc): v1.1 release notes
This commit is contained in:
2026-08-12 16:34:44 +03:00
parent 48ddf66540
commit f03a081ed6
83 changed files with 6122 additions and 1925 deletions
+12 -41
View File
@@ -2,7 +2,6 @@ package tgfmt
import (
"strconv"
"strings"
)
// HTML is an escaped Telegram HTML fragment.
@@ -11,43 +10,25 @@ import (
type HTML string
// EscapeHTML escapes special characters for Telegram HTML parse mode.
func EscapeHTML(s string) HTML {
s = strings.ReplaceAll(s, "&", "&")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return HTML(s)
}
func EscapeHTML(s string) HTML { return HTML(escapeHTML(s)) }
// Bold returns h wrapped as bold Telegram HTML text.
func (h HTML) Bold() HTML {
return "<b>" + h + "</b>"
}
func (h HTML) Bold() HTML { return "<b>" + h + "</b>" }
// Italic returns h wrapped as italic Telegram HTML text.
func (h HTML) Italic() HTML {
return "<i>" + h + "</i>"
}
func (h HTML) Italic() HTML { return "<i>" + h + "</i>" }
// Underline returns h wrapped as underlined Telegram HTML text.
func (h HTML) Underline() HTML {
return "<u>" + h + "</u>"
}
func (h HTML) Underline() HTML { return "<u>" + h + "</u>" }
// Strikethrough returns h wrapped as strikethrough Telegram HTML text.
func (h HTML) Strikethrough() HTML {
return "<s>" + h + "</s>"
}
func (h HTML) Strikethrough() HTML { return "<s>" + h + "</s>" }
// Spoiler returns h wrapped as spoiler Telegram HTML text.
func (h HTML) Spoiler() HTML {
return "<tg-spoiler>" + h + "</tg-spoiler>"
}
func (h HTML) Spoiler() HTML { return "<tg-spoiler>" + h + "</tg-spoiler>" }
// Link returns h as a Telegram HTML text link.
func (h HTML) Link(url string) HTML {
return `<a href="` + escapeHTMLAttr(url) + `">` + h + "</a>"
}
func (h HTML) Link(url string) HTML { return `<a href="` + escapeHTMLAttr(url) + `">` + h + "</a>" }
// Mention returns h as a Telegram HTML user mention.
func (h HTML) Mention(userID int64) HTML {
@@ -70,14 +51,10 @@ func (h HTML) TimeFormat(unix int64, format string) HTML {
}
// InlineCode returns h wrapped as inline code Telegram HTML text.
func (h HTML) InlineCode() HTML {
return "<code>" + h + "</code>"
}
func (h HTML) InlineCode() HTML { return "<code>" + h + "</code>" }
// BlockCode returns h wrapped as a Telegram HTML code block.
func (h HTML) BlockCode() HTML {
return "<pre>" + h + "</pre>"
}
func (h HTML) BlockCode() HTML { return "<pre>" + h + "</pre>" }
// BlockCodeLanguage returns h wrapped as a Telegram HTML code block with language.
func (h HTML) BlockCodeLanguage(lang string) HTML {
@@ -85,15 +62,9 @@ func (h HTML) BlockCodeLanguage(lang string) HTML {
}
// Quote returns h as a Telegram HTML blockquote.
func (h HTML) Quote() HTML {
return "<blockquote>" + h + "</blockquote>"
}
func (h HTML) Quote() HTML { return "<blockquote>" + h + "</blockquote>" }
// QuoteExpandable returns h as a Telegram HTML expandable blockquote.
func (h HTML) QuoteExpandable() HTML {
return "<blockquote expandable>" + h + "</blockquote>"
}
func (h HTML) QuoteExpandable() HTML { return "<blockquote expandable>" + h + "</blockquote>" }
func escapeHTMLAttr(s string) HTML {
return EscapeHTML(s)
}
func escapeHTMLAttr(s string) HTML { return HTML(escapeHTML(s)) }
+6 -22
View File
@@ -2,7 +2,6 @@ package tgfmt
import (
"strconv"
"strings"
)
// Markdown is an escaped legacy Telegram Markdown fragment.
@@ -13,27 +12,16 @@ type Markdown string
// EscapeMarkdown escapes special characters for legacy Telegram Markdown.
//
// Deprecated: Use EscapeMarkdownV2 instead.
func EscapeMarkdown(s string) Markdown {
s = strings.ReplaceAll(s, "_", `\_`)
s = strings.ReplaceAll(s, "*", `\*`)
s = strings.ReplaceAll(s, "[", `\[`)
return Markdown(strings.ReplaceAll(s, "`", "\\`"))
}
func EscapeMarkdown(s string) Markdown { return Markdown(escapeMD(s)) }
// Bold returns s wrapped as bold legacy Telegram Markdown text.
func (s Markdown) Bold() Markdown {
return "*" + s + "*"
}
func (s Markdown) Bold() Markdown { return "*" + s + "*" }
// Italic returns s wrapped as italic legacy Telegram Markdown text.
func (s Markdown) Italic() Markdown {
return "_" + s + "_"
}
func (s Markdown) Italic() Markdown { return "_" + s + "_" }
// Link returns s as a legacy Telegram Markdown text link.
func (s Markdown) Link(url string) Markdown {
return "[" + s + "](" + Markdown(url) + ")"
}
func (s Markdown) Link(url string) Markdown { return "[" + s + "](" + Markdown(url) + ")" }
// Mention returns s as a legacy Telegram Markdown user mention.
func (s Markdown) Mention(userID int64) Markdown {
@@ -41,14 +29,10 @@ func (s Markdown) Mention(userID int64) Markdown {
}
// InlineCode returns s wrapped as inline code legacy Telegram Markdown text.
func (s Markdown) InlineCode() Markdown {
return "`" + s + "`"
}
func (s Markdown) InlineCode() Markdown { return "`" + s + "`" }
// BlockCode returns s wrapped as a legacy Telegram Markdown code block.
func (s Markdown) BlockCode() Markdown {
return "```\n" + s + "\n```"
}
func (s Markdown) BlockCode() Markdown { return "```\n" + s + "\n```" }
// BlockCodeLanguage returns s wrapped as a legacy Telegram Markdown code block.
func (s Markdown) BlockCodeLanguage(lang string) Markdown {
+9 -31
View File
@@ -12,38 +12,22 @@ type MarkdownV2 string
// EscapeMarkdownV2 escapes special characters for Telegram MarkdownV2.
// https://core.telegram.org/bots/api#markdownv2-style
func EscapeMarkdownV2(s string) MarkdownV2 {
symbols := []string{"\\", "_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"}
for _, symbol := range symbols {
s = strings.ReplaceAll(s, symbol, "\\"+symbol)
}
return MarkdownV2(s)
}
func EscapeMarkdownV2(s string) MarkdownV2 { return MarkdownV2(escapeMDv2(s)) }
// Bold returns s wrapped as bold Telegram MarkdownV2 text.
func (s MarkdownV2) Bold() MarkdownV2 {
return "*" + s + "*"
}
func (s MarkdownV2) Bold() MarkdownV2 { return "*" + s + "*" }
// Italic returns s wrapped as italic Telegram MarkdownV2 text.
func (s MarkdownV2) Italic() MarkdownV2 {
return "_" + s + "_"
}
func (s MarkdownV2) Italic() MarkdownV2 { return "_" + s + "_" }
// Underline returns s wrapped as underlined Telegram MarkdownV2 text.
func (s MarkdownV2) Underline() MarkdownV2 {
return "__" + s + "__"
}
func (s MarkdownV2) Underline() MarkdownV2 { return "__" + s + "__" }
// Strikethrough returns s wrapped as strikethrough Telegram MarkdownV2 text.
func (s MarkdownV2) Strikethrough() MarkdownV2 {
return "~" + s + "~"
}
func (s MarkdownV2) Strikethrough() MarkdownV2 { return "~" + s + "~" }
// Spoiler returns s wrapped as spoiler Telegram MarkdownV2 text.
func (s MarkdownV2) Spoiler() MarkdownV2 {
return "||" + s + "||"
}
func (s MarkdownV2) Spoiler() MarkdownV2 { return "||" + s + "||" }
// Link returns s as a Telegram MarkdownV2 text link.
func (s MarkdownV2) Link(url string) MarkdownV2 {
@@ -72,14 +56,10 @@ func (s MarkdownV2) TimeFormat(unix uint64, format string) MarkdownV2 {
}
// InlineCode returns s wrapped as inline code Telegram MarkdownV2 text.
func (s MarkdownV2) InlineCode() MarkdownV2 {
return "`" + s + "`"
}
func (s MarkdownV2) InlineCode() MarkdownV2 { return "`" + s + "`" }
// BlockCode returns s wrapped as a Telegram MarkdownV2 code block.
func (s MarkdownV2) BlockCode() MarkdownV2 {
return "```\n" + s + "\n```"
}
func (s MarkdownV2) BlockCode() MarkdownV2 { return "```\n" + s + "\n```" }
// BlockCodeLanguage returns s wrapped as a Telegram MarkdownV2 code block with language.
func (s MarkdownV2) BlockCodeLanguage(lang string) MarkdownV2 {
@@ -92,9 +72,7 @@ func (s MarkdownV2) Quote() MarkdownV2 {
}
// QuoteExpandable returns s as a Telegram MarkdownV2 expandable blockquote.
func (s MarkdownV2) QuoteExpandable() MarkdownV2 {
return "**>" + s
}
func (s MarkdownV2) QuoteExpandable() MarkdownV2 { return "**>" + s }
func escapeMarkdownV2LinkDestination(s string) MarkdownV2 {
s = strings.ReplaceAll(s, "\\", "\\\\")
-492
View File
@@ -1,492 +0,0 @@
package tgfmt
import (
"fmt"
"strconv"
"strings"
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
// Rich is an inline fragment of rich-message HTML (Bot API 10.1).
// Raw text enters through NewRich, which escapes it; fragments compose as-is.
type Rich string
// RichBlock is a block-level fragment of rich-message HTML. Block
// constructors accept only Rich arguments, so invalid nesting (a block
// inside inline content) does not compile.
type RichBlock string
// RichItem is any rich-message fragment: Rich or RichBlock. Both are valid
// at the top level of a message — Telegram merges adjacent inline content
// into paragraphs.
type RichItem interface{ richItem() string }
func (r Rich) richItem() string { return string(r) }
func (r RichBlock) richItem() string { return string(r) }
// RichHTML concatenates fragments into the final rich-message HTML string.
func RichHTML(items ...RichItem) string {
var b strings.Builder
for _, item := range items {
b.WriteString(item.richItem())
}
return b.String()
}
// RichMessage builds a ready-to-send InputRichMessage from fragments.
// SkipEntityDetection is enabled so the server does not add auto-detected
// entities; set IsRTL on the result if needed.
func RichMessage(items ...RichItem) tgapi.InputRichMessage {
return tgapi.InputRichMessage{
HTML: RichHTML(items...),
SkipEntityDetection: true,
}
}
func richJoin(items ...Rich) Rich {
var out Rich
for _, item := range items {
out += item
}
return out
}
func richJoinSep(sep Rich, items ...Rich) Rich {
var out Rich
for i, item := range items {
if i > 0 {
out += sep
}
out += item
}
return out
}
func richBlocksJoin(items ...RichBlock) RichBlock {
var out RichBlock
for _, item := range items {
out += item
}
return out
}
// openTag omits the space when there are no attributes.
func openTag(name string, attrs []string) string {
if len(attrs) == 0 {
return "<" + name + ">"
}
return "<" + name + " " + strings.Join(attrs, " ") + ">"
}
func cite(credit Rich) Rich {
if credit == "" {
return ""
}
return "<cite>" + credit + "</cite>"
}
// NewRich escapes raw text and returns it as an inline fragment.
func NewRich(text string) Rich { return Rich(escapeHTML(text)) }
// Bold wraps the fragment in <b>.
func (r Rich) Bold() Rich { return "<b>" + r + "</b>" }
// Italic wraps the fragment in <i>.
func (r Rich) Italic() Rich { return "<i>" + r + "</i>" }
// Underline wraps the fragment in <u>.
func (r Rich) Underline() Rich { return "<u>" + r + "</u>" }
// Strike wraps the fragment in <s>.
func (r Rich) Strike() Rich { return "<s>" + r + "</s>" }
// Code wraps the fragment in <code>.
func (r Rich) Code() Rich { return "<code>" + r + "</code>" }
// Mark wraps the fragment in <mark>.
func (r Rich) Mark() Rich { return "<mark>" + r + "</mark>" }
// Sub wraps the fragment in <sub>.
func (r Rich) Sub() Rich { return "<sub>" + r + "</sub>" }
// Sup wraps the fragment in <sup>.
func (r Rich) Sup() Rich { return "<sup>" + r + "</sup>" }
// Spoiler wraps the fragment in <tg-spoiler>.
func (r Rich) Spoiler() Rich { return "<tg-spoiler>" + r + "</tg-spoiler>" }
// Link wraps the fragment in a hyperlink to url.
func (r Rich) Link(url string) Rich { return `<a href="` + escapeRich(url) + `">` + r + "</a>" }
// Email wraps the fragment in a mailto: link.
func (r Rich) Email(email string) Rich { return r.Link("mailto:" + email) }
// Phone wraps the fragment in a tel: link.
func (r Rich) Phone(phone string) Rich { return r.Link("tel:" + phone) }
// Mention wraps the fragment in an inline user mention link.
func (r Rich) Mention(userID int64) Rich {
return r.Link("tg://user?id=" + strconv.FormatInt(userID, 10))
}
// Anchor marks the fragment as a named anchor (<a name>).
func (r Rich) Anchor(name string) Rich { return `<a name="` + escapeRich(name) + `">` + r + "</a>" }
// AnchorLink wraps the fragment in an in-document link to a named anchor
// or reference; the server resolves which one by the target name.
func (r Rich) AnchorLink(anchor string) Rich { return r.Link("#" + anchor) }
// Ref wraps the fragment in a <tg-reference> to the named reference.
func (r Rich) Ref(ref string) Rich {
return `<tg-reference name="` + escapeRich(ref) + `">` + r + "</tg-reference>"
}
// Emoji builds a custom emoji fragment with alt text as fallback.
func Emoji(emojiID, alt string) Rich {
return `<tg-emoji emoji-id="` + escapeRich(emojiID) + `">` + escapeRich(alt) + `</tg-emoji>`
}
// Time marks the fragment as a <tg-time> bound to t.
func (r Rich) Time(t time.Time) Rich {
return `<tg-time unix="` + Rich(strconv.FormatInt(t.Unix(), 10)) + `">` + r + "</tg-time>"
}
// TimeFormat marks the fragment as a <tg-time> with an explicit display format.
func (r Rich) TimeFormat(t time.Time, format string) Rich {
return `<tg-time unix="` + Rich(strconv.FormatInt(t.Unix(), 10)) + `" format="` + escapeRich(format) + `">` + r + "</tg-time>"
}
// Math wraps the fragment in an inline <tg-math> expression.
func (r Rich) Math() Rich { return `<tg-math>` + r + `</tg-math>` }
// Br returns a line break fragment.
func Br() Rich { return "<br>" }
// H1 builds a level-1 heading block.
func H1(items ...Rich) RichBlock { return RichBlock("<h1>" + richJoin(items...) + "</h1>") }
// H2 builds a level-2 heading block.
func H2(items ...Rich) RichBlock { return RichBlock("<h2>" + richJoin(items...) + "</h2>") }
// H3 builds a level-3 heading block.
func H3(items ...Rich) RichBlock { return RichBlock("<h3>" + richJoin(items...) + "</h3>") }
// H4 builds a level-4 heading block.
func H4(items ...Rich) RichBlock { return RichBlock("<h4>" + richJoin(items...) + "</h4>") }
// H5 builds a level-5 heading block.
func H5(items ...Rich) RichBlock { return RichBlock("<h5>" + richJoin(items...) + "</h5>") }
// H6 builds a level-6 heading block.
func H6(items ...Rich) RichBlock { return RichBlock("<h6>" + richJoin(items...) + "</h6>") }
// P builds a paragraph block.
func P(items ...Rich) RichBlock { return RichBlock("<p>" + richJoin(items...) + "</p>") }
// Pre builds a preformatted code block.
func Pre(items ...Rich) RichBlock { return RichBlock("<pre>" + richJoin(items...) + "</pre>") }
// PreCode builds a preformatted code block tagged with a language.
func PreCode(lang string, items ...Rich) RichBlock {
return RichBlock(`<pre><code class="language-` + escapeRich(lang) + `">` + richJoin(items...) + `</code></pre>`)
}
// Footer builds a footer block.
func Footer(items ...Rich) RichBlock { return RichBlock("<footer>" + richJoin(items...) + "</footer>") }
// Hr builds a divider block.
func Hr() RichBlock { return "<hr/>" }
// AnchorBlock builds a standalone named anchor between blocks: <a name></a>.
func AnchorBlock(name string) RichBlock {
return RichBlock(`<a name="` + escapeHTML(name) + `"></a>`)
}
// LiItem is a list item under construction for Ul or Ol.
type LiItem struct {
text Rich
value int
typ string
checkbox bool
checked bool
}
// Li builds a list item from inline fragments.
func Li(items ...Rich) LiItem { return LiItem{text: richJoin(items...)} }
// LiCheckbox builds a checkbox list item.
func LiCheckbox(checked bool, items ...Rich) LiItem {
return LiItem{text: richJoin(items...), checkbox: true, checked: checked}
}
// SetValue sets the explicit ordinal of the item (like <li value>).
func (l LiItem) SetValue(val int) LiItem {
l.value = val
return l
}
// SetType sets the numbering type of the item: "1", "a", "A", "i", "I".
func (l LiItem) SetType(t string) LiItem {
l.typ = t
return l
}
func (l LiItem) build() Rich {
if l.checkbox {
input := Rich(`<input type="checkbox">`)
if l.checked {
input = `<input type="checkbox" checked>`
}
return "<li>" + input + l.text + "</li>"
}
attrs := make([]string, 0, 2)
if l.value != 0 {
attrs = append(attrs, `value="`+strconv.Itoa(l.value)+`"`)
}
if l.typ != "" {
attrs = append(attrs, `type="`+escapeHTML(l.typ)+`"`)
}
return Rich(openTag("li", attrs)) + l.text + "</li>"
}
func joinLiItems(items []LiItem) Rich {
var out Rich
for _, item := range items {
out += item.build()
}
return out
}
// Ul builds an unordered list block.
func Ul(items ...LiItem) RichBlock {
return RichBlock(`<ul>` + joinLiItems(items) + `</ul>`)
}
// OlOpts holds the <ol> numbering attributes.
type OlOpts struct {
Start int
Type string
Reversed bool
}
// Ol builds an ordered list block. Item labels are rendered by the server.
func Ol(opts OlOpts, items ...LiItem) RichBlock {
attrs := make([]string, 0, 3)
if opts.Start > 0 {
attrs = append(attrs, `start="`+strconv.Itoa(opts.Start)+`"`)
}
if opts.Type != "" {
attrs = append(attrs, `type="`+escapeHTML(opts.Type)+`"`)
}
if opts.Reversed {
attrs = append(attrs, "reversed")
}
return RichBlock(openTag("ol", attrs) + string(joinLiItems(items)) + "</ol>")
}
// Blockquote builds a block quotation: lines are joined with <br> (as in the
// official HTML example) and credit renders as a trailing <cite>.
func Blockquote(credit Rich, lines ...Rich) RichBlock {
return RichBlock(`<blockquote>` + richJoinSep(Br(), lines...) + cite(credit) + `</blockquote>`)
}
// Aside builds a pull quote (<aside>) with an optional <cite> credit.
func Aside(credit Rich, lines ...Rich) RichBlock {
return RichBlock(`<aside>` + richJoinSep(Br(), lines...) + cite(credit) + `</aside>`)
}
// RichMedia is a media element for standalone blocks, collages, and
// slideshows. Media is sent by HTTP(S) URL only; file_id does not work in
// html mode.
type RichMedia struct {
tag string
src string
spoiler bool
}
// Photo builds a photo element from an HTTP(S) URL.
func Photo(url string) RichMedia { return RichMedia{tag: "img", src: url} }
// Video builds a video or animation element from an HTTP(S) URL; the server
// distinguishes them by the URL extension.
func Video(url string) RichMedia { return RichMedia{tag: "video", src: url} }
// Audio builds an audio or voice-note element from an HTTP(S) URL; the
// server treats .ogg as a voice note.
func Audio(url string) RichMedia { return RichMedia{tag: "audio", src: url} }
// SetSpoiler hides the media behind a spoiler overlay.
func (m RichMedia) SetSpoiler() RichMedia {
m.spoiler = true
return m
}
func (m RichMedia) build() string {
attrs := []string{`src="` + escapeHTML(m.src) + `"`}
if m.spoiler {
attrs = append(attrs, "tg-spoiler")
}
if m.tag == "img" {
return "<img " + strings.Join(attrs, " ") + "/>"
}
return openTag(m.tag, attrs) + "</" + m.tag + ">"
}
// Block turns the media into a standalone block without a caption.
func (m RichMedia) Block() RichBlock { return RichBlock(m.build()) }
// Caption wraps the media in <figure> with a caption and optional credit.
func (m RichMedia) Caption(credit Rich, caption ...Rich) RichBlock {
return RichBlock(`<figure>` + m.build() + string(figcaption(credit, caption...)) + `</figure>`)
}
func figcaption(credit Rich, caption ...Rich) Rich {
text := richJoin(caption...) + cite(credit)
if text == "" {
return ""
}
return "<figcaption>" + text + "</figcaption>"
}
func richMediaJoin(items []RichMedia) string {
var out string
for _, item := range items {
out += item.build()
}
return out
}
// Map builds a location map block.
func Map(lat, long float64, zoom int) RichBlock {
latString := fmt.Sprintf("%.6f", lat)
longString := fmt.Sprintf("%.6f", long)
zoomString := strconv.Itoa(zoom)
return RichBlock(`<tg-map lat="` + latString + `" long="` + longString + `" zoom="` + zoomString + `"/>`)
}
// MapCaption builds a map block wrapped in <figure> with a caption.
func MapCaption(lat, long float64, zoom int, credit Rich, caption ...Rich) RichBlock {
return `<figure>` + Map(lat, long, zoom) + RichBlock(figcaption(credit, caption...)) + `</figure>`
}
// Collage builds a media collage block.
func Collage(items ...RichMedia) RichBlock {
return RichBlock(`<tg-collage>` + richMediaJoin(items) + `</tg-collage>`)
}
// CollageCaption builds a media collage block with a caption.
func CollageCaption(credit Rich, caption Rich, items ...RichMedia) RichBlock {
return RichBlock(`<tg-collage>` + richMediaJoin(items) + string(figcaption(credit, caption)) + `</tg-collage>`)
}
// Slideshow builds a media slideshow block.
func Slideshow(items ...RichMedia) RichBlock {
return RichBlock(`<tg-slideshow>` + richMediaJoin(items) + `</tg-slideshow>`)
}
// SlideshowCaption builds a media slideshow block with a caption.
func SlideshowCaption(credit Rich, caption Rich, items ...RichMedia) RichBlock {
return RichBlock(`<tg-slideshow>` + richMediaJoin(items) + string(figcaption(credit, caption)) + `</tg-slideshow>`)
}
// RichCell is a table cell under construction for Row.
type RichCell struct {
text Rich
colspan int
rowspan int
align string
valign string
}
// Cell builds a table cell from inline fragments.
func Cell(items ...Rich) RichCell {
return RichCell{text: richJoin(items...)}
}
// SetSpan sets colspan and rowspan; zero leaves the attribute out.
func (r RichCell) SetSpan(col, row int) RichCell {
r.colspan = col
r.rowspan = row
return r
}
// SetAlign sets horizontal alignment: "left", "center", or "right".
func (r RichCell) SetAlign(align string) RichCell {
r.align = align
return r
}
// SetVAlign sets vertical alignment: "top", "middle", or "bottom".
func (r RichCell) SetVAlign(align string) RichCell {
r.valign = align
return r
}
func (r RichCell) build(isHeader bool) string {
attrs := make([]string, 0, 4)
if r.colspan > 0 {
attrs = append(attrs, `colspan="`+strconv.Itoa(r.colspan)+`"`)
}
if r.rowspan > 0 {
attrs = append(attrs, `rowspan="`+strconv.Itoa(r.rowspan)+`"`)
}
if r.align != "" {
attrs = append(attrs, `align="`+escapeHTML(r.align)+`"`)
}
if r.valign != "" {
attrs = append(attrs, `valign="`+escapeHTML(r.valign)+`"`)
}
tag := "td"
if isHeader {
tag = "th"
}
return openTag(tag, attrs) + string(r.text) + "</" + tag + ">"
}
// RichRow is a table row under construction for Table.
type RichRow struct {
cells []RichCell
isHeader bool
}
// Row builds a table row; isHeader renders every cell as <th>.
func Row(isHeader bool, cells ...RichCell) RichRow {
return RichRow{cells: cells, isHeader: isHeader}
}
func (r RichRow) build() string {
var buildCells string
for _, cell := range r.cells {
buildCells += cell.build(r.isHeader)
}
return `<tr>` + buildCells + `</tr>`
}
// Table builds a table block; an empty caption is omitted.
func Table(bordered, striped bool, caption Rich, rows ...RichRow) RichBlock {
attrs := make([]string, 0, 2)
if bordered {
attrs = append(attrs, "bordered")
}
if striped {
attrs = append(attrs, "striped")
}
out := openTag("table", attrs)
if caption != "" {
out += `<caption>` + string(caption) + `</caption>`
}
for _, row := range rows {
out += row.build()
}
out += `</table>`
return RichBlock(out)
}
// Details builds an expandable block with an inline summary.
func Details(isOpen bool, summary Rich, blocks ...RichBlock) RichBlock {
tag := `<details>`
if isOpen {
tag = `<details open>`
}
return RichBlock(tag) + `<summary>` + RichBlock(summary) + `</summary>` + richBlocksJoin(blocks...) + `</details>`
}
// MathBlock builds a block-level mathematical expression.
func MathBlock(expr string) RichBlock {
return RichBlock(`<tg-math-block>` + escapeHTML(expr) + `</tg-math-block>`)
}
-179
View File
@@ -1,179 +0,0 @@
package tgfmt
import (
"testing"
"time"
)
func TestRichInline(t *testing.T) {
cases := []struct {
name string
got Rich
want string
}{
{"bold", NewRich("bold text").Bold(), "<b>bold text</b>"},
{"spoiler", NewRich("spoiler").Spoiler(), "<tg-spoiler>spoiler</tg-spoiler>"},
{"escape", NewRich(`a<b> & "c"`), "a&lt;b&gt; &amp; &quot;c&quot;"},
{"link", NewRich("inline URL").Link("https://t.me/"), `<a href="https://t.me/">inline URL</a>`},
{"link attr escape", NewRich("x").Link(`https://e/?q="><b>`), `<a href="https://e/?q=&quot;&gt;&lt;b&gt;">x</a>`},
{"mention", NewRich("user").Mention(123456789), `<a href="tg://user?id=123456789">user</a>`},
{"anchor", Rich("").Anchor("chapter-1"), `<a name="chapter-1"></a>`},
{"anchor link", NewRich("in-document link").AnchorLink("chapter-1"), `<a href="#chapter-1">in-document link</a>`},
{"reference", NewRich("Referenced text").Ref("note-1"), `<tg-reference name="note-1">Referenced text</tg-reference>`},
{"emoji", Emoji("5368324170671202286", "👍"), `<tg-emoji emoji-id="5368324170671202286">👍</tg-emoji>`},
{"emoji alt escape", Emoji("1", `<x>`), `<tg-emoji emoji-id="1">&lt;x&gt;</tg-emoji>`},
{"time format", NewRich("22:45 tomorrow").TimeFormat(time.Unix(1647531900, 0), "wDT"),
`<tg-time unix="1647531900" format="wDT">22:45 tomorrow</tg-time>`},
{"math", NewRich("x^2 + y^2").Math(), "<tg-math>x^2 + y^2</tg-math>"},
}
for _, c := range cases {
if string(c.got) != c.want {
t.Errorf("%s:\n got %s\n want %s", c.name, c.got, c.want)
}
}
}
func TestRichBlocks(t *testing.T) {
cases := []struct {
name string
got RichBlock
want string
}{
{"h1", H1(NewRich("Heading 1")), "<h1>Heading 1</h1>"},
{"p", P(NewRich("Paragraph text")), "<p>Paragraph text</p>"},
{"pre code", PreCode("python", NewRich("print('x')")),
`<pre><code class="language-python">print('x')</code></pre>`},
{"footer", Footer(NewRich("Footer text")), "<footer>Footer text</footer>"},
{"hr", Hr(), "<hr/>"},
{"anchor block", AnchorBlock("chapter-2"), `<a name="chapter-2"></a>`},
{"math block", MathBlock("E = mc^2"), "<tg-math-block>E = mc^2</tg-math-block>"},
}
for _, c := range cases {
if string(c.got) != c.want {
t.Errorf("%s:\n got %s\n want %s", c.name, c.got, c.want)
}
}
}
func TestRichLists(t *testing.T) {
cases := []struct {
name string
got RichBlock
want string
}{
{"ul", Ul(Li(NewRich("unordered list item"))),
"<ul><li>unordered list item</li></ul>"},
{"ol plain", Ol(OlOpts{}, Li(NewRich("ordered list item"))),
"<ol><li>ordered list item</li></ol>"},
{"ol attrs", Ol(OlOpts{Start: 3, Type: "a", Reversed: true}, Li(NewRich("ordered list item"))),
`<ol start="3" type="a" reversed><li>ordered list item</li></ol>`},
{"li value type", Ol(OlOpts{}, Li(NewRich("item")).SetValue(7).SetType("i")),
`<ol><li value="7" type="i">item</li></ol>`},
{"checkboxes", Ul(
LiCheckbox(true, NewRich("Checked checkbox")),
LiCheckbox(false, NewRich("Unchecked checkbox")),
), `<ul><li><input type="checkbox" checked>Checked checkbox</li><li><input type="checkbox">Unchecked checkbox</li></ul>`},
}
for _, c := range cases {
if string(c.got) != c.want {
t.Errorf("%s:\n got %s\n want %s", c.name, c.got, c.want)
}
}
}
func TestRichQuotes(t *testing.T) {
got := Blockquote(NewRich("The Author"),
NewRich("Block quotation started"),
NewRich("Block quotation continued"),
NewRich("The last line of the block quotation"),
)
want := "<blockquote>Block quotation started<br>Block quotation continued<br>" +
"The last line of the block quotation<cite>The Author</cite></blockquote>"
if string(got) != want {
t.Errorf("blockquote:\n got %s\n want %s", got, want)
}
got = Aside(NewRich("The Author"), NewRich("Pull quote"))
want = "<aside>Pull quote<cite>The Author</cite></aside>"
if string(got) != want {
t.Errorf("aside:\n got %s\n want %s", got, want)
}
got = Blockquote("", NewRich("no credit"))
want = "<blockquote>no credit</blockquote>"
if string(got) != want {
t.Errorf("blockquote without credit:\n got %s\n want %s", got, want)
}
}
func TestRichMedia(t *testing.T) {
cases := []struct {
name string
got RichBlock
want string
}{
{"photo", Photo("https://telegram.org/example/photo.jpg").Block(),
`<img src="https://telegram.org/example/photo.jpg"/>`},
{"video", Video("https://telegram.org/example/video.mp4").Block(),
`<video src="https://telegram.org/example/video.mp4"></video>`},
{"audio", Audio("https://telegram.org/example/audio.mp3").Block(),
`<audio src="https://telegram.org/example/audio.mp3"></audio>`},
{"photo spoiler caption", Photo("https://telegram.org/example/photo.jpg").SetSpoiler().
Caption(NewRich("Photo credit"), NewRich("Photo caption")),
`<figure><img src="https://telegram.org/example/photo.jpg" tg-spoiler/>` +
`<figcaption>Photo caption<cite>Photo credit</cite></figcaption></figure>`},
{"video caption no credit", Video("https://telegram.org/example/video.mp4").
Caption("", NewRich("Video caption")),
`<figure><video src="https://telegram.org/example/video.mp4"></video>` +
`<figcaption>Video caption</figcaption></figure>`},
{"map", Map(41.9, 12.5, 14), `<tg-map lat="41.900000" long="12.500000" zoom="14"/>`},
{"map caption", MapCaption(41.9, 12.5, 14, "", NewRich("Map caption")),
`<figure><tg-map lat="41.900000" long="12.500000" zoom="14"/><figcaption>Map caption</figcaption></figure>`},
{"collage", Collage(
Photo("https://telegram.org/example/photo.jpg"),
Video("https://telegram.org/example/video.mp4"),
), `<tg-collage><img src="https://telegram.org/example/photo.jpg"/>` +
`<video src="https://telegram.org/example/video.mp4"></video></tg-collage>`},
{"slideshow caption", SlideshowCaption("", NewRich("Slideshow caption"),
Photo("https://telegram.org/example/photo.jpg"),
), `<tg-slideshow><img src="https://telegram.org/example/photo.jpg"/>` +
`<figcaption>Slideshow caption</figcaption></tg-slideshow>`},
}
for _, c := range cases {
if string(c.got) != c.want {
t.Errorf("%s:\n got %s\n want %s", c.name, c.got, c.want)
}
}
}
func TestRichTable(t *testing.T) {
got := Table(false, false, "",
Row(true, Cell(NewRich("Header 1")), Cell(NewRich("Header 2"))),
Row(false, Cell(NewRich("Value 1")), Cell(NewRich("Value 2"))),
)
want := "<table><tr><th>Header 1</th><th>Header 2</th></tr>" +
"<tr><td>Value 1</td><td>Value 2</td></tr></table>"
if string(got) != want {
t.Errorf("plain table:\n got %s\n want %s", got, want)
}
got = Table(true, true, NewRich("Table caption"),
Row(false,
Cell(NewRich("Value")).SetSpan(2, 2).SetAlign("left"),
Cell(NewRich("Value2")).SetAlign("center"),
),
)
want = `<table bordered striped><caption>Table caption</caption>` +
`<tr><td colspan="2" rowspan="2" align="left">Value</td><td align="center">Value2</td></tr></table>`
if string(got) != want {
t.Errorf("table attrs:\n got %s\n want %s", got, want)
}
}
func TestRichDetails(t *testing.T) {
got := Details(true, NewRich("Title"), P(NewRich("Content")))
want := "<details open><summary>Title</summary><p>Content</p></details>"
if string(got) != want {
t.Errorf("details:\n got %s\n want %s", got, want)
}
}
+18 -2
View File
@@ -1,6 +1,8 @@
package tgfmt
import "strings"
import (
"strings"
)
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
@@ -9,7 +11,21 @@ func escapeHTML(s string) string {
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}
func escapeRich(s string) Rich { return Rich(escapeHTML(s)) }
func escapeMD(s string) string {
s = strings.ReplaceAll(s, "_", `\_`)
s = strings.ReplaceAll(s, "*", `\*`)
s = strings.ReplaceAll(s, "[", `\[`)
s = strings.ReplaceAll(s, "`", "\\`")
return s
}
func escapeMDv2(s string) string {
symbols := []string{"\\", "_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"}
for _, symbol := range symbols {
s = strings.ReplaceAll(s, symbol, "\\"+symbol)
}
return s
}
// EscapePunctuation escapes '.', '!' and '-' for MarkdownV2 fragments.
func EscapePunctuation(s string) string {