Skip to main content

oxios_markdown/
html.rs

1//! Markdown → Telegram-supported HTML subset converter.
2//!
3//! Ported from files.md (`server/pkg/txt/md.go` lines 262–432, `str.go` lines 122–170)
4//! by Artem Zakirullin.
5//!
6//! Uses parser combinators (open/close/or/and/some) for inline markup.
7//! Supported tags: `*`/`_` → `<i>`, `**`/`__` → `<b>`,
8//! `` ` `` → `<code>`, ` ``` ` → `<pre>`, `#` → `<b>`.
9
10use once_cell::sync::Lazy;
11use regex::Regex;
12use std::collections::HashMap;
13use std::rc::Rc;
14
15// Pre-compiled regexes used on hot paths (F15). Compiling per call was
16// visible in profiles, especially when markdown_to_html ran over each
17// chat block during nightly cleanup.
18static RE_STRIP_TAGS: Lazy<Regex> =
19    Lazy::new(|| Regex::new(r"<[^>]*>").expect("valid regex literal"));
20static RE_NEWLINES: Lazy<Regex> = Lazy::new(|| Regex::new(r"\n{2,}").expect("valid regex literal"));
21static RE_CODE_BLOCK: Lazy<Regex> =
22    Lazy::new(|| Regex::new(r"(?s)```(.+?)```").expect("valid regex literal"));
23static RE_INLINE_CODE: Lazy<Regex> =
24    Lazy::new(|| Regex::new(r"`([^`]+?)`").expect("valid regex literal"));
25static RE_HEADER: Lazy<Regex> =
26    Lazy::new(|| Regex::new(r"(?m)^#+\s*(.+)").expect("valid regex literal"));
27
28// ---------------------------------------------------------------------------
29// Public API — utility functions
30// ---------------------------------------------------------------------------
31/// Escape HTML special characters (`&`, `<`, `>`).
32pub fn escape_html(s: &str) -> String {
33    s.replace('&', "&amp;")
34        .replace('<', "&lt;")
35        .replace('>', "&gt;")
36}
37
38/// Strip all HTML tags from a string.
39pub fn strip_html_tags(s: &str) -> String {
40    RE_STRIP_TAGS.replace_all(s, "").to_string()
41}
42
43/// Replace regex matches with placeholders, returning the modified string
44/// and a map of placeholder → original.
45pub fn replace_with_placeholders(
46    s: &str,
47    pattern: &str,
48    placeholder: &str,
49) -> (String, HashMap<String, String>) {
50    let re = Regex::new(pattern).expect("valid regex literal");
51    let mut placeholders = HashMap::new();
52    let mut counter: usize = 0;
53
54    let result = re
55        .replace_all(s, |caps: &regex::Captures<'_>| {
56            let full = caps
57                .get(0)
58                .expect("capture group present after successful match")
59                .as_str()
60                .to_string();
61            // Wrap with NUL bytes (\x00 … \x00) so user-typed content can
62            // never collide with the placeholder and overwrite restored
63            // text (F21). NUL is illegal in well-formed markdown.
64            let ph = format!("\x00{placeholder}{counter}\x00");
65            counter += 1;
66            placeholders.insert(ph.clone(), full);
67            ph
68        })
69        .to_string();
70
71    (result, placeholders)
72}
73
74/// Restore placeholders back to their original values.
75pub fn restore_from_placeholders(s: &str, placeholders: &HashMap<String, String>) -> String {
76    let mut result = s.to_string();
77    for (ph, original) in placeholders {
78        result = result.replace(ph, original);
79    }
80    result
81}
82
83// ---------------------------------------------------------------------------
84// Parser-combinator infrastructure
85// ---------------------------------------------------------------------------
86
87/// A single parse result: `consumed` is the matched/transformed text,
88/// `left` is the unconsumed remainder.
89#[derive(Clone, Debug)]
90struct ParseResult {
91    consumed: String,
92    left: String,
93}
94
95/// The open-tag mapping: markdown token → HTML open tag.
96static OPEN_TAGS: &[(&str, &str)] = &[("*", "<i>"), ("**", "<b>"), ("_", "<i>"), ("__", "<b>")];
97
98/// The close-tag mapping: markdown token → HTML close tag.
99static CLOSE_TAGS: &[(&str, &str)] =
100    &[("*", "</i>"), ("**", "</b>"), ("_", "</i>"), ("__", "</b>")];
101
102fn open_tag(token: &str) -> &'static str {
103    OPEN_TAGS
104        .iter()
105        .find(|(k, _)| *k == token)
106        .map(|(_, v)| *v)
107        .unwrap_or("")
108}
109
110fn close_tag(token: &str) -> &'static str {
111    CLOSE_TAGS
112        .iter()
113        .find(|(k, _)| *k == token)
114        .map(|(_, v)| *v)
115        .unwrap_or("")
116}
117
118/// Using `Rc<dyn Fn>` so that parsers can be cloned (needed for grammar reuse).
119type Parser = Rc<dyn Fn(&str) -> Vec<ParseResult>>;
120
121/// `open(tag)` — recognises the opening markdown token and, on success,
122/// produces the corresponding HTML open tag.
123fn parse_open(token: &'static str) -> Parser {
124    Rc::new(move |input: &str| {
125        if let Some(rest) = input.strip_prefix(token) {
126            vec![ParseResult {
127                consumed: open_tag(token).to_string(),
128                left: rest.to_string(),
129            }]
130        } else {
131            vec![]
132        }
133    })
134}
135
136/// `close(tag)` — recognises the closing markdown token and, on success,
137/// produces the corresponding HTML close tag.
138fn parse_close(token: &'static str) -> Parser {
139    Rc::new(move |input: &str| {
140        if let Some(rest) = input.strip_prefix(token) {
141            vec![ParseResult {
142                consumed: close_tag(token).to_string(),
143                left: rest.to_string(),
144            }]
145        } else {
146            vec![]
147        }
148    })
149}
150
151/// `not_markdown()` — consumes plain text up to the next `*` or `_` character.
152fn parse_not_markdown() -> Parser {
153    Rc::new(|input: &str| {
154        for (i, ch) in input.char_indices() {
155            if ch == '*' || ch == '_' {
156                return vec![ParseResult {
157                    consumed: input[..i].to_string(),
158                    left: input[i..].to_string(),
159                }];
160            }
161        }
162        if !input.is_empty() {
163            vec![ParseResult {
164                consumed: input.to_string(),
165                left: String::new(),
166            }]
167        } else {
168            vec![]
169        }
170    })
171}
172
173/// `or` — try parsers in order; return the first non-empty result (PEG).
174///
175/// Earlier combinators collected every successful parse and the caller
176/// iterated all of them, which made ambiguous grammars like ours explode
177/// exponentially on inputs such as `*_**__…`. Switching to PEG
178/// first-match-wins keeps the parse linear in the input length.
179fn parse_or(parsers: Vec<Parser>) -> Parser {
180    Rc::new(move |input: &str| {
181        for p in &parsers {
182            if let Some(first) = p(input).into_iter().next() {
183                return vec![first];
184            }
185        }
186        vec![]
187    })
188}
189
190/// `and` — apply parsers in sequence; every parser must consume something.
191///
192/// Uses PEG semantics: only the first successful result is kept at each
193/// step, avoiding the cartesian-product explosion of the original
194/// combinator that collected every alternative.
195fn parse_and(parsers: Vec<Parser>) -> Parser {
196    Rc::new(move |input: &str| {
197        let mut current = ParseResult {
198            consumed: String::new(),
199            left: input.to_string(),
200        };
201
202        for p in &parsers {
203            let Some(parsed) = p(&current.left)
204                .into_iter()
205                .find(|x| !x.consumed.is_empty())
206            else {
207                return vec![];
208            };
209            current = ParseResult {
210                consumed: format!("{}{}", current.consumed, parsed.consumed),
211                left: parsed.left,
212            };
213        }
214        vec![current]
215    })
216}
217
218/// `some` — apply a parser one or more times (recursive).
219fn parse_some(parser: Parser) -> Parser {
220    Rc::new(move |input: &str| recursive(input, &parser, 0))
221}
222
223fn recursive(input: &str, parser: &Parser, depth: usize) -> Vec<ParseResult> {
224    // Hard depth bound as a safety net. The single-result invariant from
225    // parse_or/parse_and already gives linear-time behaviour; this cap
226    // guards against pathological inputs that could still produce deep
227    // recursion (e.g. very long plain-text runs that consume one char
228    // per step).
229    const MAX_RECURSION_DEPTH: usize = 4096;
230    if depth >= MAX_RECURSION_DEPTH {
231        return vec![ParseResult {
232            consumed: String::new(),
233            left: input.to_string(),
234        }];
235    }
236
237    let Some(item) = parser(input).into_iter().find(|x| !x.consumed.is_empty()) else {
238        // No match: at top level the whole parse failed; deeper levels
239        // return an identity (zero-consumed) result so the parent chain
240        // can include whatever was consumed so far.
241        if depth == 0 {
242            return vec![];
243        }
244        return vec![ParseResult {
245            consumed: String::new(),
246            left: input.to_string(),
247        }];
248    };
249
250    // Try to extend by recursing on the remainder.
251    let children = recursive(&item.left, parser, depth + 1);
252    if children.is_empty() {
253        return vec![item];
254    }
255    children
256        .into_iter()
257        .map(|child| ParseResult {
258            consumed: format!("{}{}", item.consumed, child.consumed),
259            left: child.left,
260        })
261        .collect()
262}
263
264/// Build the top-level inline markdown parser.
265fn markdown_parser() -> Parser {
266    // text = notMarkdown
267    let text = parse_not_markdown();
268
269    // italicNoBold = or(
270    //     and(open("*"), text, close("*")),
271    //     and(open("_"), text, close("_")),
272    // )
273    let italic_no_bold = parse_or(vec![
274        parse_and(vec![
275            parse_open("*"),
276            parse_not_markdown(),
277            parse_close("*"),
278        ]),
279        parse_and(vec![
280            parse_open("_"),
281            parse_not_markdown(),
282            parse_close("_"),
283        ]),
284    ]);
285
286    // bold = or(
287    //     and(open("**"), some(or(text, italicNoBold)), close("**")),
288    //     and(open("__"), some(or(text, italicNoBold)), close("__")),
289    // )
290    let bold = parse_or(vec![
291        parse_and(vec![
292            parse_open("**"),
293            parse_some(parse_or(vec![parse_not_markdown(), italic_no_bold.clone()])),
294            parse_close("**"),
295        ]),
296        parse_and(vec![
297            parse_open("__"),
298            parse_some(parse_or(vec![parse_not_markdown(), italic_no_bold])),
299            parse_close("__"),
300        ]),
301    ]);
302
303    // italic = or(
304    //     and(open("*"), some(or(text, bold)), close("*")),
305    //     and(open("_"), some(or(text, bold)), close("_")),
306    // )
307    let italic = parse_or(vec![
308        parse_and(vec![
309            parse_open("*"),
310            parse_some(parse_or(vec![parse_not_markdown(), bold.clone()])),
311            parse_close("*"),
312        ]),
313        parse_and(vec![
314            parse_open("_"),
315            parse_some(parse_or(vec![parse_not_markdown(), bold.clone()])),
316            parse_close("_"),
317        ]),
318    ]);
319
320    // span = or(bold, italic, text)
321    // result = some(span)
322    parse_some(parse_or(vec![bold, italic, text]))
323}
324
325// ---------------------------------------------------------------------------
326// Public API — MarkdownToHTML
327// ---------------------------------------------------------------------------
328
329/// Convert markdown to Telegram-supported HTML subset.
330///
331/// Handles inline `*`/`_` → `<i>`, `**`/`__` → `<b>`, backtick code blocks,
332/// and `#` headers.
333///
334/// Inputs larger than [`MAX_MARKDOWN_HTML_INPUT`] bypass the parser and
335/// are returned HTML-escaped only — this is a hard ceiling to bound work
336/// on attacker-controlled content (F2). The parser itself is linear-time
337/// under PEG semantics, so the cap is a secondary guard.
338pub const MAX_MARKDOWN_HTML_INPUT: usize = 64 * 1024;
339
340/// Convert a markdown string to sanitized HTML. Inputs larger than
341/// [`MAX_MARKDOWN_HTML_INPUT`] are HTML-escaped without parsing to bound work
342/// on hostile content.
343pub fn markdown_to_html(md: &str) -> String {
344    if md.len() > MAX_MARKDOWN_HTML_INPUT {
345        return escape_html(md);
346    }
347
348    let md_without_code = escape_html(md);
349
350    // Protect code blocks (```...```) and inline code (`...`)
351    let (md_without_code, code_placeholders) =
352        replace_with_placeholders(&md_without_code, r"(?s)```.*?```", "c0debl0ck");
353    let (md_without_code, inline_placeholders) =
354        replace_with_placeholders(&md_without_code, r"`[^`]+`", "inl1ne");
355
356    // Split by double-newline; each segment is parsed independently.
357    let segments = RE_NEWLINES.split(&md_without_code);
358    let processed: Vec<String> = segments
359        .map(|segment| {
360            let parser = markdown_parser();
361            let docs = parser(segment);
362            if !docs.is_empty() {
363                format!("{}{}", docs[0].consumed, docs[0].left)
364            } else {
365                segment.to_string()
366            }
367        })
368        .collect();
369    let md_without_code = processed.join("\n\n");
370
371    // Restore code blocks
372    let mut result = restore_from_placeholders(&md_without_code, &code_placeholders);
373    result = restore_from_placeholders(&result, &inline_placeholders);
374
375    // Convert ```...``` → <pre>...</pre>
376    result = RE_CODE_BLOCK
377        .replace_all(&result, |caps: &regex::Captures<'_>| {
378            let inner = caps
379                .get(1)
380                .expect("capture group present after successful match")
381                .as_str()
382                .trim();
383            format!("<pre>{inner}</pre>")
384        })
385        .to_string();
386
387    // Convert `...` → <code>...</code>
388    result = RE_INLINE_CODE
389        .replace_all(&result, "<code>$1</code>")
390        .to_string();
391
392    // Convert #+ heading → <b>heading</b>
393    result = RE_HEADER.replace_all(&result, "<b>$1</b>").to_string();
394
395    result
396}
397
398// ---------------------------------------------------------------------------
399// Tests
400// ---------------------------------------------------------------------------
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn test_escape_html() {
408        assert_eq!(escape_html("a & b < c > d"), "a &amp; b &lt; c &gt; d");
409        assert_eq!(escape_html("plain"), "plain");
410    }
411
412    #[test]
413    fn test_strip_html_tags() {
414        assert_eq!(strip_html_tags("<b>hello</b>"), "hello");
415        assert_eq!(strip_html_tags("no tags"), "no tags");
416        assert_eq!(
417            strip_html_tags("<b>bold</b> and <i>italic</i>"),
418            "bold and italic"
419        );
420    }
421
422    #[test]
423    fn test_replace_and_restore_placeholders() {
424        let input = "some ```code``` here";
425        let (modified, phs) = replace_with_placeholders(input, r"(?s)```.*?```", "c0de");
426        assert!(modified.contains("c0de"));
427        let restored = restore_from_placeholders(&modified, &phs);
428        assert_eq!(restored, input);
429    }
430
431    #[test]
432    fn test_markdown_to_html_italic() {
433        let result = markdown_to_html("hello *world*");
434        assert!(result.contains("<i>world</i>"));
435        assert!(result.contains("hello"));
436    }
437
438    #[test]
439    fn test_markdown_to_html_bold() {
440        let result = markdown_to_html("hello **world**");
441        assert!(result.contains("<b>world</b>"));
442    }
443
444    #[test]
445    fn test_markdown_to_html_bold_underscore() {
446        let result = markdown_to_html("hello __world__");
447        assert!(result.contains("<b>world</b>"));
448    }
449
450    #[test]
451    fn test_markdown_to_html_italic_underscore() {
452        let result = markdown_to_html("hello _world_");
453        assert!(result.contains("<i>world</i>"));
454    }
455
456    #[test]
457    fn test_markdown_to_html_code_block() {
458        let result = markdown_to_html("```\ncode\n```");
459        assert!(result.contains("<pre>code</pre>"));
460    }
461
462    #[test]
463    fn test_markdown_to_html_inline_code() {
464        let result = markdown_to_html("use `foo` here");
465        assert!(result.contains("<code>foo</code>"));
466    }
467
468    #[test]
469    fn test_markdown_to_html_header() {
470        let result = markdown_to_html("# Title");
471        assert!(result.contains("<b>Title</b>"));
472    }
473
474    #[test]
475    fn test_markdown_to_html_header_h3() {
476        let result = markdown_to_html("### Subtitle");
477        assert!(result.contains("<b>Subtitle</b>"));
478    }
479
480    #[test]
481    fn test_markdown_to_html_plain_text_unchanged() {
482        let result = markdown_to_html("just plain text");
483        assert_eq!(result, "just plain text");
484    }
485
486    #[test]
487    fn test_markdown_to_html_html_chars_escaped() {
488        let result = markdown_to_html("a < b & c > d");
489        assert!(result.contains("&lt;"));
490        assert!(result.contains("&gt;"));
491        assert!(result.contains("&amp;"));
492    }
493
494    #[test]
495    fn test_markdown_to_html_mixed() {
496        let result = markdown_to_html("**bold** and *italic* and `code`");
497        assert!(result.contains("<b>bold</b>"));
498        assert!(result.contains("<i>italic</i>"));
499        assert!(result.contains("<code>code</code>"));
500    }
501
502    #[test]
503    fn test_parser_not_markdown() {
504        let p = parse_not_markdown();
505        let results = p("hello*world");
506        assert_eq!(results.len(), 1);
507        assert_eq!(results[0].consumed, "hello");
508        assert_eq!(results[0].left, "*world");
509    }
510
511    #[test]
512    fn test_parser_not_markdown_no_special() {
513        let p = parse_not_markdown();
514        let results = p("hello world");
515        assert_eq!(results.len(), 1);
516        assert_eq!(results[0].consumed, "hello world");
517        assert_eq!(results[0].left, "");
518    }
519
520    #[test]
521    fn test_parser_open_close() {
522        let p = parse_open("**");
523        let results = p("**bold**");
524        assert_eq!(results.len(), 1);
525        assert_eq!(results[0].consumed, "<b>");
526        assert_eq!(results[0].left, "bold**");
527
528        let p = parse_close("**");
529        let results = p("**rest");
530        assert_eq!(results.len(), 1);
531        assert_eq!(results[0].consumed, "</b>");
532        assert_eq!(results[0].left, "rest");
533    }
534
535    #[test]
536    fn test_parser_and() {
537        let p = parse_and(vec![
538            parse_open("*"),
539            parse_not_markdown(),
540            parse_close("*"),
541        ]);
542        let results = p("*hello*");
543        assert!(!results.is_empty());
544        assert_eq!(results[0].consumed, "<i>hello</i>");
545    }
546}