Skip to main content

lex_babel/formats/html/
serializer.rs

1//! HTML serialization (Lex → HTML export)
2//!
3//! Converts Lex documents to semantic HTML5 with embedded CSS.
4//! Pipeline: Lex AST → IR → Events → RcDom → HTML string
5
6use crate::common::nested_to_flat::tree_to_events;
7use crate::error::FormatError;
8use crate::formats::html::HtmlTheme;
9use crate::ir::events::Event;
10use crate::ir::nodes::{DocNode, InlineContent, TableCellAlignment};
11use html5ever::{
12    ns, serialize, serialize::SerializeOpts, serialize::TraversalScope, Attribute, LocalName,
13    QualName,
14};
15use lex_core::lex::ast::Document;
16use markup5ever_rcdom::{Handle, Node, NodeData, RcDom, SerializableHandle};
17use std::cell::{Cell, RefCell};
18use std::default::Default;
19use std::rc::Rc;
20
21/// Options for HTML serialization
22#[derive(Debug, Clone, Default)]
23pub struct HtmlOptions {
24    /// CSS theme to use
25    pub theme: HtmlTheme,
26    /// Optional custom CSS to append after the baseline and theme CSS
27    pub custom_css: Option<String>,
28}
29
30impl HtmlOptions {
31    pub fn new(theme: HtmlTheme) -> Self {
32        Self {
33            theme,
34            custom_css: None,
35        }
36    }
37
38    pub fn with_custom_css(mut self, css: String) -> Self {
39        self.custom_css = Some(css);
40        self
41    }
42}
43
44/// Serialize a Lex document to HTML with the given theme
45pub fn serialize_to_html(doc: &Document, theme: HtmlTheme) -> Result<String, FormatError> {
46    serialize_to_html_with_options(doc, HtmlOptions::new(theme))
47}
48
49/// Serialize a Lex document to HTML with full options
50pub fn serialize_to_html_with_options(
51    doc: &Document,
52    options: HtmlOptions,
53) -> Result<String, FormatError> {
54    // Extract document title from root session (before IR conversion loses it)
55    let title = doc.root.title.as_string();
56    let title = if title.is_empty() {
57        "Lex Document".to_string()
58    } else {
59        title.to_string()
60    };
61
62    // Step 1: Lex AST → IR
63    let ir_doc = crate::to_ir(doc);
64
65    // Step 2: IR → Events
66    let events = tree_to_events(&DocNode::Document(ir_doc));
67
68    // Step 3: Events → RcDom (HTML DOM tree)
69    let dom = build_html_dom(&events)?;
70
71    // Step 4: RcDom → HTML string
72    let html_string = serialize_dom(&dom)?;
73
74    // Step 5: Wrap in complete HTML document with CSS
75    let complete_html = wrap_in_document(&html_string, &title, &options)?;
76
77    Ok(complete_html)
78}
79
80/// Build an HTML DOM tree from IR events
81fn build_html_dom(events: &[Event]) -> Result<RcDom, FormatError> {
82    let dom = RcDom::default();
83
84    // Create document container
85    let doc_container = create_element("div", vec![("class", "lex-document")]);
86
87    let mut current_parent: Handle = doc_container.clone();
88    let mut parent_stack: Vec<Handle> = vec![];
89
90    // State for collecting verbatim content
91    let mut in_verbatim = false;
92    let mut verbatim_language: Option<String> = None;
93    let mut verbatim_content = String::new();
94
95    // State for heading context
96    let mut current_heading: Option<Handle> = None;
97
98    for event in events {
99        match event {
100            Event::StartDocument => {
101                // Already created doc_container
102            }
103
104            Event::EndDocument => {
105                // Done
106            }
107
108            Event::StartHeading(level) => {
109                // Create section wrapper for this session
110                let class = format!("lex-session lex-session-{level}");
111                let section = create_element("section", vec![("class", &class)]);
112                current_parent.children.borrow_mut().push(section.clone());
113                parent_stack.push(current_parent.clone());
114                current_parent = section;
115
116                // Create heading element (h1-h6, max at h6)
117                // For levels > 6, add class attribute to preserve true depth
118                let clamped = (*level as u8).min(6);
119                let heading_tag = format!("h{clamped}");
120                let heading = if *level > 6 {
121                    let class = format!("lex-level-{level}");
122                    create_element(&heading_tag, vec![("class", &class)])
123                } else {
124                    create_element(&heading_tag, vec![])
125                };
126                current_parent.children.borrow_mut().push(heading.clone());
127                current_heading = Some(heading);
128            }
129
130            Event::EndHeading(_) => {
131                current_heading = None;
132                // Close section
133                current_parent = parent_stack.pop().ok_or_else(|| {
134                    FormatError::SerializationError("Unbalanced heading end".to_string())
135                })?;
136            }
137
138            Event::StartContent => {
139                // Create content wrapper (mirrors AST container structure for indentation)
140                current_heading = None;
141                let content = create_element("div", vec![("class", "lex-content")]);
142                current_parent.children.borrow_mut().push(content.clone());
143                parent_stack.push(current_parent.clone());
144                current_parent = content;
145            }
146
147            Event::EndContent => {
148                // Close content wrapper
149                current_parent = parent_stack.pop().ok_or_else(|| {
150                    FormatError::SerializationError("Unbalanced content end".to_string())
151                })?;
152            }
153
154            Event::StartParagraph => {
155                current_heading = None;
156                let para = create_element("p", vec![("class", "lex-paragraph")]);
157                current_parent.children.borrow_mut().push(para.clone());
158                parent_stack.push(current_parent.clone());
159                current_parent = para;
160            }
161
162            Event::EndParagraph => {
163                current_parent = parent_stack.pop().ok_or_else(|| {
164                    FormatError::SerializationError("Unbalanced paragraph end".to_string())
165                })?;
166            }
167
168            Event::StartList { ordered, style, .. } => {
169                current_heading = None;
170                let tag = if *ordered { "ol" } else { "ul" };
171                // For ordered lists, set the HTML type attribute to preserve decoration style
172                let list = match style {
173                    crate::ir::nodes::ListStyle::AlphaLower => {
174                        create_element(tag, vec![("class", "lex-list"), ("type", "a")])
175                    }
176                    crate::ir::nodes::ListStyle::AlphaUpper => {
177                        create_element(tag, vec![("class", "lex-list"), ("type", "A")])
178                    }
179                    crate::ir::nodes::ListStyle::RomanLower => {
180                        create_element(tag, vec![("class", "lex-list"), ("type", "i")])
181                    }
182                    crate::ir::nodes::ListStyle::RomanUpper => {
183                        create_element(tag, vec![("class", "lex-list"), ("type", "I")])
184                    }
185                    _ => create_element(tag, vec![("class", "lex-list")]),
186                };
187                current_parent.children.borrow_mut().push(list.clone());
188                parent_stack.push(current_parent.clone());
189                current_parent = list;
190            }
191
192            Event::EndList => {
193                current_parent = parent_stack.pop().ok_or_else(|| {
194                    FormatError::SerializationError("Unbalanced list end".to_string())
195                })?;
196            }
197
198            Event::StartListItem => {
199                current_heading = None;
200                let item = create_element("li", vec![("class", "lex-list-item")]);
201                current_parent.children.borrow_mut().push(item.clone());
202                parent_stack.push(current_parent.clone());
203                current_parent = item;
204            }
205
206            Event::EndListItem => {
207                current_parent = parent_stack.pop().ok_or_else(|| {
208                    FormatError::SerializationError("Unbalanced list item end".to_string())
209                })?;
210            }
211
212            Event::StartVerbatim { language, subject } => {
213                current_heading = None;
214                in_verbatim = true;
215                verbatim_language = language.clone();
216                verbatim_content.clear();
217
218                // Render subject as a caption before the code block
219                if let Some(subj) = subject {
220                    let caption = create_element("div", vec![("class", "lex-verbatim-subject")]);
221                    let text = create_text(subj);
222                    caption.children.borrow_mut().push(text);
223                    current_parent.children.borrow_mut().push(caption);
224                }
225            }
226
227            Event::EndVerbatim => {
228                // Check for special metadata comment format
229                if let Some(ref lang) = verbatim_language {
230                    if let Some(label) = lang.strip_prefix("lex-metadata:") {
231                        // Render as comment
232                        let comment_text = format!(" lex:{label}{verbatim_content}");
233                        let comment_node = create_comment(&comment_text);
234                        current_parent.children.borrow_mut().push(comment_node);
235
236                        in_verbatim = false;
237                        verbatim_language = None;
238                        verbatim_content.clear();
239                        continue; // Skip normal verbatim handling
240                    }
241                }
242
243                // Create pre + code block with highlight.js-compatible classes
244                let normalized_lang;
245                let mut pre_attrs = vec![("class", "lex-verbatim")];
246                let lang_string;
247                if let Some(ref lang) = verbatim_language {
248                    lang_string = lang.clone();
249                    pre_attrs.push(("data-language", &lang_string));
250                    normalized_lang = Some(format!("language-{}", normalize_language(lang)));
251                } else {
252                    normalized_lang = None;
253                }
254
255                let pre = create_element("pre", pre_attrs);
256                let code_attrs = match normalized_lang {
257                    Some(ref class) => vec![("class", class.as_str())],
258                    None => vec![],
259                };
260                let code = create_element("code", code_attrs);
261                let text = create_text(&verbatim_content);
262                code.children.borrow_mut().push(text);
263                pre.children.borrow_mut().push(code);
264                current_parent.children.borrow_mut().push(pre);
265
266                in_verbatim = false;
267                verbatim_language = None;
268                verbatim_content.clear();
269            }
270
271            Event::StartDefinition => {
272                current_heading = None;
273                let dl = create_element("dl", vec![("class", "lex-definition")]);
274                current_parent.children.borrow_mut().push(dl.clone());
275                parent_stack.push(current_parent.clone());
276                current_parent = dl;
277            }
278
279            Event::EndDefinition => {
280                current_parent = parent_stack.pop().ok_or_else(|| {
281                    FormatError::SerializationError("Unbalanced definition end".to_string())
282                })?;
283            }
284
285            Event::StartDefinitionTerm => {
286                let dt = create_element("dt", vec![]);
287                current_parent.children.borrow_mut().push(dt.clone());
288                parent_stack.push(current_parent.clone());
289                current_parent = dt;
290            }
291
292            Event::EndDefinitionTerm => {
293                current_parent = parent_stack.pop().ok_or_else(|| {
294                    FormatError::SerializationError("Unbalanced definition term end".to_string())
295                })?;
296            }
297
298            Event::StartDefinitionDescription => {
299                let dd = create_element("dd", vec![]);
300                current_parent.children.borrow_mut().push(dd.clone());
301                parent_stack.push(current_parent.clone());
302                current_parent = dd;
303            }
304
305            Event::EndDefinitionDescription => {
306                current_parent = parent_stack.pop().ok_or_else(|| {
307                    FormatError::SerializationError(
308                        "Unbalanced definition description end".to_string(),
309                    )
310                })?;
311            }
312
313            Event::StartTable => {
314                current_heading = None;
315                let table = create_element("table", vec![("class", "lex-table")]);
316                current_parent.children.borrow_mut().push(table.clone());
317                parent_stack.push(current_parent.clone());
318                current_parent = table;
319            }
320
321            Event::EndTable => {
322                current_parent = parent_stack.pop().ok_or_else(|| {
323                    FormatError::SerializationError("Unbalanced table end".to_string())
324                })?;
325            }
326
327            Event::StartTableRow { header: _ } => {
328                let tr = create_element("tr", vec![]);
329                current_parent.children.borrow_mut().push(tr.clone());
330                parent_stack.push(current_parent.clone());
331                current_parent = tr;
332            }
333
334            Event::EndTableRow => {
335                current_parent = parent_stack.pop().ok_or_else(|| {
336                    FormatError::SerializationError("Unbalanced table row end".to_string())
337                })?;
338            }
339
340            Event::StartTableCell { header, align } => {
341                let tag = if *header { "th" } else { "td" };
342                let mut attrs = vec![];
343                match align {
344                    TableCellAlignment::Left => attrs.push(("style", "text-align: left")),
345                    TableCellAlignment::Right => attrs.push(("style", "text-align: right")),
346                    TableCellAlignment::Center => attrs.push(("style", "text-align: center")),
347                    TableCellAlignment::None => {}
348                }
349
350                let cell = create_element(tag, attrs);
351                current_parent.children.borrow_mut().push(cell.clone());
352                parent_stack.push(current_parent.clone());
353                current_parent = cell;
354            }
355
356            Event::EndTableCell => {
357                current_parent = parent_stack.pop().ok_or_else(|| {
358                    FormatError::SerializationError("Unbalanced table cell end".to_string())
359                })?;
360            }
361
362            Event::Inline(inline_content) => {
363                if in_verbatim {
364                    // Accumulate verbatim content
365                    if let InlineContent::Text(text) = inline_content {
366                        verbatim_content.push_str(text);
367                    }
368                } else if let Some(ref heading) = current_heading {
369                    // Add to heading
370                    add_inline_to_node(heading, inline_content)?;
371                } else {
372                    // Add to current parent
373                    add_inline_to_node(&current_parent, inline_content)?;
374                }
375            }
376
377            Event::StartAnnotation { label, parameters } => {
378                current_heading = None;
379                // Create HTML comment
380                let mut comment = format!(" lex:{label}");
381                for (key, value) in parameters {
382                    comment.push_str(&format!(" {key}={value}"));
383                }
384                comment.push(' ');
385                let comment_node = create_comment(&comment);
386                current_parent.children.borrow_mut().push(comment_node);
387            }
388
389            Event::EndAnnotation { label } => {
390                // Closing comment
391                let comment = format!(" /lex:{label} ");
392                let comment_node = create_comment(&comment);
393                current_parent.children.borrow_mut().push(comment_node);
394            }
395
396            Event::Image(image) => {
397                let figure = create_element("figure", vec![("class", "lex-image")]);
398                current_parent.children.borrow_mut().push(figure.clone());
399
400                let mut attrs = vec![("src", image.src.as_str()), ("alt", image.alt.as_str())];
401                if let Some(title) = &image.title {
402                    attrs.push(("title", title.as_str()));
403                }
404                let img = create_element("img", attrs);
405                figure.children.borrow_mut().push(img);
406
407                if !image.alt.is_empty() {
408                    let caption = create_element("figcaption", vec![]);
409                    let text = create_text(&image.alt);
410                    caption.children.borrow_mut().push(text);
411                    figure.children.borrow_mut().push(caption);
412                }
413            }
414
415            Event::Video(video) => {
416                let figure = create_element("figure", vec![("class", "lex-video")]);
417                current_parent.children.borrow_mut().push(figure.clone());
418
419                let mut attrs = vec![("src", video.src.as_str()), ("controls", "")];
420                if let Some(poster) = &video.poster {
421                    attrs.push(("poster", poster.as_str()));
422                }
423                if let Some(title) = &video.title {
424                    attrs.push(("title", title.as_str()));
425                }
426                let vid = create_element("video", attrs);
427                figure.children.borrow_mut().push(vid);
428            }
429
430            Event::Audio(audio) => {
431                let figure = create_element("figure", vec![("class", "lex-audio")]);
432                current_parent.children.borrow_mut().push(figure.clone());
433
434                let mut attrs = vec![("src", audio.src.as_str()), ("controls", "")];
435                if let Some(title) = &audio.title {
436                    attrs.push(("title", title.as_str()));
437                }
438                let aud = create_element("audio", attrs);
439                figure.children.borrow_mut().push(aud);
440            }
441        }
442    }
443
444    // Set the document container as the root
445    dom.document.children.borrow_mut().push(doc_container);
446
447    Ok(dom)
448}
449
450/// Add inline content to an HTML node, handling references → anchors conversion
451fn add_inline_to_node(parent: &Handle, inline: &InlineContent) -> Result<(), FormatError> {
452    match inline {
453        InlineContent::Text(text) => {
454            let text_node = create_text(text);
455            parent.children.borrow_mut().push(text_node);
456        }
457
458        InlineContent::Bold(children) => {
459            let strong = create_element("strong", vec![]);
460            parent.children.borrow_mut().push(strong.clone());
461            for child in children {
462                add_inline_to_node(&strong, child)?;
463            }
464        }
465
466        InlineContent::Italic(children) => {
467            let em = create_element("em", vec![]);
468            parent.children.borrow_mut().push(em.clone());
469            for child in children {
470                add_inline_to_node(&em, child)?;
471            }
472        }
473
474        InlineContent::Code(code_text) => {
475            let code = create_element("code", vec![]);
476            let text = create_text(code_text);
477            code.children.borrow_mut().push(text);
478            parent.children.borrow_mut().push(code);
479        }
480
481        InlineContent::Math(math_text) => {
482            // Math rendered in a span with class
483            let math_span = create_element("span", vec![("class", "lex-math")]);
484            let dollar_open = create_text("$");
485            let math_content = create_text(math_text);
486            let dollar_close = create_text("$");
487            math_span.children.borrow_mut().push(dollar_open);
488            math_span.children.borrow_mut().push(math_content);
489            math_span.children.borrow_mut().push(dollar_close);
490            parent.children.borrow_mut().push(math_span);
491        }
492
493        InlineContent::Reference(ref_text) => {
494            // Convert to anchor
495            // Handle citations (@...) by targeting a reference ID
496            let href = if let Some(citation) = ref_text.strip_prefix('@') {
497                format!("#ref-{citation}")
498            } else {
499                ref_text.to_string()
500            };
501
502            let anchor = create_element("a", vec![("href", &href)]);
503            let anchor_text = create_text(ref_text);
504            anchor.children.borrow_mut().push(anchor_text);
505            parent.children.borrow_mut().push(anchor);
506        }
507
508        InlineContent::Marker(marker) => {
509            let span = create_element("span", vec![("class", "seq_marker")]);
510            let text = create_text(marker);
511            span.children.borrow_mut().push(text);
512            parent.children.borrow_mut().push(span);
513        }
514
515        InlineContent::Image(image) => {
516            let mut attrs = vec![("src", image.src.as_str()), ("alt", image.alt.as_str())];
517            if let Some(title) = &image.title {
518                attrs.push(("title", title.as_str()));
519            }
520            let img = create_element("img", attrs);
521            parent.children.borrow_mut().push(img);
522        }
523    }
524
525    Ok(())
526}
527
528/// Create an HTML element with attributes
529fn create_element(tag: &str, attrs: Vec<(&str, &str)>) -> Handle {
530    let qual_name = QualName::new(None, ns!(html), LocalName::from(tag));
531    let attributes = attrs
532        .into_iter()
533        .map(|(name, value)| Attribute {
534            name: QualName::new(None, ns!(), LocalName::from(name)),
535            value: value.to_string().into(),
536        })
537        .collect();
538
539    Rc::new(Node {
540        parent: Cell::new(None),
541        children: RefCell::new(Vec::new()),
542        data: NodeData::Element {
543            name: qual_name,
544            attrs: RefCell::new(attributes),
545            template_contents: Default::default(),
546            mathml_annotation_xml_integration_point: false,
547        },
548    })
549}
550
551/// Create a text node
552fn create_text(text: &str) -> Handle {
553    Rc::new(Node {
554        parent: Cell::new(None),
555        children: RefCell::new(Vec::new()),
556        data: NodeData::Text {
557            contents: RefCell::new(text.to_string().into()),
558        },
559    })
560}
561
562/// Create a comment node
563fn create_comment(text: &str) -> Handle {
564    Rc::new(Node {
565        parent: Cell::new(None),
566        children: RefCell::new(Vec::new()),
567        data: NodeData::Comment {
568            contents: text.to_string().into(),
569        },
570    })
571}
572
573/// Serialize the DOM to an HTML string (just the inner content)
574fn serialize_dom(dom: &RcDom) -> Result<String, FormatError> {
575    let mut output = Vec::new();
576
577    // Get the document container (first child of document root)
578    let doc_container = dom
579        .document
580        .children
581        .borrow()
582        .first()
583        .ok_or_else(|| FormatError::SerializationError("Empty document".to_string()))?
584        .clone();
585
586    // Serialize each child of the doc_container
587    // Use TraversalScope::IncludeNode to serialize the element AND its children
588    let opts = SerializeOpts {
589        traversal_scope: TraversalScope::IncludeNode,
590        ..Default::default()
591    };
592
593    for child in doc_container.children.borrow().iter() {
594        let serializable = SerializableHandle::from(child.clone());
595        serialize(&mut output, &serializable, opts.clone()).map_err(|e| {
596            FormatError::SerializationError(format!("HTML serialization failed: {e}"))
597        })?;
598    }
599
600    String::from_utf8(output)
601        .map_err(|e| FormatError::SerializationError(format!("UTF-8 conversion failed: {e}")))
602}
603
604/// Wrap the content in a complete HTML document with embedded CSS
605fn wrap_in_document(
606    body_html: &str,
607    title: &str,
608    options: &HtmlOptions,
609) -> Result<String, FormatError> {
610    let baseline_css = include_str!("../../../css/baseline.css");
611    let theme_css = match options.theme {
612        HtmlTheme::FancySerif => include_str!("../../../css/themes/theme-fancy-serif.css"),
613        HtmlTheme::Modern => include_str!("../../../css/themes/theme-modern.css"),
614    };
615
616    // Custom CSS is appended after baseline and theme
617    let custom_css = options.custom_css.as_deref().unwrap_or("");
618
619    // Escape HTML entities in title for safety
620    let escaped_title = html_escape(title);
621
622    let html = format!(
623        r#"<!DOCTYPE html>
624<html lang="en">
625<head>
626  <meta charset="UTF-8">
627  <meta name="viewport" content="width=device-width, initial-scale=1.0">
628  <meta name="generator" content="lex-babel">
629  <title>{escaped_title}</title>
630  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github.min.css">
631  <style>
632{baseline_css}
633{theme_css}
634{custom_css}
635  </style>
636  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
637  <script>hljs.highlightAll();</script>
638</head>
639<body>
640<div class="lex-document">
641{body_html}
642</div>
643</body>
644</html>"#
645    );
646
647    Ok(html)
648}
649
650/// Map common language aliases to highlight.js class names
651fn normalize_language(lang: &str) -> &str {
652    match lang {
653        "js" => "javascript",
654        "ts" => "typescript",
655        "py" => "python",
656        "sh" => "bash",
657        "c++" | "cpp" => "cpp",
658        "c#" | "csharp" => "csharp",
659        "yml" => "yaml",
660        "rb" => "ruby",
661        "rs" => "rust",
662        "kt" => "kotlin",
663        "md" => "markdown",
664        "objc" | "obj-c" => "objectivec",
665        other => other,
666    }
667}
668
669/// Escape HTML special characters in text
670fn html_escape(s: &str) -> String {
671    s.replace('&', "&amp;")
672        .replace('<', "&lt;")
673        .replace('>', "&gt;")
674        .replace('"', "&quot;")
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use lex_core::lex::transforms::standard::STRING_TO_AST;
681
682    #[test]
683    fn test_simple_paragraph() {
684        let lex_src = "This is a simple paragraph.\n";
685        let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
686
687        let html = serialize_to_html(&lex_doc, HtmlTheme::Modern).unwrap();
688
689        assert!(html.contains("<!DOCTYPE html>"));
690        assert!(html.contains("<p class=\"lex-paragraph\">"));
691        assert!(html.contains("This is a simple paragraph."));
692    }
693
694    #[test]
695    fn test_heading() {
696        let lex_src = "1. Introduction\n\n    Content here.\n";
697        let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
698
699        let html = serialize_to_html(&lex_doc, HtmlTheme::Modern).unwrap();
700
701        assert!(html.contains("<section class=\"lex-session lex-session-2\">"));
702        assert!(html.contains("<h2>"));
703        assert!(html.contains("Introduction"));
704    }
705
706    #[test]
707    fn test_css_embedded() {
708        let lex_src = "Test document.\n";
709        let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
710
711        let html = serialize_to_html(&lex_doc, HtmlTheme::Modern).unwrap();
712
713        assert!(html.contains("<style>"));
714        assert!(html.contains(".lex-document"));
715        assert!(html.contains("Helvetica")); // Modern theme uses Helvetica font
716    }
717
718    #[test]
719    fn test_fancy_serif_theme() {
720        let lex_src = "Test document.\n";
721        let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
722
723        let html = serialize_to_html(&lex_doc, HtmlTheme::FancySerif).unwrap();
724
725        assert!(html.contains("Cormorant")); // Fancy serif theme uses Cormorant font
726    }
727
728    #[test]
729    fn test_custom_css_appended() {
730        let lex_src = "Test document.\n";
731        let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
732
733        let custom_css = ".my-custom-class { color: red; }";
734        let options = HtmlOptions::new(HtmlTheme::Modern).with_custom_css(custom_css.to_string());
735        let html = serialize_to_html_with_options(&lex_doc, options).unwrap();
736
737        // Custom CSS should be present
738        assert!(html.contains(".my-custom-class { color: red; }"));
739        // Baseline CSS should still be present
740        assert!(html.contains(".lex-document"));
741    }
742
743    #[test]
744    fn test_html_options_default() {
745        let options = HtmlOptions::default();
746        assert_eq!(options.theme, HtmlTheme::Modern);
747        assert!(options.custom_css.is_none());
748    }
749}