1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::utils::regex_cache::*;
8use std::collections::HashSet;
9
10mod md033_config;
11use md033_config::{MD033Config, MD033FixMode};
12
13#[derive(Clone)]
14pub struct MD033NoInlineHtml {
15 config: MD033Config,
16 allowed: HashSet<String>,
17 table_allowed: HashSet<String>,
18 disallowed: HashSet<String>,
19 drop_attributes: HashSet<String>,
20 strip_wrapper_elements: HashSet<String>,
21}
22
23impl Default for MD033NoInlineHtml {
24 fn default() -> Self {
25 Self::from_config_struct(MD033Config::default())
26 }
27}
28
29impl MD033NoInlineHtml {
30 pub fn new() -> Self {
31 Self::default()
32 }
33
34 pub fn with_allowed(allowed_vec: Vec<String>) -> Self {
35 Self::from_config_struct(MD033Config {
36 allowed: allowed_vec,
37 ..MD033Config::default()
38 })
39 }
40
41 pub fn with_disallowed(disallowed_vec: Vec<String>) -> Self {
42 Self::from_config_struct(MD033Config {
43 disallowed: disallowed_vec,
44 ..MD033Config::default()
45 })
46 }
47
48 pub fn with_fix(fix: bool) -> Self {
50 Self::from_config_struct(MD033Config {
51 fix,
52 ..MD033Config::default()
53 })
54 }
55
56 pub fn from_config_struct(config: MD033Config) -> Self {
59 let allowed = config.allowed_set();
60 let table_allowed = config.table_allowed_set();
61 let disallowed = config.disallowed_set();
62 let drop_attributes = config.drop_attributes_set();
63 let strip_wrapper_elements = config.strip_wrapper_elements_set();
64 Self {
65 config,
66 allowed,
67 table_allowed,
68 disallowed,
69 drop_attributes,
70 strip_wrapper_elements,
71 }
72 }
73
74 #[inline]
78 fn extract_tag_name(tag: &str) -> String {
79 let trimmed = tag.trim_start_matches('<').trim_start_matches('/');
80 trimmed
81 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
82 .next()
83 .unwrap_or("")
84 .to_lowercase()
85 }
86
87 #[inline]
90 fn tag_in_set(set: &HashSet<String>, tag: &str) -> bool {
91 if set.is_empty() {
92 return false;
93 }
94 set.contains(&Self::extract_tag_name(tag))
95 }
96
97 #[inline]
99 fn is_tag_allowed(&self, tag: &str) -> bool {
100 Self::tag_in_set(&self.allowed, tag)
101 }
102
103 #[inline]
106 fn is_tag_allowed_in_table(&self, tag: &str) -> bool {
107 Self::tag_in_set(&self.table_allowed, tag)
108 }
109
110 #[inline]
112 fn is_tag_disallowed(&self, tag: &str) -> bool {
113 Self::tag_in_set(&self.disallowed, tag)
114 }
115
116 #[inline]
118 fn is_disallowed_mode(&self) -> bool {
119 self.config.is_disallowed_mode()
120 }
121
122 #[inline]
124 fn is_html_comment(&self, tag: &str) -> bool {
125 tag.starts_with("<!--") && tag.ends_with("-->")
126 }
127
128 #[inline]
133 fn is_html_element_or_custom(tag_name: &str) -> bool {
134 const HTML_ELEMENTS: &[&str] = &[
136 "a",
137 "abbr",
138 "acronym",
139 "address",
140 "applet",
141 "area",
142 "article",
143 "aside",
144 "audio",
145 "b",
146 "base",
147 "basefont",
148 "bdi",
149 "bdo",
150 "big",
151 "blockquote",
152 "body",
153 "br",
154 "button",
155 "canvas",
156 "caption",
157 "center",
158 "cite",
159 "code",
160 "col",
161 "colgroup",
162 "data",
163 "datalist",
164 "dd",
165 "del",
166 "details",
167 "dfn",
168 "dialog",
169 "dir",
170 "div",
171 "dl",
172 "dt",
173 "em",
174 "embed",
175 "fieldset",
176 "figcaption",
177 "figure",
178 "font",
179 "footer",
180 "form",
181 "frame",
182 "frameset",
183 "h1",
184 "h2",
185 "h3",
186 "h4",
187 "h5",
188 "h6",
189 "head",
190 "header",
191 "hgroup",
192 "hr",
193 "html",
194 "i",
195 "iframe",
196 "img",
197 "input",
198 "ins",
199 "isindex",
200 "kbd",
201 "label",
202 "legend",
203 "li",
204 "link",
205 "main",
206 "map",
207 "mark",
208 "marquee",
209 "math",
210 "menu",
211 "meta",
212 "meter",
213 "nav",
214 "noembed",
215 "noframes",
216 "noscript",
217 "object",
218 "ol",
219 "optgroup",
220 "option",
221 "output",
222 "p",
223 "param",
224 "picture",
225 "plaintext",
226 "pre",
227 "progress",
228 "q",
229 "rp",
230 "rt",
231 "ruby",
232 "s",
233 "samp",
234 "script",
235 "search",
236 "section",
237 "select",
238 "slot",
239 "small",
240 "source",
241 "span",
242 "strike",
243 "strong",
244 "style",
245 "sub",
246 "summary",
247 "sup",
248 "svg",
249 "table",
250 "tbody",
251 "td",
252 "template",
253 "textarea",
254 "tfoot",
255 "th",
256 "thead",
257 "time",
258 "title",
259 "tr",
260 "track",
261 "tt",
262 "u",
263 "ul",
264 "var",
265 "video",
266 "wbr",
267 "xmp",
268 ];
269
270 let lower = tag_name.to_ascii_lowercase();
271 if HTML_ELEMENTS.binary_search(&lower.as_str()).is_ok() {
272 return true;
273 }
274 tag_name.contains('-')
276 }
277
278 #[inline]
280 fn is_likely_type_annotation(&self, tag: &str) -> bool {
281 const COMMON_TYPES: &[&str] = &[
283 "any",
284 "apiresponse",
285 "array",
286 "bigint",
287 "config",
288 "data",
289 "date",
290 "e",
291 "element",
292 "error",
293 "function",
294 "generator",
295 "item",
296 "iterator",
297 "k",
298 "map",
299 "node",
300 "null",
301 "number",
302 "options",
303 "params",
304 "promise",
305 "regexp",
306 "request",
307 "response",
308 "result",
309 "set",
310 "string",
311 "symbol",
312 "t",
313 "u",
314 "undefined",
315 "userdata",
316 "v",
317 "void",
318 "weakmap",
319 "weakset",
320 ];
321
322 let tag_content = tag
323 .trim_start_matches('<')
324 .trim_end_matches('>')
325 .trim_start_matches('/');
326 let tag_name = tag_content
327 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
328 .next()
329 .unwrap_or("");
330
331 if !tag_content.contains(' ') && !tag_content.contains('=') {
333 let lower = tag_name.to_ascii_lowercase();
334 COMMON_TYPES.binary_search(&lower.as_str()).is_ok()
335 } else {
336 false
337 }
338 }
339
340 #[inline]
342 fn is_email_address(&self, tag: &str) -> bool {
343 let content = tag.trim_start_matches('<').trim_end_matches('>');
344 content.contains('@')
346 && content.chars().all(|c| c.is_alphanumeric() || "@.-_+".contains(c))
347 && content.split('@').count() == 2
348 && content.split('@').all(|part| !part.is_empty())
349 }
350
351 #[inline]
353 fn has_markdown_attribute(&self, tag: &str) -> bool {
354 tag.contains(" markdown>") || tag.contains(" markdown=") || tag.contains(" markdown ")
357 }
358
359 #[inline]
366 fn has_jsx_attributes(tag: &str) -> bool {
367 tag.contains("className")
369 || tag.contains("htmlFor")
370 || tag.contains("dangerouslySetInnerHTML")
371 || tag.contains("onClick")
373 || tag.contains("onChange")
374 || tag.contains("onSubmit")
375 || tag.contains("onFocus")
376 || tag.contains("onBlur")
377 || tag.contains("onKeyDown")
378 || tag.contains("onKeyUp")
379 || tag.contains("onKeyPress")
380 || tag.contains("onMouseDown")
381 || tag.contains("onMouseUp")
382 || tag.contains("onMouseEnter")
383 || tag.contains("onMouseLeave")
384 || tag.contains("={")
386 }
387
388 #[inline]
390 fn is_url_in_angle_brackets(&self, tag: &str) -> bool {
391 let content = tag.trim_start_matches('<').trim_end_matches('>');
392 content.starts_with("http://")
394 || content.starts_with("https://")
395 || content.starts_with("ftp://")
396 || content.starts_with("ftps://")
397 || content.starts_with("mailto:")
398 }
399
400 #[inline]
401 fn is_relaxed_fix_mode(&self) -> bool {
402 self.config.fix_mode == MD033FixMode::Relaxed
403 }
404
405 #[inline]
406 fn is_droppable_attribute(&self, attr_name: &str) -> bool {
407 if attr_name.starts_with("on") && attr_name.len() > 2 {
410 return false;
411 }
412 self.drop_attributes.contains(attr_name)
413 || (attr_name.starts_with("data-")
414 && (self.drop_attributes.contains("data-*") || self.drop_attributes.contains("data-")))
415 }
416
417 #[inline]
418 fn is_strippable_wrapper(&self, tag_name: &str) -> bool {
419 self.is_relaxed_fix_mode() && self.strip_wrapper_elements.contains(tag_name)
420 }
421
422 fn is_inside_strippable_wrapper(&self, content: &str, byte_offset: usize) -> bool {
433 if byte_offset == 0 {
434 return false;
435 }
436 let before = content[..byte_offset].trim_end();
437 if !before.ends_with('>') || before.ends_with("->") {
438 return false;
439 }
440 if let Some(last_lt) = before.rfind('<') {
441 let potential_tag = &before[last_lt..];
442 if potential_tag.starts_with("</") || potential_tag.starts_with("<!--") {
443 return false;
444 }
445 let parent_name = potential_tag
446 .trim_start_matches('<')
447 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
448 .next()
449 .unwrap_or("")
450 .to_lowercase();
451 if !self.strip_wrapper_elements.contains(&parent_name) {
452 return false;
453 }
454 let wrapper_before = before[..last_lt].trim_end();
456 if wrapper_before.ends_with('>')
457 && !wrapper_before.ends_with("->")
458 && let Some(outer_lt) = wrapper_before.rfind('<')
459 && let outer_tag = &wrapper_before[outer_lt..]
460 && !outer_tag.starts_with("</")
461 && !outer_tag.starts_with("<!--")
462 {
463 return false;
464 }
465 return true;
466 }
467 false
468 }
469
470 fn convert_to_markdown(tag_name: &str, inner_content: &str) -> Option<String> {
473 if inner_content.contains('<') {
475 return None;
476 }
477 if inner_content.contains('&') && inner_content.contains(';') {
480 let has_entity = inner_content
482 .split('&')
483 .skip(1)
484 .any(|part| part.split(';').next().is_some_and(|e| !e.is_empty() && e.len() < 10));
485 if has_entity {
486 return None;
487 }
488 }
489 match tag_name {
490 "em" | "i" => Some(format!("*{inner_content}*")),
491 "strong" | "b" => Some(format!("**{inner_content}**")),
492 "code" => {
493 if inner_content.contains('`') {
495 Some(format!("`` {inner_content} ``"))
496 } else {
497 Some(format!("`{inner_content}`"))
498 }
499 }
500 _ => None,
501 }
502 }
503
504 fn convert_self_closing_to_markdown(&self, tag_name: &str, opening_tag: &str) -> Option<String> {
506 match tag_name {
507 "br" => match self.config.br_style {
508 md033_config::BrStyle::TrailingSpaces => Some(" \n".to_string()),
509 md033_config::BrStyle::Backslash => Some("\\\n".to_string()),
510 },
511 "hr" => Some("\n---\n".to_string()),
512 "img" => self.convert_img_to_markdown(opening_tag),
513 _ => None,
514 }
515 }
516
517 fn parse_attributes(tag: &str) -> Vec<(String, Option<String>)> {
520 let mut attrs = Vec::new();
521
522 let tag_content = tag.trim_start_matches('<').trim_end_matches('>').trim_end_matches('/');
524
525 let attr_start = tag_content
529 .char_indices()
530 .find(|(_, c)| c.is_whitespace())
531 .map_or(tag_content.len(), |(i, c)| i + c.len_utf8());
532
533 if attr_start >= tag_content.len() {
534 return attrs;
535 }
536
537 let attr_str = &tag_content[attr_start..];
538 let mut chars = attr_str.chars().peekable();
539
540 while chars.peek().is_some() {
541 while chars.peek().is_some_and(|c| c.is_whitespace()) {
543 chars.next();
544 }
545
546 if chars.peek().is_none() {
547 break;
548 }
549
550 let mut attr_name = String::new();
552 while let Some(&c) = chars.peek() {
553 if c.is_whitespace() || c == '=' || c == '>' || c == '/' {
554 break;
555 }
556 attr_name.push(c);
557 chars.next();
558 }
559
560 if attr_name.is_empty() {
561 break;
562 }
563
564 while chars.peek().is_some_and(|c| c.is_whitespace()) {
566 chars.next();
567 }
568
569 if chars.peek() == Some(&'=') {
571 chars.next(); while chars.peek().is_some_and(|c| c.is_whitespace()) {
575 chars.next();
576 }
577
578 let mut value = String::new();
580 if let Some("e) = chars.peek() {
581 if quote == '"' || quote == '\'' {
582 chars.next(); for c in chars.by_ref() {
584 if c == quote {
585 break;
586 }
587 value.push(c);
588 }
589 } else {
590 while let Some(&c) = chars.peek() {
592 if c.is_whitespace() || c == '>' || c == '/' {
593 break;
594 }
595 value.push(c);
596 chars.next();
597 }
598 }
599 }
600 attrs.push((attr_name.to_ascii_lowercase(), Some(value)));
601 } else {
602 attrs.push((attr_name.to_ascii_lowercase(), None));
604 }
605 }
606
607 attrs
608 }
609
610 fn extract_attribute(tag: &str, attr_name: &str) -> Option<String> {
614 let attrs = Self::parse_attributes(tag);
615 let attr_lower = attr_name.to_ascii_lowercase();
616
617 attrs
618 .into_iter()
619 .find(|(name, _)| name == &attr_lower)
620 .and_then(|(_, value)| value)
621 }
622
623 fn has_extra_attributes(&self, tag: &str, allowed_attrs: &[&str]) -> bool {
626 let attrs = Self::parse_attributes(tag);
627
628 const DANGEROUS_ATTR_PREFIXES: &[&str] = &["on"]; const DANGEROUS_ATTRS: &[&str] = &[
632 "class",
633 "id",
634 "style",
635 "target",
636 "rel",
637 "download",
638 "referrerpolicy",
639 "crossorigin",
640 "loading",
641 "decoding",
642 "fetchpriority",
643 "sizes",
644 "srcset",
645 "usemap",
646 "ismap",
647 "width",
648 "height",
649 "name", "data-*", ];
652
653 for (attr_name, _) in attrs {
654 if allowed_attrs.iter().any(|a| a.to_ascii_lowercase() == attr_name) {
656 continue;
657 }
658
659 if self.is_relaxed_fix_mode() {
660 if self.is_droppable_attribute(&attr_name) {
661 continue;
662 }
663 return true;
664 }
665
666 for prefix in DANGEROUS_ATTR_PREFIXES {
668 if attr_name.starts_with(prefix) && attr_name.len() > prefix.len() {
669 return true;
670 }
671 }
672
673 if attr_name.starts_with("data-") {
675 return true;
676 }
677
678 if DANGEROUS_ATTRS.contains(&attr_name.as_str()) {
680 return true;
681 }
682 }
683
684 false
685 }
686
687 fn convert_a_to_markdown(&self, opening_tag: &str, inner_content: &str) -> Option<String> {
690 let href = Self::extract_attribute(opening_tag, "href")?;
692
693 if !MD033Config::is_safe_url(&href) {
695 return None;
696 }
697
698 if inner_content.contains('<') {
700 return None;
701 }
702
703 if inner_content.contains('&') && inner_content.contains(';') {
705 let has_entity = inner_content
706 .split('&')
707 .skip(1)
708 .any(|part| part.split(';').next().is_some_and(|e| !e.is_empty() && e.len() < 10));
709 if has_entity {
710 return None;
711 }
712 }
713
714 let title = Self::extract_attribute(opening_tag, "title");
716
717 if self.has_extra_attributes(opening_tag, &["href", "title"]) {
719 return None;
720 }
721
722 let trimmed_inner = inner_content.trim();
727 let is_markdown_image =
728 trimmed_inner.starts_with(" && trimmed_inner.ends_with(')') && {
729 if let Some(bracket_close) = trimmed_inner.rfind("](") {
732 let after_paren = &trimmed_inner[bracket_close + 2..];
733 after_paren.ends_with(')')
735 && after_paren.chars().filter(|&c| c == ')').count()
736 >= after_paren.chars().filter(|&c| c == '(').count()
737 } else {
738 false
739 }
740 };
741 let escaped_text = if is_markdown_image {
742 trimmed_inner.to_string()
743 } else {
744 inner_content.replace('[', r"\[").replace(']', r"\]")
747 };
748
749 let escaped_url = href.replace('(', "%28").replace(')', "%29");
751
752 if let Some(title_text) = title {
754 let escaped_title = title_text.replace('"', r#"\""#);
756 Some(format!("[{escaped_text}]({escaped_url} \"{escaped_title}\")"))
757 } else {
758 Some(format!("[{escaped_text}]({escaped_url})"))
759 }
760 }
761
762 fn convert_img_to_markdown(&self, tag: &str) -> Option<String> {
765 let src = Self::extract_attribute(tag, "src")?;
767
768 if !MD033Config::is_safe_url(&src) {
770 return None;
771 }
772
773 let alt = Self::extract_attribute(tag, "alt").unwrap_or_default();
775
776 let title = Self::extract_attribute(tag, "title");
778
779 if self.has_extra_attributes(tag, &["src", "alt", "title"]) {
781 return None;
782 }
783
784 let escaped_alt = alt.replace('[', r"\[").replace(']', r"\]");
786
787 let escaped_url = src.replace('(', "%28").replace(')', "%29");
789
790 if let Some(title_text) = title {
792 let escaped_title = title_text.replace('"', r#"\""#);
794 Some(format!(""))
795 } else {
796 Some(format!(""))
797 }
798 }
799
800 fn has_significant_attributes(opening_tag: &str) -> bool {
802 let tag_content = opening_tag
804 .trim_start_matches('<')
805 .trim_end_matches('>')
806 .trim_end_matches('/');
807
808 let parts: Vec<&str> = tag_content.split_whitespace().collect();
810 parts.len() > 1
811 }
812
813 fn is_nested_in_html(content: &str, tag_byte_start: usize, tag_byte_end: usize) -> bool {
816 if tag_byte_start > 0 {
818 let before = &content[..tag_byte_start];
819 let before_trimmed = before.trim_end();
820 if before_trimmed.ends_with('>') && !before_trimmed.ends_with("->") {
821 if let Some(last_lt) = before_trimmed.rfind('<') {
823 let potential_tag = &before_trimmed[last_lt..];
824 if !potential_tag.starts_with("</") && !potential_tag.starts_with("<!--") {
826 return true;
827 }
828 }
829 }
830 }
831 if tag_byte_end < content.len() {
833 let after = &content[tag_byte_end..];
834 let after_trimmed = after.trim_start();
835 if after_trimmed.starts_with("</") {
836 return true;
837 }
838 }
839 false
840 }
841
842 fn calculate_fix(
857 &self,
858 content: &str,
859 opening_tag: &str,
860 tag_byte_start: usize,
861 in_html_block: bool,
862 ) -> Option<(std::ops::Range<usize>, String)> {
863 let tag_name = opening_tag
865 .trim_start_matches('<')
866 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
867 .next()?
868 .to_lowercase();
869
870 let is_self_closing =
872 opening_tag.ends_with("/>") || matches!(tag_name.as_str(), "br" | "hr" | "img" | "input" | "meta" | "link");
873
874 if is_self_closing {
875 let block_ok = !in_html_block
881 || (self.is_relaxed_fix_mode() && self.is_inside_strippable_wrapper(content, tag_byte_start));
882 if self.config.fix
883 && MD033Config::is_safe_fixable_tag(&tag_name)
884 && block_ok
885 && let Some(markdown) = self.convert_self_closing_to_markdown(&tag_name, opening_tag)
886 {
887 return Some((tag_byte_start..tag_byte_start + opening_tag.len(), markdown));
888 }
889 return None;
892 }
893
894 let search_start = tag_byte_start + opening_tag.len();
896 let search_slice = &content[search_start..];
897
898 let closing_tag_lower = format!("</{tag_name}>");
900 let closing_pos = search_slice.to_ascii_lowercase().find(&closing_tag_lower);
901
902 if let Some(closing_pos) = closing_pos {
903 let closing_tag_len = closing_tag_lower.len();
905 let closing_byte_start = search_start + closing_pos;
906 let closing_byte_end = closing_byte_start + closing_tag_len;
907
908 let inner_content = &content[search_start..closing_byte_start];
910
911 if self.config.fix && self.is_strippable_wrapper(&tag_name) {
918 if Self::is_nested_in_html(content, tag_byte_start, closing_byte_end) {
919 return None;
920 }
921 if inner_content.contains('<') {
922 return None;
923 }
924 return Some((tag_byte_start..closing_byte_end, inner_content.trim().to_string()));
925 }
926
927 if in_html_block {
930 return None;
931 }
932
933 if Self::is_nested_in_html(content, tag_byte_start, closing_byte_end) {
936 return None;
937 }
938
939 if self.config.fix && MD033Config::is_safe_fixable_tag(&tag_name) {
941 if tag_name == "a" {
943 if let Some(markdown) = self.convert_a_to_markdown(opening_tag, inner_content) {
944 return Some((tag_byte_start..closing_byte_end, markdown));
945 }
946 return None;
948 }
949
950 if Self::has_significant_attributes(opening_tag) {
952 return None;
955 }
956 if let Some(markdown) = Self::convert_to_markdown(&tag_name, inner_content) {
957 return Some((tag_byte_start..closing_byte_end, markdown));
958 }
959 return None;
962 }
963
964 return None;
967 }
968
969 None
971 }
972}
973
974impl Rule for MD033NoInlineHtml {
975 fn name(&self) -> &'static str {
976 "MD033"
977 }
978
979 fn description(&self) -> &'static str {
980 "Inline HTML is not allowed"
981 }
982
983 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
984 let content = ctx.content;
985
986 if content.is_empty() || !ctx.likely_has_html() {
988 return Ok(Vec::new());
989 }
990
991 if !HTML_TAG_QUICK_CHECK.is_match(content) {
993 return Ok(Vec::new());
994 }
995
996 let mut warnings = Vec::new();
997
998 let html_tags = ctx.html_tags();
1000
1001 for html_tag in html_tags.iter() {
1002 if html_tag.is_closing {
1004 continue;
1005 }
1006
1007 let line_num = html_tag.line;
1008 let tag_byte_start = html_tag.byte_offset;
1009
1010 let tag = &content[html_tag.byte_offset..html_tag.byte_end];
1012
1013 if ctx.line_info(line_num).is_some_and(|info| {
1015 info.in_code_block
1016 || info.in_pymdown_block
1017 || info.is_kramdown_block_ial
1018 || info.in_front_matter
1019 || info.in_math_block
1020 }) {
1021 continue;
1022 }
1023
1024 if ctx.is_in_html_comment(tag_byte_start) || ctx.is_in_mdx_comment(tag_byte_start) {
1026 continue;
1027 }
1028
1029 if self.is_html_comment(tag) {
1031 continue;
1032 }
1033
1034 if ctx.is_in_link_title(tag_byte_start) {
1037 continue;
1038 }
1039
1040 if ctx.flavor.supports_jsx() && html_tag.tag_name.chars().next().is_some_and(char::is_uppercase) {
1042 continue;
1043 }
1044
1045 if ctx.flavor.supports_jsx() && (html_tag.tag_name.is_empty() || tag == "<>" || tag == "</>") {
1047 continue;
1048 }
1049
1050 if ctx.flavor.supports_jsx() && Self::has_jsx_attributes(tag) {
1053 continue;
1054 }
1055
1056 if !Self::is_html_element_or_custom(&html_tag.tag_name) {
1058 continue;
1059 }
1060
1061 if self.is_likely_type_annotation(tag) {
1063 continue;
1064 }
1065
1066 if self.is_email_address(tag) {
1068 continue;
1069 }
1070
1071 if self.is_url_in_angle_brackets(tag) {
1073 continue;
1074 }
1075
1076 if ctx.is_byte_offset_in_code_span(tag_byte_start) {
1078 continue;
1079 }
1080
1081 if self.is_disallowed_mode() {
1086 if !self.is_tag_disallowed(tag) {
1087 continue;
1088 }
1089 } else if ctx.is_in_table_block(line_num) {
1090 if self.is_tag_allowed_in_table(tag) {
1091 continue;
1092 }
1093 } else if self.is_tag_allowed(tag) {
1094 continue;
1095 }
1096
1097 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && self.has_markdown_attribute(tag) {
1099 continue;
1100 }
1101
1102 let in_html_block = ctx.is_in_html_block(line_num);
1104
1105 let fix = self
1107 .calculate_fix(content, tag, tag_byte_start, in_html_block)
1108 .map(|(range, replacement)| Fix::new(range, replacement));
1109
1110 let (end_line, end_col) = if html_tag.byte_end > 0 {
1113 ctx.offset_to_line_col(html_tag.byte_end - 1)
1114 } else {
1115 (line_num, html_tag.end_col + 1)
1116 };
1117
1118 warnings.push(LintWarning {
1120 rule_name: Some(self.name().to_string()),
1121 line: line_num,
1122 column: html_tag.start_col + 1, end_line, end_column: end_col + 1, message: format!("Inline HTML found: {tag}"),
1126 severity: Severity::Warning,
1127 fix,
1128 });
1129 }
1130
1131 Ok(warnings)
1132 }
1133
1134 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1135 if !self.config.fix {
1137 return Ok(ctx.content.to_string());
1138 }
1139
1140 let warnings = self.check(ctx)?;
1142 let warnings =
1143 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1144
1145 if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
1147 return Ok(ctx.content.to_string());
1148 }
1149
1150 let mut fixes: Vec<_> = warnings
1152 .iter()
1153 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
1154 .collect();
1155 fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
1156
1157 let mut result = ctx.content.to_string();
1159 for (start, end, replacement) in fixes {
1160 if start < result.len() && end <= result.len() && start <= end {
1161 result.replace_range(start..end, replacement);
1162 }
1163 }
1164
1165 Ok(result)
1166 }
1167
1168 fn fix_capability(&self) -> crate::rule::FixCapability {
1169 if self.config.fix {
1170 crate::rule::FixCapability::FullyFixable
1171 } else {
1172 crate::rule::FixCapability::Unfixable
1173 }
1174 }
1175
1176 fn category(&self) -> RuleCategory {
1178 RuleCategory::Html
1179 }
1180
1181 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1183 ctx.content.is_empty() || !ctx.likely_has_html()
1184 }
1185
1186 fn as_any(&self) -> &dyn std::any::Any {
1187 self
1188 }
1189
1190 crate::impl_rule_config_methods!(MD033Config, nullable);
1191
1192 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
1193 let mut aliases = std::collections::HashMap::new();
1194 aliases.insert("allowed".to_string(), "allowed-elements".to_string());
1196 aliases.insert("disallowed".to_string(), "disallowed-elements".to_string());
1197 aliases.insert("table-allowed".to_string(), "table-allowed-elements".to_string());
1200 aliases.insert("table_allowed".to_string(), "table-allowed-elements".to_string());
1201 Some(aliases)
1202 }
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207 use super::*;
1208 use crate::lint_context::LintContext;
1209 use crate::rule::Rule;
1210
1211 fn relaxed_fix_rule() -> MD033NoInlineHtml {
1212 let config = MD033Config {
1213 fix: true,
1214 fix_mode: MD033FixMode::Relaxed,
1215 ..MD033Config::default()
1216 };
1217 MD033NoInlineHtml::from_config_struct(config)
1218 }
1219
1220 #[test]
1221 fn test_md033_basic_html() {
1222 let rule = MD033NoInlineHtml::default();
1223 let content = "<div>Some content</div>";
1224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1225 let result = rule.check(&ctx).unwrap();
1226 assert_eq!(result.len(), 1); assert!(result[0].message.starts_with("Inline HTML found: <div>"));
1229 }
1230
1231 #[test]
1232 fn test_md033_front_matter() {
1233 let rule = MD033NoInlineHtml::default();
1234 let content = "---\ndescription: <div class=\"test\">hello</div>\n---\n# Title\n<div>body</div>";
1235 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1236 let result = rule.check(&ctx).unwrap();
1237 assert_eq!(result.len(), 1);
1239 assert_eq!(result[0].line, 5);
1240 assert_eq!(result[0].message, "Inline HTML found: <div>");
1241 }
1242
1243 #[test]
1244 fn test_md033_math_block() {
1245 let rule = MD033NoInlineHtml::default();
1246 let content = "$$\nx < y && y > z\n$$\n<div>body</div>";
1247 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1248 let result = rule.check(&ctx).unwrap();
1249 assert_eq!(result.len(), 1);
1251 assert_eq!(result[0].line, 4);
1252 }
1253
1254 #[test]
1255 fn test_md033_case_insensitive() {
1256 let rule = MD033NoInlineHtml::default();
1257 let content = "<DiV>Some <B>content</B></dIv>";
1258 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1259 let result = rule.check(&ctx).unwrap();
1260 assert_eq!(result.len(), 2); assert_eq!(result[0].message, "Inline HTML found: <DiV>");
1263 assert_eq!(result[1].message, "Inline HTML found: <B>");
1264 }
1265
1266 #[test]
1267 fn test_md033_multibyte_whitespace_in_tag_does_not_panic() {
1268 let rule = relaxed_fix_rule();
1271 let content = "<img\u{00A0}src=\"test.png\" alt=\"x\">";
1272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1273 let _ = rule.check(&ctx).unwrap();
1275 let _ = rule.fix(&ctx).unwrap();
1276 }
1277
1278 #[test]
1279 fn test_md033_allowed_tags() {
1280 let rule = MD033NoInlineHtml::with_allowed(vec!["div".to_string(), "br".to_string()]);
1281 let content = "<div>Allowed</div><p>Not allowed</p><br/>";
1282 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1283 let result = rule.check(&ctx).unwrap();
1284 assert_eq!(result.len(), 1);
1286 assert_eq!(result[0].message, "Inline HTML found: <p>");
1287
1288 let content2 = "<DIV>Allowed</DIV><P>Not allowed</P><BR/>";
1290 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1291 let result2 = rule.check(&ctx2).unwrap();
1292 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <P>");
1294 }
1295
1296 #[test]
1297 fn test_md033_html_comments() {
1298 let rule = MD033NoInlineHtml::default();
1299 let content = "<!-- This is a comment --> <p>Not a comment</p>";
1300 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1301 let result = rule.check(&ctx).unwrap();
1302 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <p>");
1305 }
1306
1307 #[test]
1308 fn test_md033_tags_in_links() {
1309 let rule = MD033NoInlineHtml::default();
1310 let content = "[Link](http://example.com/<div>)";
1311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1312 let result = rule.check(&ctx).unwrap();
1313 assert_eq!(result.len(), 1);
1315 assert_eq!(result[0].message, "Inline HTML found: <div>");
1316
1317 let content2 = "[Link <a>text</a>](url)";
1318 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1319 let result2 = rule.check(&ctx2).unwrap();
1320 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <a>");
1323 }
1324
1325 #[test]
1326 fn test_md033_fix_escaping() {
1327 let rule = MD033NoInlineHtml::default();
1328 let content = "Text with <div> and <br/> tags.";
1329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1330 let fixed_content = rule.fix(&ctx).unwrap();
1331 assert_eq!(fixed_content, content);
1333 }
1334
1335 #[test]
1336 fn test_md033_in_code_blocks() {
1337 let rule = MD033NoInlineHtml::default();
1338 let content = "```html\n<div>Code</div>\n```\n<div>Not code</div>";
1339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1340 let result = rule.check(&ctx).unwrap();
1341 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <div>");
1344 }
1345
1346 #[test]
1347 fn test_md033_in_code_spans() {
1348 let rule = MD033NoInlineHtml::default();
1349 let content = "Text with `<p>in code</p>` span. <br/> Not in span.";
1350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1351 let result = rule.check(&ctx).unwrap();
1352 assert_eq!(result.len(), 1);
1354 assert_eq!(result[0].message, "Inline HTML found: <br/>");
1355 }
1356
1357 #[test]
1358 fn test_md033_issue_90_code_span_with_diff_block() {
1359 let rule = MD033NoInlineHtml::default();
1361 let content = r#"# Heading
1362
1363`<env>`
1364
1365```diff
1366- this
1367+ that
1368```"#;
1369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1370 let result = rule.check(&ctx).unwrap();
1371 assert_eq!(result.len(), 0, "Should not report HTML tags inside code spans");
1373 }
1374
1375 #[test]
1376 fn test_md033_multiple_code_spans_with_angle_brackets() {
1377 let rule = MD033NoInlineHtml::default();
1379 let content = "`<one>` and `<two>` and `<three>` are all code spans";
1380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1381 let result = rule.check(&ctx).unwrap();
1382 assert_eq!(result.len(), 0, "Should not report HTML tags inside any code spans");
1383 }
1384
1385 #[test]
1386 fn test_md033_nested_angle_brackets_in_code_span() {
1387 let rule = MD033NoInlineHtml::default();
1389 let content = "Text with `<<nested>>` brackets";
1390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1391 let result = rule.check(&ctx).unwrap();
1392 assert_eq!(result.len(), 0, "Should handle nested angle brackets in code spans");
1393 }
1394
1395 #[test]
1396 fn test_md033_code_span_at_end_before_code_block() {
1397 let rule = MD033NoInlineHtml::default();
1399 let content = "Testing `<test>`\n```\ncode here\n```";
1400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1401 let result = rule.check(&ctx).unwrap();
1402 assert_eq!(result.len(), 0, "Should handle code span before code block");
1403 }
1404
1405 #[test]
1406 fn test_md033_quick_fix_inline_tag() {
1407 let rule = MD033NoInlineHtml::default();
1410 let content = "This has <span>inline text</span> that should keep content.";
1411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1412 let result = rule.check(&ctx).unwrap();
1413
1414 assert_eq!(result.len(), 1, "Should find one HTML tag");
1415 assert!(
1417 result[0].fix.is_none(),
1418 "Non-fixable tags like <span> should not have a fix"
1419 );
1420 }
1421
1422 #[test]
1423 fn test_md033_quick_fix_multiline_tag() {
1424 let rule = MD033NoInlineHtml::default();
1427 let content = "<div>\nBlock content\n</div>";
1428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1429 let result = rule.check(&ctx).unwrap();
1430
1431 assert_eq!(result.len(), 1, "Should find one HTML tag");
1432 assert!(result[0].fix.is_none(), "HTML block elements should NOT have auto-fix");
1434 }
1435
1436 #[test]
1437 fn test_md033_quick_fix_self_closing_tag() {
1438 let rule = MD033NoInlineHtml::default();
1440 let content = "Self-closing: <br/>";
1441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1442 let result = rule.check(&ctx).unwrap();
1443
1444 assert_eq!(result.len(), 1, "Should find one HTML tag");
1445 assert!(
1447 result[0].fix.is_none(),
1448 "Self-closing tags should not have a fix when fix config is false"
1449 );
1450 }
1451
1452 #[test]
1453 fn test_md033_quick_fix_multiple_tags() {
1454 let rule = MD033NoInlineHtml::default();
1457 let content = "<span>first</span> and <strong>second</strong>";
1458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1459 let result = rule.check(&ctx).unwrap();
1460
1461 assert_eq!(result.len(), 2, "Should find two HTML tags");
1462 assert!(result[0].fix.is_none(), "Non-fixable <span> should not have a fix");
1464 assert!(
1465 result[1].fix.is_none(),
1466 "<strong> should not have a fix when fix config is false"
1467 );
1468 }
1469
1470 #[test]
1471 fn test_md033_skip_angle_brackets_in_link_titles() {
1472 let rule = MD033NoInlineHtml::default();
1474 let content = r#"# Test
1475
1476[example]: <https://example.com> "Title with <Angle Brackets> inside"
1477
1478Regular text with <div>content</div> HTML tag.
1479"#;
1480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1481 let result = rule.check(&ctx).unwrap();
1482
1483 assert_eq!(result.len(), 1, "Should find opening div tag");
1486 assert!(
1487 result[0].message.contains("<div>"),
1488 "Should flag <div>, got: {}",
1489 result[0].message
1490 );
1491 }
1492
1493 #[test]
1494 fn test_md033_skip_angle_brackets_in_link_title_single_quotes() {
1495 let rule = MD033NoInlineHtml::default();
1497 let content = r#"[ref]: url 'Title <Help Wanted> here'
1498
1499<span>text</span> here
1500"#;
1501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1502 let result = rule.check(&ctx).unwrap();
1503
1504 assert_eq!(result.len(), 1, "Should find opening span tag");
1507 assert!(
1508 result[0].message.contains("<span>"),
1509 "Should flag <span>, got: {}",
1510 result[0].message
1511 );
1512 }
1513
1514 #[test]
1515 fn test_md033_multiline_tag_end_line_calculation() {
1516 let rule = MD033NoInlineHtml::default();
1518 let content = "<div\n class=\"test\"\n id=\"example\">";
1519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1520 let result = rule.check(&ctx).unwrap();
1521
1522 assert_eq!(result.len(), 1, "Should find one HTML tag");
1523 assert_eq!(result[0].line, 1, "Start line should be 1");
1525 assert_eq!(result[0].end_line, 3, "End line should be 3");
1527 }
1528
1529 #[test]
1530 fn test_md033_single_line_tag_same_start_end_line() {
1531 let rule = MD033NoInlineHtml::default();
1533 let content = "Some text <div class=\"test\"> more text";
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_eq!(result[0].line, 1, "Start line should be 1");
1539 assert_eq!(result[0].end_line, 1, "End line should be 1 for single-line tag");
1540 }
1541
1542 #[test]
1543 fn test_md033_multiline_tag_with_many_attributes() {
1544 let rule = MD033NoInlineHtml::default();
1546 let content =
1547 "Text\n<div\n data-attr1=\"value1\"\n data-attr2=\"value2\"\n data-attr3=\"value3\">\nMore text";
1548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1549 let result = rule.check(&ctx).unwrap();
1550
1551 assert_eq!(result.len(), 1, "Should find one HTML tag");
1552 assert_eq!(result[0].line, 2, "Start line should be 2");
1554 assert_eq!(result[0].end_line, 5, "End line should be 5");
1556 }
1557
1558 #[test]
1559 fn test_md033_disallowed_mode_basic() {
1560 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string(), "iframe".to_string()]);
1562 let content = "<div>Safe content</div><script>alert('xss')</script>";
1563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1564 let result = rule.check(&ctx).unwrap();
1565
1566 assert_eq!(result.len(), 1, "Should only flag disallowed tags");
1568 assert!(result[0].message.contains("<script>"), "Should flag script tag");
1569 }
1570
1571 #[test]
1572 fn test_md033_disallowed_gfm_security_tags() {
1573 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1575 let content = r#"
1576<div>Safe</div>
1577<title>Bad title</title>
1578<textarea>Bad textarea</textarea>
1579<style>.bad{}</style>
1580<iframe src="evil"></iframe>
1581<script>evil()</script>
1582<plaintext>old tag</plaintext>
1583<span>Safe span</span>
1584"#;
1585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1586 let result = rule.check(&ctx).unwrap();
1587
1588 assert_eq!(result.len(), 6, "Should flag 6 GFM security tags");
1591
1592 let flagged_tags: Vec<&str> = result
1593 .iter()
1594 .filter_map(|w| w.message.split('<').nth(1))
1595 .filter_map(|s| s.split('>').next())
1596 .filter_map(|s| s.split_whitespace().next())
1597 .collect();
1598
1599 assert!(flagged_tags.contains(&"title"), "Should flag title");
1600 assert!(flagged_tags.contains(&"textarea"), "Should flag textarea");
1601 assert!(flagged_tags.contains(&"style"), "Should flag style");
1602 assert!(flagged_tags.contains(&"iframe"), "Should flag iframe");
1603 assert!(flagged_tags.contains(&"script"), "Should flag script");
1604 assert!(flagged_tags.contains(&"plaintext"), "Should flag plaintext");
1605 assert!(!flagged_tags.contains(&"div"), "Should NOT flag div");
1606 assert!(!flagged_tags.contains(&"span"), "Should NOT flag span");
1607 }
1608
1609 #[test]
1610 fn test_md033_disallowed_case_insensitive() {
1611 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string()]);
1613 let content = "<SCRIPT>alert('xss')</SCRIPT><Script>alert('xss')</Script>";
1614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1615 let result = rule.check(&ctx).unwrap();
1616
1617 assert_eq!(result.len(), 2, "Should flag both case variants");
1619 }
1620
1621 #[test]
1622 fn test_md033_disallowed_with_attributes() {
1623 let rule = MD033NoInlineHtml::with_disallowed(vec!["iframe".to_string()]);
1625 let content = r#"<iframe src="https://evil.com" width="100" height="100"></iframe>"#;
1626 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1627 let result = rule.check(&ctx).unwrap();
1628
1629 assert_eq!(result.len(), 1, "Should flag iframe with attributes");
1630 assert!(result[0].message.contains("iframe"), "Should flag iframe");
1631 }
1632
1633 #[test]
1634 fn test_md033_disallowed_all_gfm_tags() {
1635 use md033_config::GFM_DISALLOWED_TAGS;
1637 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1638
1639 for tag in GFM_DISALLOWED_TAGS {
1640 let content = format!("<{tag}>content</{tag}>");
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, "GFM tag <{tag}> should be flagged");
1645 }
1646 }
1647
1648 #[test]
1649 fn test_md033_disallowed_mixed_with_custom() {
1650 let rule = MD033NoInlineHtml::with_disallowed(vec![
1652 "gfm".to_string(),
1653 "marquee".to_string(), ]);
1655 let content = r#"<script>bad</script><marquee>annoying</marquee><div>ok</div>"#;
1656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1657 let result = rule.check(&ctx).unwrap();
1658
1659 assert_eq!(result.len(), 2, "Should flag both gfm and custom tags");
1661 }
1662
1663 #[test]
1664 fn test_md033_disallowed_empty_means_default_mode() {
1665 let rule = MD033NoInlineHtml::with_disallowed(vec![]);
1667 let content = "<div>content</div>";
1668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1669 let result = rule.check(&ctx).unwrap();
1670
1671 assert_eq!(result.len(), 1, "Empty disallowed = default mode");
1673 }
1674
1675 #[test]
1676 fn test_md033_jsx_fragments_in_mdx() {
1677 let rule = MD033NoInlineHtml::default();
1679 let content = r#"# MDX Document
1680
1681<>
1682 <Heading />
1683 <Content />
1684</>
1685
1686<div>Regular HTML should still be flagged</div>
1687"#;
1688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1689 let result = rule.check(&ctx).unwrap();
1690
1691 assert_eq!(result.len(), 1, "Should only find one HTML tag (the div)");
1693 assert!(
1694 result[0].message.contains("<div>"),
1695 "Should flag <div>, not JSX fragments"
1696 );
1697 }
1698
1699 #[test]
1700 fn test_md033_jsx_components_in_mdx() {
1701 let rule = MD033NoInlineHtml::default();
1703 let content = r#"<CustomComponent prop="value">
1704 Content
1705</CustomComponent>
1706
1707<MyButton onClick={handler}>Click</MyButton>
1708"#;
1709 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1710 let result = rule.check(&ctx).unwrap();
1711
1712 assert_eq!(result.len(), 0, "Should not flag JSX components in MDX");
1714 }
1715
1716 #[test]
1717 fn test_md033_jsx_not_skipped_in_standard_markdown() {
1718 let rule = MD033NoInlineHtml::default();
1720 let content = "<Script>alert(1)</Script>";
1721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1722 let result = rule.check(&ctx).unwrap();
1723
1724 assert_eq!(result.len(), 1, "Should flag <Script> in standard markdown");
1726 }
1727
1728 #[test]
1729 fn test_md033_jsx_attributes_in_mdx() {
1730 let rule = MD033NoInlineHtml::default();
1732 let content = r#"# MDX with JSX Attributes
1733
1734<div className="card big">Content</div>
1735
1736<button onClick={handleClick}>Click me</button>
1737
1738<label htmlFor="input-id">Label</label>
1739
1740<input onChange={handleChange} />
1741
1742<div class="html-class">Regular HTML should be flagged</div>
1743"#;
1744 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1745 let result = rule.check(&ctx).unwrap();
1746
1747 assert_eq!(
1749 result.len(),
1750 1,
1751 "Should only flag HTML element without JSX attributes, got: {result:?}"
1752 );
1753 assert!(
1754 result[0].message.contains("<div class="),
1755 "Should flag the div with HTML class attribute"
1756 );
1757 }
1758
1759 #[test]
1760 fn test_md033_jsx_attributes_not_skipped_in_standard() {
1761 let rule = MD033NoInlineHtml::default();
1763 let content = r#"<div className="card">Content</div>"#;
1764 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1765 let result = rule.check(&ctx).unwrap();
1766
1767 assert_eq!(result.len(), 1, "Should flag JSX-style elements in standard markdown");
1769 }
1770
1771 #[test]
1774 fn test_md033_fix_disabled_by_default() {
1775 let rule = MD033NoInlineHtml::default();
1777 assert!(!rule.config.fix, "Fix should be disabled by default");
1778 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::Unfixable);
1779 }
1780
1781 #[test]
1782 fn test_md033_fix_enabled_em_to_italic() {
1783 let rule = MD033NoInlineHtml::with_fix(true);
1785 let content = "This has <em>emphasized text</em> here.";
1786 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1787 let fixed = rule.fix(&ctx).unwrap();
1788 assert_eq!(fixed, "This has *emphasized text* here.");
1789 }
1790
1791 #[test]
1792 fn test_md033_fix_enabled_i_to_italic() {
1793 let rule = MD033NoInlineHtml::with_fix(true);
1795 let content = "This has <i>italic text</i> here.";
1796 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1797 let fixed = rule.fix(&ctx).unwrap();
1798 assert_eq!(fixed, "This has *italic text* here.");
1799 }
1800
1801 #[test]
1802 fn test_md033_fix_enabled_strong_to_bold() {
1803 let rule = MD033NoInlineHtml::with_fix(true);
1805 let content = "This has <strong>bold text</strong> here.";
1806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1807 let fixed = rule.fix(&ctx).unwrap();
1808 assert_eq!(fixed, "This has **bold text** here.");
1809 }
1810
1811 #[test]
1812 fn test_md033_fix_enabled_b_to_bold() {
1813 let rule = MD033NoInlineHtml::with_fix(true);
1815 let content = "This has <b>bold text</b> here.";
1816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1817 let fixed = rule.fix(&ctx).unwrap();
1818 assert_eq!(fixed, "This has **bold text** here.");
1819 }
1820
1821 #[test]
1822 fn test_md033_fix_enabled_code_to_backticks() {
1823 let rule = MD033NoInlineHtml::with_fix(true);
1825 let content = "This has <code>inline code</code> here.";
1826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1827 let fixed = rule.fix(&ctx).unwrap();
1828 assert_eq!(fixed, "This has `inline code` here.");
1829 }
1830
1831 #[test]
1832 fn test_md033_fix_enabled_code_with_backticks() {
1833 let rule = MD033NoInlineHtml::with_fix(true);
1835 let content = "This has <code>text with `backticks`</code> here.";
1836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1837 let fixed = rule.fix(&ctx).unwrap();
1838 assert_eq!(fixed, "This has `` text with `backticks` `` here.");
1839 }
1840
1841 #[test]
1842 fn test_md033_fix_enabled_br_trailing_spaces() {
1843 let rule = MD033NoInlineHtml::with_fix(true);
1845 let content = "First line<br>Second line";
1846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1847 let fixed = rule.fix(&ctx).unwrap();
1848 assert_eq!(fixed, "First line \nSecond line");
1849 }
1850
1851 #[test]
1852 fn test_md033_fix_enabled_br_self_closing() {
1853 let rule = MD033NoInlineHtml::with_fix(true);
1855 let content = "First<br/>second<br />third";
1856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1857 let fixed = rule.fix(&ctx).unwrap();
1858 assert_eq!(fixed, "First \nsecond \nthird");
1859 }
1860
1861 #[test]
1862 fn test_md033_fix_enabled_br_backslash_style() {
1863 let config = MD033Config {
1865 allowed: Vec::new(),
1866 disallowed: Vec::new(),
1867 fix: true,
1868 br_style: md033_config::BrStyle::Backslash,
1869 ..MD033Config::default()
1870 };
1871 let rule = MD033NoInlineHtml::from_config_struct(config);
1872 let content = "First line<br>Second line";
1873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1874 let fixed = rule.fix(&ctx).unwrap();
1875 assert_eq!(fixed, "First line\\\nSecond line");
1876 }
1877
1878 #[test]
1879 fn test_md033_fix_enabled_hr() {
1880 let rule = MD033NoInlineHtml::with_fix(true);
1882 let content = "Above<hr>Below";
1883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1884 let fixed = rule.fix(&ctx).unwrap();
1885 assert_eq!(fixed, "Above\n---\nBelow");
1886 }
1887
1888 #[test]
1889 fn test_md033_fix_enabled_hr_self_closing() {
1890 let rule = MD033NoInlineHtml::with_fix(true);
1892 let content = "Above<hr/>Below";
1893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1894 let fixed = rule.fix(&ctx).unwrap();
1895 assert_eq!(fixed, "Above\n---\nBelow");
1896 }
1897
1898 #[test]
1899 fn test_md033_fix_skips_nested_tags() {
1900 let rule = MD033NoInlineHtml::with_fix(true);
1903 let content = "This has <em>text with <strong>nested</strong> tags</em> here.";
1904 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1905 let fixed = rule.fix(&ctx).unwrap();
1906 assert_eq!(fixed, "This has <em>text with **nested** tags</em> here.");
1909 }
1910
1911 #[test]
1912 fn test_md033_fix_skips_tags_with_attributes() {
1913 let rule = MD033NoInlineHtml::with_fix(true);
1916 let content = "This has <em class=\"highlight\">emphasized</em> text.";
1917 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1918 let fixed = rule.fix(&ctx).unwrap();
1919 assert_eq!(fixed, content);
1921 }
1922
1923 #[test]
1924 fn test_md033_fix_disabled_no_changes() {
1925 let rule = MD033NoInlineHtml::default(); let content = "This has <em>emphasized text</em> here.";
1928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1929 let fixed = rule.fix(&ctx).unwrap();
1930 assert_eq!(fixed, content, "Should return original content when fix is disabled");
1931 }
1932
1933 #[test]
1934 fn test_md033_fix_capability_enabled() {
1935 let rule = MD033NoInlineHtml::with_fix(true);
1936 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::FullyFixable);
1937 }
1938
1939 #[test]
1940 fn test_md033_fix_multiple_tags() {
1941 let rule = MD033NoInlineHtml::with_fix(true);
1943 let content = "Here is <em>italic</em> and <strong>bold</strong> text.";
1944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1945 let fixed = rule.fix(&ctx).unwrap();
1946 assert_eq!(fixed, "Here is *italic* and **bold** text.");
1947 }
1948
1949 #[test]
1950 fn test_md033_fix_uppercase_tags() {
1951 let rule = MD033NoInlineHtml::with_fix(true);
1953 let content = "This has <EM>emphasized</EM> text.";
1954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1955 let fixed = rule.fix(&ctx).unwrap();
1956 assert_eq!(fixed, "This has *emphasized* text.");
1957 }
1958
1959 #[test]
1960 fn test_md033_fix_unsafe_tags_not_modified() {
1961 let rule = MD033NoInlineHtml::with_fix(true);
1964 let content = "This has <div>a div</div> content.";
1965 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1966 let fixed = rule.fix(&ctx).unwrap();
1967 assert_eq!(fixed, "This has <div>a div</div> content.");
1969 }
1970
1971 #[test]
1972 fn test_md033_fix_img_tag_converted() {
1973 let rule = MD033NoInlineHtml::with_fix(true);
1975 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\">";
1976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1977 let fixed = rule.fix(&ctx).unwrap();
1978 assert_eq!(fixed, "Image: ");
1980 }
1981
1982 #[test]
1983 fn test_md033_fix_img_tag_with_extra_attrs_not_converted() {
1984 let rule = MD033NoInlineHtml::with_fix(true);
1986 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
1987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1988 let fixed = rule.fix(&ctx).unwrap();
1989 assert_eq!(fixed, "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">");
1991 }
1992
1993 #[test]
1994 fn test_md033_fix_relaxed_a_with_target_is_converted() {
1995 let rule = relaxed_fix_rule();
1996 let content = "Link: <a href=\"https://example.com\" target=\"_blank\">Example</a>";
1997 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1998 let fixed = rule.fix(&ctx).unwrap();
1999 assert_eq!(fixed, "Link: [Example](https://example.com)");
2000 }
2001
2002 #[test]
2003 fn test_md033_fix_relaxed_img_with_width_is_converted() {
2004 let rule = relaxed_fix_rule();
2005 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
2006 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2007 let fixed = rule.fix(&ctx).unwrap();
2008 assert_eq!(fixed, "Image: ");
2009 }
2010
2011 #[test]
2012 fn test_md033_fix_relaxed_rejects_unknown_extra_attributes() {
2013 let rule = relaxed_fix_rule();
2014 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" aria-label=\"hero\">";
2015 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2016 let fixed = rule.fix(&ctx).unwrap();
2017 assert_eq!(fixed, content, "Unknown attributes should not be dropped by default");
2018 }
2019
2020 #[test]
2021 fn test_md033_fix_relaxed_still_blocks_unsafe_schemes() {
2022 let rule = relaxed_fix_rule();
2023 let content = "Link: <a href=\"javascript:alert(1)\" target=\"_blank\">Example</a>";
2024 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2025 let fixed = rule.fix(&ctx).unwrap();
2026 assert_eq!(fixed, content, "Unsafe URL schemes must never be converted");
2027 }
2028
2029 #[test]
2030 fn test_md033_fix_relaxed_wrapper_strip_requires_second_pass_for_nested_html() {
2031 let rule = relaxed_fix_rule();
2032 let content = "<p align=\"center\">\n <img src=\"logo.svg\" alt=\"Logo\" width=\"120\" />\n</p>";
2033 let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2034 let fixed_once = rule.fix(&ctx1).unwrap();
2035 assert!(
2036 fixed_once.contains("<p"),
2037 "First pass should keep wrapper when inner HTML is still present: {fixed_once}"
2038 );
2039 assert!(
2040 fixed_once.contains(""),
2041 "Inner image should be converted on first pass: {fixed_once}"
2042 );
2043
2044 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2045 let fixed_twice = rule.fix(&ctx2).unwrap();
2046 assert!(
2047 !fixed_twice.contains("<p"),
2048 "Second pass should strip configured wrapper: {fixed_twice}"
2049 );
2050 assert!(fixed_twice.contains(""));
2051 }
2052
2053 #[test]
2054 fn test_md033_fix_relaxed_multiple_droppable_attrs() {
2055 let rule = relaxed_fix_rule();
2056 let content = "<a href=\"https://example.com\" target=\"_blank\" rel=\"noopener\" class=\"btn\">Click</a>";
2057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2058 let fixed = rule.fix(&ctx).unwrap();
2059 assert_eq!(fixed, "[Click](https://example.com)");
2060 }
2061
2062 #[test]
2063 fn test_md033_fix_relaxed_img_multiple_droppable_attrs() {
2064 let rule = relaxed_fix_rule();
2065 let content = "<img src=\"logo.png\" alt=\"Logo\" width=\"120\" height=\"40\" style=\"border:none\" />";
2066 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2067 let fixed = rule.fix(&ctx).unwrap();
2068 assert_eq!(fixed, "");
2069 }
2070
2071 #[test]
2072 fn test_md033_fix_relaxed_event_handler_never_dropped() {
2073 let rule = relaxed_fix_rule();
2074 let content = "<a href=\"https://example.com\" onclick=\"track()\">Link</a>";
2075 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2076 let fixed = rule.fix(&ctx).unwrap();
2077 assert_eq!(fixed, content, "Event handler attributes must block conversion");
2078 }
2079
2080 #[test]
2081 fn test_md033_fix_relaxed_event_handler_even_with_custom_config() {
2082 let config = MD033Config {
2084 fix: true,
2085 fix_mode: MD033FixMode::Relaxed,
2086 drop_attributes: vec!["on*".to_string(), "target".to_string()],
2087 ..MD033Config::default()
2088 };
2089 let rule = MD033NoInlineHtml::from_config_struct(config);
2090 let content = "<a href=\"https://example.com\" onclick=\"alert(1)\">Link</a>";
2091 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2092 let fixed = rule.fix(&ctx).unwrap();
2093 assert_eq!(fixed, content, "on* event handlers must never be dropped");
2094 }
2095
2096 #[test]
2097 fn test_md033_fix_relaxed_custom_drop_attributes() {
2098 let config = MD033Config {
2099 fix: true,
2100 fix_mode: MD033FixMode::Relaxed,
2101 drop_attributes: vec!["loading".to_string()],
2102 ..MD033Config::default()
2103 };
2104 let rule = MD033NoInlineHtml::from_config_struct(config);
2105 let content = "<img src=\"x.jpg\" alt=\"\" loading=\"lazy\">";
2107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2108 let fixed = rule.fix(&ctx).unwrap();
2109 assert_eq!(fixed, "", "Custom drop-attributes should be respected");
2110
2111 let content2 = "<img src=\"x.jpg\" alt=\"\" width=\"100\">";
2112 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
2113 let fixed2 = rule.fix(&ctx2).unwrap();
2114 assert_eq!(
2115 fixed2, content2,
2116 "Attributes not in custom list should block conversion"
2117 );
2118 }
2119
2120 #[test]
2121 fn test_md033_fix_relaxed_custom_strip_wrapper() {
2122 let config = MD033Config {
2123 fix: true,
2124 fix_mode: MD033FixMode::Relaxed,
2125 strip_wrapper_elements: vec!["div".to_string()],
2126 ..MD033Config::default()
2127 };
2128 let rule = MD033NoInlineHtml::from_config_struct(config);
2129 let content = "<div>Some text content</div>";
2130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2131 let fixed = rule.fix(&ctx).unwrap();
2132 assert_eq!(fixed, "Some text content");
2133 }
2134
2135 #[test]
2136 fn test_md033_fix_relaxed_wrapper_with_plain_text() {
2137 let rule = relaxed_fix_rule();
2138 let content = "<p align=\"center\">Just some text</p>";
2139 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2140 let fixed = rule.fix(&ctx).unwrap();
2141 assert_eq!(fixed, "Just some text");
2142 }
2143
2144 #[test]
2145 fn test_md033_fix_relaxed_data_attr_with_wildcard() {
2146 let config = MD033Config {
2147 fix: true,
2148 fix_mode: MD033FixMode::Relaxed,
2149 drop_attributes: vec!["data-*".to_string(), "target".to_string()],
2150 ..MD033Config::default()
2151 };
2152 let rule = MD033NoInlineHtml::from_config_struct(config);
2153 let content = "<a href=\"https://example.com\" data-tracking=\"abc\" target=\"_blank\">Link</a>";
2154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2155 let fixed = rule.fix(&ctx).unwrap();
2156 assert_eq!(fixed, "[Link](https://example.com)");
2157 }
2158
2159 #[test]
2160 fn test_md033_fix_relaxed_mixed_droppable_and_blocking_attrs() {
2161 let rule = relaxed_fix_rule();
2162 let content = "<a href=\"https://example.com\" target=\"_blank\" aria-label=\"nav\">Link</a>";
2164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2165 let fixed = rule.fix(&ctx).unwrap();
2166 assert_eq!(fixed, content, "Non-droppable attribute should block conversion");
2167 }
2168
2169 #[test]
2170 fn test_md033_fix_relaxed_badge_pattern() {
2171 let rule = relaxed_fix_rule();
2173 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>";
2174 let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2175 let fixed_once = rule.fix(&ctx1).unwrap();
2176 assert!(
2178 fixed_once.contains(""),
2179 "Inner img should be converted: {fixed_once}"
2180 );
2181
2182 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2184 let fixed_twice = rule.fix(&ctx2).unwrap();
2185 assert!(
2186 fixed_twice
2187 .contains("[](https://crates.io/crates/rumdl)"),
2188 "Badge should produce nested markdown image link: {fixed_twice}"
2189 );
2190 }
2191
2192 #[test]
2193 fn test_md033_fix_relaxed_conservative_mode_unchanged() {
2194 let rule = MD033NoInlineHtml::with_fix(true);
2196 let content = "<a href=\"https://example.com\" target=\"_blank\">Link</a>";
2197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2198 let fixed = rule.fix(&ctx).unwrap();
2199 assert_eq!(fixed, content, "Conservative mode should not drop target attribute");
2200 }
2201
2202 #[test]
2203 fn test_md033_fix_relaxed_img_inside_pre_not_converted() {
2204 let rule = relaxed_fix_rule();
2206 let content = "<pre>\n <img src=\"diagram.png\" alt=\"d\" width=\"100\" />\n</pre>";
2207 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2208 let fixed = rule.fix(&ctx).unwrap();
2209 assert!(fixed.contains("<img"), "img inside pre must not be converted: {fixed}");
2210 }
2211
2212 #[test]
2213 fn test_md033_fix_relaxed_wrapper_nested_inside_div_not_stripped() {
2214 let rule = relaxed_fix_rule();
2216 let content = "<div><p>text</p></div>";
2217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2218 let fixed = rule.fix(&ctx).unwrap();
2219 assert!(
2220 fixed.contains("<p>text</p>") || fixed.contains("<p>"),
2221 "Nested <p> inside <div> should not be stripped: {fixed}"
2222 );
2223 }
2224
2225 #[test]
2226 fn test_md033_fix_relaxed_img_inside_nested_wrapper_not_converted() {
2227 let rule = relaxed_fix_rule();
2231 let content = "<div><p><img src=\"x.jpg\" alt=\"pic\" width=\"100\" /></p></div>";
2232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2233 let fixed = rule.fix(&ctx).unwrap();
2234 assert!(
2235 fixed.contains("<img"),
2236 "img inside nested wrapper must not be converted: {fixed}"
2237 );
2238 }
2239
2240 #[test]
2241 fn test_md033_fix_mixed_safe_tags() {
2242 let rule = MD033NoInlineHtml::with_fix(true);
2244 let content = "<em>italic</em> and <img src=\"x.jpg\"> and <strong>bold</strong>";
2245 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2246 let fixed = rule.fix(&ctx).unwrap();
2247 assert_eq!(fixed, "*italic* and  and **bold**");
2249 }
2250
2251 #[test]
2252 fn test_md033_fix_multiple_tags_same_line() {
2253 let rule = MD033NoInlineHtml::with_fix(true);
2255 let content = "Regular text <i>italic</i> and <b>bold</b> here.";
2256 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2257 let fixed = rule.fix(&ctx).unwrap();
2258 assert_eq!(fixed, "Regular text *italic* and **bold** here.");
2259 }
2260
2261 #[test]
2262 fn test_md033_fix_multiple_em_tags_same_line() {
2263 let rule = MD033NoInlineHtml::with_fix(true);
2265 let content = "<em>first</em> and <strong>second</strong> and <code>third</code>";
2266 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2267 let fixed = rule.fix(&ctx).unwrap();
2268 assert_eq!(fixed, "*first* and **second** and `third`");
2269 }
2270
2271 #[test]
2272 fn test_md033_fix_skips_tags_inside_pre() {
2273 let rule = MD033NoInlineHtml::with_fix(true);
2275 let content = "<pre><code><em>VALUE</em></code></pre>";
2276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2277 let fixed = rule.fix(&ctx).unwrap();
2278 assert!(
2281 !fixed.contains("*VALUE*"),
2282 "Tags inside <pre> should not be converted to markdown. Got: {fixed}"
2283 );
2284 }
2285
2286 #[test]
2287 fn test_md033_fix_skips_tags_inside_div() {
2288 let rule = MD033NoInlineHtml::with_fix(true);
2290 let content = "<div>\n<em>emphasized</em>\n</div>";
2291 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2292 let fixed = rule.fix(&ctx).unwrap();
2293 assert!(
2295 !fixed.contains("*emphasized*"),
2296 "Tags inside HTML blocks should not be converted. Got: {fixed}"
2297 );
2298 }
2299
2300 #[test]
2301 fn test_md033_fix_outside_html_block() {
2302 let rule = MD033NoInlineHtml::with_fix(true);
2304 let content = "<div>\ncontent\n</div>\n\nOutside <em>emphasized</em> text.";
2305 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2306 let fixed = rule.fix(&ctx).unwrap();
2307 assert!(
2309 fixed.contains("*emphasized*"),
2310 "Tags outside HTML blocks should be converted. Got: {fixed}"
2311 );
2312 }
2313
2314 #[test]
2315 fn test_md033_fix_with_id_attribute() {
2316 let rule = MD033NoInlineHtml::with_fix(true);
2318 let content = "See <em id=\"important\">this note</em> for details.";
2319 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2320 let fixed = rule.fix(&ctx).unwrap();
2321 assert_eq!(fixed, content);
2323 }
2324
2325 #[test]
2326 fn test_md033_fix_with_style_attribute() {
2327 let rule = MD033NoInlineHtml::with_fix(true);
2329 let content = "This is <strong style=\"color: red\">important</strong> text.";
2330 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2331 let fixed = rule.fix(&ctx).unwrap();
2332 assert_eq!(fixed, content);
2334 }
2335
2336 #[test]
2337 fn test_md033_fix_mixed_with_and_without_attributes() {
2338 let rule = MD033NoInlineHtml::with_fix(true);
2340 let content = "<em>normal</em> and <em class=\"special\">styled</em> text.";
2341 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2342 let fixed = rule.fix(&ctx).unwrap();
2343 assert_eq!(fixed, "*normal* and <em class=\"special\">styled</em> text.");
2345 }
2346
2347 #[test]
2348 fn test_md033_quick_fix_tag_with_attributes_no_fix() {
2349 let rule = MD033NoInlineHtml::with_fix(true);
2351 let content = "<em class=\"test\">emphasized</em>";
2352 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2353 let result = rule.check(&ctx).unwrap();
2354
2355 assert_eq!(result.len(), 1, "Should find one HTML tag");
2356 assert!(
2358 result[0].fix.is_none(),
2359 "Should NOT have a fix for tags with attributes"
2360 );
2361 }
2362
2363 #[test]
2364 fn test_md033_fix_skips_html_entities() {
2365 let rule = MD033NoInlineHtml::with_fix(true);
2368 let content = "<code>|</code>";
2369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2370 let fixed = rule.fix(&ctx).unwrap();
2371 assert_eq!(fixed, content);
2373 }
2374
2375 #[test]
2376 fn test_md033_fix_skips_multiple_html_entities() {
2377 let rule = MD033NoInlineHtml::with_fix(true);
2379 let content = "<code><T></code>";
2380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2381 let fixed = rule.fix(&ctx).unwrap();
2382 assert_eq!(fixed, content);
2384 }
2385
2386 #[test]
2387 fn test_md033_fix_allows_ampersand_without_entity() {
2388 let rule = MD033NoInlineHtml::with_fix(true);
2390 let content = "<code>a & b</code>";
2391 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2392 let fixed = rule.fix(&ctx).unwrap();
2393 assert_eq!(fixed, "`a & b`");
2395 }
2396
2397 #[test]
2398 fn test_md033_fix_em_with_entities_skipped() {
2399 let rule = MD033NoInlineHtml::with_fix(true);
2401 let content = "<em> text</em>";
2402 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2403 let fixed = rule.fix(&ctx).unwrap();
2404 assert_eq!(fixed, content);
2406 }
2407
2408 #[test]
2409 fn test_md033_fix_skips_nested_em_in_code() {
2410 let rule = MD033NoInlineHtml::with_fix(true);
2413 let content = "<code><em>n</em></code>";
2414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2415 let fixed = rule.fix(&ctx).unwrap();
2416 assert!(
2419 !fixed.contains("*n*"),
2420 "Nested <em> should not be converted to markdown. Got: {fixed}"
2421 );
2422 }
2423
2424 #[test]
2425 fn test_md033_fix_skips_nested_in_table() {
2426 let rule = MD033NoInlineHtml::with_fix(true);
2428 let content = "| <code>><em>n</em></code> | description |";
2429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2430 let fixed = rule.fix(&ctx).unwrap();
2431 assert!(
2433 !fixed.contains("*n*"),
2434 "Nested tags in table should not be converted. Got: {fixed}"
2435 );
2436 }
2437
2438 #[test]
2439 fn test_md033_fix_standalone_em_still_converted() {
2440 let rule = MD033NoInlineHtml::with_fix(true);
2442 let content = "This is <em>emphasized</em> text.";
2443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2444 let fixed = rule.fix(&ctx).unwrap();
2445 assert_eq!(fixed, "This is *emphasized* text.");
2446 }
2447
2448 #[test]
2460 fn test_md033_templater_basic_interpolation_not_flagged() {
2461 let rule = MD033NoInlineHtml::default();
2464 let content = "Today is <% tp.date.now() %> which is nice.";
2465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2466 let result = rule.check(&ctx).unwrap();
2467 assert!(
2468 result.is_empty(),
2469 "Templater basic interpolation should not be flagged as HTML. Got: {result:?}"
2470 );
2471 }
2472
2473 #[test]
2474 fn test_md033_templater_file_functions_not_flagged() {
2475 let rule = MD033NoInlineHtml::default();
2477 let content = "File: <% tp.file.title %>\nCreated: <% tp.file.creation_date() %>";
2478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2479 let result = rule.check(&ctx).unwrap();
2480 assert!(
2481 result.is_empty(),
2482 "Templater file functions should not be flagged. Got: {result:?}"
2483 );
2484 }
2485
2486 #[test]
2487 fn test_md033_templater_with_arguments_not_flagged() {
2488 let rule = MD033NoInlineHtml::default();
2490 let content = r#"Date: <% tp.date.now("YYYY-MM-DD") %>"#;
2491 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2492 let result = rule.check(&ctx).unwrap();
2493 assert!(
2494 result.is_empty(),
2495 "Templater with arguments should not be flagged. Got: {result:?}"
2496 );
2497 }
2498
2499 #[test]
2500 fn test_md033_templater_javascript_execution_not_flagged() {
2501 let rule = MD033NoInlineHtml::default();
2503 let content = "<%* const today = tp.date.now(); tR += today; %>";
2504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2505 let result = rule.check(&ctx).unwrap();
2506 assert!(
2507 result.is_empty(),
2508 "Templater JS execution block should not be flagged. Got: {result:?}"
2509 );
2510 }
2511
2512 #[test]
2513 fn test_md033_templater_dynamic_execution_not_flagged() {
2514 let rule = MD033NoInlineHtml::default();
2516 let content = "Dynamic: <%+ tp.date.now() %>";
2517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2518 let result = rule.check(&ctx).unwrap();
2519 assert!(
2520 result.is_empty(),
2521 "Templater dynamic execution should not be flagged. Got: {result:?}"
2522 );
2523 }
2524
2525 #[test]
2526 fn test_md033_templater_whitespace_trim_all_not_flagged() {
2527 let rule = MD033NoInlineHtml::default();
2529 let content = "<%_ tp.date.now() _%>";
2530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2531 let result = rule.check(&ctx).unwrap();
2532 assert!(
2533 result.is_empty(),
2534 "Templater trim-all whitespace should not be flagged. Got: {result:?}"
2535 );
2536 }
2537
2538 #[test]
2539 fn test_md033_templater_whitespace_trim_newline_not_flagged() {
2540 let rule = MD033NoInlineHtml::default();
2542 let content = "<%- tp.date.now() -%>";
2543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2544 let result = rule.check(&ctx).unwrap();
2545 assert!(
2546 result.is_empty(),
2547 "Templater trim-newline should not be flagged. Got: {result:?}"
2548 );
2549 }
2550
2551 #[test]
2552 fn test_md033_templater_combined_modifiers_not_flagged() {
2553 let rule = MD033NoInlineHtml::default();
2555 let contents = [
2556 "<%-* const x = 1; -%>", "<%_+ tp.date.now() _%>", "<%- tp.file.title -%>", "<%_ tp.file.title _%>", ];
2561 for content in contents {
2562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2563 let result = rule.check(&ctx).unwrap();
2564 assert!(
2565 result.is_empty(),
2566 "Templater combined modifiers should not be flagged: {content}. Got: {result:?}"
2567 );
2568 }
2569 }
2570
2571 #[test]
2572 fn test_md033_templater_multiline_block_not_flagged() {
2573 let rule = MD033NoInlineHtml::default();
2575 let content = r#"<%*
2576const x = 1;
2577const y = 2;
2578tR += x + y;
2579%>"#;
2580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2581 let result = rule.check(&ctx).unwrap();
2582 assert!(
2583 result.is_empty(),
2584 "Templater multi-line block should not be flagged. Got: {result:?}"
2585 );
2586 }
2587
2588 #[test]
2589 fn test_md033_templater_with_angle_brackets_in_condition_not_flagged() {
2590 let rule = MD033NoInlineHtml::default();
2593 let content = "<%* if (x < 5) { tR += 'small'; } %>";
2594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2595 let result = rule.check(&ctx).unwrap();
2596 assert!(
2597 result.is_empty(),
2598 "Templater with angle brackets in conditions should not be flagged. Got: {result:?}"
2599 );
2600 }
2601
2602 #[test]
2603 fn test_md033_templater_mixed_with_html_only_html_flagged() {
2604 let rule = MD033NoInlineHtml::default();
2606 let content = "<% tp.date.now() %> is today's date. <div>This is HTML</div>";
2607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2608 let result = rule.check(&ctx).unwrap();
2609 assert_eq!(result.len(), 1, "Should only flag the HTML div tag");
2610 assert!(
2611 result[0].message.contains("<div>"),
2612 "Should flag <div>, got: {}",
2613 result[0].message
2614 );
2615 }
2616
2617 #[test]
2618 fn test_md033_templater_in_heading_not_flagged() {
2619 let rule = MD033NoInlineHtml::default();
2621 let content = "# <% tp.file.title %>";
2622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2623 let result = rule.check(&ctx).unwrap();
2624 assert!(
2625 result.is_empty(),
2626 "Templater in heading should not be flagged. Got: {result:?}"
2627 );
2628 }
2629
2630 #[test]
2631 fn test_md033_templater_multiple_on_same_line_not_flagged() {
2632 let rule = MD033NoInlineHtml::default();
2634 let content = "From <% tp.date.now() %> to <% tp.date.tomorrow() %> we have meetings.";
2635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2636 let result = rule.check(&ctx).unwrap();
2637 assert!(
2638 result.is_empty(),
2639 "Multiple Templater blocks should not be flagged. Got: {result:?}"
2640 );
2641 }
2642
2643 #[test]
2644 fn test_md033_templater_in_code_block_not_flagged() {
2645 let rule = MD033NoInlineHtml::default();
2647 let content = "```\n<% tp.date.now() %>\n```";
2648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2649 let result = rule.check(&ctx).unwrap();
2650 assert!(
2651 result.is_empty(),
2652 "Templater in code block should not be flagged. Got: {result:?}"
2653 );
2654 }
2655
2656 #[test]
2657 fn test_md033_templater_in_inline_code_not_flagged() {
2658 let rule = MD033NoInlineHtml::default();
2660 let content = "Use `<% tp.date.now() %>` for current date.";
2661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2662 let result = rule.check(&ctx).unwrap();
2663 assert!(
2664 result.is_empty(),
2665 "Templater in inline code should not be flagged. Got: {result:?}"
2666 );
2667 }
2668
2669 #[test]
2670 fn test_md033_templater_also_works_in_standard_flavor() {
2671 let rule = MD033NoInlineHtml::default();
2674 let content = "<% tp.date.now() %> works everywhere.";
2675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2676 let result = rule.check(&ctx).unwrap();
2677 assert!(
2678 result.is_empty(),
2679 "Templater should not be flagged even in Standard flavor. Got: {result:?}"
2680 );
2681 }
2682
2683 #[test]
2684 fn test_md033_templater_empty_tag_not_flagged() {
2685 let rule = MD033NoInlineHtml::default();
2687 let content = "<%>";
2688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2689 let result = rule.check(&ctx).unwrap();
2690 assert!(
2691 result.is_empty(),
2692 "Empty Templater-like tag should not be flagged. Got: {result:?}"
2693 );
2694 }
2695
2696 #[test]
2697 fn test_md033_templater_unclosed_not_flagged() {
2698 let rule = MD033NoInlineHtml::default();
2700 let content = "<% tp.date.now() without closing tag";
2701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2702 let result = rule.check(&ctx).unwrap();
2703 assert!(
2704 result.is_empty(),
2705 "Unclosed Templater should not be flagged as HTML. Got: {result:?}"
2706 );
2707 }
2708
2709 #[test]
2710 fn test_md033_templater_with_newlines_inside_not_flagged() {
2711 let rule = MD033NoInlineHtml::default();
2713 let content = r#"<% tp.date.now("YYYY") +
2714"-" +
2715tp.date.now("MM") %>"#;
2716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2717 let result = rule.check(&ctx).unwrap();
2718 assert!(
2719 result.is_empty(),
2720 "Templater with internal newlines should not be flagged. Got: {result:?}"
2721 );
2722 }
2723
2724 #[test]
2725 fn test_md033_erb_style_tags_not_flagged() {
2726 let rule = MD033NoInlineHtml::default();
2729 let content = "<%= variable %> and <% code %> and <%# comment %>";
2730 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2731 let result = rule.check(&ctx).unwrap();
2732 assert!(
2733 result.is_empty(),
2734 "ERB/EJS style tags should not be flagged as HTML. Got: {result:?}"
2735 );
2736 }
2737
2738 #[test]
2739 fn test_md033_templater_complex_expression_not_flagged() {
2740 let rule = MD033NoInlineHtml::default();
2742 let content = r#"<%*
2743const file = tp.file.title;
2744const date = tp.date.now("YYYY-MM-DD");
2745const folder = tp.file.folder();
2746tR += `# ${file}\n\nCreated: ${date}\nIn: ${folder}`;
2747%>"#;
2748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2749 let result = rule.check(&ctx).unwrap();
2750 assert!(
2751 result.is_empty(),
2752 "Complex Templater expression should not be flagged. Got: {result:?}"
2753 );
2754 }
2755
2756 #[test]
2757 fn test_md033_percent_sign_variations_not_flagged() {
2758 let rule = MD033NoInlineHtml::default();
2760 let patterns = [
2761 "<%=", "<%#", "<%%", "<%!", "<%@", "<%--", ];
2768 for pattern in patterns {
2769 let content = format!("{pattern} content %>");
2770 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2771 let result = rule.check(&ctx).unwrap();
2772 assert!(
2773 result.is_empty(),
2774 "Pattern {pattern} should not be flagged. Got: {result:?}"
2775 );
2776 }
2777 }
2778
2779 #[test]
2785 fn test_md033_fix_a_wrapping_markdown_image_no_escaped_brackets() {
2786 let rule = MD033NoInlineHtml::with_fix(true);
2789 let content = r#"<a href="https://example.com"></a>"#;
2790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2791 let fixed = rule.fix(&ctx).unwrap();
2792
2793 assert_eq!(fixed, "[](https://example.com)",);
2794 assert!(!fixed.contains(r"\["), "Must not escape brackets: {fixed}");
2795 assert!(!fixed.contains(r"\]"), "Must not escape brackets: {fixed}");
2796 }
2797
2798 #[test]
2799 fn test_md033_fix_a_wrapping_markdown_image_with_alt() {
2800 let rule = MD033NoInlineHtml::with_fix(true);
2802 let content =
2803 r#"<a href="https://github.com/repo"></a>"#;
2804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2805 let fixed = rule.fix(&ctx).unwrap();
2806
2807 assert_eq!(
2808 fixed,
2809 "[](https://github.com/repo)"
2810 );
2811 }
2812
2813 #[test]
2814 fn test_md033_fix_img_without_alt_produces_empty_alt() {
2815 let rule = MD033NoInlineHtml::with_fix(true);
2816 let content = r#"<img src="photo.jpg" />"#;
2817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2818 let fixed = rule.fix(&ctx).unwrap();
2819
2820 assert_eq!(fixed, "");
2821 }
2822
2823 #[test]
2824 fn test_md033_fix_a_with_plain_text_still_escapes_brackets() {
2825 let rule = MD033NoInlineHtml::with_fix(true);
2827 let content = r#"<a href="https://example.com">text with [brackets]</a>"#;
2828 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2829 let fixed = rule.fix(&ctx).unwrap();
2830
2831 assert!(
2832 fixed.contains(r"\[brackets\]"),
2833 "Plain text brackets should be escaped: {fixed}"
2834 );
2835 }
2836
2837 #[test]
2838 fn test_md033_fix_a_with_image_plus_extra_text_escapes_brackets() {
2839 let rule = MD033NoInlineHtml::with_fix(true);
2842 let content = r#"<a href="/link"> see [docs]</a>"#;
2843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2844 let fixed = rule.fix(&ctx).unwrap();
2845
2846 assert!(
2848 fixed.contains(r"\[docs\]"),
2849 "Brackets in mixed image+text content should be escaped: {fixed}"
2850 );
2851 }
2852
2853 #[test]
2854 fn test_md033_fix_img_in_a_end_to_end() {
2855 use crate::config::Config;
2858 use crate::fix_coordinator::FixCoordinator;
2859
2860 let rule = MD033NoInlineHtml::with_fix(true);
2861 let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
2862
2863 let mut content =
2864 r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image?repo=org/repo" /></a>"#
2865 .to_string();
2866 let config = Config::default();
2867 let coordinator = FixCoordinator::new();
2868
2869 let result = coordinator
2870 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
2871 .unwrap();
2872
2873 assert_eq!(
2874 content, "[](https://github.com/org/repo)",
2875 "End-to-end: <a><img></a> should become valid linked image"
2876 );
2877 assert!(result.converged);
2878 assert!(!content.contains(r"\["), "No escaped brackets: {content}");
2879 }
2880
2881 #[test]
2882 fn test_md033_fix_img_in_a_with_alt_end_to_end() {
2883 use crate::config::Config;
2884 use crate::fix_coordinator::FixCoordinator;
2885
2886 let rule = MD033NoInlineHtml::with_fix(true);
2887 let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
2888
2889 let mut content =
2890 r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image" alt="Contributors" /></a>"#
2891 .to_string();
2892 let config = Config::default();
2893 let coordinator = FixCoordinator::new();
2894
2895 let result = coordinator
2896 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
2897 .unwrap();
2898
2899 assert_eq!(
2900 content,
2901 "[](https://github.com/org/repo)",
2902 );
2903 assert!(result.converged);
2904 }
2905
2906 #[test]
2916 fn test_md033_table_allowed_unset_falls_back_to_allowed() {
2917 let config = MD033Config {
2918 allowed: vec!["br".to_string()],
2919 table_allowed_elements: None,
2920 ..MD033Config::default()
2921 };
2922 let rule = MD033NoInlineHtml::from_config_struct(config);
2923 let content = "| col |\n|-----|\n| a<br>b |\n";
2924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2925 let result = rule.check(&ctx).unwrap();
2926 assert!(
2927 result.is_empty(),
2928 "<br> in table cell should be allowed via fallback to `allowed`, got {result:?}"
2929 );
2930 }
2931
2932 #[test]
2933 fn test_md033_table_allowed_explicit_empty_rejects_in_tables() {
2934 let config = MD033Config {
2935 allowed: vec!["br".to_string()],
2936 table_allowed_elements: Some(Vec::new()),
2937 ..MD033Config::default()
2938 };
2939 let rule = MD033NoInlineHtml::from_config_struct(config);
2940 let content = "| col |\n|-----|\n| a<br>b |\n";
2941 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2942 let result = rule.check(&ctx).unwrap();
2943 assert_eq!(
2944 result.len(),
2945 1,
2946 "Explicit empty table_allowed should reject <br> in tables even if it's in `allowed`, got {result:?}"
2947 );
2948 assert_eq!(result[0].line, 3);
2949 }
2950
2951 #[test]
2952 fn test_md033_table_allowed_explicit_list_overrides_in_tables() {
2953 let config = MD033Config {
2954 allowed: vec!["br".to_string()],
2955 table_allowed_elements: Some(vec!["img".to_string()]),
2956 ..MD033Config::default()
2957 };
2958 let rule = MD033NoInlineHtml::from_config_struct(config);
2959 let content = "| col |\n|-----|\n| <br><img src=\"x\"/> |\n";
2962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2963 let result = rule.check(&ctx).unwrap();
2964 assert_eq!(
2965 result.len(),
2966 1,
2967 "table_allowed should override `allowed` inside tables, got {result:?}"
2968 );
2969 assert!(
2970 result[0].message.contains("br"),
2971 "expected the flagged tag to be <br>, got {:?}",
2972 result[0].message
2973 );
2974 }
2975
2976 #[test]
2977 fn test_md033_table_allowed_does_not_affect_out_of_table_tags() {
2978 let config = MD033Config {
2979 allowed: vec!["br".to_string()],
2980 table_allowed_elements: Some(Vec::new()),
2981 ..MD033Config::default()
2982 };
2983 let rule = MD033NoInlineHtml::from_config_struct(config);
2984 let content = "Paragraph with <br> tag.\n";
2986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2987 let result = rule.check(&ctx).unwrap();
2988 assert!(
2989 result.is_empty(),
2990 "<br> outside tables must still be allowed by `allowed`, got {result:?}"
2991 );
2992 }
2993
2994 #[test]
2995 fn test_md033_table_allowed_kebab_case_parses() {
2996 let toml_str = r#"
2997 allowed-elements = ["br"]
2998 table-allowed-elements = ["img"]
2999 "#;
3000 let config: MD033Config = toml::from_str(toml_str).unwrap();
3001 assert_eq!(config.allowed, vec!["br"]);
3002 assert_eq!(
3003 config.table_allowed_elements.as_deref(),
3004 Some(["img".to_string()].as_slice())
3005 );
3006 }
3007
3008 #[test]
3009 fn test_md033_table_allowed_snake_case_alias_parses() {
3010 let toml_str = r#"
3011 allowed_elements = ["br"]
3012 table_allowed_elements = ["img"]
3013 "#;
3014 let config: MD033Config = toml::from_str(toml_str).unwrap();
3015 assert_eq!(config.allowed, vec!["br"]);
3016 assert_eq!(
3017 config.table_allowed_elements.as_deref(),
3018 Some(["img".to_string()].as_slice())
3019 );
3020 }
3021
3022 #[test]
3023 fn test_md033_table_allowed_default_is_none() {
3024 let cfg = MD033Config::default();
3025 assert!(
3026 cfg.table_allowed_elements.is_none(),
3027 "Default for table_allowed_elements should be None (so it falls back to `allowed`)"
3028 );
3029 }
3030
3031 #[test]
3032 fn test_md033_table_allowed_case_insensitive() {
3033 let config = MD033Config {
3034 allowed: Vec::new(),
3035 table_allowed_elements: Some(vec!["BR".to_string()]),
3036 ..MD033Config::default()
3037 };
3038 let rule = MD033NoInlineHtml::from_config_struct(config);
3039 let content = "| col |\n|-----|\n| a<br>b |\n";
3040 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3041 let result = rule.check(&ctx).unwrap();
3042 assert!(
3043 result.is_empty(),
3044 "table_allowed should be case-insensitive, got {result:?}"
3045 );
3046 }
3047}