1use 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#[derive(Debug, Error)]
17pub enum MarkdownError {
18 #[error("frontmatter error: {0}")]
20 Frontmatter(#[from] typstify_core::error::CoreError),
21}
22
23pub type Result<T> = std::result::Result<T, MarkdownError>;
25
26#[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 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 pub fn with_sanitize_html(mut self, sanitize: bool) -> Self {
59 self.sanitize_html = sanitize;
60 self
61 }
62
63 pub fn with_theme(theme: &str) -> Self {
65 let mut parser = Self::new();
66 parser.highlighter.set_theme(theme);
67 parser
68 }
69
70 pub fn parse(&self, content: &str, path: &Path) -> Result<ParsedContent> {
72 let (frontmatter, body) = parse_frontmatter(content, path)?;
74
75 let (html, toc) = self.render_markdown(&body);
77
78 Ok(ParsedContent {
79 frontmatter,
80 html,
81 raw: body,
82 toc,
83 })
84 }
85
86 pub fn parse_body(&self, body: &str) -> (String, Vec<TocEntry>) {
88 self.render_markdown(body)
89 }
90
91 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 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 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 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 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 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 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 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 Event::SoftBreak => {
172 html.push('\n');
173 }
174
175 Event::HardBreak => {
177 html.push_str("<br />\n");
178 }
179
180 Event::Start(tag) => {
182 html.push_str(&tag_to_html_start(&tag));
183 }
184
185 Event::End(tag) => {
187 html.push_str(&tag_to_html_end(&tag));
188 }
189
190 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 Event::FootnoteReference(name) => {
201 html.push_str(&format!(
202 "<sup class=\"footnote-ref\"><a href=\"#fn-{name}\">[{name}]</a></sup>"
203 ));
204 }
205
206 Event::Rule => {
208 html.push_str("<hr />\n");
209 }
210
211 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
235fn sanitize_html_raw(raw: &str) -> String {
237 let mut result = raw.to_string();
238
239 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_handlers(&mut result);
254
255 result
256}
257
258fn 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
266fn 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 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 let mut word_end = i + 2;
281 while word_end < bytes.len() && bytes[word_end].is_ascii_alphanumeric() {
282 word_end += 1;
283 }
284 if word_end > i + 2 && word_end < bytes.len() && bytes[word_end] == b'=' {
286 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; }
297 }
298 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(), 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; "<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 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
372fn 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(), 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}