html-to-markdown 3.4.0 → 3.6.0.pre.rc.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +347 -0
  4. data/Steepfile +10 -2
  5. data/ext/html_to_markdown_rb/Cargo.toml +3 -2
  6. data/ext/html_to_markdown_rb/extconf.rb +5 -5
  7. data/ext/html_to_markdown_rb/native/Cargo.lock +962 -0
  8. data/ext/html_to_markdown_rb/native/Cargo.toml +6 -11
  9. data/ext/html_to_markdown_rb/native/extconf.rb +14 -0
  10. data/ext/html_to_markdown_rb/src/lib.rs +1715 -646
  11. data/lib/html_to_markdown/native.rb +913 -37
  12. data/lib/html_to_markdown/version.rb +3 -3
  13. data/lib/html_to_markdown.rb +9 -4
  14. data/lib/html_to_markdown_rb.so +0 -0
  15. data/sig/types.rbs +59 -292
  16. metadata +32 -179
  17. data/ext/html_to_markdown_rb/Makefile +0 -592
  18. data/lib/bin/html-to-markdown +0 -0
  19. data/vendor/Cargo.toml +0 -33
  20. data/vendor/html-to-markdown-rs/Cargo.toml +0 -54
  21. data/vendor/html-to-markdown-rs/README.md +0 -278
  22. data/vendor/html-to-markdown-rs/examples/basic.rs +0 -24
  23. data/vendor/html-to-markdown-rs/examples/table.rs +0 -25
  24. data/vendor/html-to-markdown-rs/examples/test_deser.rs +0 -12
  25. data/vendor/html-to-markdown-rs/examples/test_escape.rs +0 -58
  26. data/vendor/html-to-markdown-rs/examples/test_inline_formatting.rs +0 -113
  27. data/vendor/html-to-markdown-rs/examples/test_lists.rs +0 -39
  28. data/vendor/html-to-markdown-rs/examples/test_semantic_tags.rs +0 -89
  29. data/vendor/html-to-markdown-rs/examples/test_tables.rs +0 -100
  30. data/vendor/html-to-markdown-rs/examples/test_task_lists.rs +0 -61
  31. data/vendor/html-to-markdown-rs/examples/test_whitespace.rs +0 -34
  32. data/vendor/html-to-markdown-rs/src/convert_api.rs +0 -349
  33. data/vendor/html-to-markdown-rs/src/converter/block/blockquote.rs +0 -178
  34. data/vendor/html-to-markdown-rs/src/converter/block/container.rs +0 -114
  35. data/vendor/html-to-markdown-rs/src/converter/block/div.rs +0 -149
  36. data/vendor/html-to-markdown-rs/src/converter/block/heading.rs +0 -428
  37. data/vendor/html-to-markdown-rs/src/converter/block/horizontal_rule.rs +0 -103
  38. data/vendor/html-to-markdown-rs/src/converter/block/line_break.rs +0 -89
  39. data/vendor/html-to-markdown-rs/src/converter/block/mod.rs +0 -10
  40. data/vendor/html-to-markdown-rs/src/converter/block/paragraph.rs +0 -140
  41. data/vendor/html-to-markdown-rs/src/converter/block/preformatted.rs +0 -298
  42. data/vendor/html-to-markdown-rs/src/converter/block/table/builder.rs +0 -453
  43. data/vendor/html-to-markdown-rs/src/converter/block/table/caption.rs +0 -44
  44. data/vendor/html-to-markdown-rs/src/converter/block/table/cell.rs +0 -276
  45. data/vendor/html-to-markdown-rs/src/converter/block/table/cells.rs +0 -336
  46. data/vendor/html-to-markdown-rs/src/converter/block/table/layout.rs +0 -58
  47. data/vendor/html-to-markdown-rs/src/converter/block/table/mod.rs +0 -266
  48. data/vendor/html-to-markdown-rs/src/converter/block/table/scanner.rs +0 -146
  49. data/vendor/html-to-markdown-rs/src/converter/block/table/utils.rs +0 -34
  50. data/vendor/html-to-markdown-rs/src/converter/block/unknown.rs +0 -138
  51. data/vendor/html-to-markdown-rs/src/converter/context.rs +0 -208
  52. data/vendor/html-to-markdown-rs/src/converter/dom_context.rs +0 -337
  53. data/vendor/html-to-markdown-rs/src/converter/form/elements.rs +0 -770
  54. data/vendor/html-to-markdown-rs/src/converter/form/mod.rs +0 -82
  55. data/vendor/html-to-markdown-rs/src/converter/format/djot.rs +0 -64
  56. data/vendor/html-to-markdown-rs/src/converter/format/markdown.rs +0 -59
  57. data/vendor/html-to-markdown-rs/src/converter/format/mod.rs +0 -43
  58. data/vendor/html-to-markdown-rs/src/converter/handlers/blockquote.rs +0 -173
  59. data/vendor/html-to-markdown-rs/src/converter/handlers/code_block.rs +0 -434
  60. data/vendor/html-to-markdown-rs/src/converter/handlers/graphic.rs +0 -234
  61. data/vendor/html-to-markdown-rs/src/converter/handlers/image.rs +0 -282
  62. data/vendor/html-to-markdown-rs/src/converter/handlers/link.rs +0 -316
  63. data/vendor/html-to-markdown-rs/src/converter/handlers/mod.rs +0 -26
  64. data/vendor/html-to-markdown-rs/src/converter/inline/code.rs +0 -306
  65. data/vendor/html-to-markdown-rs/src/converter/inline/emphasis.rs +0 -345
  66. data/vendor/html-to-markdown-rs/src/converter/inline/link.rs +0 -428
  67. data/vendor/html-to-markdown-rs/src/converter/inline/mod.rs +0 -237
  68. data/vendor/html-to-markdown-rs/src/converter/inline/ruby.rs +0 -337
  69. data/vendor/html-to-markdown-rs/src/converter/inline/semantic/marks.rs +0 -566
  70. data/vendor/html-to-markdown-rs/src/converter/inline/semantic/mod.rs +0 -86
  71. data/vendor/html-to-markdown-rs/src/converter/inline/semantic/typography.rs +0 -558
  72. data/vendor/html-to-markdown-rs/src/converter/list/definition.rs +0 -232
  73. data/vendor/html-to-markdown-rs/src/converter/list/item.rs +0 -332
  74. data/vendor/html-to-markdown-rs/src/converter/list/mod.rs +0 -70
  75. data/vendor/html-to-markdown-rs/src/converter/list/ordered.rs +0 -201
  76. data/vendor/html-to-markdown-rs/src/converter/list/unordered.rs +0 -195
  77. data/vendor/html-to-markdown-rs/src/converter/list/utils.rs +0 -314
  78. data/vendor/html-to-markdown-rs/src/converter/main.rs +0 -710
  79. data/vendor/html-to-markdown-rs/src/converter/main_helpers.rs +0 -452
  80. data/vendor/html-to-markdown-rs/src/converter/media/embedded.rs +0 -393
  81. data/vendor/html-to-markdown-rs/src/converter/media/graphic.rs +0 -4
  82. data/vendor/html-to-markdown-rs/src/converter/media/image.rs +0 -183
  83. data/vendor/html-to-markdown-rs/src/converter/media/mod.rs +0 -87
  84. data/vendor/html-to-markdown-rs/src/converter/media/svg.rs +0 -280
  85. data/vendor/html-to-markdown-rs/src/converter/metadata.rs +0 -220
  86. data/vendor/html-to-markdown-rs/src/converter/mod.rs +0 -156
  87. data/vendor/html-to-markdown-rs/src/converter/plain_text.rs +0 -516
  88. data/vendor/html-to-markdown-rs/src/converter/preprocessing_helpers.rs +0 -201
  89. data/vendor/html-to-markdown-rs/src/converter/reference_collector.rs +0 -69
  90. data/vendor/html-to-markdown-rs/src/converter/semantic/attributes.rs +0 -269
  91. data/vendor/html-to-markdown-rs/src/converter/semantic/definition_list.rs +0 -266
  92. data/vendor/html-to-markdown-rs/src/converter/semantic/figure.rs +0 -391
  93. data/vendor/html-to-markdown-rs/src/converter/semantic/mod.rs +0 -112
  94. data/vendor/html-to-markdown-rs/src/converter/semantic/sectioning.rs +0 -85
  95. data/vendor/html-to-markdown-rs/src/converter/semantic/summary.rs +0 -324
  96. data/vendor/html-to-markdown-rs/src/converter/text/mod.rs +0 -8
  97. data/vendor/html-to-markdown-rs/src/converter/text/processing.rs +0 -56
  98. data/vendor/html-to-markdown-rs/src/converter/text_node.rs +0 -269
  99. data/vendor/html-to-markdown-rs/src/converter/utility/attributes.rs +0 -151
  100. data/vendor/html-to-markdown-rs/src/converter/utility/caching.rs +0 -74
  101. data/vendor/html-to-markdown-rs/src/converter/utility/content.rs +0 -271
  102. data/vendor/html-to-markdown-rs/src/converter/utility/mod.rs +0 -17
  103. data/vendor/html-to-markdown-rs/src/converter/utility/preprocessing.rs +0 -1002
  104. data/vendor/html-to-markdown-rs/src/converter/utility/serialization.rs +0 -126
  105. data/vendor/html-to-markdown-rs/src/converter/utility/siblings.rs +0 -97
  106. data/vendor/html-to-markdown-rs/src/converter/visitor_hooks.rs +0 -189
  107. data/vendor/html-to-markdown-rs/src/error.rs +0 -43
  108. data/vendor/html-to-markdown-rs/src/exports.rs +0 -24
  109. data/vendor/html-to-markdown-rs/src/inline_images.rs +0 -336
  110. data/vendor/html-to-markdown-rs/src/lib.rs +0 -139
  111. data/vendor/html-to-markdown-rs/src/metadata/collector.rs +0 -457
  112. data/vendor/html-to-markdown-rs/src/metadata/config.rs +0 -394
  113. data/vendor/html-to-markdown-rs/src/metadata/extraction.rs +0 -398
  114. data/vendor/html-to-markdown-rs/src/metadata/mod.rs +0 -288
  115. data/vendor/html-to-markdown-rs/src/metadata/types.rs +0 -477
  116. data/vendor/html-to-markdown-rs/src/options/conversion.rs +0 -559
  117. data/vendor/html-to-markdown-rs/src/options/inline_image.rs +0 -111
  118. data/vendor/html-to-markdown-rs/src/options/mod.rs +0 -20
  119. data/vendor/html-to-markdown-rs/src/options/preprocessing.rs +0 -201
  120. data/vendor/html-to-markdown-rs/src/options/validation.rs +0 -416
  121. data/vendor/html-to-markdown-rs/src/prelude.rs +0 -1
  122. data/vendor/html-to-markdown-rs/src/rcdom.rs +0 -487
  123. data/vendor/html-to-markdown-rs/src/text.rs +0 -358
  124. data/vendor/html-to-markdown-rs/src/types/document.rs +0 -191
  125. data/vendor/html-to-markdown-rs/src/types/mod.rs +0 -17
  126. data/vendor/html-to-markdown-rs/src/types/result.rs +0 -54
  127. data/vendor/html-to-markdown-rs/src/types/structure_builder.rs +0 -791
  128. data/vendor/html-to-markdown-rs/src/types/structure_collector.rs +0 -483
  129. data/vendor/html-to-markdown-rs/src/types/tables.rs +0 -52
  130. data/vendor/html-to-markdown-rs/src/types/warnings.rs +0 -33
  131. data/vendor/html-to-markdown-rs/src/validation.rs +0 -158
  132. data/vendor/html-to-markdown-rs/src/visitor/default_impl.rs +0 -63
  133. data/vendor/html-to-markdown-rs/src/visitor/mod.rs +0 -41
  134. data/vendor/html-to-markdown-rs/src/visitor/traits.rs +0 -370
  135. data/vendor/html-to-markdown-rs/src/visitor/types.rs +0 -319
  136. data/vendor/html-to-markdown-rs/src/visitor_helpers/helpers/callbacks/mod.rs +0 -1
  137. data/vendor/html-to-markdown-rs/src/visitor_helpers/helpers/content.rs +0 -126
  138. data/vendor/html-to-markdown-rs/src/visitor_helpers/helpers/mod.rs +0 -27
  139. data/vendor/html-to-markdown-rs/src/visitor_helpers/helpers/state.rs +0 -110
  140. data/vendor/html-to-markdown-rs/src/visitor_helpers/helpers/traversal.rs +0 -250
  141. data/vendor/html-to-markdown-rs/src/visitor_helpers.rs +0 -597
  142. data/vendor/html-to-markdown-rs/src/wrapper/sync.rs +0 -413
  143. data/vendor/html-to-markdown-rs/src/wrapper/utils.rs +0 -290
  144. data/vendor/html-to-markdown-rs/src/wrapper.rs +0 -9
  145. data/vendor/html-to-markdown-rs/tests/br_in_inline_test.rs +0 -87
  146. data/vendor/html-to-markdown-rs/tests/commonmark_compliance_test.rs +0 -297
  147. data/vendor/html-to-markdown-rs/tests/djot_output_test.rs +0 -153
  148. data/vendor/html-to-markdown-rs/tests/exclude_selectors_test.rs +0 -132
  149. data/vendor/html-to-markdown-rs/tests/integration_test.rs +0 -631
  150. data/vendor/html-to-markdown-rs/tests/issue_121_regressions.rs +0 -49
  151. data/vendor/html-to-markdown-rs/tests/issue_127_regressions.rs +0 -58
  152. data/vendor/html-to-markdown-rs/tests/issue_128_regressions.rs +0 -17
  153. data/vendor/html-to-markdown-rs/tests/issue_131_regressions.rs +0 -41
  154. data/vendor/html-to-markdown-rs/tests/issue_134_regressions.rs +0 -40
  155. data/vendor/html-to-markdown-rs/tests/issue_139_regressions.rs +0 -26
  156. data/vendor/html-to-markdown-rs/tests/issue_140_regressions.rs +0 -185
  157. data/vendor/html-to-markdown-rs/tests/issue_143_regressions.rs +0 -100
  158. data/vendor/html-to-markdown-rs/tests/issue_145_regressions.rs +0 -133
  159. data/vendor/html-to-markdown-rs/tests/issue_146_regressions.rs +0 -144
  160. data/vendor/html-to-markdown-rs/tests/issue_176_regressions.rs +0 -62
  161. data/vendor/html-to-markdown-rs/tests/issue_190_regressions.rs +0 -128
  162. data/vendor/html-to-markdown-rs/tests/issue_199_regressions.rs +0 -20
  163. data/vendor/html-to-markdown-rs/tests/issue_200_regressions.rs +0 -62
  164. data/vendor/html-to-markdown-rs/tests/issue_212_regressions.rs +0 -68
  165. data/vendor/html-to-markdown-rs/tests/issue_216_217_regressions.rs +0 -87
  166. data/vendor/html-to-markdown-rs/tests/issue_336_regressions.rs +0 -74
  167. data/vendor/html-to-markdown-rs/tests/issue_339_regressions.rs +0 -92
  168. data/vendor/html-to-markdown-rs/tests/issue_347_regressions.rs +0 -154
  169. data/vendor/html-to-markdown-rs/tests/issue_348_visitor_plain.rs +0 -93
  170. data/vendor/html-to-markdown-rs/tests/json_ld_script_extraction.rs +0 -44
  171. data/vendor/html-to-markdown-rs/tests/lists_test.rs +0 -199
  172. data/vendor/html-to-markdown-rs/tests/plain_output_test.rs +0 -273
  173. data/vendor/html-to-markdown-rs/tests/preprocessing_tests.rs +0 -61
  174. data/vendor/html-to-markdown-rs/tests/reference_links_test.rs +0 -169
  175. data/vendor/html-to-markdown-rs/tests/sectioning_elements_test.rs +0 -137
  176. data/vendor/html-to-markdown-rs/tests/skip_images_test.rs +0 -522
  177. data/vendor/html-to-markdown-rs/tests/tables_test.rs +0 -743
  178. data/vendor/html-to-markdown-rs/tests/test_custom_elements.rs +0 -41
  179. data/vendor/html-to-markdown-rs/tests/test_issue_187.rs +0 -204
  180. data/vendor/html-to-markdown-rs/tests/test_issue_218.rs +0 -68
  181. data/vendor/html-to-markdown-rs/tests/test_issue_277.rs +0 -77
  182. data/vendor/html-to-markdown-rs/tests/test_max_depth.rs +0 -82
  183. data/vendor/html-to-markdown-rs/tests/test_nested_simple.rs +0 -45
  184. data/vendor/html-to-markdown-rs/tests/test_script_style_stripping.rs +0 -396
  185. data/vendor/html-to-markdown-rs/tests/test_spa_bisect.rs +0 -34
  186. data/vendor/html-to-markdown-rs/tests/visitor_code_integration_test.rs +0 -121
  187. data/vendor/html-to-markdown-rs/tests/visitor_integration_test.rs +0 -1190
  188. data/vendor/html-to-markdown-rs/tests/xml_tables_test.rs +0 -372
