Skip to main content

html_generator/
generator.rs

1// Copyright © 2025 HTML Generator. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! HTML generation module for converting Markdown to HTML.
5//!
6//! This module provides functions to generate HTML from Markdown content
7//! using the `mdx-gen` library. It supports various Markdown extensions
8//! and custom configuration options.
9
10#[cfg(not(target_arch = "wasm32"))]
11use crate::error::HtmlError;
12use crate::{
13    accessibility::add_aria_attributes,
14    extract_front_matter,
15    performance::minify_html_string,
16    seo::{escape_html, generate_structured_data_from_doc},
17    utils::generate_table_of_contents,
18    Result,
19};
20#[cfg(target_arch = "wasm32")]
21use comrak::Options;
22use log::warn;
23#[cfg(not(target_arch = "wasm32"))]
24use mdx_gen::{process_markdown, MarkdownOptions, Options};
25use once_cell::sync::Lazy;
26#[cfg(not(target_arch = "wasm32"))]
27use regex::Regex;
28#[cfg(not(target_arch = "wasm32"))]
29use std::borrow::Cow;
30use std::error::Error;
31use std::fmt;
32
33/// Pre-built comrak [`Options`] with every extension this crate uses
34/// enabled. Cloned per call and mutated for `render.r#unsafe`, which
35/// is the only extension-layer bit that varies at runtime. Cheap
36/// shallow clone; avoids reconstructing the full option tree on each
37/// `generate_html` invocation.
38static BASE_COMRAK_OPTIONS: Lazy<Options<'static>> = Lazy::new(|| {
39    let mut opts = Options::default();
40    opts.extension.strikethrough = true;
41    opts.extension.table = true;
42    opts.extension.autolink = true;
43    opts.extension.tasklist = true;
44    opts.extension.superscript = true;
45    opts
46});
47
48/// Severity level for a processing diagnostic.
49///
50/// # Examples
51///
52/// ```
53/// use html_generator::generator::DiagnosticLevel;
54///
55/// let level = DiagnosticLevel::Warning;
56/// assert_eq!(format!("{level:?}"), "Warning");
57/// ```
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum DiagnosticLevel {
60    /// Informational — a step succeeded with notable metrics.
61    Info,
62    /// A non-fatal issue — the pipeline continued with a fallback.
63    Warning,
64    /// A step failed entirely and was skipped.
65    Error,
66}
67
68/// A diagnostic emitted when a post-processing step fails non-fatally.
69///
70/// # Examples
71///
72/// ```
73/// use html_generator::generator::{Diagnostic, DiagnosticLevel};
74///
75/// let d = Diagnostic {
76///     step: "accessibility",
77///     level: DiagnosticLevel::Info,
78///     message: "ARIA attributes added".to_string(),
79/// };
80/// assert_eq!(d.step, "accessibility");
81/// assert!(d.to_string().contains("ARIA attributes added"));
82/// ```
83#[derive(Debug, Clone)]
84pub struct Diagnostic {
85    /// Which pipeline step produced this diagnostic.
86    pub step: &'static str,
87    /// Severity.
88    pub level: DiagnosticLevel,
89    /// Human-readable description of what went wrong.
90    pub message: String,
91}
92
93impl fmt::Display for Diagnostic {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        write!(f, "[{:?}] {}: {}", self.level, self.step, self.message)
96    }
97}
98
99/// The result of [`generate_html_with_diagnostics`]: final HTML plus any
100/// warnings from post-processing steps that failed non-fatally.
101///
102/// # Examples
103///
104/// ```
105/// use html_generator::{generator::generate_html_with_diagnostics, HtmlConfig};
106///
107/// let out = generate_html_with_diagnostics("# hello", &HtmlConfig::default()).unwrap();
108/// assert!(out.html.contains("<h1>"));
109/// // diagnostics records what each pipeline step did or skipped:
110/// let _ = out.diagnostics.len();
111/// ```
112#[derive(Debug, Clone)]
113pub struct HtmlOutput {
114    /// The generated HTML content.
115    pub html: String,
116    /// Diagnostics from pipeline steps that were skipped or degraded.
117    /// Empty when every step succeeded.
118    pub diagnostics: Vec<Diagnostic>,
119}
120
121/// Regex matching triple-colon custom class blocks. The static is
122/// only consulted by the native `markdown_to_html_impl` path; on
123/// `wasm32` we delegate directly to comrak and the helpers below
124/// are dead code.
125#[cfg(not(target_arch = "wasm32"))]
126static CUSTOM_CLASS_REGEX: Lazy<Regex> = Lazy::new(|| {
127    Regex::new(r":::(\w+)\n([\s\S]*?)\n:::")
128        .expect("static CUSTOM_CLASS_REGEX must compile")
129});
130
131/// Regex matching image-with-class syntax: `![alt](url).class="cls"`.
132/// Native-only; see `CUSTOM_CLASS_REGEX` above.
133#[cfg(not(target_arch = "wasm32"))]
134static IMAGE_CLASS_REGEX: Lazy<Regex> = Lazy::new(|| {
135    Regex::new(r#"!\[(.*?)\]\((.*?)\)\.class="(.*?)""#)
136        .expect("static IMAGE_CLASS_REGEX must compile")
137});
138
139/// Generate HTML from Markdown content using `mdx-gen`.
140///
141/// This function takes Markdown content and a configuration object,
142/// converts the Markdown into HTML, and applies the full processing
143/// pipeline based on configuration:
144///
145/// 1. Markdown → HTML conversion (with extensions)
146/// 2. Accessibility: adds ARIA attributes if enabled
147/// 3. Table of contents: injects TOC at `[[TOC]]` placeholder
148/// 4. Structured data: appends JSON-LD script tag
149/// 5. Minification: compresses output if enabled
150///
151/// Non-fatal failures in steps 2–5 are silently skipped. Use
152/// [`generate_html_with_diagnostics`] to inspect which steps failed.
153///
154/// # Examples
155///
156/// ```
157/// use html_generator::{generator::generate_html, HtmlConfig};
158///
159/// let html = generate_html("# Hello", &HtmlConfig::default()).unwrap();
160/// assert!(html.contains("<h1>Hello</h1>"));
161/// ```
162///
163/// # Errors
164///
165/// Returns [`crate::error::HtmlError`] if the core Markdown→HTML
166/// conversion fails (input invalid, exceeds buffer limits, etc.).
167pub fn generate_html(
168    markdown: &str,
169    config: &crate::HtmlConfig,
170) -> Result<String> {
171    generate_html_with_diagnostics(markdown, config).map(|o| o.html)
172}
173
174/// Like [`generate_html`], but returns an [`HtmlOutput`] that includes
175/// diagnostics for any post-processing steps that failed non-fatally.
176///
177/// # Examples
178///
179/// ```
180/// use html_generator::{
181///     generator::{generate_html_with_diagnostics, DiagnosticLevel},
182///     HtmlConfig,
183/// };
184///
185/// let out =
186///     generate_html_with_diagnostics("# Hello", &HtmlConfig::default()).unwrap();
187/// assert!(out.html.contains("<h1>"));
188/// // No fatal errors; any diagnostics are informational or warnings.
189/// assert!(
190///     out.diagnostics
191///         .iter()
192///         .all(|d| d.level != DiagnosticLevel::Error)
193/// );
194/// ```
195///
196/// # Errors
197///
198/// Returns [`crate::error::HtmlError`] if the core Markdown→HTML
199/// conversion fails. Non-fatal post-processing failures are recorded
200/// as `Error`-level diagnostics rather than propagated.
201pub fn generate_html_with_diagnostics(
202    markdown: &str,
203    config: &crate::HtmlConfig,
204) -> Result<HtmlOutput> {
205    let mut diagnostics: Vec<Diagnostic> = Vec::new();
206
207    // Step 1: Core Markdown → HTML (fatal on failure)
208    let mut html = markdown_to_html_impl(markdown, config)?;
209
210    // Step 2: HTML sanitization via ammonia (when raw HTML is allowed)
211    if config.allow_unsafe_html && config.sanitize_html {
212        html = ammonia::clean(&html);
213        diagnostics.push(Diagnostic {
214            step: "sanitization",
215            level: DiagnosticLevel::Info,
216            message: "HTML sanitized via ammonia".to_string(),
217        });
218    }
219
220    // Step 3: Accessibility — add ARIA attributes
221    if config.add_aria_attributes {
222        match add_aria_attributes(&html, None) {
223            Ok(enhanced) => {
224                html = enhanced;
225                diagnostics.push(Diagnostic {
226                    step: "accessibility",
227                    level: DiagnosticLevel::Info,
228                    message: "ARIA attributes added".to_string(),
229                });
230            }
231            Err(e) => {
232                let d = Diagnostic {
233                    step: "accessibility",
234                    level: DiagnosticLevel::Error,
235                    message: format!("ARIA enhancement skipped: {e}"),
236                };
237                warn!("{d}");
238                diagnostics.push(d);
239            }
240        }
241    }
242
243    // Step 4: Table of contents — replace [[TOC]] placeholder
244    if config.generate_toc {
245        match generate_table_of_contents(&html) {
246            Ok(toc) => {
247                html = html.replace("[[TOC]]", &toc);
248                diagnostics.push(Diagnostic {
249                    step: "toc",
250                    level: DiagnosticLevel::Info,
251                    message: "Table of contents injected".to_string(),
252                });
253            }
254            Err(e) => {
255                let d = Diagnostic {
256                    step: "toc",
257                    level: DiagnosticLevel::Error,
258                    message: format!(
259                        "Table of contents generation failed: {e}"
260                    ),
261                };
262                warn!("{d}");
263                diagnostics.push(d);
264            }
265        }
266    }
267
268    // Step 4b: Math — convert $..$ / $$..$$ to inline MathML.
269    // Infallible: pulldown-latex encodes parse errors inline as
270    // `<merror>` elements rather than returning Err, so the
271    // pipeline never has to skip this step.
272    #[cfg(feature = "math")]
273    if config.enable_math {
274        let before_len = html.len();
275        html = crate::math::convert_math(&html);
276        if html.len() != before_len {
277            diagnostics.push(Diagnostic {
278                step: "math",
279                level: DiagnosticLevel::Info,
280                message: "LaTeX math rendered to MathML".to_string(),
281            });
282        }
283    }
284
285    // Step 4c: Diagrams — rewrite mermaid fenced blocks for client-side mermaid.js
286    if config.enable_diagrams {
287        let before_len = html.len();
288        html = crate::math::rewrite_mermaid_blocks(&html);
289        if html.len() != before_len {
290            diagnostics.push(Diagnostic {
291                step: "diagrams",
292                level: DiagnosticLevel::Info,
293                message:
294                    "Mermaid blocks rewritten for client-side rendering"
295                        .to_string(),
296            });
297        }
298    }
299
300    // Step 5: Parse DOM once for read-only steps (SEO, heading extraction)
301    let document = scraper::Html::parse_document(&html);
302
303    // Step 5a: Structured data — generate JSON-LD
304    let mut json_ld_fragment = String::new();
305    if config.generate_structured_data {
306        match generate_structured_data_from_doc(&document, None) {
307            Ok(json_ld) => {
308                json_ld_fragment = json_ld;
309                diagnostics.push(Diagnostic {
310                    step: "structured_data",
311                    level: DiagnosticLevel::Info,
312                    message: "JSON-LD structured data generated"
313                        .to_string(),
314                });
315            }
316            Err(e) => {
317                let d = Diagnostic {
318                    step: "structured_data",
319                    level: DiagnosticLevel::Error,
320                    message: format!(
321                        "Structured data generation failed: {e}"
322                    ),
323                };
324                warn!("{d}");
325                diagnostics.push(d);
326            }
327        }
328    }
329
330    // Step 6: Full document wrapping or fragment language injection
331    if config.generate_full_document {
332        // Extract title from already-parsed DOM (no extra parse)
333        let title = extract_first_heading_from_doc(&document);
334        html = wrap_full_document(
335            &html,
336            &json_ld_fragment,
337            title.as_deref(),
338            config,
339        );
340    } else {
341        // Fragment mode: append JSON-LD at the end (legacy behaviour)
342        if !json_ld_fragment.is_empty() {
343            html.push_str(&json_ld_fragment);
344        }
345        // Wrap in a lang div when the user set a non-default language
346        if config.language != crate::constants::DEFAULT_LANGUAGE {
347            html = format!(
348                "<div lang=\"{}\">{}</div>",
349                escape_html(&config.language),
350                html
351            );
352        }
353    }
354
355    // Step 7: Minification
356    if config.minify_output {
357        let before_len = html.len();
358        match minify_html_string(&html) {
359            Ok(minified) => {
360                let saved = before_len.saturating_sub(minified.len());
361                html = minified;
362                diagnostics.push(Diagnostic {
363                    step: "minification",
364                    level: DiagnosticLevel::Info,
365                    message: format!(
366                        "Minified: saved {} bytes ({:.0}%)",
367                        saved,
368                        if before_len > 0 {
369                            saved as f64 / before_len as f64 * 100.0
370                        } else {
371                            0.0
372                        }
373                    ),
374                });
375            }
376            Err(e) => {
377                let d = Diagnostic {
378                    step: "minification",
379                    level: DiagnosticLevel::Error,
380                    message: format!("Minification failed: {e}"),
381                };
382                warn!("{d}");
383                diagnostics.push(d);
384            }
385        }
386    }
387
388    Ok(HtmlOutput { html, diagnostics })
389}
390
391/// Wraps HTML body content in a valid HTML5 document skeleton.
392fn wrap_full_document(
393    body: &str,
394    json_ld: &str,
395    title: Option<&str>,
396    config: &crate::HtmlConfig,
397) -> String {
398    let lang = escape_html(&config.language);
399    let mut head = String::from("<meta charset=\"utf-8\">");
400
401    if let Some(t) = title {
402        head.push_str(&format!("<title>{}</title>", escape_html(t)));
403    }
404
405    if !json_ld.is_empty() {
406        head.push_str(json_ld);
407    }
408
409    format!(
410        "<!DOCTYPE html>\n<html lang=\"{lang}\">\n<head>{head}</head>\n<body>\n{body}\n</body>\n</html>"
411    )
412}
413
414/// Selector for the first heading; the source content is a compile-time
415/// constant, so parsing is infallible at runtime.
416static H1_SELECTOR: Lazy<scraper::Selector> = Lazy::new(|| {
417    scraper::Selector::parse("h1")
418        .expect("static H1_SELECTOR must parse")
419});
420
421/// Extracts text content from the first `<h1>` in a pre-parsed DOM.
422fn extract_first_heading_from_doc(
423    document: &scraper::Html,
424) -> Option<String> {
425    document
426        .select(&H1_SELECTOR)
427        .next()
428        .map(|el| el.text().collect::<String>())
429}
430
431/// Convert Markdown to HTML with specified extensions using `mdx-gen`.
432///
433/// Uses [`crate::HtmlConfig::default`] under the hood; for full control
434/// over the pipeline use [`generate_html`] directly.
435///
436/// # Examples
437///
438/// ```
439/// use html_generator::generator::markdown_to_html_with_extensions;
440///
441/// let html = markdown_to_html_with_extensions("**bold**").unwrap();
442/// assert!(html.contains("<strong>bold</strong>"));
443/// ```
444///
445/// # Errors
446///
447/// Returns [`crate::error::HtmlError::MarkdownConversion`] if the
448/// underlying `comrak`/`mdx-gen` parse fails.
449pub fn markdown_to_html_with_extensions(
450    markdown: &str,
451) -> Result<String> {
452    markdown_to_html_impl(markdown, &crate::HtmlConfig::default())
453}
454
455#[cfg(not(target_arch = "wasm32"))]
456fn markdown_to_html_impl(
457    markdown: &str,
458    config: &crate::HtmlConfig,
459) -> Result<String> {
460    // 1) Extract front matter
461    let content_without_front_matter = extract_front_matter(markdown)
462        .unwrap_or_else(|_| markdown.to_string());
463
464    // Crate-generated HTML (`:::class` wrappers, image-class `<img>`) is
465    // trusted and must survive the escaping render below. Each such fragment
466    // is replaced with an opaque sentinel *before* rendering and restored
467    // *after*. User-authored raw HTML is never turned into a sentinel, so it
468    // still obeys `render.escape`. A user who forges a sentinel can at worst
469    // surface one of *our own* fragments — each built from `\w+`-validated
470    // class names and `escape_html`'d attributes — never arbitrary HTML, so
471    // tunnelling cannot become an XSS vector.
472    let mut tunnels: Vec<String> = Vec::new();
473
474    // 2) Convert triple-colon blocks (no-alloc when no `:::` match).
475    let markdown_with_classes = add_custom_classes(
476        &content_without_front_matter,
477        config.allow_unsafe_html,
478        &mut tunnels,
479    );
480
481    // 3) Convert images with `.class="..."` (no-alloc when no match).
482    let markdown_with_images = process_images_with_classes(
483        &markdown_with_classes,
484        &mut tunnels,
485    );
486
487    // 4) Clone the cached Options tree and set the two runtime-varying
488    //    bits (unsafe HTML + syntax highlighting/theme).
489    let mut comrak_options = BASE_COMRAK_OPTIONS.clone();
490    comrak_options.render.r#unsafe = config.allow_unsafe_html;
491    // `mdx-gen` unconditionally forces `render.r#unsafe = true` on the
492    // options it receives, which would silently let raw HTML (including
493    // `<script>`) through even when `allow_unsafe_html` is false. comrak
494    // gives `escape` precedence over `r#unsafe`, so setting `escape` here
495    // entity-escapes untrusted raw HTML regardless of the mdx-gen override.
496    comrak_options.render.escape = !config.allow_unsafe_html;
497
498    let mut md_options = MarkdownOptions::default()
499        .with_comrak_options(comrak_options)
500        .with_syntax_highlighting(config.enable_syntax_highlighting);
501
502    if let Some(ref theme) = config.syntax_theme {
503        md_options = md_options.with_custom_theme(theme.clone());
504    }
505
506    // 5) Convert final Markdown to HTML
507    let mut html = process_markdown(&markdown_with_images, &md_options)
508        .map_err(|err| {
509            HtmlError::markdown_conversion(err.to_string(), None)
510        })?;
511
512    // 6) Restore tunnelled crate HTML. Block-level fragments (`:::` divs)
513    //    stood alone, so comrak wrapped the sentinel in a paragraph — strip
514    //    that wrapper to avoid `<p><div>…</div></p>`. Inline fragments
515    //    (images) are swapped in place.
516    for (index, fragment) in tunnels.iter().enumerate() {
517        let sentinel = tunnel_sentinel(index);
518        html = html.replace(&format!("<p>{sentinel}</p>"), fragment);
519        html = html.replace(&sentinel, fragment);
520    }
521
522    Ok(html)
523}
524
525/// Opaque sentinel used to tunnel trusted crate-generated HTML through the
526/// escaping render. Delimited by U+FFFC (OBJECT REPLACEMENT CHARACTER), which
527/// does not occur in normal Markdown and — not being an HTML metacharacter —
528/// is passed through verbatim by comrak regardless of `render.escape`.
529#[cfg(not(target_arch = "wasm32"))]
530fn tunnel_sentinel(index: usize) -> String {
531    format!("\u{FFFC}\u{FFFC}hgtunnel{index}\u{FFFC}\u{FFFC}")
532}
533
534/// WASM-target Markdown → HTML.
535///
536/// Bypasses `mdx-gen` (which pulls in `tokio` unconditionally and
537/// therefore does not compile to `wasm32-unknown-unknown`) and calls
538/// `comrak` directly with the same extension flags that `mdx-gen`
539/// would have set. Custom classes (`:::warning`), image-class
540/// syntax, and `syntect` syntax highlighting are not available in
541/// this build path; everything else (CommonMark + GFM tables,
542/// strikethrough, autolinks, tasklists, superscript) renders
543/// identically to the native pipeline.
544#[cfg(target_arch = "wasm32")]
545fn markdown_to_html_impl(
546    markdown: &str,
547    config: &crate::HtmlConfig,
548) -> Result<String> {
549    let content_without_front_matter = extract_front_matter(markdown)
550        .unwrap_or_else(|_| markdown.to_string());
551
552    let mut opts = BASE_COMRAK_OPTIONS.clone();
553    opts.render.r#unsafe = config.allow_unsafe_html;
554
555    Ok(comrak::markdown_to_html(
556        &content_without_front_matter,
557        &opts,
558    ))
559}
560
561/// Re-parse inline Markdown for triple-colon blocks, e.g.:
562///
563/// ```markdown
564/// :::warning
565/// **Caution:** This is risky.
566/// :::
567/// ```
568///
569/// Produces something like:
570/// ```html
571/// <div class="warning"><strong>Caution:</strong> This is risky.</div>
572/// ```
573///
574/// # Example
575/// ...
576#[cfg(not(target_arch = "wasm32"))]
577fn add_custom_classes<'a>(
578    markdown: &'a str,
579    allow_unsafe_html: bool,
580    tunnels: &mut Vec<String>,
581) -> Cow<'a, str> {
582    // `regex::Regex::replace_all` returns `Cow::Borrowed(markdown)`
583    // when there are zero matches — avoiding the allocation
584    // entirely for the common case of a document without `:::` blocks.
585    // Each rendered block is stashed in `tunnels` and replaced by a sentinel
586    // so the trusted `<div>` survives the escaping render (see
587    // `markdown_to_html_impl`).
588    CUSTOM_CLASS_REGEX.replace_all(
589        markdown,
590        |caps: &regex::Captures| {
591            let class_name = &caps[1];
592            let block_content = &caps[2];
593
594            let inline_html = match process_markdown_inline_impl(
595                block_content,
596                allow_unsafe_html,
597            ) {
598                Ok(html) => html,
599                Err(_) => block_content.to_string(),
600            };
601
602            // class_name is validated by the \w+ regex — safe to interpolate;
603            // inline_html already ran through the escaping inline renderer.
604            let fragment = format!(
605                "<div class=\"{class_name}\">{inline_html}</div>"
606            );
607            let index = tunnels.len();
608            tunnels.push(fragment);
609            tunnel_sentinel(index)
610        },
611    )
612}
613
614/// Processes inline Markdown (bold, italics, links, etc.) without block-level syntax.
615///
616/// # Examples
617///
618/// ```
619/// use html_generator::generator::process_markdown_inline;
620///
621/// let html = process_markdown_inline("**bold** and *italic*").unwrap();
622/// assert!(html.contains("<strong>bold</strong>"));
623/// assert!(html.contains("<em>italic</em>"));
624/// ```
625///
626/// # Errors
627///
628/// Returns the underlying `mdx-gen` error if Markdown parsing fails.
629pub fn process_markdown_inline(
630    content: &str,
631) -> std::result::Result<String, Box<dyn Error>> {
632    process_markdown_inline_impl(content, false)
633}
634
635#[cfg(not(target_arch = "wasm32"))]
636fn process_markdown_inline_impl(
637    content: &str,
638    allow_unsafe_html: bool,
639) -> std::result::Result<String, Box<dyn Error>> {
640    // Inline rendering shares the same extension tree as the outer
641    // pipeline; clone from the cached base rather than rebuilding.
642    let mut comrak_opts = BASE_COMRAK_OPTIONS.clone();
643    comrak_opts.render.r#unsafe = allow_unsafe_html;
644    // Same mdx-gen override guard as the outer pipeline: escape untrusted
645    // raw HTML inside `:::` block content unless unsafe HTML is allowed.
646    comrak_opts.render.escape = !allow_unsafe_html;
647
648    let options =
649        MarkdownOptions::default().with_comrak_options(comrak_opts);
650    Ok(process_markdown(content, &options)?)
651}
652
653/// WASM-target inline Markdown rendering. See the `markdown_to_html_impl`
654/// WASM variant for the rationale (no `mdx-gen` on `wasm32`).
655#[cfg(target_arch = "wasm32")]
656fn process_markdown_inline_impl(
657    content: &str,
658    allow_unsafe_html: bool,
659) -> std::result::Result<String, Box<dyn Error>> {
660    let mut opts = BASE_COMRAK_OPTIONS.clone();
661    opts.render.r#unsafe = allow_unsafe_html;
662    Ok(comrak::markdown_to_html(content, &opts))
663}
664
665/// Replaces image patterns like
666/// `![Alt text](URL).class="some-class"` with `<img src="URL" alt="Alt text" class="some-class" />`.
667#[cfg(not(target_arch = "wasm32"))]
668fn process_images_with_classes<'a>(
669    markdown: &'a str,
670    tunnels: &mut Vec<String>,
671) -> Cow<'a, str> {
672    // Borrowed-Cow when the document has no `![alt](url).class="x"`
673    // construct — i.e. every typical document. Each generated `<img>` is
674    // tunnelled (see `markdown_to_html_impl`) so it survives escaping.
675    IMAGE_CLASS_REGEX.replace_all(markdown, |caps: &regex::Captures| {
676        let fragment = format!(
677            r#"<img src="{}" alt="{}" class="{}" />"#,
678            escape_html(&caps[2]), // URL
679            escape_html(&caps[1]), // alt text
680            escape_html(&caps[3]), // class attribute
681        );
682        let index = tunnels.len();
683        tunnels.push(fragment);
684        tunnel_sentinel(index)
685    })
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use crate::HtmlConfig;
692
693    /// Test basic Markdown to HTML conversion.
694    ///
695    /// This test verifies that a simple Markdown input is correctly converted to HTML.
696    #[test]
697    fn test_generate_html_basic() {
698        let markdown = "# Hello, world!\n\nThis is a test.";
699        let config = HtmlConfig::default();
700        let result = generate_html(markdown, &config);
701        assert!(result.is_ok());
702        let html = result.unwrap();
703        assert!(html.contains("<h1>Hello, world!</h1>"));
704        assert!(html.contains("<p>This is a test.</p>"));
705    }
706
707    /// Test conversion with Markdown extensions.
708    ///
709    /// This test ensures that the Markdown extensions (e.g., custom blocks, enhanced tables, etc.)
710    /// are correctly applied when converting Markdown to HTML.
711    #[test]
712    fn test_markdown_to_html_with_extensions() {
713        let markdown = r"
714| Header 1 | Header 2 |
715| -------- | -------- |
716| Row 1    | Row 2    |
717";
718        let result = markdown_to_html_with_extensions(markdown);
719        assert!(result.is_ok());
720        let html = result.unwrap();
721
722        println!("{}", html);
723
724        // Update the test to look for the div wrapper and table classes
725        assert!(html.contains("<div class=\"table-responsive\"><table class=\"table\">"), "Table element not found");
726        assert!(
727            html.contains("<th>Header 1</th>"),
728            "Table header not found"
729        );
730        assert!(
731            html.contains("<td class=\"text-left\">Row 1</td>"),
732            "Table row not found"
733        );
734    }
735
736    /// Test conversion of empty Markdown.
737    ///
738    /// This test checks that an empty Markdown input results in an empty HTML string.
739    #[test]
740    fn test_generate_html_empty() {
741        let markdown = "";
742        let config = HtmlConfig::default();
743        let result = generate_html(markdown, &config);
744        assert!(result.is_ok());
745        let html = result.unwrap();
746        assert!(html.is_empty());
747    }
748
749    /// Test handling of invalid Markdown.
750    ///
751    /// This test verifies that even with poorly formatted Markdown, the function
752    /// will not panic and will return valid HTML.
753    #[test]
754    fn test_generate_html_invalid_markdown() {
755        let markdown = "# Unclosed header\nSome **unclosed bold";
756        let config = HtmlConfig::default();
757        let result = generate_html(markdown, &config);
758        assert!(result.is_ok());
759        let html = result.unwrap();
760
761        println!("{}", html);
762
763        assert!(
764            html.contains("<h1>Unclosed header</h1>"),
765            "Header not found"
766        );
767        assert!(
768            html.contains("<p>Some **unclosed bold</p>"),
769            "Unclosed bold tag not properly handled"
770        );
771    }
772
773    /// Test conversion with complex Markdown content.
774    ///
775    /// This test checks how the function handles more complex Markdown input with various
776    /// elements like lists, headers, code blocks, and links.
777    /// Test conversion with complex Markdown content.
778    #[test]
779    fn test_generate_html_complex() {
780        let markdown = r#"
781# Header
782
783## Subheader
784
785Some `inline code` and a [link](https://example.com).
786
787```rust
788fn main() {
789    println!("Hello, world!");
790}
791```
792
7931. First item
7942. Second item
795"#;
796        let config = HtmlConfig::default();
797        let result = generate_html(markdown, &config);
798        assert!(result.is_ok());
799        let html = result.unwrap();
800        println!("{}", html);
801
802        // Verify the header and subheader
803        assert!(
804            html.contains("<h1>Header</h1>"),
805            "H1 Header not found"
806        );
807        assert!(
808            html.contains("<h2>Subheader</h2>"),
809            "H2 Header not found"
810        );
811
812        // Verify the inline code and link
813        assert!(
814            html.contains("<code>inline code</code>"),
815            "Inline code not found"
816        );
817        assert!(
818            html.contains(r#"<a href="https://example.com">link</a>"#),
819            "Link not found"
820        );
821
822        // Verify the code block structure
823        assert!(
824            html.contains(r#"<code class="language-rust">"#),
825            "Code block with language-rust class not found"
826        );
827        assert!(
828            html.contains(r#"<span style="color:#b48ead;">fn </span>"#),
829            "`fn` keyword with syntax highlighting not found"
830        );
831        assert!(
832            html.contains(
833                r#"<span style="color:#8fa1b3;">main</span>"#
834            ),
835            "`main` function name with syntax highlighting not found"
836        );
837
838        // Check for the ordered list items
839        assert!(
840            html.contains("<li>First item</li>"),
841            "First item not found"
842        );
843        assert!(
844            html.contains("<li>Second item</li>"),
845            "Second item not found"
846        );
847    }
848
849    /// Test handling of valid front matter.
850    #[test]
851    fn test_generate_html_with_valid_front_matter() {
852        let markdown = r#"---
853title: Test
854author: Jane Doe
855---
856# Hello, world!"#;
857        let config = HtmlConfig::default();
858        let result = generate_html(markdown, &config);
859        assert!(result.is_ok());
860        let html = result.unwrap();
861        assert!(html.contains("<h1>Hello, world!</h1>"));
862    }
863
864    /// Test handling of invalid front matter.
865    #[test]
866    fn test_generate_html_with_invalid_front_matter() {
867        let markdown = r#"---
868title Test
869author: Jane Doe
870---
871# Hello, world!"#;
872        let config = HtmlConfig::default();
873        let result = generate_html(markdown, &config);
874        assert!(
875            result.is_ok(),
876            "Invalid front matter should be ignored"
877        );
878        let html = result.unwrap();
879        assert!(html.contains("<h1>Hello, world!</h1>"));
880    }
881
882    /// Test with a large Markdown input.
883    #[test]
884    fn test_generate_html_large_input() {
885        let markdown = "# Large Markdown\n\n".repeat(10_000);
886        let config = HtmlConfig::default();
887        let result = generate_html(&markdown, &config);
888        assert!(result.is_ok());
889        let html = result.unwrap();
890        assert!(html.contains("<h1>Large Markdown</h1>"));
891    }
892
893    /// Test with different MarkdownOptions configurations.
894    #[test]
895    fn test_generate_html_with_custom_markdown_options() {
896        let markdown = "**Bold text**";
897        let config = HtmlConfig::default();
898        let result = generate_html(markdown, &config);
899        assert!(result.is_ok());
900        let html = result.unwrap();
901        assert!(html.contains("<strong>Bold text</strong>"));
902    }
903
904    /// Test unsupported Markdown elements.
905    #[test]
906    fn test_generate_html_with_unsupported_elements() {
907        let markdown = "::: custom_block\nContent\n:::";
908        let config = HtmlConfig::default();
909        let result = generate_html(markdown, &config);
910        assert!(result.is_ok());
911        let html = result.unwrap();
912        assert!(html.contains("::: custom_block"));
913    }
914
915    /// Test error handling for invalid Markdown conversion.
916    #[test]
917    fn test_markdown_to_html_with_conversion_error() {
918        let markdown = "# Unclosed header\nSome **unclosed bold";
919        let result = markdown_to_html_with_extensions(markdown);
920        assert!(result.is_ok());
921        let html = result.unwrap();
922        assert!(html.contains("<p>Some **unclosed bold</p>"));
923    }
924
925    /// Test handling of whitespace-only Markdown.
926    #[test]
927    fn test_generate_html_whitespace_only() {
928        let markdown = "   \n   ";
929        let config = HtmlConfig::default();
930        let result = generate_html(markdown, &config);
931        assert!(result.is_ok());
932        let html = result.unwrap();
933        assert!(
934            html.is_empty(),
935            "Whitespace-only Markdown should produce empty HTML"
936        );
937    }
938
939    /// Test customization of Options.
940    ///
941    /// Native-only: drives `mdx_gen::{MarkdownOptions, process_markdown}`
942    /// which are not available on the wasm32 build path.
943    #[cfg(not(target_arch = "wasm32"))]
944    #[test]
945    fn test_markdown_to_html_with_custom_comrak_options() {
946        let markdown = "^^Superscript^^\n\n| Header 1 | Header 2 |\n| -------- | -------- |\n| Row 1    | Row 2    |";
947
948        // Configure Options with necessary extensions
949        let mut comrak_options = Options::default();
950        comrak_options.extension.superscript = true;
951        comrak_options.extension.table = true; // Enable table to match MarkdownOptions
952
953        // Synchronize MarkdownOptions with Options
954        let options = MarkdownOptions::default()
955            .with_comrak_options(comrak_options.clone());
956        let content_without_front_matter =
957            extract_front_matter(markdown)
958                .unwrap_or(markdown.to_string());
959
960        println!("Comrak options: {:?}", comrak_options);
961
962        let result =
963            process_markdown(&content_without_front_matter, &options);
964
965        match result {
966            Ok(ref html) => {
967                // Assert superscript rendering
968                assert!(
969                    html.contains("<sup>Superscript</sup>"),
970                    "Superscript not found in HTML output"
971                );
972
973                // Assert table rendering
974                assert!(
975                    html.contains("<table"),
976                    "Table element not found in HTML output"
977                );
978            }
979            Err(err) => {
980                panic!(
981                    "Failed to process Markdown with custom Options: {:?}",
982                    err
983                );
984            }
985        }
986    }
987    #[test]
988    fn test_generate_html_with_default_config() {
989        let markdown = "# Default Configuration Test";
990        let config = HtmlConfig::default();
991        let result = generate_html(markdown, &config);
992        assert!(result.is_ok());
993        let html = result.unwrap();
994        assert!(html.contains("<h1>Default Configuration Test</h1>"));
995    }
996
997    #[test]
998    fn test_generate_html_with_custom_front_matter_delimiter() {
999        let markdown = r#";;;;
1000title: Custom
1001author: John Doe
1002;;;;
1003# Custom Front Matter Delimiter"#;
1004
1005        let config = HtmlConfig::default();
1006        let result = generate_html(markdown, &config);
1007        assert!(result.is_ok());
1008        let html = result.unwrap();
1009        assert!(html.contains("<h1>Custom Front Matter Delimiter</h1>"));
1010    }
1011    #[test]
1012    fn test_generate_html_with_task_list() {
1013        let markdown = r"
1014- [x] Task 1
1015- [ ] Task 2
1016";
1017
1018        let result = markdown_to_html_with_extensions(markdown);
1019        assert!(result.is_ok());
1020        let html = result.unwrap();
1021
1022        println!("Generated HTML:\n{}", html);
1023
1024        // Adjust assertions to match the rendered HTML structure
1025        assert!(
1026        html.contains(r#"<li><input type="checkbox" checked="" disabled="" /> Task 1</li>"#),
1027        "Task 1 checkbox not rendered as expected"
1028    );
1029        assert!(
1030        html.contains(r#"<li><input type="checkbox" disabled="" /> Task 2</li>"#),
1031        "Task 2 checkbox not rendered as expected"
1032    );
1033    }
1034    #[test]
1035    fn test_generate_html_with_large_table() {
1036        let header =
1037            "| Header 1 | Header 2 |\n| -------- | -------- |\n";
1038        let rows = "| Row 1    | Row 2    |\n".repeat(1000);
1039        let markdown = format!("{}{}", header, rows);
1040
1041        let result = markdown_to_html_with_extensions(&markdown);
1042        assert!(result.is_ok());
1043        let html = result.unwrap();
1044
1045        let row_count = html.matches("<tr>").count();
1046        assert_eq!(
1047            row_count, 1001,
1048            "Incorrect number of rows: {}",
1049            row_count
1050        ); // 1 header + 1000 rows
1051    }
1052    #[test]
1053    fn test_generate_html_with_special_characters() {
1054        let markdown = r#"Markdown with special characters: <, >, &, "quote", 'single-quote'."#;
1055        let result = markdown_to_html_with_extensions(markdown);
1056        assert!(result.is_ok());
1057        let html = result.unwrap();
1058
1059        assert!(html.contains("&lt;"), "Less than sign not escaped");
1060        assert!(html.contains("&gt;"), "Greater than sign not escaped");
1061        assert!(html.contains("&amp;"), "Ampersand not escaped");
1062        assert!(html.contains("&quot;"), "Double quote not escaped");
1063
1064        // Adjust if single quotes are intended to remain unescaped
1065        assert!(
1066            html.contains("&#39;") || html.contains("'"),
1067            "Single quote not handled as expected"
1068        );
1069    }
1070
1071    #[test]
1072    fn test_generate_html_with_invalid_markdown_syntax() {
1073        // With unsafe_html disabled (default), raw HTML tags are stripped
1074        let markdown =
1075            r"# Invalid Markdown <unexpected> [bad](url <here)";
1076        let result = markdown_to_html_with_extensions(markdown);
1077        assert!(result.is_ok());
1078        let html = result.unwrap();
1079
1080        println!("Generated HTML:\n{}", html);
1081
1082        // Raw HTML tags are stripped when unsafe=false
1083        assert!(html.contains("<h1>"), "Header tag should be present");
1084    }
1085
1086    /// Test handling of Markdown with a mix of valid and invalid syntax.
1087    #[test]
1088    fn test_generate_html_mixed_markdown() {
1089        let markdown = r"# Valid Header
1090Some **bold text** followed by invalid Markdown:
1091~~strikethrough~~ without a closing tag.";
1092        let result = markdown_to_html_with_extensions(markdown);
1093        assert!(result.is_ok());
1094        let html = result.unwrap();
1095
1096        assert!(
1097            html.contains("<h1>Valid Header</h1>"),
1098            "Header not found"
1099        );
1100        assert!(
1101            html.contains("<strong>bold text</strong>"),
1102            "Bold text not rendered correctly"
1103        );
1104        assert!(
1105            html.contains("<del>strikethrough</del>"),
1106            "Strikethrough not rendered correctly"
1107        );
1108    }
1109
1110    /// Test handling of deeply nested Markdown content.
1111    #[test]
1112    fn test_generate_html_deeply_nested_content() {
1113        let markdown = r"
11141. Level 1
1115    1.1. Level 2
1116        1.1.1. Level 3
1117            1.1.1.1. Level 4
1118";
1119        let result = markdown_to_html_with_extensions(markdown);
1120        assert!(result.is_ok());
1121        let html = result.unwrap();
1122
1123        assert!(html.contains("<ol>"), "Ordered list not rendered");
1124        assert!(html.contains("<li>Level 1"), "Level 1 not rendered");
1125        assert!(
1126            html.contains("1.1.1.1. Level 4"),
1127            "Deeply nested levels not rendered correctly"
1128        );
1129    }
1130
1131    /// Test Markdown with embedded raw HTML content (opt-in unsafe).
1132    #[test]
1133    fn test_generate_html_with_raw_html() {
1134        let markdown = r"
1135# Header with HTML
1136<p>This is a paragraph with <strong>HTML</strong>.</p>
1137";
1138        // Opt in to unsafe HTML for this test
1139        let config = HtmlConfig {
1140            allow_unsafe_html: true,
1141            ..HtmlConfig::default()
1142        };
1143        let result = generate_html(markdown, &config);
1144        assert!(result.is_ok());
1145        let html = result.unwrap();
1146
1147        assert!(
1148            html.contains("<p>This is a paragraph with <strong>HTML</strong>.</p>"),
1149            "Raw HTML content not preserved in output"
1150        );
1151    }
1152
1153    /// Test Markdown with invalid front matter format.
1154    #[test]
1155    fn test_generate_html_invalid_front_matter_handling() {
1156        let markdown = "---
1157key_without_value
1158another_key: valid
1159---
1160# Markdown Content
1161";
1162        let result = generate_html(markdown, &HtmlConfig::default());
1163        assert!(
1164            result.is_ok(),
1165            "Invalid front matter should not cause an error"
1166        );
1167        let html = result.unwrap();
1168        assert!(
1169            html.contains("<h1>Markdown Content</h1>"),
1170            "Content not processed correctly"
1171        );
1172    }
1173
1174    /// Test handling of very large front matter in Markdown.
1175    #[test]
1176    fn test_generate_html_large_front_matter() {
1177        let front_matter = "---\n".to_owned()
1178            + &"key: value\n".repeat(10_000)
1179            + "---\n# Content";
1180        let result =
1181            generate_html(&front_matter, &HtmlConfig::default());
1182        assert!(
1183            result.is_ok(),
1184            "Large front matter should be handled gracefully"
1185        );
1186        let html = result.unwrap();
1187        assert!(
1188            html.contains("<h1>Content</h1>"),
1189            "Content not rendered correctly"
1190        );
1191    }
1192
1193    /// Test handling of Markdown with long consecutive lines.
1194    #[test]
1195    fn test_generate_html_with_long_lines() {
1196        let markdown = "A ".repeat(10_000);
1197        let result = markdown_to_html_with_extensions(&markdown);
1198        assert!(result.is_ok());
1199        let html = result.unwrap();
1200
1201        assert!(
1202            html.contains("A A A A"),
1203            "Long consecutive lines should be rendered properly"
1204        );
1205    }
1206
1207    #[test]
1208    fn test_markdown_with_custom_classes() {
1209        let markdown = r":::note
1210This is a note with a custom class.
1211:::";
1212
1213        let result = markdown_to_html_with_extensions(markdown);
1214        assert!(result.is_ok(), "Markdown conversion should not fail.");
1215
1216        let html = result.unwrap();
1217        println!("HTML:\n{}", html);
1218
1219        // Ensure we see <div class="note"> in the final output:
1220        assert!(
1221            html.contains(r#"<div class="note">"#),
1222            "Custom block should wrap in <div class=\"note\">"
1223        );
1224
1225        // Ensure the block content is present:
1226        assert!(
1227            html.contains("This is a note with a custom class."),
1228            "Block text is missing or incorrectly rendered"
1229        );
1230    }
1231
1232    #[test]
1233    fn test_markdown_with_custom_blocks_and_images() {
1234        let markdown = "![A very tall building](https://example.com/image.webp).class=\"img-fluid\"";
1235        let result = markdown_to_html_with_extensions(markdown);
1236        assert!(result.is_ok());
1237        let html = result.unwrap();
1238        println!("{}", html);
1239        assert!(
1240        html.contains(r#"<img src="https://example.com/image.webp" alt="A very tall building" class="img-fluid" />"#),
1241        "First image not rendered correctly"
1242    );
1243    }
1244
1245    /// Test empty front matter handling.
1246    #[test]
1247    fn test_empty_front_matter_handling() {
1248        let markdown = "---\n---\n# Content";
1249        let result = generate_html(markdown, &HtmlConfig::default());
1250        assert!(result.is_ok());
1251        let html = result.unwrap();
1252        assert!(
1253            html.contains("<h1>Content</h1>"),
1254            "Content should be processed correctly"
1255        );
1256    }
1257
1258    /// Test invalid image syntax.
1259    ///
1260    /// Native-only: `process_images_with_classes` lives in the
1261    /// `#[cfg(not(target_arch = "wasm32"))]` half of this module
1262    /// because the WASM build path bypasses `mdx-gen`'s extension
1263    /// helpers entirely.
1264    #[cfg(not(target_arch = "wasm32"))]
1265    #[test]
1266    fn test_invalid_image_syntax() {
1267        let markdown = "![Image with missing URL]()";
1268        let mut tunnels = Vec::new();
1269        let result =
1270            process_images_with_classes(markdown, &mut tunnels);
1271        assert_eq!(
1272            result, markdown,
1273            "Invalid image syntax should remain unchanged"
1274        );
1275        assert!(
1276            tunnels.is_empty(),
1277            "No image fragments should be tunnelled for invalid syntax"
1278        );
1279    }
1280
1281    /// Test incorrect front matter delimiters.
1282    #[test]
1283    fn test_incorrect_front_matter_delimiters() {
1284        let markdown = ";;;\ntitle: Test\n---\n# Header";
1285        let result = generate_html(markdown, &HtmlConfig::default());
1286        assert!(result.is_ok());
1287        let html = result.unwrap();
1288        assert!(
1289            html.contains("<h1>Header</h1>"),
1290            "Header should be processed correctly"
1291        );
1292    }
1293    #[cfg(test)]
1294    mod missing_scenarios_tests {
1295        use super::*;
1296
1297        /// 1) Triple-colon block with inline bold text
1298        ///
1299        /// Verifies that **Caution:** inside `:::warning` is parsed as `<strong>Caution:</strong>`.
1300        #[test]
1301        fn test_triple_colon_warning_with_bold() {
1302            let markdown = r":::warning
1303**Caution:** This operation is sensitive.
1304:::";
1305
1306            let result = markdown_to_html_with_extensions(markdown);
1307            assert!(
1308                result.is_ok(),
1309                "Markdown conversion should succeed."
1310            );
1311
1312            let html = result.unwrap();
1313            println!("HTML:\n{}", html);
1314
1315            // Expect the block to contain <strong>Caution:</strong>
1316            // plus a <div class="warning">
1317            assert!(
1318                html.contains(r#"<div class="warning">"#),
1319                "Expected <div class=\"warning\"> wrapping the block"
1320            );
1321            assert!(html.contains("<strong>Caution:</strong>"),
1322            "Expected inline bold text to become <strong>Caution:</strong>");
1323        }
1324
1325        /// 2) Multiple triple-colon blocks in the same snippet.
1326        ///
1327        /// Ensures that the parser correctly handles more than one custom block.
1328        #[test]
1329        fn test_multiple_triple_colon_blocks() {
1330            let markdown = r":::note
1331**Note:** First block
1332:::
1333
1334:::warning
1335**Warning:** Second block
1336:::";
1337
1338            let result = markdown_to_html_with_extensions(markdown);
1339            assert!(
1340                result.is_ok(),
1341                "Markdown conversion should succeed."
1342            );
1343
1344            let html = result.unwrap();
1345            println!("HTML:\n{}", html);
1346
1347            // Expect <div class="note"> ...</div> and <div class="warning"> ...</div>
1348            assert!(
1349                html.contains(r#"<div class="note">"#),
1350                "Missing <div class=\"note\"> for the first block"
1351            );
1352            assert!(
1353                html.contains(r#"<div class="warning">"#),
1354                "Missing <div class=\"warning\"> for the second block"
1355            );
1356
1357            // Check inline markdown
1358            assert!(
1359                html.contains("<strong>Note:</strong>"),
1360                "Bold text in the note block not parsed"
1361            );
1362            assert!(
1363                html.contains("<strong>Warning:</strong>"),
1364                "Bold text in the warning block not parsed"
1365            );
1366        }
1367
1368        /// 3) Triple-colon block with multi-paragraph content
1369        ///
1370        /// Checks how inline parsing deals with extra blank lines and multiple paragraphs.
1371        #[test]
1372        fn test_triple_colon_block_multi_paragraph() {
1373            let markdown = r":::note
1374**Paragraph 1:** This is the first paragraph.
1375
1376This is the second paragraph, also with **bold** text.
1377:::";
1378
1379            let result = markdown_to_html_with_extensions(markdown);
1380            assert!(
1381                result.is_ok(),
1382                "Markdown conversion should succeed."
1383            );
1384
1385            let html = result.unwrap();
1386            println!("HTML:\n{}", html);
1387
1388            // The block is inline-processed. Paragraphs might be combined or
1389            // each appear in separate <p> tags, depending on the parser.
1390            // Typically, inline parsing doesn't break paragraphs. If you want block-level
1391            // formatting, you'd need a full block parse. But let's at least confirm bold text.
1392            assert!(
1393                html.contains("<strong>Paragraph 1:</strong>"),
1394                "Inline bold text not parsed in the first paragraph"
1395            );
1396            assert!(html.contains("second paragraph, also with <strong>bold</strong> text"),
1397            "Inline bold text not parsed in the second paragraph");
1398        }
1399
1400        /// 4) Fallback logic: forcing an error in `process_markdown_inline`
1401        ///
1402        /// We'll create a scenario that intentionally breaks the inline parser.
1403        /// If an error occurs, we expect the raw text (with triple-colon block content).
1404        #[test]
1405        fn test_triple_colon_block_forcing_inline_error() {
1406            // Suppose the inline parser fails when we pass some nonsense markup or unhandled structure.
1407            // It's not always guaranteed to fail, but let's try an improbable snippet:
1408            let markdown = r":::error
1409This block tries < to break > inline parsing & [some link (unclosed).
1410:::";
1411
1412            // We'll artificially modify the parser to fail if it sees "[some link (unclosed)."
1413            // But since your code doesn't do that by default, we can't *guarantee* a real error.
1414            // We'll at least check that, if an error *did* occur, we fallback to raw text.
1415            //
1416            // For demonstration, let's proceed with the test and see if it just parses or not.
1417            let result = markdown_to_html_with_extensions(markdown);
1418            assert!(
1419                result.is_ok(),
1420                "We won't forcibly error, but let's see the output."
1421            );
1422
1423            let html = result.unwrap();
1424            println!("HTML:\n{}", html);
1425
1426            // If your parser did handle it, we'll just check the block.
1427            // If your parser chokes, you'd see a fallback with raw text.
1428            // Let's verify there's a <div class="error"> either way:
1429            assert!(
1430                html.contains(r#"<div class="error">"#),
1431                "Block div not found for 'error' class"
1432            );
1433
1434            // If the inline parser didn't fail, we might see <p> with weird text.
1435            // If it fails, we should see the original snippet inside the block.
1436            // We'll just check that it's not empty.
1437            assert!(
1438                html.contains("This block tries "),
1439                "Expected parsed content in the block"
1440            );
1441        }
1442    }
1443}