1use crate::embeds;
40use crate::error::{Error, Result};
41use crate::types::{MarkdownOptions, OutputFormat, RenderMetadata, RenderResult, RenderStats};
42use pulldown_cmark::{html, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
43use regex::Regex;
44use std::cell::Cell;
45use std::sync::LazyLock;
46use std::time::Instant;
47use tracing::{debug, instrument};
48
49#[allow(clippy::expect_used)]
60fn static_regex(pattern: &str) -> Regex {
61 Regex::new(pattern).expect("validated static regex pattern")
62}
63
64static EMBED_RE: LazyLock<Regex> = LazyLock::new(|| static_regex(r"!\{(\w+):\s*([^}]+)\}"));
65
66static WIKILINK_RE: LazyLock<Regex> =
67 LazyLock::new(|| static_regex(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]"));
68
69static ADMONITION_HEADER_RE: LazyLock<Regex> = LazyLock::new(|| static_regex(r"^>\s*\[!(\w+)\]"));
70
71static ADMONITION_BODY_RE: LazyLock<Regex> = LazyLock::new(|| static_regex(r"^>\s?(.*)"));
72
73static TOC_HEADING_REGEX: LazyLock<Regex> =
77 LazyLock::new(|| static_regex(r#"<h([1-6])[^>]*id="([^"]*)"[^>]*>(.*?)</h[1-6]>"#));
78
79static HTML_STRIP_REGEX: LazyLock<Regex> = LazyLock::new(|| static_regex(r"<[^>]+>"));
80
81static INLINE_TOC_REGEX: LazyLock<Regex> =
82 LazyLock::new(|| static_regex(r#"<h([23])[^>]*id="([^"]*)"[^>]*>(.*?)</h[23]>"#));
83
84#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
90pub struct TocEntry {
91 pub level: usize,
93 pub slug: String,
95 pub text: String,
97}
98
99#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
101pub struct HtmlTocEntry {
102 pub level: u8,
104 pub id: String,
106 pub title: String,
108}
109
110#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
112pub struct EmbedBlock {
113 pub kind: String,
115 pub id: String,
117}
118
119#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
122pub struct BlockReference {
123 pub target: String,
125 pub heading: Option<String>,
127 pub block_id: Option<String>,
129 pub reference_only: bool,
131}
132
133pub struct MarkdownParser {
139 cmark_options: Options,
141}
142
143impl MarkdownParser {
144 pub fn new() -> Self {
146 Self::default()
147 }
148
149 pub fn with_options(options: MarkdownOptions) -> Self {
151 let cmark_options = Self::build_cmark_options(&options);
152 Self { cmark_options }
153 }
154
155 fn build_cmark_options(opts: &MarkdownOptions) -> Options {
157 let mut options = Options::empty();
158
159 if opts.enable_gfm {
160 options.insert(Options::ENABLE_STRIKETHROUGH);
161 options.insert(Options::ENABLE_TABLES);
162 options.insert(Options::ENABLE_TASKLISTS);
163 }
164
165 if opts.enable_footnotes {
166 options.insert(Options::ENABLE_FOOTNOTES);
167 }
168
169 if opts.enable_strikethrough && !opts.enable_gfm {
170 options.insert(Options::ENABLE_STRIKETHROUGH);
171 }
172
173 if opts.enable_tables && !opts.enable_gfm {
174 options.insert(Options::ENABLE_TABLES);
175 }
176
177 if opts.enable_task_lists && !opts.enable_gfm {
178 options.insert(Options::ENABLE_TASKLISTS);
179 }
180
181 if opts.enable_smart_punctuation {
182 options.insert(Options::ENABLE_SMART_PUNCTUATION);
183 }
184
185 if opts.enable_heading_attributes {
186 options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
187 }
188
189 options
190 }
191
192 pub fn extract_toc(content: &str) -> Vec<TocEntry> {
197 let mut entries = Vec::new();
198 let mut in_code_block = false;
199
200 for line in content.lines() {
201 if line.trim_start().starts_with("```") {
202 in_code_block = !in_code_block;
203 continue;
204 }
205 if in_code_block {
206 continue;
207 }
208 let trimmed = line.trim_start();
209 let level = trimmed.chars().take_while(|&c| c == '#').count();
210 if level == 0 || level > 6 {
211 continue;
212 }
213 let rest = &trimmed[level..];
214 if !rest.is_empty() && !rest.starts_with(' ') {
216 continue;
217 }
218 let text = rest.trim().to_string();
219 if text.is_empty() {
220 continue;
221 }
222 let slug = text
223 .to_lowercase()
224 .chars()
225 .map(|c| {
226 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
227 c
228 } else {
229 '-'
230 }
231 })
232 .collect::<String>();
233 entries.push(TocEntry { level, slug, text });
234 }
235 entries
236 }
237
238 pub fn extract_embeds(content: &str) -> Vec<EmbedBlock> {
243 let mut embeds = Vec::new();
244 let mut in_code_block = false;
245
246 for line in content.lines() {
247 if line.trim_start().starts_with("```") {
248 in_code_block = !in_code_block;
249 continue;
250 }
251 if !in_code_block {
252 for caps in EMBED_RE.captures_iter(line) {
253 let kind = caps[1].to_lowercase();
254 let id = caps[2].trim().to_string();
255 if !id.is_empty() {
256 embeds.push(EmbedBlock { kind, id });
257 }
258 }
259 }
260 }
261 embeds
262 }
263
264 fn preprocess_admonitions(content: &str) -> String {
279 let mut result = String::with_capacity(content.len());
280 let mut in_code_block = false;
281 let mut in_admonition = false;
282 let mut admonition_type = String::new();
283 let mut admonition_lines: Vec<String> = Vec::new();
284
285 for line in content.lines() {
286 if line.trim_start().starts_with("```") {
287 if in_admonition {
288 result.push_str(&format_admonition_html(&admonition_type, &admonition_lines));
289 in_admonition = false;
290 admonition_lines.clear();
291 }
292 in_code_block = !in_code_block;
293 result.push_str(line);
294 result.push('\n');
295 continue;
296 }
297
298 if in_code_block {
299 result.push_str(line);
300 result.push('\n');
301 continue;
302 }
303
304 if !in_admonition {
305 if let Some(caps) = ADMONITION_HEADER_RE.captures(line) {
306 in_admonition = true;
307 #[allow(clippy::expect_used)]
309 {
310 admonition_type = caps
311 .get(1)
312 .expect("capture group 1 always matches")
313 .as_str()
314 .to_lowercase();
315 }
316 continue;
317 }
318 }
319
320 if in_admonition {
321 if let Some(caps) = ADMONITION_BODY_RE.captures(line) {
322 #[allow(clippy::expect_used)]
324 {
325 admonition_lines.push(
326 caps.get(1)
327 .expect("capture group 1 always matches")
328 .as_str()
329 .to_string(),
330 );
331 }
332 continue;
333 } else {
334 result.push_str(&format_admonition_html(&admonition_type, &admonition_lines));
335 in_admonition = false;
336 admonition_lines.clear();
337 }
338 }
339
340 result.push_str(line);
341 result.push('\n');
342 }
343
344 if in_admonition {
345 result.push_str(&format_admonition_html(&admonition_type, &admonition_lines));
346 }
347
348 if result.ends_with('\n') {
349 result.pop();
350 }
351
352 result
353 }
354
355 fn preprocess_wikilinks(content: &str) -> String {
360 let mut result = String::with_capacity(content.len());
361 let mut in_code_block = false;
362
363 for line in content.lines() {
364 if line.trim_start().starts_with("```") {
365 in_code_block = !in_code_block;
366 result.push_str(line);
367 result.push('\n');
368 continue;
369 }
370
371 if in_code_block {
372 result.push_str(line);
373 result.push('\n');
374 } else {
375 let replaced = WIKILINK_RE.replace_all(line, |caps: ®ex::Captures| {
376 let target: &str = &caps[1];
377 let display: &str = match caps.get(2) {
378 Some(m) => m.as_str(),
379 None => target,
380 };
381 let slug = target
382 .to_lowercase()
383 .chars()
384 .map(|c| {
385 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
386 c
387 } else {
388 '-'
389 }
390 })
391 .collect::<String>();
392 format!(
393 "<a href=\"/documents/{}\" class=\"wikilink\">{}</a>",
394 slug, display
395 )
396 });
397 result.push_str(&replaced);
398 result.push('\n');
399 }
400 }
401
402 result.pop();
403 result
404 }
405
406 fn preprocess_embeds(content: &str) -> String {
412 let mut result = String::with_capacity(content.len());
413 let mut in_code_block = false;
414
415 for line in content.lines() {
416 if line.trim_start().starts_with("```") {
417 in_code_block = !in_code_block;
418 result.push_str(line);
419 result.push('\n');
420 continue;
421 }
422
423 if in_code_block {
424 result.push_str(line);
425 result.push('\n');
426 continue;
427 }
428
429 let mut processed = line.to_string();
430 for alt in ["youtube", "figma", "gist", "codepen", "tweet"] {
431 let needle = format!(";
432 while let Some(pos) = processed.find(&needle) {
433 let after = &processed[pos + needle.len()..];
434 if let Some(close) = after.find(')') {
435 let url = after[..close].trim().to_string();
436 if !url.is_empty() {
437 if let Some(html) = embeds::render_embed(alt, &url) {
438 let end = pos + needle.len() + close + 1;
439 processed.replace_range(pos..end, &html);
440 continue;
441 }
442 }
443 break;
444 } else {
445 break;
446 }
447 }
448 }
449 result.push_str(&processed);
450 result.push('\n');
451 }
452
453 if result.ends_with('\n') {
454 result.pop();
455 }
456 result
457 }
458
459 pub fn extract_block_references(&self, content: &str) -> Vec<(usize, BlockReference)> {
466 let mut references = Vec::new();
467 let mut in_code_block = false;
468 let mut code_fence_marker = String::new();
469
470 for (line_idx, line) in content.lines().enumerate() {
471 if line.trim().starts_with("```") || line.trim().starts_with("~~~") {
472 if !in_code_block {
473 in_code_block = true;
474 code_fence_marker = line.trim().chars().take(3).collect();
475 } else if line.trim().starts_with(&code_fence_marker) {
476 in_code_block = false;
477 code_fence_marker.clear();
478 }
479 continue;
480 }
481
482 if in_code_block {
483 continue;
484 }
485
486 let mut search_start = 0;
487 let chars: Vec<char> = line.chars().collect();
488
489 while search_start < chars.len() {
490 if chars.get(search_start) == Some(&'`') {
491 let tick_count = count_consecutive(&chars[search_start..], '`');
492 let close_pos =
493 find_closing_backtick(&chars, search_start + tick_count, tick_count);
494 if let Some(pos) = close_pos {
495 search_start = pos + tick_count;
496 } else {
497 search_start = chars.len();
498 }
499 continue;
500 }
501
502 if search_start + 2 < chars.len()
503 && chars[search_start] == '!'
504 && chars[search_start + 1] == '['
505 && chars[search_start + 2] == '['
506 {
507 let close = find_closing_brackets(&chars, search_start + 3, '[', ']');
508 if let Some(end_pos) = close {
509 let inner: String = chars[search_start + 3..end_pos].iter().collect();
510 if let Some(reference) = parse_block_reference(&inner) {
511 let offset = content[..]
512 .lines()
513 .take(line_idx)
514 .map(|l| l.len() + 1)
515 .sum::<usize>()
516 + search_start;
517 references.push((offset, reference));
518 }
519 search_start = end_pos + 2;
520 } else {
521 search_start += 1;
522 }
523 } else {
524 search_start += 1;
525 }
526 }
527 }
528
529 references
530 }
531
532 pub fn extract_wikilinks(content: &str) -> Vec<String> {
534 let mut in_code_block = false;
535 let mut targets = Vec::new();
536
537 for line in content.lines() {
538 if line.trim_start().starts_with("```") {
539 in_code_block = !in_code_block;
540 continue;
541 }
542
543 if !in_code_block {
544 for caps in WIKILINK_RE.captures_iter(line) {
545 targets.push(caps[1].to_string());
546 }
547 }
548 }
549
550 targets
551 }
552
553 #[instrument(skip(self, markdown), fields(format = ?format))]
555 pub fn parse<S: AsRef<str>>(&self, markdown: S, format: OutputFormat) -> Result<RenderResult> {
556 let markdown = markdown.as_ref();
557 let markdown_str = Self::preprocess_wikilinks(markdown);
558 let start_time = Instant::now();
559
560 debug!("Parsing markdown content ({} bytes)", markdown_str.len());
561
562 let (content, metadata, stats) = match format {
563 OutputFormat::Html => self.parse_to_html(&markdown_str)?,
564 OutputFormat::PlainText => self.parse_to_plain_text(&markdown_str)?,
565 OutputFormat::Ast => self.parse_to_ast(&markdown_str)?,
566 OutputFormat::Markdown => {
567 let metadata = self.extract_metadata(&markdown_str);
568 let stats = RenderStats::new()
569 .with_render_time(start_time.elapsed())
570 .with_output_size(markdown_str.len());
571 (markdown_str.to_string(), metadata, stats)
572 }
573 };
574
575 let render_time = start_time.elapsed();
576 let stats = stats
577 .with_render_time(render_time)
578 .with_output_size(content.len());
579
580 debug!(
581 "Parsed markdown in {}ms, output {} bytes",
582 render_time.as_millis(),
583 content.len()
584 );
585
586 Ok(RenderResult::new(content, format)
587 .with_metadata(metadata)
588 .with_stats(stats))
589 }
590
591 fn parse_to_html(&self, markdown: &str) -> Result<(String, RenderMetadata, RenderStats)> {
593 let markdown = Self::preprocess_wikilinks(markdown);
594 let markdown = Self::preprocess_admonitions(&markdown);
595 let markdown = Self::preprocess_embeds(&markdown);
596 let parser = Parser::new_ext(&markdown, self.cmark_options);
597
598 let metadata = self.extract_metadata(&markdown);
599 let mut stats = RenderStats::new();
600
601 let code_block_count = Cell::new(0u32);
602 let parser_with_count = parser.inspect(|event| {
603 if matches!(event, Event::Start(Tag::CodeBlock(_))) {
604 code_block_count.set(code_block_count.get() + 1);
605 }
606 });
607
608 let mut html_output = String::with_capacity(markdown.len() * 2);
609 html::push_html(&mut html_output, parser_with_count);
610
611 html_output = ammonia::Builder::default()
612 .add_tags([
613 "img",
614 "pre",
615 "code",
616 "span",
617 "div",
618 "a",
619 "iframe",
620 "blockquote",
621 ])
622 .add_generic_attributes(&["class", "id", "style", "data-video-id", "data-tweet-url"])
623 .add_tag_attributes("img", ["src", "alt", "title", "width", "height", "loading"])
624 .add_tag_attributes("a", ["href", "title"])
625 .add_tag_attributes(
626 "iframe",
627 [
628 "src",
629 "width",
630 "height",
631 "frameborder",
632 "allowfullscreen",
633 "loading",
634 "sandbox",
635 ],
636 )
637 .clean(&html_output)
638 .to_string();
639
640 for _ in 0..code_block_count.get() {
641 stats.increment_code_blocks();
642 }
643
644 Ok((html_output, metadata, stats))
645 }
646
647 fn parse_to_plain_text(&self, markdown: &str) -> Result<(String, RenderMetadata, RenderStats)> {
649 let parser = Parser::new_ext(markdown, self.cmark_options);
650 let metadata = self.extract_metadata(markdown);
651 let stats = RenderStats::new();
652
653 let mut plain_text = String::with_capacity(markdown.len());
654
655 for event in parser {
656 match event {
657 Event::Text(text) => {
658 plain_text.push_str(&text);
659 }
660 Event::Code(code) => {
661 plain_text.push_str(&code);
662 }
663 Event::SoftBreak | Event::HardBreak => {
664 plain_text.push('\n');
665 }
666 Event::End(TagEnd::Paragraph) | Event::End(TagEnd::Heading(_)) => {
667 plain_text.push_str("\n\n");
668 }
669 _ => {}
670 }
671 }
672
673 let plain_text = plain_text
675 .lines()
676 .map(|line| line.trim())
677 .collect::<Vec<_>>()
678 .join("\n")
679 .trim()
680 .to_string();
681
682 Ok((plain_text, metadata, stats))
683 }
684
685 fn parse_to_ast(&self, markdown: &str) -> Result<(String, RenderMetadata, RenderStats)> {
687 let parser = Parser::new_ext(markdown, self.cmark_options);
688 let metadata = self.extract_metadata(markdown);
689 let stats = RenderStats::new();
690
691 let mut events = Vec::new();
692 for event in parser {
693 let event_str = match &event {
694 Event::Start(tag) => format!("Start: {:?}", tag),
695 Event::End(tag_end) => format!("End: {:?}", tag_end),
696 Event::Text(text) => format!("Text: {}", text),
697 Event::Code(code) => format!("Code: {}", code),
698 Event::Html(html) => format!("Html: {}", html),
699 Event::InlineHtml(html) => format!("InlineHtml: {}", html),
700 Event::InlineMath(math) => format!("InlineMath: {}", math),
701 Event::DisplayMath(math) => format!("DisplayMath: {}", math),
702 Event::FootnoteReference(name) => format!("FootnoteReference: {}", name),
703 Event::SoftBreak => "SoftBreak".to_string(),
704 Event::HardBreak => "HardBreak".to_string(),
705 Event::Rule => "Rule".to_string(),
706 Event::TaskListMarker(checked) => format!("TaskListMarker: {}", checked),
707 };
708 events.push(event_str);
709 }
710
711 let ast = serde_json::to_string_pretty(&events)
712 .map_err(|e| Error::serialization(e.to_string()))?;
713
714 Ok((ast, metadata, stats))
715 }
716
717 fn extract_metadata(&self, markdown: &str) -> RenderMetadata {
719 let mut metadata = RenderMetadata::new();
720
721 let mut word_count = 0;
722 let mut char_count = 0;
723 let mut heading_count = 0;
724 let mut code_block_count = 0;
725 let mut first_heading: Option<String> = None;
726
727 for event in Parser::new_ext(markdown, self.cmark_options) {
728 match &event {
729 Event::Start(Tag::CodeBlock(_)) => {
730 code_block_count += 1;
731 }
732 Event::Start(Tag::Heading { level, .. }) => {
733 heading_count += 1;
734 if first_heading.is_none() && *level == HeadingLevel::H1 {
735 }
737 }
738 Event::Text(text) => {
739 char_count += text.len();
740 word_count += text.split_whitespace().count();
741
742 if first_heading.is_none() {
744 if !text.trim().is_empty() {
746 first_heading = Some(text.chars().take(100).collect());
747 }
748 }
749 }
750 _ => {}
751 }
752 }
753
754 metadata.title = first_heading;
755 metadata.word_count = word_count;
756 metadata.char_count = char_count;
757 metadata.heading_count = heading_count;
758 metadata.code_block_count = code_block_count;
759
760 metadata
761 }
762}
763
764impl Default for MarkdownParser {
765 fn default() -> Self {
766 Self::with_options(MarkdownOptions::default())
767 }
768}
769
770pub fn render_markdown(content: &str) -> String {
781 try_render_markdown(content, &MarkdownOptions::default())
782 .map(|r| r.content)
783 .unwrap_or_else(|_| format!("<div class=\"render-error\">{}</div>", html_escape(content)))
784}
785
786pub fn try_render_markdown(content: &str, options: &MarkdownOptions) -> Result<RenderResult> {
788 MarkdownParser::with_options(options.clone()).parse(content, OutputFormat::Html)
789}
790
791pub fn extract_toc(content: &str) -> Vec<TocEntry> {
795 MarkdownParser::extract_toc(content)
796}
797
798pub fn extract_toc_from_html(html: &str) -> Vec<HtmlTocEntry> {
804 TOC_HEADING_REGEX
805 .captures_iter(html)
806 .map(|cap| HtmlTocEntry {
807 level: cap[1].parse().unwrap_or(2),
808 id: cap[2].to_string(),
809 title: decode_basic_entities(&strip_html_tags(&cap[3])),
810 })
811 .collect()
812}
813
814pub fn extract_inline_toc(html: &str) -> Vec<HtmlTocEntry> {
816 INLINE_TOC_REGEX
817 .captures_iter(html)
818 .map(|cap| HtmlTocEntry {
819 level: cap[1].parse().unwrap_or(2),
820 id: cap[2].to_string(),
821 title: decode_basic_entities(&strip_html_tags(&cap[3])),
822 })
823 .collect()
824}
825
826pub fn strip_html_tags(html: &str) -> String {
828 HTML_STRIP_REGEX.replace_all(html, "").to_string()
829}
830
831fn decode_basic_entities(s: &str) -> String {
834 s.replace("<", "<")
835 .replace(">", ">")
836 .replace(""", "\"")
837 .replace("'", "'")
838 .replace("&", "&")
839}
840
841#[allow(dead_code)]
843fn html_escape(s: &str) -> String {
844 s.replace('&', "&")
845 .replace('<', "<")
846 .replace('>', ">")
847 .replace('"', """)
848 .replace('\'', "'")
849}
850
851fn format_admonition_html(admonition_type: &str, lines: &[String]) -> String {
853 let title = match admonition_type {
854 "note" => "Note",
855 "tip" => "Tip",
856 "info" => "Info",
857 "warning" => "Warning",
858 "danger" => "Danger",
859 "caution" => "Caution",
860 _ => admonition_type,
861 };
862
863 let content = lines.join("\n");
864
865 format!(
866 "<div class=\"admonition admonition-{type}\">\
867 <div class=\"admonition-title\">{title}</div>\
868 <div class=\"admonition-content\">{content}</div>\
869 </div>",
870 type = admonition_type,
871 title = title,
872 content = content
873 )
874}
875
876fn parse_block_reference(inner: &str) -> Option<BlockReference> {
878 let inner = inner.trim();
879 if inner.is_empty() {
880 return None;
881 }
882
883 let (inner, reference_only) = if let Some(stripped) = inner.strip_prefix('!') {
884 (stripped, true)
885 } else {
886 (inner, false)
887 };
888
889 let target;
890 let mut heading = None;
891 let mut block_id = None;
892
893 if let Some(hash_pos) = inner.find('#') {
894 target = inner[..hash_pos].trim().to_string();
895 let fragment = &inner[hash_pos + 1..];
896
897 if let Some(stripped) = fragment.strip_prefix('^') {
898 block_id = Some(stripped.trim().to_string());
899 } else {
900 heading = Some(fragment.trim().to_string());
901 }
902 } else {
903 target = inner.trim().to_string();
904 }
905
906 if target.is_empty() {
907 return None;
908 }
909
910 Some(BlockReference {
911 target,
912 heading,
913 block_id,
914 reference_only,
915 })
916}
917
918fn count_consecutive(chars: &[char], target: char) -> usize {
920 chars.iter().take_while(|c| **c == target).count()
921}
922
923fn find_closing_backtick(chars: &[char], start: usize, tick_count: usize) -> Option<usize> {
925 let mut pos = start;
926 while pos + tick_count <= chars.len() {
927 if chars[pos] == '`' && count_consecutive(&chars[pos..], '`') >= tick_count {
928 return Some(pos);
929 }
930 pos += 1;
931 }
932 None
933}
934
935fn find_closing_brackets(chars: &[char], start: usize, open: char, close: char) -> Option<usize> {
937 let mut depth = 1i32;
938 let mut pos = start;
939 while pos < chars.len() {
940 if chars[pos] == open {
941 depth += 1;
942 } else if chars[pos] == close {
943 depth -= 1;
944 if depth == 0 {
945 return Some(pos);
946 }
947 }
948 pos += 1;
949 }
950 None
951}
952
953#[cfg(test)]
954mod tests {
955 #![allow(clippy::unwrap_used)]
956 use super::*;
957
958 #[test]
959 fn test_parse_simple_markdown() {
960 let parser = MarkdownParser::new();
961 let result = parser
962 .parse("# Hello World\n\nThis is a test.", OutputFormat::Html)
963 .unwrap();
964
965 assert!(result.content.contains("<h1>"));
966 assert!(result.content.contains("Hello World"));
967 assert_eq!(result.format, OutputFormat::Html);
968 }
969
970 #[test]
971 fn test_parse_to_plain_text() {
972 let parser = MarkdownParser::new();
973 let result = parser
974 .parse("# Hello World\n\nThis is a test.", OutputFormat::PlainText)
975 .unwrap();
976
977 assert!(result.content.contains("Hello World"));
978 assert!(result.content.contains("This is a test"));
979 assert!(!result.content.contains("<"));
980 }
981
982 #[test]
983 fn test_parse_to_ast() {
984 let parser = MarkdownParser::new();
985 let result = parser.parse("# Hello", OutputFormat::Ast).unwrap();
986
987 assert!(result.content.contains("Start"));
988 assert!(result.content.contains("Heading"));
989 }
990
991 #[test]
992 fn test_metadata_extraction() {
993 let parser = MarkdownParser::new();
994 let result = parser
995 .parse("# Document Title\n\nSome content here.", OutputFormat::Html)
996 .unwrap();
997
998 assert!(result.metadata.title.is_some());
999 assert_eq!(result.metadata.heading_count, 1);
1000 assert!(result.metadata.word_count > 0);
1001 }
1002
1003 #[test]
1004 fn test_code_block_counting() {
1005 let parser = MarkdownParser::new();
1006 let markdown = r#"
1007# Code Example
1008
1009```rust
1010fn main() {
1011 println!("Hello");
1012}
1013```
1014
1015Some more text.
1016"#;
1017 let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1018
1019 assert_eq!(result.metadata.code_block_count, 1);
1020 }
1021
1022 #[test]
1023 fn test_youtube_embed_rendered() {
1024 let parser = MarkdownParser::new();
1025 let result = parser
1026 .parse("", OutputFormat::Html)
1027 .unwrap();
1028 assert!(result.content.contains("embed-youtube"));
1029 assert!(result.content.contains("youtube.com/embed/dQw4w9WgXcQ"));
1030 assert!(result.content.contains("iframe"));
1031 }
1032
1033 #[test]
1034 fn test_figma_embed_rendered() {
1035 let parser = MarkdownParser::new();
1036 let result = parser
1037 .parse(
1038 "",
1039 OutputFormat::Html,
1040 )
1041 .unwrap();
1042 assert!(result.content.contains("embed-figma"));
1043 assert!(result.content.contains("figma.com/embed"));
1044 }
1045
1046 #[test]
1047 fn test_gist_embed_rendered() {
1048 let parser = MarkdownParser::new();
1049 let result = parser
1050 .parse(
1051 "",
1052 OutputFormat::Html,
1053 )
1054 .unwrap();
1055 assert!(result.content.contains("embed-gist"));
1056 assert!(result.content.contains("gist.github.com/user/abc.js"));
1057 }
1058
1059 #[test]
1060 fn test_codepen_embed_rendered() {
1061 let parser = MarkdownParser::new();
1062 let result = parser
1063 .parse(
1064 "",
1065 OutputFormat::Html,
1066 )
1067 .unwrap();
1068 assert!(result.content.contains("embed-codepen"));
1069 assert!(result.content.contains("codepen.io/embed/"));
1070 }
1071
1072 #[test]
1073 fn test_tweet_embed_rendered() {
1074 let parser = MarkdownParser::new();
1075 let result = parser
1076 .parse(
1077 "",
1078 OutputFormat::Html,
1079 )
1080 .unwrap();
1081 assert!(result.content.contains("embed-tweet"));
1082 assert!(result.content.contains("twitter-tweet"));
1083 }
1084
1085 #[test]
1086 fn test_embeds_have_lazy_loading() {
1087 let parser = MarkdownParser::new();
1088 let result = parser.parse("", OutputFormat::Html).unwrap();
1089 assert!(result.content.contains("loading=\"lazy\""));
1090 }
1091
1092 #[test]
1093 fn test_embeds_have_sandbox() {
1094 let parser = MarkdownParser::new();
1095 let result = parser.parse("", OutputFormat::Html).unwrap();
1096 assert!(result.content.contains("sandbox="));
1097 }
1098
1099 #[test]
1100 fn test_embeds_skipped_in_code_blocks() {
1101 let parser = MarkdownParser::new();
1102 let result = parser
1103 .parse("```\n\n```", OutputFormat::Html)
1104 .unwrap();
1105 assert!(!result.content.contains("embed-youtube"));
1106 }
1107
1108 #[test]
1109 fn test_regular_images_preserved() {
1110 let parser = MarkdownParser::new();
1111 let result = parser
1112 .parse("", OutputFormat::Html)
1113 .unwrap();
1114 assert!(result.content.contains("<img"));
1115 assert!(result
1116 .content
1117 .contains("src=\"https://example.com/img.png\""));
1118 }
1119
1120 #[test]
1121 fn test_gfm_features() {
1122 let parser = MarkdownParser::new();
1123 let markdown = "| Col1 | Col2 |\n|------|------|\n| A | B |";
1124 let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1125
1126 assert!(result.content.contains("<table"));
1127 }
1128
1129 #[test]
1130 fn test_pass_through() {
1131 let parser = MarkdownParser::new();
1132 let markdown = "# Hello";
1133 let result = parser.parse(markdown, OutputFormat::Markdown).unwrap();
1134
1135 assert_eq!(result.content, markdown);
1136 }
1137
1138 #[test]
1139 fn test_preprocess_wikilinks_basic() {
1140 let result = MarkdownParser::preprocess_wikilinks("See [[Hello]] for details");
1141 assert_eq!(
1142 result,
1143 r#"See <a href="/documents/hello" class="wikilink">Hello</a> for details"#
1144 );
1145 }
1146
1147 #[test]
1148 fn test_preprocess_wikilinks_with_display() {
1149 let result = MarkdownParser::preprocess_wikilinks("Click [[Hello|Click here]] now");
1150 assert_eq!(
1151 result,
1152 r#"Click <a href="/documents/hello" class="wikilink">Click here</a> now"#
1153 );
1154 }
1155
1156 #[test]
1157 fn test_preprocess_wikilinks_in_code_block() {
1158 let input = "Before\n```\n[[Hello]]\n```\nAfter [[World]]";
1159 let result = MarkdownParser::preprocess_wikilinks(input);
1160 assert!(
1161 result.contains("[[Hello]]"),
1162 "wikilink inside code block should NOT be converted"
1163 );
1164 assert!(
1165 result.contains(r#"<a href="/documents/world" class="wikilink">World</a>"#),
1166 "wikilink outside code block should be converted to HTML anchor"
1167 );
1168 }
1169
1170 #[test]
1171 fn test_preprocess_wikilinks_multiple() {
1172 let input = "Check [[Alpha]], [[Beta|the beta doc]], and [[Gamma]]";
1173 let result = MarkdownParser::preprocess_wikilinks(input);
1174 assert_eq!(
1175 result,
1176 concat!(
1177 r#"Check <a href="/documents/alpha" class="wikilink">Alpha</a>, "#,
1178 r#"<a href="/documents/beta" class="wikilink">the beta doc</a>, "#,
1179 r#"and <a href="/documents/gamma" class="wikilink">Gamma</a>"#
1180 )
1181 );
1182 }
1183
1184 #[test]
1185 fn test_preprocess_wikilinks_slug_with_special_chars() {
1186 let result = MarkdownParser::preprocess_wikilinks("[[My Document Title]]");
1187 assert_eq!(
1188 result,
1189 r#"<a href="/documents/my-document-title" class="wikilink">My Document Title</a>"#
1190 );
1191 }
1192
1193 #[test]
1194 fn test_extract_wikilinks() {
1195 let input = "Link to [[Foo]] and [[Bar|display]]\n```\n[[Ignored]]\n```\n[[After]]";
1196 let targets = MarkdownParser::extract_wikilinks(input);
1197 assert_eq!(targets, vec!["Foo", "Bar", "After"]);
1198 }
1199
1200 #[test]
1203 fn test_xss_script_tag_stripped() {
1204 let parser = MarkdownParser::new();
1205 let markdown = r#"<script>alert("xss")</script>"#;
1206 let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1207
1208 assert!(
1209 !result.content.contains("<script"),
1210 "Script tags must be stripped by ammonia sanitization, got: {}",
1211 result.content
1212 );
1213 assert!(
1214 !result.content.contains("alert"),
1215 "Script content must be stripped, got: {}",
1216 result.content
1217 );
1218 }
1219
1220 #[test]
1221 fn test_xss_event_handler_stripped() {
1222 let parser = MarkdownParser::new();
1223 let markdown = r#"<img src=x onerror="alert('xss')">"#;
1225 let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1226
1227 assert!(
1228 !result.content.contains("onerror"),
1229 "Event handlers must be stripped by ammonia, got: {}",
1230 result.content
1231 );
1232 }
1233
1234 #[test]
1235 fn test_xss_javascript_uri_stripped() {
1236 let parser = MarkdownParser::new();
1237 let markdown = r#"[click me](javascript:alert('xss'))"#;
1238 let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1239
1240 assert!(
1241 !result.content.contains("javascript:"),
1242 "javascript: URIs must be stripped by ammonia, got: {}",
1243 result.content
1244 );
1245 }
1246
1247 #[test]
1248 fn test_xss_iframe_stripped() {
1249 let parser = MarkdownParser::new();
1250 let markdown = r#"<iframe src="https://evil.com" onload="alert('xss')"></iframe>"#;
1251 let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1252
1253 assert!(
1255 !result.content.contains("onload"),
1256 "Event handlers must be stripped by ammonia, got: {}",
1257 result.content
1258 );
1259 assert!(
1261 result.content.contains("<iframe"),
1262 "iframe should be preserved for embeds, got: {}",
1263 result.content
1264 );
1265 }
1266
1267 #[test]
1268 fn test_xss_svg_onload_stripped() {
1269 let parser = MarkdownParser::new();
1270 let markdown = r#"<svg onload="alert('xss')"><circle r="40"/></svg>"#;
1271 let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1272
1273 assert!(
1274 !result.content.contains("onload"),
1275 "SVG onload handlers must be stripped by ammonia, got: {}",
1276 result.content
1277 );
1278 }
1279
1280 #[test]
1281 fn test_safe_content_preserved_after_sanitization() {
1282 let parser = MarkdownParser::new();
1283 let markdown = "# Hello\n\nParagraph with **bold** and *italic*.\n\n```rust\nfn main() {}\n```\n\n[link](https://example.com)";
1284 let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1285
1286 assert!(result.content.contains("<h1>"));
1287 assert!(result.content.contains("<strong>bold</strong>"));
1288 assert!(result.content.contains("<em>italic</em>"));
1289 assert!(result.content.contains("<code"));
1290 assert!(result.content.contains("href=\"https://example.com\""));
1291 }
1292
1293 #[test]
1294 fn test_image_rendering() {
1295 let parser = MarkdownParser::new();
1296 let markdown = "";
1297 let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1298
1299 assert!(
1300 result.content.contains("<img"),
1301 "Expected HTML to contain <img tag, got: {}",
1302 result.content
1303 );
1304 assert!(
1305 result.content.contains("alt=\"alt text\""),
1306 "Expected img to preserve alt attribute"
1307 );
1308 assert!(
1309 result
1310 .content
1311 .contains("src=\"https://example.com/image.png\""),
1312 "Expected img to preserve src attribute"
1313 );
1314 }
1315
1316 #[test]
1322 fn test_mdx_component_stripped_by_default() {
1323 let parser = MarkdownParser::new();
1324 let markdown = "<MyComponent prop=\"x\">inner text</MyComponent>";
1325 let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1326 assert!(
1327 !result.content.contains("<MyComponent"),
1328 "unknown custom components must be sanitized away, got: {}",
1329 result.content
1330 );
1331 }
1332
1333 #[test]
1335 fn test_mdx_component_inline_content_preserved() {
1336 let parser = MarkdownParser::new();
1337 let markdown = "Before <MyBadge>beta</MyBadge> after";
1338 let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1339 assert!(result.content.contains("Before"));
1340 assert!(result.content.contains("beta"));
1341 assert!(result.content.contains("after"));
1342 }
1343
1344 #[test]
1347 fn test_extract_block_references_basic() {
1348 let parser = MarkdownParser::new();
1349 let content = "See ![[design-specs]] for details.";
1350 let refs = parser.extract_block_references(content);
1351 assert_eq!(refs.len(), 1);
1352 assert_eq!(refs[0].1.target, "design-specs");
1353 assert_eq!(refs[0].1.heading, None);
1354 assert_eq!(refs[0].1.block_id, None);
1355 assert!(!refs[0].1.reference_only);
1356 }
1357
1358 #[test]
1359 fn test_extract_block_references_with_heading() {
1360 let parser = MarkdownParser::new();
1361 let content = "Embed ![[api-docs#authentication]] here.";
1362 let refs = parser.extract_block_references(content);
1363 assert_eq!(refs.len(), 1);
1364 assert_eq!(refs[0].1.target, "api-docs");
1365 assert_eq!(refs[0].1.heading, Some("authentication".to_string()));
1366 assert_eq!(refs[0].1.block_id, None);
1367 }
1368
1369 #[test]
1370 fn test_extract_block_references_with_block_id() {
1371 let parser = MarkdownParser::new();
1372 let content = "Reference ![[notes#^important-quote]] inline.";
1373 let refs = parser.extract_block_references(content);
1374 assert_eq!(refs.len(), 1);
1375 assert_eq!(refs[0].1.target, "notes");
1376 assert_eq!(refs[0].1.heading, None);
1377 assert_eq!(refs[0].1.block_id, Some("important-quote".to_string()));
1378 }
1379
1380 #[test]
1381 fn test_extract_block_references_reference_only() {
1382 let parser = MarkdownParser::new();
1383 let content = "See ![[!design-specs]] for the original.";
1384 let refs = parser.extract_block_references(content);
1385 assert_eq!(refs.len(), 1);
1386 assert_eq!(refs[0].1.target, "design-specs");
1387 assert!(refs[0].1.reference_only);
1388 }
1389
1390 #[test]
1391 fn test_extract_block_references_skips_code_blocks() {
1392 let parser = MarkdownParser::new();
1393 let content = "Before.\n```\n![[should-not-parse]]\n```\nAfter ![[real-ref]].";
1394 let refs = parser.extract_block_references(content);
1395 assert_eq!(refs.len(), 1);
1396 assert_eq!(refs[0].1.target, "real-ref");
1397 }
1398
1399 #[test]
1400 fn test_extract_block_references_skips_inline_code() {
1401 let parser = MarkdownParser::new();
1402 let content = "Use `![[not-a-ref]]` literally, but ![[actual-ref]] embeds.";
1403 let refs = parser.extract_block_references(content);
1404 assert_eq!(refs.len(), 1);
1405 assert_eq!(refs[0].1.target, "actual-ref");
1406 }
1407
1408 #[test]
1409 fn test_extract_block_references_multiple() {
1410 let parser = MarkdownParser::new();
1411 let content = "![[doc-a]] and ![[doc-b#intro]] and ![[doc-c#^key]]";
1412 let refs = parser.extract_block_references(content);
1413 assert_eq!(refs.len(), 3);
1414 assert_eq!(refs[0].1.target, "doc-a");
1415 assert_eq!(refs[1].1.target, "doc-b");
1416 assert_eq!(refs[1].1.heading, Some("intro".to_string()));
1417 assert_eq!(refs[2].1.target, "doc-c");
1418 assert_eq!(refs[2].1.block_id, Some("key".to_string()));
1419 }
1420
1421 #[test]
1422 fn test_extract_block_references_empty() {
1423 let parser = MarkdownParser::new();
1424 let content = "No references here.";
1425 let refs = parser.extract_block_references(content);
1426 assert!(refs.is_empty());
1427 }
1428
1429 #[test]
1430 fn test_parse_block_reference_invalid() {
1431 assert!(parse_block_reference("").is_none());
1432 assert!(parse_block_reference("#").is_none());
1433 }
1434
1435 #[test]
1438 fn test_extract_toc_levels() {
1439 let content = "# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6";
1440 let toc = extract_toc(content);
1441 assert_eq!(toc.len(), 6);
1442 let levels: Vec<usize> = toc.iter().map(|e| e.level).collect();
1443 assert_eq!(levels, vec![1, 2, 3, 4, 5, 6]);
1444 assert_eq!(toc[0].text, "H1");
1445 assert_eq!(toc[5].text, "H6");
1446 }
1447
1448 #[test]
1449 fn test_extract_toc_nesting_order() {
1450 let content = "# Intro\n\n## Setup\n\n### Prerequisites\n\n### Install\n\n## Usage\n\n### CLI\n\n# Outro";
1451 let toc = extract_toc(content);
1452 let texts: Vec<&str> = toc.iter().map(|e| e.text.as_str()).collect();
1453 assert_eq!(
1454 texts,
1455 vec![
1456 "Intro",
1457 "Setup",
1458 "Prerequisites",
1459 "Install",
1460 "Usage",
1461 "CLI",
1462 "Outro"
1463 ]
1464 );
1465 let levels: Vec<usize> = toc.iter().map(|e| e.level).collect();
1467 assert_eq!(levels, vec![1, 2, 3, 3, 2, 3, 1]);
1468 assert!(toc[2].level > toc[1].level);
1470 assert_eq!(toc[2].slug, "prerequisites");
1471 }
1472
1473 #[test]
1474 fn test_extract_toc_skips_code_blocks() {
1475 let content = "# Real\n\n```\n# not a heading\n```\n\n## Also Real";
1476 let toc = extract_toc(content);
1477 let texts: Vec<&str> = toc.iter().map(|e| e.text.as_str()).collect();
1478 assert_eq!(texts, vec!["Real", "Also Real"]);
1479 }
1480
1481 #[test]
1482 fn test_extract_toc_slugification() {
1483 let toc = extract_toc("## My Cool Feature (v2)!");
1484 assert_eq!(toc[0].slug, "my-cool-feature--v2--");
1485 assert_eq!(toc[0].text, "My Cool Feature (v2)!");
1486 }
1487
1488 #[test]
1489 fn test_extract_toc_requires_space_after_hashes() {
1490 let content = "#tag not a heading\n\n# Real Heading";
1491 let toc = extract_toc(content);
1492 assert_eq!(toc.len(), 1);
1493 assert_eq!(toc[0].text, "Real Heading");
1494 }
1495
1496 #[test]
1499 fn test_extract_toc_from_html() {
1500 let html = r#"<h2 id="intro">Intro</h2><p>text</p><h3 id="setup">Setup & <em>Config</em></h3>"#;
1501 let toc = extract_toc_from_html(html);
1502 assert_eq!(toc.len(), 2);
1503 assert_eq!(toc[0].level, 2);
1504 assert_eq!(toc[0].id, "intro");
1505 assert_eq!(toc[0].title, "Intro");
1506 assert_eq!(toc[1].id, "setup");
1507 assert_eq!(toc[1].title, "Setup & Config");
1508 }
1509
1510 #[test]
1511 fn test_extract_inline_toc_only_h2_h3() {
1512 let html = r#"<h1 id="a">A</h1><h2 id="b">B</h2><h3 id="c">C</h3><h4 id="d">D</h4>"#;
1513 let toc = extract_inline_toc(html);
1514 let ids: Vec<&str> = toc.iter().map(|e| e.id.as_str()).collect();
1515 assert_eq!(ids, vec!["b", "c"]);
1516 }
1517
1518 #[test]
1521 fn test_render_markdown_free_function() {
1522 let html = render_markdown("# Free Function\n\nBody **bold**.");
1523 assert!(html.contains("<h1>"));
1524 assert!(html.contains("Free Function"));
1525 assert!(html.contains("<strong>bold</strong>"));
1526 }
1527
1528 #[test]
1529 fn test_try_render_markdown_options() {
1530 let opts = MarkdownOptions {
1532 enable_gfm: false,
1533 enable_tables: false,
1534 ..MarkdownOptions::default()
1535 };
1536 let result = try_render_markdown("| a | b |\n|---|---|\n| 1 | 2 |", &opts).unwrap();
1537 assert!(!result.content.contains("<table"));
1538 }
1539
1540 #[test]
1541 fn test_ammonia_allows_class_on_code() {
1542 let html = r#"<pre><code class="language-json">{"key": "value"}</code></pre>"#;
1543 let cleaned = ammonia::Builder::default()
1544 .add_tags(["img", "pre", "code", "span", "div"])
1545 .add_generic_attributes(&["class"])
1546 .add_tag_attributes("img", ["src", "alt", "title", "width", "height", "loading"])
1547 .clean(html)
1548 .to_string();
1549 assert!(
1550 cleaned.contains(r#"class="language-json""#),
1551 "Expected class preserved, got: {}",
1552 cleaned
1553 );
1554 }
1555
1556 #[test]
1557 fn test_code_block_preserves_class_attribute() {
1558 let md = r#"```json
1559{"key": "value"}
1560```"#;
1561 let parser = MarkdownParser::new();
1562 let result = parser.parse(md, OutputFormat::Html).unwrap();
1563 assert!(
1564 result.content.contains(r#"class="language-json""#),
1565 "Expected code block to have class=\"language-json\", got: {}",
1566 &result.content
1567 );
1568 }
1569}