@@ -1,1190 +0,0 @@
1
- #![allow(missing_docs)]
2
-
3
- //! Integration tests for the visitor pattern
4
- //!
5
- //! These tests verify that visitor callbacks are properly invoked during
6
- //! HTML→Markdown conversion and that all `VisitResult` variants work correctly.
7
-
8
- #![cfg(feature = "visitor")]
9
-
10
- use html_to_markdown_rs::visitor::{HtmlVisitor, NodeContext, NodeType, VisitResult, VisitorHandle};
11
- use html_to_markdown_rs::{ConversionError, ConversionOptions, ConversionResult};
12
- use std::cell::RefCell;
13
- use std::rc::Rc;
14
-
15
- /// Test shim that bridges the legacy 3-arg call shape used throughout this file
16
- /// onto the public 2-arg `convert(html, options)` API. The visitor (if any) is
17
- /// folded into `options.visitor`.
18
- fn convert(
19
- html: &str,
20
- options: Option<ConversionOptions>,
21
- visitor: Option<VisitorHandle>,
22
- ) -> Result<ConversionResult, ConversionError> {
23
- let mut opts = options.unwrap_or_default();
24
- if visitor.is_some() {
25
- opts.visitor = visitor;
26
- }
27
- html_to_markdown_rs::convert(html, Some(opts))
28
- }
29
-
30
- /// Visitor receives `visit_form` callback even when preprocessing would normally drop the form
31
- #[test]
32
- fn test_visitor_form_custom_overrides_preprocessing() {
33
- #[derive(Debug, Default)]
34
- struct FormVisitor;
35
- impl HtmlVisitor for FormVisitor {
36
- fn visit_form(&mut self, _ctx: &NodeContext, action: Option<&str>, _method: Option<&str>) -> VisitResult {
37
- VisitResult::Custom(format!("[FORM:{}]", action.unwrap_or("none")))
38
- }
39
- }
40
- let html = r#"<div><form action="/submit" method="POST"><label>Name: <input type="text" name="name"></label><button type="submit">Submit</button></form></div>"#;
41
- let visitor: VisitorHandle = Rc::new(RefCell::new(FormVisitor));
42
- let result = convert(html, None, Some(visitor))
43
- .expect("conversion failed")
44
- .content
45
- .unwrap_or_default();
46
- assert!(
47
- result.contains("[FORM:/submit]"),
48
- "Should contain custom form output, got: {result:?}"
49
- );
50
- }
51
-
52
- /// Visitor receives `visit_input` callback for inputs inside a form that preprocessing would drop
53
- #[test]
54
- fn test_visitor_input_custom_inside_preprocessed_form() {
55
- #[derive(Debug, Default)]
56
- struct InputVisitor;
57
- impl HtmlVisitor for InputVisitor {
58
- fn visit_input(
59
- &mut self,
60
- _ctx: &NodeContext,
61
- input_type: &str,
62
- _name: Option<&str>,
63
- _value: Option<&str>,
64
- ) -> VisitResult {
65
- VisitResult::Custom(format!("[INPUT:{input_type}]"))
66
- }
67
- }
68
- let html = r#"<form><input type="text" name="username"></form>"#;
69
- let visitor: VisitorHandle = Rc::new(RefCell::new(InputVisitor));
70
- let result = convert(html, None, Some(visitor))
71
- .expect("conversion failed")
72
- .content
73
- .unwrap_or_default();
74
- assert!(
75
- result.contains("[INPUT:text]"),
76
- "Should contain custom input output, got: {result:?}"
77
- );
78
- }
79
-
80
- /// Visitor `figure_start` and `figure_end` are both called; `figure_end` receives `figure_start` output
81
- #[test]
82
- fn test_visitor_figure_start_end_both_called() {
83
- #[derive(Debug, Default)]
84
- struct FigureVisitor;
85
- impl HtmlVisitor for FigureVisitor {
86
- fn visit_figure_start(&mut self, _ctx: &NodeContext) -> VisitResult {
87
- VisitResult::Custom("[FIGURE_START]".to_string())
88
- }
89
- fn visit_figure_end(&mut self, _ctx: &NodeContext, output: &str) -> VisitResult {
90
- VisitResult::Custom(format!("{output}[/FIGURE]"))
91
- }
92
- }
93
- let html = r#"<figure><img src="test.jpg" alt="test"><figcaption>Caption</figcaption></figure>"#;
94
- let visitor: VisitorHandle = Rc::new(RefCell::new(FigureVisitor));
95
- let result = convert(html, None, Some(visitor))
96
- .expect("conversion failed")
97
- .content
98
- .unwrap_or_default();
99
- assert!(
100
- result.contains("[FIGURE_START]"),
101
- "Should contain figure start marker, got: {result:?}"
102
- );
103
- assert!(
104
- result.contains("[/FIGURE]"),
105
- "Should contain figure end marker, got: {result:?}"
106
- );
107
- }
108
-
109
- /// Test visitor that customizes all output
110
- #[derive(Debug, Default)]
111
- struct CustomizingVisitor;
112
-
113
- impl HtmlVisitor for CustomizingVisitor {
114
- fn visit_text(&mut self, _ctx: &NodeContext, text: &str) -> VisitResult {
115
- VisitResult::Custom(format!("[TEXT:{text}]"))
116
- }
117
-
118
- fn visit_link(&mut self, _ctx: &NodeContext, href: &str, text: &str, _title: Option<&str>) -> VisitResult {
119
- VisitResult::Custom(format!("[LINK:{text} -> {href}]"))
120
- }
121
-
122
- fn visit_image(&mut self, _ctx: &NodeContext, src: &str, alt: &str, _title: Option<&str>) -> VisitResult {
123
- VisitResult::Custom(format!("[IMAGE:{alt} @ {src}]"))
124
- }
125
-
126
- fn visit_heading(&mut self, _ctx: &NodeContext, level: u32, text: &str, _id: Option<&str>) -> VisitResult {
127
- VisitResult::Custom(format!("[H{level}: {text}]"))
128
- }
129
- }
130
-
131
- /// Test visitor that skips certain elements
132
- #[derive(Debug, Default)]
133
- struct SkippingVisitor {
134
- skip_images: bool,
135
- skip_links: bool,
136
- }
137
-
138
- impl HtmlVisitor for SkippingVisitor {
139
- fn visit_link(&mut self, _ctx: &NodeContext, _href: &str, _text: &str, _title: Option<&str>) -> VisitResult {
140
- if self.skip_links {
141
- VisitResult::Skip
142
- } else {
143
- VisitResult::Continue
144
- }
145
- }
146
-
147
- fn visit_image(&mut self, _ctx: &NodeContext, _src: &str, _alt: &str, _title: Option<&str>) -> VisitResult {
148
- if self.skip_images {
149
- VisitResult::Skip
150
- } else {
151
- VisitResult::Continue
152
- }
153
- }
154
- }
155
-
156
- /// Test visitor that preserves HTML for certain elements
157
- #[derive(Debug, Default)]
158
- struct PreservingVisitor {
159
- preserve_links: bool,
160
- }
161
-
162
- impl HtmlVisitor for PreservingVisitor {
163
- fn visit_link(&mut self, _ctx: &NodeContext, _href: &str, _text: &str, _title: Option<&str>) -> VisitResult {
164
- if self.preserve_links {
165
- VisitResult::PreserveHtml
166
- } else {
167
- VisitResult::Continue
168
- }
169
- }
170
- }
171
-
172
- /// Test visitor that validates node context
173
- #[derive(Debug, Default)]
174
- struct ContextCheckingVisitor {
175
- saw_heading_with_id: bool,
176
- }
177
-
178
- impl HtmlVisitor for ContextCheckingVisitor {
179
- fn visit_heading(&mut self, ctx: &NodeContext, _level: u32, _text: &str, _id: Option<&str>) -> VisitResult {
180
- assert_eq!(ctx.node_type, NodeType::Heading);
181
- assert_eq!(ctx.tag_name, "h1");
182
-
183
- if ctx.attributes.contains_key("id") {
184
- self.saw_heading_with_id = true;
185
- }
186
-
187
- VisitResult::Continue
188
- }
189
- }
190
-
191
- #[test]
192
- fn test_custom_visitor_transforms_text() {
193
- let html = r"<p>Hello world</p>";
194
- let visitor = Rc::new(RefCell::new(CustomizingVisitor));
195
-
196
- let result = convert(html, None, Some(visitor))
197
- .expect("conversion failed")
198
- .content
199
- .unwrap_or_default();
200
-
201
- assert!(result.contains("[TEXT:"), "Should contain custom text format");
202
- }
203
-
204
- #[test]
205
- fn test_custom_visitor_transforms_links() {
206
- let html = r#"<a href="https://example.com">Example</a>"#;
207
- let visitor = Rc::new(RefCell::new(CustomizingVisitor));
208
-
209
- let result = convert(html, None, Some(visitor))
210
- .expect("conversion failed")
211
- .content
212
- .unwrap_or_default();
213
-
214
- assert!(
215
- result.contains("[LINK:Example -> https://example.com]"),
216
- "Should contain custom link format, got: {result}"
217
- );
218
- }
219
-
220
- #[test]
221
- fn test_custom_visitor_transforms_images() {
222
- let html = r#"<img src="/test.png" alt="Test">"#;
223
- let visitor = Rc::new(RefCell::new(CustomizingVisitor));
224
-
225
- let result = convert(html, None, Some(visitor))
226
- .expect("conversion failed")
227
- .content
228
- .unwrap_or_default();
229
-
230
- assert!(
231
- result.contains("[IMAGE:Test @ /test.png]"),
232
- "Should contain custom image format, got: {result}"
233
- );
234
- }
235
-
236
- #[test]
237
- fn test_custom_visitor_transforms_headings() {
238
- let html = r"<h2>My Heading</h2>";
239
- let visitor = Rc::new(RefCell::new(CustomizingVisitor));
240
-
241
- let result = convert(html, None, Some(visitor))
242
- .expect("conversion failed")
243
- .content
244
- .unwrap_or_default();
245
-
246
- assert!(
247
- result.contains("[H2: My Heading]"),
248
- "Should contain custom heading format, got: {result}"
249
- );
250
- }
251
-
252
- #[test]
253
- fn test_skipping_visitor_removes_links() {
254
- let html = r#"<p>Text with <a href="https://example.com">a link</a> inside.</p>"#;
255
- let visitor = Rc::new(RefCell::new(SkippingVisitor {
256
- skip_links: true,
257
- skip_images: false,
258
- }));
259
-
260
- let result = convert(html, None, Some(visitor))
261
- .expect("conversion failed")
262
- .content
263
- .unwrap_or_default();
264
-
265
- assert!(
266
- !result.contains("example.com"),
267
- "Should not contain link URL when skipped, got: {result}"
268
- );
269
- }
270
-
271
- #[test]
272
- fn test_skipping_visitor_removes_images() {
273
- let html = r#"<p>Text <img src="/test.png" alt="Test"> more text</p>"#;
274
- let visitor = Rc::new(RefCell::new(SkippingVisitor {
275
- skip_links: false,
276
- skip_images: true,
277
- }));
278
-
279
- let result = convert(html, None, Some(visitor))
280
- .expect("conversion failed")
281
- .content
282
- .unwrap_or_default();
283
-
284
- assert!(
285
- !result.contains("test.png") && !result.contains("!["),
286
- "Should not contain image when skipped, got: {result}"
287
- );
288
- }
289
-
290
- #[test]
291
- fn test_preserving_visitor_keeps_html() {
292
- let html = r#"<a href="https://example.com" class="special">Example</a>"#;
293
- let visitor = Rc::new(RefCell::new(PreservingVisitor { preserve_links: true }));
294
-
295
- let result = convert(html, None, Some(visitor))
296
- .expect("conversion failed")
297
- .content
298
- .unwrap_or_default();
299
-
300
- assert!(
301
- result.contains("<a") && result.contains("href"),
302
- "Should preserve HTML tags when PreserveHtml is returned, got: {result}"
303
- );
304
- }
305
-
306
- #[test]
307
- fn test_visitor_receives_node_context() {
308
- let html = r#"<h1 id="title" class="main">Title</h1>"#;
309
- let visitor = Rc::new(RefCell::new(ContextCheckingVisitor::default()));
310
-
311
- let _result = convert(html, None, Some(visitor)).expect("conversion failed");
312
- }
313
-
314
- #[test]
315
- fn test_visitor_works_with_complex_document() {
316
- let html = r#"
317
- <!DOCTYPE html>
318
- <html>
319
- <head><title>Test</title></head>
320
- <body>
321
- <h1>Main Title</h1>
322
- <p>Introduction with <a href="/link">a link</a>.</p>
323
- <h2>Section</h2>
324
- <p>Text with <strong>bold</strong> and <em>italic</em>.</p>
325
- <img src="/image.png" alt="Diagram">
326
- <h3>Subsection</h3>
327
- <p>More content.</p>
328
- </body>
329
- </html>
330
- "#;
331
-
332
- let visitor = Rc::new(RefCell::new(CustomizingVisitor));
333
-
334
- let result = convert(html, None, Some(visitor))
335
- .expect("conversion failed")
336
- .content
337
- .unwrap_or_default();
338
-
339
- assert!(result.contains("[H1:"));
340
- assert!(result.contains("[H2:"));
341
- assert!(result.contains("[H3:"));
342
- assert!(result.contains("[LINK:"));
343
- assert!(result.contains("[IMAGE:"));
344
- assert!(result.contains("[TEXT:"));
345
- }
346
-
347
- #[test]
348
- fn test_visitor_with_conversion_options() {
349
- #[derive(Debug, Default)]
350
- struct ContinueVisitor;
351
-
352
- impl HtmlVisitor for ContinueVisitor {}
353
-
354
- let html = r"<h1>Title</h1><p>Text with *asterisks* and _underscores_.</p>";
355
-
356
- let options = ConversionOptions {
357
- escape_asterisks: true,
358
- escape_underscores: true,
359
- ..Default::default()
360
- };
361
-
362
- let visitor = Rc::new(RefCell::new(ContinueVisitor));
363
-
364
- let result = convert(html, Some(options), Some(visitor))
365
- .expect("conversion failed")
366
- .content
367
- .unwrap_or_default();
368
-
369
- assert!(
370
- result.contains(r"\*") || result.contains(r"\_"),
371
- "Should respect escape options with visitor, got: {result}"
372
- );
373
- }
374
-
375
- #[test]
376
- fn test_visitor_continue_result_produces_default_markdown() {
377
- #[derive(Debug, Default)]
378
- struct ContinueVisitor;
379
-
380
- impl HtmlVisitor for ContinueVisitor {
381
- fn visit_heading(&mut self, _ctx: &NodeContext, _level: u32, _text: &str, _id: Option<&str>) -> VisitResult {
382
- VisitResult::Continue
383
- }
384
- }
385
-
386
- let html = r"<h1>Title</h1>";
387
- let visitor = Rc::new(RefCell::new(ContinueVisitor));
388
-
389
- let result = convert(html, None, Some(visitor))
390
- .expect("conversion failed")
391
- .content
392
- .unwrap_or_default();
393
-
394
- assert!(
395
- result.contains("# Title"),
396
- "Continue should produce default markdown, got: {result}"
397
- );
398
- }
399
-
400
- #[test]
401
- fn test_visitor_skip_vs_continue() {
402
- #[derive(Debug)]
403
- struct SelectiveSkipper {
404
- skip_first_link: bool,
405
- }
406
-
407
- impl HtmlVisitor for SelectiveSkipper {
408
- fn visit_link(&mut self, _ctx: &NodeContext, _href: &str, _text: &str, _title: Option<&str>) -> VisitResult {
409
- if self.skip_first_link {
410
- self.skip_first_link = false;
411
- VisitResult::Skip
412
- } else {
413
- VisitResult::Continue
414
- }
415
- }
416
- }
417
-
418
- let html = r#"<p><a href="/first">First</a> and <a href="/second">Second</a></p>"#;
419
- let visitor = Rc::new(RefCell::new(SelectiveSkipper { skip_first_link: true }));
420
-
421
- let result = convert(html, None, Some(visitor))
422
- .expect("conversion failed")
423
- .content
424
- .unwrap_or_default();
425
-
426
- assert!(!result.contains("/first"));
427
- assert!(result.contains("/second"));
428
- }
429
-
430
- #[test]
431
- fn test_multiple_elements_of_same_type() {
432
- let html = r"<h1>First</h1><h2>Second</h2><h3>Third</h3>";
433
- let visitor = Rc::new(RefCell::new(CustomizingVisitor));
434
-
435
- let result = convert(html, None, Some(visitor))
436
- .expect("conversion failed")
437
- .content
438
- .unwrap_or_default();
439
-
440
- assert!(result.contains("[H1: First]"));
441
- assert!(result.contains("[H2: Second]"));
442
- assert!(result.contains("[H3: Third]"));
443
- }
444
-
445
- #[test]
446
- fn test_nested_elements_invoke_visitor() {
447
- let html = r#"<p>Text with <a href="/url">a <strong>bold</strong> link</a></p>"#;
448
- let visitor = Rc::new(RefCell::new(CustomizingVisitor));
449
-
450
- let result = convert(html, None, Some(visitor))
451
- .expect("conversion failed")
452
- .content
453
- .unwrap_or_default();
454
-
455
- assert!(result.contains("[TEXT:"));
456
- assert!(result.contains("[LINK:"));
457
- }
458
-
459
- #[test]
460
- fn test_visitor_error_stops_conversion() {
461
- #[derive(Debug, Default)]
462
- struct ErrorVisitor;
463
-
464
- impl HtmlVisitor for ErrorVisitor {
465
- fn visit_text(&mut self, _ctx: &NodeContext, _text: &str) -> VisitResult {
466
- VisitResult::Error("test error".to_string())
467
- }
468
- }
469
-
470
- let html = "<p>text</p>";
471
- let visitor = Rc::new(RefCell::new(ErrorVisitor));
472
- let result = convert(html, None, Some(visitor));
473
-
474
- assert!(result.is_err(), "Should return error when visitor returns Error");
475
- assert!(
476
- result.unwrap_err().to_string().contains("test error"),
477
- "Error message should contain visitor's error"
478
- );
479
- }
480
-
481
- #[test]
482
- fn test_visitor_code_block() {
483
- #[derive(Debug, Default)]
484
- struct CodeBlockVisitor;
485
-
486
- impl HtmlVisitor for CodeBlockVisitor {
487
- fn visit_code_block(&mut self, _ctx: &NodeContext, language: Option<&str>, code: &str) -> VisitResult {
488
- let lang = language.unwrap_or("text");
489
- VisitResult::Custom(format!("[CODE_BLOCK:{} -> {}]", lang, code.trim()))
490
- }
491
- }
492
-
493
- let html = r#"<pre><code class="language-rust">fn main() {}</code></pre>"#;
494
- let visitor = Rc::new(RefCell::new(CodeBlockVisitor));
495
- let result = convert(html, None, Some(visitor))
496
- .expect("conversion failed")
497
- .content
498
- .unwrap_or_default();
499
-
500
- assert!(
501
- result.contains("[CODE_BLOCK:rust -> fn main() {}]"),
502
- "Should contain custom code block format, got: {result}"
503
- );
504
- }
505
-
506
- #[test]
507
- fn test_visitor_code_inline() {
508
- #[derive(Debug, Default)]
509
- struct InlineCodeVisitor;
510
-
511
- impl HtmlVisitor for InlineCodeVisitor {
512
- fn visit_code_inline(&mut self, _ctx: &NodeContext, code: &str) -> VisitResult {
513
- VisitResult::Custom(format!("[CODE:{code}]"))
514
- }
515
- }
516
-
517
- let html = r"<p>Use <code>println!</code> macro</p>";
518
- let visitor = Rc::new(RefCell::new(InlineCodeVisitor));
519
- let result = convert(html, None, Some(visitor))
520
- .expect("conversion failed")
521
- .content
522
- .unwrap_or_default();
523
-
524
- assert!(
525
- result.contains("[CODE:println!]"),
526
- "Should contain custom inline code format, got: {result}"
527
- );
528
- }
529
-
530
- #[test]
531
- fn test_visitor_list_callbacks() {
532
- #[derive(Debug, Default)]
533
- struct ListVisitor {
534
- list_depth: usize,
535
- }
536
-
537
- impl HtmlVisitor for ListVisitor {
538
- fn visit_list_start(&mut self, _ctx: &NodeContext, ordered: bool) -> VisitResult {
539
- self.list_depth += 1;
540
- VisitResult::Custom(format!(
541
- "[LIST_START:{}:{}]",
542
- if ordered { "OL" } else { "UL" },
543
- self.list_depth
544
- ))
545
- }
546
-
547
- fn visit_list_item(&mut self, _ctx: &NodeContext, _ordered: bool, _marker: &str, text: &str) -> VisitResult {
548
- VisitResult::Custom(format!("[LI:{}:{}]", self.list_depth, text.trim()))
549
- }
550
-
551
- fn visit_list_end(&mut self, _ctx: &NodeContext, _ordered: bool, _output: &str) -> VisitResult {
552
- let result = VisitResult::Custom(format!("[LIST_END:{}]", self.list_depth));
553
- self.list_depth = self.list_depth.saturating_sub(1);
554
- result
555
- }
556
- }
557
-
558
- let html = r"<ul><li>First</li><li>Second</li></ul>";
559
- let visitor = Rc::new(RefCell::new(ListVisitor::default()));
560
- let result = convert(html, None, Some(visitor))
561
- .expect("conversion failed")
562
- .content
563
- .unwrap_or_default();
564
-
565
- assert!(
566
- result.contains("[LIST_START:UL:1]"),
567
- "Should see list start, got: {result}"
568
- );
569
- assert!(result.contains("[LI:1:First]"), "Should see first item, got: {result}");
570
- assert!(
571
- result.contains("[LI:1:Second]"),
572
- "Should see second item, got: {result}"
573
- );
574
- assert!(result.contains("[LIST_END:1]"), "Should see list end, got: {result}");
575
- }
576
-
577
- #[test]
578
- fn test_visitor_table_callbacks() {
579
- #[derive(Debug, Default)]
580
- struct TableVisitor {
581
- row_count: usize,
582
- }
583
-
584
- impl HtmlVisitor for TableVisitor {
585
- fn visit_table_start(&mut self, _ctx: &NodeContext) -> VisitResult {
586
- self.row_count = 0;
587
- VisitResult::Custom("[TABLE_START]".to_string())
588
- }
589
-
590
- fn visit_table_row(&mut self, _ctx: &NodeContext, cells: &[String], is_header: bool) -> VisitResult {
591
- self.row_count += 1;
592
- VisitResult::Custom(format!(
593
- "[ROW:{}:{}:{}]",
594
- if is_header { "HEADER" } else { "DATA" },
595
- self.row_count,
596
- cells.join("|")
597
- ))
598
- }
599
-
600
- fn visit_table_end(&mut self, _ctx: &NodeContext, _output: &str) -> VisitResult {
601
- VisitResult::Custom(format!("[TABLE_END:{}]", self.row_count))
602
- }
603
- }
604
-
605
- let html = r"<table><tr><th>Name</th><th>Age</th></tr><tr><td>Alice</td><td>30</td></tr></table>";
606
- let visitor = Rc::new(RefCell::new(TableVisitor::default()));
607
- let result = convert(html, None, Some(visitor))
608
- .expect("conversion failed")
609
- .content
610
- .unwrap_or_default();
611
-
612
- assert!(
613
- result.contains("[TABLE_START]"),
614
- "Should see table start, got: {result}"
615
- );
616
- assert!(
617
- result.contains("[ROW:HEADER:1:Name|Age]"),
618
- "Should see header row, got: {result}"
619
- );
620
- assert!(
621
- result.contains("[ROW:DATA:2:Alice|30]"),
622
- "Should see data row, got: {result}"
623
- );
624
- assert!(result.contains("[TABLE_END:2]"), "Should see table end, got: {result}");
625
- }
626
-
627
- #[test]
628
- fn test_visitor_blockquote() {
629
- #[derive(Debug, Default)]
630
- struct BlockquoteVisitor;
631
-
632
- impl HtmlVisitor for BlockquoteVisitor {
633
- fn visit_blockquote(&mut self, _ctx: &NodeContext, content: &str, _depth: usize) -> VisitResult {
634
- VisitResult::Custom(format!("[QUOTE:{}]", content.trim()))
635
- }
636
- }
637
-
638
- let html = r"<blockquote>This is a quote</blockquote>";
639
- let visitor = Rc::new(RefCell::new(BlockquoteVisitor));
640
- let result = convert(html, None, Some(visitor))
641
- .expect("conversion failed")
642
- .content
643
- .unwrap_or_default();
644
-
645
- assert!(
646
- result.contains("[QUOTE:This is a quote]"),
647
- "Should contain custom blockquote format, got: {result}"
648
- );
649
- }
650
-
651
- #[test]
652
- fn test_visitor_inline_formatting() {
653
- #[derive(Debug, Default)]
654
- struct FormattingVisitor;
655
-
656
- impl HtmlVisitor for FormattingVisitor {
657
- fn visit_strong(&mut self, _ctx: &NodeContext, text: &str) -> VisitResult {
658
- VisitResult::Custom(format!("[STRONG:{text}]"))
659
- }
660
-
661
- fn visit_emphasis(&mut self, _ctx: &NodeContext, text: &str) -> VisitResult {
662
- VisitResult::Custom(format!("[EM:{text}]"))
663
- }
664
-
665
- fn visit_strikethrough(&mut self, _ctx: &NodeContext, text: &str) -> VisitResult {
666
- VisitResult::Custom(format!("[DEL:{text}]"))
667
- }
668
- }
669
-
670
- let html = r"<p><strong>bold</strong> <em>italic</em> <del>struck</del></p>";
671
- let visitor = Rc::new(RefCell::new(FormattingVisitor));
672
- let result = convert(html, None, Some(visitor))
673
- .expect("conversion failed")
674
- .content
675
- .unwrap_or_default();
676
-
677
- assert!(result.contains("[STRONG:bold]"), "Should see strong, got: {result}");
678
- assert!(result.contains("[EM:italic]"), "Should see emphasis, got: {result}");
679
- assert!(
680
- result.contains("[DEL:struck]"),
681
- "Should see strikethrough, got: {result}"
682
- );
683
- }
684
-
685
- #[test]
686
- fn test_no_double_visit_in_links() {
687
- #[derive(Debug, Default)]
688
- struct CountingVisitor {
689
- text_visits: usize,
690
- }
691
-
692
- impl HtmlVisitor for CountingVisitor {
693
- fn visit_text(&mut self, _ctx: &NodeContext, _text: &str) -> VisitResult {
694
- self.text_visits += 1;
695
- VisitResult::Continue
696
- }
697
-
698
- fn visit_link(&mut self, _ctx: &NodeContext, _href: &str, _text: &str, _title: Option<&str>) -> VisitResult {
699
- VisitResult::Continue
700
- }
701
- }
702
-
703
- let html = r#"<a href="/url">link text</a>"#;
704
- let visitor = Rc::new(RefCell::new(CountingVisitor::default()));
705
- let _result = convert(html, None, Some(visitor.clone())).expect("conversion failed");
706
-
707
- assert_eq!(
708
- visitor.borrow().text_visits,
709
- 1,
710
- "Text nodes inside links should only be visited once, got {} visits",
711
- visitor.borrow().text_visits
712
- );
713
- }
714
-
715
- #[test]
716
- fn test_no_double_visit_in_headings() {
717
- #[derive(Debug, Default)]
718
- struct CountingVisitor {
719
- text_visits: usize,
720
- }
721
-
722
- impl HtmlVisitor for CountingVisitor {
723
- fn visit_text(&mut self, _ctx: &NodeContext, _text: &str) -> VisitResult {
724
- self.text_visits += 1;
725
- VisitResult::Continue
726
- }
727
-
728
- fn visit_heading(&mut self, _ctx: &NodeContext, _level: u32, _text: &str, _id: Option<&str>) -> VisitResult {
729
- VisitResult::Continue
730
- }
731
- }
732
-
733
- let html = r"<h1>heading text</h1>";
734
- let visitor = Rc::new(RefCell::new(CountingVisitor::default()));
735
- let _result = convert(html, None, Some(visitor.clone())).expect("conversion failed");
736
-
737
- assert_eq!(
738
- visitor.borrow().text_visits,
739
- 1,
740
- "Text nodes inside headings should only be visited once, got {} visits",
741
- visitor.borrow().text_visits
742
- );
743
- }
744
-
745
- // ============================================================================
746
- // Integration tests: Visitor + Feature combinations
747
- // ============================================================================
748
-
749
- /// Test that visitor callbacks work correctly when `skip_images` option is enabled
750
- #[test]
751
- fn test_visitor_with_skip_images() {
752
- #[derive(Debug, Default)]
753
- struct SkipImageVisitor {
754
- image_visits: usize,
755
- }
756
-
757
- impl HtmlVisitor for SkipImageVisitor {
758
- fn visit_image(&mut self, _ctx: &NodeContext, _src: &str, _alt: &str, _title: Option<&str>) -> VisitResult {
759
- self.image_visits += 1;
760
- VisitResult::Continue
761
- }
762
- }
763
-
764
- let html = r#"
765
- <p>Some text</p>
766
- <img src="/image1.png" alt="Image 1">
767
- <img src="/image2.png" alt="Image 2">
768
- <p>More text</p>
769
- "#;
770
-
771
- // Test with skip_images enabled and visitor
772
- let options = ConversionOptions {
773
- skip_images: true,
774
- ..Default::default()
775
- };
776
-
777
- let visitor = Rc::new(RefCell::new(SkipImageVisitor::default()));
778
- let result = convert(html, Some(options), Some(visitor))
779
- .expect("conversion with skip_images and visitor should succeed")
780
- .content
781
- .unwrap_or_default();
782
-
783
- // When skip_images is true, images should not appear in output
784
- assert!(
785
- !result.contains("!["),
786
- "skip_images should prevent image markdown in output, got: {result}"
787
- );
788
- assert!(
789
- !result.contains("image1.png"),
790
- "skip_images should prevent image src in output, got: {result}"
791
- );
792
-
793
- // When skip_images is true, the conversion still happens correctly
794
- // Images are filtered at the conversion level based on the option
795
- // This verifies that skip_images option and visitor parameters work together
796
- // without conflicts - both are optional and can be combined
797
- assert!(
798
- result.contains("Some text") && result.contains("More text"),
799
- "Other content should still be present in output, got: {result}"
800
- );
801
- }
802
-
803
- /// Test that the main `convert()` function accepts optional visitor parameter
804
- #[test]
805
- fn test_convert_accepts_visitor_parameter() {
806
- #[derive(Debug, Default)]
807
- struct CountingVisitor {
808
- text_count: usize,
809
- link_count: usize,
810
- }
811
-
812
- impl HtmlVisitor for CountingVisitor {
813
- fn visit_text(&mut self, _ctx: &NodeContext, _text: &str) -> VisitResult {
814
- self.text_count += 1;
815
- VisitResult::Continue
816
- }
817
-
818
- fn visit_link(&mut self, _ctx: &NodeContext, _href: &str, _text: &str, _title: Option<&str>) -> VisitResult {
819
- self.link_count += 1;
820
- VisitResult::Continue
821
- }
822
- }
823
-
824
- let html = r#"<p>Visit <a href="https://example.com">our site</a> for more info.</p>"#;
825
- let visitor = Rc::new(RefCell::new(CountingVisitor::default()));
826
-
827
- // Test using the main convert() function with visitor parameter
828
- let _result = convert(html, None, Some(visitor.clone())).expect("convert with visitor should work");
829
-
830
- let borrowed = visitor.borrow();
831
- assert!(
832
- borrowed.text_count >= 2,
833
- "Should visit text nodes, got {} visits",
834
- borrowed.text_count
835
- );
836
- assert_eq!(
837
- borrowed.link_count, 1,
838
- "Should visit exactly 1 link, got {}",
839
- borrowed.link_count
840
- );
841
- }
842
-
843
- /// Test visitor + `inline_images` feature combination
844
- ///
845
- /// In v3, `convert()` handles inline-image extraction via `ConversionResult.images`,
846
- /// and `convert_with_visitor()` handles visitor callbacks. We verify both paths
847
- /// work on the same HTML.
848
- #[cfg(feature = "inline-images")]
849
- #[test]
850
- fn test_convert_with_inline_images_accepts_visitor() {
851
- #[derive(Debug, Default)]
852
- struct ImageTrackingVisitor {
853
- images_seen: usize,
854
- }
855
-
856
- impl HtmlVisitor for ImageTrackingVisitor {
857
- fn visit_image(&mut self, _ctx: &NodeContext, src: &str, _alt: &str, _title: Option<&str>) -> VisitResult {
858
- if !src.starts_with("data:") {
859
- self.images_seen += 1;
860
- }
861
- VisitResult::Continue
862
- }
863
- }
864
-
865
- let html = r#"
866
- <h1>Test Page</h1>
867
- <img src="/image.png" alt="Test Image">
868
- <p>Some content</p>
869
- "#;
870
-
871
- // Verify visitor callbacks fire via convert_with_visitor
872
- let visitor = Rc::new(RefCell::new(ImageTrackingVisitor::default()));
873
- let markdown = convert(html, None, Some(visitor.clone()))
874
- .expect("convert should work")
875
- .content
876
- .unwrap_or_default();
877
-
878
- assert_eq!(
879
- visitor.borrow().images_seen,
880
- 1,
881
- "Visitor should count 1 non-data-uri image"
882
- );
883
-
884
- // Markdown should still be generated
885
- assert!(!markdown.is_empty(), "Should produce markdown output");
886
- }
887
-
888
- /// Test visitor + metadata: visitor callbacks fire and metadata is collected.
889
- ///
890
- /// In v3, `convert()` always extracts metadata into `ConversionResult.metadata`,
891
- /// and `convert_with_visitor()` handles visitor callbacks. We verify both paths
892
- /// work on the same HTML.
893
- #[cfg(feature = "metadata")]
894
- #[test]
895
- fn test_visitor_and_metadata_both_work() {
896
- #[derive(Debug, Default)]
897
- struct MetadataAwareVisitor {
898
- heading_count: usize,
899
- link_count: usize,
900
- }
901
-
902
- impl HtmlVisitor for MetadataAwareVisitor {
903
- fn visit_heading(&mut self, _ctx: &NodeContext, _level: u32, _text: &str, _id: Option<&str>) -> VisitResult {
904
- self.heading_count += 1;
905
- VisitResult::Continue
906
- }
907
-
908
- fn visit_link(&mut self, _ctx: &NodeContext, _href: &str, _text: &str, _title: Option<&str>) -> VisitResult {
909
- self.link_count += 1;
910
- VisitResult::Continue
911
- }
912
- }
913
-
914
- let html = r#"
915
- <html>
916
- <head><title>Test Page</title></head>
917
- <body>
918
- <h1>Main Title</h1>
919
- <p>Visit <a href="https://example.com">our site</a>.</p>
920
- <h2>Section</h2>
921
- <p>More <a href="/page">links</a> here.</p>
922
- </body>
923
- </html>
924
- "#;
925
-
926
- // Verify visitor callbacks fire via convert_with_visitor
927
- let visitor = Rc::new(RefCell::new(MetadataAwareVisitor::default()));
928
- let markdown = convert(html, None, Some(visitor.clone()))
929
- .expect("convert should work")
930
- .content
931
- .unwrap_or_default();
932
-
933
- let borrowed = visitor.borrow();
934
- assert!(
935
- borrowed.heading_count >= 2,
936
- "Visitor should see at least 2 headings, got {}",
937
- borrowed.heading_count
938
- );
939
- assert_eq!(
940
- borrowed.link_count, 2,
941
- "Visitor should see 2 links, got {}",
942
- borrowed.link_count
943
- );
944
- assert!(!markdown.is_empty(), "Should produce markdown output");
945
- drop(borrowed);
946
-
947
- // Verify metadata extraction via convert()
948
- let result = html_to_markdown_rs::convert(html, None).expect("convert should work");
949
- let metadata = result.metadata;
950
-
951
- assert_eq!(
952
- metadata.document.title,
953
- Some("Test Page".to_string()),
954
- "Metadata should extract title"
955
- );
956
- assert!(
957
- metadata.headers.len() >= 2,
958
- "Metadata should extract at least 2 headers, got {}",
959
- metadata.headers.len()
960
- );
961
- assert_eq!(
962
- metadata.links.len(),
963
- 2,
964
- "Metadata should extract 2 links, got {}",
965
- metadata.links.len()
966
- );
967
- }
968
-
969
- /// Test visitor + both `inline_images` and `metadata` features together
970
- ///
971
- /// In v3, `convert()` handles metadata and inline-image extraction via `ConversionResult`,
972
- /// and `convert_with_visitor()` handles visitor callbacks. We verify both paths
973
- /// work on the same HTML.
974
- #[cfg(all(feature = "inline-images", feature = "metadata"))]
975
- #[test]
976
- fn test_convert_with_all_features_and_visitor() {
977
- #[derive(Debug, Default)]
978
- struct ComprehensiveVisitor {
979
- headings: usize,
980
- images: usize,
981
- links: usize,
982
- }
983
-
984
- impl HtmlVisitor for ComprehensiveVisitor {
985
- fn visit_heading(&mut self, _ctx: &NodeContext, _level: u32, _text: &str, _id: Option<&str>) -> VisitResult {
986
- self.headings += 1;
987
- VisitResult::Continue
988
- }
989
-
990
- fn visit_image(&mut self, _ctx: &NodeContext, _src: &str, _alt: &str, _title: Option<&str>) -> VisitResult {
991
- self.images += 1;
992
- VisitResult::Continue
993
- }
994
-
995
- fn visit_link(&mut self, _ctx: &NodeContext, _href: &str, _text: &str, _title: Option<&str>) -> VisitResult {
996
- self.links += 1;
997
- VisitResult::Continue
998
- }
999
- }
1000
-
1001
- let html = r#"
1002
- <html>
1003
- <body>
1004
- <h1>Gallery</h1>
1005
- <img src="/gallery/image1.jpg" alt="Pic 1">
1006
- <p>See <a href="/more">more</a> content.</p>
1007
- <h2>Details</h2>
1008
- <img src="/gallery/image2.jpg" alt="Pic 2">
1009
- <p>Check <a href="/details">this link</a>.</p>
1010
- </body>
1011
- </html>
1012
- "#;
1013
-
1014
- // Verify visitor callbacks fire via convert_with_visitor
1015
- let visitor = Rc::new(RefCell::new(ComprehensiveVisitor::default()));
1016
- let markdown = convert(html, None, Some(visitor.clone()))
1017
- .expect("convert should work")
1018
- .content
1019
- .unwrap_or_default();
1020
-
1021
- // Verify all visitor callbacks were invoked
1022
- let borrowed = visitor.borrow();
1023
- assert!(
1024
- borrowed.headings >= 2,
1025
- "Visitor should see at least 2 headings, got {}",
1026
- borrowed.headings
1027
- );
1028
- assert_eq!(
1029
- borrowed.images, 2,
1030
- "Visitor should see 2 images, got {}",
1031
- borrowed.images
1032
- );
1033
- assert_eq!(borrowed.links, 2, "Visitor should see 2 links, got {}", borrowed.links);
1034
- drop(borrowed);
1035
-
1036
- // Verify markdown was produced
1037
- assert!(!markdown.is_empty(), "Should produce markdown output");
1038
- }
1039
-
1040
- /// Regression test: image visitor returning Custom with metadata extraction used to panic
1041
- /// with an out-of-bounds slice.
1042
- ///
1043
- /// When metadata extraction prepends a YAML frontmatter block to `output`, every element's
1044
- /// saved `element_output_start` is offset by the frontmatter length. If a child visitor
1045
- /// then returns Custom and truncates the buffer, the parent's saved offset can point
1046
- /// past `output.len()`.
1047
- #[test]
1048
- fn test_image_visitor_with_metadata_does_not_panic() {
1049
- #[derive(Debug)]
1050
- struct ImageVisitor;
1051
-
1052
- impl HtmlVisitor for ImageVisitor {
1053
- fn visit_image(&mut self, _ctx: &NodeContext, _src: &str, _alt: &str, _title: Option<&str>) -> VisitResult {
1054
- VisitResult::Custom("![img](rewritten.png)".to_string())
1055
- }
1056
- }
1057
-
1058
- let html = r#"<html><head><meta name="description" content="x"></head><body><p><img src="a.png" alt="a"></p></body></html>"#;
1059
- let options = ConversionOptions {
1060
- extract_metadata: true,
1061
- ..Default::default()
1062
- };
1063
-
1064
- let result = convert(html, Some(options), Some(Rc::new(RefCell::new(ImageVisitor))));
1065
- assert!(result.is_ok(), "conversion panicked or errored: {:?}", result.err());
1066
- }
1067
-
1068
- /// Regression test: `visit_element_end` returning Custom/Skip with metadata extraction used
1069
- /// to produce stale parent offsets and either panic or silently drop subsequent content.
1070
- #[test]
1071
- fn test_element_end_replacement_with_metadata_preserves_subsequent_content() {
1072
- #[derive(Debug)]
1073
- struct FigureReplacingVisitor;
1074
-
1075
- impl HtmlVisitor for FigureReplacingVisitor {
1076
- fn visit_element_end(&mut self, ctx: &NodeContext, _content: &str) -> VisitResult {
1077
- if ctx.tag_name == "figure" {
1078
- return VisitResult::Custom("[figure]".to_string());
1079
- }
1080
- VisitResult::Continue
1081
- }
1082
- }
1083
-
1084
- let html = r#"<html><head><meta name="description" content="x"></head><body><figure><img src="a.png"></figure><p>after</p></body></html>"#;
1085
- let options = ConversionOptions {
1086
- extract_metadata: true,
1087
- ..Default::default()
1088
- };
1089
-
1090
- let result = convert(html, Some(options), Some(Rc::new(RefCell::new(FigureReplacingVisitor))));
1091
- assert!(result.is_ok(), "conversion panicked or errored: {:?}", result.err());
1092
- assert!(
1093
- result.unwrap().content.unwrap_or_default().contains("after"),
1094
- "content after replaced element should not be lost"
1095
- );
1096
- }
1097
-
1098
- /// Regression test for issue #331: visitor receives mismatched start/end events for
1099
- /// hyphenated tag names that contain XML-style self-closing children.
1100
- ///
1101
- /// When `<ac:parameter ac:name="foo" />` appears inside a hyphenated custom element, the
1102
- /// `repair_with_html5ever` fallback (triggered because the outer tag contains a hyphen) used
1103
- /// to re-parse with HTML5 semantics. HTML5 does NOT honour XML-style self-closing on unknown
1104
- /// elements, so `<ac:parameter ... />` was treated as an open tag and subsequent siblings were
1105
- /// nested inside it. That caused `visit_element_start("ac:parameter")` for "foo" to be
1106
- /// followed by `visit_element_start("ac:parameter")` for "quux", then both ends in reversed
1107
- /// order — violating the expected pre-order/post-order pairing.
1108
- #[test]
1109
- fn test_issue_331_hyphenated_tags_xml_self_closing_visitor_events() {
1110
- #[derive(Debug, Default)]
1111
- struct EventRecorder {
1112
- events: Vec<String>,
1113
- }
1114
-
1115
- impl HtmlVisitor for EventRecorder {
1116
- fn visit_element_start(&mut self, ctx: &NodeContext) -> VisitResult {
1117
- self.events.push(format!("start({})", ctx.tag_name));
1118
- VisitResult::Continue
1119
- }
1120
-
1121
- fn visit_element_end(&mut self, ctx: &NodeContext, _output: &str) -> VisitResult {
1122
- self.events.push(format!("end({})", ctx.tag_name));
1123
- VisitResult::Continue
1124
- }
1125
- }
1126
-
1127
- let html = r#"
1128
- <structured-macro>
1129
- <ac:parameter ac:name="foo" />
1130
- <ac:parameter ac:name="quux">lalaland</ac:parameter>
1131
- </structured-macro>
1132
- "#;
1133
-
1134
- let visitor = Rc::new(RefCell::new(EventRecorder::default()));
1135
- let result = convert(html, None, Some(visitor.clone()));
1136
- assert!(result.is_ok(), "conversion should succeed: {:?}", result.err());
1137
-
1138
- let events = visitor.borrow().events.clone();
1139
-
1140
- // Find the indices of start/end pairs for the two ac:parameter elements.
1141
- // With correct XML self-closing handling:
1142
- // start(ac:parameter)[foo] → end(ac:parameter)[foo] → start(ac:parameter)[quux] → end(ac:parameter)[quux]
1143
- // With the bug (html5ever treats `/>` as open tag):
1144
- // start(ac:parameter)[foo] → start(ac:parameter)[quux] → end(ac:parameter)[quux] → end(ac:parameter)[foo]
1145
-
1146
- // Collect positions of start/end events for ac:parameter
1147
- let ac_param_starts: Vec<usize> = events
1148
- .iter()
1149
- .enumerate()
1150
- .filter(|(_, e)| e.starts_with("start(ac:parameter)"))
1151
- .map(|(i, _)| i)
1152
- .collect();
1153
- let ac_param_ends: Vec<usize> = events
1154
- .iter()
1155
- .enumerate()
1156
- .filter(|(_, e)| e.starts_with("end(ac:parameter)"))
1157
- .map(|(i, _)| i)
1158
- .collect();
1159
-
1160
- assert_eq!(
1161
- ac_param_starts.len(),
1162
- 2,
1163
- "expected exactly 2 ac:parameter start events, got: {events:?}"
1164
- );
1165
- assert_eq!(
1166
- ac_param_ends.len(),
1167
- 2,
1168
- "expected exactly 2 ac:parameter end events, got: {events:?}"
1169
- );
1170
-
1171
- // Each start must come before the corresponding end: start[0] < end[0] < start[1] < end[1]
1172
- assert!(
1173
- ac_param_starts[0] < ac_param_ends[0],
1174
- "first ac:parameter: start must precede end (got start@{}, end@{}); events: {events:?}",
1175
- ac_param_starts[0],
1176
- ac_param_ends[0],
1177
- );
1178
- assert!(
1179
- ac_param_ends[0] < ac_param_starts[1],
1180
- "first ac:parameter end must precede second ac:parameter start (got end@{}, start@{}); events: {events:?}",
1181
- ac_param_ends[0],
1182
- ac_param_starts[1],
1183
- );
1184
- assert!(
1185
- ac_param_starts[1] < ac_param_ends[1],
1186
- "second ac:parameter: start must precede end (got start@{}, end@{}); events: {events:?}",
1187
- ac_param_starts[1],
1188
- ac_param_ends[1],
1189
- );
1190
- }