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_ignores_backslash_escaped_custom_element() {
1314 let rule = MD033NoInlineHtml::default();
1315 let content = r"Press \<x-keyboard> to continue, but flag <x-keyboard>.";
1316 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1317
1318 let result = rule.check(&ctx).unwrap();
1319 assert_eq!(result.len(), 1);
1320 assert_eq!(result[0].column, 43);
1321 assert_eq!(result[0].message, "Inline HTML found: <x-keyboard>");
1322 }
1323
1324 #[test]
1325 fn test_md033_front_matter() {
1326 let rule = MD033NoInlineHtml::default();
1327 let content = "---\ndescription: <div class=\"test\">hello</div>\n---\n# Title\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, 5);
1333 assert_eq!(result[0].message, "Inline HTML found: <div>");
1334 }
1335
1336 #[test]
1337 fn test_md033_math_block() {
1338 let rule = MD033NoInlineHtml::default();
1339 let content = "$$\nx < y && y > z\n$$\n<div>body</div>";
1340 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1341 let result = rule.check(&ctx).unwrap();
1342 assert_eq!(result.len(), 1);
1344 assert_eq!(result[0].line, 4);
1345 }
1346
1347 #[test]
1348 fn test_md033_case_insensitive() {
1349 let rule = MD033NoInlineHtml::default();
1350 let content = "<DiV>Some <B>content</B></dIv>";
1351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1352 let result = rule.check(&ctx).unwrap();
1353 assert_eq!(result.len(), 2); assert_eq!(result[0].message, "Inline HTML found: <DiV>");
1356 assert_eq!(result[1].message, "Inline HTML found: <B>");
1357 }
1358
1359 #[test]
1360 fn test_md033_multibyte_whitespace_in_tag_does_not_panic() {
1361 let rule = relaxed_fix_rule();
1364 let content = "<img\u{00A0}src=\"test.png\" alt=\"x\">";
1365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1366 let _ = rule.check(&ctx).unwrap();
1368 let _ = rule.fix(&ctx).unwrap();
1369 }
1370
1371 #[test]
1372 fn test_md033_allowed_tags() {
1373 let rule = MD033NoInlineHtml::with_allowed(vec!["div".to_string(), "br".to_string()]);
1374 let content = "<div>Allowed</div><p>Not allowed</p><br/>";
1375 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1376 let result = rule.check(&ctx).unwrap();
1377 assert_eq!(result.len(), 1);
1379 assert_eq!(result[0].message, "Inline HTML found: <p>");
1380
1381 let content2 = "<DIV>Allowed</DIV><P>Not allowed</P><BR/>";
1383 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1384 let result2 = rule.check(&ctx2).unwrap();
1385 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <P>");
1387 }
1388
1389 #[test]
1390 fn test_md033_html_comments() {
1391 let rule = MD033NoInlineHtml::default();
1392 let content = "<!-- This is a comment --> <p>Not a comment</p>";
1393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1394 let result = rule.check(&ctx).unwrap();
1395 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <p>");
1398 }
1399
1400 #[test]
1401 fn test_md033_tags_in_links() {
1402 let rule = MD033NoInlineHtml::default();
1403 let content = "[Link](http://example.com/<div>)";
1404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1405 let result = rule.check(&ctx).unwrap();
1406 assert_eq!(result.len(), 1);
1408 assert_eq!(result[0].message, "Inline HTML found: <div>");
1409
1410 let content2 = "[Link <a>text</a>](url)";
1411 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1412 let result2 = rule.check(&ctx2).unwrap();
1413 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <a>");
1416 }
1417
1418 #[test]
1419 fn test_md033_fix_escaping() {
1420 let rule = MD033NoInlineHtml::default();
1421 let content = "Text with <div> and <br/> tags.";
1422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1423 let fixed_content = rule.fix(&ctx).unwrap();
1424 assert_eq!(fixed_content, content);
1426 }
1427
1428 #[test]
1429 fn test_md033_in_code_blocks() {
1430 let rule = MD033NoInlineHtml::default();
1431 let content = "```html\n<div>Code</div>\n```\n<div>Not code</div>";
1432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1433 let result = rule.check(&ctx).unwrap();
1434 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <div>");
1437 }
1438
1439 #[test]
1440 fn test_md033_in_code_spans() {
1441 let rule = MD033NoInlineHtml::default();
1442 let content = "Text with `<p>in code</p>` span. <br/> Not in span.";
1443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1444 let result = rule.check(&ctx).unwrap();
1445 assert_eq!(result.len(), 1);
1447 assert_eq!(result[0].message, "Inline HTML found: <br/>");
1448 }
1449
1450 #[test]
1451 fn test_md033_issue_90_code_span_with_diff_block() {
1452 let rule = MD033NoInlineHtml::default();
1454 let content = r#"# Heading
1455
1456`<env>`
1457
1458```diff
1459- this
1460+ that
1461```"#;
1462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1463 let result = rule.check(&ctx).unwrap();
1464 assert_eq!(result.len(), 0, "Should not report HTML tags inside code spans");
1466 }
1467
1468 #[test]
1469 fn test_md033_multiple_code_spans_with_angle_brackets() {
1470 let rule = MD033NoInlineHtml::default();
1472 let content = "`<one>` and `<two>` and `<three>` are all code spans";
1473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1474 let result = rule.check(&ctx).unwrap();
1475 assert_eq!(result.len(), 0, "Should not report HTML tags inside any code spans");
1476 }
1477
1478 #[test]
1479 fn test_md033_nested_angle_brackets_in_code_span() {
1480 let rule = MD033NoInlineHtml::default();
1482 let content = "Text with `<<nested>>` brackets";
1483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1484 let result = rule.check(&ctx).unwrap();
1485 assert_eq!(result.len(), 0, "Should handle nested angle brackets in code spans");
1486 }
1487
1488 #[test]
1489 fn test_md033_code_span_at_end_before_code_block() {
1490 let rule = MD033NoInlineHtml::default();
1492 let content = "Testing `<test>`\n```\ncode here\n```";
1493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1494 let result = rule.check(&ctx).unwrap();
1495 assert_eq!(result.len(), 0, "Should handle code span before code block");
1496 }
1497
1498 #[test]
1499 fn test_md033_quick_fix_inline_tag() {
1500 let rule = MD033NoInlineHtml::default();
1503 let content = "This has <span>inline text</span> that should keep content.";
1504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1505 let result = rule.check(&ctx).unwrap();
1506
1507 assert_eq!(result.len(), 1, "Should find one HTML tag");
1508 assert!(
1510 result[0].fix.is_none(),
1511 "Non-fixable tags like <span> should not have a fix"
1512 );
1513 }
1514
1515 #[test]
1516 fn test_md033_quick_fix_multiline_tag() {
1517 let rule = MD033NoInlineHtml::default();
1520 let content = "<div>\nBlock content\n</div>";
1521 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1522 let result = rule.check(&ctx).unwrap();
1523
1524 assert_eq!(result.len(), 1, "Should find one HTML tag");
1525 assert!(result[0].fix.is_none(), "HTML block elements should NOT have auto-fix");
1527 }
1528
1529 #[test]
1530 fn test_md033_quick_fix_self_closing_tag() {
1531 let rule = MD033NoInlineHtml::default();
1533 let content = "Self-closing: <br/>";
1534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1535 let result = rule.check(&ctx).unwrap();
1536
1537 assert_eq!(result.len(), 1, "Should find one HTML tag");
1538 assert!(
1540 result[0].fix.is_none(),
1541 "Self-closing tags should not have a fix when fix config is false"
1542 );
1543 }
1544
1545 #[test]
1546 fn test_md033_quick_fix_multiple_tags() {
1547 let rule = MD033NoInlineHtml::default();
1550 let content = "<span>first</span> and <strong>second</strong>";
1551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1552 let result = rule.check(&ctx).unwrap();
1553
1554 assert_eq!(result.len(), 2, "Should find two HTML tags");
1555 assert!(result[0].fix.is_none(), "Non-fixable <span> should not have a fix");
1557 assert!(
1558 result[1].fix.is_none(),
1559 "<strong> should not have a fix when fix config is false"
1560 );
1561 }
1562
1563 #[test]
1564 fn test_md033_skip_angle_brackets_in_link_titles() {
1565 let rule = MD033NoInlineHtml::default();
1567 let content = r#"# Test
1568
1569[example]: <https://example.com> "Title with <Angle Brackets> inside"
1570
1571Regular text with <div>content</div> HTML tag.
1572"#;
1573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1574 let result = rule.check(&ctx).unwrap();
1575
1576 assert_eq!(result.len(), 1, "Should find opening div tag");
1579 assert!(
1580 result[0].message.contains("<div>"),
1581 "Should flag <div>, got: {}",
1582 result[0].message
1583 );
1584 }
1585
1586 #[test]
1587 fn test_md033_skip_angle_brackets_in_link_title_single_quotes() {
1588 let rule = MD033NoInlineHtml::default();
1590 let content = r#"[ref]: url 'Title <Help Wanted> here'
1591
1592<span>text</span> here
1593"#;
1594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1595 let result = rule.check(&ctx).unwrap();
1596
1597 assert_eq!(result.len(), 1, "Should find opening span tag");
1600 assert!(
1601 result[0].message.contains("<span>"),
1602 "Should flag <span>, got: {}",
1603 result[0].message
1604 );
1605 }
1606
1607 #[test]
1608 fn test_md033_multiline_tag_end_line_calculation() {
1609 let rule = MD033NoInlineHtml::default();
1611 let content = "<div\n class=\"test\"\n id=\"example\">";
1612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1613 let result = rule.check(&ctx).unwrap();
1614
1615 assert_eq!(result.len(), 1, "Should find one HTML tag");
1616 assert_eq!(result[0].line, 1, "Start line should be 1");
1618 assert_eq!(result[0].end_line, 3, "End line should be 3");
1620 }
1621
1622 #[test]
1623 fn test_md033_single_line_tag_same_start_end_line() {
1624 let rule = MD033NoInlineHtml::default();
1626 let content = "Some text <div class=\"test\"> more text";
1627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1628 let result = rule.check(&ctx).unwrap();
1629
1630 assert_eq!(result.len(), 1, "Should find one HTML tag");
1631 assert_eq!(result[0].line, 1, "Start line should be 1");
1632 assert_eq!(result[0].end_line, 1, "End line should be 1 for single-line tag");
1633 }
1634
1635 #[test]
1636 fn test_md033_multiline_tag_with_many_attributes() {
1637 let rule = MD033NoInlineHtml::default();
1639 let content =
1640 "Text\n<div\n data-attr1=\"value1\"\n data-attr2=\"value2\"\n data-attr3=\"value3\">\nMore text";
1641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1642 let result = rule.check(&ctx).unwrap();
1643
1644 assert_eq!(result.len(), 1, "Should find one HTML tag");
1645 assert_eq!(result[0].line, 2, "Start line should be 2");
1647 assert_eq!(result[0].end_line, 5, "End line should be 5");
1649 }
1650
1651 #[test]
1652 fn test_md033_disallowed_mode_basic() {
1653 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string(), "iframe".to_string()]);
1655 let content = "<div>Safe content</div><script>alert('xss')</script>";
1656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1657 let result = rule.check(&ctx).unwrap();
1658
1659 assert_eq!(result.len(), 1, "Should only flag disallowed tags");
1661 assert!(result[0].message.contains("<script>"), "Should flag script tag");
1662 }
1663
1664 #[test]
1665 fn test_md033_disallowed_gfm_security_tags() {
1666 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1668 let content = r#"
1669<div>Safe</div>
1670<title>Bad title</title>
1671<textarea>Bad textarea</textarea>
1672<style>.bad{}</style>
1673<iframe src="evil"></iframe>
1674<script>evil()</script>
1675<plaintext>old tag</plaintext>
1676<span>Safe span</span>
1677"#;
1678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1679 let result = rule.check(&ctx).unwrap();
1680
1681 assert_eq!(result.len(), 6, "Should flag 6 GFM security tags");
1684
1685 let flagged_tags: Vec<&str> = result
1686 .iter()
1687 .filter_map(|w| w.message.split('<').nth(1))
1688 .filter_map(|s| s.split('>').next())
1689 .filter_map(|s| s.split_whitespace().next())
1690 .collect();
1691
1692 assert!(flagged_tags.contains(&"title"), "Should flag title");
1693 assert!(flagged_tags.contains(&"textarea"), "Should flag textarea");
1694 assert!(flagged_tags.contains(&"style"), "Should flag style");
1695 assert!(flagged_tags.contains(&"iframe"), "Should flag iframe");
1696 assert!(flagged_tags.contains(&"script"), "Should flag script");
1697 assert!(flagged_tags.contains(&"plaintext"), "Should flag plaintext");
1698 assert!(!flagged_tags.contains(&"div"), "Should NOT flag div");
1699 assert!(!flagged_tags.contains(&"span"), "Should NOT flag span");
1700 }
1701
1702 #[test]
1703 fn test_md033_disallowed_case_insensitive() {
1704 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string()]);
1706 let content = "<SCRIPT>alert('xss')</SCRIPT><Script>alert('xss')</Script>";
1707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1708 let result = rule.check(&ctx).unwrap();
1709
1710 assert_eq!(result.len(), 2, "Should flag both case variants");
1712 }
1713
1714 #[test]
1715 fn test_md033_disallowed_with_attributes() {
1716 let rule = MD033NoInlineHtml::with_disallowed(vec!["iframe".to_string()]);
1718 let content = r#"<iframe src="https://evil.com" width="100" height="100"></iframe>"#;
1719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1720 let result = rule.check(&ctx).unwrap();
1721
1722 assert_eq!(result.len(), 1, "Should flag iframe with attributes");
1723 assert!(result[0].message.contains("iframe"), "Should flag iframe");
1724 }
1725
1726 #[test]
1727 fn test_md033_disallowed_all_gfm_tags() {
1728 use md033_config::GFM_DISALLOWED_TAGS;
1730 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1731
1732 for tag in GFM_DISALLOWED_TAGS {
1733 let content = format!("<{tag}>content</{tag}>");
1734 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1735 let result = rule.check(&ctx).unwrap();
1736
1737 assert_eq!(result.len(), 1, "GFM tag <{tag}> should be flagged");
1738 }
1739 }
1740
1741 #[test]
1742 fn test_md033_disallowed_mixed_with_custom() {
1743 let rule = MD033NoInlineHtml::with_disallowed(vec![
1745 "gfm".to_string(),
1746 "marquee".to_string(), ]);
1748 let content = r#"<script>bad</script><marquee>annoying</marquee><div>ok</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(), 2, "Should flag both gfm and custom tags");
1754 }
1755
1756 #[test]
1757 fn test_md033_disallowed_empty_means_default_mode() {
1758 let rule = MD033NoInlineHtml::with_disallowed(vec![]);
1760 let content = "<div>content</div>";
1761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1762 let result = rule.check(&ctx).unwrap();
1763
1764 assert_eq!(result.len(), 1, "Empty disallowed = default mode");
1766 }
1767
1768 #[test]
1769 fn test_md033_jsx_fragments_in_mdx() {
1770 let rule = MD033NoInlineHtml::default();
1772 let content = r#"# MDX Document
1773
1774<>
1775 <Heading />
1776 <Content />
1777</>
1778
1779<div>Regular HTML should still be flagged</div>
1780"#;
1781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1782 let result = rule.check(&ctx).unwrap();
1783
1784 assert_eq!(result.len(), 1, "Should only find one HTML tag (the div)");
1786 assert!(
1787 result[0].message.contains("<div>"),
1788 "Should flag <div>, not JSX fragments"
1789 );
1790 }
1791
1792 #[test]
1793 fn test_md033_jsx_components_in_mdx() {
1794 let rule = MD033NoInlineHtml::default();
1796 let content = r#"<CustomComponent prop="value">
1797 Content
1798</CustomComponent>
1799
1800<MyButton onClick={handler}>Click</MyButton>
1801"#;
1802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1803 let result = rule.check(&ctx).unwrap();
1804
1805 assert_eq!(result.len(), 0, "Should not flag JSX components in MDX");
1807 }
1808
1809 #[test]
1810 fn test_md033_jsx_not_skipped_in_standard_markdown() {
1811 let rule = MD033NoInlineHtml::default();
1813 let content = "<Script>alert(1)</Script>";
1814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1815 let result = rule.check(&ctx).unwrap();
1816
1817 assert_eq!(result.len(), 1, "Should flag <Script> in standard markdown");
1819 }
1820
1821 #[test]
1822 fn test_md033_jsx_attributes_in_mdx() {
1823 let rule = MD033NoInlineHtml::default();
1825 let content = r#"# MDX with JSX Attributes
1826
1827<div className="card big">Content</div>
1828
1829<button onClick={handleClick}>Click me</button>
1830
1831<label htmlFor="input-id">Label</label>
1832
1833<input onChange={handleChange} />
1834
1835<div class="html-class">Regular HTML should be flagged</div>
1836"#;
1837 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1838 let result = rule.check(&ctx).unwrap();
1839
1840 assert_eq!(
1842 result.len(),
1843 1,
1844 "Should only flag HTML element without JSX attributes, got: {result:?}"
1845 );
1846 assert!(
1847 result[0].message.contains("<div class="),
1848 "Should flag the div with HTML class attribute"
1849 );
1850 }
1851
1852 #[test]
1853 fn test_md033_jsx_attributes_not_skipped_in_standard() {
1854 let rule = MD033NoInlineHtml::default();
1856 let content = r#"<div className="card">Content</div>"#;
1857 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1858 let result = rule.check(&ctx).unwrap();
1859
1860 assert_eq!(result.len(), 1, "Should flag JSX-style elements in standard markdown");
1862 }
1863
1864 #[test]
1867 fn test_md033_fix_disabled_by_default() {
1868 let rule = MD033NoInlineHtml::default();
1870 assert!(!rule.config.fix, "Fix should be disabled by default");
1871 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::Unfixable);
1872 }
1873
1874 #[test]
1875 fn test_md033_fix_enabled_em_to_italic() {
1876 let rule = MD033NoInlineHtml::with_fix(true);
1878 let content = "This has <em>emphasized text</em> here.";
1879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1880 let fixed = rule.fix(&ctx).unwrap();
1881 assert_eq!(fixed, "This has *emphasized text* here.");
1882 }
1883
1884 #[test]
1885 fn test_md033_fix_enabled_i_to_italic() {
1886 let rule = MD033NoInlineHtml::with_fix(true);
1888 let content = "This has <i>italic text</i> here.";
1889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1890 let fixed = rule.fix(&ctx).unwrap();
1891 assert_eq!(fixed, "This has *italic text* here.");
1892 }
1893
1894 #[test]
1895 fn test_md033_fix_enabled_strong_to_bold() {
1896 let rule = MD033NoInlineHtml::with_fix(true);
1898 let content = "This has <strong>bold text</strong> here.";
1899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1900 let fixed = rule.fix(&ctx).unwrap();
1901 assert_eq!(fixed, "This has **bold text** here.");
1902 }
1903
1904 #[test]
1905 fn test_md033_fix_enabled_b_to_bold() {
1906 let rule = MD033NoInlineHtml::with_fix(true);
1908 let content = "This has <b>bold text</b> here.";
1909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1910 let fixed = rule.fix(&ctx).unwrap();
1911 assert_eq!(fixed, "This has **bold text** here.");
1912 }
1913
1914 #[test]
1915 fn test_md033_fix_enabled_code_to_backticks() {
1916 let rule = MD033NoInlineHtml::with_fix(true);
1918 let content = "This has <code>inline code</code> here.";
1919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1920 let fixed = rule.fix(&ctx).unwrap();
1921 assert_eq!(fixed, "This has `inline code` here.");
1922 }
1923
1924 #[test]
1925 fn test_md033_fix_enabled_code_with_backticks() {
1926 let rule = MD033NoInlineHtml::with_fix(true);
1928 let content = "This has <code>text with `backticks`</code> here.";
1929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1930 let fixed = rule.fix(&ctx).unwrap();
1931 assert_eq!(fixed, "This has `` text with `backticks` `` here.");
1932 }
1933
1934 #[test]
1935 fn test_md033_fix_enabled_br_trailing_spaces() {
1936 let rule = MD033NoInlineHtml::with_fix(true);
1938 let content = "First line<br>Second line";
1939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1940 let fixed = rule.fix(&ctx).unwrap();
1941 assert_eq!(fixed, "First line \nSecond line");
1942 }
1943
1944 #[test]
1945 fn test_md033_fix_enabled_br_self_closing() {
1946 let rule = MD033NoInlineHtml::with_fix(true);
1948 let content = "First<br/>second<br />third";
1949 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1950 let fixed = rule.fix(&ctx).unwrap();
1951 assert_eq!(fixed, "First \nsecond \nthird");
1952 }
1953
1954 #[test]
1955 fn test_md033_fix_enabled_br_backslash_style() {
1956 let config = MD033Config {
1958 allowed: Vec::new(),
1959 disallowed: Vec::new(),
1960 fix: true,
1961 br_style: md033_config::BrStyle::Backslash,
1962 ..MD033Config::default()
1963 };
1964 let rule = MD033NoInlineHtml::from_config_struct(config);
1965 let content = "First line<br>Second line";
1966 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1967 let fixed = rule.fix(&ctx).unwrap();
1968 assert_eq!(fixed, "First line\\\nSecond line");
1969 }
1970
1971 #[test]
1972 fn test_md033_fix_enabled_hr() {
1973 let rule = MD033NoInlineHtml::with_fix(true);
1975 let content = "Above<hr>Below";
1976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1977 let fixed = rule.fix(&ctx).unwrap();
1978 assert_eq!(fixed, "Above\n---\nBelow");
1979 }
1980
1981 #[test]
1982 fn test_md033_fix_enabled_hr_self_closing() {
1983 let rule = MD033NoInlineHtml::with_fix(true);
1985 let content = "Above<hr/>Below";
1986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1987 let fixed = rule.fix(&ctx).unwrap();
1988 assert_eq!(fixed, "Above\n---\nBelow");
1989 }
1990
1991 #[test]
1992 fn test_md033_fix_skips_nested_tags() {
1993 let rule = MD033NoInlineHtml::with_fix(true);
1996 let content = "This has <em>text with <strong>nested</strong> tags</em> here.";
1997 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1998 let fixed = rule.fix(&ctx).unwrap();
1999 assert_eq!(fixed, "This has <em>text with **nested** tags</em> here.");
2002 }
2003
2004 #[test]
2005 fn test_md033_fix_skips_tags_with_attributes() {
2006 let rule = MD033NoInlineHtml::with_fix(true);
2009 let content = "This has <em class=\"highlight\">emphasized</em> text.";
2010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2011 let fixed = rule.fix(&ctx).unwrap();
2012 assert_eq!(fixed, content);
2014 }
2015
2016 #[test]
2017 fn test_md033_fix_disabled_no_changes() {
2018 let rule = MD033NoInlineHtml::default(); let content = "This has <em>emphasized text</em> here.";
2021 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2022 let fixed = rule.fix(&ctx).unwrap();
2023 assert_eq!(fixed, content, "Should return original content when fix is disabled");
2024 }
2025
2026 #[test]
2027 fn test_md033_fix_capability_enabled() {
2028 let rule = MD033NoInlineHtml::with_fix(true);
2029 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::FullyFixable);
2030 }
2031
2032 #[test]
2033 fn test_md033_fix_multiple_tags() {
2034 let rule = MD033NoInlineHtml::with_fix(true);
2036 let content = "Here is <em>italic</em> and <strong>bold</strong> text.";
2037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2038 let fixed = rule.fix(&ctx).unwrap();
2039 assert_eq!(fixed, "Here is *italic* and **bold** text.");
2040 }
2041
2042 #[test]
2043 fn test_md033_fix_uppercase_tags() {
2044 let rule = MD033NoInlineHtml::with_fix(true);
2046 let content = "This has <EM>emphasized</EM> text.";
2047 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2048 let fixed = rule.fix(&ctx).unwrap();
2049 assert_eq!(fixed, "This has *emphasized* text.");
2050 }
2051
2052 #[test]
2053 fn test_md033_fix_unsafe_tags_not_modified() {
2054 let rule = MD033NoInlineHtml::with_fix(true);
2057 let content = "This has <div>a div</div> content.";
2058 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2059 let fixed = rule.fix(&ctx).unwrap();
2060 assert_eq!(fixed, "This has <div>a div</div> content.");
2062 }
2063
2064 #[test]
2065 fn test_md033_fix_img_tag_converted() {
2066 let rule = MD033NoInlineHtml::with_fix(true);
2068 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\">";
2069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2070 let fixed = rule.fix(&ctx).unwrap();
2071 assert_eq!(fixed, "Image: ");
2073 }
2074
2075 #[test]
2076 fn test_md033_fix_img_tag_with_extra_attrs_not_converted() {
2077 let rule = MD033NoInlineHtml::with_fix(true);
2079 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
2080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2081 let fixed = rule.fix(&ctx).unwrap();
2082 assert_eq!(fixed, "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">");
2084 }
2085
2086 #[test]
2087 fn test_md033_fix_relaxed_a_with_target_is_converted() {
2088 let rule = relaxed_fix_rule();
2089 let content = "Link: <a href=\"https://example.com\" target=\"_blank\">Example</a>";
2090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2091 let fixed = rule.fix(&ctx).unwrap();
2092 assert_eq!(fixed, "Link: [Example](https://example.com)");
2093 }
2094
2095 #[test]
2096 fn test_md033_fix_relaxed_img_with_width_is_converted() {
2097 let rule = relaxed_fix_rule();
2098 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
2099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2100 let fixed = rule.fix(&ctx).unwrap();
2101 assert_eq!(fixed, "Image: ");
2102 }
2103
2104 #[test]
2105 fn test_md033_fix_relaxed_rejects_unknown_extra_attributes() {
2106 let rule = relaxed_fix_rule();
2107 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" aria-label=\"hero\">";
2108 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2109 let fixed = rule.fix(&ctx).unwrap();
2110 assert_eq!(fixed, content, "Unknown attributes should not be dropped by default");
2111 }
2112
2113 #[test]
2114 fn test_md033_fix_relaxed_still_blocks_unsafe_schemes() {
2115 let rule = relaxed_fix_rule();
2116 let content = "Link: <a href=\"javascript:alert(1)\" target=\"_blank\">Example</a>";
2117 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2118 let fixed = rule.fix(&ctx).unwrap();
2119 assert_eq!(fixed, content, "Unsafe URL schemes must never be converted");
2120 }
2121
2122 #[test]
2123 fn test_md033_fix_relaxed_wrapper_strip_requires_second_pass_for_nested_html() {
2124 let rule = relaxed_fix_rule();
2125 let content = "<p align=\"center\">\n <img src=\"logo.svg\" alt=\"Logo\" width=\"120\" />\n</p>";
2126 let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2127 let fixed_once = rule.fix(&ctx1).unwrap();
2128 assert!(
2129 fixed_once.contains("<p"),
2130 "First pass should keep wrapper when inner HTML is still present: {fixed_once}"
2131 );
2132 assert!(
2133 fixed_once.contains(""),
2134 "Inner image should be converted on first pass: {fixed_once}"
2135 );
2136
2137 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2138 let fixed_twice = rule.fix(&ctx2).unwrap();
2139 assert!(
2140 !fixed_twice.contains("<p"),
2141 "Second pass should strip configured wrapper: {fixed_twice}"
2142 );
2143 assert!(fixed_twice.contains(""));
2144 }
2145
2146 #[test]
2147 fn test_md033_fix_relaxed_multiple_droppable_attrs() {
2148 let rule = relaxed_fix_rule();
2149 let content = "<a href=\"https://example.com\" target=\"_blank\" rel=\"noopener\" class=\"btn\">Click</a>";
2150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2151 let fixed = rule.fix(&ctx).unwrap();
2152 assert_eq!(fixed, "[Click](https://example.com)");
2153 }
2154
2155 #[test]
2156 fn test_md033_fix_relaxed_img_multiple_droppable_attrs() {
2157 let rule = relaxed_fix_rule();
2158 let content = "<img src=\"logo.png\" alt=\"Logo\" width=\"120\" height=\"40\" style=\"border:none\" />";
2159 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2160 let fixed = rule.fix(&ctx).unwrap();
2161 assert_eq!(fixed, "");
2162 }
2163
2164 #[test]
2165 fn test_md033_fix_relaxed_event_handler_never_dropped() {
2166 let rule = relaxed_fix_rule();
2167 let content = "<a href=\"https://example.com\" onclick=\"track()\">Link</a>";
2168 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2169 let fixed = rule.fix(&ctx).unwrap();
2170 assert_eq!(fixed, content, "Event handler attributes must block conversion");
2171 }
2172
2173 #[test]
2174 fn test_md033_fix_relaxed_event_handler_even_with_custom_config() {
2175 let config = MD033Config {
2177 fix: true,
2178 fix_mode: MD033FixMode::Relaxed,
2179 drop_attributes: vec!["on*".to_string(), "target".to_string()],
2180 ..MD033Config::default()
2181 };
2182 let rule = MD033NoInlineHtml::from_config_struct(config);
2183 let content = "<a href=\"https://example.com\" onclick=\"alert(1)\">Link</a>";
2184 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2185 let fixed = rule.fix(&ctx).unwrap();
2186 assert_eq!(fixed, content, "on* event handlers must never be dropped");
2187 }
2188
2189 #[test]
2190 fn test_md033_fix_relaxed_custom_drop_attributes() {
2191 let config = MD033Config {
2192 fix: true,
2193 fix_mode: MD033FixMode::Relaxed,
2194 drop_attributes: vec!["loading".to_string()],
2195 ..MD033Config::default()
2196 };
2197 let rule = MD033NoInlineHtml::from_config_struct(config);
2198 let content = "<img src=\"x.jpg\" alt=\"\" loading=\"lazy\">";
2200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2201 let fixed = rule.fix(&ctx).unwrap();
2202 assert_eq!(fixed, "", "Custom drop-attributes should be respected");
2203
2204 let content2 = "<img src=\"x.jpg\" alt=\"\" width=\"100\">";
2205 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
2206 let fixed2 = rule.fix(&ctx2).unwrap();
2207 assert_eq!(
2208 fixed2, content2,
2209 "Attributes not in custom list should block conversion"
2210 );
2211 }
2212
2213 #[test]
2214 fn test_md033_fix_relaxed_custom_strip_wrapper() {
2215 let config = MD033Config {
2216 fix: true,
2217 fix_mode: MD033FixMode::Relaxed,
2218 strip_wrapper_elements: vec!["div".to_string()],
2219 ..MD033Config::default()
2220 };
2221 let rule = MD033NoInlineHtml::from_config_struct(config);
2222 let content = "<div>Some text content</div>";
2223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2224 let fixed = rule.fix(&ctx).unwrap();
2225 assert_eq!(fixed, "Some text content");
2226 }
2227
2228 #[test]
2229 fn test_md033_fix_relaxed_wrapper_with_plain_text() {
2230 let rule = relaxed_fix_rule();
2231 let content = "<p align=\"center\">Just some text</p>";
2232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2233 let fixed = rule.fix(&ctx).unwrap();
2234 assert_eq!(fixed, "Just some text");
2235 }
2236
2237 #[test]
2238 fn test_md033_fix_relaxed_data_attr_with_wildcard() {
2239 let config = MD033Config {
2240 fix: true,
2241 fix_mode: MD033FixMode::Relaxed,
2242 drop_attributes: vec!["data-*".to_string(), "target".to_string()],
2243 ..MD033Config::default()
2244 };
2245 let rule = MD033NoInlineHtml::from_config_struct(config);
2246 let content = "<a href=\"https://example.com\" data-tracking=\"abc\" target=\"_blank\">Link</a>";
2247 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2248 let fixed = rule.fix(&ctx).unwrap();
2249 assert_eq!(fixed, "[Link](https://example.com)");
2250 }
2251
2252 #[test]
2253 fn test_md033_fix_relaxed_mixed_droppable_and_blocking_attrs() {
2254 let rule = relaxed_fix_rule();
2255 let content = "<a href=\"https://example.com\" target=\"_blank\" aria-label=\"nav\">Link</a>";
2257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2258 let fixed = rule.fix(&ctx).unwrap();
2259 assert_eq!(fixed, content, "Non-droppable attribute should block conversion");
2260 }
2261
2262 #[test]
2263 fn test_md033_fix_relaxed_badge_pattern() {
2264 let rule = relaxed_fix_rule();
2266 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>";
2267 let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2268 let fixed_once = rule.fix(&ctx1).unwrap();
2269 assert!(
2271 fixed_once.contains(""),
2272 "Inner img should be converted: {fixed_once}"
2273 );
2274
2275 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2277 let fixed_twice = rule.fix(&ctx2).unwrap();
2278 assert!(
2279 fixed_twice
2280 .contains("[](https://crates.io/crates/rumdl)"),
2281 "Badge should produce nested markdown image link: {fixed_twice}"
2282 );
2283 }
2284
2285 #[test]
2286 fn test_md033_fix_relaxed_conservative_mode_unchanged() {
2287 let rule = MD033NoInlineHtml::with_fix(true);
2289 let content = "<a href=\"https://example.com\" target=\"_blank\">Link</a>";
2290 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2291 let fixed = rule.fix(&ctx).unwrap();
2292 assert_eq!(fixed, content, "Conservative mode should not drop target attribute");
2293 }
2294
2295 #[test]
2296 fn test_md033_fix_relaxed_img_inside_pre_not_converted() {
2297 let rule = relaxed_fix_rule();
2299 let content = "<pre>\n <img src=\"diagram.png\" alt=\"d\" width=\"100\" />\n</pre>";
2300 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2301 let fixed = rule.fix(&ctx).unwrap();
2302 assert!(fixed.contains("<img"), "img inside pre must not be converted: {fixed}");
2303 }
2304
2305 #[test]
2306 fn test_md033_fix_relaxed_wrapper_nested_inside_div_not_stripped() {
2307 let rule = relaxed_fix_rule();
2309 let content = "<div><p>text</p></div>";
2310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2311 let fixed = rule.fix(&ctx).unwrap();
2312 assert!(
2313 fixed.contains("<p>text</p>") || fixed.contains("<p>"),
2314 "Nested <p> inside <div> should not be stripped: {fixed}"
2315 );
2316 }
2317
2318 #[test]
2319 fn test_md033_fix_relaxed_img_inside_nested_wrapper_not_converted() {
2320 let rule = relaxed_fix_rule();
2324 let content = "<div><p><img src=\"x.jpg\" alt=\"pic\" width=\"100\" /></p></div>";
2325 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2326 let fixed = rule.fix(&ctx).unwrap();
2327 assert!(
2328 fixed.contains("<img"),
2329 "img inside nested wrapper must not be converted: {fixed}"
2330 );
2331 }
2332
2333 #[test]
2334 fn test_md033_fix_mixed_safe_tags() {
2335 let rule = MD033NoInlineHtml::with_fix(true);
2337 let content = "<em>italic</em> and <img src=\"x.jpg\"> and <strong>bold</strong>";
2338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2339 let fixed = rule.fix(&ctx).unwrap();
2340 assert_eq!(fixed, "*italic* and  and **bold**");
2342 }
2343
2344 #[test]
2345 fn test_md033_fix_multiple_tags_same_line() {
2346 let rule = MD033NoInlineHtml::with_fix(true);
2348 let content = "Regular text <i>italic</i> and <b>bold</b> here.";
2349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2350 let fixed = rule.fix(&ctx).unwrap();
2351 assert_eq!(fixed, "Regular text *italic* and **bold** here.");
2352 }
2353
2354 #[test]
2355 fn test_md033_fix_multiple_em_tags_same_line() {
2356 let rule = MD033NoInlineHtml::with_fix(true);
2358 let content = "<em>first</em> and <strong>second</strong> and <code>third</code>";
2359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2360 let fixed = rule.fix(&ctx).unwrap();
2361 assert_eq!(fixed, "*first* and **second** and `third`");
2362 }
2363
2364 #[test]
2365 fn test_md033_fix_skips_tags_inside_pre() {
2366 let rule = MD033NoInlineHtml::with_fix(true);
2368 let content = "<pre><code><em>VALUE</em></code></pre>";
2369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2370 let fixed = rule.fix(&ctx).unwrap();
2371 assert!(
2374 !fixed.contains("*VALUE*"),
2375 "Tags inside <pre> should not be converted to markdown. Got: {fixed}"
2376 );
2377 }
2378
2379 #[test]
2380 fn test_md033_fix_skips_tags_inside_div() {
2381 let rule = MD033NoInlineHtml::with_fix(true);
2383 let content = "<div>\n<em>emphasized</em>\n</div>";
2384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2385 let fixed = rule.fix(&ctx).unwrap();
2386 assert!(
2388 !fixed.contains("*emphasized*"),
2389 "Tags inside HTML blocks should not be converted. Got: {fixed}"
2390 );
2391 }
2392
2393 #[test]
2394 fn test_md033_fix_outside_html_block() {
2395 let rule = MD033NoInlineHtml::with_fix(true);
2397 let content = "<div>\ncontent\n</div>\n\nOutside <em>emphasized</em> text.";
2398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2399 let fixed = rule.fix(&ctx).unwrap();
2400 assert!(
2402 fixed.contains("*emphasized*"),
2403 "Tags outside HTML blocks should be converted. Got: {fixed}"
2404 );
2405 }
2406
2407 #[test]
2408 fn test_md033_fix_with_id_attribute() {
2409 let rule = MD033NoInlineHtml::with_fix(true);
2411 let content = "See <em id=\"important\">this note</em> for details.";
2412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2413 let fixed = rule.fix(&ctx).unwrap();
2414 assert_eq!(fixed, content);
2416 }
2417
2418 #[test]
2419 fn test_md033_fix_with_style_attribute() {
2420 let rule = MD033NoInlineHtml::with_fix(true);
2422 let content = "This is <strong style=\"color: red\">important</strong> text.";
2423 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2424 let fixed = rule.fix(&ctx).unwrap();
2425 assert_eq!(fixed, content);
2427 }
2428
2429 #[test]
2430 fn test_md033_fix_mixed_with_and_without_attributes() {
2431 let rule = MD033NoInlineHtml::with_fix(true);
2433 let content = "<em>normal</em> and <em class=\"special\">styled</em> text.";
2434 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2435 let fixed = rule.fix(&ctx).unwrap();
2436 assert_eq!(fixed, "*normal* and <em class=\"special\">styled</em> text.");
2438 }
2439
2440 #[test]
2441 fn test_md033_quick_fix_tag_with_attributes_no_fix() {
2442 let rule = MD033NoInlineHtml::with_fix(true);
2444 let content = "<em class=\"test\">emphasized</em>";
2445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2446 let result = rule.check(&ctx).unwrap();
2447
2448 assert_eq!(result.len(), 1, "Should find one HTML tag");
2449 assert!(
2451 result[0].fix.is_none(),
2452 "Should NOT have a fix for tags with attributes"
2453 );
2454 }
2455
2456 #[test]
2457 fn test_md033_fix_skips_html_entities() {
2458 let rule = MD033NoInlineHtml::with_fix(true);
2461 let content = "<code>|</code>";
2462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2463 let fixed = rule.fix(&ctx).unwrap();
2464 assert_eq!(fixed, content);
2466 }
2467
2468 #[test]
2469 fn test_md033_fix_skips_multiple_html_entities() {
2470 let rule = MD033NoInlineHtml::with_fix(true);
2472 let content = "<code><T></code>";
2473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2474 let fixed = rule.fix(&ctx).unwrap();
2475 assert_eq!(fixed, content);
2477 }
2478
2479 #[test]
2480 fn test_md033_fix_allows_ampersand_without_entity() {
2481 let rule = MD033NoInlineHtml::with_fix(true);
2483 let content = "<code>a & b</code>";
2484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2485 let fixed = rule.fix(&ctx).unwrap();
2486 assert_eq!(fixed, "`a & b`");
2488 }
2489
2490 #[test]
2491 fn test_md033_fix_em_with_entities_skipped() {
2492 let rule = MD033NoInlineHtml::with_fix(true);
2494 let content = "<em> text</em>";
2495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2496 let fixed = rule.fix(&ctx).unwrap();
2497 assert_eq!(fixed, content);
2499 }
2500
2501 #[test]
2502 fn test_md033_fix_skips_nested_em_in_code() {
2503 let rule = MD033NoInlineHtml::with_fix(true);
2506 let content = "<code><em>n</em></code>";
2507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2508 let fixed = rule.fix(&ctx).unwrap();
2509 assert!(
2512 !fixed.contains("*n*"),
2513 "Nested <em> should not be converted to markdown. Got: {fixed}"
2514 );
2515 }
2516
2517 #[test]
2518 fn test_md033_fix_skips_nested_in_table() {
2519 let rule = MD033NoInlineHtml::with_fix(true);
2521 let content = "| <code>><em>n</em></code> | description |";
2522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2523 let fixed = rule.fix(&ctx).unwrap();
2524 assert!(
2526 !fixed.contains("*n*"),
2527 "Nested tags in table should not be converted. Got: {fixed}"
2528 );
2529 }
2530
2531 #[test]
2532 fn test_md033_fix_standalone_em_still_converted() {
2533 let rule = MD033NoInlineHtml::with_fix(true);
2535 let content = "This is <em>emphasized</em> text.";
2536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2537 let fixed = rule.fix(&ctx).unwrap();
2538 assert_eq!(fixed, "This is *emphasized* text.");
2539 }
2540
2541 #[test]
2553 fn test_md033_templater_basic_interpolation_not_flagged() {
2554 let rule = MD033NoInlineHtml::default();
2557 let content = "Today is <% tp.date.now() %> which is nice.";
2558 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2559 let result = rule.check(&ctx).unwrap();
2560 assert!(
2561 result.is_empty(),
2562 "Templater basic interpolation should not be flagged as HTML. Got: {result:?}"
2563 );
2564 }
2565
2566 #[test]
2567 fn test_md033_templater_file_functions_not_flagged() {
2568 let rule = MD033NoInlineHtml::default();
2570 let content = "File: <% tp.file.title %>\nCreated: <% tp.file.creation_date() %>";
2571 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2572 let result = rule.check(&ctx).unwrap();
2573 assert!(
2574 result.is_empty(),
2575 "Templater file functions should not be flagged. Got: {result:?}"
2576 );
2577 }
2578
2579 #[test]
2580 fn test_md033_templater_with_arguments_not_flagged() {
2581 let rule = MD033NoInlineHtml::default();
2583 let content = r#"Date: <% tp.date.now("YYYY-MM-DD") %>"#;
2584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2585 let result = rule.check(&ctx).unwrap();
2586 assert!(
2587 result.is_empty(),
2588 "Templater with arguments should not be flagged. Got: {result:?}"
2589 );
2590 }
2591
2592 #[test]
2593 fn test_md033_templater_javascript_execution_not_flagged() {
2594 let rule = MD033NoInlineHtml::default();
2596 let content = "<%* const today = tp.date.now(); tR += today; %>";
2597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2598 let result = rule.check(&ctx).unwrap();
2599 assert!(
2600 result.is_empty(),
2601 "Templater JS execution block should not be flagged. Got: {result:?}"
2602 );
2603 }
2604
2605 #[test]
2606 fn test_md033_templater_dynamic_execution_not_flagged() {
2607 let rule = MD033NoInlineHtml::default();
2609 let content = "Dynamic: <%+ tp.date.now() %>";
2610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2611 let result = rule.check(&ctx).unwrap();
2612 assert!(
2613 result.is_empty(),
2614 "Templater dynamic execution should not be flagged. Got: {result:?}"
2615 );
2616 }
2617
2618 #[test]
2619 fn test_md033_templater_whitespace_trim_all_not_flagged() {
2620 let rule = MD033NoInlineHtml::default();
2622 let content = "<%_ tp.date.now() _%>";
2623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2624 let result = rule.check(&ctx).unwrap();
2625 assert!(
2626 result.is_empty(),
2627 "Templater trim-all whitespace should not be flagged. Got: {result:?}"
2628 );
2629 }
2630
2631 #[test]
2632 fn test_md033_templater_whitespace_trim_newline_not_flagged() {
2633 let rule = MD033NoInlineHtml::default();
2635 let content = "<%- tp.date.now() -%>";
2636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2637 let result = rule.check(&ctx).unwrap();
2638 assert!(
2639 result.is_empty(),
2640 "Templater trim-newline should not be flagged. Got: {result:?}"
2641 );
2642 }
2643
2644 #[test]
2645 fn test_md033_templater_combined_modifiers_not_flagged() {
2646 let rule = MD033NoInlineHtml::default();
2648 let contents = [
2649 "<%-* const x = 1; -%>", "<%_+ tp.date.now() _%>", "<%- tp.file.title -%>", "<%_ tp.file.title _%>", ];
2654 for content in contents {
2655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2656 let result = rule.check(&ctx).unwrap();
2657 assert!(
2658 result.is_empty(),
2659 "Templater combined modifiers should not be flagged: {content}. Got: {result:?}"
2660 );
2661 }
2662 }
2663
2664 #[test]
2665 fn test_md033_templater_multiline_block_not_flagged() {
2666 let rule = MD033NoInlineHtml::default();
2668 let content = r#"<%*
2669const x = 1;
2670const y = 2;
2671tR += x + y;
2672%>"#;
2673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2674 let result = rule.check(&ctx).unwrap();
2675 assert!(
2676 result.is_empty(),
2677 "Templater multi-line block should not be flagged. Got: {result:?}"
2678 );
2679 }
2680
2681 #[test]
2682 fn test_md033_templater_with_angle_brackets_in_condition_not_flagged() {
2683 let rule = MD033NoInlineHtml::default();
2686 let content = "<%* if (x < 5) { tR += 'small'; } %>";
2687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2688 let result = rule.check(&ctx).unwrap();
2689 assert!(
2690 result.is_empty(),
2691 "Templater with angle brackets in conditions should not be flagged. Got: {result:?}"
2692 );
2693 }
2694
2695 #[test]
2696 fn test_md033_templater_mixed_with_html_only_html_flagged() {
2697 let rule = MD033NoInlineHtml::default();
2699 let content = "<% tp.date.now() %> is today's date. <div>This is HTML</div>";
2700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2701 let result = rule.check(&ctx).unwrap();
2702 assert_eq!(result.len(), 1, "Should only flag the HTML div tag");
2703 assert!(
2704 result[0].message.contains("<div>"),
2705 "Should flag <div>, got: {}",
2706 result[0].message
2707 );
2708 }
2709
2710 #[test]
2711 fn test_md033_templater_in_heading_not_flagged() {
2712 let rule = MD033NoInlineHtml::default();
2714 let content = "# <% tp.file.title %>";
2715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2716 let result = rule.check(&ctx).unwrap();
2717 assert!(
2718 result.is_empty(),
2719 "Templater in heading should not be flagged. Got: {result:?}"
2720 );
2721 }
2722
2723 #[test]
2724 fn test_md033_templater_multiple_on_same_line_not_flagged() {
2725 let rule = MD033NoInlineHtml::default();
2727 let content = "From <% tp.date.now() %> to <% tp.date.tomorrow() %> we have meetings.";
2728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2729 let result = rule.check(&ctx).unwrap();
2730 assert!(
2731 result.is_empty(),
2732 "Multiple Templater blocks should not be flagged. Got: {result:?}"
2733 );
2734 }
2735
2736 #[test]
2737 fn test_md033_templater_in_code_block_not_flagged() {
2738 let rule = MD033NoInlineHtml::default();
2740 let content = "```\n<% tp.date.now() %>\n```";
2741 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2742 let result = rule.check(&ctx).unwrap();
2743 assert!(
2744 result.is_empty(),
2745 "Templater in code block should not be flagged. Got: {result:?}"
2746 );
2747 }
2748
2749 #[test]
2750 fn test_md033_templater_in_inline_code_not_flagged() {
2751 let rule = MD033NoInlineHtml::default();
2753 let content = "Use `<% tp.date.now() %>` for current date.";
2754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2755 let result = rule.check(&ctx).unwrap();
2756 assert!(
2757 result.is_empty(),
2758 "Templater in inline code should not be flagged. Got: {result:?}"
2759 );
2760 }
2761
2762 #[test]
2763 fn test_md033_templater_also_works_in_standard_flavor() {
2764 let rule = MD033NoInlineHtml::default();
2767 let content = "<% tp.date.now() %> works everywhere.";
2768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2769 let result = rule.check(&ctx).unwrap();
2770 assert!(
2771 result.is_empty(),
2772 "Templater should not be flagged even in Standard flavor. Got: {result:?}"
2773 );
2774 }
2775
2776 #[test]
2777 fn test_md033_templater_empty_tag_not_flagged() {
2778 let rule = MD033NoInlineHtml::default();
2780 let content = "<%>";
2781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2782 let result = rule.check(&ctx).unwrap();
2783 assert!(
2784 result.is_empty(),
2785 "Empty Templater-like tag should not be flagged. Got: {result:?}"
2786 );
2787 }
2788
2789 #[test]
2790 fn test_md033_templater_unclosed_not_flagged() {
2791 let rule = MD033NoInlineHtml::default();
2793 let content = "<% tp.date.now() without closing tag";
2794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2795 let result = rule.check(&ctx).unwrap();
2796 assert!(
2797 result.is_empty(),
2798 "Unclosed Templater should not be flagged as HTML. Got: {result:?}"
2799 );
2800 }
2801
2802 #[test]
2803 fn test_md033_templater_with_newlines_inside_not_flagged() {
2804 let rule = MD033NoInlineHtml::default();
2806 let content = r#"<% tp.date.now("YYYY") +
2807"-" +
2808tp.date.now("MM") %>"#;
2809 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2810 let result = rule.check(&ctx).unwrap();
2811 assert!(
2812 result.is_empty(),
2813 "Templater with internal newlines should not be flagged. Got: {result:?}"
2814 );
2815 }
2816
2817 #[test]
2818 fn test_md033_erb_style_tags_not_flagged() {
2819 let rule = MD033NoInlineHtml::default();
2822 let content = "<%= variable %> and <% code %> and <%# comment %>";
2823 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2824 let result = rule.check(&ctx).unwrap();
2825 assert!(
2826 result.is_empty(),
2827 "ERB/EJS style tags should not be flagged as HTML. Got: {result:?}"
2828 );
2829 }
2830
2831 #[test]
2832 fn test_md033_templater_complex_expression_not_flagged() {
2833 let rule = MD033NoInlineHtml::default();
2835 let content = r#"<%*
2836const file = tp.file.title;
2837const date = tp.date.now("YYYY-MM-DD");
2838const folder = tp.file.folder();
2839tR += `# ${file}\n\nCreated: ${date}\nIn: ${folder}`;
2840%>"#;
2841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2842 let result = rule.check(&ctx).unwrap();
2843 assert!(
2844 result.is_empty(),
2845 "Complex Templater expression should not be flagged. Got: {result:?}"
2846 );
2847 }
2848
2849 #[test]
2850 fn test_md033_percent_sign_variations_not_flagged() {
2851 let rule = MD033NoInlineHtml::default();
2853 let patterns = [
2854 "<%=", "<%#", "<%%", "<%!", "<%@", "<%--", ];
2861 for pattern in patterns {
2862 let content = format!("{pattern} content %>");
2863 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2864 let result = rule.check(&ctx).unwrap();
2865 assert!(
2866 result.is_empty(),
2867 "Pattern {pattern} should not be flagged. Got: {result:?}"
2868 );
2869 }
2870 }
2871
2872 #[test]
2878 fn test_md033_fix_a_wrapping_markdown_image_no_escaped_brackets() {
2879 let rule = MD033NoInlineHtml::with_fix(true);
2882 let content = r#"<a href="https://example.com"></a>"#;
2883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2884 let fixed = rule.fix(&ctx).unwrap();
2885
2886 assert_eq!(fixed, "[](https://example.com)",);
2887 assert!(!fixed.contains(r"\["), "Must not escape brackets: {fixed}");
2888 assert!(!fixed.contains(r"\]"), "Must not escape brackets: {fixed}");
2889 }
2890
2891 #[test]
2892 fn test_md033_fix_a_wrapping_markdown_image_with_alt() {
2893 let rule = MD033NoInlineHtml::with_fix(true);
2895 let content =
2896 r#"<a href="https://github.com/repo"></a>"#;
2897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2898 let fixed = rule.fix(&ctx).unwrap();
2899
2900 assert_eq!(
2901 fixed,
2902 "[](https://github.com/repo)"
2903 );
2904 }
2905
2906 #[test]
2907 fn test_md033_fix_img_without_alt_produces_empty_alt() {
2908 let rule = MD033NoInlineHtml::with_fix(true);
2909 let content = r#"<img src="photo.jpg" />"#;
2910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2911 let fixed = rule.fix(&ctx).unwrap();
2912
2913 assert_eq!(fixed, "");
2914 }
2915
2916 #[test]
2917 fn test_md033_fix_a_with_plain_text_still_escapes_brackets() {
2918 let rule = MD033NoInlineHtml::with_fix(true);
2920 let content = r#"<a href="https://example.com">text with [brackets]</a>"#;
2921 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2922 let fixed = rule.fix(&ctx).unwrap();
2923
2924 assert!(
2925 fixed.contains(r"\[brackets\]"),
2926 "Plain text brackets should be escaped: {fixed}"
2927 );
2928 }
2929
2930 #[test]
2931 fn test_md033_fix_a_with_image_plus_extra_text_escapes_brackets() {
2932 let rule = MD033NoInlineHtml::with_fix(true);
2935 let content = r#"<a href="/link"> see [docs]</a>"#;
2936 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2937 let fixed = rule.fix(&ctx).unwrap();
2938
2939 assert!(
2941 fixed.contains(r"\[docs\]"),
2942 "Brackets in mixed image+text content should be escaped: {fixed}"
2943 );
2944 }
2945
2946 #[test]
2947 fn test_md033_fix_img_in_a_end_to_end() {
2948 use crate::config::Config;
2951 use crate::fix_coordinator::FixCoordinator;
2952
2953 let rule = MD033NoInlineHtml::with_fix(true);
2954 let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
2955
2956 let mut content =
2957 r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image?repo=org/repo" /></a>"#
2958 .to_string();
2959 let config = Config::default();
2960 let coordinator = FixCoordinator::new();
2961
2962 let result = coordinator
2963 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
2964 .unwrap();
2965
2966 assert_eq!(
2967 content, "[](https://github.com/org/repo)",
2968 "End-to-end: <a><img></a> should become valid linked image"
2969 );
2970 assert!(result.converged);
2971 assert!(!content.contains(r"\["), "No escaped brackets: {content}");
2972 }
2973
2974 #[test]
2975 fn test_md033_fix_img_in_a_with_alt_end_to_end() {
2976 use crate::config::Config;
2977 use crate::fix_coordinator::FixCoordinator;
2978
2979 let rule = MD033NoInlineHtml::with_fix(true);
2980 let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
2981
2982 let mut content =
2983 r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image" alt="Contributors" /></a>"#
2984 .to_string();
2985 let config = Config::default();
2986 let coordinator = FixCoordinator::new();
2987
2988 let result = coordinator
2989 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
2990 .unwrap();
2991
2992 assert_eq!(
2993 content,
2994 "[](https://github.com/org/repo)",
2995 );
2996 assert!(result.converged);
2997 }
2998
2999 #[test]
3009 fn test_md033_table_allowed_unset_falls_back_to_allowed() {
3010 let config = MD033Config {
3011 allowed: vec!["br".to_string()],
3012 table_allowed_elements: None,
3013 ..MD033Config::default()
3014 };
3015 let rule = MD033NoInlineHtml::from_config_struct(config);
3016 let content = "| col |\n|-----|\n| a<br>b |\n";
3017 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3018 let result = rule.check(&ctx).unwrap();
3019 assert!(
3020 result.is_empty(),
3021 "<br> in table cell should be allowed via fallback to `allowed`, got {result:?}"
3022 );
3023 }
3024
3025 #[test]
3026 fn test_md033_table_allowed_explicit_empty_rejects_in_tables() {
3027 let config = MD033Config {
3028 allowed: vec!["br".to_string()],
3029 table_allowed_elements: Some(Vec::new()),
3030 ..MD033Config::default()
3031 };
3032 let rule = MD033NoInlineHtml::from_config_struct(config);
3033 let content = "| col |\n|-----|\n| a<br>b |\n";
3034 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3035 let result = rule.check(&ctx).unwrap();
3036 assert_eq!(
3037 result.len(),
3038 1,
3039 "Explicit empty table_allowed should reject <br> in tables even if it's in `allowed`, got {result:?}"
3040 );
3041 assert_eq!(result[0].line, 3);
3042 }
3043
3044 #[test]
3045 fn test_md033_table_allowed_explicit_list_overrides_in_tables() {
3046 let config = MD033Config {
3047 allowed: vec!["br".to_string()],
3048 table_allowed_elements: Some(vec!["img".to_string()]),
3049 ..MD033Config::default()
3050 };
3051 let rule = MD033NoInlineHtml::from_config_struct(config);
3052 let content = "| col |\n|-----|\n| <br><img src=\"x\"/> |\n";
3055 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3056 let result = rule.check(&ctx).unwrap();
3057 assert_eq!(
3058 result.len(),
3059 1,
3060 "table_allowed should override `allowed` inside tables, got {result:?}"
3061 );
3062 assert!(
3063 result[0].message.contains("br"),
3064 "expected the flagged tag to be <br>, got {:?}",
3065 result[0].message
3066 );
3067 }
3068
3069 #[test]
3070 fn test_md033_table_allowed_does_not_affect_out_of_table_tags() {
3071 let config = MD033Config {
3072 allowed: vec!["br".to_string()],
3073 table_allowed_elements: Some(Vec::new()),
3074 ..MD033Config::default()
3075 };
3076 let rule = MD033NoInlineHtml::from_config_struct(config);
3077 let content = "Paragraph with <br> tag.\n";
3079 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3080 let result = rule.check(&ctx).unwrap();
3081 assert!(
3082 result.is_empty(),
3083 "<br> outside tables must still be allowed by `allowed`, got {result:?}"
3084 );
3085 }
3086
3087 #[test]
3088 fn test_md033_table_allowed_kebab_case_parses() {
3089 let toml_str = r#"
3090 allowed-elements = ["br"]
3091 table-allowed-elements = ["img"]
3092 "#;
3093 let config: MD033Config = toml::from_str(toml_str).unwrap();
3094 assert_eq!(config.allowed, vec!["br"]);
3095 assert_eq!(
3096 config.table_allowed_elements.as_deref(),
3097 Some(["img".to_string()].as_slice())
3098 );
3099 }
3100
3101 #[test]
3102 fn test_md033_table_allowed_snake_case_alias_parses() {
3103 let toml_str = r#"
3104 allowed_elements = ["br"]
3105 table_allowed_elements = ["img"]
3106 "#;
3107 let config: MD033Config = toml::from_str(toml_str).unwrap();
3108 assert_eq!(config.allowed, vec!["br"]);
3109 assert_eq!(
3110 config.table_allowed_elements.as_deref(),
3111 Some(["img".to_string()].as_slice())
3112 );
3113 }
3114
3115 #[test]
3116 fn test_md033_table_allowed_default_is_none() {
3117 let cfg = MD033Config::default();
3118 assert!(
3119 cfg.table_allowed_elements.is_none(),
3120 "Default for table_allowed_elements should be None (so it falls back to `allowed`)"
3121 );
3122 }
3123
3124 #[test]
3125 fn test_md033_table_allowed_case_insensitive() {
3126 let config = MD033Config {
3127 allowed: Vec::new(),
3128 table_allowed_elements: Some(vec!["BR".to_string()]),
3129 ..MD033Config::default()
3130 };
3131 let rule = MD033NoInlineHtml::from_config_struct(config);
3132 let content = "| col |\n|-----|\n| a<br>b |\n";
3133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3134 let result = rule.check(&ctx).unwrap();
3135 assert!(
3136 result.is_empty(),
3137 "table_allowed should be case-insensitive, got {result:?}"
3138 );
3139 }
3140
3141 fn reported_tags_in(rule: &MD033NoInlineHtml, content: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
3150 let ctx = LintContext::new(content, flavor, None);
3151 rule.check(&ctx)
3152 .unwrap()
3153 .into_iter()
3154 .map(|warning| {
3155 warning
3156 .message
3157 .strip_prefix("Inline HTML found: ")
3158 .expect("MD033 reports the tag it found")
3159 .to_string()
3160 })
3161 .collect()
3162 }
3163
3164 fn reported_tags(rule: &MD033NoInlineHtml, content: &str) -> Vec<String> {
3165 reported_tags_in(rule, content, crate::config::MarkdownFlavor::Standard)
3166 }
3167
3168 fn rule_allowing_inside(elements: &[&str]) -> MD033NoInlineHtml {
3169 MD033NoInlineHtml::from_config_struct(MD033Config {
3170 allowed_inside: elements.iter().map(ToString::to_string).collect(),
3171 ..MD033Config::default()
3172 })
3173 }
3174
3175 #[test]
3176 fn test_md033_allowed_inside_permits_the_element_and_its_contents() {
3177 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";
3178
3179 let without = reported_tags(&MD033NoInlineHtml::default(), content);
3180 assert_eq!(
3181 without,
3182 vec!["<details>", "<summary>", "<a href=\"./other.md\">", "<b>", "<b>"],
3183 "control: every tag is reported without the option"
3184 );
3185
3186 let with = reported_tags(&rule_allowing_inside(&["details"]), content);
3187 assert_eq!(
3188 with,
3189 vec!["<b>"],
3190 "only the tag outside the details element is left, got {with:?}"
3191 );
3192 }
3193
3194 #[test]
3195 fn test_md033_allowed_inside_ends_at_the_matching_closing_tag() {
3196 let content = "<details>\n<details>\n<b>x</b>\n</details>\n<i>y</i>\n</details>\n<b>z</b>\n";
3198 let reported = reported_tags(&rule_allowing_inside(&["details"]), content);
3199 assert_eq!(reported, vec!["<b>"], "got {reported:?}");
3200
3201 let unclosed = "<details>\n\n<b>x</b>\n\n<i>y</i>\n";
3203 assert!(
3204 reported_tags(&rule_allowing_inside(&["details"]), unclosed).is_empty(),
3205 "an unclosed element reaches the end of the document"
3206 );
3207 }
3208
3209 #[test]
3210 fn test_md033_allowed_inside_ignores_an_element_quoted_in_a_code_block() {
3211 let content = "```html\n<details>\n```\n\n<b>x</b>\n";
3212 let reported = reported_tags(&rule_allowing_inside(&["details"]), content);
3213 assert_eq!(
3214 reported,
3215 vec!["<b>"],
3216 "a code block quotes the element, it does not open one: {reported:?}"
3217 );
3218 }
3219
3220 #[test]
3221 fn test_md033_allowed_inside_a_void_element_holds_nothing() {
3222 let content = "a<br>b <b>x</b>\n";
3224 let reported = reported_tags(&rule_allowing_inside(&["br"]), content);
3225 assert_eq!(
3226 reported,
3227 vec!["<br>", "<b>"],
3228 "a void element must not swallow the rest of the document: {reported:?}"
3229 );
3230 }
3231
3232 #[test]
3233 fn test_md033_allowed_inside_is_case_insensitive() {
3234 let content = "<DETAILS>\n<b>x</b>\n</DETAILS>\n<i>y</i>\n";
3235 let reported = reported_tags(&rule_allowing_inside(&["Details"]), content);
3236 assert_eq!(reported, vec!["<i>"], "got {reported:?}");
3237 }
3238
3239 #[test]
3240 fn test_md033_allowed_inside_takes_no_part_in_disallowed_mode() {
3241 let config = MD033Config {
3242 allowed_inside: vec!["details".to_string()],
3243 disallowed: vec!["b".to_string()],
3244 ..MD033Config::default()
3245 };
3246 let rule = MD033NoInlineHtml::from_config_struct(config);
3247 let content = "<details>\n<b>x</b>\n<kbd>k</kbd>\n</details>\n";
3248 let reported = reported_tags(&rule, content);
3249 assert_eq!(
3250 reported,
3251 vec!["<b>"],
3252 "a denylist names what is wrong wherever it appears: {reported:?}"
3253 );
3254 }
3255
3256 #[test]
3257 fn test_md033_allowed_inside_yields_to_an_explicit_table_allowlist() {
3258 let content = "| col |\n|-----|\n| <details><b>x</b></details> |\n";
3259
3260 let unset = reported_tags(&rule_allowing_inside(&["details"]), content);
3261 assert!(
3262 unset.is_empty(),
3263 "without a table allowlist the element applies inside a cell too: {unset:?}"
3264 );
3265
3266 let config = MD033Config {
3267 allowed_inside: vec!["details".to_string()],
3268 table_allowed_elements: Some(Vec::new()),
3269 ..MD033Config::default()
3270 };
3271 let rule = MD033NoInlineHtml::from_config_struct(config);
3272 let reported = reported_tags(&rule, content);
3273 assert_eq!(
3274 reported,
3275 vec!["<details>", "<b>"],
3276 "an explicit table allowlist decides inside a cell: {reported:?}"
3277 );
3278 }
3279
3280 fn rule_allowing(elements: &[&str]) -> MD033NoInlineHtml {
3288 MD033NoInlineHtml::with_allowed(elements.iter().map(ToString::to_string).collect())
3289 }
3290
3291 #[test]
3292 fn test_md033_no_markdown_equivalent_reports_only_what_markdown_can_write() {
3293 let content = "Press <kbd>Ctrl</kbd>, <b>bold</b>, <mark>hi</mark>, <abbr title=\"x\">A</abbr>, <em>i</em>, <details>d</details>\n";
3294
3295 let control = reported_tags(&MD033NoInlineHtml::default(), content);
3296 assert_eq!(control.len(), 6, "control: every tag is reported: {control:?}");
3297
3298 let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent"]), content);
3299 assert_eq!(
3300 reported,
3301 vec!["<b>", "<em>"],
3302 "only the elements with Markdown syntax are left: {reported:?}"
3303 );
3304 }
3305
3306 #[test]
3307 fn test_md033_no_markdown_equivalent_keeps_reporting_gfm_filtered_tags() {
3308 let content = "<kbd>k</kbd>\n\n<script>alert(1)</script>\n\n<iframe src=\"x\"></iframe>\n";
3310 let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent"]), content);
3311 assert_eq!(reported, vec!["<script>", "<iframe src=\"x\">"], "got {reported:?}");
3312 }
3313
3314 #[test]
3315 fn test_md033_no_markdown_equivalent_follows_the_flavor() {
3316 let content = "H<sub>2</sub>O, x<sup>2</sup>, <mark>hi</mark>, <kbd>k</kbd>\n";
3317 let rule = rule_allowing(&["no-markdown-equivalent"]);
3318
3319 assert!(
3320 reported_tags_in(&rule, content, crate::config::MarkdownFlavor::Standard).is_empty(),
3321 "standard Markdown writes none of these"
3322 );
3323 assert_eq!(
3324 reported_tags_in(&rule, content, crate::config::MarkdownFlavor::Pandoc),
3325 vec!["<sub>", "<sup>"],
3326 "Pandoc writes ~sub~ and ^sup^"
3327 );
3328 assert_eq!(
3329 reported_tags_in(&rule, content, crate::config::MarkdownFlavor::Obsidian),
3330 vec!["<mark>"],
3331 "Obsidian writes ==highlight=="
3332 );
3333 }
3334
3335 #[test]
3336 fn test_md033_no_markdown_equivalent_permits_a_line_break_inside_a_table_cell() {
3337 let content = "| col |\n|-----|\n| a<br>b |\n\nOutside a<br>b.\n";
3340 let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent"]), content);
3341 assert_eq!(reported, vec!["<br>"], "got {reported:?}");
3342 }
3343
3344 #[test]
3345 fn test_md033_no_markdown_equivalent_composes_with_named_elements() {
3346 let content = "<kbd>k</kbd> <b>bold</b> <em>italic</em>\n";
3347 let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent", "b"]), content);
3348 assert_eq!(
3349 reported,
3350 vec!["<em>"],
3351 "a named element joins the ones the sentinel permits: {reported:?}"
3352 );
3353 }
3354
3355 #[test]
3356 fn test_md033_no_markdown_equivalent_reads_the_snake_case_spelling() {
3357 let content = "<kbd>k</kbd> <b>bold</b>\n";
3359 let reported = reported_tags(&rule_allowing(&["no_markdown_equivalent"]), content);
3360 assert_eq!(reported, vec!["<b>"], "got {reported:?}");
3361 }
3362
3363 #[test]
3364 fn test_md033_no_markdown_equivalent_takes_no_part_in_disallowed_mode() {
3365 let config = MD033Config {
3366 allowed: vec!["no-markdown-equivalent".to_string()],
3367 disallowed: vec!["kbd".to_string()],
3368 ..MD033Config::default()
3369 };
3370 let rule = MD033NoInlineHtml::from_config_struct(config);
3371 let reported = reported_tags(&rule, "<kbd>k</kbd> <b>bold</b>\n");
3372 assert_eq!(
3373 reported,
3374 vec!["<kbd>"],
3375 "a denylist names what is wrong wherever it appears: {reported:?}"
3376 );
3377 }
3378}