Skip to main content

pebble_cms/services/
markdown.rs

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
10// Statically compiled regexes - avoids runtime panic and improves performance
11static 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
17/// Shortcode processor for embedding media and other dynamic content.
18///
19/// Supported shortcodes:
20/// - `[media src="filename.jpg"]` - Auto-detects type and embeds appropriately
21/// - `[image src="filename.jpg" alt="description"]` - Embeds image with optional alt text
22/// - `[video src="filename.mp4" controls]` - Embeds video player
23/// - `[audio src="filename.mp3" controls]` - Embeds audio player
24/// - `[gallery src="file1.jpg,file2.jpg,file3.jpg"]` - Embeds a gallery of images
25pub 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    /// Process all shortcodes in the content and return the processed content.
39    pub fn process(&self, content: &str) -> String {
40        SHORTCODE_REGEX
41            .replace_all(content, |caps: &regex::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(), // Unknown shortcode, leave as-is
53                }
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        // Determine type from extension
80        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    /// Normalize the src path by removing any leading /media/ prefix
96    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        // Build srcset for responsive images (webp variants at 400w, 800w, 1200w, 1600w)
117        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        // Use picture element with responsive srcset and webp fallback
144        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; // Autoplay requires muted
171        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        // Determine MIME type from extension
207        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        // Determine MIME type from extension
254        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        // Media embedding tags
375        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        // Allow id attributes on headings for TOC anchor links
390        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        // Media element attributes (class is handled via add_allowed_classes)
397        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        // Process shortcodes first (before markdown parsing)
480        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        // Track heading state for adding IDs
495        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                // Handle headings to add ID attributes
517                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 the heading already has an ID from {#custom-id} syntax, use it
526                    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; // Don't process further, it already has an ID
534                    }
535                }
536                pulldown_cmark::Event::End(pulldown_cmark::TagEnd::Heading(level)) => {
537                    if in_heading {
538                        // Generate slug from heading text
539                        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                        // Emit heading with ID as raw HTML
549                        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                // syntect outputs <pre style="..."><span>...</span></pre>
588                // We need to strip the outer <pre> and just use the inner content
589                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    /// Calculate estimated reading time in minutes based on word count.
650    /// Uses 200 words per minute as average reading speed.
651    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        // 200 words per minute, minimum 1 minute
658        ((word_count as f64 / 200.0).ceil() as u32).max(1)
659    }
660}
661
662fn html_escape(s: &str) -> String {
663    s.replace('&', "&amp;")
664        .replace('<', "&lt;")
665        .replace('>', "&gt;")
666        .replace('"', "&quot;")
667}
668
669/// Convert text to a URL-friendly slug for heading IDs
670fn 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                // Skip other characters
680                '\0'
681            }
682        })
683        .filter(|&c| c != '\0')
684        .collect::<String>()
685        // Collapse multiple dashes
686        .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    // Remove inline code
696    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    // Remove links [text](url) -> text
711    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    // Remove bold/italic markers
731    result = result.replace("***", "");
732    result = result.replace("**", "");
733    result = result.replace("__", "");
734    result = result.replace('*', "");
735    result = result.replace('_', " ");
736
737    // Remove images ![alt](url)
738    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    // Clean up multiple spaces
756    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        // Check that heading IDs match TOC links
806        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}