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