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    // Untrusted raw HTML is contained by *sanitization*, not escaping.
492    // `MarkdownOptions::with_comrak_options` copies `render.unsafe` into
493    // mdx-gen's own `allow_unsafe_html`, so when this config is safe mode
494    // mdx-gen runs its ammonia pass over the rendered output and strips
495    // `<script>` and friends (see `tests/fixtures/xss_vectors.txt`).
496    //
497    // Do NOT set `comrak_options.render.escape` here. mdx-gen enhances
498    // tables and custom blocks by swapping AST nodes for *raw HTML* nodes
499    // before rendering; comrak gives `escape` precedence over `unsafe`, so
500    // escaping would entity-escape mdx-gen's own trusted markup and emit
501    // `&lt;table class="table"&gt;` instead of a real table.
502
503    let mut md_options = MarkdownOptions::default()
504        .with_comrak_options(comrak_options)
505        .with_syntax_highlighting(config.enable_syntax_highlighting)
506        // Without this the pipeline silently enforces mdx-gen's own
507        // 1 MiB default and `HtmlConfig::max_input_size` has no effect.
508        .with_max_input_size(config.max_input_size);
509
510    if let Some(ref theme) = config.syntax_theme {
511        md_options = md_options.with_custom_theme(theme.clone());
512    }
513
514    // 5) Convert final Markdown to HTML
515    let mut html = process_markdown(&markdown_with_images, &md_options)
516        .map_err(|err| {
517            HtmlError::markdown_conversion(err.to_string(), None)
518        })?;
519
520    // 6) Restore tunnelled crate HTML. Block-level fragments (`:::` divs)
521    //    stood alone, so comrak wrapped the sentinel in a paragraph — strip
522    //    that wrapper to avoid `<p><div>…</div></p>`. Inline fragments
523    //    (images) are swapped in place.
524    for (index, fragment) in tunnels.iter().enumerate() {
525        let sentinel = tunnel_sentinel(index);
526        html = html.replace(&format!("<p>{sentinel}</p>"), fragment);
527        html = html.replace(&sentinel, fragment);
528    }
529
530    Ok(html)
531}
532
533/// Opaque sentinel used to tunnel trusted crate-generated HTML through the
534/// escaping render. Delimited by U+FFFC (OBJECT REPLACEMENT CHARACTER), which
535/// does not occur in normal Markdown and — not being an HTML metacharacter —
536/// is passed through verbatim by comrak regardless of `render.escape`.
537#[cfg(not(target_arch = "wasm32"))]
538fn tunnel_sentinel(index: usize) -> String {
539    format!("\u{FFFC}\u{FFFC}hgtunnel{index}\u{FFFC}\u{FFFC}")
540}
541
542/// WASM-target Markdown → HTML.
543///
544/// Bypasses `mdx-gen` (which pulls in `tokio` unconditionally and
545/// therefore does not compile to `wasm32-unknown-unknown`) and calls
546/// `comrak` directly with the same extension flags that `mdx-gen`
547/// would have set. Custom classes (`:::warning`), image-class
548/// syntax, and `syntect` syntax highlighting are not available in
549/// this build path; everything else (CommonMark + GFM tables,
550/// strikethrough, autolinks, tasklists, superscript) renders
551/// identically to the native pipeline.
552#[cfg(target_arch = "wasm32")]
553fn markdown_to_html_impl(
554    markdown: &str,
555    config: &crate::HtmlConfig,
556) -> Result<String> {
557    let content_without_front_matter = extract_front_matter(markdown)
558        .unwrap_or_else(|_| markdown.to_string());
559
560    let mut opts = BASE_COMRAK_OPTIONS.clone();
561    opts.render.r#unsafe = config.allow_unsafe_html;
562
563    Ok(comrak::markdown_to_html(
564        &content_without_front_matter,
565        &opts,
566    ))
567}
568
569/// Re-parse inline Markdown for triple-colon blocks, e.g.:
570///
571/// ```markdown
572/// :::warning
573/// **Caution:** This is risky.
574/// :::
575/// ```
576///
577/// Produces something like:
578/// ```html
579/// <div class="warning"><strong>Caution:</strong> This is risky.</div>
580/// ```
581///
582/// # Example
583/// ...
584#[cfg(not(target_arch = "wasm32"))]
585fn add_custom_classes<'a>(
586    markdown: &'a str,
587    allow_unsafe_html: bool,
588    tunnels: &mut Vec<String>,
589) -> Cow<'a, str> {
590    // `regex::Regex::replace_all` returns `Cow::Borrowed(markdown)`
591    // when there are zero matches — avoiding the allocation
592    // entirely for the common case of a document without `:::` blocks.
593    // Each rendered block is stashed in `tunnels` and replaced by a sentinel
594    // so the trusted `<div>` survives the escaping render (see
595    // `markdown_to_html_impl`).
596    CUSTOM_CLASS_REGEX.replace_all(
597        markdown,
598        |caps: &regex::Captures| {
599            let class_name = &caps[1];
600            let block_content = &caps[2];
601
602            let inline_html = match process_markdown_inline_impl(
603                block_content,
604                allow_unsafe_html,
605            ) {
606                Ok(html) => html,
607                Err(_) => block_content.to_string(),
608            };
609
610            // class_name is validated by the \w+ regex — safe to interpolate;
611            // inline_html already ran through the escaping inline renderer.
612            let fragment = format!(
613                "<div class=\"{class_name}\">{inline_html}</div>"
614            );
615            let index = tunnels.len();
616            tunnels.push(fragment);
617            tunnel_sentinel(index)
618        },
619    )
620}
621
622/// Processes inline Markdown (bold, italics, links, etc.) without block-level syntax.
623///
624/// # Examples
625///
626/// ```
627/// use html_generator::generator::process_markdown_inline;
628///
629/// let html = process_markdown_inline("**bold** and *italic*").unwrap();
630/// assert!(html.contains("<strong>bold</strong>"));
631/// assert!(html.contains("<em>italic</em>"));
632/// ```
633///
634/// # Errors
635///
636/// Returns the underlying `mdx-gen` error if Markdown parsing fails.
637pub fn process_markdown_inline(
638    content: &str,
639) -> std::result::Result<String, Box<dyn Error>> {
640    process_markdown_inline_impl(content, false)
641}
642
643#[cfg(not(target_arch = "wasm32"))]
644fn process_markdown_inline_impl(
645    content: &str,
646    allow_unsafe_html: bool,
647) -> std::result::Result<String, Box<dyn Error>> {
648    // Inline rendering shares the same extension tree as the outer
649    // pipeline; clone from the cached base rather than rebuilding.
650    let mut comrak_opts = BASE_COMRAK_OPTIONS.clone();
651    comrak_opts.render.r#unsafe = allow_unsafe_html;
652    // Same mdx-gen override guard as the outer pipeline: escape untrusted
653    // raw HTML inside `:::` block content unless unsafe HTML is allowed.
654    comrak_opts.render.escape = !allow_unsafe_html;
655
656    let options =
657        MarkdownOptions::default().with_comrak_options(comrak_opts);
658    Ok(process_markdown(content, &options)?)
659}
660
661/// WASM-target inline Markdown rendering. See the `markdown_to_html_impl`
662/// WASM variant for the rationale (no `mdx-gen` on `wasm32`).
663#[cfg(target_arch = "wasm32")]
664fn process_markdown_inline_impl(
665    content: &str,
666    allow_unsafe_html: bool,
667) -> std::result::Result<String, Box<dyn Error>> {
668    let mut opts = BASE_COMRAK_OPTIONS.clone();
669    opts.render.r#unsafe = allow_unsafe_html;
670    Ok(comrak::markdown_to_html(content, &opts))
671}
672
673/// Replaces image patterns like
674/// `![Alt text](URL).class="some-class"` with `<img src="URL" alt="Alt text" class="some-class" />`.
675#[cfg(not(target_arch = "wasm32"))]
676fn process_images_with_classes<'a>(
677    markdown: &'a str,
678    tunnels: &mut Vec<String>,
679) -> Cow<'a, str> {
680    // Borrowed-Cow when the document has no `![alt](url).class="x"`
681    // construct — i.e. every typical document. Each generated `<img>` is
682    // tunnelled (see `markdown_to_html_impl`) so it survives escaping.
683    IMAGE_CLASS_REGEX.replace_all(markdown, |caps: &regex::Captures| {
684        let fragment = format!(
685            r#"<img src="{}" alt="{}" class="{}" />"#,
686            escape_html(&caps[2]), // URL
687            escape_html(&caps[1]), // alt text
688            escape_html(&caps[3]), // class attribute
689        );
690        let index = tunnels.len();
691        tunnels.push(fragment);
692        tunnel_sentinel(index)
693    })
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699    use crate::HtmlConfig;
700
701    /// Test basic Markdown to HTML conversion.
702    ///
703    /// This test verifies that a simple Markdown input is correctly converted to HTML.
704    #[test]
705    fn test_generate_html_basic() {
706        let markdown = "# Hello, world!\n\nThis is a test.";
707        let config = HtmlConfig::default();
708        let result = generate_html(markdown, &config);
709        assert!(result.is_ok());
710        let html = result.unwrap();
711        assert!(html.contains("<h1>Hello, world!</h1>"));
712        assert!(html.contains("<p>This is a test.</p>"));
713    }
714
715    /// Test conversion with Markdown extensions.
716    ///
717    /// This test ensures that the Markdown extensions (e.g., custom blocks, enhanced tables, etc.)
718    /// are correctly applied when converting Markdown to HTML.
719    #[test]
720    fn test_markdown_to_html_with_extensions() {
721        let markdown = r"
722| Header 1 | Header 2 |
723| -------- | -------- |
724| Row 1    | Row 2    |
725";
726        let result = markdown_to_html_with_extensions(markdown);
727        assert!(result.is_ok());
728        let html = result.unwrap();
729
730        println!("{}", html);
731
732        // Update the test to look for the div wrapper and table classes
733        assert!(html.contains("<div class=\"table-responsive\"><table class=\"table\">"), "Table element not found");
734        assert!(
735            html.contains("<th>Header 1</th>"),
736            "Table header not found"
737        );
738        assert!(
739            html.contains("<td class=\"text-left\">Row 1</td>"),
740            "Table row not found"
741        );
742    }
743
744    /// Test conversion of empty Markdown.
745    ///
746    /// This test checks that an empty Markdown input results in an empty HTML string.
747    #[test]
748    fn test_generate_html_empty() {
749        let markdown = "";
750        let config = HtmlConfig::default();
751        let result = generate_html(markdown, &config);
752        assert!(result.is_ok());
753        let html = result.unwrap();
754        assert!(html.is_empty());
755    }
756
757    /// Test handling of invalid Markdown.
758    ///
759    /// This test verifies that even with poorly formatted Markdown, the function
760    /// will not panic and will return valid HTML.
761    #[test]
762    fn test_generate_html_invalid_markdown() {
763        let markdown = "# Unclosed header\nSome **unclosed bold";
764        let config = HtmlConfig::default();
765        let result = generate_html(markdown, &config);
766        assert!(result.is_ok());
767        let html = result.unwrap();
768
769        println!("{}", html);
770
771        assert!(
772            html.contains("<h1>Unclosed header</h1>"),
773            "Header not found"
774        );
775        assert!(
776            html.contains("<p>Some **unclosed bold</p>"),
777            "Unclosed bold tag not properly handled"
778        );
779    }
780
781    /// Test conversion with complex Markdown content.
782    ///
783    /// This test checks how the function handles more complex Markdown input with various
784    /// elements like lists, headers, code blocks, and links.
785    /// Test conversion with complex Markdown content.
786    #[test]
787    fn test_generate_html_complex() {
788        let markdown = r#"
789# Header
790
791## Subheader
792
793Some `inline code` and a [link](https://example.com).
794
795```rust
796fn main() {
797    println!("Hello, world!");
798}
799```
800
8011. First item
8022. Second item
803"#;
804        let config = HtmlConfig::default();
805        let result = generate_html(markdown, &config);
806        assert!(result.is_ok());
807        let html = result.unwrap();
808        println!("{}", html);
809
810        // Verify the header and subheader
811        assert!(
812            html.contains("<h1>Header</h1>"),
813            "H1 Header not found"
814        );
815        assert!(
816            html.contains("<h2>Subheader</h2>"),
817            "H2 Header not found"
818        );
819
820        // Verify the inline code and link
821        assert!(
822            html.contains("<code>inline code</code>"),
823            "Inline code not found"
824        );
825        // The sanitizer hardens outbound links with
826        // `rel="noopener noreferrer"`, so match the href and the link
827        // text rather than a literal anchor tag.
828        assert!(
829            html.contains(r#"href="https://example.com""#),
830            "Link href not found"
831        );
832        assert!(html.contains(">link</a>"), "Link text not found");
833
834        // Verify the code block structure
835        assert!(
836            html.contains(r#"<code class="language-rust">"#),
837            "Code block with language-rust class not found"
838        );
839        // Highlighting is emitted as scope *classes*, not inline
840        // `style="color:…"` attributes, so the theme choice does not
841        // leak into the markup.
842        assert!(
843            html.contains(
844                r#"<span class="storage type function rust">fn</span>"#
845            ),
846            "`fn` keyword with syntax highlighting not found"
847        );
848        assert!(
849            html.contains(
850                r#"<span class="entity name function rust">main</span>"#
851            ),
852            "`main` function name with syntax highlighting not found"
853        );
854
855        // Check for the ordered list items
856        assert!(
857            html.contains("<li>First item</li>"),
858            "First item not found"
859        );
860        assert!(
861            html.contains("<li>Second item</li>"),
862            "Second item not found"
863        );
864    }
865
866    /// Test handling of valid front matter.
867    #[test]
868    fn test_generate_html_with_valid_front_matter() {
869        let markdown = r#"---
870title: Test
871author: Jane Doe
872---
873# Hello, world!"#;
874        let config = HtmlConfig::default();
875        let result = generate_html(markdown, &config);
876        assert!(result.is_ok());
877        let html = result.unwrap();
878        assert!(html.contains("<h1>Hello, world!</h1>"));
879    }
880
881    /// Test handling of invalid front matter.
882    #[test]
883    fn test_generate_html_with_invalid_front_matter() {
884        let markdown = r#"---
885title Test
886author: Jane Doe
887---
888# Hello, world!"#;
889        let config = HtmlConfig::default();
890        let result = generate_html(markdown, &config);
891        assert!(
892            result.is_ok(),
893            "Invalid front matter should be ignored"
894        );
895        let html = result.unwrap();
896        assert!(html.contains("<h1>Hello, world!</h1>"));
897    }
898
899    /// Test with a large Markdown input.
900    #[test]
901    fn test_generate_html_large_input() {
902        let markdown = "# Large Markdown\n\n".repeat(10_000);
903        let config = HtmlConfig::default();
904        let result = generate_html(&markdown, &config);
905        assert!(result.is_ok());
906        let html = result.unwrap();
907        assert!(html.contains("<h1>Large Markdown</h1>"));
908    }
909
910    /// Test with different MarkdownOptions configurations.
911    #[test]
912    fn test_generate_html_with_custom_markdown_options() {
913        let markdown = "**Bold text**";
914        let config = HtmlConfig::default();
915        let result = generate_html(markdown, &config);
916        assert!(result.is_ok());
917        let html = result.unwrap();
918        assert!(html.contains("<strong>Bold text</strong>"));
919    }
920
921    /// Test unsupported Markdown elements.
922    #[test]
923    fn test_generate_html_with_unsupported_elements() {
924        let markdown = "::: custom_block\nContent\n:::";
925        let config = HtmlConfig::default();
926        let result = generate_html(markdown, &config);
927        assert!(result.is_ok());
928        let html = result.unwrap();
929        assert!(html.contains("::: custom_block"));
930    }
931
932    /// Test error handling for invalid Markdown conversion.
933    #[test]
934    fn test_markdown_to_html_with_conversion_error() {
935        let markdown = "# Unclosed header\nSome **unclosed bold";
936        let result = markdown_to_html_with_extensions(markdown);
937        assert!(result.is_ok());
938        let html = result.unwrap();
939        assert!(html.contains("<p>Some **unclosed bold</p>"));
940    }
941
942    /// Test handling of whitespace-only Markdown.
943    #[test]
944    fn test_generate_html_whitespace_only() {
945        let markdown = "   \n   ";
946        let config = HtmlConfig::default();
947        let result = generate_html(markdown, &config);
948        assert!(result.is_ok());
949        let html = result.unwrap();
950        assert!(
951            html.is_empty(),
952            "Whitespace-only Markdown should produce empty HTML"
953        );
954    }
955
956    /// Test customization of Options.
957    ///
958    /// Native-only: drives `mdx_gen::{MarkdownOptions, process_markdown}`
959    /// which are not available on the wasm32 build path.
960    #[cfg(not(target_arch = "wasm32"))]
961    #[test]
962    fn test_markdown_to_html_with_custom_comrak_options() {
963        let markdown = "^^Superscript^^\n\n| Header 1 | Header 2 |\n| -------- | -------- |\n| Row 1    | Row 2    |";
964
965        // Configure Options with necessary extensions
966        let mut comrak_options = Options::default();
967        comrak_options.extension.superscript = true;
968        comrak_options.extension.table = true; // Enable table to match MarkdownOptions
969
970        // Synchronize MarkdownOptions with Options
971        let options = MarkdownOptions::default()
972            .with_comrak_options(comrak_options.clone());
973        let content_without_front_matter =
974            extract_front_matter(markdown)
975                .unwrap_or(markdown.to_string());
976
977        println!("Comrak options: {:?}", comrak_options);
978
979        let result =
980            process_markdown(&content_without_front_matter, &options);
981
982        match result {
983            Ok(ref html) => {
984                // Assert superscript rendering
985                assert!(
986                    html.contains("<sup>Superscript</sup>"),
987                    "Superscript not found in HTML output"
988                );
989
990                // Assert table rendering
991                assert!(
992                    html.contains("<table"),
993                    "Table element not found in HTML output"
994                );
995            }
996            Err(err) => {
997                panic!(
998                    "Failed to process Markdown with custom Options: {:?}",
999                    err
1000                );
1001            }
1002        }
1003    }
1004    #[test]
1005    fn test_generate_html_with_default_config() {
1006        let markdown = "# Default Configuration Test";
1007        let config = HtmlConfig::default();
1008        let result = generate_html(markdown, &config);
1009        assert!(result.is_ok());
1010        let html = result.unwrap();
1011        assert!(html.contains("<h1>Default Configuration Test</h1>"));
1012    }
1013
1014    #[test]
1015    fn test_generate_html_with_custom_front_matter_delimiter() {
1016        let markdown = r#";;;;
1017title: Custom
1018author: John Doe
1019;;;;
1020# Custom Front Matter Delimiter"#;
1021
1022        let config = HtmlConfig::default();
1023        let result = generate_html(markdown, &config);
1024        assert!(result.is_ok());
1025        let html = result.unwrap();
1026        assert!(html.contains("<h1>Custom Front Matter Delimiter</h1>"));
1027    }
1028    #[test]
1029    fn test_generate_html_with_task_list() {
1030        let markdown = r"
1031- [x] Task 1
1032- [ ] Task 2
1033";
1034
1035        let result = markdown_to_html_with_extensions(markdown);
1036        assert!(result.is_ok());
1037        let html = result.unwrap();
1038
1039        println!("Generated HTML:\n{}", html);
1040
1041        // `input` is a void element, so the sanitized output emits
1042        // `<input …>` rather than the XHTML-style `<input … />`.
1043        assert!(
1044        html.contains(r#"<li><input type="checkbox" checked="" disabled=""> Task 1</li>"#),
1045        "Task 1 checkbox not rendered as expected"
1046    );
1047        assert!(
1048            html.contains(
1049                r#"<li><input type="checkbox" disabled=""> Task 2</li>"#
1050            ),
1051            "Task 2 checkbox not rendered as expected"
1052        );
1053    }
1054    #[test]
1055    fn test_generate_html_with_large_table() {
1056        let header =
1057            "| Header 1 | Header 2 |\n| -------- | -------- |\n";
1058        let rows = "| Row 1    | Row 2    |\n".repeat(1000);
1059        let markdown = format!("{}{}", header, rows);
1060
1061        let result = markdown_to_html_with_extensions(&markdown);
1062        assert!(result.is_ok());
1063        let html = result.unwrap();
1064
1065        let row_count = html.matches("<tr>").count();
1066        assert_eq!(
1067            row_count, 1001,
1068            "Incorrect number of rows: {}",
1069            row_count
1070        ); // 1 header + 1000 rows
1071    }
1072    #[test]
1073    fn test_generate_html_with_special_characters() {
1074        let markdown = r#"Markdown with special characters: <, >, &, "quote", 'single-quote'."#;
1075        let result = markdown_to_html_with_extensions(markdown);
1076        assert!(result.is_ok());
1077        let html = result.unwrap();
1078
1079        assert!(html.contains("&lt;"), "Less than sign not escaped");
1080        assert!(html.contains("&gt;"), "Greater than sign not escaped");
1081        assert!(html.contains("&amp;"), "Ampersand not escaped");
1082
1083        // Quotes only need escaping inside attribute values; in text
1084        // content both forms are well-formed HTML, so accept either.
1085        assert!(
1086            html.contains("&quot;") || html.contains('"'),
1087            "Double quote not handled as expected"
1088        );
1089        assert!(
1090            html.contains("&#39;") || html.contains('\''),
1091            "Single quote not handled as expected"
1092        );
1093    }
1094
1095    #[test]
1096    fn test_generate_html_with_invalid_markdown_syntax() {
1097        // With unsafe_html disabled (default), raw HTML tags are stripped
1098        let markdown =
1099            r"# Invalid Markdown <unexpected> [bad](url <here)";
1100        let result = markdown_to_html_with_extensions(markdown);
1101        assert!(result.is_ok());
1102        let html = result.unwrap();
1103
1104        println!("Generated HTML:\n{}", html);
1105
1106        // Raw HTML tags are stripped when unsafe=false
1107        assert!(html.contains("<h1>"), "Header tag should be present");
1108    }
1109
1110    /// Test handling of Markdown with a mix of valid and invalid syntax.
1111    #[test]
1112    fn test_generate_html_mixed_markdown() {
1113        let markdown = r"# Valid Header
1114Some **bold text** followed by invalid Markdown:
1115~~strikethrough~~ without a closing tag.";
1116        let result = markdown_to_html_with_extensions(markdown);
1117        assert!(result.is_ok());
1118        let html = result.unwrap();
1119
1120        assert!(
1121            html.contains("<h1>Valid Header</h1>"),
1122            "Header not found"
1123        );
1124        assert!(
1125            html.contains("<strong>bold text</strong>"),
1126            "Bold text not rendered correctly"
1127        );
1128        assert!(
1129            html.contains("<del>strikethrough</del>"),
1130            "Strikethrough not rendered correctly"
1131        );
1132    }
1133
1134    /// Test handling of deeply nested Markdown content.
1135    #[test]
1136    fn test_generate_html_deeply_nested_content() {
1137        let markdown = r"
11381. Level 1
1139    1.1. Level 2
1140        1.1.1. Level 3
1141            1.1.1.1. Level 4
1142";
1143        let result = markdown_to_html_with_extensions(markdown);
1144        assert!(result.is_ok());
1145        let html = result.unwrap();
1146
1147        assert!(html.contains("<ol>"), "Ordered list not rendered");
1148        assert!(html.contains("<li>Level 1"), "Level 1 not rendered");
1149        assert!(
1150            html.contains("1.1.1.1. Level 4"),
1151            "Deeply nested levels not rendered correctly"
1152        );
1153    }
1154
1155    /// Test Markdown with embedded raw HTML content (opt-in unsafe).
1156    #[test]
1157    fn test_generate_html_with_raw_html() {
1158        let markdown = r"
1159# Header with HTML
1160<p>This is a paragraph with <strong>HTML</strong>.</p>
1161";
1162        // Opt in to unsafe HTML for this test
1163        let config = HtmlConfig {
1164            allow_unsafe_html: true,
1165            ..HtmlConfig::default()
1166        };
1167        let result = generate_html(markdown, &config);
1168        assert!(result.is_ok());
1169        let html = result.unwrap();
1170
1171        assert!(
1172            html.contains("<p>This is a paragraph with <strong>HTML</strong>.</p>"),
1173            "Raw HTML content not preserved in output"
1174        );
1175    }
1176
1177    /// Test Markdown with invalid front matter format.
1178    #[test]
1179    fn test_generate_html_invalid_front_matter_handling() {
1180        let markdown = "---
1181key_without_value
1182another_key: valid
1183---
1184# Markdown Content
1185";
1186        let result = generate_html(markdown, &HtmlConfig::default());
1187        assert!(
1188            result.is_ok(),
1189            "Invalid front matter should not cause an error"
1190        );
1191        let html = result.unwrap();
1192        assert!(
1193            html.contains("<h1>Markdown Content</h1>"),
1194            "Content not processed correctly"
1195        );
1196    }
1197
1198    /// Test handling of very large front matter in Markdown.
1199    #[test]
1200    fn test_generate_html_large_front_matter() {
1201        let front_matter = "---\n".to_owned()
1202            + &"key: value\n".repeat(10_000)
1203            + "---\n# Content";
1204        let result =
1205            generate_html(&front_matter, &HtmlConfig::default());
1206        assert!(
1207            result.is_ok(),
1208            "Large front matter should be handled gracefully"
1209        );
1210        let html = result.unwrap();
1211        assert!(
1212            html.contains("<h1>Content</h1>"),
1213            "Content not rendered correctly"
1214        );
1215    }
1216
1217    /// Test handling of Markdown with long consecutive lines.
1218    #[test]
1219    fn test_generate_html_with_long_lines() {
1220        let markdown = "A ".repeat(10_000);
1221        let result = markdown_to_html_with_extensions(&markdown);
1222        assert!(result.is_ok());
1223        let html = result.unwrap();
1224
1225        assert!(
1226            html.contains("A A A A"),
1227            "Long consecutive lines should be rendered properly"
1228        );
1229    }
1230
1231    #[test]
1232    fn test_markdown_with_custom_classes() {
1233        let markdown = r":::note
1234This is a note with a custom class.
1235:::";
1236
1237        let result = markdown_to_html_with_extensions(markdown);
1238        assert!(result.is_ok(), "Markdown conversion should not fail.");
1239
1240        let html = result.unwrap();
1241        println!("HTML:\n{}", html);
1242
1243        // Ensure we see <div class="note"> in the final output:
1244        assert!(
1245            html.contains(r#"<div class="note">"#),
1246            "Custom block should wrap in <div class=\"note\">"
1247        );
1248
1249        // Ensure the block content is present:
1250        assert!(
1251            html.contains("This is a note with a custom class."),
1252            "Block text is missing or incorrectly rendered"
1253        );
1254    }
1255
1256    #[test]
1257    fn test_markdown_with_custom_blocks_and_images() {
1258        let markdown = "![A very tall building](https://example.com/image.webp).class=\"img-fluid\"";
1259        let result = markdown_to_html_with_extensions(markdown);
1260        assert!(result.is_ok());
1261        let html = result.unwrap();
1262        println!("{}", html);
1263        assert!(
1264        html.contains(r#"<img src="https://example.com/image.webp" alt="A very tall building" class="img-fluid" />"#),
1265        "First image not rendered correctly"
1266    );
1267    }
1268
1269    /// Test empty front matter handling.
1270    #[test]
1271    fn test_empty_front_matter_handling() {
1272        let markdown = "---\n---\n# Content";
1273        let result = generate_html(markdown, &HtmlConfig::default());
1274        assert!(result.is_ok());
1275        let html = result.unwrap();
1276        assert!(
1277            html.contains("<h1>Content</h1>"),
1278            "Content should be processed correctly"
1279        );
1280    }
1281
1282    /// Test invalid image syntax.
1283    ///
1284    /// Native-only: `process_images_with_classes` lives in the
1285    /// `#[cfg(not(target_arch = "wasm32"))]` half of this module
1286    /// because the WASM build path bypasses `mdx-gen`'s extension
1287    /// helpers entirely.
1288    #[cfg(not(target_arch = "wasm32"))]
1289    #[test]
1290    fn test_invalid_image_syntax() {
1291        let markdown = "![Image with missing URL]()";
1292        let mut tunnels = Vec::new();
1293        let result =
1294            process_images_with_classes(markdown, &mut tunnels);
1295        assert_eq!(
1296            result, markdown,
1297            "Invalid image syntax should remain unchanged"
1298        );
1299        assert!(
1300            tunnels.is_empty(),
1301            "No image fragments should be tunnelled for invalid syntax"
1302        );
1303    }
1304
1305    /// Test incorrect front matter delimiters.
1306    #[test]
1307    fn test_incorrect_front_matter_delimiters() {
1308        let markdown = ";;;\ntitle: Test\n---\n# Header";
1309        let result = generate_html(markdown, &HtmlConfig::default());
1310        assert!(result.is_ok());
1311        let html = result.unwrap();
1312        assert!(
1313            html.contains("<h1>Header</h1>"),
1314            "Header should be processed correctly"
1315        );
1316    }
1317    #[cfg(test)]
1318    mod missing_scenarios_tests {
1319        use super::*;
1320
1321        /// 1) Triple-colon block with inline bold text
1322        ///
1323        /// Verifies that **Caution:** inside `:::warning` is parsed as `<strong>Caution:</strong>`.
1324        #[test]
1325        fn test_triple_colon_warning_with_bold() {
1326            let markdown = r":::warning
1327**Caution:** This operation is sensitive.
1328:::";
1329
1330            let result = markdown_to_html_with_extensions(markdown);
1331            assert!(
1332                result.is_ok(),
1333                "Markdown conversion should succeed."
1334            );
1335
1336            let html = result.unwrap();
1337            println!("HTML:\n{}", html);
1338
1339            // Expect the block to contain <strong>Caution:</strong>
1340            // plus a <div class="warning">
1341            assert!(
1342                html.contains(r#"<div class="warning">"#),
1343                "Expected <div class=\"warning\"> wrapping the block"
1344            );
1345            assert!(html.contains("<strong>Caution:</strong>"),
1346            "Expected inline bold text to become <strong>Caution:</strong>");
1347        }
1348
1349        /// 2) Multiple triple-colon blocks in the same snippet.
1350        ///
1351        /// Ensures that the parser correctly handles more than one custom block.
1352        #[test]
1353        fn test_multiple_triple_colon_blocks() {
1354            let markdown = r":::note
1355**Note:** First block
1356:::
1357
1358:::warning
1359**Warning:** Second block
1360:::";
1361
1362            let result = markdown_to_html_with_extensions(markdown);
1363            assert!(
1364                result.is_ok(),
1365                "Markdown conversion should succeed."
1366            );
1367
1368            let html = result.unwrap();
1369            println!("HTML:\n{}", html);
1370
1371            // Expect <div class="note"> ...</div> and <div class="warning"> ...</div>
1372            assert!(
1373                html.contains(r#"<div class="note">"#),
1374                "Missing <div class=\"note\"> for the first block"
1375            );
1376            assert!(
1377                html.contains(r#"<div class="warning">"#),
1378                "Missing <div class=\"warning\"> for the second block"
1379            );
1380
1381            // Check inline markdown
1382            assert!(
1383                html.contains("<strong>Note:</strong>"),
1384                "Bold text in the note block not parsed"
1385            );
1386            assert!(
1387                html.contains("<strong>Warning:</strong>"),
1388                "Bold text in the warning block not parsed"
1389            );
1390        }
1391
1392        /// 3) Triple-colon block with multi-paragraph content
1393        ///
1394        /// Checks how inline parsing deals with extra blank lines and multiple paragraphs.
1395        #[test]
1396        fn test_triple_colon_block_multi_paragraph() {
1397            let markdown = r":::note
1398**Paragraph 1:** This is the first paragraph.
1399
1400This is the second paragraph, also with **bold** text.
1401:::";
1402
1403            let result = markdown_to_html_with_extensions(markdown);
1404            assert!(
1405                result.is_ok(),
1406                "Markdown conversion should succeed."
1407            );
1408
1409            let html = result.unwrap();
1410            println!("HTML:\n{}", html);
1411
1412            // The block is inline-processed. Paragraphs might be combined or
1413            // each appear in separate <p> tags, depending on the parser.
1414            // Typically, inline parsing doesn't break paragraphs. If you want block-level
1415            // formatting, you'd need a full block parse. But let's at least confirm bold text.
1416            assert!(
1417                html.contains("<strong>Paragraph 1:</strong>"),
1418                "Inline bold text not parsed in the first paragraph"
1419            );
1420            assert!(html.contains("second paragraph, also with <strong>bold</strong> text"),
1421            "Inline bold text not parsed in the second paragraph");
1422        }
1423
1424        /// 4) Fallback logic: forcing an error in `process_markdown_inline`
1425        ///
1426        /// We'll create a scenario that intentionally breaks the inline parser.
1427        /// If an error occurs, we expect the raw text (with triple-colon block content).
1428        #[test]
1429        fn test_triple_colon_block_forcing_inline_error() {
1430            // Suppose the inline parser fails when we pass some nonsense markup or unhandled structure.
1431            // It's not always guaranteed to fail, but let's try an improbable snippet:
1432            let markdown = r":::error
1433This block tries < to break > inline parsing & [some link (unclosed).
1434:::";
1435
1436            // We'll artificially modify the parser to fail if it sees "[some link (unclosed)."
1437            // But since your code doesn't do that by default, we can't *guarantee* a real error.
1438            // We'll at least check that, if an error *did* occur, we fallback to raw text.
1439            //
1440            // For demonstration, let's proceed with the test and see if it just parses or not.
1441            let result = markdown_to_html_with_extensions(markdown);
1442            assert!(
1443                result.is_ok(),
1444                "We won't forcibly error, but let's see the output."
1445            );
1446
1447            let html = result.unwrap();
1448            println!("HTML:\n{}", html);
1449
1450            // If your parser did handle it, we'll just check the block.
1451            // If your parser chokes, you'd see a fallback with raw text.
1452            // Let's verify there's a <div class="error"> either way:
1453            assert!(
1454                html.contains(r#"<div class="error">"#),
1455                "Block div not found for 'error' class"
1456            );
1457
1458            // If the inline parser didn't fail, we might see <p> with weird text.
1459            // If it fails, we should see the original snippet inside the block.
1460            // We'll just check that it's not empty.
1461            assert!(
1462                html.contains("This block tries "),
1463                "Expected parsed content in the block"
1464            );
1465        }
1466    }
1467}