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