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