1use crate::config::MarkdownFlavor;
7use crate::lint_context::HtmlTag;
8use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
9use crate::utils::regex_cache::*;
10use std::collections::HashSet;
11use std::ops::Range;
12
13mod md033_config;
14use md033_config::{MD033Config, MD033FixMode, VOID_ELEMENTS, is_permitted_without_markdown_equivalent};
15
16#[derive(Clone)]
17pub struct MD033NoInlineHtml {
18 config: MD033Config,
19 allowed: HashSet<String>,
20 allowed_inside: HashSet<String>,
21 table_allowed: HashSet<String>,
22 disallowed: HashSet<String>,
23 drop_attributes: HashSet<String>,
24 strip_wrapper_elements: HashSet<String>,
25 allows_no_markdown_equivalent: bool,
26 table_allows_no_markdown_equivalent: bool,
27}
28
29impl Default for MD033NoInlineHtml {
30 fn default() -> Self {
31 Self::from_config_struct(MD033Config::default())
32 }
33}
34
35impl MD033NoInlineHtml {
36 pub fn new() -> Self {
37 Self::default()
38 }
39
40 pub fn with_allowed(allowed_vec: Vec<String>) -> Self {
41 Self::from_config_struct(MD033Config {
42 allowed: allowed_vec,
43 ..MD033Config::default()
44 })
45 }
46
47 pub fn with_disallowed(disallowed_vec: Vec<String>) -> Self {
48 Self::from_config_struct(MD033Config {
49 disallowed: disallowed_vec,
50 ..MD033Config::default()
51 })
52 }
53
54 pub fn with_fix(fix: bool) -> Self {
56 Self::from_config_struct(MD033Config {
57 fix,
58 ..MD033Config::default()
59 })
60 }
61
62 pub fn from_config_struct(config: MD033Config) -> Self {
65 let allowed = config.allowed_set();
66 let allowed_inside = config.allowed_inside_set();
67 let table_allowed = config.table_allowed_set();
68 let disallowed = config.disallowed_set();
69 let drop_attributes = config.drop_attributes_set();
70 let strip_wrapper_elements = config.strip_wrapper_elements_set();
71 let allows_no_markdown_equivalent = config.allows_no_markdown_equivalent();
72 let table_allows_no_markdown_equivalent = config.table_allows_no_markdown_equivalent();
73 Self {
74 config,
75 allowed,
76 allowed_inside,
77 table_allowed,
78 disallowed,
79 drop_attributes,
80 strip_wrapper_elements,
81 allows_no_markdown_equivalent,
82 table_allows_no_markdown_equivalent,
83 }
84 }
85
86 #[inline]
90 fn extract_tag_name(tag: &str) -> String {
91 let trimmed = tag.trim_start_matches('<').trim_start_matches('/');
92 trimmed
93 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
94 .next()
95 .unwrap_or("")
96 .to_lowercase()
97 }
98
99 #[inline]
102 fn tag_in_set(set: &HashSet<String>, tag: &str) -> bool {
103 if set.is_empty() {
104 return false;
105 }
106 set.contains(&Self::extract_tag_name(tag))
107 }
108
109 #[inline]
111 fn is_tag_allowed(&self, tag: &str, flavor: MarkdownFlavor) -> bool {
112 if !self.allows_no_markdown_equivalent {
113 return Self::tag_in_set(&self.allowed, tag);
114 }
115 let name = Self::extract_tag_name(tag);
116 self.allowed.contains(&name) || is_permitted_without_markdown_equivalent(&name, flavor, false)
117 }
118
119 #[inline]
122 fn is_tag_allowed_in_table(&self, tag: &str, flavor: MarkdownFlavor) -> bool {
123 if !self.table_allows_no_markdown_equivalent {
124 return Self::tag_in_set(&self.table_allowed, tag);
125 }
126 let name = Self::extract_tag_name(tag);
127 self.table_allowed.contains(&name) || is_permitted_without_markdown_equivalent(&name, flavor, true)
128 }
129
130 #[inline]
133 fn is_void_element(tag_name: &str) -> bool {
134 VOID_ELEMENTS.binary_search(&tag_name).is_ok()
135 }
136
137 fn is_inert_markup(ctx: &crate::lint_context::LintContext, html_tag: &HtmlTag) -> bool {
140 ctx.line_info(html_tag.line).is_some_and(|info| {
141 info.in_code_block
142 || info.in_pymdown_block
143 || info.is_kramdown_block_ial
144 || info.in_front_matter
145 || info.in_math_block
146 }) || ctx.is_in_html_comment(html_tag.byte_offset)
147 || ctx.is_in_mdx_comment(html_tag.byte_offset)
148 || ctx.is_in_link_title(html_tag.byte_offset)
149 || ctx.is_byte_offset_in_code_span(html_tag.byte_offset)
150 }
151
152 fn allowed_inside_ranges(&self, ctx: &crate::lint_context::LintContext) -> Vec<Range<usize>> {
158 if self.allowed_inside.is_empty() {
159 return Vec::new();
160 }
161
162 let html_tags = ctx.html_tags();
163 let mut ranges = Vec::new();
164 let mut open: Vec<(&str, usize)> = Vec::new();
165
166 for html_tag in html_tags.iter() {
167 if !self.allowed_inside.contains(&html_tag.tag_name) || Self::is_inert_markup(ctx, html_tag) {
168 continue;
169 }
170 if html_tag.is_closing {
171 if let Some(index) = open.iter().rposition(|(name, _)| *name == html_tag.tag_name) {
174 ranges.extend(open.drain(index..).map(|(_, start)| start..html_tag.byte_end));
175 }
176 } else if !html_tag.is_self_closing && !Self::is_void_element(&html_tag.tag_name) {
177 open.push((html_tag.tag_name.as_str(), html_tag.byte_offset));
178 }
179 }
180
181 ranges.extend(open.into_iter().map(|(_, start)| start..ctx.content.len()));
182 ranges
183 }
184
185 #[inline]
187 fn is_inside_allowed_element(ranges: &[Range<usize>], byte_offset: usize) -> bool {
188 ranges.iter().any(|range| range.contains(&byte_offset))
189 }
190
191 #[inline]
193 fn is_tag_disallowed(&self, tag: &str) -> bool {
194 Self::tag_in_set(&self.disallowed, tag)
195 }
196
197 #[inline]
199 fn is_disallowed_mode(&self) -> bool {
200 self.config.is_disallowed_mode()
201 }
202
203 #[inline]
205 fn is_html_comment(&self, tag: &str) -> bool {
206 tag.starts_with("<!--") && tag.ends_with("-->")
207 }
208
209 #[inline]
214 fn is_html_element_or_custom(tag_name: &str) -> bool {
215 const HTML_ELEMENTS: &[&str] = &[
217 "a",
218 "abbr",
219 "acronym",
220 "address",
221 "applet",
222 "area",
223 "article",
224 "aside",
225 "audio",
226 "b",
227 "base",
228 "basefont",
229 "bdi",
230 "bdo",
231 "big",
232 "blockquote",
233 "body",
234 "br",
235 "button",
236 "canvas",
237 "caption",
238 "center",
239 "cite",
240 "code",
241 "col",
242 "colgroup",
243 "data",
244 "datalist",
245 "dd",
246 "del",
247 "details",
248 "dfn",
249 "dialog",
250 "dir",
251 "div",
252 "dl",
253 "dt",
254 "em",
255 "embed",
256 "fieldset",
257 "figcaption",
258 "figure",
259 "font",
260 "footer",
261 "form",
262 "frame",
263 "frameset",
264 "h1",
265 "h2",
266 "h3",
267 "h4",
268 "h5",
269 "h6",
270 "head",
271 "header",
272 "hgroup",
273 "hr",
274 "html",
275 "i",
276 "iframe",
277 "img",
278 "input",
279 "ins",
280 "isindex",
281 "kbd",
282 "label",
283 "legend",
284 "li",
285 "link",
286 "main",
287 "map",
288 "mark",
289 "marquee",
290 "math",
291 "menu",
292 "meta",
293 "meter",
294 "nav",
295 "noembed",
296 "noframes",
297 "noscript",
298 "object",
299 "ol",
300 "optgroup",
301 "option",
302 "output",
303 "p",
304 "param",
305 "picture",
306 "plaintext",
307 "pre",
308 "progress",
309 "q",
310 "rp",
311 "rt",
312 "ruby",
313 "s",
314 "samp",
315 "script",
316 "search",
317 "section",
318 "select",
319 "slot",
320 "small",
321 "source",
322 "span",
323 "strike",
324 "strong",
325 "style",
326 "sub",
327 "summary",
328 "sup",
329 "svg",
330 "table",
331 "tbody",
332 "td",
333 "template",
334 "textarea",
335 "tfoot",
336 "th",
337 "thead",
338 "time",
339 "title",
340 "tr",
341 "track",
342 "tt",
343 "u",
344 "ul",
345 "var",
346 "video",
347 "wbr",
348 "xmp",
349 ];
350
351 let lower = tag_name.to_ascii_lowercase();
352 if HTML_ELEMENTS.binary_search(&lower.as_str()).is_ok() {
353 return true;
354 }
355 tag_name.contains('-')
357 }
358
359 #[inline]
361 fn is_likely_type_annotation(&self, tag: &str) -> bool {
362 const COMMON_TYPES: &[&str] = &[
364 "any",
365 "apiresponse",
366 "array",
367 "bigint",
368 "config",
369 "data",
370 "date",
371 "e",
372 "element",
373 "error",
374 "function",
375 "generator",
376 "item",
377 "iterator",
378 "k",
379 "map",
380 "node",
381 "null",
382 "number",
383 "options",
384 "params",
385 "promise",
386 "regexp",
387 "request",
388 "response",
389 "result",
390 "set",
391 "string",
392 "symbol",
393 "t",
394 "u",
395 "undefined",
396 "userdata",
397 "v",
398 "void",
399 "weakmap",
400 "weakset",
401 ];
402
403 let tag_content = tag
404 .trim_start_matches('<')
405 .trim_end_matches('>')
406 .trim_start_matches('/');
407 let tag_name = tag_content
408 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
409 .next()
410 .unwrap_or("");
411
412 if !tag_content.contains(' ') && !tag_content.contains('=') {
414 let lower = tag_name.to_ascii_lowercase();
415 COMMON_TYPES.binary_search(&lower.as_str()).is_ok()
416 } else {
417 false
418 }
419 }
420
421 #[inline]
423 fn is_email_address(&self, tag: &str) -> bool {
424 let content = tag.trim_start_matches('<').trim_end_matches('>');
425 content.contains('@')
427 && content.chars().all(|c| c.is_alphanumeric() || "@.-_+".contains(c))
428 && content.split('@').count() == 2
429 && content.split('@').all(|part| !part.is_empty())
430 }
431
432 #[inline]
434 fn has_markdown_attribute(&self, tag: &str) -> bool {
435 tag.contains(" markdown>") || tag.contains(" markdown=") || tag.contains(" markdown ")
438 }
439
440 #[inline]
447 fn has_jsx_attributes(tag: &str) -> bool {
448 tag.contains("className")
450 || tag.contains("htmlFor")
451 || tag.contains("dangerouslySetInnerHTML")
452 || tag.contains("onClick")
454 || tag.contains("onChange")
455 || tag.contains("onSubmit")
456 || tag.contains("onFocus")
457 || tag.contains("onBlur")
458 || tag.contains("onKeyDown")
459 || tag.contains("onKeyUp")
460 || tag.contains("onKeyPress")
461 || tag.contains("onMouseDown")
462 || tag.contains("onMouseUp")
463 || tag.contains("onMouseEnter")
464 || tag.contains("onMouseLeave")
465 || tag.contains("={")
467 }
468
469 #[inline]
471 fn is_url_in_angle_brackets(&self, tag: &str) -> bool {
472 let content = tag.trim_start_matches('<').trim_end_matches('>');
473 content.starts_with("http://")
475 || content.starts_with("https://")
476 || content.starts_with("ftp://")
477 || content.starts_with("ftps://")
478 || content.starts_with("mailto:")
479 }
480
481 #[inline]
482 fn is_relaxed_fix_mode(&self) -> bool {
483 self.config.fix_mode == MD033FixMode::Relaxed
484 }
485
486 #[inline]
487 fn is_droppable_attribute(&self, attr_name: &str) -> bool {
488 if attr_name.starts_with("on") && attr_name.len() > 2 {
491 return false;
492 }
493 self.drop_attributes.contains(attr_name)
494 || (attr_name.starts_with("data-")
495 && (self.drop_attributes.contains("data-*") || self.drop_attributes.contains("data-")))
496 }
497
498 #[inline]
499 fn is_strippable_wrapper(&self, tag_name: &str) -> bool {
500 self.is_relaxed_fix_mode() && self.strip_wrapper_elements.contains(tag_name)
501 }
502
503 fn is_inside_strippable_wrapper(&self, content: &str, byte_offset: usize) -> bool {
514 if byte_offset == 0 {
515 return false;
516 }
517 let before = content[..byte_offset].trim_end();
518 if !before.ends_with('>') || before.ends_with("->") {
519 return false;
520 }
521 if let Some(last_lt) = before.rfind('<') {
522 let potential_tag = &before[last_lt..];
523 if potential_tag.starts_with("</") || potential_tag.starts_with("<!--") {
524 return false;
525 }
526 let parent_name = potential_tag
527 .trim_start_matches('<')
528 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
529 .next()
530 .unwrap_or("")
531 .to_lowercase();
532 if !self.strip_wrapper_elements.contains(&parent_name) {
533 return false;
534 }
535 let wrapper_before = before[..last_lt].trim_end();
537 if wrapper_before.ends_with('>')
538 && !wrapper_before.ends_with("->")
539 && let Some(outer_lt) = wrapper_before.rfind('<')
540 && let outer_tag = &wrapper_before[outer_lt..]
541 && !outer_tag.starts_with("</")
542 && !outer_tag.starts_with("<!--")
543 {
544 return false;
545 }
546 return true;
547 }
548 false
549 }
550
551 fn convert_to_markdown(tag_name: &str, inner_content: &str) -> Option<String> {
554 if inner_content.contains('<') {
556 return None;
557 }
558 if inner_content.contains('&') && inner_content.contains(';') {
561 let has_entity = inner_content
563 .split('&')
564 .skip(1)
565 .any(|part| part.split(';').next().is_some_and(|e| !e.is_empty() && e.len() < 10));
566 if has_entity {
567 return None;
568 }
569 }
570 match tag_name {
571 "em" | "i" => Some(format!("*{inner_content}*")),
572 "strong" | "b" => Some(format!("**{inner_content}**")),
573 "code" => {
574 if inner_content.contains('`') {
576 Some(format!("`` {inner_content} ``"))
577 } else {
578 Some(format!("`{inner_content}`"))
579 }
580 }
581 _ => None,
582 }
583 }
584
585 fn convert_self_closing_to_markdown(&self, tag_name: &str, opening_tag: &str) -> Option<String> {
587 match tag_name {
588 "br" => match self.config.br_style {
589 md033_config::BrStyle::TrailingSpaces => Some(" \n".to_string()),
590 md033_config::BrStyle::Backslash => Some("\\\n".to_string()),
591 },
592 "hr" => Some("\n---\n".to_string()),
593 "img" => self.convert_img_to_markdown(opening_tag),
594 _ => None,
595 }
596 }
597
598 fn parse_attributes(tag: &str) -> Vec<(String, Option<String>)> {
601 let mut attrs = Vec::new();
602
603 let tag_content = tag.trim_start_matches('<').trim_end_matches('>').trim_end_matches('/');
605
606 let attr_start = tag_content
610 .char_indices()
611 .find(|(_, c)| c.is_whitespace())
612 .map_or(tag_content.len(), |(i, c)| i + c.len_utf8());
613
614 if attr_start >= tag_content.len() {
615 return attrs;
616 }
617
618 let attr_str = &tag_content[attr_start..];
619 let mut chars = attr_str.chars().peekable();
620
621 while chars.peek().is_some() {
622 while chars.peek().is_some_and(|c| c.is_whitespace()) {
624 chars.next();
625 }
626
627 if chars.peek().is_none() {
628 break;
629 }
630
631 let mut attr_name = String::new();
633 while let Some(&c) = chars.peek() {
634 if c.is_whitespace() || c == '=' || c == '>' || c == '/' {
635 break;
636 }
637 attr_name.push(c);
638 chars.next();
639 }
640
641 if attr_name.is_empty() {
642 break;
643 }
644
645 while chars.peek().is_some_and(|c| c.is_whitespace()) {
647 chars.next();
648 }
649
650 if chars.peek() == Some(&'=') {
652 chars.next(); while chars.peek().is_some_and(|c| c.is_whitespace()) {
656 chars.next();
657 }
658
659 let mut value = String::new();
661 if let Some("e) = chars.peek() {
662 if quote == '"' || quote == '\'' {
663 chars.next(); for c in chars.by_ref() {
665 if c == quote {
666 break;
667 }
668 value.push(c);
669 }
670 } else {
671 while let Some(&c) = chars.peek() {
673 if c.is_whitespace() || c == '>' || c == '/' {
674 break;
675 }
676 value.push(c);
677 chars.next();
678 }
679 }
680 }
681 attrs.push((attr_name.to_ascii_lowercase(), Some(value)));
682 } else {
683 attrs.push((attr_name.to_ascii_lowercase(), None));
685 }
686 }
687
688 attrs
689 }
690
691 fn extract_attribute(tag: &str, attr_name: &str) -> Option<String> {
695 let attrs = Self::parse_attributes(tag);
696 let attr_lower = attr_name.to_ascii_lowercase();
697
698 attrs
699 .into_iter()
700 .find(|(name, _)| name == &attr_lower)
701 .and_then(|(_, value)| value)
702 }
703
704 fn has_extra_attributes(&self, tag: &str, allowed_attrs: &[&str]) -> bool {
707 let attrs = Self::parse_attributes(tag);
708
709 const DANGEROUS_ATTR_PREFIXES: &[&str] = &["on"]; const DANGEROUS_ATTRS: &[&str] = &[
713 "class",
714 "id",
715 "style",
716 "target",
717 "rel",
718 "download",
719 "referrerpolicy",
720 "crossorigin",
721 "loading",
722 "decoding",
723 "fetchpriority",
724 "sizes",
725 "srcset",
726 "usemap",
727 "ismap",
728 "width",
729 "height",
730 "name", "data-*", ];
733
734 for (attr_name, _) in attrs {
735 if allowed_attrs.iter().any(|a| a.to_ascii_lowercase() == attr_name) {
737 continue;
738 }
739
740 if self.is_relaxed_fix_mode() {
741 if self.is_droppable_attribute(&attr_name) {
742 continue;
743 }
744 return true;
745 }
746
747 for prefix in DANGEROUS_ATTR_PREFIXES {
749 if attr_name.starts_with(prefix) && attr_name.len() > prefix.len() {
750 return true;
751 }
752 }
753
754 if attr_name.starts_with("data-") {
756 return true;
757 }
758
759 if DANGEROUS_ATTRS.contains(&attr_name.as_str()) {
761 return true;
762 }
763 }
764
765 false
766 }
767
768 fn convert_a_to_markdown(&self, opening_tag: &str, inner_content: &str) -> Option<String> {
771 let href = Self::extract_attribute(opening_tag, "href")?;
773
774 if !MD033Config::is_safe_url(&href) {
776 return None;
777 }
778
779 if inner_content.contains('<') {
781 return None;
782 }
783
784 if inner_content.contains('&') && inner_content.contains(';') {
786 let has_entity = inner_content
787 .split('&')
788 .skip(1)
789 .any(|part| part.split(';').next().is_some_and(|e| !e.is_empty() && e.len() < 10));
790 if has_entity {
791 return None;
792 }
793 }
794
795 let title = Self::extract_attribute(opening_tag, "title");
797
798 if self.has_extra_attributes(opening_tag, &["href", "title"]) {
800 return None;
801 }
802
803 let trimmed_inner = inner_content.trim();
808 let is_markdown_image =
809 trimmed_inner.starts_with(" && trimmed_inner.ends_with(')') && {
810 if let Some(bracket_close) = trimmed_inner.rfind("](") {
813 let after_paren = &trimmed_inner[bracket_close + 2..];
814 after_paren.ends_with(')')
816 && after_paren.chars().filter(|&c| c == ')').count()
817 >= after_paren.chars().filter(|&c| c == '(').count()
818 } else {
819 false
820 }
821 };
822 let escaped_text = if is_markdown_image {
823 trimmed_inner.to_string()
824 } else {
825 inner_content.replace('[', r"\[").replace(']', r"\]")
828 };
829
830 let escaped_url = href.replace('(', "%28").replace(')', "%29");
832
833 if let Some(title_text) = title {
835 let escaped_title = title_text.replace('"', r#"\""#);
837 Some(format!("[{escaped_text}]({escaped_url} \"{escaped_title}\")"))
838 } else {
839 Some(format!("[{escaped_text}]({escaped_url})"))
840 }
841 }
842
843 fn convert_img_to_markdown(&self, tag: &str) -> Option<String> {
846 let src = Self::extract_attribute(tag, "src")?;
848
849 if !MD033Config::is_safe_url(&src) {
851 return None;
852 }
853
854 let alt = Self::extract_attribute(tag, "alt").unwrap_or_default();
856
857 let title = Self::extract_attribute(tag, "title");
859
860 if self.has_extra_attributes(tag, &["src", "alt", "title"]) {
862 return None;
863 }
864
865 let escaped_alt = alt.replace('[', r"\[").replace(']', r"\]");
867
868 let escaped_url = src.replace('(', "%28").replace(')', "%29");
870
871 if let Some(title_text) = title {
873 let escaped_title = title_text.replace('"', r#"\""#);
875 Some(format!(""))
876 } else {
877 Some(format!(""))
878 }
879 }
880
881 fn has_significant_attributes(opening_tag: &str) -> bool {
883 let tag_content = opening_tag
885 .trim_start_matches('<')
886 .trim_end_matches('>')
887 .trim_end_matches('/');
888
889 let parts: Vec<&str> = tag_content.split_whitespace().collect();
891 parts.len() > 1
892 }
893
894 fn is_nested_in_html(content: &str, tag_byte_start: usize, tag_byte_end: usize) -> bool {
897 if tag_byte_start > 0 {
899 let before = &content[..tag_byte_start];
900 let before_trimmed = before.trim_end();
901 if before_trimmed.ends_with('>') && !before_trimmed.ends_with("->") {
902 if let Some(last_lt) = before_trimmed.rfind('<') {
904 let potential_tag = &before_trimmed[last_lt..];
905 if !potential_tag.starts_with("</") && !potential_tag.starts_with("<!--") {
907 return true;
908 }
909 }
910 }
911 }
912 if tag_byte_end < content.len() {
914 let after = &content[tag_byte_end..];
915 let after_trimmed = after.trim_start();
916 if after_trimmed.starts_with("</") {
917 return true;
918 }
919 }
920 false
921 }
922
923 fn calculate_fix(
938 &self,
939 content: &str,
940 opening_tag: &str,
941 tag_byte_start: usize,
942 in_html_block: bool,
943 ) -> Option<(std::ops::Range<usize>, String)> {
944 let tag_name = opening_tag
946 .trim_start_matches('<')
947 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
948 .next()?
949 .to_lowercase();
950
951 let is_self_closing =
953 opening_tag.ends_with("/>") || matches!(tag_name.as_str(), "br" | "hr" | "img" | "input" | "meta" | "link");
954
955 if is_self_closing {
956 let block_ok = !in_html_block
962 || (self.is_relaxed_fix_mode() && self.is_inside_strippable_wrapper(content, tag_byte_start));
963 if self.config.fix
964 && MD033Config::is_safe_fixable_tag(&tag_name)
965 && block_ok
966 && let Some(markdown) = self.convert_self_closing_to_markdown(&tag_name, opening_tag)
967 {
968 return Some((tag_byte_start..tag_byte_start + opening_tag.len(), markdown));
969 }
970 return None;
973 }
974
975 let search_start = tag_byte_start + opening_tag.len();
977 let search_slice = &content[search_start..];
978
979 let closing_tag_lower = format!("</{tag_name}>");
981 let closing_pos = search_slice.to_ascii_lowercase().find(&closing_tag_lower);
982
983 if let Some(closing_pos) = closing_pos {
984 let closing_tag_len = closing_tag_lower.len();
986 let closing_byte_start = search_start + closing_pos;
987 let closing_byte_end = closing_byte_start + closing_tag_len;
988
989 let inner_content = &content[search_start..closing_byte_start];
991
992 if self.config.fix && self.is_strippable_wrapper(&tag_name) {
999 if Self::is_nested_in_html(content, tag_byte_start, closing_byte_end) {
1000 return None;
1001 }
1002 if inner_content.contains('<') {
1003 return None;
1004 }
1005 return Some((tag_byte_start..closing_byte_end, inner_content.trim().to_string()));
1006 }
1007
1008 if in_html_block {
1011 return None;
1012 }
1013
1014 if Self::is_nested_in_html(content, tag_byte_start, closing_byte_end) {
1017 return None;
1018 }
1019
1020 if self.config.fix && MD033Config::is_safe_fixable_tag(&tag_name) {
1022 if tag_name == "a" {
1024 if let Some(markdown) = self.convert_a_to_markdown(opening_tag, inner_content) {
1025 return Some((tag_byte_start..closing_byte_end, markdown));
1026 }
1027 return None;
1029 }
1030
1031 if Self::has_significant_attributes(opening_tag) {
1033 return None;
1036 }
1037 if let Some(markdown) = Self::convert_to_markdown(&tag_name, inner_content) {
1038 return Some((tag_byte_start..closing_byte_end, markdown));
1039 }
1040 return None;
1043 }
1044
1045 return None;
1048 }
1049
1050 None
1052 }
1053}
1054
1055impl Rule for MD033NoInlineHtml {
1056 fn name(&self) -> &'static str {
1057 "MD033"
1058 }
1059
1060 fn description(&self) -> &'static str {
1061 "Inline HTML is not allowed"
1062 }
1063
1064 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1065 let content = ctx.content;
1066
1067 if content.is_empty() || !ctx.likely_has_html() {
1069 return Ok(Vec::new());
1070 }
1071
1072 if !HTML_TAG_QUICK_CHECK.is_match(content) {
1074 return Ok(Vec::new());
1075 }
1076
1077 let mut warnings = Vec::new();
1078
1079 let html_tags = ctx.html_tags();
1081
1082 let allowed_inside_ranges = if self.is_disallowed_mode() {
1084 Vec::new()
1085 } else {
1086 self.allowed_inside_ranges(ctx)
1087 };
1088
1089 for html_tag in html_tags.iter() {
1090 if html_tag.is_closing {
1092 continue;
1093 }
1094
1095 let line_num = html_tag.line;
1096 let tag_byte_start = html_tag.byte_offset;
1097
1098 let tag = &content[html_tag.byte_offset..html_tag.byte_end];
1100
1101 if Self::is_inert_markup(ctx, html_tag) {
1105 continue;
1106 }
1107
1108 if self.is_html_comment(tag) {
1110 continue;
1111 }
1112
1113 if ctx.flavor.supports_jsx() && html_tag.tag_name.chars().next().is_some_and(char::is_uppercase) {
1115 continue;
1116 }
1117
1118 if ctx.flavor.supports_jsx() && (html_tag.tag_name.is_empty() || tag == "<>" || tag == "</>") {
1120 continue;
1121 }
1122
1123 if ctx.flavor.supports_jsx() && Self::has_jsx_attributes(tag) {
1126 continue;
1127 }
1128
1129 if !Self::is_html_element_or_custom(&html_tag.tag_name) {
1131 continue;
1132 }
1133
1134 if self.is_likely_type_annotation(tag) {
1136 continue;
1137 }
1138
1139 if self.is_email_address(tag) {
1141 continue;
1142 }
1143
1144 if self.is_url_in_angle_brackets(tag) {
1146 continue;
1147 }
1148
1149 if self.is_disallowed_mode() {
1155 if !self.is_tag_disallowed(tag) {
1156 continue;
1157 }
1158 } else if ctx.is_in_table_block(line_num) {
1159 let inside_allowed_element = self.config.table_allowed_elements.is_none()
1162 && Self::is_inside_allowed_element(&allowed_inside_ranges, tag_byte_start);
1163 if self.is_tag_allowed_in_table(tag, ctx.flavor) || inside_allowed_element {
1164 continue;
1165 }
1166 } else if self.is_tag_allowed(tag, ctx.flavor)
1167 || Self::is_inside_allowed_element(&allowed_inside_ranges, tag_byte_start)
1168 {
1169 continue;
1170 }
1171
1172 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && self.has_markdown_attribute(tag) {
1174 continue;
1175 }
1176
1177 let in_html_block = ctx.is_in_html_block(line_num);
1179
1180 let fix = self
1182 .calculate_fix(content, tag, tag_byte_start, in_html_block)
1183 .map(|(range, replacement)| Fix::new(range, replacement));
1184
1185 let (end_line, end_col) = if html_tag.byte_end > 0 {
1188 ctx.offset_to_line_col(html_tag.byte_end - 1)
1189 } else {
1190 (line_num, html_tag.end_col + 1)
1191 };
1192
1193 warnings.push(LintWarning {
1195 rule_name: Some(self.name().to_string()),
1196 line: line_num,
1197 column: html_tag.start_col + 1, end_line, end_column: end_col + 1, message: format!("Inline HTML found: {tag}"),
1201 severity: Severity::Warning,
1202 fix,
1203 });
1204 }
1205
1206 Ok(warnings)
1207 }
1208
1209 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1210 if !self.config.fix {
1212 return Ok(ctx.content.to_string());
1213 }
1214
1215 let warnings = self.check(ctx)?;
1217 let warnings =
1218 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1219
1220 if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
1222 return Ok(ctx.content.to_string());
1223 }
1224
1225 let mut fixes: Vec<_> = warnings
1227 .iter()
1228 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
1229 .collect();
1230 fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
1231
1232 let mut result = ctx.content.to_string();
1234 for (start, end, replacement) in fixes {
1235 if start < result.len() && end <= result.len() && start <= end {
1236 result.replace_range(start..end, replacement);
1237 }
1238 }
1239
1240 Ok(result)
1241 }
1242
1243 fn fix_capability(&self) -> crate::rule::FixCapability {
1244 if self.config.fix {
1245 crate::rule::FixCapability::FullyFixable
1246 } else {
1247 crate::rule::FixCapability::Unfixable
1248 }
1249 }
1250
1251 fn category(&self) -> RuleCategory {
1253 RuleCategory::Html
1254 }
1255
1256 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1258 ctx.content.is_empty() || !ctx.likely_has_html()
1259 }
1260
1261 fn as_any(&self) -> &dyn std::any::Any {
1262 self
1263 }
1264
1265 crate::impl_rule_config_methods!(MD033Config, nullable);
1266
1267 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
1268 let mut aliases = std::collections::HashMap::new();
1269 aliases.insert("allowed".to_string(), "allowed-elements".to_string());
1271 aliases.insert("disallowed".to_string(), "disallowed-elements".to_string());
1272 aliases.insert("table-allowed".to_string(), "table-allowed-elements".to_string());
1275 aliases.insert("table_allowed".to_string(), "table-allowed-elements".to_string());
1276 Some(aliases)
1277 }
1278}
1279
1280#[cfg(test)]
1281mod tests {
1282 use super::*;
1283 use crate::lint_context::LintContext;
1284 use crate::rule::Rule;
1285
1286 fn relaxed_fix_rule() -> MD033NoInlineHtml {
1287 let config = MD033Config {
1288 fix: true,
1289 fix_mode: MD033FixMode::Relaxed,
1290 ..MD033Config::default()
1291 };
1292 MD033NoInlineHtml::from_config_struct(config)
1293 }
1294
1295 #[test]
1296 fn test_md033_basic_html() {
1297 let rule = MD033NoInlineHtml::default();
1298 let content = "<div>Some content</div>";
1299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1300 let result = rule.check(&ctx).unwrap();
1301 assert_eq!(result.len(), 1); assert!(result[0].message.starts_with("Inline HTML found: <div>"));
1304 }
1305
1306 #[test]
1307 fn test_md033_front_matter() {
1308 let rule = MD033NoInlineHtml::default();
1309 let content = "---\ndescription: <div class=\"test\">hello</div>\n---\n# Title\n<div>body</div>";
1310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1311 let result = rule.check(&ctx).unwrap();
1312 assert_eq!(result.len(), 1);
1314 assert_eq!(result[0].line, 5);
1315 assert_eq!(result[0].message, "Inline HTML found: <div>");
1316 }
1317
1318 #[test]
1319 fn test_md033_math_block() {
1320 let rule = MD033NoInlineHtml::default();
1321 let content = "$$\nx < y && y > z\n$$\n<div>body</div>";
1322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1323 let result = rule.check(&ctx).unwrap();
1324 assert_eq!(result.len(), 1);
1326 assert_eq!(result[0].line, 4);
1327 }
1328
1329 #[test]
1330 fn test_md033_case_insensitive() {
1331 let rule = MD033NoInlineHtml::default();
1332 let content = "<DiV>Some <B>content</B></dIv>";
1333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1334 let result = rule.check(&ctx).unwrap();
1335 assert_eq!(result.len(), 2); assert_eq!(result[0].message, "Inline HTML found: <DiV>");
1338 assert_eq!(result[1].message, "Inline HTML found: <B>");
1339 }
1340
1341 #[test]
1342 fn test_md033_multibyte_whitespace_in_tag_does_not_panic() {
1343 let rule = relaxed_fix_rule();
1346 let content = "<img\u{00A0}src=\"test.png\" alt=\"x\">";
1347 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1348 let _ = rule.check(&ctx).unwrap();
1350 let _ = rule.fix(&ctx).unwrap();
1351 }
1352
1353 #[test]
1354 fn test_md033_allowed_tags() {
1355 let rule = MD033NoInlineHtml::with_allowed(vec!["div".to_string(), "br".to_string()]);
1356 let content = "<div>Allowed</div><p>Not allowed</p><br/>";
1357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1358 let result = rule.check(&ctx).unwrap();
1359 assert_eq!(result.len(), 1);
1361 assert_eq!(result[0].message, "Inline HTML found: <p>");
1362
1363 let content2 = "<DIV>Allowed</DIV><P>Not allowed</P><BR/>";
1365 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1366 let result2 = rule.check(&ctx2).unwrap();
1367 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <P>");
1369 }
1370
1371 #[test]
1372 fn test_md033_html_comments() {
1373 let rule = MD033NoInlineHtml::default();
1374 let content = "<!-- This is a comment --> <p>Not a comment</p>";
1375 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1376 let result = rule.check(&ctx).unwrap();
1377 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <p>");
1380 }
1381
1382 #[test]
1383 fn test_md033_tags_in_links() {
1384 let rule = MD033NoInlineHtml::default();
1385 let content = "[Link](http://example.com/<div>)";
1386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1387 let result = rule.check(&ctx).unwrap();
1388 assert_eq!(result.len(), 1);
1390 assert_eq!(result[0].message, "Inline HTML found: <div>");
1391
1392 let content2 = "[Link <a>text</a>](url)";
1393 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1394 let result2 = rule.check(&ctx2).unwrap();
1395 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <a>");
1398 }
1399
1400 #[test]
1401 fn test_md033_fix_escaping() {
1402 let rule = MD033NoInlineHtml::default();
1403 let content = "Text with <div> and <br/> tags.";
1404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1405 let fixed_content = rule.fix(&ctx).unwrap();
1406 assert_eq!(fixed_content, content);
1408 }
1409
1410 #[test]
1411 fn test_md033_in_code_blocks() {
1412 let rule = MD033NoInlineHtml::default();
1413 let content = "```html\n<div>Code</div>\n```\n<div>Not code</div>";
1414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1415 let result = rule.check(&ctx).unwrap();
1416 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <div>");
1419 }
1420
1421 #[test]
1422 fn test_md033_in_code_spans() {
1423 let rule = MD033NoInlineHtml::default();
1424 let content = "Text with `<p>in code</p>` span. <br/> Not in span.";
1425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1426 let result = rule.check(&ctx).unwrap();
1427 assert_eq!(result.len(), 1);
1429 assert_eq!(result[0].message, "Inline HTML found: <br/>");
1430 }
1431
1432 #[test]
1433 fn test_md033_issue_90_code_span_with_diff_block() {
1434 let rule = MD033NoInlineHtml::default();
1436 let content = r#"# Heading
1437
1438`<env>`
1439
1440```diff
1441- this
1442+ that
1443```"#;
1444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1445 let result = rule.check(&ctx).unwrap();
1446 assert_eq!(result.len(), 0, "Should not report HTML tags inside code spans");
1448 }
1449
1450 #[test]
1451 fn test_md033_multiple_code_spans_with_angle_brackets() {
1452 let rule = MD033NoInlineHtml::default();
1454 let content = "`<one>` and `<two>` and `<three>` are all code spans";
1455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1456 let result = rule.check(&ctx).unwrap();
1457 assert_eq!(result.len(), 0, "Should not report HTML tags inside any code spans");
1458 }
1459
1460 #[test]
1461 fn test_md033_nested_angle_brackets_in_code_span() {
1462 let rule = MD033NoInlineHtml::default();
1464 let content = "Text with `<<nested>>` brackets";
1465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1466 let result = rule.check(&ctx).unwrap();
1467 assert_eq!(result.len(), 0, "Should handle nested angle brackets in code spans");
1468 }
1469
1470 #[test]
1471 fn test_md033_code_span_at_end_before_code_block() {
1472 let rule = MD033NoInlineHtml::default();
1474 let content = "Testing `<test>`\n```\ncode here\n```";
1475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476 let result = rule.check(&ctx).unwrap();
1477 assert_eq!(result.len(), 0, "Should handle code span before code block");
1478 }
1479
1480 #[test]
1481 fn test_md033_quick_fix_inline_tag() {
1482 let rule = MD033NoInlineHtml::default();
1485 let content = "This has <span>inline text</span> that should keep content.";
1486 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1487 let result = rule.check(&ctx).unwrap();
1488
1489 assert_eq!(result.len(), 1, "Should find one HTML tag");
1490 assert!(
1492 result[0].fix.is_none(),
1493 "Non-fixable tags like <span> should not have a fix"
1494 );
1495 }
1496
1497 #[test]
1498 fn test_md033_quick_fix_multiline_tag() {
1499 let rule = MD033NoInlineHtml::default();
1502 let content = "<div>\nBlock content\n</div>";
1503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1504 let result = rule.check(&ctx).unwrap();
1505
1506 assert_eq!(result.len(), 1, "Should find one HTML tag");
1507 assert!(result[0].fix.is_none(), "HTML block elements should NOT have auto-fix");
1509 }
1510
1511 #[test]
1512 fn test_md033_quick_fix_self_closing_tag() {
1513 let rule = MD033NoInlineHtml::default();
1515 let content = "Self-closing: <br/>";
1516 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1517 let result = rule.check(&ctx).unwrap();
1518
1519 assert_eq!(result.len(), 1, "Should find one HTML tag");
1520 assert!(
1522 result[0].fix.is_none(),
1523 "Self-closing tags should not have a fix when fix config is false"
1524 );
1525 }
1526
1527 #[test]
1528 fn test_md033_quick_fix_multiple_tags() {
1529 let rule = MD033NoInlineHtml::default();
1532 let content = "<span>first</span> and <strong>second</strong>";
1533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1534 let result = rule.check(&ctx).unwrap();
1535
1536 assert_eq!(result.len(), 2, "Should find two HTML tags");
1537 assert!(result[0].fix.is_none(), "Non-fixable <span> should not have a fix");
1539 assert!(
1540 result[1].fix.is_none(),
1541 "<strong> should not have a fix when fix config is false"
1542 );
1543 }
1544
1545 #[test]
1546 fn test_md033_skip_angle_brackets_in_link_titles() {
1547 let rule = MD033NoInlineHtml::default();
1549 let content = r#"# Test
1550
1551[example]: <https://example.com> "Title with <Angle Brackets> inside"
1552
1553Regular text with <div>content</div> HTML tag.
1554"#;
1555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1556 let result = rule.check(&ctx).unwrap();
1557
1558 assert_eq!(result.len(), 1, "Should find opening div tag");
1561 assert!(
1562 result[0].message.contains("<div>"),
1563 "Should flag <div>, got: {}",
1564 result[0].message
1565 );
1566 }
1567
1568 #[test]
1569 fn test_md033_skip_angle_brackets_in_link_title_single_quotes() {
1570 let rule = MD033NoInlineHtml::default();
1572 let content = r#"[ref]: url 'Title <Help Wanted> here'
1573
1574<span>text</span> here
1575"#;
1576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1577 let result = rule.check(&ctx).unwrap();
1578
1579 assert_eq!(result.len(), 1, "Should find opening span tag");
1582 assert!(
1583 result[0].message.contains("<span>"),
1584 "Should flag <span>, got: {}",
1585 result[0].message
1586 );
1587 }
1588
1589 #[test]
1590 fn test_md033_multiline_tag_end_line_calculation() {
1591 let rule = MD033NoInlineHtml::default();
1593 let content = "<div\n class=\"test\"\n id=\"example\">";
1594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1595 let result = rule.check(&ctx).unwrap();
1596
1597 assert_eq!(result.len(), 1, "Should find one HTML tag");
1598 assert_eq!(result[0].line, 1, "Start line should be 1");
1600 assert_eq!(result[0].end_line, 3, "End line should be 3");
1602 }
1603
1604 #[test]
1605 fn test_md033_single_line_tag_same_start_end_line() {
1606 let rule = MD033NoInlineHtml::default();
1608 let content = "Some text <div class=\"test\"> more text";
1609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1610 let result = rule.check(&ctx).unwrap();
1611
1612 assert_eq!(result.len(), 1, "Should find one HTML tag");
1613 assert_eq!(result[0].line, 1, "Start line should be 1");
1614 assert_eq!(result[0].end_line, 1, "End line should be 1 for single-line tag");
1615 }
1616
1617 #[test]
1618 fn test_md033_multiline_tag_with_many_attributes() {
1619 let rule = MD033NoInlineHtml::default();
1621 let content =
1622 "Text\n<div\n data-attr1=\"value1\"\n data-attr2=\"value2\"\n data-attr3=\"value3\">\nMore text";
1623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1624 let result = rule.check(&ctx).unwrap();
1625
1626 assert_eq!(result.len(), 1, "Should find one HTML tag");
1627 assert_eq!(result[0].line, 2, "Start line should be 2");
1629 assert_eq!(result[0].end_line, 5, "End line should be 5");
1631 }
1632
1633 #[test]
1634 fn test_md033_disallowed_mode_basic() {
1635 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string(), "iframe".to_string()]);
1637 let content = "<div>Safe content</div><script>alert('xss')</script>";
1638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639 let result = rule.check(&ctx).unwrap();
1640
1641 assert_eq!(result.len(), 1, "Should only flag disallowed tags");
1643 assert!(result[0].message.contains("<script>"), "Should flag script tag");
1644 }
1645
1646 #[test]
1647 fn test_md033_disallowed_gfm_security_tags() {
1648 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1650 let content = r#"
1651<div>Safe</div>
1652<title>Bad title</title>
1653<textarea>Bad textarea</textarea>
1654<style>.bad{}</style>
1655<iframe src="evil"></iframe>
1656<script>evil()</script>
1657<plaintext>old tag</plaintext>
1658<span>Safe span</span>
1659"#;
1660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1661 let result = rule.check(&ctx).unwrap();
1662
1663 assert_eq!(result.len(), 6, "Should flag 6 GFM security tags");
1666
1667 let flagged_tags: Vec<&str> = result
1668 .iter()
1669 .filter_map(|w| w.message.split('<').nth(1))
1670 .filter_map(|s| s.split('>').next())
1671 .filter_map(|s| s.split_whitespace().next())
1672 .collect();
1673
1674 assert!(flagged_tags.contains(&"title"), "Should flag title");
1675 assert!(flagged_tags.contains(&"textarea"), "Should flag textarea");
1676 assert!(flagged_tags.contains(&"style"), "Should flag style");
1677 assert!(flagged_tags.contains(&"iframe"), "Should flag iframe");
1678 assert!(flagged_tags.contains(&"script"), "Should flag script");
1679 assert!(flagged_tags.contains(&"plaintext"), "Should flag plaintext");
1680 assert!(!flagged_tags.contains(&"div"), "Should NOT flag div");
1681 assert!(!flagged_tags.contains(&"span"), "Should NOT flag span");
1682 }
1683
1684 #[test]
1685 fn test_md033_disallowed_case_insensitive() {
1686 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string()]);
1688 let content = "<SCRIPT>alert('xss')</SCRIPT><Script>alert('xss')</Script>";
1689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1690 let result = rule.check(&ctx).unwrap();
1691
1692 assert_eq!(result.len(), 2, "Should flag both case variants");
1694 }
1695
1696 #[test]
1697 fn test_md033_disallowed_with_attributes() {
1698 let rule = MD033NoInlineHtml::with_disallowed(vec!["iframe".to_string()]);
1700 let content = r#"<iframe src="https://evil.com" width="100" height="100"></iframe>"#;
1701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1702 let result = rule.check(&ctx).unwrap();
1703
1704 assert_eq!(result.len(), 1, "Should flag iframe with attributes");
1705 assert!(result[0].message.contains("iframe"), "Should flag iframe");
1706 }
1707
1708 #[test]
1709 fn test_md033_disallowed_all_gfm_tags() {
1710 use md033_config::GFM_DISALLOWED_TAGS;
1712 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1713
1714 for tag in GFM_DISALLOWED_TAGS {
1715 let content = format!("<{tag}>content</{tag}>");
1716 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1717 let result = rule.check(&ctx).unwrap();
1718
1719 assert_eq!(result.len(), 1, "GFM tag <{tag}> should be flagged");
1720 }
1721 }
1722
1723 #[test]
1724 fn test_md033_disallowed_mixed_with_custom() {
1725 let rule = MD033NoInlineHtml::with_disallowed(vec![
1727 "gfm".to_string(),
1728 "marquee".to_string(), ]);
1730 let content = r#"<script>bad</script><marquee>annoying</marquee><div>ok</div>"#;
1731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1732 let result = rule.check(&ctx).unwrap();
1733
1734 assert_eq!(result.len(), 2, "Should flag both gfm and custom tags");
1736 }
1737
1738 #[test]
1739 fn test_md033_disallowed_empty_means_default_mode() {
1740 let rule = MD033NoInlineHtml::with_disallowed(vec![]);
1742 let content = "<div>content</div>";
1743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1744 let result = rule.check(&ctx).unwrap();
1745
1746 assert_eq!(result.len(), 1, "Empty disallowed = default mode");
1748 }
1749
1750 #[test]
1751 fn test_md033_jsx_fragments_in_mdx() {
1752 let rule = MD033NoInlineHtml::default();
1754 let content = r#"# MDX Document
1755
1756<>
1757 <Heading />
1758 <Content />
1759</>
1760
1761<div>Regular HTML should still be flagged</div>
1762"#;
1763 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1764 let result = rule.check(&ctx).unwrap();
1765
1766 assert_eq!(result.len(), 1, "Should only find one HTML tag (the div)");
1768 assert!(
1769 result[0].message.contains("<div>"),
1770 "Should flag <div>, not JSX fragments"
1771 );
1772 }
1773
1774 #[test]
1775 fn test_md033_jsx_components_in_mdx() {
1776 let rule = MD033NoInlineHtml::default();
1778 let content = r#"<CustomComponent prop="value">
1779 Content
1780</CustomComponent>
1781
1782<MyButton onClick={handler}>Click</MyButton>
1783"#;
1784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1785 let result = rule.check(&ctx).unwrap();
1786
1787 assert_eq!(result.len(), 0, "Should not flag JSX components in MDX");
1789 }
1790
1791 #[test]
1792 fn test_md033_jsx_not_skipped_in_standard_markdown() {
1793 let rule = MD033NoInlineHtml::default();
1795 let content = "<Script>alert(1)</Script>";
1796 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1797 let result = rule.check(&ctx).unwrap();
1798
1799 assert_eq!(result.len(), 1, "Should flag <Script> in standard markdown");
1801 }
1802
1803 #[test]
1804 fn test_md033_jsx_attributes_in_mdx() {
1805 let rule = MD033NoInlineHtml::default();
1807 let content = r#"# MDX with JSX Attributes
1808
1809<div className="card big">Content</div>
1810
1811<button onClick={handleClick}>Click me</button>
1812
1813<label htmlFor="input-id">Label</label>
1814
1815<input onChange={handleChange} />
1816
1817<div class="html-class">Regular HTML should be flagged</div>
1818"#;
1819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1820 let result = rule.check(&ctx).unwrap();
1821
1822 assert_eq!(
1824 result.len(),
1825 1,
1826 "Should only flag HTML element without JSX attributes, got: {result:?}"
1827 );
1828 assert!(
1829 result[0].message.contains("<div class="),
1830 "Should flag the div with HTML class attribute"
1831 );
1832 }
1833
1834 #[test]
1835 fn test_md033_jsx_attributes_not_skipped_in_standard() {
1836 let rule = MD033NoInlineHtml::default();
1838 let content = r#"<div className="card">Content</div>"#;
1839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1840 let result = rule.check(&ctx).unwrap();
1841
1842 assert_eq!(result.len(), 1, "Should flag JSX-style elements in standard markdown");
1844 }
1845
1846 #[test]
1849 fn test_md033_fix_disabled_by_default() {
1850 let rule = MD033NoInlineHtml::default();
1852 assert!(!rule.config.fix, "Fix should be disabled by default");
1853 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::Unfixable);
1854 }
1855
1856 #[test]
1857 fn test_md033_fix_enabled_em_to_italic() {
1858 let rule = MD033NoInlineHtml::with_fix(true);
1860 let content = "This has <em>emphasized text</em> here.";
1861 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1862 let fixed = rule.fix(&ctx).unwrap();
1863 assert_eq!(fixed, "This has *emphasized text* here.");
1864 }
1865
1866 #[test]
1867 fn test_md033_fix_enabled_i_to_italic() {
1868 let rule = MD033NoInlineHtml::with_fix(true);
1870 let content = "This has <i>italic text</i> here.";
1871 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1872 let fixed = rule.fix(&ctx).unwrap();
1873 assert_eq!(fixed, "This has *italic text* here.");
1874 }
1875
1876 #[test]
1877 fn test_md033_fix_enabled_strong_to_bold() {
1878 let rule = MD033NoInlineHtml::with_fix(true);
1880 let content = "This has <strong>bold text</strong> here.";
1881 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1882 let fixed = rule.fix(&ctx).unwrap();
1883 assert_eq!(fixed, "This has **bold text** here.");
1884 }
1885
1886 #[test]
1887 fn test_md033_fix_enabled_b_to_bold() {
1888 let rule = MD033NoInlineHtml::with_fix(true);
1890 let content = "This has <b>bold text</b> here.";
1891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1892 let fixed = rule.fix(&ctx).unwrap();
1893 assert_eq!(fixed, "This has **bold text** here.");
1894 }
1895
1896 #[test]
1897 fn test_md033_fix_enabled_code_to_backticks() {
1898 let rule = MD033NoInlineHtml::with_fix(true);
1900 let content = "This has <code>inline code</code> here.";
1901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1902 let fixed = rule.fix(&ctx).unwrap();
1903 assert_eq!(fixed, "This has `inline code` here.");
1904 }
1905
1906 #[test]
1907 fn test_md033_fix_enabled_code_with_backticks() {
1908 let rule = MD033NoInlineHtml::with_fix(true);
1910 let content = "This has <code>text with `backticks`</code> here.";
1911 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1912 let fixed = rule.fix(&ctx).unwrap();
1913 assert_eq!(fixed, "This has `` text with `backticks` `` here.");
1914 }
1915
1916 #[test]
1917 fn test_md033_fix_enabled_br_trailing_spaces() {
1918 let rule = MD033NoInlineHtml::with_fix(true);
1920 let content = "First line<br>Second line";
1921 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1922 let fixed = rule.fix(&ctx).unwrap();
1923 assert_eq!(fixed, "First line \nSecond line");
1924 }
1925
1926 #[test]
1927 fn test_md033_fix_enabled_br_self_closing() {
1928 let rule = MD033NoInlineHtml::with_fix(true);
1930 let content = "First<br/>second<br />third";
1931 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1932 let fixed = rule.fix(&ctx).unwrap();
1933 assert_eq!(fixed, "First \nsecond \nthird");
1934 }
1935
1936 #[test]
1937 fn test_md033_fix_enabled_br_backslash_style() {
1938 let config = MD033Config {
1940 allowed: Vec::new(),
1941 disallowed: Vec::new(),
1942 fix: true,
1943 br_style: md033_config::BrStyle::Backslash,
1944 ..MD033Config::default()
1945 };
1946 let rule = MD033NoInlineHtml::from_config_struct(config);
1947 let content = "First line<br>Second line";
1948 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1949 let fixed = rule.fix(&ctx).unwrap();
1950 assert_eq!(fixed, "First line\\\nSecond line");
1951 }
1952
1953 #[test]
1954 fn test_md033_fix_enabled_hr() {
1955 let rule = MD033NoInlineHtml::with_fix(true);
1957 let content = "Above<hr>Below";
1958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1959 let fixed = rule.fix(&ctx).unwrap();
1960 assert_eq!(fixed, "Above\n---\nBelow");
1961 }
1962
1963 #[test]
1964 fn test_md033_fix_enabled_hr_self_closing() {
1965 let rule = MD033NoInlineHtml::with_fix(true);
1967 let content = "Above<hr/>Below";
1968 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1969 let fixed = rule.fix(&ctx).unwrap();
1970 assert_eq!(fixed, "Above\n---\nBelow");
1971 }
1972
1973 #[test]
1974 fn test_md033_fix_skips_nested_tags() {
1975 let rule = MD033NoInlineHtml::with_fix(true);
1978 let content = "This has <em>text with <strong>nested</strong> tags</em> here.";
1979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1980 let fixed = rule.fix(&ctx).unwrap();
1981 assert_eq!(fixed, "This has <em>text with **nested** tags</em> here.");
1984 }
1985
1986 #[test]
1987 fn test_md033_fix_skips_tags_with_attributes() {
1988 let rule = MD033NoInlineHtml::with_fix(true);
1991 let content = "This has <em class=\"highlight\">emphasized</em> text.";
1992 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1993 let fixed = rule.fix(&ctx).unwrap();
1994 assert_eq!(fixed, content);
1996 }
1997
1998 #[test]
1999 fn test_md033_fix_disabled_no_changes() {
2000 let rule = MD033NoInlineHtml::default(); let content = "This has <em>emphasized text</em> here.";
2003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2004 let fixed = rule.fix(&ctx).unwrap();
2005 assert_eq!(fixed, content, "Should return original content when fix is disabled");
2006 }
2007
2008 #[test]
2009 fn test_md033_fix_capability_enabled() {
2010 let rule = MD033NoInlineHtml::with_fix(true);
2011 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::FullyFixable);
2012 }
2013
2014 #[test]
2015 fn test_md033_fix_multiple_tags() {
2016 let rule = MD033NoInlineHtml::with_fix(true);
2018 let content = "Here is <em>italic</em> and <strong>bold</strong> text.";
2019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2020 let fixed = rule.fix(&ctx).unwrap();
2021 assert_eq!(fixed, "Here is *italic* and **bold** text.");
2022 }
2023
2024 #[test]
2025 fn test_md033_fix_uppercase_tags() {
2026 let rule = MD033NoInlineHtml::with_fix(true);
2028 let content = "This has <EM>emphasized</EM> text.";
2029 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2030 let fixed = rule.fix(&ctx).unwrap();
2031 assert_eq!(fixed, "This has *emphasized* text.");
2032 }
2033
2034 #[test]
2035 fn test_md033_fix_unsafe_tags_not_modified() {
2036 let rule = MD033NoInlineHtml::with_fix(true);
2039 let content = "This has <div>a div</div> content.";
2040 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2041 let fixed = rule.fix(&ctx).unwrap();
2042 assert_eq!(fixed, "This has <div>a div</div> content.");
2044 }
2045
2046 #[test]
2047 fn test_md033_fix_img_tag_converted() {
2048 let rule = MD033NoInlineHtml::with_fix(true);
2050 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\">";
2051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2052 let fixed = rule.fix(&ctx).unwrap();
2053 assert_eq!(fixed, "Image: ");
2055 }
2056
2057 #[test]
2058 fn test_md033_fix_img_tag_with_extra_attrs_not_converted() {
2059 let rule = MD033NoInlineHtml::with_fix(true);
2061 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
2062 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2063 let fixed = rule.fix(&ctx).unwrap();
2064 assert_eq!(fixed, "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">");
2066 }
2067
2068 #[test]
2069 fn test_md033_fix_relaxed_a_with_target_is_converted() {
2070 let rule = relaxed_fix_rule();
2071 let content = "Link: <a href=\"https://example.com\" target=\"_blank\">Example</a>";
2072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2073 let fixed = rule.fix(&ctx).unwrap();
2074 assert_eq!(fixed, "Link: [Example](https://example.com)");
2075 }
2076
2077 #[test]
2078 fn test_md033_fix_relaxed_img_with_width_is_converted() {
2079 let rule = relaxed_fix_rule();
2080 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
2081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2082 let fixed = rule.fix(&ctx).unwrap();
2083 assert_eq!(fixed, "Image: ");
2084 }
2085
2086 #[test]
2087 fn test_md033_fix_relaxed_rejects_unknown_extra_attributes() {
2088 let rule = relaxed_fix_rule();
2089 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" aria-label=\"hero\">";
2090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2091 let fixed = rule.fix(&ctx).unwrap();
2092 assert_eq!(fixed, content, "Unknown attributes should not be dropped by default");
2093 }
2094
2095 #[test]
2096 fn test_md033_fix_relaxed_still_blocks_unsafe_schemes() {
2097 let rule = relaxed_fix_rule();
2098 let content = "Link: <a href=\"javascript:alert(1)\" target=\"_blank\">Example</a>";
2099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2100 let fixed = rule.fix(&ctx).unwrap();
2101 assert_eq!(fixed, content, "Unsafe URL schemes must never be converted");
2102 }
2103
2104 #[test]
2105 fn test_md033_fix_relaxed_wrapper_strip_requires_second_pass_for_nested_html() {
2106 let rule = relaxed_fix_rule();
2107 let content = "<p align=\"center\">\n <img src=\"logo.svg\" alt=\"Logo\" width=\"120\" />\n</p>";
2108 let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2109 let fixed_once = rule.fix(&ctx1).unwrap();
2110 assert!(
2111 fixed_once.contains("<p"),
2112 "First pass should keep wrapper when inner HTML is still present: {fixed_once}"
2113 );
2114 assert!(
2115 fixed_once.contains(""),
2116 "Inner image should be converted on first pass: {fixed_once}"
2117 );
2118
2119 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2120 let fixed_twice = rule.fix(&ctx2).unwrap();
2121 assert!(
2122 !fixed_twice.contains("<p"),
2123 "Second pass should strip configured wrapper: {fixed_twice}"
2124 );
2125 assert!(fixed_twice.contains(""));
2126 }
2127
2128 #[test]
2129 fn test_md033_fix_relaxed_multiple_droppable_attrs() {
2130 let rule = relaxed_fix_rule();
2131 let content = "<a href=\"https://example.com\" target=\"_blank\" rel=\"noopener\" class=\"btn\">Click</a>";
2132 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2133 let fixed = rule.fix(&ctx).unwrap();
2134 assert_eq!(fixed, "[Click](https://example.com)");
2135 }
2136
2137 #[test]
2138 fn test_md033_fix_relaxed_img_multiple_droppable_attrs() {
2139 let rule = relaxed_fix_rule();
2140 let content = "<img src=\"logo.png\" alt=\"Logo\" width=\"120\" height=\"40\" style=\"border:none\" />";
2141 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2142 let fixed = rule.fix(&ctx).unwrap();
2143 assert_eq!(fixed, "");
2144 }
2145
2146 #[test]
2147 fn test_md033_fix_relaxed_event_handler_never_dropped() {
2148 let rule = relaxed_fix_rule();
2149 let content = "<a href=\"https://example.com\" onclick=\"track()\">Link</a>";
2150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2151 let fixed = rule.fix(&ctx).unwrap();
2152 assert_eq!(fixed, content, "Event handler attributes must block conversion");
2153 }
2154
2155 #[test]
2156 fn test_md033_fix_relaxed_event_handler_even_with_custom_config() {
2157 let config = MD033Config {
2159 fix: true,
2160 fix_mode: MD033FixMode::Relaxed,
2161 drop_attributes: vec!["on*".to_string(), "target".to_string()],
2162 ..MD033Config::default()
2163 };
2164 let rule = MD033NoInlineHtml::from_config_struct(config);
2165 let content = "<a href=\"https://example.com\" onclick=\"alert(1)\">Link</a>";
2166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2167 let fixed = rule.fix(&ctx).unwrap();
2168 assert_eq!(fixed, content, "on* event handlers must never be dropped");
2169 }
2170
2171 #[test]
2172 fn test_md033_fix_relaxed_custom_drop_attributes() {
2173 let config = MD033Config {
2174 fix: true,
2175 fix_mode: MD033FixMode::Relaxed,
2176 drop_attributes: vec!["loading".to_string()],
2177 ..MD033Config::default()
2178 };
2179 let rule = MD033NoInlineHtml::from_config_struct(config);
2180 let content = "<img src=\"x.jpg\" alt=\"\" loading=\"lazy\">";
2182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2183 let fixed = rule.fix(&ctx).unwrap();
2184 assert_eq!(fixed, "", "Custom drop-attributes should be respected");
2185
2186 let content2 = "<img src=\"x.jpg\" alt=\"\" width=\"100\">";
2187 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
2188 let fixed2 = rule.fix(&ctx2).unwrap();
2189 assert_eq!(
2190 fixed2, content2,
2191 "Attributes not in custom list should block conversion"
2192 );
2193 }
2194
2195 #[test]
2196 fn test_md033_fix_relaxed_custom_strip_wrapper() {
2197 let config = MD033Config {
2198 fix: true,
2199 fix_mode: MD033FixMode::Relaxed,
2200 strip_wrapper_elements: vec!["div".to_string()],
2201 ..MD033Config::default()
2202 };
2203 let rule = MD033NoInlineHtml::from_config_struct(config);
2204 let content = "<div>Some text content</div>";
2205 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2206 let fixed = rule.fix(&ctx).unwrap();
2207 assert_eq!(fixed, "Some text content");
2208 }
2209
2210 #[test]
2211 fn test_md033_fix_relaxed_wrapper_with_plain_text() {
2212 let rule = relaxed_fix_rule();
2213 let content = "<p align=\"center\">Just some text</p>";
2214 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2215 let fixed = rule.fix(&ctx).unwrap();
2216 assert_eq!(fixed, "Just some text");
2217 }
2218
2219 #[test]
2220 fn test_md033_fix_relaxed_data_attr_with_wildcard() {
2221 let config = MD033Config {
2222 fix: true,
2223 fix_mode: MD033FixMode::Relaxed,
2224 drop_attributes: vec!["data-*".to_string(), "target".to_string()],
2225 ..MD033Config::default()
2226 };
2227 let rule = MD033NoInlineHtml::from_config_struct(config);
2228 let content = "<a href=\"https://example.com\" data-tracking=\"abc\" target=\"_blank\">Link</a>";
2229 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2230 let fixed = rule.fix(&ctx).unwrap();
2231 assert_eq!(fixed, "[Link](https://example.com)");
2232 }
2233
2234 #[test]
2235 fn test_md033_fix_relaxed_mixed_droppable_and_blocking_attrs() {
2236 let rule = relaxed_fix_rule();
2237 let content = "<a href=\"https://example.com\" target=\"_blank\" aria-label=\"nav\">Link</a>";
2239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2240 let fixed = rule.fix(&ctx).unwrap();
2241 assert_eq!(fixed, content, "Non-droppable attribute should block conversion");
2242 }
2243
2244 #[test]
2245 fn test_md033_fix_relaxed_badge_pattern() {
2246 let rule = relaxed_fix_rule();
2248 let content = "<a href=\"https://crates.io/crates/rumdl\" target=\"_blank\"><img src=\"https://img.shields.io/crates/v/rumdl.svg\" alt=\"Crate\" width=\"120\" /></a>";
2249 let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2250 let fixed_once = rule.fix(&ctx1).unwrap();
2251 assert!(
2253 fixed_once.contains(""),
2254 "Inner img should be converted: {fixed_once}"
2255 );
2256
2257 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2259 let fixed_twice = rule.fix(&ctx2).unwrap();
2260 assert!(
2261 fixed_twice
2262 .contains("[](https://crates.io/crates/rumdl)"),
2263 "Badge should produce nested markdown image link: {fixed_twice}"
2264 );
2265 }
2266
2267 #[test]
2268 fn test_md033_fix_relaxed_conservative_mode_unchanged() {
2269 let rule = MD033NoInlineHtml::with_fix(true);
2271 let content = "<a href=\"https://example.com\" target=\"_blank\">Link</a>";
2272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2273 let fixed = rule.fix(&ctx).unwrap();
2274 assert_eq!(fixed, content, "Conservative mode should not drop target attribute");
2275 }
2276
2277 #[test]
2278 fn test_md033_fix_relaxed_img_inside_pre_not_converted() {
2279 let rule = relaxed_fix_rule();
2281 let content = "<pre>\n <img src=\"diagram.png\" alt=\"d\" width=\"100\" />\n</pre>";
2282 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2283 let fixed = rule.fix(&ctx).unwrap();
2284 assert!(fixed.contains("<img"), "img inside pre must not be converted: {fixed}");
2285 }
2286
2287 #[test]
2288 fn test_md033_fix_relaxed_wrapper_nested_inside_div_not_stripped() {
2289 let rule = relaxed_fix_rule();
2291 let content = "<div><p>text</p></div>";
2292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2293 let fixed = rule.fix(&ctx).unwrap();
2294 assert!(
2295 fixed.contains("<p>text</p>") || fixed.contains("<p>"),
2296 "Nested <p> inside <div> should not be stripped: {fixed}"
2297 );
2298 }
2299
2300 #[test]
2301 fn test_md033_fix_relaxed_img_inside_nested_wrapper_not_converted() {
2302 let rule = relaxed_fix_rule();
2306 let content = "<div><p><img src=\"x.jpg\" alt=\"pic\" width=\"100\" /></p></div>";
2307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2308 let fixed = rule.fix(&ctx).unwrap();
2309 assert!(
2310 fixed.contains("<img"),
2311 "img inside nested wrapper must not be converted: {fixed}"
2312 );
2313 }
2314
2315 #[test]
2316 fn test_md033_fix_mixed_safe_tags() {
2317 let rule = MD033NoInlineHtml::with_fix(true);
2319 let content = "<em>italic</em> and <img src=\"x.jpg\"> and <strong>bold</strong>";
2320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2321 let fixed = rule.fix(&ctx).unwrap();
2322 assert_eq!(fixed, "*italic* and  and **bold**");
2324 }
2325
2326 #[test]
2327 fn test_md033_fix_multiple_tags_same_line() {
2328 let rule = MD033NoInlineHtml::with_fix(true);
2330 let content = "Regular text <i>italic</i> and <b>bold</b> here.";
2331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2332 let fixed = rule.fix(&ctx).unwrap();
2333 assert_eq!(fixed, "Regular text *italic* and **bold** here.");
2334 }
2335
2336 #[test]
2337 fn test_md033_fix_multiple_em_tags_same_line() {
2338 let rule = MD033NoInlineHtml::with_fix(true);
2340 let content = "<em>first</em> and <strong>second</strong> and <code>third</code>";
2341 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2342 let fixed = rule.fix(&ctx).unwrap();
2343 assert_eq!(fixed, "*first* and **second** and `third`");
2344 }
2345
2346 #[test]
2347 fn test_md033_fix_skips_tags_inside_pre() {
2348 let rule = MD033NoInlineHtml::with_fix(true);
2350 let content = "<pre><code><em>VALUE</em></code></pre>";
2351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2352 let fixed = rule.fix(&ctx).unwrap();
2353 assert!(
2356 !fixed.contains("*VALUE*"),
2357 "Tags inside <pre> should not be converted to markdown. Got: {fixed}"
2358 );
2359 }
2360
2361 #[test]
2362 fn test_md033_fix_skips_tags_inside_div() {
2363 let rule = MD033NoInlineHtml::with_fix(true);
2365 let content = "<div>\n<em>emphasized</em>\n</div>";
2366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2367 let fixed = rule.fix(&ctx).unwrap();
2368 assert!(
2370 !fixed.contains("*emphasized*"),
2371 "Tags inside HTML blocks should not be converted. Got: {fixed}"
2372 );
2373 }
2374
2375 #[test]
2376 fn test_md033_fix_outside_html_block() {
2377 let rule = MD033NoInlineHtml::with_fix(true);
2379 let content = "<div>\ncontent\n</div>\n\nOutside <em>emphasized</em> text.";
2380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2381 let fixed = rule.fix(&ctx).unwrap();
2382 assert!(
2384 fixed.contains("*emphasized*"),
2385 "Tags outside HTML blocks should be converted. Got: {fixed}"
2386 );
2387 }
2388
2389 #[test]
2390 fn test_md033_fix_with_id_attribute() {
2391 let rule = MD033NoInlineHtml::with_fix(true);
2393 let content = "See <em id=\"important\">this note</em> for details.";
2394 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2395 let fixed = rule.fix(&ctx).unwrap();
2396 assert_eq!(fixed, content);
2398 }
2399
2400 #[test]
2401 fn test_md033_fix_with_style_attribute() {
2402 let rule = MD033NoInlineHtml::with_fix(true);
2404 let content = "This is <strong style=\"color: red\">important</strong> text.";
2405 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2406 let fixed = rule.fix(&ctx).unwrap();
2407 assert_eq!(fixed, content);
2409 }
2410
2411 #[test]
2412 fn test_md033_fix_mixed_with_and_without_attributes() {
2413 let rule = MD033NoInlineHtml::with_fix(true);
2415 let content = "<em>normal</em> and <em class=\"special\">styled</em> text.";
2416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2417 let fixed = rule.fix(&ctx).unwrap();
2418 assert_eq!(fixed, "*normal* and <em class=\"special\">styled</em> text.");
2420 }
2421
2422 #[test]
2423 fn test_md033_quick_fix_tag_with_attributes_no_fix() {
2424 let rule = MD033NoInlineHtml::with_fix(true);
2426 let content = "<em class=\"test\">emphasized</em>";
2427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2428 let result = rule.check(&ctx).unwrap();
2429
2430 assert_eq!(result.len(), 1, "Should find one HTML tag");
2431 assert!(
2433 result[0].fix.is_none(),
2434 "Should NOT have a fix for tags with attributes"
2435 );
2436 }
2437
2438 #[test]
2439 fn test_md033_fix_skips_html_entities() {
2440 let rule = MD033NoInlineHtml::with_fix(true);
2443 let content = "<code>|</code>";
2444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2445 let fixed = rule.fix(&ctx).unwrap();
2446 assert_eq!(fixed, content);
2448 }
2449
2450 #[test]
2451 fn test_md033_fix_skips_multiple_html_entities() {
2452 let rule = MD033NoInlineHtml::with_fix(true);
2454 let content = "<code><T></code>";
2455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2456 let fixed = rule.fix(&ctx).unwrap();
2457 assert_eq!(fixed, content);
2459 }
2460
2461 #[test]
2462 fn test_md033_fix_allows_ampersand_without_entity() {
2463 let rule = MD033NoInlineHtml::with_fix(true);
2465 let content = "<code>a & b</code>";
2466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2467 let fixed = rule.fix(&ctx).unwrap();
2468 assert_eq!(fixed, "`a & b`");
2470 }
2471
2472 #[test]
2473 fn test_md033_fix_em_with_entities_skipped() {
2474 let rule = MD033NoInlineHtml::with_fix(true);
2476 let content = "<em> text</em>";
2477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2478 let fixed = rule.fix(&ctx).unwrap();
2479 assert_eq!(fixed, content);
2481 }
2482
2483 #[test]
2484 fn test_md033_fix_skips_nested_em_in_code() {
2485 let rule = MD033NoInlineHtml::with_fix(true);
2488 let content = "<code><em>n</em></code>";
2489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2490 let fixed = rule.fix(&ctx).unwrap();
2491 assert!(
2494 !fixed.contains("*n*"),
2495 "Nested <em> should not be converted to markdown. Got: {fixed}"
2496 );
2497 }
2498
2499 #[test]
2500 fn test_md033_fix_skips_nested_in_table() {
2501 let rule = MD033NoInlineHtml::with_fix(true);
2503 let content = "| <code>><em>n</em></code> | description |";
2504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2505 let fixed = rule.fix(&ctx).unwrap();
2506 assert!(
2508 !fixed.contains("*n*"),
2509 "Nested tags in table should not be converted. Got: {fixed}"
2510 );
2511 }
2512
2513 #[test]
2514 fn test_md033_fix_standalone_em_still_converted() {
2515 let rule = MD033NoInlineHtml::with_fix(true);
2517 let content = "This is <em>emphasized</em> text.";
2518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2519 let fixed = rule.fix(&ctx).unwrap();
2520 assert_eq!(fixed, "This is *emphasized* text.");
2521 }
2522
2523 #[test]
2535 fn test_md033_templater_basic_interpolation_not_flagged() {
2536 let rule = MD033NoInlineHtml::default();
2539 let content = "Today is <% tp.date.now() %> which is nice.";
2540 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2541 let result = rule.check(&ctx).unwrap();
2542 assert!(
2543 result.is_empty(),
2544 "Templater basic interpolation should not be flagged as HTML. Got: {result:?}"
2545 );
2546 }
2547
2548 #[test]
2549 fn test_md033_templater_file_functions_not_flagged() {
2550 let rule = MD033NoInlineHtml::default();
2552 let content = "File: <% tp.file.title %>\nCreated: <% tp.file.creation_date() %>";
2553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2554 let result = rule.check(&ctx).unwrap();
2555 assert!(
2556 result.is_empty(),
2557 "Templater file functions should not be flagged. Got: {result:?}"
2558 );
2559 }
2560
2561 #[test]
2562 fn test_md033_templater_with_arguments_not_flagged() {
2563 let rule = MD033NoInlineHtml::default();
2565 let content = r#"Date: <% tp.date.now("YYYY-MM-DD") %>"#;
2566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2567 let result = rule.check(&ctx).unwrap();
2568 assert!(
2569 result.is_empty(),
2570 "Templater with arguments should not be flagged. Got: {result:?}"
2571 );
2572 }
2573
2574 #[test]
2575 fn test_md033_templater_javascript_execution_not_flagged() {
2576 let rule = MD033NoInlineHtml::default();
2578 let content = "<%* const today = tp.date.now(); tR += today; %>";
2579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2580 let result = rule.check(&ctx).unwrap();
2581 assert!(
2582 result.is_empty(),
2583 "Templater JS execution block should not be flagged. Got: {result:?}"
2584 );
2585 }
2586
2587 #[test]
2588 fn test_md033_templater_dynamic_execution_not_flagged() {
2589 let rule = MD033NoInlineHtml::default();
2591 let content = "Dynamic: <%+ tp.date.now() %>";
2592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2593 let result = rule.check(&ctx).unwrap();
2594 assert!(
2595 result.is_empty(),
2596 "Templater dynamic execution should not be flagged. Got: {result:?}"
2597 );
2598 }
2599
2600 #[test]
2601 fn test_md033_templater_whitespace_trim_all_not_flagged() {
2602 let rule = MD033NoInlineHtml::default();
2604 let content = "<%_ tp.date.now() _%>";
2605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2606 let result = rule.check(&ctx).unwrap();
2607 assert!(
2608 result.is_empty(),
2609 "Templater trim-all whitespace should not be flagged. Got: {result:?}"
2610 );
2611 }
2612
2613 #[test]
2614 fn test_md033_templater_whitespace_trim_newline_not_flagged() {
2615 let rule = MD033NoInlineHtml::default();
2617 let content = "<%- tp.date.now() -%>";
2618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2619 let result = rule.check(&ctx).unwrap();
2620 assert!(
2621 result.is_empty(),
2622 "Templater trim-newline should not be flagged. Got: {result:?}"
2623 );
2624 }
2625
2626 #[test]
2627 fn test_md033_templater_combined_modifiers_not_flagged() {
2628 let rule = MD033NoInlineHtml::default();
2630 let contents = [
2631 "<%-* const x = 1; -%>", "<%_+ tp.date.now() _%>", "<%- tp.file.title -%>", "<%_ tp.file.title _%>", ];
2636 for content in contents {
2637 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2638 let result = rule.check(&ctx).unwrap();
2639 assert!(
2640 result.is_empty(),
2641 "Templater combined modifiers should not be flagged: {content}. Got: {result:?}"
2642 );
2643 }
2644 }
2645
2646 #[test]
2647 fn test_md033_templater_multiline_block_not_flagged() {
2648 let rule = MD033NoInlineHtml::default();
2650 let content = r#"<%*
2651const x = 1;
2652const y = 2;
2653tR += x + y;
2654%>"#;
2655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2656 let result = rule.check(&ctx).unwrap();
2657 assert!(
2658 result.is_empty(),
2659 "Templater multi-line block should not be flagged. Got: {result:?}"
2660 );
2661 }
2662
2663 #[test]
2664 fn test_md033_templater_with_angle_brackets_in_condition_not_flagged() {
2665 let rule = MD033NoInlineHtml::default();
2668 let content = "<%* if (x < 5) { tR += 'small'; } %>";
2669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2670 let result = rule.check(&ctx).unwrap();
2671 assert!(
2672 result.is_empty(),
2673 "Templater with angle brackets in conditions should not be flagged. Got: {result:?}"
2674 );
2675 }
2676
2677 #[test]
2678 fn test_md033_templater_mixed_with_html_only_html_flagged() {
2679 let rule = MD033NoInlineHtml::default();
2681 let content = "<% tp.date.now() %> is today's date. <div>This is HTML</div>";
2682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2683 let result = rule.check(&ctx).unwrap();
2684 assert_eq!(result.len(), 1, "Should only flag the HTML div tag");
2685 assert!(
2686 result[0].message.contains("<div>"),
2687 "Should flag <div>, got: {}",
2688 result[0].message
2689 );
2690 }
2691
2692 #[test]
2693 fn test_md033_templater_in_heading_not_flagged() {
2694 let rule = MD033NoInlineHtml::default();
2696 let content = "# <% tp.file.title %>";
2697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2698 let result = rule.check(&ctx).unwrap();
2699 assert!(
2700 result.is_empty(),
2701 "Templater in heading should not be flagged. Got: {result:?}"
2702 );
2703 }
2704
2705 #[test]
2706 fn test_md033_templater_multiple_on_same_line_not_flagged() {
2707 let rule = MD033NoInlineHtml::default();
2709 let content = "From <% tp.date.now() %> to <% tp.date.tomorrow() %> we have meetings.";
2710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2711 let result = rule.check(&ctx).unwrap();
2712 assert!(
2713 result.is_empty(),
2714 "Multiple Templater blocks should not be flagged. Got: {result:?}"
2715 );
2716 }
2717
2718 #[test]
2719 fn test_md033_templater_in_code_block_not_flagged() {
2720 let rule = MD033NoInlineHtml::default();
2722 let content = "```\n<% tp.date.now() %>\n```";
2723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2724 let result = rule.check(&ctx).unwrap();
2725 assert!(
2726 result.is_empty(),
2727 "Templater in code block should not be flagged. Got: {result:?}"
2728 );
2729 }
2730
2731 #[test]
2732 fn test_md033_templater_in_inline_code_not_flagged() {
2733 let rule = MD033NoInlineHtml::default();
2735 let content = "Use `<% tp.date.now() %>` for current date.";
2736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2737 let result = rule.check(&ctx).unwrap();
2738 assert!(
2739 result.is_empty(),
2740 "Templater in inline code should not be flagged. Got: {result:?}"
2741 );
2742 }
2743
2744 #[test]
2745 fn test_md033_templater_also_works_in_standard_flavor() {
2746 let rule = MD033NoInlineHtml::default();
2749 let content = "<% tp.date.now() %> works everywhere.";
2750 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2751 let result = rule.check(&ctx).unwrap();
2752 assert!(
2753 result.is_empty(),
2754 "Templater should not be flagged even in Standard flavor. Got: {result:?}"
2755 );
2756 }
2757
2758 #[test]
2759 fn test_md033_templater_empty_tag_not_flagged() {
2760 let rule = MD033NoInlineHtml::default();
2762 let content = "<%>";
2763 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2764 let result = rule.check(&ctx).unwrap();
2765 assert!(
2766 result.is_empty(),
2767 "Empty Templater-like tag should not be flagged. Got: {result:?}"
2768 );
2769 }
2770
2771 #[test]
2772 fn test_md033_templater_unclosed_not_flagged() {
2773 let rule = MD033NoInlineHtml::default();
2775 let content = "<% tp.date.now() without closing tag";
2776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2777 let result = rule.check(&ctx).unwrap();
2778 assert!(
2779 result.is_empty(),
2780 "Unclosed Templater should not be flagged as HTML. Got: {result:?}"
2781 );
2782 }
2783
2784 #[test]
2785 fn test_md033_templater_with_newlines_inside_not_flagged() {
2786 let rule = MD033NoInlineHtml::default();
2788 let content = r#"<% tp.date.now("YYYY") +
2789"-" +
2790tp.date.now("MM") %>"#;
2791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2792 let result = rule.check(&ctx).unwrap();
2793 assert!(
2794 result.is_empty(),
2795 "Templater with internal newlines should not be flagged. Got: {result:?}"
2796 );
2797 }
2798
2799 #[test]
2800 fn test_md033_erb_style_tags_not_flagged() {
2801 let rule = MD033NoInlineHtml::default();
2804 let content = "<%= variable %> and <% code %> and <%# comment %>";
2805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2806 let result = rule.check(&ctx).unwrap();
2807 assert!(
2808 result.is_empty(),
2809 "ERB/EJS style tags should not be flagged as HTML. Got: {result:?}"
2810 );
2811 }
2812
2813 #[test]
2814 fn test_md033_templater_complex_expression_not_flagged() {
2815 let rule = MD033NoInlineHtml::default();
2817 let content = r#"<%*
2818const file = tp.file.title;
2819const date = tp.date.now("YYYY-MM-DD");
2820const folder = tp.file.folder();
2821tR += `# ${file}\n\nCreated: ${date}\nIn: ${folder}`;
2822%>"#;
2823 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2824 let result = rule.check(&ctx).unwrap();
2825 assert!(
2826 result.is_empty(),
2827 "Complex Templater expression should not be flagged. Got: {result:?}"
2828 );
2829 }
2830
2831 #[test]
2832 fn test_md033_percent_sign_variations_not_flagged() {
2833 let rule = MD033NoInlineHtml::default();
2835 let patterns = [
2836 "<%=", "<%#", "<%%", "<%!", "<%@", "<%--", ];
2843 for pattern in patterns {
2844 let content = format!("{pattern} content %>");
2845 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2846 let result = rule.check(&ctx).unwrap();
2847 assert!(
2848 result.is_empty(),
2849 "Pattern {pattern} should not be flagged. Got: {result:?}"
2850 );
2851 }
2852 }
2853
2854 #[test]
2860 fn test_md033_fix_a_wrapping_markdown_image_no_escaped_brackets() {
2861 let rule = MD033NoInlineHtml::with_fix(true);
2864 let content = r#"<a href="https://example.com"></a>"#;
2865 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2866 let fixed = rule.fix(&ctx).unwrap();
2867
2868 assert_eq!(fixed, "[](https://example.com)",);
2869 assert!(!fixed.contains(r"\["), "Must not escape brackets: {fixed}");
2870 assert!(!fixed.contains(r"\]"), "Must not escape brackets: {fixed}");
2871 }
2872
2873 #[test]
2874 fn test_md033_fix_a_wrapping_markdown_image_with_alt() {
2875 let rule = MD033NoInlineHtml::with_fix(true);
2877 let content =
2878 r#"<a href="https://github.com/repo"></a>"#;
2879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2880 let fixed = rule.fix(&ctx).unwrap();
2881
2882 assert_eq!(
2883 fixed,
2884 "[](https://github.com/repo)"
2885 );
2886 }
2887
2888 #[test]
2889 fn test_md033_fix_img_without_alt_produces_empty_alt() {
2890 let rule = MD033NoInlineHtml::with_fix(true);
2891 let content = r#"<img src="photo.jpg" />"#;
2892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2893 let fixed = rule.fix(&ctx).unwrap();
2894
2895 assert_eq!(fixed, "");
2896 }
2897
2898 #[test]
2899 fn test_md033_fix_a_with_plain_text_still_escapes_brackets() {
2900 let rule = MD033NoInlineHtml::with_fix(true);
2902 let content = r#"<a href="https://example.com">text with [brackets]</a>"#;
2903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2904 let fixed = rule.fix(&ctx).unwrap();
2905
2906 assert!(
2907 fixed.contains(r"\[brackets\]"),
2908 "Plain text brackets should be escaped: {fixed}"
2909 );
2910 }
2911
2912 #[test]
2913 fn test_md033_fix_a_with_image_plus_extra_text_escapes_brackets() {
2914 let rule = MD033NoInlineHtml::with_fix(true);
2917 let content = r#"<a href="/link"> see [docs]</a>"#;
2918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2919 let fixed = rule.fix(&ctx).unwrap();
2920
2921 assert!(
2923 fixed.contains(r"\[docs\]"),
2924 "Brackets in mixed image+text content should be escaped: {fixed}"
2925 );
2926 }
2927
2928 #[test]
2929 fn test_md033_fix_img_in_a_end_to_end() {
2930 use crate::config::Config;
2933 use crate::fix_coordinator::FixCoordinator;
2934
2935 let rule = MD033NoInlineHtml::with_fix(true);
2936 let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
2937
2938 let mut content =
2939 r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image?repo=org/repo" /></a>"#
2940 .to_string();
2941 let config = Config::default();
2942 let coordinator = FixCoordinator::new();
2943
2944 let result = coordinator
2945 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
2946 .unwrap();
2947
2948 assert_eq!(
2949 content, "[](https://github.com/org/repo)",
2950 "End-to-end: <a><img></a> should become valid linked image"
2951 );
2952 assert!(result.converged);
2953 assert!(!content.contains(r"\["), "No escaped brackets: {content}");
2954 }
2955
2956 #[test]
2957 fn test_md033_fix_img_in_a_with_alt_end_to_end() {
2958 use crate::config::Config;
2959 use crate::fix_coordinator::FixCoordinator;
2960
2961 let rule = MD033NoInlineHtml::with_fix(true);
2962 let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
2963
2964 let mut content =
2965 r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image" alt="Contributors" /></a>"#
2966 .to_string();
2967 let config = Config::default();
2968 let coordinator = FixCoordinator::new();
2969
2970 let result = coordinator
2971 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
2972 .unwrap();
2973
2974 assert_eq!(
2975 content,
2976 "[](https://github.com/org/repo)",
2977 );
2978 assert!(result.converged);
2979 }
2980
2981 #[test]
2991 fn test_md033_table_allowed_unset_falls_back_to_allowed() {
2992 let config = MD033Config {
2993 allowed: vec!["br".to_string()],
2994 table_allowed_elements: None,
2995 ..MD033Config::default()
2996 };
2997 let rule = MD033NoInlineHtml::from_config_struct(config);
2998 let content = "| col |\n|-----|\n| a<br>b |\n";
2999 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3000 let result = rule.check(&ctx).unwrap();
3001 assert!(
3002 result.is_empty(),
3003 "<br> in table cell should be allowed via fallback to `allowed`, got {result:?}"
3004 );
3005 }
3006
3007 #[test]
3008 fn test_md033_table_allowed_explicit_empty_rejects_in_tables() {
3009 let config = MD033Config {
3010 allowed: vec!["br".to_string()],
3011 table_allowed_elements: Some(Vec::new()),
3012 ..MD033Config::default()
3013 };
3014 let rule = MD033NoInlineHtml::from_config_struct(config);
3015 let content = "| col |\n|-----|\n| a<br>b |\n";
3016 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3017 let result = rule.check(&ctx).unwrap();
3018 assert_eq!(
3019 result.len(),
3020 1,
3021 "Explicit empty table_allowed should reject <br> in tables even if it's in `allowed`, got {result:?}"
3022 );
3023 assert_eq!(result[0].line, 3);
3024 }
3025
3026 #[test]
3027 fn test_md033_table_allowed_explicit_list_overrides_in_tables() {
3028 let config = MD033Config {
3029 allowed: vec!["br".to_string()],
3030 table_allowed_elements: Some(vec!["img".to_string()]),
3031 ..MD033Config::default()
3032 };
3033 let rule = MD033NoInlineHtml::from_config_struct(config);
3034 let content = "| col |\n|-----|\n| <br><img src=\"x\"/> |\n";
3037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3038 let result = rule.check(&ctx).unwrap();
3039 assert_eq!(
3040 result.len(),
3041 1,
3042 "table_allowed should override `allowed` inside tables, got {result:?}"
3043 );
3044 assert!(
3045 result[0].message.contains("br"),
3046 "expected the flagged tag to be <br>, got {:?}",
3047 result[0].message
3048 );
3049 }
3050
3051 #[test]
3052 fn test_md033_table_allowed_does_not_affect_out_of_table_tags() {
3053 let config = MD033Config {
3054 allowed: vec!["br".to_string()],
3055 table_allowed_elements: Some(Vec::new()),
3056 ..MD033Config::default()
3057 };
3058 let rule = MD033NoInlineHtml::from_config_struct(config);
3059 let content = "Paragraph with <br> tag.\n";
3061 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3062 let result = rule.check(&ctx).unwrap();
3063 assert!(
3064 result.is_empty(),
3065 "<br> outside tables must still be allowed by `allowed`, got {result:?}"
3066 );
3067 }
3068
3069 #[test]
3070 fn test_md033_table_allowed_kebab_case_parses() {
3071 let toml_str = r#"
3072 allowed-elements = ["br"]
3073 table-allowed-elements = ["img"]
3074 "#;
3075 let config: MD033Config = toml::from_str(toml_str).unwrap();
3076 assert_eq!(config.allowed, vec!["br"]);
3077 assert_eq!(
3078 config.table_allowed_elements.as_deref(),
3079 Some(["img".to_string()].as_slice())
3080 );
3081 }
3082
3083 #[test]
3084 fn test_md033_table_allowed_snake_case_alias_parses() {
3085 let toml_str = r#"
3086 allowed_elements = ["br"]
3087 table_allowed_elements = ["img"]
3088 "#;
3089 let config: MD033Config = toml::from_str(toml_str).unwrap();
3090 assert_eq!(config.allowed, vec!["br"]);
3091 assert_eq!(
3092 config.table_allowed_elements.as_deref(),
3093 Some(["img".to_string()].as_slice())
3094 );
3095 }
3096
3097 #[test]
3098 fn test_md033_table_allowed_default_is_none() {
3099 let cfg = MD033Config::default();
3100 assert!(
3101 cfg.table_allowed_elements.is_none(),
3102 "Default for table_allowed_elements should be None (so it falls back to `allowed`)"
3103 );
3104 }
3105
3106 #[test]
3107 fn test_md033_table_allowed_case_insensitive() {
3108 let config = MD033Config {
3109 allowed: Vec::new(),
3110 table_allowed_elements: Some(vec!["BR".to_string()]),
3111 ..MD033Config::default()
3112 };
3113 let rule = MD033NoInlineHtml::from_config_struct(config);
3114 let content = "| col |\n|-----|\n| a<br>b |\n";
3115 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3116 let result = rule.check(&ctx).unwrap();
3117 assert!(
3118 result.is_empty(),
3119 "table_allowed should be case-insensitive, got {result:?}"
3120 );
3121 }
3122
3123 fn reported_tags_in(rule: &MD033NoInlineHtml, content: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
3132 let ctx = LintContext::new(content, flavor, None);
3133 rule.check(&ctx)
3134 .unwrap()
3135 .into_iter()
3136 .map(|warning| {
3137 warning
3138 .message
3139 .strip_prefix("Inline HTML found: ")
3140 .expect("MD033 reports the tag it found")
3141 .to_string()
3142 })
3143 .collect()
3144 }
3145
3146 fn reported_tags(rule: &MD033NoInlineHtml, content: &str) -> Vec<String> {
3147 reported_tags_in(rule, content, crate::config::MarkdownFlavor::Standard)
3148 }
3149
3150 fn rule_allowing_inside(elements: &[&str]) -> MD033NoInlineHtml {
3151 MD033NoInlineHtml::from_config_struct(MD033Config {
3152 allowed_inside: elements.iter().map(ToString::to_string).collect(),
3153 ..MD033Config::default()
3154 })
3155 }
3156
3157 #[test]
3158 fn test_md033_allowed_inside_permits_the_element_and_its_contents() {
3159 let content = "<details>\n<summary>\n<a href=\"./other.md\">Go there</a>\n</summary>\n<b>bold</b>\n</details>\n\nOutside <b>bold</b>.\n";
3160
3161 let without = reported_tags(&MD033NoInlineHtml::default(), content);
3162 assert_eq!(
3163 without,
3164 vec!["<details>", "<summary>", "<a href=\"./other.md\">", "<b>", "<b>"],
3165 "control: every tag is reported without the option"
3166 );
3167
3168 let with = reported_tags(&rule_allowing_inside(&["details"]), content);
3169 assert_eq!(
3170 with,
3171 vec!["<b>"],
3172 "only the tag outside the details element is left, got {with:?}"
3173 );
3174 }
3175
3176 #[test]
3177 fn test_md033_allowed_inside_ends_at_the_matching_closing_tag() {
3178 let content = "<details>\n<details>\n<b>x</b>\n</details>\n<i>y</i>\n</details>\n<b>z</b>\n";
3180 let reported = reported_tags(&rule_allowing_inside(&["details"]), content);
3181 assert_eq!(reported, vec!["<b>"], "got {reported:?}");
3182
3183 let unclosed = "<details>\n\n<b>x</b>\n\n<i>y</i>\n";
3185 assert!(
3186 reported_tags(&rule_allowing_inside(&["details"]), unclosed).is_empty(),
3187 "an unclosed element reaches the end of the document"
3188 );
3189 }
3190
3191 #[test]
3192 fn test_md033_allowed_inside_ignores_an_element_quoted_in_a_code_block() {
3193 let content = "```html\n<details>\n```\n\n<b>x</b>\n";
3194 let reported = reported_tags(&rule_allowing_inside(&["details"]), content);
3195 assert_eq!(
3196 reported,
3197 vec!["<b>"],
3198 "a code block quotes the element, it does not open one: {reported:?}"
3199 );
3200 }
3201
3202 #[test]
3203 fn test_md033_allowed_inside_a_void_element_holds_nothing() {
3204 let content = "a<br>b <b>x</b>\n";
3206 let reported = reported_tags(&rule_allowing_inside(&["br"]), content);
3207 assert_eq!(
3208 reported,
3209 vec!["<br>", "<b>"],
3210 "a void element must not swallow the rest of the document: {reported:?}"
3211 );
3212 }
3213
3214 #[test]
3215 fn test_md033_allowed_inside_is_case_insensitive() {
3216 let content = "<DETAILS>\n<b>x</b>\n</DETAILS>\n<i>y</i>\n";
3217 let reported = reported_tags(&rule_allowing_inside(&["Details"]), content);
3218 assert_eq!(reported, vec!["<i>"], "got {reported:?}");
3219 }
3220
3221 #[test]
3222 fn test_md033_allowed_inside_takes_no_part_in_disallowed_mode() {
3223 let config = MD033Config {
3224 allowed_inside: vec!["details".to_string()],
3225 disallowed: vec!["b".to_string()],
3226 ..MD033Config::default()
3227 };
3228 let rule = MD033NoInlineHtml::from_config_struct(config);
3229 let content = "<details>\n<b>x</b>\n<kbd>k</kbd>\n</details>\n";
3230 let reported = reported_tags(&rule, content);
3231 assert_eq!(
3232 reported,
3233 vec!["<b>"],
3234 "a denylist names what is wrong wherever it appears: {reported:?}"
3235 );
3236 }
3237
3238 #[test]
3239 fn test_md033_allowed_inside_yields_to_an_explicit_table_allowlist() {
3240 let content = "| col |\n|-----|\n| <details><b>x</b></details> |\n";
3241
3242 let unset = reported_tags(&rule_allowing_inside(&["details"]), content);
3243 assert!(
3244 unset.is_empty(),
3245 "without a table allowlist the element applies inside a cell too: {unset:?}"
3246 );
3247
3248 let config = MD033Config {
3249 allowed_inside: vec!["details".to_string()],
3250 table_allowed_elements: Some(Vec::new()),
3251 ..MD033Config::default()
3252 };
3253 let rule = MD033NoInlineHtml::from_config_struct(config);
3254 let reported = reported_tags(&rule, content);
3255 assert_eq!(
3256 reported,
3257 vec!["<details>", "<b>"],
3258 "an explicit table allowlist decides inside a cell: {reported:?}"
3259 );
3260 }
3261
3262 fn rule_allowing(elements: &[&str]) -> MD033NoInlineHtml {
3270 MD033NoInlineHtml::with_allowed(elements.iter().map(ToString::to_string).collect())
3271 }
3272
3273 #[test]
3274 fn test_md033_no_markdown_equivalent_reports_only_what_markdown_can_write() {
3275 let content = "Press <kbd>Ctrl</kbd>, <b>bold</b>, <mark>hi</mark>, <abbr title=\"x\">A</abbr>, <em>i</em>, <details>d</details>\n";
3276
3277 let control = reported_tags(&MD033NoInlineHtml::default(), content);
3278 assert_eq!(control.len(), 6, "control: every tag is reported: {control:?}");
3279
3280 let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent"]), content);
3281 assert_eq!(
3282 reported,
3283 vec!["<b>", "<em>"],
3284 "only the elements with Markdown syntax are left: {reported:?}"
3285 );
3286 }
3287
3288 #[test]
3289 fn test_md033_no_markdown_equivalent_keeps_reporting_gfm_filtered_tags() {
3290 let content = "<kbd>k</kbd>\n\n<script>alert(1)</script>\n\n<iframe src=\"x\"></iframe>\n";
3292 let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent"]), content);
3293 assert_eq!(reported, vec!["<script>", "<iframe src=\"x\">"], "got {reported:?}");
3294 }
3295
3296 #[test]
3297 fn test_md033_no_markdown_equivalent_follows_the_flavor() {
3298 let content = "H<sub>2</sub>O, x<sup>2</sup>, <mark>hi</mark>, <kbd>k</kbd>\n";
3299 let rule = rule_allowing(&["no-markdown-equivalent"]);
3300
3301 assert!(
3302 reported_tags_in(&rule, content, crate::config::MarkdownFlavor::Standard).is_empty(),
3303 "standard Markdown writes none of these"
3304 );
3305 assert_eq!(
3306 reported_tags_in(&rule, content, crate::config::MarkdownFlavor::Pandoc),
3307 vec!["<sub>", "<sup>"],
3308 "Pandoc writes ~sub~ and ^sup^"
3309 );
3310 assert_eq!(
3311 reported_tags_in(&rule, content, crate::config::MarkdownFlavor::Obsidian),
3312 vec!["<mark>"],
3313 "Obsidian writes ==highlight=="
3314 );
3315 }
3316
3317 #[test]
3318 fn test_md033_no_markdown_equivalent_permits_a_line_break_inside_a_table_cell() {
3319 let content = "| col |\n|-----|\n| a<br>b |\n\nOutside a<br>b.\n";
3322 let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent"]), content);
3323 assert_eq!(reported, vec!["<br>"], "got {reported:?}");
3324 }
3325
3326 #[test]
3327 fn test_md033_no_markdown_equivalent_composes_with_named_elements() {
3328 let content = "<kbd>k</kbd> <b>bold</b> <em>italic</em>\n";
3329 let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent", "b"]), content);
3330 assert_eq!(
3331 reported,
3332 vec!["<em>"],
3333 "a named element joins the ones the sentinel permits: {reported:?}"
3334 );
3335 }
3336
3337 #[test]
3338 fn test_md033_no_markdown_equivalent_reads_the_snake_case_spelling() {
3339 let content = "<kbd>k</kbd> <b>bold</b>\n";
3341 let reported = reported_tags(&rule_allowing(&["no_markdown_equivalent"]), content);
3342 assert_eq!(reported, vec!["<b>"], "got {reported:?}");
3343 }
3344
3345 #[test]
3346 fn test_md033_no_markdown_equivalent_takes_no_part_in_disallowed_mode() {
3347 let config = MD033Config {
3348 allowed: vec!["no-markdown-equivalent".to_string()],
3349 disallowed: vec!["kbd".to_string()],
3350 ..MD033Config::default()
3351 };
3352 let rule = MD033NoInlineHtml::from_config_struct(config);
3353 let reported = reported_tags(&rule, "<kbd>k</kbd> <b>bold</b>\n");
3354 assert_eq!(
3355 reported,
3356 vec!["<kbd>"],
3357 "a denylist names what is wrong wherever it appears: {reported:?}"
3358 );
3359 }
3360}