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<u32>) {}
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<HashMap<String, String>>) {}
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<serde_json::Value>) {}</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 = "<latest-version>"
508 tokio = { version = "<latest-version>", features = ["full"] }
509 tower = "<latest-version>"
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}