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