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