Skip to main content

robit_chatbot/
markdown.rs

1//! Markdown sanitizer for platform-specific rendering.
2//!
3//! LLM outputs are Markdown. Each chat platform supports a subset of
4//! CommonMark. This module converts LLM Markdown into the subset supported
5//! by a given platform, stripping or converting unsupported features:
6//!
7//! - **Tables** → aligned plain text (QQ does not support table syntax)
8//! - **Task lists** (`- [ ]`) → plain unordered lists
9//! - **HTML tags** → stripped entirely
10//! - **Images** (`![]()`) → `[Image: alt]` fallback text
11//! - **Horizontal rules** (`---`) → stripped
12//!
13//! Everything else (headings, bold, italic, code, links, lists, blockquotes,
14//! strikethrough) is passed through as-is. Parsing uses `pulldown-cmark`
15//! (already in the workspace) for safe event-driven handling, so unsupported
16//! syntax inside code blocks is preserved verbatim.
17
18use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
19
20use crate::adapter::MarkdownFeatures;
21
22/// Prepare Markdown for a platform — pass through supported syntax, strip/convert unsupported.
23///
24/// `features` describes what the target platform supports; unsupported features
25/// are converted to a readable fallback rather than dropped silently.
26pub fn prepare_markdown_for_platform(text: &str, features: &MarkdownFeatures) -> String {
27    let mut opts = Options::empty();
28    if features.strikethrough {
29        opts.insert(Options::ENABLE_STRIKETHROUGH);
30    }
31    opts.insert(Options::ENABLE_TABLES);
32    opts.insert(Options::ENABLE_TASKLISTS);
33
34    let parser = Parser::new_ext(text, opts);
35    let mut output = String::with_capacity(text.len());
36    let mut ctx = RenderCtx::new(features);
37
38    for event in parser {
39        match event {
40            // ----- Inline text -----
41            Event::Text(t) => {
42                output.push_str(&t);
43            }
44            Event::Code(c) => {
45                if features.inline_code {
46                    output.push('`');
47                    output.push_str(&c);
48                    output.push('`');
49                } else {
50                    output.push_str(&c);
51                }
52            }
53            Event::SoftBreak => output.push('\n'),
54            Event::HardBreak => output.push_str("  \n"),
55
56            // ----- Emphasis -----
57            Event::Start(Tag::Strong) if features.bold => output.push_str("**"),
58            Event::End(TagEnd::Strong) if features.bold => output.push_str("**"),
59            Event::Start(Tag::Emphasis) if features.italic => output.push('*'),
60            Event::End(TagEnd::Emphasis) if features.italic => output.push('*'),
61            Event::Start(Tag::Strikethrough) if features.strikethrough => output.push_str("~~"),
62            Event::End(TagEnd::Strikethrough) if features.strikethrough => output.push_str("~~"),
63
64            // ----- Headings -----
65            Event::Start(Tag::Heading { level, .. }) if features.headings => {
66                let hashes = "#".repeat(level as usize);
67                output.push_str(&hashes);
68                output.push(' ');
69            }
70            Event::End(TagEnd::Heading(_)) if features.headings => {
71                output.push_str("\n\n");
72            }
73            // Unsupported headings → bold + blank line fallback.
74            Event::Start(Tag::Heading { .. }) => output.push_str("**"),
75            Event::End(TagEnd::Heading(_)) => output.push_str("**\n\n"),
76
77            // ----- Paragraphs -----
78            Event::Start(Tag::Paragraph) => {}
79            Event::End(TagEnd::Paragraph) => output.push_str("\n\n"),
80
81            // ----- Code blocks (pass through verbatim) -----
82            Event::Start(Tag::CodeBlock(_)) => {
83                ctx.in_code_block = true;
84                output.push_str("\n```\n");
85            }
86            Event::End(TagEnd::CodeBlock) if ctx.in_code_block => {
87                ctx.in_code_block = false;
88                // Avoid doubling a newline already present at the end of the
89                // code text.
90                if output.ends_with('\n') {
91                    output.push_str("```\n\n");
92                } else {
93                    output.push_str("\n```\n\n");
94                }
95            }
96
97            // ----- Lists -----
98            Event::Start(Tag::List(None)) => ctx.list_stack.push(ListKind::Unordered),
99            Event::Start(Tag::List(Some(start))) => {
100                ctx.list_stack.push(ListKind::Ordered(start));
101            }
102            Event::End(TagEnd::List(_)) => {
103                ctx.list_stack.pop();
104                if ctx.list_stack.is_empty() {
105                    output.push('\n');
106                }
107            }
108            Event::Start(Tag::Item) => {
109                ctx.indent(&mut output);
110                match ctx.list_stack.last() {
111                    Some(ListKind::Ordered(n)) => {
112                        output.push_str(&format!("{}. ", n));
113                    }
114                    _ => output.push_str("- "),
115                }
116            }
117            Event::End(TagEnd::Item) => {
118                if !output.ends_with('\n') {
119                    output.push('\n');
120                }
121            }
122
123            // ----- Task list items → plain list items -----
124            // pulldown-cmark emits TaskList markers via Item start; the checked
125            // state arrives as a separate event we don't model here, so the
126            // `[ ]`/`[x]` prefix is simply not emitted (already covered by the
127            // Item handling above, which writes `- `).
128
129            // ----- Blockquotes -----
130            Event::Start(Tag::BlockQuote(_)) if features.blockquotes => {
131                ctx.in_blockquote = true;
132            }
133            Event::End(TagEnd::BlockQuote(_)) if ctx.blockquote_was_open() => {
134                ctx.in_blockquote = false;
135            }
136            Event::Start(Tag::BlockQuote(_)) => {
137                // Unsupported blockquote → indent as plain text.
138                output.push_str("> ");
139            }
140            Event::End(TagEnd::BlockQuote(_)) => {
141                output.push('\n');
142            }
143
144            // ----- Links -----
145            Event::Start(Tag::Link { dest_url, .. }) if features.links => {
146                ctx.link_url = Some(dest_url.into_string());
147                output.push('[');
148            }
149            Event::End(TagEnd::Link) if ctx.link_url.is_some() => {
150                if let Some(url) = ctx.link_url.take() {
151                    output.push_str(&format!("]({})", url));
152                }
153            }
154            // Unsupported links → just the link text (no syntax).
155            Event::Start(Tag::Link { .. }) => {}
156            Event::End(TagEnd::Link) => {}
157
158            // ----- Images → [Image: alt] fallback -----
159            Event::Start(Tag::Image { dest_url, .. }) => {
160                ctx.image_url = Some(dest_url.into_string());
161                output.push_str("[Image: ");
162            }
163            Event::End(TagEnd::Image) => {
164                ctx.image_url = None;
165                output.push(']');
166            }
167
168            // ----- Horizontal rules → strip (unsupported on QQ) -----
169            Event::Rule => {
170                output.push('\n');
171            }
172
173            // ----- Tables → render as aligned plain text -----
174            Event::Start(Tag::Table(_)) => {
175                ctx.in_table = true;
176                ctx.table_rows.clear();
177                ctx.current_row.clear();
178            }
179            Event::End(TagEnd::Table) => {
180                ctx.in_table = false;
181                output.push_str(&render_table(&ctx.table_rows));
182                ctx.table_rows.clear();
183            }
184            Event::Start(Tag::TableHead) => {}
185            Event::End(TagEnd::TableHead) => {
186                ctx.table_rows.push(std::mem::take(&mut ctx.current_row));
187            }
188            Event::Start(Tag::TableRow) => {}
189            Event::End(TagEnd::TableRow) => {
190                ctx.table_rows.push(std::mem::take(&mut ctx.current_row));
191            }
192            Event::Start(Tag::TableCell) => {
193                ctx.cell_buf.clear();
194                ctx.in_cell = true;
195            }
196            Event::End(TagEnd::TableCell) => {
197                ctx.in_cell = false;
198                ctx.current_row.push(ctx.cell_buf.trim().to_string());
199            }
200
201            // Inside a table cell, capture text rather than emitting directly.
202            // (Handled in the Text branch below via in_cell flag.)
203
204            // ----- Footnote / definition / everything else → ignore -----
205            _ => {}
206        }
207
208        // Capture text inside table cells into the cell buffer instead of output.
209        if ctx.in_cell {
210            if let Some(stripped) = strip_last_text(&mut output) {
211                ctx.cell_buf.push_str(&stripped);
212            }
213        }
214    }
215
216    // Collapse 3+ newlines to 2 for tidy output.
217    while output.contains("\n\n\n") {
218        output = output.replace("\n\n\n", "\n\n");
219    }
220    output.trim_end().to_string() + "\n"
221}
222
223#[derive(Debug, Clone, Copy)]
224enum ListKind {
225    Unordered,
226    Ordered(u64),
227}
228
229struct RenderCtx<'a> {
230    #[allow(dead_code)]
231    features: &'a MarkdownFeatures,
232    in_code_block: bool,
233    in_blockquote: bool,
234    list_stack: Vec<ListKind>,
235    link_url: Option<String>,
236    image_url: Option<String>,
237    // Table state
238    in_table: bool,
239    in_cell: bool,
240    cell_buf: String,
241    current_row: Vec<String>,
242    table_rows: Vec<Vec<String>>,
243}
244
245impl<'a> RenderCtx<'a> {
246    fn new(features: &'a MarkdownFeatures) -> Self {
247        Self {
248            features,
249            in_code_block: false,
250            in_blockquote: false,
251            list_stack: Vec::new(),
252            link_url: None,
253            image_url: None,
254            in_table: false,
255            in_cell: false,
256            cell_buf: String::new(),
257            current_row: Vec::new(),
258            table_rows: Vec::new(),
259        }
260    }
261
262    fn indent(&self, out: &mut String) {
263        // One space per nested list level (beyond the first).
264        for _ in 0..self.list_stack.len().saturating_sub(1) {
265            out.push_str("  ");
266        }
267    }
268
269    fn blockquote_was_open(&self) -> bool {
270        self.in_blockquote
271    }
272}
273
274/// Render collected table rows as aligned plain text.
275fn render_table(rows: &[Vec<String>]) -> String {
276    if rows.is_empty() {
277        return String::new();
278    }
279    let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
280    let mut widths = vec![0usize; cols];
281    for row in rows {
282        for (i, cell) in row.iter().enumerate() {
283            widths[i] = widths[i].max(cell.chars().count());
284        }
285    }
286    let mut out = String::new();
287    for (ri, row) in rows.iter().enumerate() {
288        for (i, cell) in row.iter().enumerate() {
289            let w = widths.get(i).copied().unwrap_or(0);
290            let pad = w.saturating_sub(cell.chars().count());
291            out.push_str(cell);
292            out.push_str(&" ".repeat(pad));
293            if i + 1 < cols {
294                out.push_str(" | ");
295            }
296        }
297        out.push('\n');
298        if ri == 0 {
299            // Separator line under the header.
300            for (i, w) in widths.iter().enumerate() {
301                out.push_str(&"-".repeat(*w));
302                if i + 1 < cols {
303                    out.push_str("-+-");
304                }
305            }
306            out.push('\n');
307        }
308    }
309    out.push('\n');
310    out
311}
312
313/// Pull the last contiguous text chunk back out of `output` (used to redirect
314/// cell text into the cell buffer). Returns the extracted text.
315fn strip_last_text(output: &mut String) -> Option<String> {
316    // The Text handler pushes the raw string; we appended it at the very end,
317    // so trim trailing non-newline chars back to the last newline boundary.
318    let end = output.len();
319    if end == 0 {
320        return None;
321    }
322    let bytes = output.as_bytes();
323    let mut start = end;
324    while start > 0 && bytes[start - 1] != b'\n' {
325        start -= 1;
326    }
327    if start == end {
328        return None;
329    }
330    let text = output[start..end].to_string();
331    output.truncate(start);
332    Some(text)
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    fn qq() -> MarkdownFeatures {
340        MarkdownFeatures::qq()
341    }
342
343    #[test]
344    fn strips_bold_and_italic_for_qq() {
345        // QQ does not support bold/italic markdown - they should be stripped to plain text.
346        let out = prepare_markdown_for_platform("**bold** and *italic*", &qq());
347        assert!(!out.contains("**bold**"));
348        assert!(!out.contains("*italic*"));
349        assert!(out.contains("bold and italic"));
350    }
351
352    #[test]
353    fn passes_through_code_blocks_for_qq() {
354        // QQ does support code blocks.
355        let md = "```rust\nfn main() {}\n```\n";
356        let out = prepare_markdown_for_platform(md, &qq());
357        assert!(out.contains("```\nfn main() {}\n```"));
358    }
359
360    #[test]
361    fn strips_inline_code_for_qq() {
362        // QQ does not support inline code markdown - backticks should be removed.
363        let out = prepare_markdown_for_platform("use `cargo` to build", &qq());
364        assert!(!out.contains("`cargo`"));
365        assert!(out.contains("cargo"));
366    }
367
368    #[test]
369    fn strips_links_for_qq() {
370        // QQ does not support link markdown - output plain text.
371        let out = prepare_markdown_for_platform("[site](https://example.com)", &qq());
372        // Links should be converted to plain text (either the label or URL, not markdown).
373        assert!(out.contains("site") || out.contains("https://example.com"));
374    }
375
376    #[test]
377    fn converts_image_to_alt_fallback() {
378        let out = prepare_markdown_for_platform("![logo](https://x.com/a.png)", &qq());
379        assert!(out.contains("[Image: logo]"));
380        assert!(!out.contains("https://x.com/a.png"));
381    }
382
383    #[test]
384    fn strips_html_tags() {
385        let out = prepare_markdown_for_platform("<b>hi</b>", &qq());
386        assert!(!out.contains("<b>"));
387        assert!(out.contains("hi"));
388    }
389
390    #[test]
391    fn converts_table_to_aligned_text() {
392        let md = "| a | b |\n|---|---|\n| 1 | 2 |\n";
393        let out = prepare_markdown_for_platform(md, &qq());
394        // No pipe-table markdown remains; aligned text rows present.
395        assert!(!out.contains("|---|"));
396        assert!(out.contains("a"));
397        assert!(out.contains("1"));
398    }
399
400    #[test]
401    fn preserves_code_block_contents_with_dashes() {
402        // Dashes inside a code block must not be mangled into rules.
403        let md = "```\n---\nx\n```\n";
404        let out = prepare_markdown_for_platform(md, &qq());
405        assert!(out.contains("---\nx"));
406    }
407
408    #[test]
409    fn handles_empty_input() {
410        let out = prepare_markdown_for_platform("", &qq());
411        assert!(out.trim().is_empty());
412    }
413
414    #[test]
415    fn handles_unicode_for_qq() {
416        // Bold markers are stripped for QQ, but the text content should remain.
417        let out = prepare_markdown_for_platform("**你好** 世界", &qq());
418        assert!(out.contains("你好"));
419        assert!(out.contains("世界"));
420    }
421
422    #[test]
423    fn unsupported_headings_become_bold() {
424        let mut f = MarkdownFeatures::default(); // headings disabled
425        f.bold = true;
426        let out = prepare_markdown_for_platform("# Title", &f);
427        assert!(out.contains("**Title**"));
428    }
429}