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')
 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&lt;u32&gt;) {}
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&lt;HashMap&lt;String, String&gt;&gt;) {}
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&lt;serde_json::Value&gt;) {}</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 = &quot;&lt;latest-version&gt;&quot;
513            tokio = { version = &quot;&lt;latest-version&gt;&quot;, features = [&quot;full&quot;] }
514            tower = &quot;&lt;latest-version&gt;&quot;
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}