Skip to main content

docs_pipeline/
markdown.rs

1//! Markdown parsing and rendering.
2//!
3//! This module provides markdown parsing capabilities using `pulldown-cmark`,
4//! supporting CommonMark and GitHub Flavored Markdown (GFM), plus Tachyon-style
5//! extensions:
6//!
7//! - Wikilinks (`[[target]]`, `[[target|display]]`)
8//! - Admonitions (`> [!note]`)
9//! - Embeds (`![youtube](id)`, `![{type id]}` extraction)
10//! - Block references / transclusions (`![[doc#heading]]`)
11//! - Table of contents extraction (from markdown source or rendered HTML)
12//!
13//! ## Sanitization
14//!
15//! HTML output is sanitized with `ammonia` before being returned. Script tags,
16//! event handlers (`on*`), and `javascript:` URLs are stripped, while `class`
17//! and other safe attributes are preserved for syntax highlighting.
18//!
19//! ## MDX-style component passthrough
20//!
21//! Raw HTML (including JSX-like components such as `<MyComponent>`) is parsed
22//! by `pulldown-cmark` as HTML events and then passed through the ammonia
23//! sanitizer. Unknown/custom element tags are **removed** by the default
24//! allowlist — consumers who want specific custom components to survive must
25//! add them via a custom ammonia builder (see [`sanitize`]).
26//!
27//! ## Why no streaming/chunked rendering?
28//!
29//! pulldown-cmark is already an incremental, event-driven parser (it yields
30//! `Event` items via a standard Rust `Iterator`). The `html::push_html` call
31//! consumes this stream in a single pass with no intermediate buffering.
32//! Benchmark data shows pulldown-cmark renders 1 MB of markdown in <100 ms on
33//! modern hardware, so streaming only matters for documents >10 MB — far
34//! beyond typical knowledge-base notes. Additionally, the `ammonia` XSS
35//! sanitizer operates on the complete HTML string, making true chunked output
36//! incorrect (tags can span chunk boundaries). Streaming would add complexity
37//! without measurable benefit for this use case.
38
39use crate::embeds;
40use crate::error::{Error, Result};
41use crate::types::{MarkdownOptions, OutputFormat, RenderMetadata, RenderResult, RenderStats};
42use pulldown_cmark::{html, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
43use regex::Regex;
44use std::cell::Cell;
45use std::sync::LazyLock;
46use std::time::Instant;
47use tracing::{debug, instrument};
48
49// ============================================================================
50// Static Regex Patterns (compiled once, zero per-call overhead)
51// ============================================================================
52
53static EMBED_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!\{(\w+):\s*([^}]+)\}").unwrap());
54
55static WIKILINK_RE: LazyLock<Regex> =
56    LazyLock::new(|| Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap());
57
58static ADMONITION_HEADER_RE: LazyLock<Regex> =
59    LazyLock::new(|| Regex::new(r"^>\s*\[!(\w+)\]").unwrap());
60
61static ADMONITION_BODY_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^>\s?(.*)").unwrap());
62
63// Rendered-HTML TOC patterns (used by [`extract_toc_from_html`] and
64// [`extract_inline_toc`]; headings must already carry `id` attributes).
65
66static TOC_HEADING_REGEX: LazyLock<Regex> =
67    LazyLock::new(|| Regex::new(r#"<h([1-6])[^>]*id="([^"]*)"[^>]*>(.*?)</h[1-6]>"#).unwrap());
68
69static HTML_STRIP_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]+>").unwrap());
70
71static INLINE_TOC_REGEX: LazyLock<Regex> =
72    LazyLock::new(|| Regex::new(r#"<h([23])[^>]*id="([^"]*)"[^>]*>(.*?)</h[23]>"#).unwrap());
73
74// ============================================================================
75// TOC & Embed Types
76// ============================================================================
77
78/// A single entry in the table of contents (extracted from markdown source).
79#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
80pub struct TocEntry {
81    /// Heading level (1-6).
82    pub level: usize,
83    /// Slugified heading ID for anchor links.
84    pub slug: String,
85    /// Heading text.
86    pub text: String,
87}
88
89/// A single entry in a table of contents extracted from **rendered HTML**.
90#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
91pub struct HtmlTocEntry {
92    /// Heading level (1-6).
93    pub level: u8,
94    /// The heading element's `id` attribute.
95    pub id: String,
96    /// Heading text (HTML tags stripped).
97    pub title: String,
98}
99
100/// An embed block extracted from content.
101#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
102pub struct EmbedBlock {
103    /// Embed type (youtube, vimeo, figma, mermaid, plantuml, codepen, github).
104    pub kind: String,
105    /// Embed identifier (video ID, file hash, diagram code, etc).
106    pub id: String,
107}
108
109/// A block reference (transclusion) parsed from markdown.
110/// Syntax: `![[doc-id]]` or `![[doc-id#heading]]` or `![[doc-id#^block-id]]`
111#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
112pub struct BlockReference {
113    /// The target document slug or ID.
114    pub target: String,
115    /// Optional heading to transclude within the target document.
116    pub heading: Option<String>,
117    /// Optional block-level reference (e.g., `^block-id`).
118    pub block_id: Option<String>,
119    /// Whether this is a "reference only" (`![[doc-id#^block-id]]`) vs. full embed.
120    pub reference_only: bool,
121}
122
123// ============================================================================
124// MarkdownParser
125// ============================================================================
126
127/// Markdown parser for parsing and rendering markdown documents
128pub struct MarkdownParser {
129    /// Compiled pulldown-cmark options
130    cmark_options: Options,
131}
132
133impl MarkdownParser {
134    /// Create a new markdown parser with default options
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    /// Create a new markdown parser with custom options
140    pub fn with_options(options: MarkdownOptions) -> Self {
141        let cmark_options = Self::build_cmark_options(&options);
142        Self { cmark_options }
143    }
144
145    /// Build pulldown-cmark options from our MarkdownOptions
146    fn build_cmark_options(opts: &MarkdownOptions) -> Options {
147        let mut options = Options::empty();
148
149        if opts.enable_gfm {
150            options.insert(Options::ENABLE_STRIKETHROUGH);
151            options.insert(Options::ENABLE_TABLES);
152            options.insert(Options::ENABLE_TASKLISTS);
153        }
154
155        if opts.enable_footnotes {
156            options.insert(Options::ENABLE_FOOTNOTES);
157        }
158
159        if opts.enable_strikethrough && !opts.enable_gfm {
160            options.insert(Options::ENABLE_STRIKETHROUGH);
161        }
162
163        if opts.enable_tables && !opts.enable_gfm {
164            options.insert(Options::ENABLE_TABLES);
165        }
166
167        if opts.enable_task_lists && !opts.enable_gfm {
168            options.insert(Options::ENABLE_TASKLISTS);
169        }
170
171        if opts.enable_smart_punctuation {
172            options.insert(Options::ENABLE_SMART_PUNCTUATION);
173        }
174
175        if opts.enable_heading_attributes {
176            options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
177        }
178
179        options
180    }
181
182    /// Extract table of contents headings from markdown content.
183    ///
184    /// Returns heading level (1-6), slug, and text for each heading found
185    /// outside of code blocks.
186    pub fn extract_toc(content: &str) -> Vec<TocEntry> {
187        let mut entries = Vec::new();
188        let mut in_code_block = false;
189
190        for line in content.lines() {
191            if line.trim_start().starts_with("```") {
192                in_code_block = !in_code_block;
193                continue;
194            }
195            if in_code_block {
196                continue;
197            }
198            let trimmed = line.trim_start();
199            let level = trimmed.chars().take_while(|&c| c == '#').count();
200            if level == 0 || level > 6 {
201                continue;
202            }
203            let rest = &trimmed[level..];
204            // An ATX heading requires a space (or end of line) after the hashes.
205            if !rest.is_empty() && !rest.starts_with(' ') {
206                continue;
207            }
208            let text = rest.trim().to_string();
209            if text.is_empty() {
210                continue;
211            }
212            let slug = text
213                .to_lowercase()
214                .chars()
215                .map(|c| {
216                    if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
217                        c
218                    } else {
219                        '-'
220                    }
221                })
222                .collect::<String>();
223            entries.push(TocEntry { level, slug, text });
224        }
225        entries
226    }
227
228    /// Extract embed blocks `!{type id}` from content.
229    ///
230    /// Recognized types: youtube, vimeo, figma, mermaid, plantuml, codepen, github.
231    /// Skips content inside code blocks.
232    pub fn extract_embeds(content: &str) -> Vec<EmbedBlock> {
233        let mut embeds = Vec::new();
234        let mut in_code_block = false;
235
236        for line in content.lines() {
237            if line.trim_start().starts_with("```") {
238                in_code_block = !in_code_block;
239                continue;
240            }
241            if !in_code_block {
242                for caps in EMBED_RE.captures_iter(line) {
243                    let kind = caps[1].to_lowercase();
244                    let id = caps[2].trim().to_string();
245                    if !id.is_empty() {
246                        embeds.push(EmbedBlock { kind, id });
247                    }
248                }
249            }
250        }
251        embeds
252    }
253
254    /// Pre-process admonition blocks `> [!type]` into HTML divs.
255    ///
256    /// Converts blocks like:
257    /// ```markdown
258    /// > [!note]
259    /// > This is a note
260    /// ```
261    ///
262    /// Into:
263    /// ```html
264    /// <div class="admonition admonition-note"><div class="admonition-title">Note</div><div class="admonition-content">
265    /// This is a note
266    /// </div></div>
267    /// ```
268    fn preprocess_admonitions(content: &str) -> String {
269        let mut result = String::with_capacity(content.len());
270        let mut in_code_block = false;
271        let mut in_admonition = false;
272        let mut admonition_type = String::new();
273        let mut admonition_lines: Vec<String> = Vec::new();
274
275        for line in content.lines() {
276            if line.trim_start().starts_with("```") {
277                if in_admonition {
278                    result.push_str(&format_admonition_html(&admonition_type, &admonition_lines));
279                    in_admonition = false;
280                    admonition_lines.clear();
281                }
282                in_code_block = !in_code_block;
283                result.push_str(line);
284                result.push('\n');
285                continue;
286            }
287
288            if in_code_block {
289                result.push_str(line);
290                result.push('\n');
291                continue;
292            }
293
294            if !in_admonition {
295                if let Some(caps) = ADMONITION_HEADER_RE.captures(line) {
296                    in_admonition = true;
297                    admonition_type = caps.get(1).unwrap().as_str().to_lowercase();
298                    continue;
299                }
300            }
301
302            if in_admonition {
303                if let Some(caps) = ADMONITION_BODY_RE.captures(line) {
304                    admonition_lines.push(caps.get(1).unwrap().as_str().to_string());
305                    continue;
306                } else {
307                    result.push_str(&format_admonition_html(&admonition_type, &admonition_lines));
308                    in_admonition = false;
309                    admonition_lines.clear();
310                }
311            }
312
313            result.push_str(line);
314            result.push('\n');
315        }
316
317        if in_admonition {
318            result.push_str(&format_admonition_html(&admonition_type, &admonition_lines));
319        }
320
321        if result.ends_with('\n') {
322            result.pop();
323        }
324
325        result
326    }
327
328    /// Pre-process wikilinks [[target]] and [[target|display]] into HTML anchors.
329    ///
330    /// Converts to `<a href="/documents/{slug}" class="wikilink">{text}</a>`.
331    /// Skips wikilinks inside code blocks.
332    fn preprocess_wikilinks(content: &str) -> String {
333        let mut result = String::with_capacity(content.len());
334        let mut in_code_block = false;
335
336        for line in content.lines() {
337            if line.trim_start().starts_with("```") {
338                in_code_block = !in_code_block;
339                result.push_str(line);
340                result.push('\n');
341                continue;
342            }
343
344            if in_code_block {
345                result.push_str(line);
346                result.push('\n');
347            } else {
348                let replaced = WIKILINK_RE.replace_all(line, |caps: &regex::Captures| {
349                    let target: &str = &caps[1];
350                    let display: &str = match caps.get(2) {
351                        Some(m) => m.as_str(),
352                        None => target,
353                    };
354                    let slug = target
355                        .to_lowercase()
356                        .chars()
357                        .map(|c| {
358                            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
359                                c
360                            } else {
361                                '-'
362                            }
363                        })
364                        .collect::<String>();
365                    format!(
366                        "<a href=\"/documents/{}\" class=\"wikilink\">{}</a>",
367                        slug, display
368                    )
369                });
370                result.push_str(&replaced);
371                result.push('\n');
372            }
373        }
374
375        result.pop();
376        result
377    }
378
379    /// Pre-process embed blocks `![type](url)` into raw HTML.
380    ///
381    /// Converts recognized embed types (youtube, figma, gist, codepen, tweet)
382    /// into HTML embed markup. Passes through unrecognized image syntax unchanged.
383    /// Skips content inside code blocks.
384    fn preprocess_embeds(content: &str) -> String {
385        let mut result = String::with_capacity(content.len());
386        let mut in_code_block = false;
387
388        for line in content.lines() {
389            if line.trim_start().starts_with("```") {
390                in_code_block = !in_code_block;
391                result.push_str(line);
392                result.push('\n');
393                continue;
394            }
395
396            if in_code_block {
397                result.push_str(line);
398                result.push('\n');
399                continue;
400            }
401
402            let mut processed = line.to_string();
403            for alt in ["youtube", "figma", "gist", "codepen", "tweet"] {
404                let needle = format!("![{}](", alt);
405                while let Some(pos) = processed.find(&needle) {
406                    let after = &processed[pos + needle.len()..];
407                    if let Some(close) = after.find(')') {
408                        let url = after[..close].trim().to_string();
409                        if !url.is_empty() {
410                            if let Some(html) = embeds::render_embed(alt, &url) {
411                                let end = pos + needle.len() + close + 1;
412                                processed.replace_range(pos..end, &html);
413                                continue;
414                            }
415                        }
416                        break;
417                    } else {
418                        break;
419                    }
420                }
421            }
422            result.push_str(&processed);
423            result.push('\n');
424        }
425
426        if result.ends_with('\n') {
427            result.pop();
428        }
429        result
430    }
431
432    /// Extract block references (transclusions) from markdown content.
433    ///
434    /// Parses `![[target]]`, `![[target#heading]]`, `![[target#^block-id]]` syntax.
435    /// Skips references inside code blocks and inline code.
436    ///
437    /// Returns the block references found and their positions.
438    pub fn extract_block_references(&self, content: &str) -> Vec<(usize, BlockReference)> {
439        let mut references = Vec::new();
440        let mut in_code_block = false;
441        let mut code_fence_marker = String::new();
442
443        for (line_idx, line) in content.lines().enumerate() {
444            if line.trim().starts_with("```") || line.trim().starts_with("~~~") {
445                if !in_code_block {
446                    in_code_block = true;
447                    code_fence_marker = line.trim().chars().take(3).collect();
448                } else if line.trim().starts_with(&code_fence_marker) {
449                    in_code_block = false;
450                    code_fence_marker.clear();
451                }
452                continue;
453            }
454
455            if in_code_block {
456                continue;
457            }
458
459            let mut search_start = 0;
460            let chars: Vec<char> = line.chars().collect();
461
462            while search_start < chars.len() {
463                if chars.get(search_start) == Some(&'`') {
464                    let tick_count = count_consecutive(&chars[search_start..], '`');
465                    let close_pos =
466                        find_closing_backtick(&chars, search_start + tick_count, tick_count);
467                    if let Some(pos) = close_pos {
468                        search_start = pos + tick_count;
469                    } else {
470                        search_start = chars.len();
471                    }
472                    continue;
473                }
474
475                if search_start + 2 < chars.len()
476                    && chars[search_start] == '!'
477                    && chars[search_start + 1] == '['
478                    && chars[search_start + 2] == '['
479                {
480                    let close = find_closing_brackets(&chars, search_start + 3, '[', ']');
481                    if let Some(end_pos) = close {
482                        let inner: String = chars[search_start + 3..end_pos].iter().collect();
483                        if let Some(reference) = parse_block_reference(&inner) {
484                            let offset = content[..]
485                                .lines()
486                                .take(line_idx)
487                                .map(|l| l.len() + 1)
488                                .sum::<usize>()
489                                + search_start;
490                            references.push((offset, reference));
491                        }
492                        search_start = end_pos + 2;
493                    } else {
494                        search_start += 1;
495                    }
496                } else {
497                    search_start += 1;
498                }
499            }
500        }
501
502        references
503    }
504
505    /// Extract all wikilink targets from content (without converting)
506    pub fn extract_wikilinks(content: &str) -> Vec<String> {
507        let mut in_code_block = false;
508        let mut targets = Vec::new();
509
510        for line in content.lines() {
511            if line.trim_start().starts_with("```") {
512                in_code_block = !in_code_block;
513                continue;
514            }
515
516            if !in_code_block {
517                for caps in WIKILINK_RE.captures_iter(line) {
518                    targets.push(caps[1].to_string());
519                }
520            }
521        }
522
523        targets
524    }
525
526    /// Parse markdown content
527    #[instrument(skip(self, markdown), fields(format = ?format))]
528    pub fn parse<S: AsRef<str>>(&self, markdown: S, format: OutputFormat) -> Result<RenderResult> {
529        let markdown = markdown.as_ref();
530        let markdown_str = Self::preprocess_wikilinks(markdown);
531        let start_time = Instant::now();
532
533        debug!("Parsing markdown content ({} bytes)", markdown_str.len());
534
535        let (content, metadata, stats) = match format {
536            OutputFormat::Html => self.parse_to_html(&markdown_str)?,
537            OutputFormat::PlainText => self.parse_to_plain_text(&markdown_str)?,
538            OutputFormat::Ast => self.parse_to_ast(&markdown_str)?,
539            OutputFormat::Markdown => {
540                let metadata = self.extract_metadata(&markdown_str);
541                let stats = RenderStats::new()
542                    .with_render_time(start_time.elapsed())
543                    .with_output_size(markdown_str.len());
544                (markdown_str.to_string(), metadata, stats)
545            }
546        };
547
548        let render_time = start_time.elapsed();
549        let stats = stats
550            .with_render_time(render_time)
551            .with_output_size(content.len());
552
553        debug!(
554            "Parsed markdown in {}ms, output {} bytes",
555            render_time.as_millis(),
556            content.len()
557        );
558
559        Ok(RenderResult::new(content, format)
560            .with_metadata(metadata)
561            .with_stats(stats))
562    }
563
564    /// Parse markdown to HTML
565    fn parse_to_html(&self, markdown: &str) -> Result<(String, RenderMetadata, RenderStats)> {
566        let markdown = Self::preprocess_wikilinks(markdown);
567        let markdown = Self::preprocess_admonitions(&markdown);
568        let markdown = Self::preprocess_embeds(&markdown);
569        let parser = Parser::new_ext(&markdown, self.cmark_options);
570
571        let metadata = self.extract_metadata(&markdown);
572        let mut stats = RenderStats::new();
573
574        let code_block_count = Cell::new(0u32);
575        let parser_with_count = parser.inspect(|event| {
576            if matches!(event, Event::Start(Tag::CodeBlock(_))) {
577                code_block_count.set(code_block_count.get() + 1);
578            }
579        });
580
581        let mut html_output = String::with_capacity(markdown.len() * 2);
582        html::push_html(&mut html_output, parser_with_count);
583
584        html_output = ammonia::Builder::default()
585            .add_tags([
586                "img",
587                "pre",
588                "code",
589                "span",
590                "div",
591                "a",
592                "iframe",
593                "blockquote",
594            ])
595            .add_generic_attributes(&["class", "id", "style", "data-video-id", "data-tweet-url"])
596            .add_tag_attributes("img", ["src", "alt", "title", "width", "height", "loading"])
597            .add_tag_attributes("a", ["href", "title"])
598            .add_tag_attributes(
599                "iframe",
600                [
601                    "src",
602                    "width",
603                    "height",
604                    "frameborder",
605                    "allowfullscreen",
606                    "loading",
607                    "sandbox",
608                ],
609            )
610            .clean(&html_output)
611            .to_string();
612
613        for _ in 0..code_block_count.get() {
614            stats.increment_code_blocks();
615        }
616
617        Ok((html_output, metadata, stats))
618    }
619
620    /// Parse markdown to plain text
621    fn parse_to_plain_text(&self, markdown: &str) -> Result<(String, RenderMetadata, RenderStats)> {
622        let parser = Parser::new_ext(markdown, self.cmark_options);
623        let metadata = self.extract_metadata(markdown);
624        let stats = RenderStats::new();
625
626        let mut plain_text = String::with_capacity(markdown.len());
627
628        for event in parser {
629            match event {
630                Event::Text(text) => {
631                    plain_text.push_str(&text);
632                }
633                Event::Code(code) => {
634                    plain_text.push_str(&code);
635                }
636                Event::SoftBreak | Event::HardBreak => {
637                    plain_text.push('\n');
638                }
639                Event::End(TagEnd::Paragraph) | Event::End(TagEnd::Heading(_)) => {
640                    plain_text.push_str("\n\n");
641                }
642                _ => {}
643            }
644        }
645
646        // Clean up extra whitespace
647        let plain_text = plain_text
648            .lines()
649            .map(|line| line.trim())
650            .collect::<Vec<_>>()
651            .join("\n")
652            .trim()
653            .to_string();
654
655        Ok((plain_text, metadata, stats))
656    }
657
658    /// Parse markdown to AST representation (JSON)
659    fn parse_to_ast(&self, markdown: &str) -> Result<(String, RenderMetadata, RenderStats)> {
660        let parser = Parser::new_ext(markdown, self.cmark_options);
661        let metadata = self.extract_metadata(markdown);
662        let stats = RenderStats::new();
663
664        let mut events = Vec::new();
665        for event in parser {
666            let event_str = match &event {
667                Event::Start(tag) => format!("Start: {:?}", tag),
668                Event::End(tag_end) => format!("End: {:?}", tag_end),
669                Event::Text(text) => format!("Text: {}", text),
670                Event::Code(code) => format!("Code: {}", code),
671                Event::Html(html) => format!("Html: {}", html),
672                Event::InlineHtml(html) => format!("InlineHtml: {}", html),
673                Event::InlineMath(math) => format!("InlineMath: {}", math),
674                Event::DisplayMath(math) => format!("DisplayMath: {}", math),
675                Event::FootnoteReference(name) => format!("FootnoteReference: {}", name),
676                Event::SoftBreak => "SoftBreak".to_string(),
677                Event::HardBreak => "HardBreak".to_string(),
678                Event::Rule => "Rule".to_string(),
679                Event::TaskListMarker(checked) => format!("TaskListMarker: {}", checked),
680            };
681            events.push(event_str);
682        }
683
684        let ast = serde_json::to_string_pretty(&events)
685            .map_err(|e| Error::serialization(e.to_string()))?;
686
687        Ok((ast, metadata, stats))
688    }
689
690    /// Extract metadata from markdown content
691    fn extract_metadata(&self, markdown: &str) -> RenderMetadata {
692        let mut metadata = RenderMetadata::new();
693
694        let mut word_count = 0;
695        let mut char_count = 0;
696        let mut heading_count = 0;
697        let mut code_block_count = 0;
698        let mut first_heading: Option<String> = None;
699
700        for event in Parser::new_ext(markdown, self.cmark_options) {
701            match &event {
702                Event::Start(Tag::CodeBlock(_)) => {
703                    code_block_count += 1;
704                }
705                Event::Start(Tag::Heading { level, .. }) => {
706                    heading_count += 1;
707                    if first_heading.is_none() && *level == HeadingLevel::H1 {
708                        // Next text event will be the title
709                    }
710                }
711                Event::Text(text) => {
712                    char_count += text.len();
713                    word_count += text.split_whitespace().count();
714
715                    // Use first H1 as title
716                    if first_heading.is_none() {
717                        // Simple heuristic: first text in document is often the title
718                        if !text.trim().is_empty() {
719                            first_heading = Some(text.chars().take(100).collect());
720                        }
721                    }
722                }
723                _ => {}
724            }
725        }
726
727        metadata.title = first_heading;
728        metadata.word_count = word_count;
729        metadata.char_count = char_count;
730        metadata.heading_count = heading_count;
731        metadata.code_block_count = code_block_count;
732
733        metadata
734    }
735}
736
737impl Default for MarkdownParser {
738    fn default() -> Self {
739        Self::with_options(MarkdownOptions::default())
740    }
741}
742
743// ============================================================================
744// Free functions
745// ============================================================================
746
747/// Render markdown to HTML with default options, sanitizing the output.
748///
749/// This is a convenience wrapper around [`MarkdownParser::parse`] with
750/// [`OutputFormat::Html`]. On error, the (escaped) input is wrapped in a
751/// `<div class="render-error">` block so callers can always insert the result
752/// into a page.
753pub fn render_markdown(content: &str) -> String {
754    try_render_markdown(content, &MarkdownOptions::default())
755        .map(|r| r.content)
756        .unwrap_or_else(|_| format!("<div class=\"render-error\">{}</div>", html_escape(content)))
757}
758
759/// Render markdown with explicit options, returning the full render result.
760pub fn try_render_markdown(content: &str, options: &MarkdownOptions) -> Result<RenderResult> {
761    MarkdownParser::with_options(options.clone()).parse(content, OutputFormat::Html)
762}
763
764/// Extract table of contents headings from markdown source.
765///
766/// See [`MarkdownParser::extract_toc`].
767pub fn extract_toc(content: &str) -> Vec<TocEntry> {
768    MarkdownParser::extract_toc(content)
769}
770
771/// Extract table of contents from **rendered HTML**.
772///
773/// Matches `<h1>`–`<h6>` elements that already carry `id` attributes
774/// (e.g. assigned by an `add_heading_ids` pass) and strips inner HTML tags
775/// from the titles.
776pub fn extract_toc_from_html(html: &str) -> Vec<HtmlTocEntry> {
777    TOC_HEADING_REGEX
778        .captures_iter(html)
779        .map(|cap| HtmlTocEntry {
780            level: cap[1].parse().unwrap_or(2),
781            id: cap[2].to_string(),
782            title: decode_basic_entities(&strip_html_tags(&cap[3])),
783        })
784        .collect()
785}
786
787/// Extract an inline (h2/h3 only) table of contents from rendered HTML.
788pub fn extract_inline_toc(html: &str) -> Vec<HtmlTocEntry> {
789    INLINE_TOC_REGEX
790        .captures_iter(html)
791        .map(|cap| HtmlTocEntry {
792            level: cap[1].parse().unwrap_or(2),
793            id: cap[2].to_string(),
794            title: decode_basic_entities(&strip_html_tags(&cap[3])),
795        })
796        .collect()
797}
798
799/// Strip all HTML tags from a string (used for TOC titles).
800pub fn strip_html_tags(html: &str) -> String {
801    HTML_STRIP_REGEX.replace_all(html, "").to_string()
802}
803
804/// Decode the five basic HTML entities in TOC titles so downstream escaping
805/// does not double-escape them.
806fn decode_basic_entities(s: &str) -> String {
807    s.replace("&lt;", "<")
808        .replace("&gt;", ">")
809        .replace("&quot;", "\"")
810        .replace("&#39;", "'")
811        .replace("&amp;", "&")
812}
813
814/// Escape a string for safe inclusion in HTML text content.
815#[allow(dead_code)]
816fn html_escape(s: &str) -> String {
817    s.replace('&', "&amp;")
818        .replace('<', "&lt;")
819        .replace('>', "&gt;")
820        .replace('"', "&quot;")
821        .replace('\'', "&#39;")
822}
823
824/// Format an admonition block as HTML.
825fn format_admonition_html(admonition_type: &str, lines: &[String]) -> String {
826    let title = match admonition_type {
827        "note" => "Note",
828        "tip" => "Tip",
829        "info" => "Info",
830        "warning" => "Warning",
831        "danger" => "Danger",
832        "caution" => "Caution",
833        _ => admonition_type,
834    };
835
836    let content = lines.join("\n");
837
838    format!(
839        "<div class=\"admonition admonition-{type}\">\
840         <div class=\"admonition-title\">{title}</div>\
841         <div class=\"admonition-content\">{content}</div>\
842         </div>",
843        type = admonition_type,
844        title = title,
845        content = content
846    )
847}
848
849/// Parse the inner content of a block reference `[[target]]`, `[[target#heading]]`, `[[target#^block-id]]`.
850fn parse_block_reference(inner: &str) -> Option<BlockReference> {
851    let inner = inner.trim();
852    if inner.is_empty() {
853        return None;
854    }
855
856    let (inner, reference_only) = if let Some(stripped) = inner.strip_prefix('!') {
857        (stripped, true)
858    } else {
859        (inner, false)
860    };
861
862    let target;
863    let mut heading = None;
864    let mut block_id = None;
865
866    if let Some(hash_pos) = inner.find('#') {
867        target = inner[..hash_pos].trim().to_string();
868        let fragment = &inner[hash_pos + 1..];
869
870        if let Some(stripped) = fragment.strip_prefix('^') {
871            block_id = Some(stripped.trim().to_string());
872        } else {
873            heading = Some(fragment.trim().to_string());
874        }
875    } else {
876        target = inner.trim().to_string();
877    }
878
879    if target.is_empty() {
880        return None;
881    }
882
883    Some(BlockReference {
884        target,
885        heading,
886        block_id,
887        reference_only,
888    })
889}
890
891/// Count consecutive occurrences of a character at the start of a slice.
892fn count_consecutive(chars: &[char], target: char) -> usize {
893    chars.iter().take_while(|c| **c == target).count()
894}
895
896/// Find the closing backtick matching the opening tick count.
897fn find_closing_backtick(chars: &[char], start: usize, tick_count: usize) -> Option<usize> {
898    let mut pos = start;
899    while pos + tick_count <= chars.len() {
900        if chars[pos] == '`' && count_consecutive(&chars[pos..], '`') >= tick_count {
901            return Some(pos);
902        }
903        pos += 1;
904    }
905    None
906}
907
908/// Find closing `]]` bracket pair.
909fn find_closing_brackets(chars: &[char], start: usize, open: char, close: char) -> Option<usize> {
910    let mut depth = 1i32;
911    let mut pos = start;
912    while pos < chars.len() {
913        if chars[pos] == open {
914            depth += 1;
915        } else if chars[pos] == close {
916            depth -= 1;
917            if depth == 0 {
918                return Some(pos);
919            }
920        }
921        pos += 1;
922    }
923    None
924}
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929
930    #[test]
931    fn test_parse_simple_markdown() {
932        let parser = MarkdownParser::new();
933        let result = parser
934            .parse("# Hello World\n\nThis is a test.", OutputFormat::Html)
935            .unwrap();
936
937        assert!(result.content.contains("<h1>"));
938        assert!(result.content.contains("Hello World"));
939        assert_eq!(result.format, OutputFormat::Html);
940    }
941
942    #[test]
943    fn test_parse_to_plain_text() {
944        let parser = MarkdownParser::new();
945        let result = parser
946            .parse("# Hello World\n\nThis is a test.", OutputFormat::PlainText)
947            .unwrap();
948
949        assert!(result.content.contains("Hello World"));
950        assert!(result.content.contains("This is a test"));
951        assert!(!result.content.contains("<"));
952    }
953
954    #[test]
955    fn test_parse_to_ast() {
956        let parser = MarkdownParser::new();
957        let result = parser.parse("# Hello", OutputFormat::Ast).unwrap();
958
959        assert!(result.content.contains("Start"));
960        assert!(result.content.contains("Heading"));
961    }
962
963    #[test]
964    fn test_metadata_extraction() {
965        let parser = MarkdownParser::new();
966        let result = parser
967            .parse("# Document Title\n\nSome content here.", OutputFormat::Html)
968            .unwrap();
969
970        assert!(result.metadata.title.is_some());
971        assert_eq!(result.metadata.heading_count, 1);
972        assert!(result.metadata.word_count > 0);
973    }
974
975    #[test]
976    fn test_code_block_counting() {
977        let parser = MarkdownParser::new();
978        let markdown = r#"
979# Code Example
980
981```rust
982fn main() {
983    println!("Hello");
984}
985```
986
987Some more text.
988"#;
989        let result = parser.parse(markdown, OutputFormat::Html).unwrap();
990
991        assert_eq!(result.metadata.code_block_count, 1);
992    }
993
994    #[test]
995    fn test_youtube_embed_rendered() {
996        let parser = MarkdownParser::new();
997        let result = parser
998            .parse("![youtube](dQw4w9WgXcQ)", OutputFormat::Html)
999            .unwrap();
1000        assert!(result.content.contains("embed-youtube"));
1001        assert!(result.content.contains("youtube.com/embed/dQw4w9WgXcQ"));
1002        assert!(result.content.contains("iframe"));
1003    }
1004
1005    #[test]
1006    fn test_figma_embed_rendered() {
1007        let parser = MarkdownParser::new();
1008        let result = parser
1009            .parse(
1010                "![figma](https://www.figma.com/file/abc)",
1011                OutputFormat::Html,
1012            )
1013            .unwrap();
1014        assert!(result.content.contains("embed-figma"));
1015        assert!(result.content.contains("figma.com/embed"));
1016    }
1017
1018    #[test]
1019    fn test_gist_embed_rendered() {
1020        let parser = MarkdownParser::new();
1021        let result = parser
1022            .parse(
1023                "![gist](https://gist.github.com/user/abc)",
1024                OutputFormat::Html,
1025            )
1026            .unwrap();
1027        assert!(result.content.contains("embed-gist"));
1028        assert!(result.content.contains("gist.github.com/user/abc.js"));
1029    }
1030
1031    #[test]
1032    fn test_codepen_embed_rendered() {
1033        let parser = MarkdownParser::new();
1034        let result = parser
1035            .parse(
1036                "![codepen](https://codepen.io/user/pen/abc)",
1037                OutputFormat::Html,
1038            )
1039            .unwrap();
1040        assert!(result.content.contains("embed-codepen"));
1041        assert!(result.content.contains("codepen.io/embed/"));
1042    }
1043
1044    #[test]
1045    fn test_tweet_embed_rendered() {
1046        let parser = MarkdownParser::new();
1047        let result = parser
1048            .parse(
1049                "![tweet](https://x.com/user/status/123)",
1050                OutputFormat::Html,
1051            )
1052            .unwrap();
1053        assert!(result.content.contains("embed-tweet"));
1054        assert!(result.content.contains("twitter-tweet"));
1055    }
1056
1057    #[test]
1058    fn test_embeds_have_lazy_loading() {
1059        let parser = MarkdownParser::new();
1060        let result = parser.parse("![youtube](abc)", OutputFormat::Html).unwrap();
1061        assert!(result.content.contains("loading=\"lazy\""));
1062    }
1063
1064    #[test]
1065    fn test_embeds_have_sandbox() {
1066        let parser = MarkdownParser::new();
1067        let result = parser.parse("![youtube](abc)", OutputFormat::Html).unwrap();
1068        assert!(result.content.contains("sandbox="));
1069    }
1070
1071    #[test]
1072    fn test_embeds_skipped_in_code_blocks() {
1073        let parser = MarkdownParser::new();
1074        let result = parser
1075            .parse("```\n![youtube](abc)\n```", OutputFormat::Html)
1076            .unwrap();
1077        assert!(!result.content.contains("embed-youtube"));
1078    }
1079
1080    #[test]
1081    fn test_regular_images_preserved() {
1082        let parser = MarkdownParser::new();
1083        let result = parser
1084            .parse("![alt](https://example.com/img.png)", OutputFormat::Html)
1085            .unwrap();
1086        assert!(result.content.contains("<img"));
1087        assert!(result
1088            .content
1089            .contains("src=\"https://example.com/img.png\""));
1090    }
1091
1092    #[test]
1093    fn test_gfm_features() {
1094        let parser = MarkdownParser::new();
1095        let markdown = "| Col1 | Col2 |\n|------|------|\n| A | B |";
1096        let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1097
1098        assert!(result.content.contains("<table"));
1099    }
1100
1101    #[test]
1102    fn test_pass_through() {
1103        let parser = MarkdownParser::new();
1104        let markdown = "# Hello";
1105        let result = parser.parse(markdown, OutputFormat::Markdown).unwrap();
1106
1107        assert_eq!(result.content, markdown);
1108    }
1109
1110    #[test]
1111    fn test_preprocess_wikilinks_basic() {
1112        let result = MarkdownParser::preprocess_wikilinks("See [[Hello]] for details");
1113        assert_eq!(
1114            result,
1115            r#"See <a href="/documents/hello" class="wikilink">Hello</a> for details"#
1116        );
1117    }
1118
1119    #[test]
1120    fn test_preprocess_wikilinks_with_display() {
1121        let result = MarkdownParser::preprocess_wikilinks("Click [[Hello|Click here]] now");
1122        assert_eq!(
1123            result,
1124            r#"Click <a href="/documents/hello" class="wikilink">Click here</a> now"#
1125        );
1126    }
1127
1128    #[test]
1129    fn test_preprocess_wikilinks_in_code_block() {
1130        let input = "Before\n```\n[[Hello]]\n```\nAfter [[World]]";
1131        let result = MarkdownParser::preprocess_wikilinks(input);
1132        assert!(
1133            result.contains("[[Hello]]"),
1134            "wikilink inside code block should NOT be converted"
1135        );
1136        assert!(
1137            result.contains(r#"<a href="/documents/world" class="wikilink">World</a>"#),
1138            "wikilink outside code block should be converted to HTML anchor"
1139        );
1140    }
1141
1142    #[test]
1143    fn test_preprocess_wikilinks_multiple() {
1144        let input = "Check [[Alpha]], [[Beta|the beta doc]], and [[Gamma]]";
1145        let result = MarkdownParser::preprocess_wikilinks(input);
1146        assert_eq!(
1147            result,
1148            concat!(
1149                r#"Check <a href="/documents/alpha" class="wikilink">Alpha</a>, "#,
1150                r#"<a href="/documents/beta" class="wikilink">the beta doc</a>, "#,
1151                r#"and <a href="/documents/gamma" class="wikilink">Gamma</a>"#
1152            )
1153        );
1154    }
1155
1156    #[test]
1157    fn test_preprocess_wikilinks_slug_with_special_chars() {
1158        let result = MarkdownParser::preprocess_wikilinks("[[My Document Title]]");
1159        assert_eq!(
1160            result,
1161            r#"<a href="/documents/my-document-title" class="wikilink">My Document Title</a>"#
1162        );
1163    }
1164
1165    #[test]
1166    fn test_extract_wikilinks() {
1167        let input = "Link to [[Foo]] and [[Bar|display]]\n```\n[[Ignored]]\n```\n[[After]]";
1168        let targets = MarkdownParser::extract_wikilinks(input);
1169        assert_eq!(targets, vec!["Foo", "Bar", "After"]);
1170    }
1171
1172    // ── XSS Sanitization Tests ──────────────────────────────────────────
1173
1174    #[test]
1175    fn test_xss_script_tag_stripped() {
1176        let parser = MarkdownParser::new();
1177        let markdown = r#"<script>alert("xss")</script>"#;
1178        let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1179
1180        assert!(
1181            !result.content.contains("<script"),
1182            "Script tags must be stripped by ammonia sanitization, got: {}",
1183            result.content
1184        );
1185        assert!(
1186            !result.content.contains("alert"),
1187            "Script content must be stripped, got: {}",
1188            result.content
1189        );
1190    }
1191
1192    #[test]
1193    fn test_xss_event_handler_stripped() {
1194        let parser = MarkdownParser::new();
1195        // pulldown-cmark treats raw HTML as Event::Html, which ammonia then sanitizes
1196        let markdown = r#"<img src=x onerror="alert('xss')">"#;
1197        let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1198
1199        assert!(
1200            !result.content.contains("onerror"),
1201            "Event handlers must be stripped by ammonia, got: {}",
1202            result.content
1203        );
1204    }
1205
1206    #[test]
1207    fn test_xss_javascript_uri_stripped() {
1208        let parser = MarkdownParser::new();
1209        let markdown = r#"[click me](javascript:alert('xss'))"#;
1210        let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1211
1212        assert!(
1213            !result.content.contains("javascript:"),
1214            "javascript: URIs must be stripped by ammonia, got: {}",
1215            result.content
1216        );
1217    }
1218
1219    #[test]
1220    fn test_xss_iframe_stripped() {
1221        let parser = MarkdownParser::new();
1222        let markdown = r#"<iframe src="https://evil.com" onload="alert('xss')"></iframe>"#;
1223        let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1224
1225        // iframe is now allowed for embeds, but event handlers must be stripped
1226        assert!(
1227            !result.content.contains("onload"),
1228            "Event handlers must be stripped by ammonia, got: {}",
1229            result.content
1230        );
1231        // iframe src is preserved (for embeds)
1232        assert!(
1233            result.content.contains("<iframe"),
1234            "iframe should be preserved for embeds, got: {}",
1235            result.content
1236        );
1237    }
1238
1239    #[test]
1240    fn test_xss_svg_onload_stripped() {
1241        let parser = MarkdownParser::new();
1242        let markdown = r#"<svg onload="alert('xss')"><circle r="40"/></svg>"#;
1243        let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1244
1245        assert!(
1246            !result.content.contains("onload"),
1247            "SVG onload handlers must be stripped by ammonia, got: {}",
1248            result.content
1249        );
1250    }
1251
1252    #[test]
1253    fn test_safe_content_preserved_after_sanitization() {
1254        let parser = MarkdownParser::new();
1255        let markdown = "# Hello\n\nParagraph with **bold** and *italic*.\n\n```rust\nfn main() {}\n```\n\n[link](https://example.com)";
1256        let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1257
1258        assert!(result.content.contains("<h1>"));
1259        assert!(result.content.contains("<strong>bold</strong>"));
1260        assert!(result.content.contains("<em>italic</em>"));
1261        assert!(result.content.contains("<code"));
1262        assert!(result.content.contains("href=\"https://example.com\""));
1263    }
1264
1265    #[test]
1266    fn test_image_rendering() {
1267        let parser = MarkdownParser::new();
1268        let markdown = "![alt text](https://example.com/image.png)";
1269        let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1270
1271        assert!(
1272            result.content.contains("<img"),
1273            "Expected HTML to contain <img tag, got: {}",
1274            result.content
1275        );
1276        assert!(
1277            result.content.contains("alt=\"alt text\""),
1278            "Expected img to preserve alt attribute"
1279        );
1280        assert!(
1281            result
1282                .content
1283                .contains("src=\"https://example.com/image.png\""),
1284            "Expected img to preserve src attribute"
1285        );
1286    }
1287
1288    // ── MDX Component Passthrough (documented behavior) ─────────────────
1289
1290    /// MDX-style components in markdown are treated as raw HTML by
1291    /// pulldown-cmark. The default ammonia allowlist strips unknown custom
1292    /// tags — consumers must allowlist component names they want to keep.
1293    #[test]
1294    fn test_mdx_component_stripped_by_default() {
1295        let parser = MarkdownParser::new();
1296        let markdown = "<MyComponent prop=\"x\">inner text</MyComponent>";
1297        let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1298        assert!(
1299            !result.content.contains("<MyComponent"),
1300            "unknown custom components must be sanitized away, got: {}",
1301            result.content
1302        );
1303    }
1304
1305    /// Inline content of a stripped custom component is preserved as text.
1306    #[test]
1307    fn test_mdx_component_inline_content_preserved() {
1308        let parser = MarkdownParser::new();
1309        let markdown = "Before <MyBadge>beta</MyBadge> after";
1310        let result = parser.parse(markdown, OutputFormat::Html).unwrap();
1311        assert!(result.content.contains("Before"));
1312        assert!(result.content.contains("beta"));
1313        assert!(result.content.contains("after"));
1314    }
1315
1316    // ── Block Reference Tests ───────────────────────────────────────────
1317
1318    #[test]
1319    fn test_extract_block_references_basic() {
1320        let parser = MarkdownParser::new();
1321        let content = "See ![[design-specs]] for details.";
1322        let refs = parser.extract_block_references(content);
1323        assert_eq!(refs.len(), 1);
1324        assert_eq!(refs[0].1.target, "design-specs");
1325        assert_eq!(refs[0].1.heading, None);
1326        assert_eq!(refs[0].1.block_id, None);
1327        assert!(!refs[0].1.reference_only);
1328    }
1329
1330    #[test]
1331    fn test_extract_block_references_with_heading() {
1332        let parser = MarkdownParser::new();
1333        let content = "Embed ![[api-docs#authentication]] here.";
1334        let refs = parser.extract_block_references(content);
1335        assert_eq!(refs.len(), 1);
1336        assert_eq!(refs[0].1.target, "api-docs");
1337        assert_eq!(refs[0].1.heading, Some("authentication".to_string()));
1338        assert_eq!(refs[0].1.block_id, None);
1339    }
1340
1341    #[test]
1342    fn test_extract_block_references_with_block_id() {
1343        let parser = MarkdownParser::new();
1344        let content = "Reference ![[notes#^important-quote]] inline.";
1345        let refs = parser.extract_block_references(content);
1346        assert_eq!(refs.len(), 1);
1347        assert_eq!(refs[0].1.target, "notes");
1348        assert_eq!(refs[0].1.heading, None);
1349        assert_eq!(refs[0].1.block_id, Some("important-quote".to_string()));
1350    }
1351
1352    #[test]
1353    fn test_extract_block_references_reference_only() {
1354        let parser = MarkdownParser::new();
1355        let content = "See ![[!design-specs]] for the original.";
1356        let refs = parser.extract_block_references(content);
1357        assert_eq!(refs.len(), 1);
1358        assert_eq!(refs[0].1.target, "design-specs");
1359        assert!(refs[0].1.reference_only);
1360    }
1361
1362    #[test]
1363    fn test_extract_block_references_skips_code_blocks() {
1364        let parser = MarkdownParser::new();
1365        let content = "Before.\n```\n![[should-not-parse]]\n```\nAfter ![[real-ref]].";
1366        let refs = parser.extract_block_references(content);
1367        assert_eq!(refs.len(), 1);
1368        assert_eq!(refs[0].1.target, "real-ref");
1369    }
1370
1371    #[test]
1372    fn test_extract_block_references_skips_inline_code() {
1373        let parser = MarkdownParser::new();
1374        let content = "Use `![[not-a-ref]]` literally, but ![[actual-ref]] embeds.";
1375        let refs = parser.extract_block_references(content);
1376        assert_eq!(refs.len(), 1);
1377        assert_eq!(refs[0].1.target, "actual-ref");
1378    }
1379
1380    #[test]
1381    fn test_extract_block_references_multiple() {
1382        let parser = MarkdownParser::new();
1383        let content = "![[doc-a]] and ![[doc-b#intro]] and ![[doc-c#^key]]";
1384        let refs = parser.extract_block_references(content);
1385        assert_eq!(refs.len(), 3);
1386        assert_eq!(refs[0].1.target, "doc-a");
1387        assert_eq!(refs[1].1.target, "doc-b");
1388        assert_eq!(refs[1].1.heading, Some("intro".to_string()));
1389        assert_eq!(refs[2].1.target, "doc-c");
1390        assert_eq!(refs[2].1.block_id, Some("key".to_string()));
1391    }
1392
1393    #[test]
1394    fn test_extract_block_references_empty() {
1395        let parser = MarkdownParser::new();
1396        let content = "No references here.";
1397        let refs = parser.extract_block_references(content);
1398        assert!(refs.is_empty());
1399    }
1400
1401    #[test]
1402    fn test_parse_block_reference_invalid() {
1403        assert!(parse_block_reference("").is_none());
1404        assert!(parse_block_reference("#").is_none());
1405    }
1406
1407    // ── TOC Extraction Tests ────────────────────────────────────────────
1408
1409    #[test]
1410    fn test_extract_toc_levels() {
1411        let content = "# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6";
1412        let toc = extract_toc(content);
1413        assert_eq!(toc.len(), 6);
1414        let levels: Vec<usize> = toc.iter().map(|e| e.level).collect();
1415        assert_eq!(levels, vec![1, 2, 3, 4, 5, 6]);
1416        assert_eq!(toc[0].text, "H1");
1417        assert_eq!(toc[5].text, "H6");
1418    }
1419
1420    #[test]
1421    fn test_extract_toc_nesting_order() {
1422        let content = "# Intro\n\n## Setup\n\n### Prerequisites\n\n### Install\n\n## Usage\n\n### CLI\n\n# Outro";
1423        let toc = extract_toc(content);
1424        let texts: Vec<&str> = toc.iter().map(|e| e.text.as_str()).collect();
1425        assert_eq!(
1426            texts,
1427            vec![
1428                "Intro",
1429                "Setup",
1430                "Prerequisites",
1431                "Install",
1432                "Usage",
1433                "CLI",
1434                "Outro"
1435            ]
1436        );
1437        // Level sequence reflects document nesting
1438        let levels: Vec<usize> = toc.iter().map(|e| e.level).collect();
1439        assert_eq!(levels, vec![1, 2, 3, 3, 2, 3, 1]);
1440        // A level-3 entry follows its level-2 parent
1441        assert!(toc[2].level > toc[1].level);
1442        assert_eq!(toc[2].slug, "prerequisites");
1443    }
1444
1445    #[test]
1446    fn test_extract_toc_skips_code_blocks() {
1447        let content = "# Real\n\n```\n# not a heading\n```\n\n## Also Real";
1448        let toc = extract_toc(content);
1449        let texts: Vec<&str> = toc.iter().map(|e| e.text.as_str()).collect();
1450        assert_eq!(texts, vec!["Real", "Also Real"]);
1451    }
1452
1453    #[test]
1454    fn test_extract_toc_slugification() {
1455        let toc = extract_toc("## My Cool Feature (v2)!");
1456        assert_eq!(toc[0].slug, "my-cool-feature--v2--");
1457        assert_eq!(toc[0].text, "My Cool Feature (v2)!");
1458    }
1459
1460    #[test]
1461    fn test_extract_toc_requires_space_after_hashes() {
1462        let content = "#tag not a heading\n\n# Real Heading";
1463        let toc = extract_toc(content);
1464        assert_eq!(toc.len(), 1);
1465        assert_eq!(toc[0].text, "Real Heading");
1466    }
1467
1468    // ── HTML TOC Tests ──────────────────────────────────────────────────
1469
1470    #[test]
1471    fn test_extract_toc_from_html() {
1472        let html = r#"<h2 id="intro">Intro</h2><p>text</p><h3 id="setup">Setup &amp; <em>Config</em></h3>"#;
1473        let toc = extract_toc_from_html(html);
1474        assert_eq!(toc.len(), 2);
1475        assert_eq!(toc[0].level, 2);
1476        assert_eq!(toc[0].id, "intro");
1477        assert_eq!(toc[0].title, "Intro");
1478        assert_eq!(toc[1].id, "setup");
1479        assert_eq!(toc[1].title, "Setup & Config");
1480    }
1481
1482    #[test]
1483    fn test_extract_inline_toc_only_h2_h3() {
1484        let html = r#"<h1 id="a">A</h1><h2 id="b">B</h2><h3 id="c">C</h3><h4 id="d">D</h4>"#;
1485        let toc = extract_inline_toc(html);
1486        let ids: Vec<&str> = toc.iter().map(|e| e.id.as_str()).collect();
1487        assert_eq!(ids, vec!["b", "c"]);
1488    }
1489
1490    // ── Convenience Function Tests ──────────────────────────────────────
1491
1492    #[test]
1493    fn test_render_markdown_free_function() {
1494        let html = render_markdown("# Free Function\n\nBody **bold**.");
1495        assert!(html.contains("<h1>"));
1496        assert!(html.contains("Free Function"));
1497        assert!(html.contains("<strong>bold</strong>"));
1498    }
1499
1500    #[test]
1501    fn test_try_render_markdown_options() {
1502        // Individual flags apply when the GFM bundle is off.
1503        let opts = MarkdownOptions {
1504            enable_gfm: false,
1505            enable_tables: false,
1506            ..MarkdownOptions::default()
1507        };
1508        let result = try_render_markdown("| a | b |\n|---|---|\n| 1 | 2 |", &opts).unwrap();
1509        assert!(!result.content.contains("<table"));
1510    }
1511
1512    #[test]
1513    fn test_ammonia_allows_class_on_code() {
1514        let html = r#"<pre><code class="language-json">{"key": "value"}</code></pre>"#;
1515        let cleaned = ammonia::Builder::default()
1516            .add_tags(["img", "pre", "code", "span", "div"])
1517            .add_generic_attributes(&["class"])
1518            .add_tag_attributes("img", ["src", "alt", "title", "width", "height", "loading"])
1519            .clean(html)
1520            .to_string();
1521        assert!(
1522            cleaned.contains(r#"class="language-json""#),
1523            "Expected class preserved, got: {}",
1524            cleaned
1525        );
1526    }
1527
1528    #[test]
1529    fn test_code_block_preserves_class_attribute() {
1530        let md = r#"```json
1531{"key": "value"}
1532```"#;
1533        let parser = MarkdownParser::new();
1534        let result = parser.parse(md, OutputFormat::Html).unwrap();
1535        assert!(
1536            result.content.contains(r#"class="language-json""#),
1537            "Expected code block to have class=\"language-json\", got: {}",
1538            &result.content
1539        );
1540    }
1541}