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