Skip to main content

typstify_parser/
markdown.rs

1//! Markdown parser using pulldown-cmark.
2
3use std::path::Path;
4
5use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
6use thiserror::Error;
7use typstify_core::{
8    content::{ParsedContent, TocEntry},
9    frontmatter::parse_frontmatter,
10    utils::{html_escape, slugify},
11};
12
13use crate::syntax::SyntaxHighlighter;
14
15/// Markdown parsing errors.
16#[derive(Debug, Error)]
17pub enum MarkdownError {
18    /// Failed to parse frontmatter.
19    #[error("frontmatter error: {0}")]
20    Frontmatter(#[from] typstify_core::error::CoreError),
21}
22
23/// Result type for markdown operations.
24pub type Result<T> = std::result::Result<T, MarkdownError>;
25
26/// Markdown parser with syntax highlighting support.
27#[derive(Debug)]
28pub struct MarkdownParser {
29    highlighter: SyntaxHighlighter,
30    options: Options,
31    sanitize_html: bool,
32}
33
34impl Default for MarkdownParser {
35    fn default() -> Self {
36        Self::new()
37    }
38}
39
40impl MarkdownParser {
41    /// Create a new markdown parser with default options.
42    pub fn new() -> Self {
43        let mut options = Options::empty();
44        options.insert(Options::ENABLE_TABLES);
45        options.insert(Options::ENABLE_FOOTNOTES);
46        options.insert(Options::ENABLE_STRIKETHROUGH);
47        options.insert(Options::ENABLE_TASKLISTS);
48        options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
49
50        Self {
51            highlighter: SyntaxHighlighter::default(),
52            options,
53            sanitize_html: false,
54        }
55    }
56
57    /// Enable or disable HTML sanitization.
58    pub fn with_sanitize_html(mut self, sanitize: bool) -> Self {
59        self.sanitize_html = sanitize;
60        self
61    }
62
63    /// Create a parser with a custom syntax theme.
64    pub fn with_theme(theme: &str) -> Self {
65        let mut parser = Self::new();
66        parser.highlighter.set_theme(theme);
67        parser
68    }
69
70    /// Parse markdown content with frontmatter.
71    pub fn parse(&self, content: &str, path: &Path) -> Result<ParsedContent> {
72        // Split frontmatter from body
73        let (frontmatter, body) = parse_frontmatter(content, path)?;
74
75        // Parse the markdown body
76        let (html, toc) = self.render_markdown(&body);
77
78        Ok(ParsedContent {
79            frontmatter,
80            html,
81            raw: body,
82            toc,
83        })
84    }
85
86    /// Parse markdown without frontmatter (body only).
87    pub fn parse_body(&self, body: &str) -> (String, Vec<TocEntry>) {
88        self.render_markdown(body)
89    }
90
91    /// Render markdown to HTML with TOC extraction.
92    fn render_markdown(&self, content: &str) -> (String, Vec<TocEntry>) {
93        let parser = Parser::new_ext(content, self.options);
94        let mut toc = Vec::new();
95        let mut html = String::new();
96        let mut current_heading: Option<(u8, String)> = None;
97        let mut code_block_lang: Option<String> = None;
98        let mut code_block_content = String::new();
99
100        for event in parser {
101            match event {
102                // Handle heading start
103                Event::Start(Tag::Heading { level, id, .. }) => {
104                    let lvl = level as u8;
105                    current_heading = Some((lvl, String::new()));
106                    let id_attr = id.map(|i| format!(" id=\"{i}\"")).unwrap_or_default();
107                    html.push_str(&format!("<h{lvl}{id_attr}>"));
108                }
109
110                // Handle heading end
111                Event::End(TagEnd::Heading(level)) => {
112                    let lvl = level as u8;
113                    if let Some((_, ref text)) = current_heading {
114                        let id = slugify(text);
115                        toc.push(TocEntry {
116                            level: lvl,
117                            text: text.clone(),
118                            id: id.clone(),
119                        });
120                    }
121                    html.push_str(&format!("</h{lvl}>"));
122                    current_heading = None;
123                }
124
125                // Handle code block start
126                Event::Start(Tag::CodeBlock(kind)) => {
127                    code_block_lang = match kind {
128                        CodeBlockKind::Fenced(lang) => {
129                            let lang = lang.to_string();
130                            if lang.is_empty() { None } else { Some(lang) }
131                        }
132                        CodeBlockKind::Indented => None,
133                    };
134                    code_block_content.clear();
135                }
136
137                // Handle code block end
138                Event::End(TagEnd::CodeBlock) => {
139                    let highlighted = self
140                        .highlighter
141                        .highlight(&code_block_content, code_block_lang.as_deref());
142                    html.push_str(&highlighted);
143                    code_block_lang = None;
144                    code_block_content.clear();
145                }
146
147                // Handle text inside code blocks
148                Event::Text(text)
149                    if code_block_lang.is_some() || !code_block_content.is_empty() =>
150                {
151                    code_block_content.push_str(&text);
152                }
153
154                // Handle regular text
155                Event::Text(text) => {
156                    if let Some((_, ref mut heading_text)) = current_heading {
157                        heading_text.push_str(&text);
158                    }
159                    html.push_str(&html_escape(&text));
160                }
161
162                // Handle code (inline)
163                Event::Code(code) => {
164                    if let Some((_, ref mut heading_text)) = current_heading {
165                        heading_text.push_str(&code);
166                    }
167                    html.push_str(&format!("<code>{}</code>", html_escape(&code)));
168                }
169
170                // Handle soft breaks
171                Event::SoftBreak => {
172                    html.push('\n');
173                }
174
175                // Handle hard breaks
176                Event::HardBreak => {
177                    html.push_str("<br />\n");
178                }
179
180                // Handle other start tags
181                Event::Start(tag) => {
182                    html.push_str(&tag_to_html_start(&tag));
183                }
184
185                // Handle other end tags
186                Event::End(tag) => {
187                    html.push_str(&tag_to_html_end(&tag));
188                }
189
190                // Handle HTML
191                Event::Html(raw) | Event::InlineHtml(raw) => {
192                    if self.sanitize_html {
193                        html.push_str(&sanitize_html_raw(&raw));
194                    } else {
195                        html.push_str(&raw);
196                    }
197                }
198
199                // Handle footnote references
200                Event::FootnoteReference(name) => {
201                    html.push_str(&format!(
202                        "<sup class=\"footnote-ref\"><a href=\"#fn-{name}\">[{name}]</a></sup>"
203                    ));
204                }
205
206                // Handle rules
207                Event::Rule => {
208                    html.push_str("<hr />\n");
209                }
210
211                // Handle task list markers
212                Event::TaskListMarker(checked) => {
213                    let checkbox = if checked {
214                        "<input type=\"checkbox\" checked disabled />"
215                    } else {
216                        "<input type=\"checkbox\" disabled />"
217                    };
218                    html.push_str(checkbox);
219                }
220
221                Event::InlineMath(math) => {
222                    html.push_str(&format!("<span class=\"math inline\">\\({math}\\)</span>"));
223                }
224
225                Event::DisplayMath(math) => {
226                    html.push_str(&format!("<div class=\"math display\">\\[{math}\\]</div>"));
227                }
228            }
229        }
230
231        (html, toc)
232    }
233}
234
235/// Strip dangerous HTML tags and on* event handler attributes.
236fn sanitize_html_raw(raw: &str) -> String {
237    let mut result = raw.to_string();
238
239    // Strip <script>, <iframe>, <object> tags (opening and closing)
240    for tag in &["script", "iframe", "object"] {
241        let open = format!("<{tag}");
242        let close = format!("</{tag}>");
243        while let Some(start) = result.to_lowercase().find(&open.to_lowercase()) {
244            let tag_end = find_tag_end(&result, start);
245            result.replace_range(start..tag_end, "");
246        }
247        while let Some(pos) = result.to_lowercase().find(&close.to_lowercase()) {
248            result.replace_range(pos..pos + close.len(), "");
249        }
250    }
251
252    // Strip on* event handler attributes from tags
253    strip_on_event_handlers(&mut result);
254
255    result
256}
257
258/// Find the end of an opening HTML tag (the position after '>').
259fn find_tag_end(s: &str, start: usize) -> usize {
260    s[start..]
261        .find('>')
262        .map(|i| start + i + 1)
263        .unwrap_or(s.len())
264}
265
266/// Strip `on*="..."` and `on*='...'` attributes from HTML tags.
267fn strip_on_event_handlers(s: &mut String) {
268    let mut result = String::with_capacity(s.len());
269    let bytes = s.as_bytes();
270    let mut i = 0;
271
272    while i < bytes.len() {
273        // Look for 'on' at start of an attribute name
274        if bytes[i] == b'o'
275            && i + 1 < bytes.len()
276            && bytes[i + 1] == b'n'
277            && (i == 0 || !bytes[i - 1].is_ascii_alphanumeric())
278        {
279            // Scan past the rest of the attribute name
280            let mut word_end = i + 2;
281            while word_end < bytes.len() && bytes[word_end].is_ascii_alphanumeric() {
282                word_end += 1;
283            }
284            // Check if followed by '=' (assignment)
285            if word_end > i + 2 && word_end < bytes.len() && bytes[word_end] == b'=' {
286                // Skip the on*="..." or on*='...' attribute
287                let mut pos = word_end + 1;
288                if pos < bytes.len() {
289                    let quote = bytes[pos];
290                    if quote == b'"' || quote == b'\'' {
291                        pos += 1;
292                        while pos < bytes.len() && bytes[pos] != quote {
293                            pos += 1;
294                        }
295                        pos += 1; // skip closing quote
296                    }
297                }
298                // Also skip any leading whitespace before the attribute
299                i = pos;
300                continue;
301            }
302        }
303        result.push(bytes[i] as char);
304        i += 1;
305    }
306
307    *s = result;
308}
309fn tag_to_html_start(tag: &Tag) -> String {
310    match tag {
311        Tag::Paragraph => "<p>".to_string(),
312        Tag::Heading { level, id, .. } => {
313            let id_attr = id
314                .as_ref()
315                .map(|i| format!(" id=\"{i}\""))
316                .unwrap_or_default();
317            format!("<h{}{id_attr}>", *level as u8)
318        }
319        Tag::BlockQuote(_) => "<blockquote>".to_string(),
320        Tag::CodeBlock(_) => String::new(), // Handled separately
321        Tag::List(Some(start)) => format!("<ol start=\"{start}\">"),
322        Tag::List(None) => "<ul>".to_string(),
323        Tag::Item => "<li>".to_string(),
324        Tag::FootnoteDefinition(name) => {
325            format!("<div class=\"footnote\" id=\"fn-{name}\">")
326        }
327        Tag::Table(alignments) => {
328            let _ = alignments; // Alignments handled per cell
329            "<table>".to_string()
330        }
331        Tag::TableHead => "<thead><tr>".to_string(),
332        Tag::TableRow => "<tr>".to_string(),
333        Tag::TableCell => "<td>".to_string(),
334        Tag::Emphasis => "<em>".to_string(),
335        Tag::Strong => "<strong>".to_string(),
336        Tag::Strikethrough => "<del>".to_string(),
337        Tag::Link {
338            dest_url, title, ..
339        } => {
340            let title_attr = if title.is_empty() {
341                String::new()
342            } else {
343                format!(" title=\"{}\"", html_escape(title))
344            };
345            format!("<a href=\"{}\"{}> ", html_escape(dest_url), title_attr)
346        }
347        Tag::Image {
348            dest_url, title, ..
349        } => {
350            let title_attr = if title.is_empty() {
351                String::new()
352            } else {
353                format!(" title=\"{}\"", html_escape(title))
354            };
355            // Add loading="lazy" and decoding="async" for performance
356            format!(
357                "<img src=\"{}\" loading=\"lazy\" decoding=\"async\"{}",
358                html_escape(dest_url),
359                title_attr
360            )
361        }
362        Tag::HtmlBlock => String::new(),
363        Tag::MetadataBlock(_) => String::new(),
364        Tag::DefinitionList => "<dl>".to_string(),
365        Tag::DefinitionListTitle => "<dt>".to_string(),
366        Tag::DefinitionListDefinition => "<dd>".to_string(),
367        Tag::Superscript => "<sup>".to_string(),
368        Tag::Subscript => "<sub>".to_string(),
369    }
370}
371
372/// Convert a pulldown-cmark tag end to HTML closing tag.
373fn tag_to_html_end(tag: &TagEnd) -> String {
374    match tag {
375        TagEnd::Paragraph => "</p>\n".to_string(),
376        TagEnd::Heading(level) => format!("</h{}>\n", *level as u8),
377        TagEnd::BlockQuote(_) => "</blockquote>\n".to_string(),
378        TagEnd::CodeBlock => String::new(), // Handled separately
379        TagEnd::List(ordered) => {
380            if *ordered {
381                "</ol>\n".to_string()
382            } else {
383                "</ul>\n".to_string()
384            }
385        }
386        TagEnd::Item => "</li>\n".to_string(),
387        TagEnd::FootnoteDefinition => "</div>\n".to_string(),
388        TagEnd::Table => "</table>\n".to_string(),
389        TagEnd::TableHead => "</tr></thead>\n".to_string(),
390        TagEnd::TableRow => "</tr>\n".to_string(),
391        TagEnd::TableCell => "</td>".to_string(),
392        TagEnd::Emphasis => "</em>".to_string(),
393        TagEnd::Strong => "</strong>".to_string(),
394        TagEnd::Strikethrough => "</del>".to_string(),
395        TagEnd::Link => "</a>".to_string(),
396        TagEnd::Image => " />".to_string(),
397        TagEnd::HtmlBlock => String::new(),
398        TagEnd::MetadataBlock(_) => String::new(),
399        TagEnd::DefinitionList => "</dl>\n".to_string(),
400        TagEnd::DefinitionListTitle => "</dt>\n".to_string(),
401        TagEnd::DefinitionListDefinition => "</dd>\n".to_string(),
402        TagEnd::Superscript => "</sup>".to_string(),
403        TagEnd::Subscript => "</sub>".to_string(),
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn test_parse_simple_markdown() {
413        let parser = MarkdownParser::new();
414        let content = r#"---
415title: "Test Post"
416---
417
418# Hello World
419
420This is a test."#;
421
422        let result = parser.parse(content, Path::new("test.md")).unwrap();
423
424        assert_eq!(result.frontmatter.title, "Test Post");
425        assert!(result.html.contains("<h1"));
426        assert!(result.html.contains("Hello World"));
427        assert!(result.html.contains("<p>"));
428    }
429
430    #[test]
431    fn test_parse_code_block() {
432        let parser = MarkdownParser::new();
433        let (html, _) = parser.parse_body(
434            r#"```rust
435fn main() {
436    println!("Hello");
437}
438```"#,
439        );
440
441        assert!(html.contains("fn"));
442        assert!(html.contains("main"));
443    }
444
445    #[test]
446    fn test_toc_extraction() {
447        let parser = MarkdownParser::new();
448        let (_, toc) = parser.parse_body(
449            r#"# Heading 1
450## Heading 2
451### Heading 3"#,
452        );
453
454        assert_eq!(toc.len(), 3);
455        assert_eq!(toc[0].level, 1);
456        assert_eq!(toc[0].text, "Heading 1");
457        assert_eq!(toc[1].level, 2);
458        assert_eq!(toc[2].level, 3);
459    }
460
461    #[test]
462    fn test_table_rendering() {
463        let parser = MarkdownParser::new();
464        let (html, _) = parser.parse_body(
465            r#"| Header 1 | Header 2 |
466|----------|----------|
467| Cell 1   | Cell 2   |"#,
468        );
469
470        assert!(html.contains("<table>"));
471        assert!(html.contains("<thead>"));
472        assert!(html.contains("<tr>"));
473        assert!(html.contains("<td>"));
474    }
475
476    #[test]
477    fn test_task_list() {
478        let parser = MarkdownParser::new();
479        let (html, _) = parser.parse_body(
480            r#"- [x] Done
481- [ ] Not done"#,
482        );
483
484        assert!(html.contains("checkbox"));
485        assert!(html.contains("checked"));
486    }
487
488    #[test]
489    fn test_no_frontmatter() {
490        let parser = MarkdownParser::new();
491        let content = "# Just Content\n\nNo frontmatter here.";
492        let result = parser.parse(content, Path::new("test.md")).unwrap();
493
494        assert!(result.frontmatter.title.is_empty());
495        assert!(result.html.contains("Just Content"));
496    }
497
498    #[test]
499    fn test_sanitize_html_strips_script_tags() {
500        let parser = MarkdownParser::new().with_sanitize_html(true);
501        let content = "Hello\n\n<script>alert(1)</script>\n\nWorld";
502        let (html, _) = parser.render_markdown(content);
503        assert!(!html.contains("<script>"), "script tag should be stripped");
504        assert!(html.contains("Hello"), "content before script preserved");
505        assert!(html.contains("World"), "content after script preserved");
506    }
507
508    #[test]
509    fn test_sanitize_html_disabled_preserves() {
510        let parser = MarkdownParser::new().with_sanitize_html(false);
511        let content = "Hello\n\n<script>alert(1)</script>\n\nWorld";
512        let (html, _) = parser.render_markdown(content);
513        assert!(
514            html.contains("<script>alert(1)</script>"),
515            "raw HTML preserved when disabled"
516        );
517    }
518
519    #[test]
520    fn test_sanitize_html_strips_iframe() {
521        let parser = MarkdownParser::new().with_sanitize_html(true);
522        let content = "Before\n\n<iframe src=\"evil.com\"></iframe>\n\nAfter";
523        let (html, _) = parser.render_markdown(content);
524        assert!(!html.contains("<iframe>"), "iframe tag should be stripped");
525        assert!(html.contains("Before"));
526        assert!(html.contains("After"));
527    }
528
529    #[test]
530    fn test_sanitize_html_strips_object() {
531        let parser = MarkdownParser::new().with_sanitize_html(true);
532        let content = "Before\n\n<object data=\"evil.swf\"></object>\n\nAfter";
533        let (html, _) = parser.render_markdown(content);
534        assert!(!html.contains("<object>"), "object tag should be stripped");
535        assert!(html.contains("Before"));
536        assert!(html.contains("After"));
537    }
538
539    #[test]
540    fn test_sanitize_html_strips_onclick() {
541        let parser = MarkdownParser::new().with_sanitize_html(true);
542        let content = "Hello\n\n<div onclick=\"alert(1)\">content</div>\n\nWorld";
543        let (html, _) = parser.render_markdown(content);
544        assert!(
545            !html.contains("onclick"),
546            "onclick attribute should be stripped"
547        );
548        assert!(html.contains("content"));
549    }
550
551    #[test]
552    fn test_sanitize_html_preserves_safe_tags() {
553        let parser = MarkdownParser::new().with_sanitize_html(true);
554        let content = "Hello\n\n<div class=\"safe\">content</div>\n\nWorld";
555        let (html, _) = parser.render_markdown(content);
556        assert!(html.contains("<div"), "safe div tag preserved");
557        assert!(html.contains("content"));
558    }
559
560    #[test]
561    fn test_sanitize_html_default_is_false() {
562        let parser = MarkdownParser::new();
563        let content = "Hello\n\n<script>alert(1)</script>\n\nWorld";
564        let (html, _) = parser.render_markdown(content);
565        assert!(
566            html.contains("<script>alert(1)</script>"),
567            "default behavior preserves raw HTML"
568        );
569    }
570}