Skip to main content

ronami/utils/
html.rs

1//! Utils for working with the [HTML message style][spec].
2//!
3//! [spec]: https://core.telegram.org/bots/api#html-style
4
5use ronami_core::types::{User, UserId};
6
7/// Applies the bold font style to the string.
8///
9/// Passed string will not be automatically escaped because it can contain
10/// nested markup.
11#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
12              without using its output does nothing useful"]
13pub fn bold(s: &str) -> String {
14    format!("<b>{s}</b>")
15}
16
17/// Applies the block quotation style to the string.
18///
19/// Passed string will not be automatically escaped because it can contain
20/// nested markup.
21#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
22              without using its output does nothing useful"]
23pub fn blockquote(s: &str) -> String {
24    format!("<blockquote>{s}</blockquote>")
25}
26
27/// Applies the expandable block quotation style to the string.
28///
29/// Passed string will not be automatically escaped because it can contain
30/// nested markup.
31#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
32              without using its output does nothing useful"]
33pub fn expandable_blockquote(s: &str) -> String {
34    format!("<blockquote expandable>{s}</blockquote>")
35}
36
37/// Applies the italic font style to the string.
38///
39/// Passed string will not be automatically escaped because it can contain
40/// nested markup.
41#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
42              without using its output does nothing useful"]
43pub fn italic(s: &str) -> String {
44    format!("<i>{s}</i>")
45}
46
47/// Applies the underline font style to the string.
48///
49/// Passed string will not be automatically escaped because it can contain
50/// nested markup.
51#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
52              without using its output does nothing useful"]
53pub fn underline(s: &str) -> String {
54    format!("<u>{s}</u>")
55}
56
57/// Applies the strikethrough font style to the string.
58///
59/// Passed string will not be automatically escaped because it can contain
60/// nested markup.
61#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
62              without using its output does nothing useful"]
63pub fn strike(s: &str) -> String {
64    format!("<s>{s}</s>")
65}
66
67/// Builds an inline link with an anchor.
68///
69/// Escapes the passed URL and the link text.
70#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
71              without using its output does nothing useful"]
72pub fn link(url: &str, text: &str) -> String {
73    format!("<a href=\"{}\">{}</a>", escape(url).replace('"', "&quot;"), escape(text))
74}
75
76/// Builds an inline user mention link with an anchor.
77#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
78              without using its output does nothing useful"]
79pub fn user_mention(user_id: UserId, text: &str) -> String {
80    link(format!("tg://user?id={user_id}").as_str(), text)
81}
82
83/// Formats the code block.
84///
85/// Escapes HTML characters inside the block.
86#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
87              without using its output does nothing useful"]
88pub fn code_block(code: &str) -> String {
89    format!("<pre>{}</pre>", escape(code))
90}
91
92/// Formats the code block with a specific language syntax.
93///
94/// Escapes HTML characters inside the block.
95#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
96              without using its output does nothing useful"]
97pub fn code_block_with_lang(code: &str, lang: &str) -> String {
98    format!(
99        "<pre><code class=\"language-{}\">{}</code></pre>",
100        escape(lang).replace('"', "&quot;"),
101        escape(code)
102    )
103}
104
105/// Formats the string as an inline code.
106///
107/// Escapes HTML characters inside the block.
108#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
109              without using its output does nothing useful"]
110pub fn code_inline(s: &str) -> String {
111    format!("<code>{}</code>", escape(s))
112}
113
114/// Escapes the string to be shown "as is" within the Telegram HTML message
115/// style.
116///
117/// Does not escape ' and " characters (as should be for usual HTML), because
118/// they shouldn't be escaped by the [spec].
119///
120/// [spec]: https://core.telegram.org/bots/api#html-style
121#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
122              without using its output does nothing useful"]
123pub fn escape(s: &str) -> String {
124    s.chars().fold(String::with_capacity(s.len()), |mut s, c| {
125        match c {
126            '&' => s.push_str("&amp;"),
127            '<' => s.push_str("&lt;"),
128            '>' => s.push_str("&gt;"),
129            c => s.push(c),
130        }
131        s
132    })
133}
134
135#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
136              without using its output does nothing useful"]
137pub fn user_mention_or_link(user: &User) -> String {
138    match user.mention() {
139        Some(mention) => mention,
140        None => link(user.url().as_str(), &user.full_name()),
141    }
142}
143
144/// Formats text as a date-time entity for HTML.
145#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
146              without using its output does nothing useful"]
147pub fn date_time(text: &str, unix_time: i64, format: Option<&str>) -> String {
148    let escaped_text = escape(text);
149    match format {
150        Some(fmt) => {
151            format!("<tg-time unix=\"{unix_time}\" format=\"{fmt}\">{escaped_text}</tg-time>")
152        }
153        None => format!("<tg-time unix=\"{unix_time}\">{escaped_text}</tg-time>"),
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_bold() {
163        assert_eq!(bold(" foobar "), "<b> foobar </b>");
164        assert_eq!(bold(" <i>foobar</i> "), "<b> <i>foobar</i> </b>");
165        assert_eq!(bold("<s>(`foobar`)</s>"), "<b><s>(`foobar`)</s></b>");
166    }
167
168    #[test]
169    fn test_italic() {
170        assert_eq!(italic(" foobar "), "<i> foobar </i>");
171        assert_eq!(italic(" <b>foobar</b> "), "<i> <b>foobar</b> </i>");
172        assert_eq!(italic("<s>(`foobar`)</s>"), "<i><s>(`foobar`)</s></i>");
173    }
174
175    #[test]
176    fn test_underline() {
177        assert_eq!(underline(" foobar "), "<u> foobar </u>");
178        assert_eq!(underline(" <b>foobar</b> "), "<u> <b>foobar</b> </u>");
179        assert_eq!(underline("<s>(`foobar`)</s>"), "<u><s>(`foobar`)</s></u>");
180    }
181
182    #[test]
183    fn test_strike() {
184        assert_eq!(strike(" foobar "), "<s> foobar </s>");
185        assert_eq!(strike(" <b>foobar</b> "), "<s> <b>foobar</b> </s>");
186        assert_eq!(strike("<b>(`foobar`)</b>"), "<s><b>(`foobar`)</b></s>");
187    }
188
189    #[test]
190    fn test_link() {
191        assert_eq!(
192            link("https://www.google.com/?q=foo&l=ru", "<google>"),
193            "<a href=\"https://www.google.com/?q=foo&amp;l=ru\">&lt;google&gt;</a>",
194        );
195    }
196
197    #[test]
198    fn test_user_mention() {
199        assert_eq!(
200            user_mention(UserId(123_456_789), "<pwner666>"),
201            "<a href=\"tg://user?id=123456789\">&lt;pwner666&gt;</a>",
202        );
203    }
204
205    #[test]
206    fn test_code_block() {
207        assert_eq!(
208            code_block("<p>pre-'formatted'\n & fixed-width \\code `block`</p>"),
209            "<pre>&lt;p&gt;pre-'formatted'\n &amp; fixed-width \\code `block`&lt;/p&gt;</pre>"
210        );
211    }
212
213    #[test]
214    fn test_code_block_with_lang() {
215        assert_eq!(
216            code_block_with_lang(
217                "<p>pre-'formatted'\n & fixed-width \\code `block`</p>",
218                "<html>\"",
219            ),
220            concat!(
221                "<pre><code class=\"language-&lt;html&gt;&quot;\">",
222                "&lt;p&gt;pre-'formatted'\n &amp; fixed-width \\code `block`&lt;/p&gt;",
223                "</code></pre>",
224            )
225        );
226    }
227
228    #[test]
229    fn test_code_inline() {
230        assert_eq!(
231            code_inline("<span class=\"foo\">foo & bar</span>"),
232            "<code>&lt;span class=\"foo\"&gt;foo &amp; bar&lt;/span&gt;</code>",
233        );
234    }
235
236    #[test]
237    fn test_escape() {
238        assert_eq!(
239            escape("  <title>Foo & Bar</title>   "),
240            "  &lt;title&gt;Foo &amp; Bar&lt;/title&gt;   "
241        );
242        assert_eq!(escape("<p>你好 & 再見</p>"), "&lt;p&gt;你好 &amp; 再見&lt;/p&gt;");
243        assert_eq!(escape("'foo\""), "'foo\"");
244    }
245
246    #[test]
247    fn user_mention_link() {
248        let user_with_username = User {
249            id: UserId(0),
250            is_bot: false,
251            first_name: "".to_string(),
252            last_name: None,
253            username: Some("abcd".to_string()),
254            language_code: None,
255            is_premium: false,
256            added_to_attachment_menu: false,
257            can_manage_bots: false,
258            supports_guest_queries: false,
259            supports_join_request_queries: false,
260        };
261        assert_eq!(user_mention_or_link(&user_with_username), "@abcd");
262        let user_without_username = User {
263            id: UserId(123_456_789),
264            is_bot: false,
265            first_name: "Name".to_string(),
266            last_name: None,
267            username: None,
268            language_code: None,
269            is_premium: false,
270            added_to_attachment_menu: false,
271            can_manage_bots: false,
272            supports_guest_queries: false,
273            supports_join_request_queries: false,
274        };
275        assert_eq!(
276            user_mention_or_link(&user_without_username),
277            r#"<a href="tg://user/?id=123456789">Name</a>"#
278        )
279    }
280}