1use ammonia::Builder;
2use once_cell::sync::Lazy;
3use pulldown_cmark::{html, Options, Parser};
4use regex::Regex;
5use std::collections::HashMap;
6use syntect::highlighting::ThemeSet;
7use syntect::html::highlighted_html_for_string;
8use syntect::parsing::SyntaxSet;
9
10static SHORTCODE_REGEX: Lazy<Regex> =
12 Lazy::new(|| Regex::new(r"\[(\w+)([^\]]*)\]").expect("Invalid shortcode regex pattern"));
13static ATTR_REGEX: Lazy<Regex> = Lazy::new(|| {
14 Regex::new(r#"(\w+)(?:="([^"]*)")?|(\w+)"#).expect("Invalid attribute regex pattern")
15});
16
17pub struct ShortcodeProcessor;
26
27impl Default for ShortcodeProcessor {
28 fn default() -> Self {
29 Self::new()
30 }
31}
32
33impl ShortcodeProcessor {
34 pub fn new() -> Self {
35 Self
36 }
37
38 pub fn process(&self, content: &str) -> String {
40 SHORTCODE_REGEX
41 .replace_all(content, |caps: ®ex::Captures| {
42 let name = &caps[1];
43 let attrs_str = caps.get(2).map(|m| m.as_str()).unwrap_or("");
44 let attrs = self.parse_attributes(attrs_str);
45
46 match name {
47 "media" => self.render_media(&attrs),
48 "image" | "img" => self.render_image(&attrs),
49 "video" => self.render_video(&attrs),
50 "audio" => self.render_audio(&attrs),
51 "gallery" => self.render_gallery(&attrs),
52 _ => caps[0].to_string(), }
54 })
55 .to_string()
56 }
57
58 fn parse_attributes(&self, attrs_str: &str) -> HashMap<String, String> {
59 let mut attrs = HashMap::new();
60
61 for cap in ATTR_REGEX.captures_iter(attrs_str) {
62 if let Some(name) = cap.get(1) {
63 let value = cap.get(2).map(|m| m.as_str()).unwrap_or("true");
64 attrs.insert(name.as_str().to_string(), value.to_string());
65 } else if let Some(flag) = cap.get(3) {
66 attrs.insert(flag.as_str().to_string(), "true".to_string());
67 }
68 }
69
70 attrs
71 }
72
73 fn render_media(&self, attrs: &HashMap<String, String>) -> String {
74 let Some(raw_src) = attrs.get("src") else {
75 return "<!-- media shortcode: missing src attribute -->".to_string();
76 };
77 let src = Self::normalize_src(raw_src);
78
79 let extension = src.rsplit('.').next().unwrap_or("").to_lowercase();
81
82 match extension.as_str() {
83 "jpg" | "jpeg" | "png" | "gif" | "webp" | "svg" => self.render_image(attrs),
84 "mp4" | "webm" => self.render_video(attrs),
85 "mp3" | "ogg" => self.render_audio(attrs),
86 "pdf" => self.render_pdf(attrs),
87 _ => format!(
88 r#"<a href="/media/{}" class="media-link">{}</a>"#,
89 html_escape(src),
90 html_escape(attrs.get("title").map(|s| s.as_str()).unwrap_or(src))
91 ),
92 }
93 }
94
95 fn normalize_src(src: &str) -> &str {
97 src.trim_start_matches("/media/")
98 .trim_start_matches("media/")
99 }
100
101 fn render_image(&self, attrs: &HashMap<String, String>) -> String {
102 let Some(raw_src) = attrs.get("src") else {
103 return "<!-- image shortcode: missing src attribute -->".to_string();
104 };
105 let src = Self::normalize_src(raw_src);
106
107 let alt = attrs.get("alt").map(|s| s.as_str()).unwrap_or("");
108 let title = attrs.get("title").map(|s| s.as_str());
109 let class = attrs
110 .get("class")
111 .map(|s| s.as_str())
112 .unwrap_or("media-image");
113 let width = attrs.get("width");
114 let height = attrs.get("height");
115
116 let base_name = src.rsplit_once('.').map(|(n, _)| n).unwrap_or(src);
118 let srcset = format!(
119 "/media/{}-400w.webp 400w, /media/{}-800w.webp 800w, /media/{}-1200w.webp 1200w, /media/{}.webp 1600w",
120 html_escape(base_name),
121 html_escape(base_name),
122 html_escape(base_name),
123 html_escape(base_name)
124 );
125
126 let mut img_attrs = vec![
127 format!(r#"src="/media/{}""#, html_escape(src)),
128 format!(r#"alt="{}""#, html_escape(alt)),
129 format!(r#"class="{}""#, html_escape(class)),
130 "loading=\"lazy\"".to_string(),
131 ];
132
133 if let Some(t) = title {
134 img_attrs.push(format!(r#"title="{}""#, html_escape(t)));
135 }
136 if let Some(w) = width {
137 img_attrs.push(format!(r#"width="{}""#, html_escape(w)));
138 }
139 if let Some(h) = height {
140 img_attrs.push(format!(r#"height="{}""#, html_escape(h)));
141 }
142
143 format!(
145 r#"<figure class="media-figure">
146<picture>
147<source srcset="{}" sizes="(max-width: 400px) 400px, (max-width: 800px) 800px, (max-width: 1200px) 1200px, 1600px" type="image/webp">
148<img {}>
149</picture>
150{}</figure>"#,
151 srcset,
152 img_attrs.join(" "),
153 if !alt.is_empty() {
154 format!(r#"<figcaption>{}</figcaption>"#, html_escape(alt))
155 } else {
156 String::new()
157 }
158 )
159 }
160
161 fn render_video(&self, attrs: &HashMap<String, String>) -> String {
162 let Some(raw_src) = attrs.get("src") else {
163 return "<!-- video shortcode: missing src attribute -->".to_string();
164 };
165 let src = Self::normalize_src(raw_src);
166
167 let controls = attrs.contains_key("controls") || !attrs.contains_key("nocontrols");
168 let autoplay = attrs.contains_key("autoplay");
169 let loop_attr = attrs.contains_key("loop");
170 let muted = attrs.contains_key("muted") || autoplay; let class = attrs
172 .get("class")
173 .map(|s| s.as_str())
174 .unwrap_or("media-video");
175 let poster = attrs.get("poster");
176 let width = attrs.get("width");
177 let height = attrs.get("height");
178
179 let mut video_attrs = vec![
180 format!(r#"class="{}""#, html_escape(class)),
181 "preload=\"metadata\"".to_string(),
182 ];
183
184 if controls {
185 video_attrs.push("controls".to_string());
186 }
187 if autoplay {
188 video_attrs.push("autoplay".to_string());
189 }
190 if loop_attr {
191 video_attrs.push("loop".to_string());
192 }
193 if muted {
194 video_attrs.push("muted".to_string());
195 }
196 if let Some(p) = poster {
197 video_attrs.push(format!(r#"poster="/media/{}""#, html_escape(p)));
198 }
199 if let Some(w) = width {
200 video_attrs.push(format!(r#"width="{}""#, html_escape(w)));
201 }
202 if let Some(h) = height {
203 video_attrs.push(format!(r#"height="{}""#, html_escape(h)));
204 }
205
206 let extension = src.rsplit('.').next().unwrap_or("").to_lowercase();
208 let mime_type = match extension.as_str() {
209 "mp4" => "video/mp4",
210 "webm" => "video/webm",
211 _ => "video/mp4",
212 };
213
214 format!(
215 r#"<figure class="media-figure">
216<video {}>
217<source src="/media/{}" type="{}">
218Your browser does not support the video tag.
219</video>
220</figure>"#,
221 video_attrs.join(" "),
222 html_escape(src),
223 mime_type
224 )
225 }
226
227 fn render_audio(&self, attrs: &HashMap<String, String>) -> String {
228 let Some(raw_src) = attrs.get("src") else {
229 return "<!-- audio shortcode: missing src attribute -->".to_string();
230 };
231 let src = Self::normalize_src(raw_src);
232
233 let controls = attrs.contains_key("controls") || !attrs.contains_key("nocontrols");
234 let autoplay = attrs.contains_key("autoplay");
235 let loop_attr = attrs.contains_key("loop");
236 let class = attrs
237 .get("class")
238 .map(|s| s.as_str())
239 .unwrap_or("media-audio");
240
241 let mut audio_attrs = vec![format!(r#"class="{}""#, html_escape(class))];
242
243 if controls {
244 audio_attrs.push("controls".to_string());
245 }
246 if autoplay {
247 audio_attrs.push("autoplay".to_string());
248 }
249 if loop_attr {
250 audio_attrs.push("loop".to_string());
251 }
252
253 let extension = src.rsplit('.').next().unwrap_or("").to_lowercase();
255 let mime_type = match extension.as_str() {
256 "mp3" => "audio/mpeg",
257 "ogg" => "audio/ogg",
258 _ => "audio/mpeg",
259 };
260
261 format!(
262 r#"<figure class="media-figure">
263<audio {}>
264<source src="/media/{}" type="{}">
265Your browser does not support the audio tag.
266</audio>
267</figure>"#,
268 audio_attrs.join(" "),
269 html_escape(src),
270 mime_type
271 )
272 }
273
274 fn render_pdf(&self, attrs: &HashMap<String, String>) -> String {
275 let Some(raw_src) = attrs.get("src") else {
276 return "<!-- pdf shortcode: missing src attribute -->".to_string();
277 };
278 let src = Self::normalize_src(raw_src);
279
280 let width = attrs.get("width").map(|s| s.as_str()).unwrap_or("100%");
281 let height = attrs.get("height").map(|s| s.as_str()).unwrap_or("600px");
282 let title = attrs
283 .get("title")
284 .map(|s| s.as_str())
285 .unwrap_or("PDF Document");
286
287 format!(
288 r#"<figure class="media-figure media-pdf">
289<iframe src="/media/{}" width="{}" height="{}" title="{}" class="media-pdf-embed">
290<p>Your browser does not support PDFs. <a href="/media/{}">Download the PDF</a>.</p>
291</iframe>
292</figure>"#,
293 html_escape(src),
294 html_escape(width),
295 html_escape(height),
296 html_escape(title),
297 html_escape(src)
298 )
299 }
300
301 fn render_gallery(&self, attrs: &HashMap<String, String>) -> String {
302 let Some(raw_src) = attrs.get("src") else {
303 return "<!-- gallery shortcode: missing src attribute -->".to_string();
304 };
305
306 let class = attrs
307 .get("class")
308 .map(|s| s.as_str())
309 .unwrap_or("media-gallery");
310 let columns = attrs.get("columns").map(|s| s.as_str()).unwrap_or("3");
311
312 let images: Vec<String> = raw_src
313 .split(',')
314 .map(|s| Self::normalize_src(s.trim()).to_string())
315 .collect();
316
317 let mut gallery_html = format!(
318 r#"<div class="{}" style="display: grid; grid-template-columns: repeat({}, 1fr); gap: 1rem;">"#,
319 html_escape(class),
320 html_escape(columns)
321 );
322
323 for image in &images {
324 let base_name = image.rsplit_once('.').map(|(n, _)| n).unwrap_or(image);
325 let webp_src = format!("{}.webp", base_name);
326 let thumb_src = format!("{}-thumb.webp", base_name);
327
328 gallery_html.push_str(&format!(
329 r#"
330<a href="/media/{}" class="gallery-item">
331<picture>
332<source srcset="/media/{}" type="image/webp">
333<img src="/media/{}" alt="" loading="lazy" class="gallery-image">
334</picture>
335</a>"#,
336 html_escape(image),
337 html_escape(&thumb_src),
338 html_escape(&webp_src),
339 ));
340 }
341
342 gallery_html.push_str("\n</div>");
343 gallery_html
344 }
345}
346
347pub struct MarkdownRenderer {
348 syntax_set: SyntaxSet,
349 theme_set: ThemeSet,
350 sanitizer: Builder<'static>,
351 shortcode_processor: ShortcodeProcessor,
352}
353
354impl Default for MarkdownRenderer {
355 fn default() -> Self {
356 Self::new()
357 }
358}
359
360impl MarkdownRenderer {
361 pub fn new() -> Self {
362 let mut tags = ammonia::Builder::default().clone_tags();
363 tags.insert("pre");
364 tags.insert("code");
365 tags.insert("span");
366 tags.insert("table");
367 tags.insert("thead");
368 tags.insert("tbody");
369 tags.insert("tr");
370 tags.insert("th");
371 tags.insert("td");
372 tags.insert("del");
373 tags.insert("input");
374 tags.insert("figure");
376 tags.insert("figcaption");
377 tags.insert("picture");
378 tags.insert("source");
379 tags.insert("video");
380 tags.insert("audio");
381 tags.insert("iframe");
382
383 let mut attrs = ammonia::Builder::default().clone_tag_attributes();
384 attrs.insert("span", ["style"].iter().cloned().collect());
385 attrs.insert(
386 "input",
387 ["type", "checked", "disabled"].iter().cloned().collect(),
388 );
389 attrs.insert("h1", ["id"].iter().cloned().collect());
391 attrs.insert("h2", ["id"].iter().cloned().collect());
392 attrs.insert("h3", ["id"].iter().cloned().collect());
393 attrs.insert("h4", ["id"].iter().cloned().collect());
394 attrs.insert("h5", ["id"].iter().cloned().collect());
395 attrs.insert("h6", ["id"].iter().cloned().collect());
396 attrs.insert(
398 "img",
399 ["src", "alt", "title", "width", "height", "loading"]
400 .iter()
401 .cloned()
402 .collect(),
403 );
404 attrs.insert(
405 "source",
406 ["src", "srcset", "type", "media"].iter().cloned().collect(),
407 );
408 attrs.insert(
409 "video",
410 [
411 "src", "controls", "autoplay", "loop", "muted", "poster", "width", "height",
412 "preload",
413 ]
414 .iter()
415 .cloned()
416 .collect(),
417 );
418 attrs.insert(
419 "audio",
420 ["src", "controls", "autoplay", "loop"]
421 .iter()
422 .cloned()
423 .collect(),
424 );
425 attrs.insert(
426 "iframe",
427 ["src", "width", "height", "title"]
428 .iter()
429 .cloned()
430 .collect(),
431 );
432 attrs.insert("div", ["style"].iter().cloned().collect());
433
434 let mut sanitizer = Builder::default();
435 sanitizer
436 .tags(tags)
437 .tag_attributes(attrs)
438 .add_allowed_classes(
439 "code",
440 &[
441 "language-rust",
442 "language-python",
443 "language-javascript",
444 "language-typescript",
445 "language-go",
446 "language-c",
447 "language-cpp",
448 "language-java",
449 "language-html",
450 "language-css",
451 "language-json",
452 "language-yaml",
453 "language-toml",
454 "language-sql",
455 "language-bash",
456 "language-shell",
457 "language-markdown",
458 ],
459 )
460 .add_allowed_classes("pre", &["code-block"])
461 .add_allowed_classes("figure", &["media-figure", "media-pdf"])
462 .add_allowed_classes("img", &["media-image", "gallery-image"])
463 .add_allowed_classes("video", &["media-video"])
464 .add_allowed_classes("audio", &["media-audio"])
465 .add_allowed_classes("iframe", &["media-pdf-embed"])
466 .add_allowed_classes("div", &["media-gallery"])
467 .add_allowed_classes("a", &["media-link", "gallery-item"])
468 .link_rel(Some("noopener noreferrer"));
469
470 Self {
471 syntax_set: SyntaxSet::load_defaults_newlines(),
472 theme_set: ThemeSet::load_defaults(),
473 sanitizer,
474 shortcode_processor: ShortcodeProcessor::new(),
475 }
476 }
477
478 pub fn render(&self, markdown: &str) -> String {
479 let processed = self.shortcode_processor.process(markdown);
481
482 let options = Options::ENABLE_TABLES
483 | Options::ENABLE_FOOTNOTES
484 | Options::ENABLE_STRIKETHROUGH
485 | Options::ENABLE_TASKLISTS
486 | Options::ENABLE_HEADING_ATTRIBUTES;
487
488 let parser = Parser::new_ext(&processed, options);
489 let mut events: Vec<pulldown_cmark::Event> = Vec::new();
490 let mut in_code_block = false;
491 let mut code_lang = String::new();
492 let mut code_content = String::new();
493
494 let mut in_heading = false;
496 let mut heading_text = String::new();
497
498 for event in parser {
499 match event {
500 pulldown_cmark::Event::Start(pulldown_cmark::Tag::CodeBlock(kind)) => {
501 in_code_block = true;
502 code_lang = match kind {
503 pulldown_cmark::CodeBlockKind::Fenced(lang) => lang.to_string(),
504 _ => String::new(),
505 };
506 code_content.clear();
507 }
508 pulldown_cmark::Event::End(pulldown_cmark::TagEnd::CodeBlock) => {
509 in_code_block = false;
510 let highlighted = self.highlight_code(&code_content, &code_lang);
511 events.push(pulldown_cmark::Event::Html(highlighted.into()));
512 }
513 pulldown_cmark::Event::Text(text) if in_code_block => {
514 code_content.push_str(&text);
515 }
516 pulldown_cmark::Event::Start(pulldown_cmark::Tag::Heading {
518 level,
519 id,
520 classes,
521 attrs,
522 }) => {
523 in_heading = true;
524 heading_text.clear();
525 if id.is_some() {
527 events.push(pulldown_cmark::Event::Start(pulldown_cmark::Tag::Heading {
528 level,
529 id,
530 classes,
531 attrs,
532 }));
533 in_heading = false; }
535 }
536 pulldown_cmark::Event::End(pulldown_cmark::TagEnd::Heading(level)) => {
537 if in_heading {
538 let slug = slugify(&heading_text);
540 let level_num = match level {
541 pulldown_cmark::HeadingLevel::H1 => 1,
542 pulldown_cmark::HeadingLevel::H2 => 2,
543 pulldown_cmark::HeadingLevel::H3 => 3,
544 pulldown_cmark::HeadingLevel::H4 => 4,
545 pulldown_cmark::HeadingLevel::H5 => 5,
546 pulldown_cmark::HeadingLevel::H6 => 6,
547 };
548 let heading_html = format!(
550 r#"<h{} id="{}">{}</h{}>"#,
551 level_num,
552 html_escape(&slug),
553 html_escape(&heading_text),
554 level_num
555 );
556 events.push(pulldown_cmark::Event::Html(heading_html.into()));
557 in_heading = false;
558 } else {
559 events.push(pulldown_cmark::Event::End(pulldown_cmark::TagEnd::Heading(
560 level,
561 )));
562 }
563 }
564 pulldown_cmark::Event::Text(text) if in_heading => {
565 heading_text.push_str(&text);
566 }
567 _ => events.push(event),
568 }
569 }
570
571 let mut html_output = String::new();
572 html::push_html(&mut html_output, events.into_iter());
573
574 self.sanitizer.clean(&html_output).to_string()
575 }
576
577 fn highlight_code(&self, code: &str, lang: &str) -> String {
578 let syntax = self
579 .syntax_set
580 .find_syntax_by_token(lang)
581 .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
582
583 let theme = &self.theme_set.themes["base16-ocean.dark"];
584
585 match highlighted_html_for_string(code, &self.syntax_set, syntax, theme) {
586 Ok(html) => {
587 let inner = html
590 .trim()
591 .strip_prefix("<pre style=\"background-color:#2b303b;\">\n")
592 .and_then(|s| s.strip_suffix("\n</pre>"))
593 .or_else(|| {
594 html.trim()
595 .strip_prefix("<pre style=\"background-color:#2b303b;\">")
596 .and_then(|s| s.strip_suffix("</pre>"))
597 })
598 .unwrap_or(&html);
599 format!(
600 r#"<pre class="code-block"><code class="language-{}">{}</code></pre>"#,
601 lang, inner
602 )
603 }
604 Err(_) => format!(
605 r#"<pre class="code-block"><code class="language-{}">{}</code></pre>"#,
606 lang,
607 html_escape(code)
608 ),
609 }
610 }
611
612 pub fn generate_excerpt(&self, markdown: &str, max_len: usize) -> String {
613 let text: String = markdown
614 .lines()
615 .filter(|line| {
616 let trimmed = line.trim();
617 !trimmed.is_empty()
618 && !trimmed.starts_with('#')
619 && !trimmed.starts_with("```")
620 && !trimmed.starts_with('|')
621 && !trimmed.starts_with("---")
622 && !trimmed.starts_with("![")
623 && !trimmed.starts_with("> ")
624 })
625 .collect::<Vec<_>>()
626 .join(" ");
627
628 let text = strip_markdown(&text);
629
630 let char_count = text.chars().count();
631 if char_count <= max_len {
632 text
633 } else {
634 let truncated: String = text.chars().take(max_len).collect();
635 let last_space_pos = truncated
636 .char_indices()
637 .rev()
638 .find(|(_, c)| *c == ' ')
639 .map(|(i, _)| i);
640
641 if let Some(pos) = last_space_pos {
642 format!("{}...", &truncated[..pos])
643 } else {
644 format!("{}...", truncated)
645 }
646 }
647 }
648
649 pub fn calculate_reading_time(&self, markdown: &str) -> u32 {
652 let word_count = markdown
653 .split_whitespace()
654 .filter(|word| !word.starts_with('#') && !word.starts_with("```"))
655 .count();
656
657 ((word_count as f64 / 200.0).ceil() as u32).max(1)
659 }
660}
661
662fn html_escape(s: &str) -> String {
663 s.replace('&', "&")
664 .replace('<', "<")
665 .replace('>', ">")
666 .replace('"', """)
667}
668
669fn slugify(text: &str) -> String {
671 text.to_lowercase()
672 .chars()
673 .map(|c| {
674 if c.is_alphanumeric() {
675 c
676 } else if c.is_whitespace() || c == '-' || c == '_' {
677 '-'
678 } else {
679 '\0'
681 }
682 })
683 .filter(|&c| c != '\0')
684 .collect::<String>()
685 .split('-')
687 .filter(|s| !s.is_empty())
688 .collect::<Vec<_>>()
689 .join("-")
690}
691
692fn strip_markdown(text: &str) -> String {
693 let mut result = text.to_string();
694
695 while let Some(start) = result.find('`') {
697 if let Some(end) = result[start + 1..].find('`') {
698 let code_content = &result[start + 1..start + 1 + end];
699 result = format!(
700 "{}{}{}",
701 &result[..start],
702 code_content,
703 &result[start + 2 + end..]
704 );
705 } else {
706 break;
707 }
708 }
709
710 while let Some(bracket_start) = result.find('[') {
712 if let Some(bracket_end) = result[bracket_start..].find("](") {
713 let abs_bracket_end = bracket_start + bracket_end;
714 if let Some(paren_end) = result[abs_bracket_end + 2..].find(')') {
715 let link_text = &result[bracket_start + 1..abs_bracket_end];
716 result = format!(
717 "{}{}{}",
718 &result[..bracket_start],
719 link_text,
720 &result[abs_bracket_end + 3 + paren_end..]
721 );
722 } else {
723 break;
724 }
725 } else {
726 break;
727 }
728 }
729
730 result = result.replace("***", "");
732 result = result.replace("**", "");
733 result = result.replace("__", "");
734 result = result.replace('*', "");
735 result = result.replace('_', " ");
736
737 while let Some(img_start) = result.find("![") {
739 if let Some(bracket_end) = result[img_start + 2..].find("](") {
740 let abs_bracket_end = img_start + 2 + bracket_end;
741 if let Some(paren_end) = result[abs_bracket_end + 2..].find(')') {
742 result = format!(
743 "{}{}",
744 &result[..img_start],
745 &result[abs_bracket_end + 3 + paren_end..]
746 );
747 } else {
748 break;
749 }
750 } else {
751 break;
752 }
753 }
754
755 while result.contains(" ") {
757 result = result.replace(" ", " ");
758 }
759
760 result.trim().to_string()
761}
762
763#[cfg(test)]
764mod tests {
765 use super::*;
766
767 #[test]
768 fn test_slugify() {
769 assert_eq!(slugify("Quick Start"), "quick-start");
770 assert_eq!(slugify("CLI Commands"), "cli-commands");
771 assert_eq!(slugify("Hello World!"), "hello-world");
772 assert_eq!(slugify("Test Multiple Spaces"), "test-multiple-spaces");
773 assert_eq!(slugify("Already-Hyphenated"), "already-hyphenated");
774 }
775
776 #[test]
777 fn test_heading_ids() {
778 let renderer = MarkdownRenderer::new();
779 let input = "## Quick Start\n\nSome content here.";
780 let output = renderer.render(input);
781 assert!(
782 output.contains(r#"id="quick-start""#),
783 "Output was: {}",
784 output
785 );
786 }
787
788 #[test]
789 fn test_toc_links_match_headings() {
790 let renderer = MarkdownRenderer::new();
791 let input = r#"## Table of Contents
792
793- [Quick Start](#quick-start)
794- [CLI Commands](#cli-commands)
795
796## Quick Start
797
798Getting started guide.
799
800## CLI Commands
801
802Command reference.
803"#;
804 let output = renderer.render(input);
805 assert!(
807 output.contains(r#"id="quick-start""#),
808 "Missing quick-start ID. Output: {}",
809 output
810 );
811 assert!(
812 output.contains(r#"id="cli-commands""#),
813 "Missing cli-commands ID. Output: {}",
814 output
815 );
816 assert!(
817 output.contains("href=\"#quick-start\""),
818 "Missing quick-start link. Output: {}",
819 output
820 );
821 }
822}