pub fn render(input: &str) -> String {
let mut result = String::with_capacity(input.len() * 2);
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'*' => {
let content = read_until(&mut chars, '*');
result.push_str("<b>");
result.push_str(&escape(&content));
result.push_str("</b>");
}
'_' if chars.peek() == Some(&'_') => {
chars.next();
let content = read_until_double(&mut chars, '_');
result.push_str("<u>");
result.push_str(&escape(&content));
result.push_str("</u>");
}
'_' => {
let content = read_until(&mut chars, '_');
result.push_str("<i>");
result.push_str(&escape(&content));
result.push_str("</i>");
}
'~' => {
let content = read_until(&mut chars, '~');
result.push_str("<s>");
result.push_str(&escape(&content));
result.push_str("</s>");
}
'`' if chars.peek() == Some(&'`') => {
chars.next();
if chars.peek() == Some(&'`') {
chars.next();
let block = read_until_triple(&mut chars, '`');
if let Some((lang, code)) = block.split_once('\n') {
let lang = lang.trim();
if !lang.is_empty() {
result.push_str(&format!(
"<pre><code class=\"language-{}\">{}</code></pre>",
escape_attr(lang),
escape(code)
));
} else {
result.push_str("<pre>");
result.push_str(&escape(code));
result.push_str("</pre>");
}
} else {
result.push_str("<pre>");
result.push_str(&escape(&block));
result.push_str("</pre>");
}
} else {
result.push_str("``");
}
}
'`' => {
let content = read_until(&mut chars, '`');
result.push_str("<code>");
result.push_str(&escape(&content));
result.push_str("</code>");
}
'[' => {
let text = read_until(&mut chars, ']');
if chars.peek() == Some(&'(') {
chars.next();
let url = read_until(&mut chars, ')');
result.push_str(&format!(
"<a href=\"{}\">{}</a>",
escape_attr(&url),
escape(&text)
));
} else {
result.push('[');
result.push_str(&escape(&text));
result.push(']');
}
}
'|' if chars.peek() == Some(&'|') => {
chars.next();
let content = read_until_double(&mut chars, '|');
result.push_str("<tg-spoiler>");
result.push_str(&escape(&content));
result.push_str("</tg-spoiler>");
}
'<' => result.push_str("<"),
'>' => result.push_str(">"),
'&' => result.push_str("&"),
_ => result.push(ch),
}
}
result
}
pub fn escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
pub fn escape_attr(s: &str) -> String {
s.replace('&', "&")
.replace('"', """)
.replace('<', "<")
.replace('>', ">")
}
pub fn bold(text: &str) -> String {
format!("<b>{}</b>", escape(text))
}
pub fn italic(text: &str) -> String {
format!("<i>{}</i>", escape(text))
}
pub fn underline(text: &str) -> String {
format!("<u>{}</u>", escape(text))
}
pub fn strike(text: &str) -> String {
format!("<s>{}</s>", escape(text))
}
pub fn code(text: &str) -> String {
format!("<code>{}</code>", escape(text))
}
pub fn pre(text: &str) -> String {
format!("<pre>{}</pre>", escape(text))
}
pub fn pre_lang(lang: &str, text: &str) -> String {
format!(
"<pre><code class=\"language-{}\">{}</code></pre>",
escape_attr(lang),
escape(text)
)
}
pub fn link(text: &str, url: &str) -> String {
format!("<a href=\"{}\">{}</a>", escape_attr(url), escape(text))
}
pub fn spoiler(text: &str) -> String {
format!("<tg-spoiler>{}</tg-spoiler>", escape(text))
}
pub fn blockquote(text: &str) -> String {
format!("<blockquote>{}</blockquote>", escape(text))
}
pub fn mention(user_id: u64, text: &str) -> String {
format!("<a href=\"tg://user?id={}\">{}</a>", user_id, escape(text))
}
fn read_until(chars: &mut std::iter::Peekable<std::str::Chars>, delimiter: char) -> String {
let mut result = String::new();
for ch in chars.by_ref() {
if ch == delimiter {
break;
}
result.push(ch);
}
result
}
fn read_until_double(chars: &mut std::iter::Peekable<std::str::Chars>, delimiter: char) -> String {
let mut result = String::new();
while let Some(ch) = chars.next() {
if ch == delimiter && chars.peek() == Some(&delimiter) {
chars.next();
break;
}
result.push(ch);
}
result
}
fn read_until_triple(chars: &mut std::iter::Peekable<std::str::Chars>, delimiter: char) -> String {
let mut result = String::new();
let mut count = 0;
for ch in chars.by_ref() {
if ch == delimiter {
count += 1;
if count == 3 {
break;
}
} else {
for _ in 0..count {
result.push(delimiter);
}
count = 0;
result.push(ch);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bold() {
assert_eq!(render("*bold*"), "<b>bold</b>");
}
#[test]
fn test_italic() {
assert_eq!(render("_italic_"), "<i>italic</i>");
}
#[test]
fn test_code() {
assert_eq!(render("`code`"), "<code>code</code>");
}
#[test]
fn test_link() {
assert_eq!(
render("[click](https://example.com)"),
r#"<a href="https://example.com">click</a>"#
);
}
#[test]
fn test_escape_user_input() {
assert_eq!(
render("hello <script>alert(1)</script>"),
"hello <script>alert(1)</script>"
);
}
#[test]
fn test_mixed() {
assert_eq!(
render("*bold* and _italic_ and `code`"),
"<b>bold</b> and <i>italic</i> and <code>code</code>"
);
}
#[test]
fn test_code_block_lang_attr_escaped() {
let input = "```x\" onmouseover=\"alert(1)\nmalicious```";
let html = render(input);
assert!(
html.contains("""),
"double quotes in language name must be escaped as ""
);
assert!(
!html.contains("onmouseover\""),
"attribute injection must be prevented"
);
}
#[test]
fn test_pre_lang_escapes_quotes() {
let html = pre_lang("x\" onclick=\"alert", "code");
assert!(html.contains("""));
assert!(!html.contains("onclick\""));
}
}