(new): rich message support
(fix): runtime reliability (tests): regression coverage (doc): v1.1 release notes
This commit is contained in:
+12
-41
@@ -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, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
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
@@ -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
@@ -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
@@ -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>`)
|
||||
}
|
||||
@@ -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<b> & "c""},
|
||||
{"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="><b>">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"><x></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
@@ -1,6 +1,8 @@
|
||||
package tgfmt
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func escapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
@@ -9,7 +11,21 @@ func escapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user