to_markdown.rs

  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' || char == '§')
 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" | "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            "div" | "span" => {
238                let classes_to_skip = ["nav-container", "sidebar-elems", "out-of-band"];
239                if tag.has_any_classes(&classes_to_skip) {
240                    return StartTagOutcome::Skip;
241                }
242            }
243            _ => {}
244        }
245
246        StartTagOutcome::Continue
247    }
248}
249
250pub struct RustdocItemCollector {
251    pub items: IndexSet<RustdocItem>,
252}
253
254impl RustdocItemCollector {
255    pub fn new() -> Self {
256        Self {
257            items: IndexSet::new(),
258        }
259    }
260
261    fn parse_item(tag: &HtmlElement) -> Option<RustdocItem> {
262        if tag.tag() != "a" {
263            return None;
264        }
265
266        let href = tag.attr("href")?;
267        if href.starts_with('#') || href.starts_with("https://") || href.starts_with("../") {
268            return None;
269        }
270
271        for kind in RustdocItemKind::iter() {
272            if tag.has_class(kind.class()) {
273                let mut parts = href.trim_end_matches("/index.html").split('/');
274
275                if let Some(last_component) = parts.next_back() {
276                    let last_component = match last_component.split_once('#') {
277                        Some((component, _fragment)) => component,
278                        None => last_component,
279                    };
280
281                    let name = last_component
282                        .trim_start_matches(&format!("{}.", kind.class()))
283                        .trim_end_matches(".html");
284
285                    return Some(RustdocItem {
286                        kind,
287                        name: name.into(),
288                        path: parts.map(Into::into).collect(),
289                    });
290                }
291            }
292        }
293
294        None
295    }
296}
297
298impl HandleTag for RustdocItemCollector {
299    fn should_handle(&self, tag: &str) -> bool {
300        tag == "a"
301    }
302
303    fn handle_tag_start(
304        &mut self,
305        tag: &HtmlElement,
306        writer: &mut MarkdownWriter,
307    ) -> StartTagOutcome {
308        match tag.tag() {
309            "a" => {
310                let is_reexport = writer.current_element_stack().iter().any(|element| {
311                    if let Some(id) = element.attr("id") {
312                        id.starts_with("reexport.") || id.starts_with("method.")
313                    } else {
314                        false
315                    }
316                });
317
318                if !is_reexport {
319                    if let Some(item) = Self::parse_item(tag) {
320                        self.items.insert(item);
321                    }
322                }
323            }
324            _ => {}
325        }
326
327        StartTagOutcome::Continue
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use html_to_markdown::{convert_html_to_markdown, TagHandler};
334    use indoc::indoc;
335    use pretty_assertions::assert_eq;
336
337    use super::*;
338
339    fn rustdoc_handlers() -> Vec<TagHandler> {
340        vec![
341            Rc::new(RefCell::new(ParagraphHandler)),
342            Rc::new(RefCell::new(HeadingHandler)),
343            Rc::new(RefCell::new(ListHandler)),
344            Rc::new(RefCell::new(TableHandler::new())),
345            Rc::new(RefCell::new(StyledTextHandler)),
346            Rc::new(RefCell::new(RustdocChromeRemover)),
347            Rc::new(RefCell::new(RustdocHeadingHandler)),
348            Rc::new(RefCell::new(RustdocCodeHandler)),
349            Rc::new(RefCell::new(RustdocItemHandler)),
350        ]
351    }
352
353    #[test]
354    fn test_main_heading_buttons_get_removed() {
355        let html = indoc! {r##"
356            <div class="main-heading">
357                <h1>Crate <a class="mod" href="#">serde</a><button id="copy-path" title="Copy item path to clipboard">Copy item path</button></h1>
358                <span class="out-of-band">
359                    <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>
360                </span>
361            </div>
362        "##};
363        let expected = indoc! {"
364            # Crate serde
365        "}
366        .trim();
367
368        assert_eq!(
369            convert_html_to_markdown(html.as_bytes(), &mut rustdoc_handlers()).unwrap(),
370            expected
371        )
372    }
373
374    #[test]
375    fn test_single_paragraph() {
376        let html = indoc! {r#"
377            <p>In particular, the last point is what sets <code>axum</code> apart from other frameworks.
378            <code>axum</code> doesn’t have its own middleware system but instead uses
379            <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,
380            authorization, and more, for free. It also enables you to share middleware with
381            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>
382        "#};
383        let expected = indoc! {"
384            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`.
385        "}
386        .trim();
387
388        assert_eq!(
389            convert_html_to_markdown(html.as_bytes(), &mut rustdoc_handlers()).unwrap(),
390            expected
391        )
392    }
393
394    #[test]
395    fn test_multiple_paragraphs() {
396        let html = indoc! {r##"
397            <h2 id="serde"><a class="doc-anchor" href="#serde">§</a>Serde</h2>
398            <p>Serde is a framework for <em><strong>ser</strong></em>ializing and <em><strong>de</strong></em>serializing Rust data
399            structures efficiently and generically.</p>
400            <p>The Serde ecosystem consists of data structures that know how to serialize
401            and deserialize themselves along with data formats that know how to
402            serialize and deserialize other things. Serde provides the layer by which
403            these two groups interact with each other, allowing any supported data
404            structure to be serialized and deserialized using any supported data format.</p>
405            <p>See the Serde website <a href="https://serde.rs/">https://serde.rs/</a> for additional documentation and
406            usage examples.</p>
407            <h3 id="design"><a class="doc-anchor" href="#design">§</a>Design</h3>
408            <p>Where many other languages rely on runtime reflection for serializing data,
409            Serde is instead built on Rust’s powerful trait system. A data structure
410            that knows how to serialize and deserialize itself is one that implements
411            Serde’s <code>Serialize</code> and <code>Deserialize</code> traits (or uses Serde’s derive
412            attribute to automatically generate implementations at compile time). This
413            avoids any overhead of reflection or runtime type information. In fact in
414            many situations the interaction between data structure and data format can
415            be completely optimized away by the Rust compiler, leaving Serde
416            serialization to perform the same speed as a handwritten serializer for the
417            specific selection of data structure and data format.</p>
418        "##};
419        let expected = indoc! {"
420            ## Serde
421
422            Serde is a framework for _**ser**_ializing and _**de**_serializing Rust data structures efficiently and generically.
423
424            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.
425
426            See the Serde website https://serde.rs/ for additional documentation and usage examples.
427
428            ### Design
429
430            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.
431        "}
432        .trim();
433
434        assert_eq!(
435            convert_html_to_markdown(html.as_bytes(), &mut rustdoc_handlers()).unwrap(),
436            expected
437        )
438    }
439
440    #[test]
441    fn test_styled_text() {
442        let html = indoc! {r#"
443            <p>This text is <strong>bolded</strong>.</p>
444            <p>This text is <em>italicized</em>.</p>
445        "#};
446        let expected = indoc! {"
447            This text is **bolded**.
448
449            This text is _italicized_.
450        "}
451        .trim();
452
453        assert_eq!(
454            convert_html_to_markdown(html.as_bytes(), &mut rustdoc_handlers()).unwrap(),
455            expected
456        )
457    }
458
459    #[test]
460    fn test_rust_code_block() {
461        let html = indoc! {r#"
462            <pre class="rust rust-example-rendered"><code><span class="kw">use </span>axum::extract::{Path, Query, Json};
463            <span class="kw">use </span>std::collections::HashMap;
464
465            <span class="comment">// `Path` gives you the path parameters and deserializes them.
466            </span><span class="kw">async fn </span>path(Path(user_id): Path&lt;u32&gt;) {}
467
468            <span class="comment">// `Query` gives you the query parameters and deserializes them.
469            </span><span class="kw">async fn </span>query(Query(params): Query&lt;HashMap&lt;String, String&gt;&gt;) {}
470
471            <span class="comment">// Buffer the request body and deserialize it as JSON into a
472            // `serde_json::Value`. `Json` supports any type that implements
473            // `serde::Deserialize`.
474            </span><span class="kw">async fn </span>json(Json(payload): Json&lt;serde_json::Value&gt;) {}</code></pre>
475        "#};
476        let expected = indoc! {"
477            ```rs
478            use axum::extract::{Path, Query, Json};
479            use std::collections::HashMap;
480
481            // `Path` gives you the path parameters and deserializes them.
482            async fn path(Path(user_id): Path<u32>) {}
483
484            // `Query` gives you the query parameters and deserializes them.
485            async fn query(Query(params): Query<HashMap<String, String>>) {}
486
487            // Buffer the request body and deserialize it as JSON into a
488            // `serde_json::Value`. `Json` supports any type that implements
489            // `serde::Deserialize`.
490            async fn json(Json(payload): Json<serde_json::Value>) {}
491            ```
492        "}
493        .trim();
494
495        assert_eq!(
496            convert_html_to_markdown(html.as_bytes(), &mut rustdoc_handlers()).unwrap(),
497            expected
498        )
499    }
500
501    #[test]
502    fn test_toml_code_block() {
503        let html = indoc! {r##"
504            <h2 id="required-dependencies"><a class="doc-anchor" href="#required-dependencies">§</a>Required dependencies</h2>
505            <p>To use axum there are a few dependencies you have to pull in as well:</p>
506            <div class="example-wrap"><pre class="language-toml"><code>[dependencies]
507            axum = &quot;&lt;latest-version&gt;&quot;
508            tokio = { version = &quot;&lt;latest-version&gt;&quot;, features = [&quot;full&quot;] }
509            tower = &quot;&lt;latest-version&gt;&quot;
510            </code></pre></div>
511        "##};
512        let expected = indoc! {r#"
513            ## Required dependencies
514
515            To use axum there are a few dependencies you have to pull in as well:
516
517            ```toml
518            [dependencies]
519            axum = "<latest-version>"
520            tokio = { version = "<latest-version>", features = ["full"] }
521            tower = "<latest-version>"
522
523            ```
524        "#}
525        .trim();
526
527        assert_eq!(
528            convert_html_to_markdown(html.as_bytes(), &mut rustdoc_handlers()).unwrap(),
529            expected
530        )
531    }
532
533    #[test]
534    fn test_item_table() {
535        let html = indoc! {r##"
536            <h2 id="structs" class="section-header">Structs<a href="#structs" class="anchor">§</a></h2>
537            <ul class="item-table">
538            <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>
539            <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>
540            <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>
541            <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>
542            <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>
543            <h2 id="functions" class="section-header">Functions<a href="#functions" class="anchor">§</a></h2>
544            <ul class="item-table">
545            <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>
546            </ul>
547        "##};
548        let expected = indoc! {r#"
549            ## Structs
550
551            - `Error`: Errors that can happen when using axum.
552            - `Extension`: Extractor and response for extensions.
553            - `Form` [`form`]: URL encoded extractor and response.
554            - `Json` [`json`]: JSON Extractor / Response.
555            - `Router`: The router type for composing handlers and services.
556
557            ## Functions
558
559            - `serve` [`tokio` and (`http1` or `http2`)]: Serve the service with the supplied listener.
560        "#}
561        .trim();
562
563        assert_eq!(
564            convert_html_to_markdown(html.as_bytes(), &mut rustdoc_handlers()).unwrap(),
565            expected
566        )
567    }
568
569    #[test]
570    fn test_table() {
571        let html = indoc! {r##"
572            <h2 id="feature-flags"><a class="doc-anchor" href="#feature-flags">§</a>Feature flags</h2>
573            <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
574            optional dependencies.</p>
575            <p>The following optional features are available:</p>
576            <div><table><thead><tr><th>Name</th><th>Description</th><th>Default?</th></tr></thead><tbody>
577            <tr><td><code>http1</code></td><td>Enables hyper’s <code>http1</code> feature</td><td>Yes</td></tr>
578            <tr><td><code>http2</code></td><td>Enables hyper’s <code>http2</code> feature</td><td>No</td></tr>
579            <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>
580            <tr><td><code>macros</code></td><td>Enables optional utility macros</td><td>No</td></tr>
581            <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>
582            <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>
583            <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>
584            <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>
585            <tr><td><code>tower-log</code></td><td>Enables <code>tower</code>’s <code>log</code> feature</td><td>Yes</td></tr>
586            <tr><td><code>tracing</code></td><td>Log rejections from built-in extractors</td><td>Yes</td></tr>
587            <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>
588            <tr><td><code>form</code></td><td>Enables the <code>Form</code> extractor</td><td>Yes</td></tr>
589            <tr><td><code>query</code></td><td>Enables the <code>Query</code> extractor</td><td>Yes</td></tr>
590            </tbody></table>
591        "##};
592        let expected = indoc! {r#"
593            ## Feature flags
594
595            axum uses a set of feature flags to reduce the amount of compiled and optional dependencies.
596
597            The following optional features are available:
598
599            | Name | Description | Default? |
600            | --- | --- | --- |
601            | `http1` | Enables hyper’s `http1` feature | Yes |
602            | `http2` | Enables hyper’s `http2` feature | No |
603            | `json` | Enables the `Json` type and some similar convenience functionality | Yes |
604            | `macros` | Enables optional utility macros | No |
605            | `matched-path` | Enables capturing of every request’s router path and the `MatchedPath` extractor | Yes |
606            | `multipart` | Enables parsing `multipart/form-data` requests with `Multipart` | No |
607            | `original-uri` | Enables capturing of every request’s original URI and the `OriginalUri` extractor | Yes |
608            | `tokio` | Enables `tokio` as a dependency and `axum::serve`, `SSE` and `extract::connect_info` types. | Yes |
609            | `tower-log` | Enables `tower`’s `log` feature | Yes |
610            | `tracing` | Log rejections from built-in extractors | Yes |
611            | `ws` | Enables WebSockets support via `extract::ws` | No |
612            | `form` | Enables the `Form` extractor | Yes |
613            | `query` | Enables the `Query` extractor | Yes |
614        "#}
615        .trim();
616
617        assert_eq!(
618            convert_html_to_markdown(html.as_bytes(), &mut rustdoc_handlers()).unwrap(),
619            expected
620        )
621    }
622}