1use crate::error::MarkdownError;
19use crate::extensions::{
20 collect_headings, enhance_table_nodes, process_custom_block_nodes,
21 CustomBlockConfig, Heading,
22};
23use comrak::options::Plugins;
24#[cfg(feature = "syntax_highlighting")]
25use comrak::options::RenderPlugins;
26use comrak::{Arena, Options};
27use log::{debug, info, warn};
28use std::collections::{HashMap, HashSet};
29use std::fmt;
30use std::io::Write;
31use std::sync::LazyLock;
32
33#[cfg(feature = "syntax_highlighting")]
34use crate::highlight::SyntectAdapter;
35
36pub const DEFAULT_MAX_INPUT_SIZE: usize = 1_048_576;
38
39#[derive(Clone)]
41pub struct MarkdownOptions<'a> {
42 pub comrak_options: Options<'a>,
44 pub enable_custom_blocks: bool,
46 pub enable_syntax_highlighting: bool,
48 pub enable_enhanced_tables: bool,
50 pub syntax_theme: Option<String>,
52 pub allow_unsafe_html: bool,
60 pub custom_block_config: CustomBlockConfig,
62 pub max_input_size: usize,
64 pub header_ids: Option<String>,
70 pub sanitizer_config: Option<SanitizerConfig>,
77 pub enable_diagrams: bool,
86}
87
88impl<'a> Default for MarkdownOptions<'a> {
89 fn default() -> Self {
90 let mut comrak_options = Options::default();
95 comrak_options.extension.table = true;
96 Self {
97 comrak_options,
98 enable_custom_blocks: true,
99 enable_syntax_highlighting: true,
100 enable_enhanced_tables: true,
101 syntax_theme: None,
102 allow_unsafe_html: false,
106 custom_block_config: CustomBlockConfig::default(),
107 max_input_size: DEFAULT_MAX_INPUT_SIZE,
108 header_ids: None,
109 sanitizer_config: None,
110 enable_diagrams: false,
111 }
112 }
113}
114
115impl<'a> MarkdownOptions<'a> {
116 pub fn new() -> Self {
118 Self::default()
119 }
120
121 pub fn with_custom_blocks(mut self, enable: bool) -> Self {
123 self.enable_custom_blocks = enable;
124 self
125 }
126
127 pub fn with_syntax_highlighting(mut self, enable: bool) -> Self {
129 self.enable_syntax_highlighting = enable;
130 self
131 }
132
133 pub fn with_enhanced_tables(mut self, enable: bool) -> Self {
135 self.enable_enhanced_tables = enable;
136 self
137 }
138
139 pub fn with_custom_theme(mut self, theme: String) -> Self {
141 self.syntax_theme = Some(theme);
142 self
143 }
144
145 pub fn with_comrak_options(mut self, options: Options<'a>) -> Self {
149 self.allow_unsafe_html = options.render.r#unsafe;
150 self.comrak_options = options;
151 self
152 }
153
154 pub fn with_unsafe_html(mut self, enable: bool) -> Self {
159 self.allow_unsafe_html = enable;
160 self
161 }
162
163 pub fn with_custom_block_config(
165 mut self,
166 config: CustomBlockConfig,
167 ) -> Self {
168 self.custom_block_config = config;
169 self
170 }
171
172 pub fn with_max_input_size(mut self, size: usize) -> Self {
174 self.max_input_size = size;
175 self
176 }
177
178 pub fn with_header_ids(
183 mut self,
184 prefix: impl Into<String>,
185 ) -> Self {
186 self.header_ids = Some(prefix.into());
187 self
188 }
189
190 pub fn with_sanitizer_config(
197 mut self,
198 config: SanitizerConfig,
199 ) -> Self {
200 self.sanitizer_config = Some(config);
201 self
202 }
203
204 pub fn with_diagrams(mut self, enable: bool) -> Self {
209 self.enable_diagrams = enable;
210 self
211 }
212
213 pub fn validate(
253 &self,
254 ) -> Result<(), Vec<(String, crate::validation::ValidationError)>>
255 {
256 use crate::validation::{ValidationError, Validator};
257
258 let mut v = Validator::new();
259
260 v.check("enable_enhanced_tables", || {
262 if self.enable_enhanced_tables
263 && !self.comrak_options.extension.table
264 {
265 Err(ValidationError::Custom(
266 "enhanced_tables = true requires comrak_options.extension.table = true"
267 .into(),
268 ))
269 } else {
270 Ok(())
271 }
272 });
273
274 #[cfg(feature = "syntax_highlighting")]
276 v.check("syntax_theme", || {
277 if let Some(ref theme) = self.syntax_theme {
278 let available =
279 crate::highlight::SyntectAdapter::available_themes(
280 );
281 if !available.contains(&theme.as_str()) {
282 return Err(ValidationError::NotInSet {
283 allowed: available
284 .iter()
285 .map(|s| (*s).to_string())
286 .collect(),
287 });
288 }
289 }
290 Ok(())
291 });
292
293 v.check("syntax_theme", || {
295 if !self.enable_syntax_highlighting
296 && self.syntax_theme.is_some()
297 {
298 Err(ValidationError::Custom(
299 "syntax_theme is set but enable_syntax_highlighting = false (theme would be ignored)"
300 .into(),
301 ))
302 } else {
303 Ok(())
304 }
305 });
306
307 v.check("sanitizer_config", || {
310 if self.allow_unsafe_html && self.sanitizer_config.is_some()
311 {
312 Err(ValidationError::Custom(
313 "sanitizer_config is set but allow_unsafe_html = true (sanitizer is skipped)"
314 .into(),
315 ))
316 } else {
317 Ok(())
318 }
319 });
320
321 v.check("header_ids", || {
324 if let Some(ref prefix) = self.header_ids {
325 if let Some(c) = prefix.chars().find(|c| {
326 c.is_whitespace()
327 || matches!(
328 c,
329 '"' | '\''
330 | '<'
331 | '>'
332 | '&'
333 | '='
334 )
335 }) {
336 return Err(ValidationError::InvalidPattern {
337 pattern: format!(
338 "no whitespace or HTML-special chars (found {c:?})"
339 ),
340 });
341 }
342 }
343 Ok(())
344 });
345
346 if let Some(ref cfg) = self.sanitizer_config {
348 check_tag_list(
349 &mut v,
350 "sanitizer_config.extra_tags",
351 &cfg.extra_tags,
352 );
353 check_tag_attr_map(
354 &mut v,
355 "sanitizer_config.extra_tag_attributes",
356 &cfg.extra_tag_attributes,
357 );
358 check_attr_list(
359 &mut v,
360 "sanitizer_config.extra_generic_attributes",
361 &cfg.extra_generic_attributes,
362 );
363 check_allowed_classes_map(
364 &mut v,
365 "sanitizer_config.extra_allowed_classes",
366 &cfg.extra_allowed_classes,
367 );
368 }
369
370 for (block_type, class) in
372 &self.custom_block_config.class_overrides
373 {
374 let field = format!(
375 "custom_block_config.class_overrides[{block_type:?}]"
376 );
377 let c = class.clone();
378 v.check(&field, move || {
379 if c.is_empty() {
380 return Err(ValidationError::Empty);
381 }
382 if let Some(ch) = c.chars().find(|c| {
383 c.is_whitespace() || matches!(c, '"' | '\'')
384 }) {
385 return Err(ValidationError::InvalidPattern {
386 pattern: format!(
387 "non-empty, no whitespace or quotes (found {ch:?})"
388 ),
389 });
390 }
391 Ok(())
392 });
393 }
394 for (block_type, title) in
395 &self.custom_block_config.title_overrides
396 {
397 let field = format!(
398 "custom_block_config.title_overrides[{block_type:?}]"
399 );
400 let t = title.clone();
401 v.check(&field, move || {
402 if t.trim().is_empty() {
403 return Err(ValidationError::Empty);
404 }
405 Ok(())
406 });
407 }
408
409 v.finish()
410 }
411}
412
413fn is_html_name(s: &str) -> bool {
417 let mut chars = s.chars();
418 match chars.next() {
419 Some(c) if c.is_ascii_alphabetic() => {}
420 _ => return false,
421 }
422 chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
423}
424
425fn check_tag_list(
426 v: &mut crate::validation::Validator,
427 field: &str,
428 tags: &[String],
429) {
430 for (i, tag) in tags.iter().enumerate() {
431 let f = format!("{field}[{i}]");
432 let t = tag.clone();
433 v.check(&f, move || {
434 if !is_html_name(&t) {
435 Err(crate::validation::ValidationError::InvalidPattern {
436 pattern: format!(
437 "valid HTML tag name (got {t:?})"
438 ),
439 })
440 } else {
441 Ok(())
442 }
443 });
444 }
445}
446
447fn check_attr_list(
448 v: &mut crate::validation::Validator,
449 field: &str,
450 attrs: &[String],
451) {
452 for (i, attr) in attrs.iter().enumerate() {
453 let f = format!("{field}[{i}]");
454 let a = attr.clone();
455 v.check(&f, move || {
456 if !is_html_name(&a) {
457 Err(crate::validation::ValidationError::InvalidPattern {
458 pattern: format!(
459 "valid HTML attribute name (got {a:?})"
460 ),
461 })
462 } else {
463 Ok(())
464 }
465 });
466 }
467}
468
469fn check_tag_attr_map(
470 v: &mut crate::validation::Validator,
471 field: &str,
472 map: &HashMap<String, Vec<String>>,
473) {
474 for (tag, attrs) in map {
475 let f_tag = format!("{field}.{tag}");
476 let t = tag.clone();
477 v.check(&f_tag, move || {
478 if !is_html_name(&t) {
479 Err(crate::validation::ValidationError::InvalidPattern {
480 pattern: format!(
481 "valid HTML tag name (got {t:?})"
482 ),
483 })
484 } else {
485 Ok(())
486 }
487 });
488 check_attr_list(v, &f_tag, attrs);
489 }
490}
491
492fn check_allowed_classes_map(
493 v: &mut crate::validation::Validator,
494 field: &str,
495 map: &HashMap<String, Vec<String>>,
496) {
497 for (tag, classes) in map {
498 let f_tag = format!("{field}.{tag}");
499 let t = tag.clone();
500 v.check(&f_tag, move || {
501 if !is_html_name(&t) {
502 Err(crate::validation::ValidationError::InvalidPattern {
503 pattern: format!(
504 "valid HTML tag name (got {t:?})"
505 ),
506 })
507 } else {
508 Ok(())
509 }
510 });
511 for (i, class) in classes.iter().enumerate() {
512 let f = format!("{f_tag}[{i}]");
513 let c = class.clone();
514 v.check(&f, move || {
515 if c.is_empty() {
516 return Err(
517 crate::validation::ValidationError::Empty,
518 );
519 }
520 if let Some(ch) = c.chars().find(|c| {
521 c.is_whitespace() || matches!(c, '"' | '\'')
522 }) {
523 return Err(
524 crate::validation::ValidationError::InvalidPattern {
525 pattern: format!(
526 "non-empty, no whitespace or quotes (got {ch:?})"
527 ),
528 },
529 );
530 }
531 Ok(())
532 });
533 }
534 }
535}
536
537impl fmt::Debug for MarkdownOptions<'_> {
538 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
539 f.debug_struct("MarkdownOptions")
540 .field("enable_custom_blocks", &self.enable_custom_blocks)
541 .field(
542 "enable_syntax_highlighting",
543 &self.enable_syntax_highlighting,
544 )
545 .field(
546 "enable_enhanced_tables",
547 &self.enable_enhanced_tables,
548 )
549 .field("syntax_theme", &self.syntax_theme)
550 .field("allow_unsafe_html", &self.allow_unsafe_html)
551 .field("max_input_size", &self.max_input_size)
552 .field("header_ids", &self.header_ids)
553 .field("sanitizer_config", &self.sanitizer_config)
554 .field("enable_diagrams", &self.enable_diagrams)
555 .finish()
556 }
557}
558
559#[derive(Debug, Clone, Default)]
567pub struct SanitizerConfig {
568 pub extra_tags: Vec<String>,
570 pub extra_tag_attributes: HashMap<String, Vec<String>>,
572 pub extra_generic_attributes: Vec<String>,
574 pub extra_allowed_classes: HashMap<String, Vec<String>>,
576}
577
578impl SanitizerConfig {
579 pub fn new() -> Self {
581 Self::default()
582 }
583
584 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
586 self.extra_tags.push(tag.into());
587 self
588 }
589
590 pub fn with_tag_attribute(
592 mut self,
593 tag: impl Into<String>,
594 attr: impl Into<String>,
595 ) -> Self {
596 self.extra_tag_attributes
597 .entry(tag.into())
598 .or_default()
599 .push(attr.into());
600 self
601 }
602
603 pub fn with_generic_attribute(
605 mut self,
606 attr: impl Into<String>,
607 ) -> Self {
608 self.extra_generic_attributes.push(attr.into());
609 self
610 }
611
612 pub fn with_allowed_class(
614 mut self,
615 tag: impl Into<String>,
616 class: impl Into<String>,
617 ) -> Self {
618 self.extra_allowed_classes
619 .entry(tag.into())
620 .or_default()
621 .push(class.into());
622 self
623 }
624}
625
626pub fn default_markdown_options() -> MarkdownOptions<'static> {
635 MarkdownOptions::new()
636 .with_custom_blocks(true)
637 .with_syntax_highlighting(true)
638 .with_enhanced_tables(true)
639 .with_comrak_options({
640 let mut opts = Options::default();
641 opts.extension.table = true;
642 opts
643 })
644 .with_unsafe_html(false)
645}
646
647pub fn process_markdown(
659 content: &str,
660 options: &MarkdownOptions,
661) -> Result<String, MarkdownError> {
662 let mut buf: Vec<u8> = Vec::new();
663 process_markdown_to_writer(content, &mut buf, options)?;
664 String::from_utf8(buf).map_err(|e| {
667 MarkdownError::RenderError(format!(
668 "non-UTF-8 output from pipeline: {e}"
669 ))
670 })
671}
672
673pub fn process_markdown_to_writer<W: Write>(
687 content: &str,
688 writer: &mut W,
689 options: &MarkdownOptions,
690) -> Result<(), MarkdownError> {
691 pipeline(content, writer, options, None)
692}
693
694pub fn process_markdown_with_toc(
707 content: &str,
708 options: &MarkdownOptions,
709) -> Result<(String, Vec<Heading>), MarkdownError> {
710 let mut buf: Vec<u8> = Vec::new();
711 let mut toc = Vec::new();
712 pipeline(content, &mut buf, options, Some(&mut toc))?;
713 let html = String::from_utf8(buf).map_err(|e| {
714 MarkdownError::RenderError(format!(
715 "non-UTF-8 output from pipeline: {e}"
716 ))
717 })?;
718 Ok((html, toc))
719}
720
721pub fn process_markdown_with_toc_to_writer<W: Write>(
727 content: &str,
728 writer: &mut W,
729 options: &MarkdownOptions,
730) -> Result<Vec<Heading>, MarkdownError> {
731 let mut toc = Vec::new();
732 pipeline(content, writer, options, Some(&mut toc))?;
733 Ok(toc)
734}
735
736pub fn process_markdown_to_plain_text(
746 content: &str,
747 options: &MarkdownOptions,
748) -> Result<String, MarkdownError> {
749 if options.max_input_size > 0
750 && content.len() > options.max_input_size
751 {
752 return Err(MarkdownError::InputTooLarge {
753 size: content.len(),
754 limit: options.max_input_size,
755 });
756 }
757
758 let arena = Arena::new();
759 let root = comrak::parse_document(
760 &arena,
761 content,
762 &options.comrak_options,
763 );
764
765 Ok(crate::extensions::collect_all_text(root))
766}
767
768fn pipeline<W: Write>(
772 content: &str,
773 writer: &mut W,
774 options: &MarkdownOptions,
775 toc_out: Option<&mut Vec<Heading>>,
776) -> Result<(), MarkdownError> {
777 info!("Starting markdown processing");
778 debug!("Markdown options: {:?}", options);
779
780 if options.max_input_size > 0
782 && content.len() > options.max_input_size
783 {
784 return Err(MarkdownError::InputTooLarge {
785 size: content.len(),
786 limit: options.max_input_size,
787 });
788 }
789
790 if let Err(errors) = options.validate() {
792 for (field, err) in &errors {
793 warn!("Invalid MarkdownOptions.{field}: {err}");
794 }
795 return Err(MarkdownError::from(errors));
796 }
797
798 let mut comrak_opts = options.comrak_options.clone();
800 comrak_opts.render.r#unsafe = true;
803
804 if let Some(ref prefix) = options.header_ids {
806 comrak_opts.extension.header_id_prefix = Some(prefix.clone());
807 }
808
809 let arena = Arena::new();
811 let root = comrak::parse_document(&arena, content, &comrak_opts);
812
813 if options.enable_custom_blocks {
815 debug!("Processing custom blocks at AST level");
816 process_custom_block_nodes(root, &options.custom_block_config);
817 }
818 if options.enable_diagrams {
819 debug!("Rewriting diagram code blocks");
820 crate::diagrams::process_diagram_code_blocks(root);
821 }
822 if let Some(toc) = toc_out {
823 debug!("Collecting headings for table of contents");
828 *toc = collect_headings(root, options.header_ids.as_deref());
829 }
830 if options.enable_enhanced_tables {
831 debug!("Enhancing tables at AST level");
832 enhance_table_nodes(root, &arena, &comrak_opts);
833 }
834
835 debug!("Rendering AST to HTML");
837
838 #[cfg(feature = "syntax_highlighting")]
839 let adapter;
840 #[cfg(feature = "syntax_highlighting")]
841 let plugins = if options.enable_syntax_highlighting {
842 adapter = SyntectAdapter::new(options.syntax_theme.as_deref());
843 Plugins {
844 render: RenderPlugins {
845 codefence_syntax_highlighter: Some(&adapter),
846 ..Default::default()
847 },
848 }
849 } else {
850 Plugins::default()
851 };
852 #[cfg(not(feature = "syntax_highlighting"))]
853 let plugins = Plugins::default();
854
855 let mut html = String::new();
856 comrak::format_html_with_plugins(
857 root,
858 &comrak_opts,
859 &mut html,
860 &plugins,
861 )
862 .map_err(|e| MarkdownError::RenderError(e.to_string()))?;
863
864 if options.allow_unsafe_html {
866 writer.write_all(html.as_bytes())?;
867 } else {
868 debug!("Sanitizing HTML output");
869 sanitize_html_to_writer(
870 &html,
871 writer,
872 options.sanitizer_config.as_ref(),
873 )?;
874 }
875
876 info!("Markdown processing completed successfully");
877 Ok(())
878}
879
880static CODE_LANG_CLASSES: LazyLock<HashSet<String>> =
885 LazyLock::new(|| {
886 [
887 "rust",
888 "python",
889 "javascript",
890 "typescript",
891 "java",
892 "c",
893 "cpp",
894 "csharp",
895 "go",
896 "ruby",
897 "swift",
898 "kotlin",
899 "php",
900 "html",
901 "css",
902 "sql",
903 "bash",
904 "shell",
905 "json",
906 "yaml",
907 "toml",
908 "xml",
909 "markdown",
910 "plaintext",
911 "text",
912 "mermaid",
916 "geojson",
917 "topojson",
918 "stl",
919 ]
920 .iter()
921 .map(|lang| format!("language-{lang}"))
922 .collect()
923 });
924
925fn configure_default_sanitizer<'a>(builder: &mut ammonia::Builder<'a>) {
932 let code_class_refs: HashSet<&'static str> =
933 CODE_LANG_CLASSES.iter().map(|s| s.as_str()).collect();
934
935 let mut allowed_classes: HashMap<
936 &'static str,
937 HashSet<&'static str>,
938 > = HashMap::new();
939
940 allowed_classes.insert(
941 "div",
942 [
943 "alert",
944 "alert-info",
945 "alert-warning",
946 "alert-success",
947 "alert-primary",
948 "alert-danger",
949 "alert-secondary",
950 "table-responsive",
951 ]
952 .into_iter()
953 .collect(),
954 );
955 allowed_classes.insert("table", ["table"].into_iter().collect());
956 allowed_classes.insert(
957 "td",
958 ["text-left", "text-center", "text-right"]
959 .into_iter()
960 .collect(),
961 );
962 allowed_classes.insert("code", code_class_refs);
963 allowed_classes.insert("pre", ["mermaid"].into_iter().collect());
965
966 builder
967 .add_tags(["div", "pre", "code", "span", "input"])
968 .add_tag_attributes("div", &["role", "id"])
969 .add_tag_attributes("td", &["align"])
970 .add_tag_attributes("th", &["align"])
971 .add_tag_attributes("input", &["type", "checked", "disabled"])
972 .add_tag_attributes("h1", &["id"])
973 .add_tag_attributes("h2", &["id"])
974 .add_tag_attributes("h3", &["id"])
975 .add_tag_attributes("h4", &["id"])
976 .add_tag_attributes("h5", &["id"])
977 .add_tag_attributes("h6", &["id"])
978 .add_tag_attributes("a", &["id"])
979 .allowed_classes(allowed_classes)
980 .add_tag_attributes("span", &["class", "data-math-style"]);
986}
987
988static SANITIZE_BUILDER: LazyLock<ammonia::Builder<'static>> =
997 LazyLock::new(|| {
998 let mut builder = ammonia::Builder::default();
999 configure_default_sanitizer(&mut builder);
1000 builder
1001 });
1002
1003fn sanitize_html_to_writer<W: Write>(
1010 html: &str,
1011 writer: &mut W,
1012 cfg: Option<&SanitizerConfig>,
1013) -> std::io::Result<()> {
1014 match cfg {
1015 None => SANITIZE_BUILDER.clean(html).write_to(writer),
1016 Some(custom) => {
1017 build_custom_sanitizer(custom).clean(html).write_to(writer)
1018 }
1019 }
1020}
1021
1022fn build_custom_sanitizer(
1026 cfg: &SanitizerConfig,
1027) -> ammonia::Builder<'_> {
1028 let mut builder = ammonia::Builder::default();
1029 configure_default_sanitizer(&mut builder);
1030
1031 for tag in cfg.extra_allowed_classes.keys() {
1039 builder.rm_tag_attributes(tag.as_str(), &["class"]);
1040 }
1041
1042 if !cfg.extra_tags.is_empty() {
1043 builder.add_tags(cfg.extra_tags.iter().map(String::as_str));
1044 }
1045 for (tag, attrs) in &cfg.extra_tag_attributes {
1046 builder.add_tag_attributes(
1047 tag.as_str(),
1048 attrs.iter().map(String::as_str),
1049 );
1050 }
1051 if !cfg.extra_generic_attributes.is_empty() {
1052 builder.add_generic_attributes(
1053 cfg.extra_generic_attributes.iter().map(String::as_str),
1054 );
1055 }
1056 for (tag, classes) in &cfg.extra_allowed_classes {
1057 builder.add_allowed_classes(
1058 tag.as_str(),
1059 classes.iter().map(String::as_str),
1060 );
1061 }
1062 builder
1063}
1064
1065#[cfg(test)]
1068mod tests {
1069 use super::*;
1070 use crate::CustomBlockType;
1071
1072 #[test]
1073 fn test_process_markdown_with_all_features() {
1074 let markdown = r#"
1075# Test Markdown
1076
1077| Left | Center | Right |
1078|:-----|:------:|------:|
1079| 1 | 2 | 3 |
1080
1081```rust
1082fn main() {
1083 println!("Hello, world!");
1084}
1085```
1086
1087<div class="note">This is a note.</div>
1088<div class="warning">This is a warning.</div>
1089<div class="tip">This is a tip.</div>
1090"#;
1091
1092 let options = default_markdown_options();
1093 let result = process_markdown(markdown, &options);
1094 assert!(result.is_ok(), "Failed: {:?}", result.err());
1095
1096 let html = result.unwrap();
1097 assert!(html.contains("table-responsive"));
1098 assert!(html.contains("language-rust"));
1099 assert!(html.contains("alert alert-info"));
1100 assert!(html.contains("alert alert-warning"));
1101 assert!(html.contains("alert alert-success"));
1102 }
1103
1104 #[test]
1105 fn test_process_markdown_without_custom_blocks() {
1106 let markdown = "# Test\n<div class=\"note\">Note.</div>";
1107 let options = MarkdownOptions::new()
1108 .with_custom_blocks(false)
1109 .with_comrak_options({
1110 let mut opts = Options::default();
1111 opts.extension.table = true;
1112 opts
1113 })
1114 .with_unsafe_html(true);
1115
1116 let html = process_markdown(markdown, &options).unwrap();
1117 assert!(html.contains("<div class=\"note\">"));
1119 assert!(!html.contains("alert"));
1120 }
1121
1122 #[test]
1123 fn test_process_markdown_without_enhanced_tables() {
1124 let markdown = "| H1 | H2 |\n|---|---|\n| A | B |";
1125 let options = MarkdownOptions::new()
1126 .with_enhanced_tables(false)
1127 .with_custom_blocks(false)
1128 .with_comrak_options({
1129 let mut opts = Options::default();
1130 opts.extension.table = true;
1131 opts
1132 });
1133
1134 let html = process_markdown(markdown, &options).unwrap();
1135 assert!(!html.contains("table-responsive"));
1136 assert!(html.contains("<table>"));
1137 }
1138
1139 #[test]
1140 fn test_validation_enhanced_tables_without_extension() {
1141 let options = MarkdownOptions::new()
1142 .with_enhanced_tables(true)
1143 .with_custom_blocks(false)
1144 .with_comrak_options({
1145 let mut opts = Options::default();
1146 opts.extension.table = false;
1147 opts
1148 });
1149 let errors = options.validate().unwrap_err();
1150 assert!(errors
1151 .iter()
1152 .any(|(f, _)| f == "enable_enhanced_tables"));
1153 }
1154
1155 #[test]
1156 fn test_validation_default_options_pass() {
1157 let mut comrak = Options::default();
1164 comrak.extension.table = true;
1165 let options =
1166 MarkdownOptions::new().with_comrak_options(comrak);
1167 assert!(
1168 options.validate().is_ok(),
1169 "{:?}",
1170 options.validate().unwrap_err()
1171 );
1172 }
1173
1174 #[cfg(feature = "syntax_highlighting")]
1177 #[test]
1178 fn test_validation_unknown_syntax_theme() {
1179 let options = MarkdownOptions::new()
1180 .with_enhanced_tables(false)
1181 .with_custom_blocks(false)
1182 .with_custom_theme("no-such-theme-exists".into());
1183 let errors = options.validate().unwrap_err();
1184 assert!(
1185 errors.iter().any(|(f, _)| f == "syntax_theme"),
1186 "expected syntax_theme failure, got {errors:?}"
1187 );
1188 }
1189
1190 #[test]
1191 fn test_validation_theme_without_highlighter_disabled() {
1192 let options = MarkdownOptions::new()
1195 .with_enhanced_tables(false)
1196 .with_custom_blocks(false)
1197 .with_syntax_highlighting(false)
1198 .with_custom_theme("base16-ocean.dark".into());
1199 let errors = options.validate().unwrap_err();
1200 assert!(errors.iter().any(|(f, _)| f == "syntax_theme"));
1201 }
1202
1203 #[test]
1204 fn test_validation_sanitizer_with_unsafe_html() {
1205 let options = MarkdownOptions::new()
1208 .with_enhanced_tables(false)
1209 .with_custom_blocks(false)
1210 .with_unsafe_html(true)
1211 .with_sanitizer_config(
1212 SanitizerConfig::new().with_tag("main"),
1213 );
1214 let errors = options.validate().unwrap_err();
1215 assert!(errors.iter().any(|(f, _)| f == "sanitizer_config"));
1216 }
1217
1218 #[test]
1219 fn test_validation_header_ids_bad_chars() {
1220 for bad in [
1221 "user content ", "quo\"te-",
1223 "ang<le-",
1224 "amp&-",
1225 ] {
1226 let options = MarkdownOptions::new()
1227 .with_enhanced_tables(false)
1228 .with_custom_blocks(false)
1229 .with_header_ids(bad);
1230 let errors = options.validate().unwrap_err();
1231 assert!(
1232 errors.iter().any(|(f, _)| f == "header_ids"),
1233 "expected header_ids failure for {bad:?}, got {errors:?}"
1234 );
1235 }
1236 }
1237
1238 #[test]
1239 fn test_validation_header_ids_clean_prefix_ok() {
1240 let options = MarkdownOptions::new()
1241 .with_enhanced_tables(false)
1242 .with_custom_blocks(false)
1243 .with_header_ids("user-content-");
1244 assert!(options.validate().is_ok());
1245 }
1246
1247 #[test]
1248 fn test_validation_sanitizer_extra_tag_invalid() {
1249 let options = MarkdownOptions::new()
1250 .with_enhanced_tables(false)
1251 .with_custom_blocks(false)
1252 .with_unsafe_html(false)
1253 .with_sanitizer_config(
1254 SanitizerConfig::new().with_tag("has space"),
1255 );
1256 let errors = options.validate().unwrap_err();
1257 assert!(
1258 errors
1259 .iter()
1260 .any(|(f, _)| f
1261 .starts_with("sanitizer_config.extra_tags"))
1262 );
1263 }
1264
1265 #[test]
1266 fn test_validation_sanitizer_extra_generic_attribute_invalid() {
1267 let options = MarkdownOptions::new()
1268 .with_enhanced_tables(false)
1269 .with_custom_blocks(false)
1270 .with_unsafe_html(false)
1271 .with_sanitizer_config(
1272 SanitizerConfig::new().with_generic_attribute(""),
1273 );
1274 let errors = options.validate().unwrap_err();
1275 assert!(errors.iter().any(|(f, _)| f
1276 .starts_with("sanitizer_config.extra_generic_attributes")));
1277 }
1278
1279 #[test]
1280 fn test_validation_sanitizer_allowed_class_with_whitespace() {
1281 let options = MarkdownOptions::new()
1282 .with_enhanced_tables(false)
1283 .with_custom_blocks(false)
1284 .with_unsafe_html(false)
1285 .with_sanitizer_config(
1286 SanitizerConfig::new()
1287 .with_allowed_class("span", "has space"),
1288 );
1289 let errors = options.validate().unwrap_err();
1290 assert!(errors.iter().any(|(f, _)| f
1291 .starts_with("sanitizer_config.extra_allowed_classes")));
1292 }
1293
1294 #[test]
1295 fn test_validation_custom_block_class_override_empty() {
1296 let cfg = CustomBlockConfig::new()
1297 .with_class(CustomBlockType::Note, "");
1298 let options = MarkdownOptions::new()
1299 .with_enhanced_tables(false)
1300 .with_custom_block_config(cfg);
1301 let errors = options.validate().unwrap_err();
1302 assert!(errors.iter().any(|(f, _)| f
1303 .starts_with("custom_block_config.class_overrides")));
1304 }
1305
1306 #[test]
1307 fn test_validation_custom_block_title_override_blank() {
1308 let cfg = CustomBlockConfig::new()
1309 .with_title(CustomBlockType::Warning, " ");
1310 let options = MarkdownOptions::new()
1311 .with_enhanced_tables(false)
1312 .with_custom_block_config(cfg);
1313 let errors = options.validate().unwrap_err();
1314 assert!(errors.iter().any(|(f, _)| f
1315 .starts_with("custom_block_config.title_overrides")));
1316 }
1317
1318 #[test]
1319 fn test_sanitizer_config_applies_extra_tag_attribute() {
1320 let options = MarkdownOptions::new()
1324 .with_custom_blocks(false)
1325 .with_enhanced_tables(false)
1326 .with_unsafe_html(false)
1327 .with_sanitizer_config(
1328 SanitizerConfig::new()
1329 .with_tag("section")
1330 .with_tag_attribute("section", "data-foo"),
1331 );
1332
1333 let md = r#"<section data-foo="bar">hello</section>"#;
1334 let html = process_markdown(md, &options).unwrap();
1335 assert!(html.contains("<section"));
1336 assert!(html.contains("data-foo=\"bar\""));
1337 }
1338
1339 #[test]
1340 fn test_sanitizer_config_applies_extra_generic_attribute() {
1341 let options = MarkdownOptions::new()
1345 .with_custom_blocks(false)
1346 .with_enhanced_tables(false)
1347 .with_unsafe_html(false)
1348 .with_sanitizer_config(
1349 SanitizerConfig::new().with_generic_attribute("data-x"),
1350 );
1351
1352 let md = r#"<p data-x="v">hi</p>"#;
1353 let html = process_markdown(md, &options).unwrap();
1354 assert!(html.contains("data-x=\"v\""));
1355 }
1356
1357 #[test]
1358 fn test_sanitizer_config_with_tag_attribute_direct() {
1359 let cfg = SanitizerConfig::new()
1361 .with_tag_attribute("div", "role")
1362 .with_tag_attribute("div", "id");
1363 let attrs = cfg
1364 .extra_tag_attributes
1365 .get("div")
1366 .expect("div should exist");
1367 assert_eq!(attrs, &vec!["role".to_string(), "id".to_string()]);
1368 }
1369
1370 #[test]
1371 fn test_validation_sanitizer_tag_attr_invalid_tag() {
1372 let options = MarkdownOptions::new()
1373 .with_enhanced_tables(false)
1374 .with_custom_blocks(false)
1375 .with_unsafe_html(false)
1376 .with_sanitizer_config(
1377 SanitizerConfig::new()
1378 .with_tag_attribute("has space", "id"),
1379 );
1380 let errors = options.validate().unwrap_err();
1381 assert!(errors.iter().any(|(f, _)| f
1382 .starts_with("sanitizer_config.extra_tag_attributes")));
1383 }
1384
1385 #[test]
1386 fn test_validation_sanitizer_tag_attr_invalid_attr_name() {
1387 let options = MarkdownOptions::new()
1388 .with_enhanced_tables(false)
1389 .with_custom_blocks(false)
1390 .with_unsafe_html(false)
1391 .with_sanitizer_config(
1392 SanitizerConfig::new().with_tag_attribute("div", ""),
1393 );
1394 let errors = options.validate().unwrap_err();
1395 assert!(errors.iter().any(|(f, _)| f
1396 .starts_with("sanitizer_config.extra_tag_attributes")));
1397 }
1398
1399 #[test]
1400 fn test_validation_sanitizer_allowed_class_invalid_tag() {
1401 let options = MarkdownOptions::new()
1402 .with_enhanced_tables(false)
1403 .with_custom_blocks(false)
1404 .with_unsafe_html(false)
1405 .with_sanitizer_config(
1406 SanitizerConfig::new()
1407 .with_allowed_class("bad tag", "foo"),
1408 );
1409 let errors = options.validate().unwrap_err();
1410 assert!(errors.iter().any(|(f, _)| f
1411 .starts_with("sanitizer_config.extra_allowed_classes")));
1412 }
1413
1414 #[test]
1415 fn test_validation_sanitizer_allowed_class_empty() {
1416 let options = MarkdownOptions::new()
1417 .with_enhanced_tables(false)
1418 .with_custom_blocks(false)
1419 .with_unsafe_html(false)
1420 .with_sanitizer_config(
1421 SanitizerConfig::new().with_allowed_class("span", ""),
1422 );
1423 let errors = options.validate().unwrap_err();
1424 assert!(errors.iter().any(|(f, _)| f
1425 .starts_with("sanitizer_config.extra_allowed_classes")));
1426 }
1427
1428 #[test]
1429 fn test_validation_custom_block_class_override_whitespace() {
1430 let cfg = CustomBlockConfig::new()
1431 .with_class(CustomBlockType::Note, "bad class");
1432 let options = MarkdownOptions::new()
1433 .with_enhanced_tables(false)
1434 .with_custom_block_config(cfg);
1435 let errors = options.validate().unwrap_err();
1436 assert!(errors.iter().any(|(f, _)| f
1437 .starts_with("custom_block_config.class_overrides")));
1438 }
1439
1440 #[test]
1441 fn test_toc_extracts_image_title() {
1442 let md = "# See  here\n";
1444 let options = MarkdownOptions::new()
1445 .with_enhanced_tables(false)
1446 .with_custom_blocks(false);
1447 let (_html, toc) =
1448 process_markdown_with_toc(md, &options).unwrap();
1449 assert_eq!(toc.len(), 1);
1450 assert!(
1452 toc[0].text.contains("Logo Title")
1453 || toc[0].text.contains("alt"),
1454 "expected image title/alt in: {:?}",
1455 toc[0].text
1456 );
1457 }
1458
1459 #[test]
1460 fn test_plain_text_soft_break_inserts_space() {
1461 let md = "one\ntwo\nthree\n";
1465 let text = process_markdown_to_plain_text(
1466 md,
1467 &MarkdownOptions::default(),
1468 )
1469 .unwrap();
1470 assert_eq!(text, "one two three");
1471 }
1472
1473 #[test]
1474 fn test_plain_text_image_title_included() {
1475 let md = "Caption: \n";
1477 let text = process_markdown_to_plain_text(
1478 md,
1479 &MarkdownOptions::default(),
1480 )
1481 .unwrap();
1482 assert!(text.contains("Caption:"));
1484 }
1485
1486 #[test]
1487 fn test_validation_reports_all_failures_at_once() {
1488 let cfg = CustomBlockConfig::new()
1492 .with_class(CustomBlockType::Note, "");
1493 let options = MarkdownOptions::new()
1494 .with_enhanced_tables(true) .with_header_ids("a b") .with_custom_block_config(cfg) .with_comrak_options({
1498 let mut opts = Options::default();
1499 opts.extension.table = false;
1500 opts
1501 });
1502 let errors = options.validate().unwrap_err();
1503 assert!(
1504 errors.len() >= 3,
1505 "expected 3+ errors, got {errors:?}"
1506 );
1507 }
1508
1509 #[test]
1510 fn test_empty_content() {
1511 let options = MarkdownOptions::new()
1512 .with_enhanced_tables(false)
1513 .with_custom_blocks(false);
1514 let html = process_markdown("", &options).unwrap();
1515 assert!(html.trim().is_empty());
1516 }
1517
1518 #[test]
1519 fn test_no_features_enabled() {
1520 let markdown = "# Title\n\nPlain text.";
1521 let options = MarkdownOptions::new()
1522 .with_syntax_highlighting(false)
1523 .with_custom_blocks(false)
1524 .with_enhanced_tables(false);
1525
1526 let html = process_markdown(markdown, &options).unwrap();
1527 assert!(html.contains("<h1>Title</h1>"));
1528 assert!(html.contains("Plain text."));
1529 }
1530
1531 #[test]
1532 fn test_sanitization_strips_script() {
1533 let markdown = "<script>alert('xss')</script>";
1534 let options = MarkdownOptions::new()
1535 .with_custom_blocks(false)
1536 .with_enhanced_tables(false)
1537 .with_unsafe_html(false);
1538
1539 let html = process_markdown(markdown, &options).unwrap();
1540 assert!(
1541 !html.contains("<script>"),
1542 "Script tags should be stripped"
1543 );
1544 }
1545
1546 #[test]
1547 fn test_default_options_sanitize_raw_html() {
1548 let markdown = "<script>alert(1)</script>\n\n# Safe";
1552 let options = MarkdownOptions::default();
1553 assert!(!options.allow_unsafe_html);
1554
1555 let html = process_markdown(markdown, &options).unwrap();
1556 assert!(
1557 !html.contains("<script>") && !html.contains("alert(1)"),
1558 "default options must neutralize raw script: {html}"
1559 );
1560 assert!(html.contains("<h1>Safe</h1>"));
1561 }
1562
1563 #[test]
1564 fn test_default_markdown_options_helper_is_safe() {
1565 let markdown = "<script>alert(1)</script>";
1568 let options = default_markdown_options();
1569 assert!(!options.allow_unsafe_html);
1570
1571 let html = process_markdown(markdown, &options).unwrap();
1572 assert!(
1573 !html.contains("<script>") && !html.contains("alert(1)"),
1574 "default_markdown_options must sanitize: {html}"
1575 );
1576 }
1577
1578 #[test]
1579 fn test_explicit_unsafe_passes_raw_html_through() {
1580 let markdown = "<script>alert(1)</script>";
1583 let options = MarkdownOptions::new()
1584 .with_custom_blocks(false)
1585 .with_enhanced_tables(false)
1586 .with_unsafe_html(true);
1587
1588 let html = process_markdown(markdown, &options).unwrap();
1589 assert!(
1590 html.contains("<script>alert(1)</script>"),
1591 "explicit unsafe opt-in must pass raw HTML: {html}"
1592 );
1593 }
1594
1595 #[test]
1596 fn test_custom_blocks_render_under_safe_default() {
1597 let markdown = "<div class=\"warning\">Careful.</div>";
1601 let options = MarkdownOptions::default();
1602
1603 let html = process_markdown(markdown, &options).unwrap();
1604 assert!(
1605 html.contains("alert alert-warning"),
1606 "custom blocks must render under the safe default: {html}"
1607 );
1608 assert!(html.contains("Careful."));
1609 }
1610
1611 #[test]
1612 fn test_sanitization_preserves_alerts() {
1613 let markdown = "<div class=\"note\">Important info.</div>";
1614 let options = MarkdownOptions::new()
1615 .with_custom_blocks(true)
1616 .with_enhanced_tables(false)
1617 .with_unsafe_html(false);
1618
1619 let html = process_markdown(markdown, &options).unwrap();
1620 assert!(
1621 html.contains("alert alert-info"),
1622 "Alert divs should survive sanitization"
1623 );
1624 }
1625
1626 #[test]
1627 fn test_input_too_large() {
1628 let options = MarkdownOptions::new()
1629 .with_max_input_size(10)
1630 .with_custom_blocks(false)
1631 .with_enhanced_tables(false);
1632
1633 let result =
1634 process_markdown("a]".repeat(20).as_str(), &options);
1635 assert!(matches!(
1636 result,
1637 Err(MarkdownError::InputTooLarge { .. })
1638 ));
1639 }
1640
1641 #[test]
1642 fn test_syntax_theme_customization() {
1643 let markdown = "```rust\nfn main() {}\n```";
1644 let options = MarkdownOptions::new()
1645 .with_custom_blocks(false)
1646 .with_enhanced_tables(false)
1647 .with_custom_theme("InspiredGitHub".to_string());
1648
1649 let result = process_markdown(markdown, &options);
1650 assert!(result.is_ok());
1651 }
1652
1653 #[test]
1654 fn test_custom_block_config() {
1655 let markdown = "<div class=\"note\">Custom styled.</div>";
1656 let config = CustomBlockConfig::new()
1657 .with_class(
1658 crate::extensions::CustomBlockType::Note,
1659 "my-note",
1660 )
1661 .with_title(
1662 crate::extensions::CustomBlockType::Note,
1663 "Heads up",
1664 );
1665
1666 let options = MarkdownOptions::new()
1667 .with_custom_blocks(true)
1668 .with_enhanced_tables(false)
1669 .with_custom_block_config(config)
1670 .with_unsafe_html(true);
1671
1672 let html = process_markdown(markdown, &options).unwrap();
1673 assert!(html.contains("my-note"));
1674 assert!(html.contains("Heads up:"));
1675 }
1676
1677 #[test]
1678 fn test_builder_order_comrak_then_unsafe() {
1679 let options = MarkdownOptions::new()
1680 .with_comrak_options(Options::default())
1681 .with_unsafe_html(true);
1682 assert!(options.allow_unsafe_html);
1683 }
1684
1685 #[test]
1686 fn test_comrak_options_syncs_unsafe() {
1687 let mut opts = Options::default();
1688 opts.render.r#unsafe = true;
1689 let options = MarkdownOptions::new().with_comrak_options(opts);
1690 assert!(options.allow_unsafe_html);
1691 }
1692
1693 #[test]
1694 fn test_markdown_options_debug_impl() {
1695 let options = MarkdownOptions::new()
1696 .with_custom_blocks(true)
1697 .with_syntax_highlighting(false)
1698 .with_enhanced_tables(true)
1699 .with_custom_theme("InspiredGitHub".to_string())
1700 .with_unsafe_html(false)
1701 .with_max_input_size(2048);
1702
1703 let debug_output = format!("{:?}", options);
1704 assert!(debug_output.contains("MarkdownOptions"));
1705 assert!(debug_output.contains("enable_custom_blocks: true"));
1706 assert!(
1707 debug_output.contains("enable_syntax_highlighting: false")
1708 );
1709 assert!(debug_output.contains("enable_enhanced_tables: true"));
1710 assert!(debug_output.contains("InspiredGitHub"));
1711 assert!(debug_output.contains("allow_unsafe_html: false"));
1712 assert!(debug_output.contains("max_input_size: 2048"));
1713 }
1714
1715 #[test]
1716 fn test_header_ids() {
1717 let markdown = "# Hello World\n## Sub Section";
1718 let options = MarkdownOptions::new()
1719 .with_custom_blocks(false)
1720 .with_enhanced_tables(false)
1721 .with_header_ids("")
1722 .with_unsafe_html(true);
1723
1724 let html = process_markdown(markdown, &options).unwrap();
1725 assert!(
1726 html.contains("id=\"hello-world\""),
1727 "H1 should have id attribute: {html}"
1728 );
1729 assert!(
1730 html.contains("id=\"sub-section\""),
1731 "H2 should have id attribute: {html}"
1732 );
1733 }
1734
1735 #[test]
1736 fn test_header_ids_with_prefix() {
1737 let markdown = "# Title";
1738 let options = MarkdownOptions::new()
1739 .with_custom_blocks(false)
1740 .with_enhanced_tables(false)
1741 .with_header_ids("user-content-")
1742 .with_unsafe_html(true);
1743
1744 let html = process_markdown(markdown, &options).unwrap();
1745 assert!(
1746 html.contains("id=\"user-content-title\""),
1747 "Should have prefixed id: {html}"
1748 );
1749 }
1750
1751 #[test]
1752 fn test_header_ids_survive_sanitization() {
1753 let markdown = "# Hello World";
1754 let options = MarkdownOptions::new()
1755 .with_custom_blocks(false)
1756 .with_enhanced_tables(false)
1757 .with_header_ids("")
1758 .with_unsafe_html(false);
1759
1760 let html = process_markdown(markdown, &options).unwrap();
1761 assert!(
1762 html.contains("id=\"hello-world\""),
1763 "Header id should survive ammonia sanitization: {html}"
1764 );
1765 }
1766
1767 #[test]
1768 fn test_ast_table_enhancement() {
1769 let markdown =
1770 "| H1 | H2 |\n|:---|---:|\n| L | R |\n\nParagraph\n\n| A | B |\n|---|---|\n| C | D |";
1771 let options = MarkdownOptions::new()
1772 .with_custom_blocks(false)
1773 .with_comrak_options({
1774 let mut opts = Options::default();
1775 opts.extension.table = true;
1776 opts
1777 })
1778 .with_unsafe_html(true);
1779
1780 let html = process_markdown(markdown, &options).unwrap();
1781 assert_eq!(
1783 html.matches("table-responsive").count(),
1784 2,
1785 "Both tables should get responsive wrapper: {html}"
1786 );
1787 assert!(
1788 html.contains("text-right"),
1789 "Right-aligned cells should have class"
1790 );
1791 }
1792
1793 #[test]
1796 fn test_process_markdown_to_writer_matches_string_variant() {
1797 let markdown = "# Title\n\nParagraph with *emphasis*.";
1798 let options = MarkdownOptions::new()
1799 .with_custom_blocks(false)
1800 .with_enhanced_tables(false)
1801 .with_syntax_highlighting(false);
1802
1803 let as_string = process_markdown(markdown, &options).unwrap();
1804
1805 let mut buf: Vec<u8> = Vec::new();
1806 process_markdown_to_writer(markdown, &mut buf, &options)
1807 .unwrap();
1808 let as_bytes = String::from_utf8(buf).unwrap();
1809
1810 assert_eq!(
1811 as_string, as_bytes,
1812 "writer variant must produce byte-identical output"
1813 );
1814 }
1815
1816 #[test]
1817 fn test_process_markdown_to_writer_sanitizes() {
1818 let markdown = "<script>alert('xss')</script>\n\n# Safe";
1819 let options = MarkdownOptions::new()
1820 .with_custom_blocks(false)
1821 .with_enhanced_tables(false)
1822 .with_unsafe_html(false);
1823
1824 let mut buf: Vec<u8> = Vec::new();
1825 process_markdown_to_writer(markdown, &mut buf, &options)
1826 .unwrap();
1827 let html = String::from_utf8(buf).unwrap();
1828 assert!(!html.contains("<script>"));
1829 assert!(html.contains("<h1>Safe</h1>"));
1830 }
1831
1832 #[test]
1833 fn test_process_markdown_to_writer_propagates_io_error() {
1834 struct AlwaysFails;
1835 impl Write for AlwaysFails {
1836 fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
1837 Err(std::io::Error::new(
1838 std::io::ErrorKind::BrokenPipe,
1839 "nope",
1840 ))
1841 }
1842 fn flush(&mut self) -> std::io::Result<()> {
1843 Ok(())
1844 }
1845 }
1846
1847 let options = MarkdownOptions::new()
1848 .with_custom_blocks(false)
1849 .with_enhanced_tables(false);
1850 let err = process_markdown_to_writer(
1851 "# hi",
1852 &mut AlwaysFails,
1853 &options,
1854 )
1855 .unwrap_err();
1856 assert!(matches!(err, MarkdownError::IoError(_)));
1857 }
1858
1859 #[test]
1862 fn test_sanitizer_config_allows_extra_tag() {
1863 let markdown = "<main>wrapper</main>";
1867
1868 let strict = MarkdownOptions::new()
1869 .with_custom_blocks(false)
1870 .with_enhanced_tables(false)
1871 .with_unsafe_html(false);
1872 let stripped = process_markdown(markdown, &strict).unwrap();
1873 assert!(
1874 !stripped.contains("<main>"),
1875 "default sanitizer drops <main>: {stripped}"
1876 );
1877
1878 let extended = MarkdownOptions::new()
1879 .with_custom_blocks(false)
1880 .with_enhanced_tables(false)
1881 .with_unsafe_html(false)
1882 .with_sanitizer_config(
1883 SanitizerConfig::new().with_tag("main"),
1884 );
1885 let kept = process_markdown(markdown, &extended).unwrap();
1886 assert!(
1887 kept.contains("<main>wrapper</main>"),
1888 "extended sanitizer keeps <main>: {kept}"
1889 );
1890 }
1891
1892 #[test]
1893 fn test_sanitizer_config_adds_allowed_class() {
1894 let markdown =
1895 "<span class=\"badge\">new</span> <span class=\"danger\">x</span>";
1896
1897 let options = MarkdownOptions::new()
1898 .with_custom_blocks(false)
1899 .with_enhanced_tables(false)
1900 .with_unsafe_html(false)
1901 .with_sanitizer_config(
1902 SanitizerConfig::new()
1903 .with_allowed_class("span", "badge"),
1904 );
1905
1906 let html = process_markdown(markdown, &options).unwrap();
1907 assert!(
1908 html.contains("class=\"badge\""),
1909 "whitelisted class survives: {html}"
1910 );
1911 assert!(
1912 !html.contains("class=\"danger\""),
1913 "non-whitelisted class dropped: {html}"
1914 );
1915 }
1916
1917 #[cfg(feature = "syntax_highlighting")]
1918 #[test]
1919 fn test_sanitized_output_keeps_syntect_span_classes() {
1920 let markdown = "```rust\nfn main() {}\n```";
1924 let options = MarkdownOptions::new()
1925 .with_custom_blocks(false)
1926 .with_enhanced_tables(false)
1927 .with_unsafe_html(false);
1928
1929 let html = process_markdown(markdown, &options).unwrap();
1930 assert!(
1931 html.contains("<span class=\""),
1932 "syntect classes were stripped by sanitizer: {html}"
1933 );
1934 }
1935
1936 #[test]
1937 fn test_sanitizer_config_restricts_span_class() {
1938 let markdown = "<span class=\"badge\">a</span> <span class=\"danger\">b</span>";
1942 let options = MarkdownOptions::new()
1943 .with_custom_blocks(false)
1944 .with_enhanced_tables(false)
1945 .with_unsafe_html(false)
1946 .with_sanitizer_config(
1947 SanitizerConfig::new()
1948 .with_allowed_class("span", "badge"),
1949 );
1950
1951 let html = process_markdown(markdown, &options).unwrap();
1952 assert!(
1953 html.contains("class=\"badge\""),
1954 "whitelisted class survives: {html}"
1955 );
1956 assert!(
1957 !html.contains("class=\"danger\""),
1958 "non-whitelisted class dropped: {html}"
1959 );
1960 }
1961
1962 #[test]
1963 fn test_sanitizer_strips_style_attribute() {
1964 let markdown =
1968 "<div style=\"position:fixed;top:0;left:0;width:100%;height:100%;z-index:9999;\">overlay</div>";
1969 let options = MarkdownOptions::new()
1970 .with_custom_blocks(false)
1971 .with_enhanced_tables(false)
1972 .with_unsafe_html(false);
1973
1974 let html = process_markdown(markdown, &options).unwrap();
1975 assert!(
1976 !html.contains("style="),
1977 "style attribute must be stripped: {html}"
1978 );
1979 assert!(html.contains("<div"), "div tag dropped: {html}");
1981 }
1982
1983 #[test]
1986 fn test_toc_collects_headings_in_document_order() {
1987 let markdown = "\
1988# First
1989Some text.
1990
1991## Second-A
1992More text.
1993
1994## Second-B
1995
1996# Third\n";
1997
1998 let options = MarkdownOptions::new()
1999 .with_custom_blocks(false)
2000 .with_enhanced_tables(false);
2001
2002 let (_html, toc) =
2003 process_markdown_with_toc(markdown, &options).unwrap();
2004 let levels: Vec<u8> = toc.iter().map(|h| h.level).collect();
2005 let texts: Vec<&str> =
2006 toc.iter().map(|h| h.text.as_str()).collect();
2007 assert_eq!(levels, vec![1, 2, 2, 1]);
2008 assert_eq!(
2009 texts,
2010 vec!["First", "Second-A", "Second-B", "Third"]
2011 );
2012 }
2013
2014 #[test]
2015 fn test_toc_ids_match_rendered_html() {
2016 let markdown = "# Hello World\n\n## A Second Heading\n";
2019 let options = MarkdownOptions::new()
2020 .with_custom_blocks(false)
2021 .with_enhanced_tables(false)
2022 .with_header_ids("");
2023
2024 let (html, toc) =
2025 process_markdown_with_toc(markdown, &options).unwrap();
2026 assert_eq!(toc.len(), 2);
2027 for h in &toc {
2028 let needle = format!("id=\"{}\"", h.id);
2029 assert!(
2030 html.contains(&needle),
2031 "ToC id {:?} not found in HTML: {}",
2032 h.id,
2033 html
2034 );
2035 }
2036 }
2037
2038 #[test]
2039 fn test_toc_prefix_propagates() {
2040 let markdown = "# Intro\n";
2041 let options = MarkdownOptions::new()
2042 .with_custom_blocks(false)
2043 .with_enhanced_tables(false)
2044 .with_header_ids("user-content-");
2045
2046 let (html, toc) =
2047 process_markdown_with_toc(markdown, &options).unwrap();
2048 assert_eq!(toc.len(), 1);
2049 assert_eq!(toc[0].id, "user-content-intro");
2050 assert!(html.contains("id=\"user-content-intro\""));
2051 }
2052
2053 #[test]
2054 fn test_toc_dedup_with_repeated_headings() {
2055 let markdown = "# Notes\n## Notes\n### Notes\n";
2056 let options = MarkdownOptions::new()
2057 .with_custom_blocks(false)
2058 .with_enhanced_tables(false)
2059 .with_header_ids("");
2060
2061 let (_html, toc) =
2062 process_markdown_with_toc(markdown, &options).unwrap();
2063 let ids: Vec<&str> =
2064 toc.iter().map(|h| h.id.as_str()).collect();
2065 assert_eq!(ids, vec!["notes", "notes-1", "notes-2"]);
2066 }
2067
2068 #[test]
2069 fn test_toc_empty_document() {
2070 let options = MarkdownOptions::new()
2071 .with_custom_blocks(false)
2072 .with_enhanced_tables(false);
2073 let (html, toc) =
2074 process_markdown_with_toc("", &options).unwrap();
2075 assert!(toc.is_empty());
2076 assert!(html.trim().is_empty());
2077 }
2078
2079 #[test]
2080 fn test_toc_writer_variant_writes_html_and_returns_toc() {
2081 let markdown = "# Title\n\n## Sub\n";
2082 let options = MarkdownOptions::new()
2083 .with_custom_blocks(false)
2084 .with_enhanced_tables(false);
2085
2086 let mut buf: Vec<u8> = Vec::new();
2087 let toc = process_markdown_with_toc_to_writer(
2088 markdown, &mut buf, &options,
2089 )
2090 .unwrap();
2091 let html = String::from_utf8(buf).unwrap();
2092 assert!(html.contains("<h1>Title</h1>"));
2093 assert!(html.contains("<h2>Sub</h2>"));
2094 assert_eq!(toc.len(), 2);
2095 }
2096
2097 #[test]
2098 fn test_toc_extracts_inline_code_text() {
2099 let markdown = "# Using `&str` types\n";
2100 let options = MarkdownOptions::new()
2101 .with_custom_blocks(false)
2102 .with_enhanced_tables(false);
2103
2104 let (_html, toc) =
2105 process_markdown_with_toc(markdown, &options).unwrap();
2106 assert_eq!(toc.len(), 1);
2107 assert_eq!(toc[0].text, "Using &str types");
2108 }
2109
2110 #[test]
2111 fn test_sanitizer_config_default_path_unchanged() {
2112 let markdown = "<script>x</script>\n<div class=\"alert alert-info\">safe</div>";
2116 let options = MarkdownOptions::new()
2117 .with_custom_blocks(false)
2118 .with_enhanced_tables(false)
2119 .with_unsafe_html(false);
2120
2121 let html = process_markdown(markdown, &options).unwrap();
2122 assert!(!html.contains("<script>"));
2123 assert!(html.contains("alert alert-info"));
2124 }
2125
2126 #[test]
2129 fn test_diagrams_off_by_default_leaves_code_block() {
2130 let md = "```mermaid\ngraph TD\nA-->B\n```\n";
2131 let options = MarkdownOptions::new()
2132 .with_custom_blocks(false)
2133 .with_enhanced_tables(false);
2134 let html = process_markdown(md, &options).unwrap();
2135 assert!(html.contains("<code class=\"language-mermaid\">"));
2139 assert!(!html.contains("class=\"mermaid\""));
2140 }
2141
2142 #[test]
2143 fn test_diagrams_mermaid_survives_sanitizer() {
2144 let md = "```mermaid\ngraph TD\nA-->B\n```\n";
2145 let options = MarkdownOptions::new()
2146 .with_custom_blocks(false)
2147 .with_enhanced_tables(false)
2148 .with_syntax_highlighting(false)
2149 .with_diagrams(true)
2150 .with_unsafe_html(false);
2151 let html = process_markdown(md, &options).unwrap();
2152 assert!(
2153 html.contains("<pre class=\"mermaid\">"),
2154 "mermaid container stripped: {html}"
2155 );
2156 assert!(html.contains("graph TD"));
2157 }
2158
2159 #[test]
2160 fn test_diagrams_non_matching_lang_still_highlighted() {
2161 let md = "```python\nprint('hi')\n```\n";
2164 let options = MarkdownOptions::new()
2165 .with_custom_blocks(false)
2166 .with_enhanced_tables(false)
2167 .with_diagrams(true);
2168 let html = process_markdown(md, &options).unwrap();
2169 assert!(html.contains("<code class=\"language-python\">"));
2170 assert!(!html.contains("class=\"mermaid\""));
2171 }
2172
2173 #[test]
2174 fn test_diagrams_formerly_supported_langs_highlight_as_usual() {
2175 let md = "```geojson\n{\"type\":\"Feature\"}\n```\n\n```stl\nsolid x\nendsolid x\n```\n";
2183 let options = MarkdownOptions::new()
2184 .with_custom_blocks(false)
2185 .with_enhanced_tables(false)
2186 .with_diagrams(true);
2187 let html = process_markdown(md, &options).unwrap();
2188 assert!(!html.contains("class=\"mermaid\""));
2189 assert!(!html.contains("mdx-diagram"));
2190 }
2191
2192 #[test]
2195 fn test_plain_text_basic() {
2196 let md = "# Hello World\n\nA **bold** paragraph.";
2197 let text = process_markdown_to_plain_text(
2198 md,
2199 &MarkdownOptions::default(),
2200 )
2201 .unwrap();
2202 assert_eq!(text, "Hello World A bold paragraph.");
2203 }
2204
2205 #[test]
2206 fn test_plain_text_lists_and_code() {
2207 let md =
2208 "# Title\n\nDesc.\n\n- one\n- two\n\n```\nfn main() {}\n```";
2209 let text = process_markdown_to_plain_text(
2210 md,
2211 &MarkdownOptions::default(),
2212 )
2213 .unwrap();
2214 assert!(text.contains("Title"));
2215 assert!(text.contains("Desc."));
2216 assert!(text.contains("one"));
2217 assert!(text.contains("two"));
2218 assert!(text.contains("fn main() {}"));
2219 assert!(!text.contains("Titleone"));
2221 assert!(!text.contains("onetwo"));
2222 }
2223
2224 #[test]
2225 fn test_plain_text_strips_html() {
2226 let md = "A <strong>bold</strong> word";
2228 let text = process_markdown_to_plain_text(
2229 md,
2230 &MarkdownOptions::default(),
2231 )
2232 .unwrap();
2233 assert!(!text.contains("<strong>"));
2234 assert!(!text.contains("</strong>"));
2235 assert!(text.contains("bold"));
2236 }
2237
2238 #[test]
2239 fn test_plain_text_respects_input_cap() {
2240 let options = MarkdownOptions::new().with_max_input_size(8);
2241 let err =
2242 process_markdown_to_plain_text(&"a".repeat(64), &options)
2243 .unwrap_err();
2244 assert!(matches!(err, MarkdownError::InputTooLarge { .. }));
2245 }
2246
2247 #[test]
2248 fn test_plain_text_includes_inline_code() {
2249 let md = "Use `println!` to print, then `drop`.";
2253 let text = process_markdown_to_plain_text(
2254 md,
2255 &MarkdownOptions::default(),
2256 )
2257 .unwrap();
2258 assert!(
2259 text.contains("println!"),
2260 "expected inline code literal, got: {text:?}"
2261 );
2262 assert!(
2263 text.contains("drop"),
2264 "expected second inline code literal, got: {text:?}"
2265 );
2266 }
2267
2268 #[test]
2271 fn test_math_dollars_survives_sanitizer() {
2272 let mut comrak = Options::default();
2277 comrak.extension.math_dollars = true;
2278 let options = MarkdownOptions::new()
2279 .with_comrak_options(comrak)
2280 .with_custom_blocks(false)
2281 .with_enhanced_tables(false)
2282 .with_syntax_highlighting(false)
2283 .with_unsafe_html(false);
2284
2285 let html =
2286 process_markdown("Inline $a^2 + b^2$ math.", &options)
2287 .unwrap();
2288 assert!(
2289 html.contains("data-math-style"),
2290 "data-math-style attribute stripped by sanitizer: {html}"
2291 );
2292 }
2293
2294 #[test]
2295 fn test_footnote_link_survives_sanitizer() {
2296 let mut comrak = Options::default();
2300 comrak.extension.footnotes = true;
2301 let options = MarkdownOptions::new()
2302 .with_comrak_options(comrak)
2303 .with_custom_blocks(false)
2304 .with_enhanced_tables(false)
2305 .with_syntax_highlighting(false)
2306 .with_unsafe_html(false);
2307
2308 let md = "Claim[^1].\n\n[^1]: Reason.\n";
2309 let html = process_markdown(md, &options).unwrap();
2310 assert!(html.contains("<sup"), "missing <sup>: {html}");
2311 assert!(
2312 html.contains("href=\"#fn-1\"")
2313 || html.contains("href=\"#fn1\""),
2314 "missing forward link: {html}"
2315 );
2316 assert!(
2317 html.contains("href=\"#fnref-1\"")
2318 || html.contains("href=\"#fnref1\""),
2319 "missing back-reference link: {html}"
2320 );
2321 }
2322}