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