Skip to main content

mdx_gen/
markdown.rs

1//! Core Markdown processing functionality.
2//!
3//! This module handles the conversion of Markdown content into HTML,
4//! with support for custom blocks, enhanced tables, and syntax highlighting.
5//!
6//! ## Processing Pipeline
7//!
8//! 1. **Parse** — Markdown source → comrak AST (arena-allocated).
9//! 2. **Transform** — Walk the AST to rewrite custom-block `HtmlBlock`
10//!    nodes in-place.
11//! 3. **Render** — Convert the (possibly modified) AST to HTML, using
12//!    comrak's plugin system for syntax highlighting.
13//! 4. **Enhance** — Post-process table HTML for responsive wrappers and
14//!    alignment classes.
15//! 5. **Sanitize** — When `allow_unsafe_html` is `false`, run ammonia to
16//!    strip dangerous tags while preserving safe structural markup.
17
18use crate::error::MarkdownError;
19use crate::extensions::{
20    collect_headings, enhance_table_nodes, process_custom_block_nodes,
21    CustomBlockConfig, Heading,
22};
23use comrak::options::Plugins;
24#[cfg(feature = "syntax_highlighting")]
25use comrak::options::RenderPlugins;
26use comrak::{Arena, Options};
27use log::{debug, info, warn};
28use std::collections::{HashMap, HashSet};
29use std::fmt;
30use std::io::Write;
31use std::sync::LazyLock;
32
33#[cfg(feature = "syntax_highlighting")]
34use crate::highlight::SyntectAdapter;
35
36/// Default maximum input size: 1 MiB.
37pub const DEFAULT_MAX_INPUT_SIZE: usize = 1_048_576;
38
39/// Options for configuring Markdown processing behavior.
40#[derive(Clone)]
41pub struct MarkdownOptions<'a> {
42    /// Options for the underlying Comrak Markdown parser.
43    pub comrak_options: Options<'a>,
44    /// Enable or disable processing of custom blocks.
45    pub enable_custom_blocks: bool,
46    /// Enable or disable syntax highlighting for code blocks.
47    pub enable_syntax_highlighting: bool,
48    /// Enable or disable enhanced table formatting.
49    pub enable_enhanced_tables: bool,
50    /// Optional custom theme for syntax highlighting.
51    pub syntax_theme: Option<String>,
52    /// Allow raw HTML pass-through in Markdown output.
53    ///
54    /// When `true`, raw HTML in the Markdown source is passed through
55    /// unchanged. When `false` (the default), output is sanitized with
56    /// ammonia to strip dangerous tags while preserving safe
57    /// structural HTML (our generated alert divs, tables, code
58    /// blocks, etc.).
59    pub allow_unsafe_html: bool,
60    /// Configuration for custom block rendering.
61    pub custom_block_config: CustomBlockConfig,
62    /// Maximum input size in bytes. `0` means no limit.
63    pub max_input_size: usize,
64    /// Enable automatic `id` attributes on headings for anchor links.
65    ///
66    /// When `Some(prefix)`, headings get `id="prefix-slug"` attributes.
67    /// Use `Some("")` for bare `id="slug"` without a prefix.
68    /// `None` disables header IDs (default).
69    pub header_ids: Option<String>,
70    /// Optional extensions to the default HTML sanitizer allow-list.
71    ///
72    /// When `None`, the cached default sanitizer is used — the hot
73    /// path. When `Some`, a fresh `ammonia::Builder` is constructed
74    /// per call that merges the defaults with the extras declared in
75    /// [`SanitizerConfig`].
76    pub sanitizer_config: Option<SanitizerConfig>,
77    /// Rewrite fenced code blocks tagged `mermaid`, `geojson`,
78    /// `topojson`, or `stl` into sanitizer-safe containers that a
79    /// client-side JS hydrator (see
80    /// [`crate::hydration_script_html`]) replaces with inline SVG.
81    ///
82    /// Off by default so existing users with plain `mermaid` code
83    /// blocks continue to see syntax-highlighted source. Opt in via
84    /// [`MarkdownOptions::with_diagrams`].
85    pub enable_diagrams: bool,
86}
87
88impl<'a> Default for MarkdownOptions<'a> {
89    fn default() -> Self {
90        // Keep the default internally consistent: enhanced tables
91        // depend on comrak's `extension.table`, so enable both
92        // together. Callers who want either piece off can disable
93        // via the builder.
94        let mut comrak_options = Options::default();
95        comrak_options.extension.table = true;
96        Self {
97            comrak_options,
98            enable_custom_blocks: true,
99            enable_syntax_highlighting: true,
100            enable_enhanced_tables: true,
101            syntax_theme: None,
102            // Safe by default: raw HTML in untrusted Markdown is
103            // sanitized unless the caller explicitly opts in via
104            // `with_unsafe_html(true)`.
105            allow_unsafe_html: false,
106            custom_block_config: CustomBlockConfig::default(),
107            max_input_size: DEFAULT_MAX_INPUT_SIZE,
108            header_ids: None,
109            sanitizer_config: None,
110            enable_diagrams: false,
111        }
112    }
113}
114
115impl<'a> MarkdownOptions<'a> {
116    /// Creates a new instance with default values.
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// Enables or disables custom blocks.
122    pub fn with_custom_blocks(mut self, enable: bool) -> Self {
123        self.enable_custom_blocks = enable;
124        self
125    }
126
127    /// Enables or disables syntax highlighting for code blocks.
128    pub fn with_syntax_highlighting(mut self, enable: bool) -> Self {
129        self.enable_syntax_highlighting = enable;
130        self
131    }
132
133    /// Enables or disables enhanced table formatting.
134    pub fn with_enhanced_tables(mut self, enable: bool) -> Self {
135        self.enable_enhanced_tables = enable;
136        self
137    }
138
139    /// Sets a custom theme for syntax highlighting.
140    pub fn with_custom_theme(mut self, theme: String) -> Self {
141        self.syntax_theme = Some(theme);
142        self
143    }
144
145    /// Sets custom Comrak options.
146    ///
147    /// Also syncs `allow_unsafe_html` from `render.unsafe`.
148    pub fn with_comrak_options(mut self, options: Options<'a>) -> Self {
149        self.allow_unsafe_html = options.render.r#unsafe;
150        self.comrak_options = options;
151        self
152    }
153
154    /// Enables or disables raw HTML pass-through.
155    ///
156    /// This is the authoritative control. Call **after**
157    /// `with_comrak_options` if you need to override.
158    pub fn with_unsafe_html(mut self, enable: bool) -> Self {
159        self.allow_unsafe_html = enable;
160        self
161    }
162
163    /// Sets the custom block configuration.
164    pub fn with_custom_block_config(
165        mut self,
166        config: CustomBlockConfig,
167    ) -> Self {
168        self.custom_block_config = config;
169        self
170    }
171
172    /// Sets the maximum input size in bytes. `0` means no limit.
173    pub fn with_max_input_size(mut self, size: usize) -> Self {
174        self.max_input_size = size;
175        self
176    }
177
178    /// Enables automatic `id` attributes on headings.
179    ///
180    /// Pass `""` for bare slugs, or a prefix like `"user-content-"`
181    /// to namespace them (GitHub-style).
182    pub fn with_header_ids(
183        mut self,
184        prefix: impl Into<String>,
185    ) -> Self {
186        self.header_ids = Some(prefix.into());
187        self
188    }
189
190    /// Extends the HTML sanitizer allow-list.
191    ///
192    /// Setting this disables the cached default sanitizer for calls
193    /// made with these options — a fresh `ammonia::Builder` is
194    /// constructed per call that merges the defaults with the extras.
195    /// Only used when `allow_unsafe_html` is `false`.
196    pub fn with_sanitizer_config(
197        mut self,
198        config: SanitizerConfig,
199    ) -> Self {
200        self.sanitizer_config = Some(config);
201        self
202    }
203
204    /// Enables or disables diagram code-block rendering (mermaid,
205    /// geojson, topojson, stl). See [`crate::diagrams`] for the
206    /// supported info strings and the client-side hydration
207    /// contract.
208    pub fn with_diagrams(mut self, enable: bool) -> Self {
209        self.enable_diagrams = enable;
210        self
211    }
212
213    /// Validates that options are internally consistent and that
214    /// every string the pipeline will splice into HTML is well-
215    /// formed.
216    ///
217    /// Uses [`crate::validation::Validator`] so every failing
218    /// check is reported in one pass — callers get the full list
219    /// of problems, not just the first. Each entry in the returned
220    /// `Vec` is `(field_name, ValidationError)`.
221    ///
222    /// # Checks
223    ///
224    /// 1. `enhanced_tables` requires `comrak.extension.table`.
225    /// 2. `syntax_theme`, if set, must name a theme bundled with
226    ///    syntect (feature-gated).
227    /// 3. `syntax_theme` set but `enable_syntax_highlighting =
228    ///    false` is a silent no-op — rejected so the mistake
229    ///    surfaces.
230    /// 4. `sanitizer_config` set but `allow_unsafe_html = true`
231    ///    skips sanitization entirely — rejected.
232    /// 5. `header_ids` prefix must not contain whitespace or any
233    ///    of `" ' < > & =` (would break the emitted `id="…"`).
234    /// 6. `sanitizer_config.extra_tags` / `extra_tag_attributes`
235    ///    keys must be valid HTML names; attribute lists must
236    ///    contain only valid HTML names.
237    /// 7. `sanitizer_config.extra_generic_attributes` must be
238    ///    valid HTML names.
239    /// 8. `sanitizer_config.extra_allowed_classes` keys must be
240    ///    valid HTML names; class values must be non-empty and
241    ///    free of whitespace / quote characters.
242    /// 9. `custom_block_config` override values must be non-empty
243    ///    and free of whitespace / quote characters (class
244    ///    overrides) or non-empty (title overrides).
245    ///
246    /// # Errors
247    ///
248    /// Returns `Err(errors)` when any check fails. The pipeline
249    /// converts the list into a single
250    /// [`MarkdownError::InvalidOptionsError`] via the `From` impl
251    /// in [`crate::error`].
252    pub fn validate(
253        &self,
254    ) -> Result<(), Vec<(String, crate::validation::ValidationError)>>
255    {
256        use crate::validation::{ValidationError, Validator};
257
258        let mut v = Validator::new();
259
260        // 1. enhanced_tables requires comrak.extension.table
261        v.check("enable_enhanced_tables", || {
262            if self.enable_enhanced_tables
263                && !self.comrak_options.extension.table
264            {
265                Err(ValidationError::Custom(
266                    "enhanced_tables = true requires comrak_options.extension.table = true"
267                        .into(),
268                ))
269            } else {
270                Ok(())
271            }
272        });
273
274        // 2. syntax_theme must be a bundled theme.
275        #[cfg(feature = "syntax_highlighting")]
276        v.check("syntax_theme", || {
277            if let Some(ref theme) = self.syntax_theme {
278                let available =
279                    crate::highlight::SyntectAdapter::available_themes(
280                    );
281                if !available.contains(&theme.as_str()) {
282                    return Err(ValidationError::NotInSet {
283                        allowed: available
284                            .iter()
285                            .map(|s| (*s).to_string())
286                            .collect(),
287                    });
288                }
289            }
290            Ok(())
291        });
292
293        // 3. syntax_theme + highlighter disabled is a no-op.
294        v.check("syntax_theme", || {
295            if !self.enable_syntax_highlighting
296                && self.syntax_theme.is_some()
297            {
298                Err(ValidationError::Custom(
299                    "syntax_theme is set but enable_syntax_highlighting = false (theme would be ignored)"
300                        .into(),
301                ))
302            } else {
303                Ok(())
304            }
305        });
306
307        // 4. sanitizer_config + unsafe_html is a no-op (sanitizer
308        //    never runs when unsafe_html is true).
309        v.check("sanitizer_config", || {
310            if self.allow_unsafe_html && self.sanitizer_config.is_some()
311            {
312                Err(ValidationError::Custom(
313                    "sanitizer_config is set but allow_unsafe_html = true (sanitizer is skipped)"
314                        .into(),
315                ))
316            } else {
317                Ok(())
318            }
319        });
320
321        // 5. header_ids prefix — no chars that would escape out of
322        //    the `id="…"` attribute.
323        v.check("header_ids", || {
324            if let Some(ref prefix) = self.header_ids {
325                if let Some(c) = prefix.chars().find(|c| {
326                    c.is_whitespace()
327                        || matches!(
328                            c,
329                            '"' | '\''
330                                | '<'
331                                | '>'
332                                | '&'
333                                | '='
334                        )
335                }) {
336                    return Err(ValidationError::InvalidPattern {
337                        pattern: format!(
338                            "no whitespace or HTML-special chars (found {c:?})"
339                        ),
340                    });
341                }
342            }
343            Ok(())
344        });
345
346        // 6. & 7. & 8. — SanitizerConfig.
347        if let Some(ref cfg) = self.sanitizer_config {
348            check_tag_list(
349                &mut v,
350                "sanitizer_config.extra_tags",
351                &cfg.extra_tags,
352            );
353            check_tag_attr_map(
354                &mut v,
355                "sanitizer_config.extra_tag_attributes",
356                &cfg.extra_tag_attributes,
357            );
358            check_attr_list(
359                &mut v,
360                "sanitizer_config.extra_generic_attributes",
361                &cfg.extra_generic_attributes,
362            );
363            check_allowed_classes_map(
364                &mut v,
365                "sanitizer_config.extra_allowed_classes",
366                &cfg.extra_allowed_classes,
367            );
368        }
369
370        // 9. CustomBlockConfig override values.
371        for (block_type, class) in
372            &self.custom_block_config.class_overrides
373        {
374            let field = format!(
375                "custom_block_config.class_overrides[{block_type:?}]"
376            );
377            let c = class.clone();
378            v.check(&field, move || {
379                if c.is_empty() {
380                    return Err(ValidationError::Empty);
381                }
382                if let Some(ch) = c.chars().find(|c| {
383                    c.is_whitespace() || matches!(c, '"' | '\'')
384                }) {
385                    return Err(ValidationError::InvalidPattern {
386                        pattern: format!(
387                            "non-empty, no whitespace or quotes (found {ch:?})"
388                        ),
389                    });
390                }
391                Ok(())
392            });
393        }
394        for (block_type, title) in
395            &self.custom_block_config.title_overrides
396        {
397            let field = format!(
398                "custom_block_config.title_overrides[{block_type:?}]"
399            );
400            let t = title.clone();
401            v.check(&field, move || {
402                if t.trim().is_empty() {
403                    return Err(ValidationError::Empty);
404                }
405                Ok(())
406            });
407        }
408
409        v.finish()
410    }
411}
412
413/// ASCII-alphabetic first character, ASCII alphanumeric + `-` + `_`
414/// after. Empty strings rejected. Conservative shape for HTML tag
415/// and attribute names.
416fn is_html_name(s: &str) -> bool {
417    let mut chars = s.chars();
418    match chars.next() {
419        Some(c) if c.is_ascii_alphabetic() => {}
420        _ => return false,
421    }
422    chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
423}
424
425fn check_tag_list(
426    v: &mut crate::validation::Validator,
427    field: &str,
428    tags: &[String],
429) {
430    for (i, tag) in tags.iter().enumerate() {
431        let f = format!("{field}[{i}]");
432        let t = tag.clone();
433        v.check(&f, move || {
434            if !is_html_name(&t) {
435                Err(crate::validation::ValidationError::InvalidPattern {
436                    pattern: format!(
437                        "valid HTML tag name (got {t:?})"
438                    ),
439                })
440            } else {
441                Ok(())
442            }
443        });
444    }
445}
446
447fn check_attr_list(
448    v: &mut crate::validation::Validator,
449    field: &str,
450    attrs: &[String],
451) {
452    for (i, attr) in attrs.iter().enumerate() {
453        let f = format!("{field}[{i}]");
454        let a = attr.clone();
455        v.check(&f, move || {
456            if !is_html_name(&a) {
457                Err(crate::validation::ValidationError::InvalidPattern {
458                    pattern: format!(
459                        "valid HTML attribute name (got {a:?})"
460                    ),
461                })
462            } else {
463                Ok(())
464            }
465        });
466    }
467}
468
469fn check_tag_attr_map(
470    v: &mut crate::validation::Validator,
471    field: &str,
472    map: &HashMap<String, Vec<String>>,
473) {
474    for (tag, attrs) in map {
475        let f_tag = format!("{field}.{tag}");
476        let t = tag.clone();
477        v.check(&f_tag, move || {
478            if !is_html_name(&t) {
479                Err(crate::validation::ValidationError::InvalidPattern {
480                    pattern: format!(
481                        "valid HTML tag name (got {t:?})"
482                    ),
483                })
484            } else {
485                Ok(())
486            }
487        });
488        check_attr_list(v, &f_tag, attrs);
489    }
490}
491
492fn check_allowed_classes_map(
493    v: &mut crate::validation::Validator,
494    field: &str,
495    map: &HashMap<String, Vec<String>>,
496) {
497    for (tag, classes) in map {
498        let f_tag = format!("{field}.{tag}");
499        let t = tag.clone();
500        v.check(&f_tag, move || {
501            if !is_html_name(&t) {
502                Err(crate::validation::ValidationError::InvalidPattern {
503                    pattern: format!(
504                        "valid HTML tag name (got {t:?})"
505                    ),
506                })
507            } else {
508                Ok(())
509            }
510        });
511        for (i, class) in classes.iter().enumerate() {
512            let f = format!("{f_tag}[{i}]");
513            let c = class.clone();
514            v.check(&f, move || {
515                if c.is_empty() {
516                    return Err(
517                        crate::validation::ValidationError::Empty,
518                    );
519                }
520                if let Some(ch) = c.chars().find(|c| {
521                    c.is_whitespace() || matches!(c, '"' | '\'')
522                }) {
523                    return Err(
524                        crate::validation::ValidationError::InvalidPattern {
525                            pattern: format!(
526                                "non-empty, no whitespace or quotes (got {ch:?})"
527                            ),
528                        },
529                    );
530                }
531                Ok(())
532            });
533        }
534    }
535}
536
537impl fmt::Debug for MarkdownOptions<'_> {
538    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
539        f.debug_struct("MarkdownOptions")
540            .field("enable_custom_blocks", &self.enable_custom_blocks)
541            .field(
542                "enable_syntax_highlighting",
543                &self.enable_syntax_highlighting,
544            )
545            .field(
546                "enable_enhanced_tables",
547                &self.enable_enhanced_tables,
548            )
549            .field("syntax_theme", &self.syntax_theme)
550            .field("allow_unsafe_html", &self.allow_unsafe_html)
551            .field("max_input_size", &self.max_input_size)
552            .field("header_ids", &self.header_ids)
553            .field("sanitizer_config", &self.sanitizer_config)
554            .field("enable_diagrams", &self.enable_diagrams)
555            .finish()
556    }
557}
558
559// ── Sanitizer configuration ─────────────────────────────────────────
560
561/// User-supplied extensions to the default HTML sanitizer allow-list.
562///
563/// Each field is additive: values here are merged on top of the
564/// defaults that ship with `mdx-gen`. Wire an instance into
565/// [`MarkdownOptions::with_sanitizer_config`].
566#[derive(Debug, Clone, Default)]
567pub struct SanitizerConfig {
568    /// Additional tags to allow (beyond the defaults).
569    pub extra_tags: Vec<String>,
570    /// Additional attributes per tag, in the form `tag -> attrs`.
571    pub extra_tag_attributes: HashMap<String, Vec<String>>,
572    /// Additional generic attributes that may appear on any tag.
573    pub extra_generic_attributes: Vec<String>,
574    /// Additional allowed class values per tag.
575    pub extra_allowed_classes: HashMap<String, Vec<String>>,
576}
577
578impl SanitizerConfig {
579    /// Creates a new, empty config (equivalent to `Default`).
580    pub fn new() -> Self {
581        Self::default()
582    }
583
584    /// Adds one extra tag to the allow-list.
585    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
586        self.extra_tags.push(tag.into());
587        self
588    }
589
590    /// Adds one extra attribute for a specific tag.
591    pub fn with_tag_attribute(
592        mut self,
593        tag: impl Into<String>,
594        attr: impl Into<String>,
595    ) -> Self {
596        self.extra_tag_attributes
597            .entry(tag.into())
598            .or_default()
599            .push(attr.into());
600        self
601    }
602
603    /// Adds one extra generic attribute (applies to any allowed tag).
604    pub fn with_generic_attribute(
605        mut self,
606        attr: impl Into<String>,
607    ) -> Self {
608        self.extra_generic_attributes.push(attr.into());
609        self
610    }
611
612    /// Adds one extra allowed class value for a specific tag.
613    pub fn with_allowed_class(
614        mut self,
615        tag: impl Into<String>,
616        class: impl Into<String>,
617    ) -> Self {
618        self.extra_allowed_classes
619            .entry(tag.into())
620            .or_default()
621            .push(class.into());
622        self
623    }
624}
625
626/// Creates a convenience set of options with all features enabled.
627///
628/// The HTML sanitizer stays **on** (`allow_unsafe_html = false`):
629/// raw HTML in the Markdown source is cleaned with ammonia while
630/// mdx-gen's own generated markup (alert divs, responsive tables,
631/// highlighted code) is preserved. Chain
632/// [`MarkdownOptions::with_unsafe_html`]`(true)` only for trusted
633/// input that needs raw HTML pass-through.
634pub fn default_markdown_options() -> MarkdownOptions<'static> {
635    MarkdownOptions::new()
636        .with_custom_blocks(true)
637        .with_syntax_highlighting(true)
638        .with_enhanced_tables(true)
639        .with_comrak_options({
640            let mut opts = Options::default();
641            opts.extension.table = true;
642            opts
643        })
644        .with_unsafe_html(false)
645}
646
647// ── Core processing pipeline ────────────────────────────────────────
648
649/// Processes the input Markdown content and converts it into HTML.
650///
651/// The pipeline:
652/// 1. Validate options and check resource limits.
653/// 2. Parse Markdown to a comrak AST.
654/// 3. (Optional) Transform custom-block `HtmlBlock` nodes in the AST.
655/// 4. Render to HTML, using comrak's syntax-highlighting plugin.
656/// 5. (Optional) Enhance tables with responsive wrappers.
657/// 6. (Optional) Sanitize HTML when `allow_unsafe_html` is `false`.
658pub fn process_markdown(
659    content: &str,
660    options: &MarkdownOptions,
661) -> Result<String, MarkdownError> {
662    let mut buf: Vec<u8> = Vec::new();
663    process_markdown_to_writer(content, &mut buf, options)?;
664    // comrak and ammonia both emit valid UTF-8, so this should never
665    // fail in practice — but surface the error rather than panic.
666    String::from_utf8(buf).map_err(|e| {
667        MarkdownError::RenderError(format!(
668            "non-UTF-8 output from pipeline: {e}"
669        ))
670    })
671}
672
673/// Streams processed HTML directly to a `Write` sink.
674///
675/// Semantically equivalent to [`process_markdown`], but avoids one
676/// intermediate allocation when callers already have a `Write`
677/// destination (a file, a buffered network writer, a template engine).
678/// The comrak render stage still produces a `String` internally — the
679/// 1 MiB default input cap means end-to-end streaming would add API
680/// surface without meaningful memory savings.
681///
682/// # Errors
683///
684/// Returns [`MarkdownError::IoError`] if the writer fails. All other
685/// error conditions mirror [`process_markdown`].
686pub fn process_markdown_to_writer<W: Write>(
687    content: &str,
688    writer: &mut W,
689    options: &MarkdownOptions,
690) -> Result<(), MarkdownError> {
691    pipeline(content, writer, options, None)
692}
693
694/// Processes Markdown and returns both the rendered HTML and a
695/// document-order table of contents.
696///
697/// Each [`Heading`] carries the level, the plain-text content, and
698/// the anchor id that comrak emits for that heading. To make those
699/// ids actually appear in the rendered HTML, set
700/// [`MarkdownOptions::header_ids`] (the same prefix is reflected in
701/// `Heading::id`).
702///
703/// # Errors
704///
705/// Mirrors [`process_markdown`].
706pub fn process_markdown_with_toc(
707    content: &str,
708    options: &MarkdownOptions,
709) -> Result<(String, Vec<Heading>), MarkdownError> {
710    let mut buf: Vec<u8> = Vec::new();
711    let mut toc = Vec::new();
712    pipeline(content, &mut buf, options, Some(&mut toc))?;
713    let html = String::from_utf8(buf).map_err(|e| {
714        MarkdownError::RenderError(format!(
715            "non-UTF-8 output from pipeline: {e}"
716        ))
717    })?;
718    Ok((html, toc))
719}
720
721/// Streams processed HTML to `writer` and returns the table of
722/// contents collected during the AST walk.
723///
724/// Same shape as [`process_markdown_to_writer`] but with the toc
725/// metadata returned alongside the IO result.
726pub fn process_markdown_with_toc_to_writer<W: Write>(
727    content: &str,
728    writer: &mut W,
729    options: &MarkdownOptions,
730) -> Result<Vec<Heading>, MarkdownError> {
731    let mut toc = Vec::new();
732    pipeline(content, writer, options, Some(&mut toc))?;
733    Ok(toc)
734}
735
736/// Extracts plain-text content from Markdown, stripping all
737/// formatting and markup.
738///
739/// This is useful for building search indexes, generating
740/// plain-text excerpts, or calculating reading time.
741///
742/// # Errors
743///
744/// Returns a [`MarkdownError`] if input exceeds the size limit.
745pub fn process_markdown_to_plain_text(
746    content: &str,
747    options: &MarkdownOptions,
748) -> Result<String, MarkdownError> {
749    if options.max_input_size > 0
750        && content.len() > options.max_input_size
751    {
752        return Err(MarkdownError::InputTooLarge {
753            size: content.len(),
754            limit: options.max_input_size,
755        });
756    }
757
758    let arena = Arena::new();
759    let root = comrak::parse_document(
760        &arena,
761        content,
762        &options.comrak_options,
763    );
764
765    Ok(crate::extensions::collect_all_text(root))
766}
767
768/// Internal pipeline shared by every public entry point. When
769/// `toc_out` is `Some`, headings are collected during the AST pass
770/// using [`collect_headings`].
771fn pipeline<W: Write>(
772    content: &str,
773    writer: &mut W,
774    options: &MarkdownOptions,
775    toc_out: Option<&mut Vec<Heading>>,
776) -> Result<(), MarkdownError> {
777    info!("Starting markdown processing");
778    debug!("Markdown options: {:?}", options);
779
780    // ── 0. Resource limits ──────────────────────────────────────
781    if options.max_input_size > 0
782        && content.len() > options.max_input_size
783    {
784        return Err(MarkdownError::InputTooLarge {
785            size: content.len(),
786            limit: options.max_input_size,
787        });
788    }
789
790    // ── 1. Validate options ─────────────────────────────────────
791    if let Err(errors) = options.validate() {
792        for (field, err) in &errors {
793            warn!("Invalid MarkdownOptions.{field}: {err}");
794        }
795        return Err(MarkdownError::from(errors));
796    }
797
798    // ── 2. Build comrak options ─────────────────────────────────
799    let mut comrak_opts = options.comrak_options.clone();
800    // Always enable unsafe for internal rendering — we sanitize
801    // at the end if the caller wants safety.
802    comrak_opts.render.r#unsafe = true;
803
804    // Wire header_ids into comrak's extension
805    if let Some(ref prefix) = options.header_ids {
806        comrak_opts.extension.header_id_prefix = Some(prefix.clone());
807    }
808
809    // ── 3. Parse → AST ─────────────────────────────────────────
810    let arena = Arena::new();
811    let root = comrak::parse_document(&arena, content, &comrak_opts);
812
813    // ── 4. AST transforms ───────────────────────────────────────
814    if options.enable_custom_blocks {
815        debug!("Processing custom blocks at AST level");
816        process_custom_block_nodes(root, &options.custom_block_config);
817    }
818    if options.enable_diagrams {
819        debug!("Rewriting diagram code blocks");
820        crate::diagrams::process_diagram_code_blocks(root);
821    }
822    if let Some(toc) = toc_out {
823        // Collect after custom-block transforms (which may add
824        // structural divs) but before table enhancement (which
825        // detaches table nodes — irrelevant to headings, but keeps
826        // the heading walk on a stable subtree).
827        debug!("Collecting headings for table of contents");
828        *toc = collect_headings(root, options.header_ids.as_deref());
829    }
830    if options.enable_enhanced_tables {
831        debug!("Enhancing tables at AST level");
832        enhance_table_nodes(root, &arena, &comrak_opts);
833    }
834
835    // ── 5. Render to HTML ───────────────────────────────────────
836    debug!("Rendering AST to HTML");
837
838    #[cfg(feature = "syntax_highlighting")]
839    let adapter;
840    #[cfg(feature = "syntax_highlighting")]
841    let plugins = if options.enable_syntax_highlighting {
842        adapter = SyntectAdapter::new(options.syntax_theme.as_deref());
843        Plugins {
844            render: RenderPlugins {
845                codefence_syntax_highlighter: Some(&adapter),
846                ..Default::default()
847            },
848        }
849    } else {
850        Plugins::default()
851    };
852    #[cfg(not(feature = "syntax_highlighting"))]
853    let plugins = Plugins::default();
854
855    let mut html = String::new();
856    comrak::format_html_with_plugins(
857        root,
858        &comrak_opts,
859        &mut html,
860        &plugins,
861    )
862    .map_err(|e| MarkdownError::RenderError(e.to_string()))?;
863
864    // ── 6. Sanitize and emit ────────────────────────────────────
865    if options.allow_unsafe_html {
866        writer.write_all(html.as_bytes())?;
867    } else {
868        debug!("Sanitizing HTML output");
869        sanitize_html_to_writer(
870            &html,
871            writer,
872            options.sanitizer_config.as_ref(),
873        )?;
874    }
875
876    info!("Markdown processing completed successfully");
877    Ok(())
878}
879
880// ── HTML sanitization ───────────────────────────────────────────────
881
882/// Pre-generated `language-*` class names for code elements,
883/// allocated once and reused across all sanitize calls.
884static CODE_LANG_CLASSES: LazyLock<HashSet<String>> =
885    LazyLock::new(|| {
886        [
887            "rust",
888            "python",
889            "javascript",
890            "typescript",
891            "java",
892            "c",
893            "cpp",
894            "csharp",
895            "go",
896            "ruby",
897            "swift",
898            "kotlin",
899            "php",
900            "html",
901            "css",
902            "sql",
903            "bash",
904            "shell",
905            "json",
906            "yaml",
907            "toml",
908            "xml",
909            "markdown",
910            "plaintext",
911            "text",
912            // Diagram info-strings: with `enable_diagrams` off these
913            // render as ordinary highlighted code blocks and must
914            // keep their `language-*` hook under the safe default.
915            "mermaid",
916            "geojson",
917            "topojson",
918            "stl",
919        ]
920        .iter()
921        .map(|lang| format!("language-{lang}"))
922        .collect()
923    });
924
925/// Applies the default sanitizer allow-list to a `Builder<'a>`.
926///
927/// Kept separate so the cached default builder and any per-call
928/// builder (used when the caller supplies a [`SanitizerConfig`])
929/// share one source of truth for the base policy. All strings
930/// threaded through here are `'static`, which coerces into any `'a`.
931fn configure_default_sanitizer<'a>(builder: &mut ammonia::Builder<'a>) {
932    let code_class_refs: HashSet<&'static str> =
933        CODE_LANG_CLASSES.iter().map(|s| s.as_str()).collect();
934
935    let mut allowed_classes: HashMap<
936        &'static str,
937        HashSet<&'static str>,
938    > = HashMap::new();
939
940    allowed_classes.insert(
941        "div",
942        [
943            "alert",
944            "alert-info",
945            "alert-warning",
946            "alert-success",
947            "alert-primary",
948            "alert-danger",
949            "alert-secondary",
950            "table-responsive",
951        ]
952        .into_iter()
953        .collect(),
954    );
955    allowed_classes.insert("table", ["table"].into_iter().collect());
956    allowed_classes.insert(
957        "td",
958        ["text-left", "text-center", "text-right"]
959            .into_iter()
960            .collect(),
961    );
962    allowed_classes.insert("code", code_class_refs);
963    // Mermaid's JS library looks for <pre class="mermaid">.
964    allowed_classes.insert("pre", ["mermaid"].into_iter().collect());
965
966    builder
967        .add_tags(["div", "pre", "code", "span", "input"])
968        .add_tag_attributes("div", &["role", "id"])
969        .add_tag_attributes("td", &["align"])
970        .add_tag_attributes("th", &["align"])
971        .add_tag_attributes("input", &["type", "checked", "disabled"])
972        .add_tag_attributes("h1", &["id"])
973        .add_tag_attributes("h2", &["id"])
974        .add_tag_attributes("h3", &["id"])
975        .add_tag_attributes("h4", &["id"])
976        .add_tag_attributes("h5", &["id"])
977        .add_tag_attributes("h6", &["id"])
978        .add_tag_attributes("a", &["id"])
979        .allowed_classes(allowed_classes)
980        // Syntect's class-based highlighter emits open-ended class
981        // names on <span> (one per grammar scope). Whitelisting them
982        // individually is impractical, so we allow `class` on <span>
983        // with unrestricted values — class attributes are CSS hooks,
984        // they cannot execute script.
985        .add_tag_attributes("span", &["class", "data-math-style"]);
986}
987
988/// Pre-configured ammonia sanitizer, built once and reused across
989/// every default-config call to the sanitizer.
990///
991/// Why: `ammonia::Builder` is relatively expensive to construct — it
992/// allocates several tag/attribute hash sets and the allowed-classes
993/// map. Since the default configuration is static (all `'static`
994/// strs), we build a single `Builder<'static>` behind a `LazyLock`
995/// and call `clean(&self, …)` on it repeatedly.
996static SANITIZE_BUILDER: LazyLock<ammonia::Builder<'static>> =
997    LazyLock::new(|| {
998        let mut builder = ammonia::Builder::default();
999        configure_default_sanitizer(&mut builder);
1000        builder
1001    });
1002
1003/// Writes sanitized HTML to the given writer.
1004///
1005/// Uses the cached default sanitizer when `cfg` is `None` (hot path).
1006/// When `cfg` is `Some`, builds a fresh `Builder` that merges the
1007/// defaults with the caller's extras — per-call cost, but scoped to
1008/// the uncommon case.
1009fn sanitize_html_to_writer<W: Write>(
1010    html: &str,
1011    writer: &mut W,
1012    cfg: Option<&SanitizerConfig>,
1013) -> std::io::Result<()> {
1014    match cfg {
1015        None => SANITIZE_BUILDER.clean(html).write_to(writer),
1016        Some(custom) => {
1017            build_custom_sanitizer(custom).clean(html).write_to(writer)
1018        }
1019    }
1020}
1021
1022/// Builds a one-shot sanitizer that layers `cfg`'s extras over the
1023/// default allow-list. Lifetime is tied to `cfg` since the extras
1024/// are `String`-owned on the caller side.
1025fn build_custom_sanitizer(
1026    cfg: &SanitizerConfig,
1027) -> ammonia::Builder<'_> {
1028    let mut builder = ammonia::Builder::default();
1029    configure_default_sanitizer(&mut builder);
1030
1031    // ammonia forbids a tag from appearing in both `tag_attributes`
1032    // (with "class") and `allowed_classes`. The default config
1033    // grants <span> open `class` via tag_attributes so syntect's
1034    // class-based highlighter survives sanitization. If the caller
1035    // is now restricting classes for any of those tags via
1036    // SanitizerConfig, swap them out of permissive mode before
1037    // adding the whitelist.
1038    for tag in cfg.extra_allowed_classes.keys() {
1039        builder.rm_tag_attributes(tag.as_str(), &["class"]);
1040    }
1041
1042    if !cfg.extra_tags.is_empty() {
1043        builder.add_tags(cfg.extra_tags.iter().map(String::as_str));
1044    }
1045    for (tag, attrs) in &cfg.extra_tag_attributes {
1046        builder.add_tag_attributes(
1047            tag.as_str(),
1048            attrs.iter().map(String::as_str),
1049        );
1050    }
1051    if !cfg.extra_generic_attributes.is_empty() {
1052        builder.add_generic_attributes(
1053            cfg.extra_generic_attributes.iter().map(String::as_str),
1054        );
1055    }
1056    for (tag, classes) in &cfg.extra_allowed_classes {
1057        builder.add_allowed_classes(
1058            tag.as_str(),
1059            classes.iter().map(String::as_str),
1060        );
1061    }
1062    builder
1063}
1064
1065// ── Tests ───────────────────────────────────────────────────────────
1066
1067#[cfg(test)]
1068mod tests {
1069    use super::*;
1070    use crate::CustomBlockType;
1071
1072    #[test]
1073    fn test_process_markdown_with_all_features() {
1074        let markdown = r#"
1075# Test Markdown
1076
1077| Left | Center | Right |
1078|:-----|:------:|------:|
1079| 1    |   2    |     3 |
1080
1081```rust
1082fn main() {
1083    println!("Hello, world!");
1084}
1085```
1086
1087<div class="note">This is a note.</div>
1088<div class="warning">This is a warning.</div>
1089<div class="tip">This is a tip.</div>
1090"#;
1091
1092        let options = default_markdown_options();
1093        let result = process_markdown(markdown, &options);
1094        assert!(result.is_ok(), "Failed: {:?}", result.err());
1095
1096        let html = result.unwrap();
1097        assert!(html.contains("table-responsive"));
1098        assert!(html.contains("language-rust"));
1099        assert!(html.contains("alert alert-info"));
1100        assert!(html.contains("alert alert-warning"));
1101        assert!(html.contains("alert alert-success"));
1102    }
1103
1104    #[test]
1105    fn test_process_markdown_without_custom_blocks() {
1106        let markdown = "# Test\n<div class=\"note\">Note.</div>";
1107        let options = MarkdownOptions::new()
1108            .with_custom_blocks(false)
1109            .with_comrak_options({
1110                let mut opts = Options::default();
1111                opts.extension.table = true;
1112                opts
1113            })
1114            .with_unsafe_html(true);
1115
1116        let html = process_markdown(markdown, &options).unwrap();
1117        // The div should remain as-is (not converted to alert)
1118        assert!(html.contains("<div class=\"note\">"));
1119        assert!(!html.contains("alert"));
1120    }
1121
1122    #[test]
1123    fn test_process_markdown_without_enhanced_tables() {
1124        let markdown = "| H1 | H2 |\n|---|---|\n| A | B |";
1125        let options = MarkdownOptions::new()
1126            .with_enhanced_tables(false)
1127            .with_custom_blocks(false)
1128            .with_comrak_options({
1129                let mut opts = Options::default();
1130                opts.extension.table = true;
1131                opts
1132            });
1133
1134        let html = process_markdown(markdown, &options).unwrap();
1135        assert!(!html.contains("table-responsive"));
1136        assert!(html.contains("<table>"));
1137    }
1138
1139    #[test]
1140    fn test_validation_enhanced_tables_without_extension() {
1141        let options = MarkdownOptions::new()
1142            .with_enhanced_tables(true)
1143            .with_custom_blocks(false)
1144            .with_comrak_options({
1145                let mut opts = Options::default();
1146                opts.extension.table = false;
1147                opts
1148            });
1149        let errors = options.validate().unwrap_err();
1150        assert!(errors
1151            .iter()
1152            .any(|(f, _)| f == "enable_enhanced_tables"));
1153    }
1154
1155    #[test]
1156    fn test_validation_default_options_pass() {
1157        // Defaults should pass every check in validate() — the
1158        // suite is "tight" but not hostile to normal config.
1159        // Note: defaults have enable_enhanced_tables = true but
1160        // Options::default() has extension.table = false, so this
1161        // catches check #1 by design. Enable the extension to see
1162        // the all-green path.
1163        let mut comrak = Options::default();
1164        comrak.extension.table = true;
1165        let options =
1166            MarkdownOptions::new().with_comrak_options(comrak);
1167        assert!(
1168            options.validate().is_ok(),
1169            "{:?}",
1170            options.validate().unwrap_err()
1171        );
1172    }
1173
1174    // Theme-name validation (check #2) only runs when syntect is
1175    // compiled in, so this test is feature-gated to match.
1176    #[cfg(feature = "syntax_highlighting")]
1177    #[test]
1178    fn test_validation_unknown_syntax_theme() {
1179        let options = MarkdownOptions::new()
1180            .with_enhanced_tables(false)
1181            .with_custom_blocks(false)
1182            .with_custom_theme("no-such-theme-exists".into());
1183        let errors = options.validate().unwrap_err();
1184        assert!(
1185            errors.iter().any(|(f, _)| f == "syntax_theme"),
1186            "expected syntax_theme failure, got {errors:?}"
1187        );
1188    }
1189
1190    #[test]
1191    fn test_validation_theme_without_highlighter_disabled() {
1192        // syntax_theme set + syntax_highlighting = false is a
1193        // silent no-op — rejected.
1194        let options = MarkdownOptions::new()
1195            .with_enhanced_tables(false)
1196            .with_custom_blocks(false)
1197            .with_syntax_highlighting(false)
1198            .with_custom_theme("base16-ocean.dark".into());
1199        let errors = options.validate().unwrap_err();
1200        assert!(errors.iter().any(|(f, _)| f == "syntax_theme"));
1201    }
1202
1203    #[test]
1204    fn test_validation_sanitizer_with_unsafe_html() {
1205        // sanitizer_config set + allow_unsafe_html = true skips
1206        // sanitization entirely — reject the silent no-op.
1207        let options = MarkdownOptions::new()
1208            .with_enhanced_tables(false)
1209            .with_custom_blocks(false)
1210            .with_unsafe_html(true)
1211            .with_sanitizer_config(
1212                SanitizerConfig::new().with_tag("main"),
1213            );
1214        let errors = options.validate().unwrap_err();
1215        assert!(errors.iter().any(|(f, _)| f == "sanitizer_config"));
1216    }
1217
1218    #[test]
1219    fn test_validation_header_ids_bad_chars() {
1220        for bad in [
1221            "user content ", // whitespace
1222            "quo\"te-",
1223            "ang<le-",
1224            "amp&-",
1225        ] {
1226            let options = MarkdownOptions::new()
1227                .with_enhanced_tables(false)
1228                .with_custom_blocks(false)
1229                .with_header_ids(bad);
1230            let errors = options.validate().unwrap_err();
1231            assert!(
1232                errors.iter().any(|(f, _)| f == "header_ids"),
1233                "expected header_ids failure for {bad:?}, got {errors:?}"
1234            );
1235        }
1236    }
1237
1238    #[test]
1239    fn test_validation_header_ids_clean_prefix_ok() {
1240        let options = MarkdownOptions::new()
1241            .with_enhanced_tables(false)
1242            .with_custom_blocks(false)
1243            .with_header_ids("user-content-");
1244        assert!(options.validate().is_ok());
1245    }
1246
1247    #[test]
1248    fn test_validation_sanitizer_extra_tag_invalid() {
1249        let options = MarkdownOptions::new()
1250            .with_enhanced_tables(false)
1251            .with_custom_blocks(false)
1252            .with_unsafe_html(false)
1253            .with_sanitizer_config(
1254                SanitizerConfig::new().with_tag("has space"),
1255            );
1256        let errors = options.validate().unwrap_err();
1257        assert!(
1258            errors
1259                .iter()
1260                .any(|(f, _)| f
1261                    .starts_with("sanitizer_config.extra_tags"))
1262        );
1263    }
1264
1265    #[test]
1266    fn test_validation_sanitizer_extra_generic_attribute_invalid() {
1267        let options = MarkdownOptions::new()
1268            .with_enhanced_tables(false)
1269            .with_custom_blocks(false)
1270            .with_unsafe_html(false)
1271            .with_sanitizer_config(
1272                SanitizerConfig::new().with_generic_attribute(""),
1273            );
1274        let errors = options.validate().unwrap_err();
1275        assert!(errors.iter().any(|(f, _)| f
1276            .starts_with("sanitizer_config.extra_generic_attributes")));
1277    }
1278
1279    #[test]
1280    fn test_validation_sanitizer_allowed_class_with_whitespace() {
1281        let options = MarkdownOptions::new()
1282            .with_enhanced_tables(false)
1283            .with_custom_blocks(false)
1284            .with_unsafe_html(false)
1285            .with_sanitizer_config(
1286                SanitizerConfig::new()
1287                    .with_allowed_class("span", "has space"),
1288            );
1289        let errors = options.validate().unwrap_err();
1290        assert!(errors.iter().any(|(f, _)| f
1291            .starts_with("sanitizer_config.extra_allowed_classes")));
1292    }
1293
1294    #[test]
1295    fn test_validation_custom_block_class_override_empty() {
1296        let cfg = CustomBlockConfig::new()
1297            .with_class(CustomBlockType::Note, "");
1298        let options = MarkdownOptions::new()
1299            .with_enhanced_tables(false)
1300            .with_custom_block_config(cfg);
1301        let errors = options.validate().unwrap_err();
1302        assert!(errors.iter().any(|(f, _)| f
1303            .starts_with("custom_block_config.class_overrides")));
1304    }
1305
1306    #[test]
1307    fn test_validation_custom_block_title_override_blank() {
1308        let cfg = CustomBlockConfig::new()
1309            .with_title(CustomBlockType::Warning, "   ");
1310        let options = MarkdownOptions::new()
1311            .with_enhanced_tables(false)
1312            .with_custom_block_config(cfg);
1313        let errors = options.validate().unwrap_err();
1314        assert!(errors.iter().any(|(f, _)| f
1315            .starts_with("custom_block_config.title_overrides")));
1316    }
1317
1318    #[test]
1319    fn test_sanitizer_config_applies_extra_tag_attribute() {
1320        // Drives build_custom_sanitizer past validation with a
1321        // tag-specific attribute add — exercises the
1322        // add_tag_attributes branch in the sanitiser factory.
1323        let options = MarkdownOptions::new()
1324            .with_custom_blocks(false)
1325            .with_enhanced_tables(false)
1326            .with_unsafe_html(false)
1327            .with_sanitizer_config(
1328                SanitizerConfig::new()
1329                    .with_tag("section")
1330                    .with_tag_attribute("section", "data-foo"),
1331            );
1332
1333        let md = r#"<section data-foo="bar">hello</section>"#;
1334        let html = process_markdown(md, &options).unwrap();
1335        assert!(html.contains("<section"));
1336        assert!(html.contains("data-foo=\"bar\""));
1337    }
1338
1339    #[test]
1340    fn test_sanitizer_config_applies_extra_generic_attribute() {
1341        // Drives build_custom_sanitizer past validation with a
1342        // generic-attr add — exercises the add_generic_attributes
1343        // branch.
1344        let options = MarkdownOptions::new()
1345            .with_custom_blocks(false)
1346            .with_enhanced_tables(false)
1347            .with_unsafe_html(false)
1348            .with_sanitizer_config(
1349                SanitizerConfig::new().with_generic_attribute("data-x"),
1350            );
1351
1352        let md = r#"<p data-x="v">hi</p>"#;
1353        let html = process_markdown(md, &options).unwrap();
1354        assert!(html.contains("data-x=\"v\""));
1355    }
1356
1357    #[test]
1358    fn test_sanitizer_config_with_tag_attribute_direct() {
1359        // The builder method had zero direct coverage.
1360        let cfg = SanitizerConfig::new()
1361            .with_tag_attribute("div", "role")
1362            .with_tag_attribute("div", "id");
1363        let attrs = cfg
1364            .extra_tag_attributes
1365            .get("div")
1366            .expect("div should exist");
1367        assert_eq!(attrs, &vec!["role".to_string(), "id".to_string()]);
1368    }
1369
1370    #[test]
1371    fn test_validation_sanitizer_tag_attr_invalid_tag() {
1372        let options = MarkdownOptions::new()
1373            .with_enhanced_tables(false)
1374            .with_custom_blocks(false)
1375            .with_unsafe_html(false)
1376            .with_sanitizer_config(
1377                SanitizerConfig::new()
1378                    .with_tag_attribute("has space", "id"),
1379            );
1380        let errors = options.validate().unwrap_err();
1381        assert!(errors.iter().any(|(f, _)| f
1382            .starts_with("sanitizer_config.extra_tag_attributes")));
1383    }
1384
1385    #[test]
1386    fn test_validation_sanitizer_tag_attr_invalid_attr_name() {
1387        let options = MarkdownOptions::new()
1388            .with_enhanced_tables(false)
1389            .with_custom_blocks(false)
1390            .with_unsafe_html(false)
1391            .with_sanitizer_config(
1392                SanitizerConfig::new().with_tag_attribute("div", ""),
1393            );
1394        let errors = options.validate().unwrap_err();
1395        assert!(errors.iter().any(|(f, _)| f
1396            .starts_with("sanitizer_config.extra_tag_attributes")));
1397    }
1398
1399    #[test]
1400    fn test_validation_sanitizer_allowed_class_invalid_tag() {
1401        let options = MarkdownOptions::new()
1402            .with_enhanced_tables(false)
1403            .with_custom_blocks(false)
1404            .with_unsafe_html(false)
1405            .with_sanitizer_config(
1406                SanitizerConfig::new()
1407                    .with_allowed_class("bad tag", "foo"),
1408            );
1409        let errors = options.validate().unwrap_err();
1410        assert!(errors.iter().any(|(f, _)| f
1411            .starts_with("sanitizer_config.extra_allowed_classes")));
1412    }
1413
1414    #[test]
1415    fn test_validation_sanitizer_allowed_class_empty() {
1416        let options = MarkdownOptions::new()
1417            .with_enhanced_tables(false)
1418            .with_custom_blocks(false)
1419            .with_unsafe_html(false)
1420            .with_sanitizer_config(
1421                SanitizerConfig::new().with_allowed_class("span", ""),
1422            );
1423        let errors = options.validate().unwrap_err();
1424        assert!(errors.iter().any(|(f, _)| f
1425            .starts_with("sanitizer_config.extra_allowed_classes")));
1426    }
1427
1428    #[test]
1429    fn test_validation_custom_block_class_override_whitespace() {
1430        let cfg = CustomBlockConfig::new()
1431            .with_class(CustomBlockType::Note, "bad class");
1432        let options = MarkdownOptions::new()
1433            .with_enhanced_tables(false)
1434            .with_custom_block_config(cfg);
1435        let errors = options.validate().unwrap_err();
1436        assert!(errors.iter().any(|(f, _)| f
1437            .starts_with("custom_block_config.class_overrides")));
1438    }
1439
1440    #[test]
1441    fn test_toc_extracts_image_title() {
1442        // Exercises the NodeValue::Image branch of `extract_text`.
1443        let md = "# See ![alt](logo.png \"Logo Title\") here\n";
1444        let options = MarkdownOptions::new()
1445            .with_enhanced_tables(false)
1446            .with_custom_blocks(false);
1447        let (_html, toc) =
1448            process_markdown_with_toc(md, &options).unwrap();
1449        assert_eq!(toc.len(), 1);
1450        // Image title text should make it into the heading's plain text.
1451        assert!(
1452            toc[0].text.contains("Logo Title")
1453                || toc[0].text.contains("alt"),
1454            "expected image title/alt in: {:?}",
1455            toc[0].text
1456        );
1457    }
1458
1459    #[test]
1460    fn test_plain_text_soft_break_inserts_space() {
1461        // Two text nodes joined by a soft break should get a space
1462        // between them (covers SoftBreak/LineBreak arm in
1463        // collect_all_text).
1464        let md = "one\ntwo\nthree\n";
1465        let text = process_markdown_to_plain_text(
1466            md,
1467            &MarkdownOptions::default(),
1468        )
1469        .unwrap();
1470        assert_eq!(text, "one two three");
1471    }
1472
1473    #[test]
1474    fn test_plain_text_image_title_included() {
1475        // Image titles end up in plain-text output too.
1476        let md = "Caption: ![alt](x.png \"Title Here\")\n";
1477        let text = process_markdown_to_plain_text(
1478            md,
1479            &MarkdownOptions::default(),
1480        )
1481        .unwrap();
1482        // Plain text should at minimum keep the surrounding caption.
1483        assert!(text.contains("Caption:"));
1484    }
1485
1486    #[test]
1487    fn test_validation_reports_all_failures_at_once() {
1488        // Three independent violations in one options instance.
1489        // The validator must collect all of them, not bail on the
1490        // first.
1491        let cfg = CustomBlockConfig::new()
1492            .with_class(CustomBlockType::Note, "");
1493        let options = MarkdownOptions::new()
1494            .with_enhanced_tables(true) // ← 1: needs comrak.extension.table
1495            .with_header_ids("a b") // ← 2: whitespace
1496            .with_custom_block_config(cfg) // ← 3: empty override
1497            .with_comrak_options({
1498                let mut opts = Options::default();
1499                opts.extension.table = false;
1500                opts
1501            });
1502        let errors = options.validate().unwrap_err();
1503        assert!(
1504            errors.len() >= 3,
1505            "expected 3+ errors, got {errors:?}"
1506        );
1507    }
1508
1509    #[test]
1510    fn test_empty_content() {
1511        let options = MarkdownOptions::new()
1512            .with_enhanced_tables(false)
1513            .with_custom_blocks(false);
1514        let html = process_markdown("", &options).unwrap();
1515        assert!(html.trim().is_empty());
1516    }
1517
1518    #[test]
1519    fn test_no_features_enabled() {
1520        let markdown = "# Title\n\nPlain text.";
1521        let options = MarkdownOptions::new()
1522            .with_syntax_highlighting(false)
1523            .with_custom_blocks(false)
1524            .with_enhanced_tables(false);
1525
1526        let html = process_markdown(markdown, &options).unwrap();
1527        assert!(html.contains("<h1>Title</h1>"));
1528        assert!(html.contains("Plain text."));
1529    }
1530
1531    #[test]
1532    fn test_sanitization_strips_script() {
1533        let markdown = "<script>alert('xss')</script>";
1534        let options = MarkdownOptions::new()
1535            .with_custom_blocks(false)
1536            .with_enhanced_tables(false)
1537            .with_unsafe_html(false);
1538
1539        let html = process_markdown(markdown, &options).unwrap();
1540        assert!(
1541            !html.contains("<script>"),
1542            "Script tags should be stripped"
1543        );
1544    }
1545
1546    #[test]
1547    fn test_default_options_sanitize_raw_html() {
1548        // Safe by default: `MarkdownOptions::default()` must NOT
1549        // pass raw script through — no opt-out required from the
1550        // caller.
1551        let markdown = "<script>alert(1)</script>\n\n# Safe";
1552        let options = MarkdownOptions::default();
1553        assert!(!options.allow_unsafe_html);
1554
1555        let html = process_markdown(markdown, &options).unwrap();
1556        assert!(
1557            !html.contains("<script>") && !html.contains("alert(1)"),
1558            "default options must neutralize raw script: {html}"
1559        );
1560        assert!(html.contains("<h1>Safe</h1>"));
1561    }
1562
1563    #[test]
1564    fn test_default_markdown_options_helper_is_safe() {
1565        // The all-features convenience constructor keeps the
1566        // sanitizer on too.
1567        let markdown = "<script>alert(1)</script>";
1568        let options = default_markdown_options();
1569        assert!(!options.allow_unsafe_html);
1570
1571        let html = process_markdown(markdown, &options).unwrap();
1572        assert!(
1573            !html.contains("<script>") && !html.contains("alert(1)"),
1574            "default_markdown_options must sanitize: {html}"
1575        );
1576    }
1577
1578    #[test]
1579    fn test_explicit_unsafe_passes_raw_html_through() {
1580        // Opting in via `with_unsafe_html(true)` restores raw
1581        // pass-through for trusted input.
1582        let markdown = "<script>alert(1)</script>";
1583        let options = MarkdownOptions::new()
1584            .with_custom_blocks(false)
1585            .with_enhanced_tables(false)
1586            .with_unsafe_html(true);
1587
1588        let html = process_markdown(markdown, &options).unwrap();
1589        assert!(
1590            html.contains("<script>alert(1)</script>"),
1591            "explicit unsafe opt-in must pass raw HTML: {html}"
1592        );
1593    }
1594
1595    #[test]
1596    fn test_custom_blocks_render_under_safe_default() {
1597        // mdx-gen's own generated markup (alert divs) must survive
1598        // the sanitizer even when the caller never touches the
1599        // unsafe knob.
1600        let markdown = "<div class=\"warning\">Careful.</div>";
1601        let options = MarkdownOptions::default();
1602
1603        let html = process_markdown(markdown, &options).unwrap();
1604        assert!(
1605            html.contains("alert alert-warning"),
1606            "custom blocks must render under the safe default: {html}"
1607        );
1608        assert!(html.contains("Careful."));
1609    }
1610
1611    #[test]
1612    fn test_sanitization_preserves_alerts() {
1613        let markdown = "<div class=\"note\">Important info.</div>";
1614        let options = MarkdownOptions::new()
1615            .with_custom_blocks(true)
1616            .with_enhanced_tables(false)
1617            .with_unsafe_html(false);
1618
1619        let html = process_markdown(markdown, &options).unwrap();
1620        assert!(
1621            html.contains("alert alert-info"),
1622            "Alert divs should survive sanitization"
1623        );
1624    }
1625
1626    #[test]
1627    fn test_input_too_large() {
1628        let options = MarkdownOptions::new()
1629            .with_max_input_size(10)
1630            .with_custom_blocks(false)
1631            .with_enhanced_tables(false);
1632
1633        let result =
1634            process_markdown("a]".repeat(20).as_str(), &options);
1635        assert!(matches!(
1636            result,
1637            Err(MarkdownError::InputTooLarge { .. })
1638        ));
1639    }
1640
1641    #[test]
1642    fn test_syntax_theme_customization() {
1643        let markdown = "```rust\nfn main() {}\n```";
1644        let options = MarkdownOptions::new()
1645            .with_custom_blocks(false)
1646            .with_enhanced_tables(false)
1647            .with_custom_theme("InspiredGitHub".to_string());
1648
1649        let result = process_markdown(markdown, &options);
1650        assert!(result.is_ok());
1651    }
1652
1653    #[test]
1654    fn test_custom_block_config() {
1655        let markdown = "<div class=\"note\">Custom styled.</div>";
1656        let config = CustomBlockConfig::new()
1657            .with_class(
1658                crate::extensions::CustomBlockType::Note,
1659                "my-note",
1660            )
1661            .with_title(
1662                crate::extensions::CustomBlockType::Note,
1663                "Heads up",
1664            );
1665
1666        let options = MarkdownOptions::new()
1667            .with_custom_blocks(true)
1668            .with_enhanced_tables(false)
1669            .with_custom_block_config(config)
1670            .with_unsafe_html(true);
1671
1672        let html = process_markdown(markdown, &options).unwrap();
1673        assert!(html.contains("my-note"));
1674        assert!(html.contains("Heads up:"));
1675    }
1676
1677    #[test]
1678    fn test_builder_order_comrak_then_unsafe() {
1679        let options = MarkdownOptions::new()
1680            .with_comrak_options(Options::default())
1681            .with_unsafe_html(true);
1682        assert!(options.allow_unsafe_html);
1683    }
1684
1685    #[test]
1686    fn test_comrak_options_syncs_unsafe() {
1687        let mut opts = Options::default();
1688        opts.render.r#unsafe = true;
1689        let options = MarkdownOptions::new().with_comrak_options(opts);
1690        assert!(options.allow_unsafe_html);
1691    }
1692
1693    #[test]
1694    fn test_markdown_options_debug_impl() {
1695        let options = MarkdownOptions::new()
1696            .with_custom_blocks(true)
1697            .with_syntax_highlighting(false)
1698            .with_enhanced_tables(true)
1699            .with_custom_theme("InspiredGitHub".to_string())
1700            .with_unsafe_html(false)
1701            .with_max_input_size(2048);
1702
1703        let debug_output = format!("{:?}", options);
1704        assert!(debug_output.contains("MarkdownOptions"));
1705        assert!(debug_output.contains("enable_custom_blocks: true"));
1706        assert!(
1707            debug_output.contains("enable_syntax_highlighting: false")
1708        );
1709        assert!(debug_output.contains("enable_enhanced_tables: true"));
1710        assert!(debug_output.contains("InspiredGitHub"));
1711        assert!(debug_output.contains("allow_unsafe_html: false"));
1712        assert!(debug_output.contains("max_input_size: 2048"));
1713    }
1714
1715    #[test]
1716    fn test_header_ids() {
1717        let markdown = "# Hello World\n## Sub Section";
1718        let options = MarkdownOptions::new()
1719            .with_custom_blocks(false)
1720            .with_enhanced_tables(false)
1721            .with_header_ids("")
1722            .with_unsafe_html(true);
1723
1724        let html = process_markdown(markdown, &options).unwrap();
1725        assert!(
1726            html.contains("id=\"hello-world\""),
1727            "H1 should have id attribute: {html}"
1728        );
1729        assert!(
1730            html.contains("id=\"sub-section\""),
1731            "H2 should have id attribute: {html}"
1732        );
1733    }
1734
1735    #[test]
1736    fn test_header_ids_with_prefix() {
1737        let markdown = "# Title";
1738        let options = MarkdownOptions::new()
1739            .with_custom_blocks(false)
1740            .with_enhanced_tables(false)
1741            .with_header_ids("user-content-")
1742            .with_unsafe_html(true);
1743
1744        let html = process_markdown(markdown, &options).unwrap();
1745        assert!(
1746            html.contains("id=\"user-content-title\""),
1747            "Should have prefixed id: {html}"
1748        );
1749    }
1750
1751    #[test]
1752    fn test_header_ids_survive_sanitization() {
1753        let markdown = "# Hello World";
1754        let options = MarkdownOptions::new()
1755            .with_custom_blocks(false)
1756            .with_enhanced_tables(false)
1757            .with_header_ids("")
1758            .with_unsafe_html(false);
1759
1760        let html = process_markdown(markdown, &options).unwrap();
1761        assert!(
1762            html.contains("id=\"hello-world\""),
1763            "Header id should survive ammonia sanitization: {html}"
1764        );
1765    }
1766
1767    #[test]
1768    fn test_ast_table_enhancement() {
1769        let markdown =
1770            "| H1 | H2 |\n|:---|---:|\n| L | R |\n\nParagraph\n\n| A | B |\n|---|---|\n| C | D |";
1771        let options = MarkdownOptions::new()
1772            .with_custom_blocks(false)
1773            .with_comrak_options({
1774                let mut opts = Options::default();
1775                opts.extension.table = true;
1776                opts
1777            })
1778            .with_unsafe_html(true);
1779
1780        let html = process_markdown(markdown, &options).unwrap();
1781        // Both tables should be wrapped
1782        assert_eq!(
1783            html.matches("table-responsive").count(),
1784            2,
1785            "Both tables should get responsive wrapper: {html}"
1786        );
1787        assert!(
1788            html.contains("text-right"),
1789            "Right-aligned cells should have class"
1790        );
1791    }
1792
1793    // ── Streaming API ───────────────────────────────────────────
1794
1795    #[test]
1796    fn test_process_markdown_to_writer_matches_string_variant() {
1797        let markdown = "# Title\n\nParagraph with *emphasis*.";
1798        let options = MarkdownOptions::new()
1799            .with_custom_blocks(false)
1800            .with_enhanced_tables(false)
1801            .with_syntax_highlighting(false);
1802
1803        let as_string = process_markdown(markdown, &options).unwrap();
1804
1805        let mut buf: Vec<u8> = Vec::new();
1806        process_markdown_to_writer(markdown, &mut buf, &options)
1807            .unwrap();
1808        let as_bytes = String::from_utf8(buf).unwrap();
1809
1810        assert_eq!(
1811            as_string, as_bytes,
1812            "writer variant must produce byte-identical output"
1813        );
1814    }
1815
1816    #[test]
1817    fn test_process_markdown_to_writer_sanitizes() {
1818        let markdown = "<script>alert('xss')</script>\n\n# Safe";
1819        let options = MarkdownOptions::new()
1820            .with_custom_blocks(false)
1821            .with_enhanced_tables(false)
1822            .with_unsafe_html(false);
1823
1824        let mut buf: Vec<u8> = Vec::new();
1825        process_markdown_to_writer(markdown, &mut buf, &options)
1826            .unwrap();
1827        let html = String::from_utf8(buf).unwrap();
1828        assert!(!html.contains("<script>"));
1829        assert!(html.contains("<h1>Safe</h1>"));
1830    }
1831
1832    #[test]
1833    fn test_process_markdown_to_writer_propagates_io_error() {
1834        struct AlwaysFails;
1835        impl Write for AlwaysFails {
1836            fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
1837                Err(std::io::Error::new(
1838                    std::io::ErrorKind::BrokenPipe,
1839                    "nope",
1840                ))
1841            }
1842            fn flush(&mut self) -> std::io::Result<()> {
1843                Ok(())
1844            }
1845        }
1846
1847        let options = MarkdownOptions::new()
1848            .with_custom_blocks(false)
1849            .with_enhanced_tables(false);
1850        let err = process_markdown_to_writer(
1851            "# hi",
1852            &mut AlwaysFails,
1853            &options,
1854        )
1855        .unwrap_err();
1856        assert!(matches!(err, MarkdownError::IoError(_)));
1857    }
1858
1859    // ── SanitizerConfig ─────────────────────────────────────────
1860
1861    #[test]
1862    fn test_sanitizer_config_allows_extra_tag() {
1863        // <main> is not in ammonia's default tag allow-list and is
1864        // not added by our defaults, so it's stripped to text unless
1865        // the SanitizerConfig extends the list.
1866        let markdown = "<main>wrapper</main>";
1867
1868        let strict = MarkdownOptions::new()
1869            .with_custom_blocks(false)
1870            .with_enhanced_tables(false)
1871            .with_unsafe_html(false);
1872        let stripped = process_markdown(markdown, &strict).unwrap();
1873        assert!(
1874            !stripped.contains("<main>"),
1875            "default sanitizer drops <main>: {stripped}"
1876        );
1877
1878        let extended = MarkdownOptions::new()
1879            .with_custom_blocks(false)
1880            .with_enhanced_tables(false)
1881            .with_unsafe_html(false)
1882            .with_sanitizer_config(
1883                SanitizerConfig::new().with_tag("main"),
1884            );
1885        let kept = process_markdown(markdown, &extended).unwrap();
1886        assert!(
1887            kept.contains("<main>wrapper</main>"),
1888            "extended sanitizer keeps <main>: {kept}"
1889        );
1890    }
1891
1892    #[test]
1893    fn test_sanitizer_config_adds_allowed_class() {
1894        let markdown =
1895            "<span class=\"badge\">new</span> <span class=\"danger\">x</span>";
1896
1897        let options = MarkdownOptions::new()
1898            .with_custom_blocks(false)
1899            .with_enhanced_tables(false)
1900            .with_unsafe_html(false)
1901            .with_sanitizer_config(
1902                SanitizerConfig::new()
1903                    .with_allowed_class("span", "badge"),
1904            );
1905
1906        let html = process_markdown(markdown, &options).unwrap();
1907        assert!(
1908            html.contains("class=\"badge\""),
1909            "whitelisted class survives: {html}"
1910        );
1911        assert!(
1912            !html.contains("class=\"danger\""),
1913            "non-whitelisted class dropped: {html}"
1914        );
1915    }
1916
1917    #[cfg(feature = "syntax_highlighting")]
1918    #[test]
1919    fn test_sanitized_output_keeps_syntect_span_classes() {
1920        // Sanitized pipeline must preserve the open-ended class
1921        // names that ClassedHTMLGenerator emits on <span> — without
1922        // them, code blocks render unstyled.
1923        let markdown = "```rust\nfn main() {}\n```";
1924        let options = MarkdownOptions::new()
1925            .with_custom_blocks(false)
1926            .with_enhanced_tables(false)
1927            .with_unsafe_html(false);
1928
1929        let html = process_markdown(markdown, &options).unwrap();
1930        assert!(
1931            html.contains("<span class=\""),
1932            "syntect classes were stripped by sanitizer: {html}"
1933        );
1934    }
1935
1936    #[test]
1937    fn test_sanitizer_config_restricts_span_class() {
1938        // Custom config with extra_allowed_classes for span must
1939        // override the default permissive span policy: only the
1940        // whitelisted class survives.
1941        let markdown = "<span class=\"badge\">a</span> <span class=\"danger\">b</span>";
1942        let options = MarkdownOptions::new()
1943            .with_custom_blocks(false)
1944            .with_enhanced_tables(false)
1945            .with_unsafe_html(false)
1946            .with_sanitizer_config(
1947                SanitizerConfig::new()
1948                    .with_allowed_class("span", "badge"),
1949            );
1950
1951        let html = process_markdown(markdown, &options).unwrap();
1952        assert!(
1953            html.contains("class=\"badge\""),
1954            "whitelisted class survives: {html}"
1955        );
1956        assert!(
1957            !html.contains("class=\"danger\""),
1958            "non-whitelisted class dropped: {html}"
1959        );
1960    }
1961
1962    #[test]
1963    fn test_sanitizer_strips_style_attribute() {
1964        // The default sanitizer no longer allows `style` on any tag.
1965        // Clickjacking via position:fixed/z-index would otherwise be
1966        // possible through raw HTML in user-supplied Markdown.
1967        let markdown =
1968            "<div style=\"position:fixed;top:0;left:0;width:100%;height:100%;z-index:9999;\">overlay</div>";
1969        let options = MarkdownOptions::new()
1970            .with_custom_blocks(false)
1971            .with_enhanced_tables(false)
1972            .with_unsafe_html(false);
1973
1974        let html = process_markdown(markdown, &options).unwrap();
1975        assert!(
1976            !html.contains("style="),
1977            "style attribute must be stripped: {html}"
1978        );
1979        // The div itself is still allowed; only the attribute goes.
1980        assert!(html.contains("<div"), "div tag dropped: {html}");
1981    }
1982
1983    // ── Table of contents ───────────────────────────────────────
1984
1985    #[test]
1986    fn test_toc_collects_headings_in_document_order() {
1987        let markdown = "\
1988# First
1989Some text.
1990
1991## Second-A
1992More text.
1993
1994## Second-B
1995
1996# Third\n";
1997
1998        let options = MarkdownOptions::new()
1999            .with_custom_blocks(false)
2000            .with_enhanced_tables(false);
2001
2002        let (_html, toc) =
2003            process_markdown_with_toc(markdown, &options).unwrap();
2004        let levels: Vec<u8> = toc.iter().map(|h| h.level).collect();
2005        let texts: Vec<&str> =
2006            toc.iter().map(|h| h.text.as_str()).collect();
2007        assert_eq!(levels, vec![1, 2, 2, 1]);
2008        assert_eq!(
2009            texts,
2010            vec!["First", "Second-A", "Second-B", "Third"]
2011        );
2012    }
2013
2014    #[test]
2015    fn test_toc_ids_match_rendered_html() {
2016        // With header_ids enabled, the rendered HTML must contain
2017        // an id matching every Heading::id we report.
2018        let markdown = "# Hello World\n\n## A Second Heading\n";
2019        let options = MarkdownOptions::new()
2020            .with_custom_blocks(false)
2021            .with_enhanced_tables(false)
2022            .with_header_ids("");
2023
2024        let (html, toc) =
2025            process_markdown_with_toc(markdown, &options).unwrap();
2026        assert_eq!(toc.len(), 2);
2027        for h in &toc {
2028            let needle = format!("id=\"{}\"", h.id);
2029            assert!(
2030                html.contains(&needle),
2031                "ToC id {:?} not found in HTML: {}",
2032                h.id,
2033                html
2034            );
2035        }
2036    }
2037
2038    #[test]
2039    fn test_toc_prefix_propagates() {
2040        let markdown = "# Intro\n";
2041        let options = MarkdownOptions::new()
2042            .with_custom_blocks(false)
2043            .with_enhanced_tables(false)
2044            .with_header_ids("user-content-");
2045
2046        let (html, toc) =
2047            process_markdown_with_toc(markdown, &options).unwrap();
2048        assert_eq!(toc.len(), 1);
2049        assert_eq!(toc[0].id, "user-content-intro");
2050        assert!(html.contains("id=\"user-content-intro\""));
2051    }
2052
2053    #[test]
2054    fn test_toc_dedup_with_repeated_headings() {
2055        let markdown = "# Notes\n## Notes\n### Notes\n";
2056        let options = MarkdownOptions::new()
2057            .with_custom_blocks(false)
2058            .with_enhanced_tables(false)
2059            .with_header_ids("");
2060
2061        let (_html, toc) =
2062            process_markdown_with_toc(markdown, &options).unwrap();
2063        let ids: Vec<&str> =
2064            toc.iter().map(|h| h.id.as_str()).collect();
2065        assert_eq!(ids, vec!["notes", "notes-1", "notes-2"]);
2066    }
2067
2068    #[test]
2069    fn test_toc_empty_document() {
2070        let options = MarkdownOptions::new()
2071            .with_custom_blocks(false)
2072            .with_enhanced_tables(false);
2073        let (html, toc) =
2074            process_markdown_with_toc("", &options).unwrap();
2075        assert!(toc.is_empty());
2076        assert!(html.trim().is_empty());
2077    }
2078
2079    #[test]
2080    fn test_toc_writer_variant_writes_html_and_returns_toc() {
2081        let markdown = "# Title\n\n## Sub\n";
2082        let options = MarkdownOptions::new()
2083            .with_custom_blocks(false)
2084            .with_enhanced_tables(false);
2085
2086        let mut buf: Vec<u8> = Vec::new();
2087        let toc = process_markdown_with_toc_to_writer(
2088            markdown, &mut buf, &options,
2089        )
2090        .unwrap();
2091        let html = String::from_utf8(buf).unwrap();
2092        assert!(html.contains("<h1>Title</h1>"));
2093        assert!(html.contains("<h2>Sub</h2>"));
2094        assert_eq!(toc.len(), 2);
2095    }
2096
2097    #[test]
2098    fn test_toc_extracts_inline_code_text() {
2099        let markdown = "# Using `&str` types\n";
2100        let options = MarkdownOptions::new()
2101            .with_custom_blocks(false)
2102            .with_enhanced_tables(false);
2103
2104        let (_html, toc) =
2105            process_markdown_with_toc(markdown, &options).unwrap();
2106        assert_eq!(toc.len(), 1);
2107        assert_eq!(toc[0].text, "Using &str types");
2108    }
2109
2110    #[test]
2111    fn test_sanitizer_config_default_path_unchanged() {
2112        // Options with no sanitizer_config must go through the cached
2113        // default builder and produce the same output as before the
2114        // feature was added.
2115        let markdown = "<script>x</script>\n<div class=\"alert alert-info\">safe</div>";
2116        let options = MarkdownOptions::new()
2117            .with_custom_blocks(false)
2118            .with_enhanced_tables(false)
2119            .with_unsafe_html(false);
2120
2121        let html = process_markdown(markdown, &options).unwrap();
2122        assert!(!html.contains("<script>"));
2123        assert!(html.contains("alert alert-info"));
2124    }
2125
2126    // ── Diagrams (mermaid / geojson / topojson / stl) ───────────
2127
2128    #[test]
2129    fn test_diagrams_off_by_default_leaves_code_block() {
2130        let md = "```mermaid\ngraph TD\nA-->B\n```\n";
2131        let options = MarkdownOptions::new()
2132            .with_custom_blocks(false)
2133            .with_enhanced_tables(false);
2134        let html = process_markdown(md, &options).unwrap();
2135        // Default syntax highlighting kicks in — the content is
2136        // rendered as a syntax-highlighted code block, NOT a mermaid
2137        // container.
2138        assert!(html.contains("<code class=\"language-mermaid\">"));
2139        assert!(!html.contains("class=\"mermaid\""));
2140    }
2141
2142    #[test]
2143    fn test_diagrams_mermaid_survives_sanitizer() {
2144        let md = "```mermaid\ngraph TD\nA-->B\n```\n";
2145        let options = MarkdownOptions::new()
2146            .with_custom_blocks(false)
2147            .with_enhanced_tables(false)
2148            .with_syntax_highlighting(false)
2149            .with_diagrams(true)
2150            .with_unsafe_html(false);
2151        let html = process_markdown(md, &options).unwrap();
2152        assert!(
2153            html.contains("<pre class=\"mermaid\">"),
2154            "mermaid container stripped: {html}"
2155        );
2156        assert!(html.contains("graph TD"));
2157    }
2158
2159    #[test]
2160    fn test_diagrams_non_matching_lang_still_highlighted() {
2161        // With diagrams on, a non-mermaid language still gets the
2162        // normal syntax-highlighter treatment.
2163        let md = "```python\nprint('hi')\n```\n";
2164        let options = MarkdownOptions::new()
2165            .with_custom_blocks(false)
2166            .with_enhanced_tables(false)
2167            .with_diagrams(true);
2168        let html = process_markdown(md, &options).unwrap();
2169        assert!(html.contains("<code class=\"language-python\">"));
2170        assert!(!html.contains("class=\"mermaid\""));
2171    }
2172
2173    #[test]
2174    fn test_diagrams_formerly_supported_langs_highlight_as_usual() {
2175        // `geojson`, `topojson`, `stl` used to be recognised but
2176        // produced lifeless output even with rich demo data and
2177        // proper lighting, so the project narrowed scope to
2178        // mermaid. Those code blocks now flow through the standard
2179        // syntax-highlighter path (or render as plain code when
2180        // the syntax is unknown to syntect), never as a mermaid
2181        // container.
2182        let md = "```geojson\n{\"type\":\"Feature\"}\n```\n\n```stl\nsolid x\nendsolid x\n```\n";
2183        let options = MarkdownOptions::new()
2184            .with_custom_blocks(false)
2185            .with_enhanced_tables(false)
2186            .with_diagrams(true);
2187        let html = process_markdown(md, &options).unwrap();
2188        assert!(!html.contains("class=\"mermaid\""));
2189        assert!(!html.contains("mdx-diagram"));
2190    }
2191
2192    // ── Plain text extractor ────────────────────────────────────
2193
2194    #[test]
2195    fn test_plain_text_basic() {
2196        let md = "# Hello World\n\nA **bold** paragraph.";
2197        let text = process_markdown_to_plain_text(
2198            md,
2199            &MarkdownOptions::default(),
2200        )
2201        .unwrap();
2202        assert_eq!(text, "Hello World A bold paragraph.");
2203    }
2204
2205    #[test]
2206    fn test_plain_text_lists_and_code() {
2207        let md =
2208            "# Title\n\nDesc.\n\n- one\n- two\n\n```\nfn main() {}\n```";
2209        let text = process_markdown_to_plain_text(
2210            md,
2211            &MarkdownOptions::default(),
2212        )
2213        .unwrap();
2214        assert!(text.contains("Title"));
2215        assert!(text.contains("Desc."));
2216        assert!(text.contains("one"));
2217        assert!(text.contains("two"));
2218        assert!(text.contains("fn main() {}"));
2219        // No words merged into each other.
2220        assert!(!text.contains("Titleone"));
2221        assert!(!text.contains("onetwo"));
2222    }
2223
2224    #[test]
2225    fn test_plain_text_strips_html() {
2226        // Raw HTML should NOT appear in the plain-text output.
2227        let md = "A <strong>bold</strong> word";
2228        let text = process_markdown_to_plain_text(
2229            md,
2230            &MarkdownOptions::default(),
2231        )
2232        .unwrap();
2233        assert!(!text.contains("<strong>"));
2234        assert!(!text.contains("</strong>"));
2235        assert!(text.contains("bold"));
2236    }
2237
2238    #[test]
2239    fn test_plain_text_respects_input_cap() {
2240        let options = MarkdownOptions::new().with_max_input_size(8);
2241        let err =
2242            process_markdown_to_plain_text(&"a".repeat(64), &options)
2243                .unwrap_err();
2244        assert!(matches!(err, MarkdownError::InputTooLarge { .. }));
2245    }
2246
2247    #[test]
2248    fn test_plain_text_includes_inline_code() {
2249        // Covers the `NodeValue::Code` arm of `collect_all_text` —
2250        // inline backtick code was the only text-emitting AST node
2251        // variant not touched by any other plain-text test.
2252        let md = "Use `println!` to print, then `drop`.";
2253        let text = process_markdown_to_plain_text(
2254            md,
2255            &MarkdownOptions::default(),
2256        )
2257        .unwrap();
2258        assert!(
2259            text.contains("println!"),
2260            "expected inline code literal, got: {text:?}"
2261        );
2262        assert!(
2263            text.contains("drop"),
2264            "expected second inline code literal, got: {text:?}"
2265        );
2266    }
2267
2268    // ── Math + footnote sanitizer survival ──────────────────────
2269
2270    #[test]
2271    fn test_math_dollars_survives_sanitizer() {
2272        // `extension.math_dollars` renders as
2273        // `<span data-math-style="inline">…</span>`. The default
2274        // sanitizer must preserve the attribute so a frontend
2275        // library (KaTeX, MathJax) can find and render it.
2276        let mut comrak = Options::default();
2277        comrak.extension.math_dollars = true;
2278        let options = MarkdownOptions::new()
2279            .with_comrak_options(comrak)
2280            .with_custom_blocks(false)
2281            .with_enhanced_tables(false)
2282            .with_syntax_highlighting(false)
2283            .with_unsafe_html(false);
2284
2285        let html =
2286            process_markdown("Inline $a^2 + b^2$ math.", &options)
2287                .unwrap();
2288        assert!(
2289            html.contains("data-math-style"),
2290            "data-math-style attribute stripped by sanitizer: {html}"
2291        );
2292    }
2293
2294    #[test]
2295    fn test_footnote_link_survives_sanitizer() {
2296        // GFM footnotes emit `<sup><a href="#fn-1" id="fnref-1">`
2297        // + a back-reference link. The default sanitizer must
2298        // preserve both so footnote navigation works.
2299        let mut comrak = Options::default();
2300        comrak.extension.footnotes = true;
2301        let options = MarkdownOptions::new()
2302            .with_comrak_options(comrak)
2303            .with_custom_blocks(false)
2304            .with_enhanced_tables(false)
2305            .with_syntax_highlighting(false)
2306            .with_unsafe_html(false);
2307
2308        let md = "Claim[^1].\n\n[^1]: Reason.\n";
2309        let html = process_markdown(md, &options).unwrap();
2310        assert!(html.contains("<sup"), "missing <sup>: {html}");
2311        assert!(
2312            html.contains("href=\"#fn-1\"")
2313                || html.contains("href=\"#fn1\""),
2314            "missing forward link: {html}"
2315        );
2316        assert!(
2317            html.contains("href=\"#fnref-1\"")
2318                || html.contains("href=\"#fnref1\""),
2319            "missing back-reference link: {html}"
2320        );
2321    }
2322}