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 Some(aliases)
1198 }
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203 use super::*;
1204 use crate::lint_context::LintContext;
1205 use crate::rule::Rule;
1206
1207 fn relaxed_fix_rule() -> MD033NoInlineHtml {
1208 let config = MD033Config {
1209 fix: true,
1210 fix_mode: MD033FixMode::Relaxed,
1211 ..MD033Config::default()
1212 };
1213 MD033NoInlineHtml::from_config_struct(config)
1214 }
1215
1216 #[test]
1217 fn test_md033_basic_html() {
1218 let rule = MD033NoInlineHtml::default();
1219 let content = "<div>Some content</div>";
1220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221 let result = rule.check(&ctx).unwrap();
1222 assert_eq!(result.len(), 1); assert!(result[0].message.starts_with("Inline HTML found: <div>"));
1225 }
1226
1227 #[test]
1228 fn test_md033_front_matter() {
1229 let rule = MD033NoInlineHtml::default();
1230 let content = "---\ndescription: <div class=\"test\">hello</div>\n---\n# Title\n<div>body</div>";
1231 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1232 let result = rule.check(&ctx).unwrap();
1233 assert_eq!(result.len(), 1);
1235 assert_eq!(result[0].line, 5);
1236 assert_eq!(result[0].message, "Inline HTML found: <div>");
1237 }
1238
1239 #[test]
1240 fn test_md033_math_block() {
1241 let rule = MD033NoInlineHtml::default();
1242 let content = "$$\nx < y && y > z\n$$\n<div>body</div>";
1243 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1244 let result = rule.check(&ctx).unwrap();
1245 assert_eq!(result.len(), 1);
1247 assert_eq!(result[0].line, 4);
1248 }
1249
1250 #[test]
1251 fn test_md033_case_insensitive() {
1252 let rule = MD033NoInlineHtml::default();
1253 let content = "<DiV>Some <B>content</B></dIv>";
1254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255 let result = rule.check(&ctx).unwrap();
1256 assert_eq!(result.len(), 2); assert_eq!(result[0].message, "Inline HTML found: <DiV>");
1259 assert_eq!(result[1].message, "Inline HTML found: <B>");
1260 }
1261
1262 #[test]
1263 fn test_md033_multibyte_whitespace_in_tag_does_not_panic() {
1264 let rule = relaxed_fix_rule();
1267 let content = "<img\u{00A0}src=\"test.png\" alt=\"x\">";
1268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1269 let _ = rule.check(&ctx).unwrap();
1271 let _ = rule.fix(&ctx).unwrap();
1272 }
1273
1274 #[test]
1275 fn test_md033_allowed_tags() {
1276 let rule = MD033NoInlineHtml::with_allowed(vec!["div".to_string(), "br".to_string()]);
1277 let content = "<div>Allowed</div><p>Not allowed</p><br/>";
1278 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1279 let result = rule.check(&ctx).unwrap();
1280 assert_eq!(result.len(), 1);
1282 assert_eq!(result[0].message, "Inline HTML found: <p>");
1283
1284 let content2 = "<DIV>Allowed</DIV><P>Not allowed</P><BR/>";
1286 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1287 let result2 = rule.check(&ctx2).unwrap();
1288 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <P>");
1290 }
1291
1292 #[test]
1293 fn test_md033_html_comments() {
1294 let rule = MD033NoInlineHtml::default();
1295 let content = "<!-- This is a comment --> <p>Not a comment</p>";
1296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1297 let result = rule.check(&ctx).unwrap();
1298 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <p>");
1301 }
1302
1303 #[test]
1304 fn test_md033_tags_in_links() {
1305 let rule = MD033NoInlineHtml::default();
1306 let content = "[Link](http://example.com/<div>)";
1307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1308 let result = rule.check(&ctx).unwrap();
1309 assert_eq!(result.len(), 1);
1311 assert_eq!(result[0].message, "Inline HTML found: <div>");
1312
1313 let content2 = "[Link <a>text</a>](url)";
1314 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1315 let result2 = rule.check(&ctx2).unwrap();
1316 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <a>");
1319 }
1320
1321 #[test]
1322 fn test_md033_fix_escaping() {
1323 let rule = MD033NoInlineHtml::default();
1324 let content = "Text with <div> and <br/> tags.";
1325 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1326 let fixed_content = rule.fix(&ctx).unwrap();
1327 assert_eq!(fixed_content, content);
1329 }
1330
1331 #[test]
1332 fn test_md033_in_code_blocks() {
1333 let rule = MD033NoInlineHtml::default();
1334 let content = "```html\n<div>Code</div>\n```\n<div>Not code</div>";
1335 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1336 let result = rule.check(&ctx).unwrap();
1337 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <div>");
1340 }
1341
1342 #[test]
1343 fn test_md033_in_code_spans() {
1344 let rule = MD033NoInlineHtml::default();
1345 let content = "Text with `<p>in code</p>` span. <br/> Not in span.";
1346 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1347 let result = rule.check(&ctx).unwrap();
1348 assert_eq!(result.len(), 1);
1350 assert_eq!(result[0].message, "Inline HTML found: <br/>");
1351 }
1352
1353 #[test]
1354 fn test_md033_issue_90_code_span_with_diff_block() {
1355 let rule = MD033NoInlineHtml::default();
1357 let content = r#"# Heading
1358
1359`<env>`
1360
1361```diff
1362- this
1363+ that
1364```"#;
1365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1366 let result = rule.check(&ctx).unwrap();
1367 assert_eq!(result.len(), 0, "Should not report HTML tags inside code spans");
1369 }
1370
1371 #[test]
1372 fn test_md033_multiple_code_spans_with_angle_brackets() {
1373 let rule = MD033NoInlineHtml::default();
1375 let content = "`<one>` and `<two>` and `<three>` are all code spans";
1376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1377 let result = rule.check(&ctx).unwrap();
1378 assert_eq!(result.len(), 0, "Should not report HTML tags inside any code spans");
1379 }
1380
1381 #[test]
1382 fn test_md033_nested_angle_brackets_in_code_span() {
1383 let rule = MD033NoInlineHtml::default();
1385 let content = "Text with `<<nested>>` brackets";
1386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1387 let result = rule.check(&ctx).unwrap();
1388 assert_eq!(result.len(), 0, "Should handle nested angle brackets in code spans");
1389 }
1390
1391 #[test]
1392 fn test_md033_code_span_at_end_before_code_block() {
1393 let rule = MD033NoInlineHtml::default();
1395 let content = "Testing `<test>`\n```\ncode here\n```";
1396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1397 let result = rule.check(&ctx).unwrap();
1398 assert_eq!(result.len(), 0, "Should handle code span before code block");
1399 }
1400
1401 #[test]
1402 fn test_md033_quick_fix_inline_tag() {
1403 let rule = MD033NoInlineHtml::default();
1406 let content = "This has <span>inline text</span> that should keep content.";
1407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408 let result = rule.check(&ctx).unwrap();
1409
1410 assert_eq!(result.len(), 1, "Should find one HTML tag");
1411 assert!(
1413 result[0].fix.is_none(),
1414 "Non-fixable tags like <span> should not have a fix"
1415 );
1416 }
1417
1418 #[test]
1419 fn test_md033_quick_fix_multiline_tag() {
1420 let rule = MD033NoInlineHtml::default();
1423 let content = "<div>\nBlock content\n</div>";
1424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1425 let result = rule.check(&ctx).unwrap();
1426
1427 assert_eq!(result.len(), 1, "Should find one HTML tag");
1428 assert!(result[0].fix.is_none(), "HTML block elements should NOT have auto-fix");
1430 }
1431
1432 #[test]
1433 fn test_md033_quick_fix_self_closing_tag() {
1434 let rule = MD033NoInlineHtml::default();
1436 let content = "Self-closing: <br/>";
1437 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1438 let result = rule.check(&ctx).unwrap();
1439
1440 assert_eq!(result.len(), 1, "Should find one HTML tag");
1441 assert!(
1443 result[0].fix.is_none(),
1444 "Self-closing tags should not have a fix when fix config is false"
1445 );
1446 }
1447
1448 #[test]
1449 fn test_md033_quick_fix_multiple_tags() {
1450 let rule = MD033NoInlineHtml::default();
1453 let content = "<span>first</span> and <strong>second</strong>";
1454 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1455 let result = rule.check(&ctx).unwrap();
1456
1457 assert_eq!(result.len(), 2, "Should find two HTML tags");
1458 assert!(result[0].fix.is_none(), "Non-fixable <span> should not have a fix");
1460 assert!(
1461 result[1].fix.is_none(),
1462 "<strong> should not have a fix when fix config is false"
1463 );
1464 }
1465
1466 #[test]
1467 fn test_md033_skip_angle_brackets_in_link_titles() {
1468 let rule = MD033NoInlineHtml::default();
1470 let content = r#"# Test
1471
1472[example]: <https://example.com> "Title with <Angle Brackets> inside"
1473
1474Regular text with <div>content</div> HTML tag.
1475"#;
1476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1477 let result = rule.check(&ctx).unwrap();
1478
1479 assert_eq!(result.len(), 1, "Should find opening div tag");
1482 assert!(
1483 result[0].message.contains("<div>"),
1484 "Should flag <div>, got: {}",
1485 result[0].message
1486 );
1487 }
1488
1489 #[test]
1490 fn test_md033_skip_angle_brackets_in_link_title_single_quotes() {
1491 let rule = MD033NoInlineHtml::default();
1493 let content = r#"[ref]: url 'Title <Help Wanted> here'
1494
1495<span>text</span> here
1496"#;
1497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1498 let result = rule.check(&ctx).unwrap();
1499
1500 assert_eq!(result.len(), 1, "Should find opening span tag");
1503 assert!(
1504 result[0].message.contains("<span>"),
1505 "Should flag <span>, got: {}",
1506 result[0].message
1507 );
1508 }
1509
1510 #[test]
1511 fn test_md033_multiline_tag_end_line_calculation() {
1512 let rule = MD033NoInlineHtml::default();
1514 let content = "<div\n class=\"test\"\n id=\"example\">";
1515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1516 let result = rule.check(&ctx).unwrap();
1517
1518 assert_eq!(result.len(), 1, "Should find one HTML tag");
1519 assert_eq!(result[0].line, 1, "Start line should be 1");
1521 assert_eq!(result[0].end_line, 3, "End line should be 3");
1523 }
1524
1525 #[test]
1526 fn test_md033_single_line_tag_same_start_end_line() {
1527 let rule = MD033NoInlineHtml::default();
1529 let content = "Some text <div class=\"test\"> more text";
1530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1531 let result = rule.check(&ctx).unwrap();
1532
1533 assert_eq!(result.len(), 1, "Should find one HTML tag");
1534 assert_eq!(result[0].line, 1, "Start line should be 1");
1535 assert_eq!(result[0].end_line, 1, "End line should be 1 for single-line tag");
1536 }
1537
1538 #[test]
1539 fn test_md033_multiline_tag_with_many_attributes() {
1540 let rule = MD033NoInlineHtml::default();
1542 let content =
1543 "Text\n<div\n data-attr1=\"value1\"\n data-attr2=\"value2\"\n data-attr3=\"value3\">\nMore text";
1544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1545 let result = rule.check(&ctx).unwrap();
1546
1547 assert_eq!(result.len(), 1, "Should find one HTML tag");
1548 assert_eq!(result[0].line, 2, "Start line should be 2");
1550 assert_eq!(result[0].end_line, 5, "End line should be 5");
1552 }
1553
1554 #[test]
1555 fn test_md033_disallowed_mode_basic() {
1556 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string(), "iframe".to_string()]);
1558 let content = "<div>Safe content</div><script>alert('xss')</script>";
1559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1560 let result = rule.check(&ctx).unwrap();
1561
1562 assert_eq!(result.len(), 1, "Should only flag disallowed tags");
1564 assert!(result[0].message.contains("<script>"), "Should flag script tag");
1565 }
1566
1567 #[test]
1568 fn test_md033_disallowed_gfm_security_tags() {
1569 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1571 let content = r#"
1572<div>Safe</div>
1573<title>Bad title</title>
1574<textarea>Bad textarea</textarea>
1575<style>.bad{}</style>
1576<iframe src="evil"></iframe>
1577<script>evil()</script>
1578<plaintext>old tag</plaintext>
1579<span>Safe span</span>
1580"#;
1581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1582 let result = rule.check(&ctx).unwrap();
1583
1584 assert_eq!(result.len(), 6, "Should flag 6 GFM security tags");
1587
1588 let flagged_tags: Vec<&str> = result
1589 .iter()
1590 .filter_map(|w| w.message.split('<').nth(1))
1591 .filter_map(|s| s.split('>').next())
1592 .filter_map(|s| s.split_whitespace().next())
1593 .collect();
1594
1595 assert!(flagged_tags.contains(&"title"), "Should flag title");
1596 assert!(flagged_tags.contains(&"textarea"), "Should flag textarea");
1597 assert!(flagged_tags.contains(&"style"), "Should flag style");
1598 assert!(flagged_tags.contains(&"iframe"), "Should flag iframe");
1599 assert!(flagged_tags.contains(&"script"), "Should flag script");
1600 assert!(flagged_tags.contains(&"plaintext"), "Should flag plaintext");
1601 assert!(!flagged_tags.contains(&"div"), "Should NOT flag div");
1602 assert!(!flagged_tags.contains(&"span"), "Should NOT flag span");
1603 }
1604
1605 #[test]
1606 fn test_md033_disallowed_case_insensitive() {
1607 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string()]);
1609 let content = "<SCRIPT>alert('xss')</SCRIPT><Script>alert('xss')</Script>";
1610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1611 let result = rule.check(&ctx).unwrap();
1612
1613 assert_eq!(result.len(), 2, "Should flag both case variants");
1615 }
1616
1617 #[test]
1618 fn test_md033_disallowed_with_attributes() {
1619 let rule = MD033NoInlineHtml::with_disallowed(vec!["iframe".to_string()]);
1621 let content = r#"<iframe src="https://evil.com" width="100" height="100"></iframe>"#;
1622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1623 let result = rule.check(&ctx).unwrap();
1624
1625 assert_eq!(result.len(), 1, "Should flag iframe with attributes");
1626 assert!(result[0].message.contains("iframe"), "Should flag iframe");
1627 }
1628
1629 #[test]
1630 fn test_md033_disallowed_all_gfm_tags() {
1631 use md033_config::GFM_DISALLOWED_TAGS;
1633 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1634
1635 for tag in GFM_DISALLOWED_TAGS {
1636 let content = format!("<{tag}>content</{tag}>");
1637 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1638 let result = rule.check(&ctx).unwrap();
1639
1640 assert_eq!(result.len(), 1, "GFM tag <{tag}> should be flagged");
1641 }
1642 }
1643
1644 #[test]
1645 fn test_md033_disallowed_mixed_with_custom() {
1646 let rule = MD033NoInlineHtml::with_disallowed(vec![
1648 "gfm".to_string(),
1649 "marquee".to_string(), ]);
1651 let content = r#"<script>bad</script><marquee>annoying</marquee><div>ok</div>"#;
1652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1653 let result = rule.check(&ctx).unwrap();
1654
1655 assert_eq!(result.len(), 2, "Should flag both gfm and custom tags");
1657 }
1658
1659 #[test]
1660 fn test_md033_disallowed_empty_means_default_mode() {
1661 let rule = MD033NoInlineHtml::with_disallowed(vec![]);
1663 let content = "<div>content</div>";
1664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1665 let result = rule.check(&ctx).unwrap();
1666
1667 assert_eq!(result.len(), 1, "Empty disallowed = default mode");
1669 }
1670
1671 #[test]
1672 fn test_md033_jsx_fragments_in_mdx() {
1673 let rule = MD033NoInlineHtml::default();
1675 let content = r#"# MDX Document
1676
1677<>
1678 <Heading />
1679 <Content />
1680</>
1681
1682<div>Regular HTML should still be flagged</div>
1683"#;
1684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1685 let result = rule.check(&ctx).unwrap();
1686
1687 assert_eq!(result.len(), 1, "Should only find one HTML tag (the div)");
1689 assert!(
1690 result[0].message.contains("<div>"),
1691 "Should flag <div>, not JSX fragments"
1692 );
1693 }
1694
1695 #[test]
1696 fn test_md033_jsx_components_in_mdx() {
1697 let rule = MD033NoInlineHtml::default();
1699 let content = r#"<CustomComponent prop="value">
1700 Content
1701</CustomComponent>
1702
1703<MyButton onClick={handler}>Click</MyButton>
1704"#;
1705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1706 let result = rule.check(&ctx).unwrap();
1707
1708 assert_eq!(result.len(), 0, "Should not flag JSX components in MDX");
1710 }
1711
1712 #[test]
1713 fn test_md033_jsx_not_skipped_in_standard_markdown() {
1714 let rule = MD033NoInlineHtml::default();
1716 let content = "<Script>alert(1)</Script>";
1717 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1718 let result = rule.check(&ctx).unwrap();
1719
1720 assert_eq!(result.len(), 1, "Should flag <Script> in standard markdown");
1722 }
1723
1724 #[test]
1725 fn test_md033_jsx_attributes_in_mdx() {
1726 let rule = MD033NoInlineHtml::default();
1728 let content = r#"# MDX with JSX Attributes
1729
1730<div className="card big">Content</div>
1731
1732<button onClick={handleClick}>Click me</button>
1733
1734<label htmlFor="input-id">Label</label>
1735
1736<input onChange={handleChange} />
1737
1738<div class="html-class">Regular HTML should be flagged</div>
1739"#;
1740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1741 let result = rule.check(&ctx).unwrap();
1742
1743 assert_eq!(
1745 result.len(),
1746 1,
1747 "Should only flag HTML element without JSX attributes, got: {result:?}"
1748 );
1749 assert!(
1750 result[0].message.contains("<div class="),
1751 "Should flag the div with HTML class attribute"
1752 );
1753 }
1754
1755 #[test]
1756 fn test_md033_jsx_attributes_not_skipped_in_standard() {
1757 let rule = MD033NoInlineHtml::default();
1759 let content = r#"<div className="card">Content</div>"#;
1760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1761 let result = rule.check(&ctx).unwrap();
1762
1763 assert_eq!(result.len(), 1, "Should flag JSX-style elements in standard markdown");
1765 }
1766
1767 #[test]
1770 fn test_md033_fix_disabled_by_default() {
1771 let rule = MD033NoInlineHtml::default();
1773 assert!(!rule.config.fix, "Fix should be disabled by default");
1774 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::Unfixable);
1775 }
1776
1777 #[test]
1778 fn test_md033_fix_enabled_em_to_italic() {
1779 let rule = MD033NoInlineHtml::with_fix(true);
1781 let content = "This has <em>emphasized text</em> here.";
1782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1783 let fixed = rule.fix(&ctx).unwrap();
1784 assert_eq!(fixed, "This has *emphasized text* here.");
1785 }
1786
1787 #[test]
1788 fn test_md033_fix_enabled_i_to_italic() {
1789 let rule = MD033NoInlineHtml::with_fix(true);
1791 let content = "This has <i>italic text</i> here.";
1792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1793 let fixed = rule.fix(&ctx).unwrap();
1794 assert_eq!(fixed, "This has *italic text* here.");
1795 }
1796
1797 #[test]
1798 fn test_md033_fix_enabled_strong_to_bold() {
1799 let rule = MD033NoInlineHtml::with_fix(true);
1801 let content = "This has <strong>bold text</strong> here.";
1802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1803 let fixed = rule.fix(&ctx).unwrap();
1804 assert_eq!(fixed, "This has **bold text** here.");
1805 }
1806
1807 #[test]
1808 fn test_md033_fix_enabled_b_to_bold() {
1809 let rule = MD033NoInlineHtml::with_fix(true);
1811 let content = "This has <b>bold text</b> here.";
1812 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1813 let fixed = rule.fix(&ctx).unwrap();
1814 assert_eq!(fixed, "This has **bold text** here.");
1815 }
1816
1817 #[test]
1818 fn test_md033_fix_enabled_code_to_backticks() {
1819 let rule = MD033NoInlineHtml::with_fix(true);
1821 let content = "This has <code>inline code</code> here.";
1822 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1823 let fixed = rule.fix(&ctx).unwrap();
1824 assert_eq!(fixed, "This has `inline code` here.");
1825 }
1826
1827 #[test]
1828 fn test_md033_fix_enabled_code_with_backticks() {
1829 let rule = MD033NoInlineHtml::with_fix(true);
1831 let content = "This has <code>text with `backticks`</code> here.";
1832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1833 let fixed = rule.fix(&ctx).unwrap();
1834 assert_eq!(fixed, "This has `` text with `backticks` `` here.");
1835 }
1836
1837 #[test]
1838 fn test_md033_fix_enabled_br_trailing_spaces() {
1839 let rule = MD033NoInlineHtml::with_fix(true);
1841 let content = "First line<br>Second line";
1842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1843 let fixed = rule.fix(&ctx).unwrap();
1844 assert_eq!(fixed, "First line \nSecond line");
1845 }
1846
1847 #[test]
1848 fn test_md033_fix_enabled_br_self_closing() {
1849 let rule = MD033NoInlineHtml::with_fix(true);
1851 let content = "First<br/>second<br />third";
1852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1853 let fixed = rule.fix(&ctx).unwrap();
1854 assert_eq!(fixed, "First \nsecond \nthird");
1855 }
1856
1857 #[test]
1858 fn test_md033_fix_enabled_br_backslash_style() {
1859 let config = MD033Config {
1861 allowed: Vec::new(),
1862 disallowed: Vec::new(),
1863 fix: true,
1864 br_style: md033_config::BrStyle::Backslash,
1865 ..MD033Config::default()
1866 };
1867 let rule = MD033NoInlineHtml::from_config_struct(config);
1868 let content = "First line<br>Second line";
1869 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1870 let fixed = rule.fix(&ctx).unwrap();
1871 assert_eq!(fixed, "First line\\\nSecond line");
1872 }
1873
1874 #[test]
1875 fn test_md033_fix_enabled_hr() {
1876 let rule = MD033NoInlineHtml::with_fix(true);
1878 let content = "Above<hr>Below";
1879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1880 let fixed = rule.fix(&ctx).unwrap();
1881 assert_eq!(fixed, "Above\n---\nBelow");
1882 }
1883
1884 #[test]
1885 fn test_md033_fix_enabled_hr_self_closing() {
1886 let rule = MD033NoInlineHtml::with_fix(true);
1888 let content = "Above<hr/>Below";
1889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1890 let fixed = rule.fix(&ctx).unwrap();
1891 assert_eq!(fixed, "Above\n---\nBelow");
1892 }
1893
1894 #[test]
1895 fn test_md033_fix_skips_nested_tags() {
1896 let rule = MD033NoInlineHtml::with_fix(true);
1899 let content = "This has <em>text with <strong>nested</strong> tags</em> here.";
1900 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1901 let fixed = rule.fix(&ctx).unwrap();
1902 assert_eq!(fixed, "This has <em>text with **nested** tags</em> here.");
1905 }
1906
1907 #[test]
1908 fn test_md033_fix_skips_tags_with_attributes() {
1909 let rule = MD033NoInlineHtml::with_fix(true);
1912 let content = "This has <em class=\"highlight\">emphasized</em> text.";
1913 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1914 let fixed = rule.fix(&ctx).unwrap();
1915 assert_eq!(fixed, content);
1917 }
1918
1919 #[test]
1920 fn test_md033_fix_disabled_no_changes() {
1921 let rule = MD033NoInlineHtml::default(); let content = "This has <em>emphasized text</em> here.";
1924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1925 let fixed = rule.fix(&ctx).unwrap();
1926 assert_eq!(fixed, content, "Should return original content when fix is disabled");
1927 }
1928
1929 #[test]
1930 fn test_md033_fix_capability_enabled() {
1931 let rule = MD033NoInlineHtml::with_fix(true);
1932 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::FullyFixable);
1933 }
1934
1935 #[test]
1936 fn test_md033_fix_multiple_tags() {
1937 let rule = MD033NoInlineHtml::with_fix(true);
1939 let content = "Here is <em>italic</em> and <strong>bold</strong> text.";
1940 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1941 let fixed = rule.fix(&ctx).unwrap();
1942 assert_eq!(fixed, "Here is *italic* and **bold** text.");
1943 }
1944
1945 #[test]
1946 fn test_md033_fix_uppercase_tags() {
1947 let rule = MD033NoInlineHtml::with_fix(true);
1949 let content = "This has <EM>emphasized</EM> text.";
1950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1951 let fixed = rule.fix(&ctx).unwrap();
1952 assert_eq!(fixed, "This has *emphasized* text.");
1953 }
1954
1955 #[test]
1956 fn test_md033_fix_unsafe_tags_not_modified() {
1957 let rule = MD033NoInlineHtml::with_fix(true);
1960 let content = "This has <div>a div</div> content.";
1961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1962 let fixed = rule.fix(&ctx).unwrap();
1963 assert_eq!(fixed, "This has <div>a div</div> content.");
1965 }
1966
1967 #[test]
1968 fn test_md033_fix_img_tag_converted() {
1969 let rule = MD033NoInlineHtml::with_fix(true);
1971 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\">";
1972 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1973 let fixed = rule.fix(&ctx).unwrap();
1974 assert_eq!(fixed, "Image: ");
1976 }
1977
1978 #[test]
1979 fn test_md033_fix_img_tag_with_extra_attrs_not_converted() {
1980 let rule = MD033NoInlineHtml::with_fix(true);
1982 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
1983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1984 let fixed = rule.fix(&ctx).unwrap();
1985 assert_eq!(fixed, "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">");
1987 }
1988
1989 #[test]
1990 fn test_md033_fix_relaxed_a_with_target_is_converted() {
1991 let rule = relaxed_fix_rule();
1992 let content = "Link: <a href=\"https://example.com\" target=\"_blank\">Example</a>";
1993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1994 let fixed = rule.fix(&ctx).unwrap();
1995 assert_eq!(fixed, "Link: [Example](https://example.com)");
1996 }
1997
1998 #[test]
1999 fn test_md033_fix_relaxed_img_with_width_is_converted() {
2000 let rule = relaxed_fix_rule();
2001 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
2002 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2003 let fixed = rule.fix(&ctx).unwrap();
2004 assert_eq!(fixed, "Image: ");
2005 }
2006
2007 #[test]
2008 fn test_md033_fix_relaxed_rejects_unknown_extra_attributes() {
2009 let rule = relaxed_fix_rule();
2010 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" aria-label=\"hero\">";
2011 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2012 let fixed = rule.fix(&ctx).unwrap();
2013 assert_eq!(fixed, content, "Unknown attributes should not be dropped by default");
2014 }
2015
2016 #[test]
2017 fn test_md033_fix_relaxed_still_blocks_unsafe_schemes() {
2018 let rule = relaxed_fix_rule();
2019 let content = "Link: <a href=\"javascript:alert(1)\" target=\"_blank\">Example</a>";
2020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2021 let fixed = rule.fix(&ctx).unwrap();
2022 assert_eq!(fixed, content, "Unsafe URL schemes must never be converted");
2023 }
2024
2025 #[test]
2026 fn test_md033_fix_relaxed_wrapper_strip_requires_second_pass_for_nested_html() {
2027 let rule = relaxed_fix_rule();
2028 let content = "<p align=\"center\">\n <img src=\"logo.svg\" alt=\"Logo\" width=\"120\" />\n</p>";
2029 let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2030 let fixed_once = rule.fix(&ctx1).unwrap();
2031 assert!(
2032 fixed_once.contains("<p"),
2033 "First pass should keep wrapper when inner HTML is still present: {fixed_once}"
2034 );
2035 assert!(
2036 fixed_once.contains(""),
2037 "Inner image should be converted on first pass: {fixed_once}"
2038 );
2039
2040 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2041 let fixed_twice = rule.fix(&ctx2).unwrap();
2042 assert!(
2043 !fixed_twice.contains("<p"),
2044 "Second pass should strip configured wrapper: {fixed_twice}"
2045 );
2046 assert!(fixed_twice.contains(""));
2047 }
2048
2049 #[test]
2050 fn test_md033_fix_relaxed_multiple_droppable_attrs() {
2051 let rule = relaxed_fix_rule();
2052 let content = "<a href=\"https://example.com\" target=\"_blank\" rel=\"noopener\" class=\"btn\">Click</a>";
2053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2054 let fixed = rule.fix(&ctx).unwrap();
2055 assert_eq!(fixed, "[Click](https://example.com)");
2056 }
2057
2058 #[test]
2059 fn test_md033_fix_relaxed_img_multiple_droppable_attrs() {
2060 let rule = relaxed_fix_rule();
2061 let content = "<img src=\"logo.png\" alt=\"Logo\" width=\"120\" height=\"40\" style=\"border:none\" />";
2062 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2063 let fixed = rule.fix(&ctx).unwrap();
2064 assert_eq!(fixed, "");
2065 }
2066
2067 #[test]
2068 fn test_md033_fix_relaxed_event_handler_never_dropped() {
2069 let rule = relaxed_fix_rule();
2070 let content = "<a href=\"https://example.com\" onclick=\"track()\">Link</a>";
2071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2072 let fixed = rule.fix(&ctx).unwrap();
2073 assert_eq!(fixed, content, "Event handler attributes must block conversion");
2074 }
2075
2076 #[test]
2077 fn test_md033_fix_relaxed_event_handler_even_with_custom_config() {
2078 let config = MD033Config {
2080 fix: true,
2081 fix_mode: MD033FixMode::Relaxed,
2082 drop_attributes: vec!["on*".to_string(), "target".to_string()],
2083 ..MD033Config::default()
2084 };
2085 let rule = MD033NoInlineHtml::from_config_struct(config);
2086 let content = "<a href=\"https://example.com\" onclick=\"alert(1)\">Link</a>";
2087 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2088 let fixed = rule.fix(&ctx).unwrap();
2089 assert_eq!(fixed, content, "on* event handlers must never be dropped");
2090 }
2091
2092 #[test]
2093 fn test_md033_fix_relaxed_custom_drop_attributes() {
2094 let config = MD033Config {
2095 fix: true,
2096 fix_mode: MD033FixMode::Relaxed,
2097 drop_attributes: vec!["loading".to_string()],
2098 ..MD033Config::default()
2099 };
2100 let rule = MD033NoInlineHtml::from_config_struct(config);
2101 let content = "<img src=\"x.jpg\" alt=\"\" loading=\"lazy\">";
2103 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2104 let fixed = rule.fix(&ctx).unwrap();
2105 assert_eq!(fixed, "", "Custom drop-attributes should be respected");
2106
2107 let content2 = "<img src=\"x.jpg\" alt=\"\" width=\"100\">";
2108 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
2109 let fixed2 = rule.fix(&ctx2).unwrap();
2110 assert_eq!(
2111 fixed2, content2,
2112 "Attributes not in custom list should block conversion"
2113 );
2114 }
2115
2116 #[test]
2117 fn test_md033_fix_relaxed_custom_strip_wrapper() {
2118 let config = MD033Config {
2119 fix: true,
2120 fix_mode: MD033FixMode::Relaxed,
2121 strip_wrapper_elements: vec!["div".to_string()],
2122 ..MD033Config::default()
2123 };
2124 let rule = MD033NoInlineHtml::from_config_struct(config);
2125 let content = "<div>Some text content</div>";
2126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2127 let fixed = rule.fix(&ctx).unwrap();
2128 assert_eq!(fixed, "Some text content");
2129 }
2130
2131 #[test]
2132 fn test_md033_fix_relaxed_wrapper_with_plain_text() {
2133 let rule = relaxed_fix_rule();
2134 let content = "<p align=\"center\">Just some text</p>";
2135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2136 let fixed = rule.fix(&ctx).unwrap();
2137 assert_eq!(fixed, "Just some text");
2138 }
2139
2140 #[test]
2141 fn test_md033_fix_relaxed_data_attr_with_wildcard() {
2142 let config = MD033Config {
2143 fix: true,
2144 fix_mode: MD033FixMode::Relaxed,
2145 drop_attributes: vec!["data-*".to_string(), "target".to_string()],
2146 ..MD033Config::default()
2147 };
2148 let rule = MD033NoInlineHtml::from_config_struct(config);
2149 let content = "<a href=\"https://example.com\" data-tracking=\"abc\" target=\"_blank\">Link</a>";
2150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2151 let fixed = rule.fix(&ctx).unwrap();
2152 assert_eq!(fixed, "[Link](https://example.com)");
2153 }
2154
2155 #[test]
2156 fn test_md033_fix_relaxed_mixed_droppable_and_blocking_attrs() {
2157 let rule = relaxed_fix_rule();
2158 let content = "<a href=\"https://example.com\" target=\"_blank\" aria-label=\"nav\">Link</a>";
2160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2161 let fixed = rule.fix(&ctx).unwrap();
2162 assert_eq!(fixed, content, "Non-droppable attribute should block conversion");
2163 }
2164
2165 #[test]
2166 fn test_md033_fix_relaxed_badge_pattern() {
2167 let rule = relaxed_fix_rule();
2169 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>";
2170 let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2171 let fixed_once = rule.fix(&ctx1).unwrap();
2172 assert!(
2174 fixed_once.contains(""),
2175 "Inner img should be converted: {fixed_once}"
2176 );
2177
2178 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2180 let fixed_twice = rule.fix(&ctx2).unwrap();
2181 assert!(
2182 fixed_twice
2183 .contains("[](https://crates.io/crates/rumdl)"),
2184 "Badge should produce nested markdown image link: {fixed_twice}"
2185 );
2186 }
2187
2188 #[test]
2189 fn test_md033_fix_relaxed_conservative_mode_unchanged() {
2190 let rule = MD033NoInlineHtml::with_fix(true);
2192 let content = "<a href=\"https://example.com\" target=\"_blank\">Link</a>";
2193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2194 let fixed = rule.fix(&ctx).unwrap();
2195 assert_eq!(fixed, content, "Conservative mode should not drop target attribute");
2196 }
2197
2198 #[test]
2199 fn test_md033_fix_relaxed_img_inside_pre_not_converted() {
2200 let rule = relaxed_fix_rule();
2202 let content = "<pre>\n <img src=\"diagram.png\" alt=\"d\" width=\"100\" />\n</pre>";
2203 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2204 let fixed = rule.fix(&ctx).unwrap();
2205 assert!(fixed.contains("<img"), "img inside pre must not be converted: {fixed}");
2206 }
2207
2208 #[test]
2209 fn test_md033_fix_relaxed_wrapper_nested_inside_div_not_stripped() {
2210 let rule = relaxed_fix_rule();
2212 let content = "<div><p>text</p></div>";
2213 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2214 let fixed = rule.fix(&ctx).unwrap();
2215 assert!(
2216 fixed.contains("<p>text</p>") || fixed.contains("<p>"),
2217 "Nested <p> inside <div> should not be stripped: {fixed}"
2218 );
2219 }
2220
2221 #[test]
2222 fn test_md033_fix_relaxed_img_inside_nested_wrapper_not_converted() {
2223 let rule = relaxed_fix_rule();
2227 let content = "<div><p><img src=\"x.jpg\" alt=\"pic\" width=\"100\" /></p></div>";
2228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2229 let fixed = rule.fix(&ctx).unwrap();
2230 assert!(
2231 fixed.contains("<img"),
2232 "img inside nested wrapper must not be converted: {fixed}"
2233 );
2234 }
2235
2236 #[test]
2237 fn test_md033_fix_mixed_safe_tags() {
2238 let rule = MD033NoInlineHtml::with_fix(true);
2240 let content = "<em>italic</em> and <img src=\"x.jpg\"> and <strong>bold</strong>";
2241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2242 let fixed = rule.fix(&ctx).unwrap();
2243 assert_eq!(fixed, "*italic* and  and **bold**");
2245 }
2246
2247 #[test]
2248 fn test_md033_fix_multiple_tags_same_line() {
2249 let rule = MD033NoInlineHtml::with_fix(true);
2251 let content = "Regular text <i>italic</i> and <b>bold</b> here.";
2252 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2253 let fixed = rule.fix(&ctx).unwrap();
2254 assert_eq!(fixed, "Regular text *italic* and **bold** here.");
2255 }
2256
2257 #[test]
2258 fn test_md033_fix_multiple_em_tags_same_line() {
2259 let rule = MD033NoInlineHtml::with_fix(true);
2261 let content = "<em>first</em> and <strong>second</strong> and <code>third</code>";
2262 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2263 let fixed = rule.fix(&ctx).unwrap();
2264 assert_eq!(fixed, "*first* and **second** and `third`");
2265 }
2266
2267 #[test]
2268 fn test_md033_fix_skips_tags_inside_pre() {
2269 let rule = MD033NoInlineHtml::with_fix(true);
2271 let content = "<pre><code><em>VALUE</em></code></pre>";
2272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2273 let fixed = rule.fix(&ctx).unwrap();
2274 assert!(
2277 !fixed.contains("*VALUE*"),
2278 "Tags inside <pre> should not be converted to markdown. Got: {fixed}"
2279 );
2280 }
2281
2282 #[test]
2283 fn test_md033_fix_skips_tags_inside_div() {
2284 let rule = MD033NoInlineHtml::with_fix(true);
2286 let content = "<div>\n<em>emphasized</em>\n</div>";
2287 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2288 let fixed = rule.fix(&ctx).unwrap();
2289 assert!(
2291 !fixed.contains("*emphasized*"),
2292 "Tags inside HTML blocks should not be converted. Got: {fixed}"
2293 );
2294 }
2295
2296 #[test]
2297 fn test_md033_fix_outside_html_block() {
2298 let rule = MD033NoInlineHtml::with_fix(true);
2300 let content = "<div>\ncontent\n</div>\n\nOutside <em>emphasized</em> text.";
2301 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2302 let fixed = rule.fix(&ctx).unwrap();
2303 assert!(
2305 fixed.contains("*emphasized*"),
2306 "Tags outside HTML blocks should be converted. Got: {fixed}"
2307 );
2308 }
2309
2310 #[test]
2311 fn test_md033_fix_with_id_attribute() {
2312 let rule = MD033NoInlineHtml::with_fix(true);
2314 let content = "See <em id=\"important\">this note</em> for details.";
2315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2316 let fixed = rule.fix(&ctx).unwrap();
2317 assert_eq!(fixed, content);
2319 }
2320
2321 #[test]
2322 fn test_md033_fix_with_style_attribute() {
2323 let rule = MD033NoInlineHtml::with_fix(true);
2325 let content = "This is <strong style=\"color: red\">important</strong> text.";
2326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2327 let fixed = rule.fix(&ctx).unwrap();
2328 assert_eq!(fixed, content);
2330 }
2331
2332 #[test]
2333 fn test_md033_fix_mixed_with_and_without_attributes() {
2334 let rule = MD033NoInlineHtml::with_fix(true);
2336 let content = "<em>normal</em> and <em class=\"special\">styled</em> text.";
2337 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2338 let fixed = rule.fix(&ctx).unwrap();
2339 assert_eq!(fixed, "*normal* and <em class=\"special\">styled</em> text.");
2341 }
2342
2343 #[test]
2344 fn test_md033_quick_fix_tag_with_attributes_no_fix() {
2345 let rule = MD033NoInlineHtml::with_fix(true);
2347 let content = "<em class=\"test\">emphasized</em>";
2348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2349 let result = rule.check(&ctx).unwrap();
2350
2351 assert_eq!(result.len(), 1, "Should find one HTML tag");
2352 assert!(
2354 result[0].fix.is_none(),
2355 "Should NOT have a fix for tags with attributes"
2356 );
2357 }
2358
2359 #[test]
2360 fn test_md033_fix_skips_html_entities() {
2361 let rule = MD033NoInlineHtml::with_fix(true);
2364 let content = "<code>|</code>";
2365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2366 let fixed = rule.fix(&ctx).unwrap();
2367 assert_eq!(fixed, content);
2369 }
2370
2371 #[test]
2372 fn test_md033_fix_skips_multiple_html_entities() {
2373 let rule = MD033NoInlineHtml::with_fix(true);
2375 let content = "<code><T></code>";
2376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2377 let fixed = rule.fix(&ctx).unwrap();
2378 assert_eq!(fixed, content);
2380 }
2381
2382 #[test]
2383 fn test_md033_fix_allows_ampersand_without_entity() {
2384 let rule = MD033NoInlineHtml::with_fix(true);
2386 let content = "<code>a & b</code>";
2387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2388 let fixed = rule.fix(&ctx).unwrap();
2389 assert_eq!(fixed, "`a & b`");
2391 }
2392
2393 #[test]
2394 fn test_md033_fix_em_with_entities_skipped() {
2395 let rule = MD033NoInlineHtml::with_fix(true);
2397 let content = "<em> text</em>";
2398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2399 let fixed = rule.fix(&ctx).unwrap();
2400 assert_eq!(fixed, content);
2402 }
2403
2404 #[test]
2405 fn test_md033_fix_skips_nested_em_in_code() {
2406 let rule = MD033NoInlineHtml::with_fix(true);
2409 let content = "<code><em>n</em></code>";
2410 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2411 let fixed = rule.fix(&ctx).unwrap();
2412 assert!(
2415 !fixed.contains("*n*"),
2416 "Nested <em> should not be converted to markdown. Got: {fixed}"
2417 );
2418 }
2419
2420 #[test]
2421 fn test_md033_fix_skips_nested_in_table() {
2422 let rule = MD033NoInlineHtml::with_fix(true);
2424 let content = "| <code>><em>n</em></code> | description |";
2425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2426 let fixed = rule.fix(&ctx).unwrap();
2427 assert!(
2429 !fixed.contains("*n*"),
2430 "Nested tags in table should not be converted. Got: {fixed}"
2431 );
2432 }
2433
2434 #[test]
2435 fn test_md033_fix_standalone_em_still_converted() {
2436 let rule = MD033NoInlineHtml::with_fix(true);
2438 let content = "This is <em>emphasized</em> text.";
2439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2440 let fixed = rule.fix(&ctx).unwrap();
2441 assert_eq!(fixed, "This is *emphasized* text.");
2442 }
2443
2444 #[test]
2456 fn test_md033_templater_basic_interpolation_not_flagged() {
2457 let rule = MD033NoInlineHtml::default();
2460 let content = "Today is <% tp.date.now() %> which is nice.";
2461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2462 let result = rule.check(&ctx).unwrap();
2463 assert!(
2464 result.is_empty(),
2465 "Templater basic interpolation should not be flagged as HTML. Got: {result:?}"
2466 );
2467 }
2468
2469 #[test]
2470 fn test_md033_templater_file_functions_not_flagged() {
2471 let rule = MD033NoInlineHtml::default();
2473 let content = "File: <% tp.file.title %>\nCreated: <% tp.file.creation_date() %>";
2474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2475 let result = rule.check(&ctx).unwrap();
2476 assert!(
2477 result.is_empty(),
2478 "Templater file functions should not be flagged. Got: {result:?}"
2479 );
2480 }
2481
2482 #[test]
2483 fn test_md033_templater_with_arguments_not_flagged() {
2484 let rule = MD033NoInlineHtml::default();
2486 let content = r#"Date: <% tp.date.now("YYYY-MM-DD") %>"#;
2487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2488 let result = rule.check(&ctx).unwrap();
2489 assert!(
2490 result.is_empty(),
2491 "Templater with arguments should not be flagged. Got: {result:?}"
2492 );
2493 }
2494
2495 #[test]
2496 fn test_md033_templater_javascript_execution_not_flagged() {
2497 let rule = MD033NoInlineHtml::default();
2499 let content = "<%* const today = tp.date.now(); tR += today; %>";
2500 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2501 let result = rule.check(&ctx).unwrap();
2502 assert!(
2503 result.is_empty(),
2504 "Templater JS execution block should not be flagged. Got: {result:?}"
2505 );
2506 }
2507
2508 #[test]
2509 fn test_md033_templater_dynamic_execution_not_flagged() {
2510 let rule = MD033NoInlineHtml::default();
2512 let content = "Dynamic: <%+ tp.date.now() %>";
2513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2514 let result = rule.check(&ctx).unwrap();
2515 assert!(
2516 result.is_empty(),
2517 "Templater dynamic execution should not be flagged. Got: {result:?}"
2518 );
2519 }
2520
2521 #[test]
2522 fn test_md033_templater_whitespace_trim_all_not_flagged() {
2523 let rule = MD033NoInlineHtml::default();
2525 let content = "<%_ tp.date.now() _%>";
2526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2527 let result = rule.check(&ctx).unwrap();
2528 assert!(
2529 result.is_empty(),
2530 "Templater trim-all whitespace should not be flagged. Got: {result:?}"
2531 );
2532 }
2533
2534 #[test]
2535 fn test_md033_templater_whitespace_trim_newline_not_flagged() {
2536 let rule = MD033NoInlineHtml::default();
2538 let content = "<%- tp.date.now() -%>";
2539 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2540 let result = rule.check(&ctx).unwrap();
2541 assert!(
2542 result.is_empty(),
2543 "Templater trim-newline should not be flagged. Got: {result:?}"
2544 );
2545 }
2546
2547 #[test]
2548 fn test_md033_templater_combined_modifiers_not_flagged() {
2549 let rule = MD033NoInlineHtml::default();
2551 let contents = [
2552 "<%-* const x = 1; -%>", "<%_+ tp.date.now() _%>", "<%- tp.file.title -%>", "<%_ tp.file.title _%>", ];
2557 for content in contents {
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 combined modifiers should not be flagged: {content}. Got: {result:?}"
2563 );
2564 }
2565 }
2566
2567 #[test]
2568 fn test_md033_templater_multiline_block_not_flagged() {
2569 let rule = MD033NoInlineHtml::default();
2571 let content = r#"<%*
2572const x = 1;
2573const y = 2;
2574tR += x + y;
2575%>"#;
2576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2577 let result = rule.check(&ctx).unwrap();
2578 assert!(
2579 result.is_empty(),
2580 "Templater multi-line block should not be flagged. Got: {result:?}"
2581 );
2582 }
2583
2584 #[test]
2585 fn test_md033_templater_with_angle_brackets_in_condition_not_flagged() {
2586 let rule = MD033NoInlineHtml::default();
2589 let content = "<%* if (x < 5) { tR += 'small'; } %>";
2590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2591 let result = rule.check(&ctx).unwrap();
2592 assert!(
2593 result.is_empty(),
2594 "Templater with angle brackets in conditions should not be flagged. Got: {result:?}"
2595 );
2596 }
2597
2598 #[test]
2599 fn test_md033_templater_mixed_with_html_only_html_flagged() {
2600 let rule = MD033NoInlineHtml::default();
2602 let content = "<% tp.date.now() %> is today's date. <div>This is HTML</div>";
2603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2604 let result = rule.check(&ctx).unwrap();
2605 assert_eq!(result.len(), 1, "Should only flag the HTML div tag");
2606 assert!(
2607 result[0].message.contains("<div>"),
2608 "Should flag <div>, got: {}",
2609 result[0].message
2610 );
2611 }
2612
2613 #[test]
2614 fn test_md033_templater_in_heading_not_flagged() {
2615 let rule = MD033NoInlineHtml::default();
2617 let content = "# <% tp.file.title %>";
2618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2619 let result = rule.check(&ctx).unwrap();
2620 assert!(
2621 result.is_empty(),
2622 "Templater in heading should not be flagged. Got: {result:?}"
2623 );
2624 }
2625
2626 #[test]
2627 fn test_md033_templater_multiple_on_same_line_not_flagged() {
2628 let rule = MD033NoInlineHtml::default();
2630 let content = "From <% tp.date.now() %> to <% tp.date.tomorrow() %> we have meetings.";
2631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2632 let result = rule.check(&ctx).unwrap();
2633 assert!(
2634 result.is_empty(),
2635 "Multiple Templater blocks should not be flagged. Got: {result:?}"
2636 );
2637 }
2638
2639 #[test]
2640 fn test_md033_templater_in_code_block_not_flagged() {
2641 let rule = MD033NoInlineHtml::default();
2643 let content = "```\n<% tp.date.now() %>\n```";
2644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2645 let result = rule.check(&ctx).unwrap();
2646 assert!(
2647 result.is_empty(),
2648 "Templater in code block should not be flagged. Got: {result:?}"
2649 );
2650 }
2651
2652 #[test]
2653 fn test_md033_templater_in_inline_code_not_flagged() {
2654 let rule = MD033NoInlineHtml::default();
2656 let content = "Use `<% tp.date.now() %>` for current date.";
2657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2658 let result = rule.check(&ctx).unwrap();
2659 assert!(
2660 result.is_empty(),
2661 "Templater in inline code should not be flagged. Got: {result:?}"
2662 );
2663 }
2664
2665 #[test]
2666 fn test_md033_templater_also_works_in_standard_flavor() {
2667 let rule = MD033NoInlineHtml::default();
2670 let content = "<% tp.date.now() %> works everywhere.";
2671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2672 let result = rule.check(&ctx).unwrap();
2673 assert!(
2674 result.is_empty(),
2675 "Templater should not be flagged even in Standard flavor. Got: {result:?}"
2676 );
2677 }
2678
2679 #[test]
2680 fn test_md033_templater_empty_tag_not_flagged() {
2681 let rule = MD033NoInlineHtml::default();
2683 let content = "<%>";
2684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2685 let result = rule.check(&ctx).unwrap();
2686 assert!(
2687 result.is_empty(),
2688 "Empty Templater-like tag should not be flagged. Got: {result:?}"
2689 );
2690 }
2691
2692 #[test]
2693 fn test_md033_templater_unclosed_not_flagged() {
2694 let rule = MD033NoInlineHtml::default();
2696 let content = "<% tp.date.now() without closing tag";
2697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2698 let result = rule.check(&ctx).unwrap();
2699 assert!(
2700 result.is_empty(),
2701 "Unclosed Templater should not be flagged as HTML. Got: {result:?}"
2702 );
2703 }
2704
2705 #[test]
2706 fn test_md033_templater_with_newlines_inside_not_flagged() {
2707 let rule = MD033NoInlineHtml::default();
2709 let content = r#"<% tp.date.now("YYYY") +
2710"-" +
2711tp.date.now("MM") %>"#;
2712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2713 let result = rule.check(&ctx).unwrap();
2714 assert!(
2715 result.is_empty(),
2716 "Templater with internal newlines should not be flagged. Got: {result:?}"
2717 );
2718 }
2719
2720 #[test]
2721 fn test_md033_erb_style_tags_not_flagged() {
2722 let rule = MD033NoInlineHtml::default();
2725 let content = "<%= variable %> and <% code %> and <%# comment %>";
2726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2727 let result = rule.check(&ctx).unwrap();
2728 assert!(
2729 result.is_empty(),
2730 "ERB/EJS style tags should not be flagged as HTML. Got: {result:?}"
2731 );
2732 }
2733
2734 #[test]
2735 fn test_md033_templater_complex_expression_not_flagged() {
2736 let rule = MD033NoInlineHtml::default();
2738 let content = r#"<%*
2739const file = tp.file.title;
2740const date = tp.date.now("YYYY-MM-DD");
2741const folder = tp.file.folder();
2742tR += `# ${file}\n\nCreated: ${date}\nIn: ${folder}`;
2743%>"#;
2744 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2745 let result = rule.check(&ctx).unwrap();
2746 assert!(
2747 result.is_empty(),
2748 "Complex Templater expression should not be flagged. Got: {result:?}"
2749 );
2750 }
2751
2752 #[test]
2753 fn test_md033_percent_sign_variations_not_flagged() {
2754 let rule = MD033NoInlineHtml::default();
2756 let patterns = [
2757 "<%=", "<%#", "<%%", "<%!", "<%@", "<%--", ];
2764 for pattern in patterns {
2765 let content = format!("{pattern} content %>");
2766 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2767 let result = rule.check(&ctx).unwrap();
2768 assert!(
2769 result.is_empty(),
2770 "Pattern {pattern} should not be flagged. Got: {result:?}"
2771 );
2772 }
2773 }
2774
2775 #[test]
2781 fn test_md033_fix_a_wrapping_markdown_image_no_escaped_brackets() {
2782 let rule = MD033NoInlineHtml::with_fix(true);
2785 let content = r#"<a href="https://example.com"></a>"#;
2786 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2787 let fixed = rule.fix(&ctx).unwrap();
2788
2789 assert_eq!(fixed, "[](https://example.com)",);
2790 assert!(!fixed.contains(r"\["), "Must not escape brackets: {fixed}");
2791 assert!(!fixed.contains(r"\]"), "Must not escape brackets: {fixed}");
2792 }
2793
2794 #[test]
2795 fn test_md033_fix_a_wrapping_markdown_image_with_alt() {
2796 let rule = MD033NoInlineHtml::with_fix(true);
2798 let content =
2799 r#"<a href="https://github.com/repo"></a>"#;
2800 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2801 let fixed = rule.fix(&ctx).unwrap();
2802
2803 assert_eq!(
2804 fixed,
2805 "[](https://github.com/repo)"
2806 );
2807 }
2808
2809 #[test]
2810 fn test_md033_fix_img_without_alt_produces_empty_alt() {
2811 let rule = MD033NoInlineHtml::with_fix(true);
2812 let content = r#"<img src="photo.jpg" />"#;
2813 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2814 let fixed = rule.fix(&ctx).unwrap();
2815
2816 assert_eq!(fixed, "");
2817 }
2818
2819 #[test]
2820 fn test_md033_fix_a_with_plain_text_still_escapes_brackets() {
2821 let rule = MD033NoInlineHtml::with_fix(true);
2823 let content = r#"<a href="https://example.com">text with [brackets]</a>"#;
2824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2825 let fixed = rule.fix(&ctx).unwrap();
2826
2827 assert!(
2828 fixed.contains(r"\[brackets\]"),
2829 "Plain text brackets should be escaped: {fixed}"
2830 );
2831 }
2832
2833 #[test]
2834 fn test_md033_fix_a_with_image_plus_extra_text_escapes_brackets() {
2835 let rule = MD033NoInlineHtml::with_fix(true);
2838 let content = r#"<a href="/link"> see [docs]</a>"#;
2839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2840 let fixed = rule.fix(&ctx).unwrap();
2841
2842 assert!(
2844 fixed.contains(r"\[docs\]"),
2845 "Brackets in mixed image+text content should be escaped: {fixed}"
2846 );
2847 }
2848
2849 #[test]
2850 fn test_md033_fix_img_in_a_end_to_end() {
2851 use crate::config::Config;
2854 use crate::fix_coordinator::FixCoordinator;
2855
2856 let rule = MD033NoInlineHtml::with_fix(true);
2857 let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
2858
2859 let mut content =
2860 r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image?repo=org/repo" /></a>"#
2861 .to_string();
2862 let config = Config::default();
2863 let coordinator = FixCoordinator::new();
2864
2865 let result = coordinator
2866 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
2867 .unwrap();
2868
2869 assert_eq!(
2870 content, "[](https://github.com/org/repo)",
2871 "End-to-end: <a><img></a> should become valid linked image"
2872 );
2873 assert!(result.converged);
2874 assert!(!content.contains(r"\["), "No escaped brackets: {content}");
2875 }
2876
2877 #[test]
2878 fn test_md033_fix_img_in_a_with_alt_end_to_end() {
2879 use crate::config::Config;
2880 use crate::fix_coordinator::FixCoordinator;
2881
2882 let rule = MD033NoInlineHtml::with_fix(true);
2883 let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
2884
2885 let mut content =
2886 r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image" alt="Contributors" /></a>"#
2887 .to_string();
2888 let config = Config::default();
2889 let coordinator = FixCoordinator::new();
2890
2891 let result = coordinator
2892 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
2893 .unwrap();
2894
2895 assert_eq!(
2896 content,
2897 "[](https://github.com/org/repo)",
2898 );
2899 assert!(result.converged);
2900 }
2901
2902 #[test]
2912 fn test_md033_table_allowed_unset_falls_back_to_allowed() {
2913 let config = MD033Config {
2914 allowed: vec!["br".to_string()],
2915 table_allowed_elements: None,
2916 ..MD033Config::default()
2917 };
2918 let rule = MD033NoInlineHtml::from_config_struct(config);
2919 let content = "| col |\n|-----|\n| a<br>b |\n";
2920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2921 let result = rule.check(&ctx).unwrap();
2922 assert!(
2923 result.is_empty(),
2924 "<br> in table cell should be allowed via fallback to `allowed`, got {result:?}"
2925 );
2926 }
2927
2928 #[test]
2929 fn test_md033_table_allowed_explicit_empty_rejects_in_tables() {
2930 let config = MD033Config {
2931 allowed: vec!["br".to_string()],
2932 table_allowed_elements: Some(Vec::new()),
2933 ..MD033Config::default()
2934 };
2935 let rule = MD033NoInlineHtml::from_config_struct(config);
2936 let content = "| col |\n|-----|\n| a<br>b |\n";
2937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2938 let result = rule.check(&ctx).unwrap();
2939 assert_eq!(
2940 result.len(),
2941 1,
2942 "Explicit empty table_allowed should reject <br> in tables even if it's in `allowed`, got {result:?}"
2943 );
2944 assert_eq!(result[0].line, 3);
2945 }
2946
2947 #[test]
2948 fn test_md033_table_allowed_explicit_list_overrides_in_tables() {
2949 let config = MD033Config {
2950 allowed: vec!["br".to_string()],
2951 table_allowed_elements: Some(vec!["img".to_string()]),
2952 ..MD033Config::default()
2953 };
2954 let rule = MD033NoInlineHtml::from_config_struct(config);
2955 let content = "| col |\n|-----|\n| <br><img src=\"x\"/> |\n";
2958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2959 let result = rule.check(&ctx).unwrap();
2960 assert_eq!(
2961 result.len(),
2962 1,
2963 "table_allowed should override `allowed` inside tables, got {result:?}"
2964 );
2965 assert!(
2966 result[0].message.contains("br"),
2967 "expected the flagged tag to be <br>, got {:?}",
2968 result[0].message
2969 );
2970 }
2971
2972 #[test]
2973 fn test_md033_table_allowed_does_not_affect_out_of_table_tags() {
2974 let config = MD033Config {
2975 allowed: vec!["br".to_string()],
2976 table_allowed_elements: Some(Vec::new()),
2977 ..MD033Config::default()
2978 };
2979 let rule = MD033NoInlineHtml::from_config_struct(config);
2980 let content = "Paragraph with <br> tag.\n";
2982 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2983 let result = rule.check(&ctx).unwrap();
2984 assert!(
2985 result.is_empty(),
2986 "<br> outside tables must still be allowed by `allowed`, got {result:?}"
2987 );
2988 }
2989
2990 #[test]
2991 fn test_md033_table_allowed_kebab_case_parses() {
2992 let toml_str = r#"
2993 allowed-elements = ["br"]
2994 table-allowed-elements = ["img"]
2995 "#;
2996 let config: MD033Config = toml::from_str(toml_str).unwrap();
2997 assert_eq!(config.allowed, vec!["br"]);
2998 assert_eq!(
2999 config.table_allowed_elements.as_deref(),
3000 Some(["img".to_string()].as_slice())
3001 );
3002 }
3003
3004 #[test]
3005 fn test_md033_table_allowed_snake_case_alias_parses() {
3006 let toml_str = r#"
3007 allowed_elements = ["br"]
3008 table_allowed_elements = ["img"]
3009 "#;
3010 let config: MD033Config = toml::from_str(toml_str).unwrap();
3011 assert_eq!(config.allowed, vec!["br"]);
3012 assert_eq!(
3013 config.table_allowed_elements.as_deref(),
3014 Some(["img".to_string()].as_slice())
3015 );
3016 }
3017
3018 #[test]
3019 fn test_md033_table_allowed_default_is_none() {
3020 let cfg = MD033Config::default();
3021 assert!(
3022 cfg.table_allowed_elements.is_none(),
3023 "Default for table_allowed_elements should be None (so it falls back to `allowed`)"
3024 );
3025 }
3026
3027 #[test]
3028 fn test_md033_table_allowed_case_insensitive() {
3029 let config = MD033Config {
3030 allowed: Vec::new(),
3031 table_allowed_elements: Some(vec!["BR".to_string()]),
3032 ..MD033Config::default()
3033 };
3034 let rule = MD033NoInlineHtml::from_config_struct(config);
3035 let content = "| col |\n|-----|\n| a<br>b |\n";
3036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3037 let result = rule.check(&ctx).unwrap();
3038 assert!(
3039 result.is_empty(),
3040 "table_allowed should be case-insensitive, got {result:?}"
3041 );
3042 }
3043}