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