1use std::cell::RefCell;
2use std::io::Read;
3use std::rc::Rc;
4
5use anyhow::Result;
6use html_to_markdown::markdown::{
7 HeadingHandler, ListHandler, ParagraphHandler, StyledTextHandler, TableHandler,
8};
9use html_to_markdown::{
10 convert_html_to_markdown, HandleTag, HandlerOutcome, HtmlElement, MarkdownWriter,
11 StartTagOutcome, TagHandler,
12};
13use indexmap::IndexSet;
14use strum::IntoEnumIterator;
15
16use crate::{RustdocItem, RustdocItemKind};
17
18/// Converts the provided rustdoc HTML to Markdown.
19pub fn convert_rustdoc_to_markdown(html: impl Read) -> Result<(String, Vec<RustdocItem>)> {
20 let item_collector = Rc::new(RefCell::new(RustdocItemCollector::new()));
21
22 let mut handlers: Vec<TagHandler> = vec![
23 Rc::new(RefCell::new(ParagraphHandler)),
24 Rc::new(RefCell::new(HeadingHandler)),
25 Rc::new(RefCell::new(ListHandler)),
26 Rc::new(RefCell::new(TableHandler::new())),
27 Rc::new(RefCell::new(StyledTextHandler)),
28 Rc::new(RefCell::new(RustdocChromeRemover)),
29 Rc::new(RefCell::new(RustdocHeadingHandler)),
30 Rc::new(RefCell::new(RustdocCodeHandler)),
31 Rc::new(RefCell::new(RustdocItemHandler)),
32 item_collector.clone(),
33 ];
34
35 let markdown = convert_html_to_markdown(html, &mut handlers)?;
36
37 let items = item_collector
38 .borrow()
39 .items
40 .iter()
41 .cloned()
42 .collect::<Vec<_>>();
43
44 Ok((markdown, items))
45}
46
47pub struct RustdocHeadingHandler;
48
49impl HandleTag for RustdocHeadingHandler {
50 fn should_handle(&self, _tag: &str) -> bool {
51 // We're only handling text, so we don't need to visit any tags.
52 false
53 }
54
55 fn handle_text(&mut self, text: &str, writer: &mut MarkdownWriter) -> HandlerOutcome {
56 if writer.is_inside("h1")
57 || writer.is_inside("h2")
58 || writer.is_inside("h3")
59 || writer.is_inside("h4")
60 || writer.is_inside("h5")
61 || writer.is_inside("h6")
62 {
63 let text = text
64 .trim_matches(|char| char == '\n' || char == '\r')
65 .replace('\n', " ");
66 writer.push_str(&text);
67
68 return HandlerOutcome::Handled;
69 }
70
71 HandlerOutcome::NoOp
72 }
73}
74
75pub struct RustdocCodeHandler;
76
77impl HandleTag for RustdocCodeHandler {
78 fn should_handle(&self, tag: &str) -> bool {
79 match tag {
80 "pre" | "code" => true,
81 _ => false,
82 }
83 }
84
85 fn handle_tag_start(
86 &mut self,
87 tag: &HtmlElement,
88 writer: &mut MarkdownWriter,
89 ) -> StartTagOutcome {
90 match tag.tag() {
91 "code" => {
92 if !writer.is_inside("pre") {
93 writer.push_str("`");
94 }
95 }
96 "pre" => {
97 let classes = tag.classes();
98 let is_rust = classes.iter().any(|class| class == "rust");
99 let language = is_rust
100 .then(|| "rs")
101 .or_else(|| {
102 classes.iter().find_map(|class| {
103 if let Some((_, language)) = class.split_once("language-") {
104 Some(language.trim())
105 } else {
106 None
107 }
108 })
109 })
110 .unwrap_or("");
111
112 writer.push_str(&format!("\n\n```{language}\n"));
113 }
114 _ => {}
115 }
116
117 StartTagOutcome::Continue
118 }
119
120 fn handle_tag_end(&mut self, tag: &HtmlElement, writer: &mut MarkdownWriter) {
121 match tag.tag() {
122 "code" => {
123 if !writer.is_inside("pre") {
124 writer.push_str("`");
125 }
126 }
127 "pre" => writer.push_str("\n```\n"),
128 _ => {}
129 }
130 }
131
132 fn handle_text(&mut self, text: &str, writer: &mut MarkdownWriter) -> HandlerOutcome {
133 if writer.is_inside("pre") {
134 writer.push_str(&text);
135 return HandlerOutcome::Handled;
136 }
137
138 HandlerOutcome::NoOp
139 }
140}
141
142const RUSTDOC_ITEM_NAME_CLASS: &str = "item-name";
143
144pub struct RustdocItemHandler;
145
146impl RustdocItemHandler {
147 /// Returns whether we're currently inside of an `.item-name` element, which
148 /// rustdoc uses to display Rust items in a list.
149 fn is_inside_item_name(writer: &MarkdownWriter) -> bool {
150 writer
151 .current_element_stack()
152 .iter()
153 .any(|element| element.has_class(RUSTDOC_ITEM_NAME_CLASS))
154 }
155}
156
157impl HandleTag for RustdocItemHandler {
158 fn should_handle(&self, tag: &str) -> bool {
159 match tag {
160 "div" | "span" => true,
161 _ => false,
162 }
163 }
164
165 fn handle_tag_start(
166 &mut self,
167 tag: &HtmlElement,
168 writer: &mut MarkdownWriter,
169 ) -> StartTagOutcome {
170 match tag.tag() {
171 "div" | "span" => {
172 if Self::is_inside_item_name(writer) && tag.has_class("stab") {
173 writer.push_str(" [");
174 }
175 }
176 _ => {}
177 }
178
179 StartTagOutcome::Continue
180 }
181
182 fn handle_tag_end(&mut self, tag: &HtmlElement, writer: &mut MarkdownWriter) {
183 match tag.tag() {
184 "div" | "span" => {
185 if tag.has_class(RUSTDOC_ITEM_NAME_CLASS) {
186 writer.push_str(": ");
187 }
188
189 if Self::is_inside_item_name(writer) && tag.has_class("stab") {
190 writer.push_str("]");
191 }
192 }
193 _ => {}
194 }
195 }
196
197 fn handle_text(&mut self, text: &str, writer: &mut MarkdownWriter) -> HandlerOutcome {
198 if Self::is_inside_item_name(writer)
199 && !writer.is_inside("span")
200 && !writer.is_inside("code")
201 {
202 writer.push_str(&format!("`{text}`"));
203 return HandlerOutcome::Handled;
204 }
205
206 HandlerOutcome::NoOp
207 }
208}
209
210pub struct RustdocChromeRemover;
211
212impl HandleTag for RustdocChromeRemover {
213 fn should_handle(&self, tag: &str) -> bool {
214 match tag {
215 "head" | "script" | "nav" | "summary" | "button" | "a" | "div" | "span" => true,
216 _ => false,
217 }
218 }
219
220 fn handle_tag_start(
221 &mut self,
222 tag: &HtmlElement,
223 _writer: &mut MarkdownWriter,
224 ) -> StartTagOutcome {
225 match tag.tag() {
226 "head" | "script" | "nav" => return StartTagOutcome::Skip,
227 "summary" => {
228 if tag.has_class("hideme") {
229 return StartTagOutcome::Skip;
230 }
231 }
232 "button" => {
233 if tag.attr("id").as_deref() == Some("copy-path") {
234 return StartTagOutcome::Skip;
235 }
236 }
237 "a" => {
238 if tag.has_any_classes(&["anchor", "doc-anchor", "src"]) {
239 return StartTagOutcome::Skip;
240 }
241 }
242 "div" | "span" => {
243 if tag.has_any_classes(&["nav-container", "sidebar-elems", "out-of-band"]) {
244 return StartTagOutcome::Skip;
245 }
246 }
247
248 _ => {}
249 }
250
251 StartTagOutcome::Continue
252 }
253}
254
255pub struct RustdocItemCollector {
256 pub items: IndexSet<RustdocItem>,
257}
258
259impl RustdocItemCollector {
260 pub fn new() -> Self {
261 Self {
262 items: IndexSet::new(),
263 }
264 }
265
266 fn parse_item(tag: &HtmlElement) -> Option<RustdocItem> {
267 if tag.tag() != "a" {
268 return None;
269 }
270
271 let href = tag.attr("href")?;
272 if href.starts_with('#') || href.starts_with("https://") || href.starts_with("../") {
273 return None;
274 }
275
276 for kind in RustdocItemKind::iter() {
277 if tag.has_class(kind.class()) {
278 let mut parts = href.trim_end_matches("/index.html").split('/');
279
280 if let Some(last_component) = parts.next_back() {
281 let last_component = match last_component.split_once('#') {
282 Some((component, _fragment)) => component,
283 None => last_component,
284 };
285
286 let name = last_component
287 .trim_start_matches(&format!("{}.", kind.class()))
288 .trim_end_matches(".html");
289
290 return Some(RustdocItem {
291 kind,
292 name: name.into(),
293 path: parts.map(Into::into).collect(),
294 });
295 }
296 }
297 }
298
299 None
300 }
301}
302
303impl HandleTag for RustdocItemCollector {
304 fn should_handle(&self, tag: &str) -> bool {
305 tag == "a"
306 }
307
308 fn handle_tag_start(
309 &mut self,
310 tag: &HtmlElement,
311 writer: &mut MarkdownWriter,
312 ) -> StartTagOutcome {
313 match tag.tag() {
314 "a" => {
315 let is_reexport = writer.current_element_stack().iter().any(|element| {
316 if let Some(id) = element.attr("id") {
317 id.starts_with("reexport.") || id.starts_with("method.")
318 } else {
319 false
320 }
321 });
322
323 if !is_reexport {
324 if let Some(item) = Self::parse_item(tag) {
325 self.items.insert(item);
326 }
327 }
328 }
329 _ => {}
330 }
331
332 StartTagOutcome::Continue
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use html_to_markdown::{convert_html_to_markdown, TagHandler};
339 use indoc::indoc;
340 use pretty_assertions::assert_eq;
341
342 use super::*;
343
344 fn rustdoc_handlers() -> Vec<TagHandler> {
345 vec![
346 Rc::new(RefCell::new(ParagraphHandler)),
347 Rc::new(RefCell::new(HeadingHandler)),
348 Rc::new(RefCell::new(ListHandler)),
349 Rc::new(RefCell::new(TableHandler::new())),
350 Rc::new(RefCell::new(StyledTextHandler)),
351 Rc::new(RefCell::new(RustdocChromeRemover)),
352 Rc::new(RefCell::new(RustdocHeadingHandler)),
353 Rc::new(RefCell::new(RustdocCodeHandler)),
354 Rc::new(RefCell::new(RustdocItemHandler)),
355 ]
356 }
357
358 #[test]
359 fn test_main_heading_buttons_get_removed() {
360 let html = indoc! {r##"
361 <div class="main-heading">
362 <h1>Crate <a class="mod" href="#">serde</a><button id="copy-path" title="Copy item path to clipboard">Copy item path</button></h1>
363 <span class="out-of-band">
364 <a class="src" href="../src/serde/lib.rs.html#1-340">source</a> · <button id="toggle-all-docs" title="collapse all docs">[<span>−</span>]</button>
365 </span>
366 </div>
367 "##};
368 let expected = indoc! {"
369 # Crate serde
370 "}
371 .trim();
372
373 assert_eq!(
374 convert_html_to_markdown(html.as_bytes(), &mut rustdoc_handlers()).unwrap(),
375 expected
376 )
377 }
378
379 #[test]
380 fn test_single_paragraph() {
381 let html = indoc! {r#"
382 <p>In particular, the last point is what sets <code>axum</code> apart from other frameworks.
383 <code>axum</code> doesn’t have its own middleware system but instead uses
384 <a href="https://docs.rs/tower-service/0.3.2/x86_64-unknown-linux-gnu/tower_service/trait.Service.html" title="trait tower_service::Service"><code>tower::Service</code></a>. This means <code>axum</code> gets timeouts, tracing, compression,
385 authorization, and more, for free. It also enables you to share middleware with
386 applications written using <a href="http://crates.io/crates/hyper"><code>hyper</code></a> or <a href="http://crates.io/crates/tonic"><code>tonic</code></a>.</p>
387 "#};
388 let expected = indoc! {"
389 In particular, the last point is what sets `axum` apart from other frameworks. `axum` doesn’t have its own middleware system but instead uses `tower::Service`. This means `axum` gets timeouts, tracing, compression, authorization, and more, for free. It also enables you to share middleware with applications written using `hyper` or `tonic`.
390 "}
391 .trim();
392
393 assert_eq!(
394 convert_html_to_markdown(html.as_bytes(), &mut rustdoc_handlers()).unwrap(),
395 expected
396 )
397 }
398
399 #[test]
400 fn test_multiple_paragraphs() {
401 let html = indoc! {r##"
402 <h2 id="serde"><a class="doc-anchor" href="#serde">§</a>Serde</h2>
403 <p>Serde is a framework for <em><strong>ser</strong></em>ializing and <em><strong>de</strong></em>serializing Rust data
404 structures efficiently and generically.</p>
405 <p>The Serde ecosystem consists of data structures that know how to serialize
406 and deserialize themselves along with data formats that know how to
407 serialize and deserialize other things. Serde provides the layer by which
408 these two groups interact with each other, allowing any supported data
409 structure to be serialized and deserialized using any supported data format.</p>
410 <p>See the Serde website <a href="https://serde.rs/">https://serde.rs/</a> for additional documentation and
411 usage examples.</p>
412 <h3 id="design"><a class="doc-anchor" href="#design">§</a>Design</h3>
413 <p>Where many other languages rely on runtime reflection for serializing data,
414 Serde is instead built on Rust’s powerful trait system. A data structure
415 that knows how to serialize and deserialize itself is one that implements
416 Serde’s <code>Serialize</code> and <code>Deserialize</code> traits (or uses Serde’s derive
417 attribute to automatically generate implementations at compile time). This
418 avoids any overhead of reflection or runtime type information. In fact in
419 many situations the interaction between data structure and data format can
420 be completely optimized away by the Rust compiler, leaving Serde
421 serialization to perform the same speed as a handwritten serializer for the
422 specific selection of data structure and data format.</p>
423 "##};
424 let expected = indoc! {"
425 ## Serde
426
427 Serde is a framework for _**ser**_ializing and _**de**_serializing Rust data structures efficiently and generically.
428
429 The Serde ecosystem consists of data structures that know how to serialize and deserialize themselves along with data formats that know how to serialize and deserialize other things. Serde provides the layer by which these two groups interact with each other, allowing any supported data structure to be serialized and deserialized using any supported data format.
430
431 See the Serde website https://serde.rs/ for additional documentation and usage examples.
432
433 ### Design
434
435 Where many other languages rely on runtime reflection for serializing data, Serde is instead built on Rust’s powerful trait system. A data structure that knows how to serialize and deserialize itself is one that implements Serde’s `Serialize` and `Deserialize` traits (or uses Serde’s derive attribute to automatically generate implementations at compile time). This avoids any overhead of reflection or runtime type information. In fact in many situations the interaction between data structure and data format can be completely optimized away by the Rust compiler, leaving Serde serialization to perform the same speed as a handwritten serializer for the specific selection of data structure and data format.
436 "}
437 .trim();
438
439 assert_eq!(
440 convert_html_to_markdown(html.as_bytes(), &mut rustdoc_handlers()).unwrap(),
441 expected
442 )
443 }
444
445 #[test]
446 fn test_styled_text() {
447 let html = indoc! {r#"
448 <p>This text is <strong>bolded</strong>.</p>
449 <p>This text is <em>italicized</em>.</p>
450 "#};
451 let expected = indoc! {"
452 This text is **bolded**.
453
454 This text is _italicized_.
455 "}
456 .trim();
457
458 assert_eq!(
459 convert_html_to_markdown(html.as_bytes(), &mut rustdoc_handlers()).unwrap(),
460 expected
461 )
462 }
463
464 #[test]
465 fn test_rust_code_block() {
466 let html = indoc! {r#"
467 <pre class="rust rust-example-rendered"><code><span class="kw">use </span>axum::extract::{Path, Query, Json};
468 <span class="kw">use </span>std::collections::HashMap;
469
470 <span class="comment">// `Path` gives you the path parameters and deserializes them.
471 </span><span class="kw">async fn </span>path(Path(user_id): Path<u32>) {}
472
473 <span class="comment">// `Query` gives you the query parameters and deserializes them.
474 </span><span class="kw">async fn </span>query(Query(params): Query<HashMap<String, String>>) {}
475
476 <span class="comment">// Buffer the request body and deserialize it as JSON into a
477 // `serde_json::Value`. `Json` supports any type that implements
478 // `serde::Deserialize`.
479 </span><span class="kw">async fn </span>json(Json(payload): Json<serde_json::Value>) {}</code></pre>
480 "#};
481 let expected = indoc! {"
482 ```rs
483 use axum::extract::{Path, Query, Json};
484 use std::collections::HashMap;
485
486 // `Path` gives you the path parameters and deserializes them.
487 async fn path(Path(user_id): Path<u32>) {}
488
489 // `Query` gives you the query parameters and deserializes them.
490 async fn query(Query(params): Query<HashMap<String, String>>) {}
491
492 // Buffer the request body and deserialize it as JSON into a
493 // `serde_json::Value`. `Json` supports any type that implements
494 // `serde::Deserialize`.
495 async fn json(Json(payload): Json<serde_json::Value>) {}
496 ```
497 "}
498 .trim();
499
500 assert_eq!(
501 convert_html_to_markdown(html.as_bytes(), &mut rustdoc_handlers()).unwrap(),
502 expected
503 )
504 }
505
506 #[test]
507 fn test_toml_code_block() {
508 let html = indoc! {r##"
509 <h2 id="required-dependencies"><a class="doc-anchor" href="#required-dependencies">§</a>Required dependencies</h2>
510 <p>To use axum there are a few dependencies you have to pull in as well:</p>
511 <div class="example-wrap"><pre class="language-toml"><code>[dependencies]
512 axum = "<latest-version>"
513 tokio = { version = "<latest-version>", features = ["full"] }
514 tower = "<latest-version>"
515 </code></pre></div>
516 "##};
517 let expected = indoc! {r#"
518 ## Required dependencies
519
520 To use axum there are a few dependencies you have to pull in as well:
521
522 ```toml
523 [dependencies]
524 axum = "<latest-version>"
525 tokio = { version = "<latest-version>", features = ["full"] }
526 tower = "<latest-version>"
527
528 ```
529 "#}
530 .trim();
531
532 assert_eq!(
533 convert_html_to_markdown(html.as_bytes(), &mut rustdoc_handlers()).unwrap(),
534 expected
535 )
536 }
537
538 #[test]
539 fn test_item_table() {
540 let html = indoc! {r##"
541 <h2 id="structs" class="section-header">Structs<a href="#structs" class="anchor">§</a></h2>
542 <ul class="item-table">
543 <li><div class="item-name"><a class="struct" href="struct.Error.html" title="struct axum::Error">Error</a></div><div class="desc docblock-short">Errors that can happen when using axum.</div></li>
544 <li><div class="item-name"><a class="struct" href="struct.Extension.html" title="struct axum::Extension">Extension</a></div><div class="desc docblock-short">Extractor and response for extensions.</div></li>
545 <li><div class="item-name"><a class="struct" href="struct.Form.html" title="struct axum::Form">Form</a><span class="stab portability" title="Available on crate feature `form` only"><code>form</code></span></div><div class="desc docblock-short">URL encoded extractor and response.</div></li>
546 <li><div class="item-name"><a class="struct" href="struct.Json.html" title="struct axum::Json">Json</a><span class="stab portability" title="Available on crate feature `json` only"><code>json</code></span></div><div class="desc docblock-short">JSON Extractor / Response.</div></li>
547 <li><div class="item-name"><a class="struct" href="struct.Router.html" title="struct axum::Router">Router</a></div><div class="desc docblock-short">The router type for composing handlers and services.</div></li></ul>
548 <h2 id="functions" class="section-header">Functions<a href="#functions" class="anchor">§</a></h2>
549 <ul class="item-table">
550 <li><div class="item-name"><a class="fn" href="fn.serve.html" title="fn axum::serve">serve</a><span class="stab portability" title="Available on crate feature `tokio` and (crate features `http1` or `http2`) only"><code>tokio</code> and (<code>http1</code> or <code>http2</code>)</span></div><div class="desc docblock-short">Serve the service with the supplied listener.</div></li>
551 </ul>
552 "##};
553 let expected = indoc! {r#"
554 ## Structs
555
556 - `Error`: Errors that can happen when using axum.
557 - `Extension`: Extractor and response for extensions.
558 - `Form` [`form`]: URL encoded extractor and response.
559 - `Json` [`json`]: JSON Extractor / Response.
560 - `Router`: The router type for composing handlers and services.
561
562 ## Functions
563
564 - `serve` [`tokio` and (`http1` or `http2`)]: Serve the service with the supplied listener.
565 "#}
566 .trim();
567
568 assert_eq!(
569 convert_html_to_markdown(html.as_bytes(), &mut rustdoc_handlers()).unwrap(),
570 expected
571 )
572 }
573
574 #[test]
575 fn test_table() {
576 let html = indoc! {r##"
577 <h2 id="feature-flags"><a class="doc-anchor" href="#feature-flags">§</a>Feature flags</h2>
578 <p>axum uses a set of <a href="https://doc.rust-lang.org/cargo/reference/features.html#the-features-section">feature flags</a> to reduce the amount of compiled and
579 optional dependencies.</p>
580 <p>The following optional features are available:</p>
581 <div><table><thead><tr><th>Name</th><th>Description</th><th>Default?</th></tr></thead><tbody>
582 <tr><td><code>http1</code></td><td>Enables hyper’s <code>http1</code> feature</td><td>Yes</td></tr>
583 <tr><td><code>http2</code></td><td>Enables hyper’s <code>http2</code> feature</td><td>No</td></tr>
584 <tr><td><code>json</code></td><td>Enables the <a href="struct.Json.html" title="struct axum::Json"><code>Json</code></a> type and some similar convenience functionality</td><td>Yes</td></tr>
585 <tr><td><code>macros</code></td><td>Enables optional utility macros</td><td>No</td></tr>
586 <tr><td><code>matched-path</code></td><td>Enables capturing of every request’s router path and the <a href="extract/struct.MatchedPath.html" title="struct axum::extract::MatchedPath"><code>MatchedPath</code></a> extractor</td><td>Yes</td></tr>
587 <tr><td><code>multipart</code></td><td>Enables parsing <code>multipart/form-data</code> requests with <a href="extract/struct.Multipart.html" title="struct axum::extract::Multipart"><code>Multipart</code></a></td><td>No</td></tr>
588 <tr><td><code>original-uri</code></td><td>Enables capturing of every request’s original URI and the <a href="extract/struct.OriginalUri.html" title="struct axum::extract::OriginalUri"><code>OriginalUri</code></a> extractor</td><td>Yes</td></tr>
589 <tr><td><code>tokio</code></td><td>Enables <code>tokio</code> as a dependency and <code>axum::serve</code>, <code>SSE</code> and <code>extract::connect_info</code> types.</td><td>Yes</td></tr>
590 <tr><td><code>tower-log</code></td><td>Enables <code>tower</code>’s <code>log</code> feature</td><td>Yes</td></tr>
591 <tr><td><code>tracing</code></td><td>Log rejections from built-in extractors</td><td>Yes</td></tr>
592 <tr><td><code>ws</code></td><td>Enables WebSockets support via <a href="extract/ws/index.html" title="mod axum::extract::ws"><code>extract::ws</code></a></td><td>No</td></tr>
593 <tr><td><code>form</code></td><td>Enables the <code>Form</code> extractor</td><td>Yes</td></tr>
594 <tr><td><code>query</code></td><td>Enables the <code>Query</code> extractor</td><td>Yes</td></tr>
595 </tbody></table>
596 "##};
597 let expected = indoc! {r#"
598 ## Feature flags
599
600 axum uses a set of feature flags to reduce the amount of compiled and optional dependencies.
601
602 The following optional features are available:
603
604 | Name | Description | Default? |
605 | --- | --- | --- |
606 | `http1` | Enables hyper’s `http1` feature | Yes |
607 | `http2` | Enables hyper’s `http2` feature | No |
608 | `json` | Enables the `Json` type and some similar convenience functionality | Yes |
609 | `macros` | Enables optional utility macros | No |
610 | `matched-path` | Enables capturing of every request’s router path and the `MatchedPath` extractor | Yes |
611 | `multipart` | Enables parsing `multipart/form-data` requests with `Multipart` | No |
612 | `original-uri` | Enables capturing of every request’s original URI and the `OriginalUri` extractor | Yes |
613 | `tokio` | Enables `tokio` as a dependency and `axum::serve`, `SSE` and `extract::connect_info` types. | Yes |
614 | `tower-log` | Enables `tower`’s `log` feature | Yes |
615 | `tracing` | Log rejections from built-in extractors | Yes |
616 | `ws` | Enables WebSockets support via `extract::ws` | No |
617 | `form` | Enables the `Form` extractor | Yes |
618 | `query` | Enables the `Query` extractor | Yes |
619 "#}
620 .trim();
621
622 assert_eq!(
623 convert_html_to_markdown(html.as_bytes(), &mut rustdoc_handlers()).unwrap(),
624 expected
625 )
626 }
627}