1use 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#[derive(Debug, Clone, Default)]
23pub struct HtmlOptions {
24 pub theme: HtmlTheme,
26 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
44pub fn serialize_to_html(doc: &Document, theme: HtmlTheme) -> Result<String, FormatError> {
46 serialize_to_html_with_options(doc, HtmlOptions::new(theme))
47}
48
49pub fn serialize_to_html_with_options(
51 doc: &Document,
52 options: HtmlOptions,
53) -> Result<String, FormatError> {
54 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 let ir_doc = crate::to_ir(doc);
64
65 let events = tree_to_events(&DocNode::Document(ir_doc));
67
68 let dom = build_html_dom(&events)?;
70
71 let html_string = serialize_dom(&dom)?;
73
74 let complete_html = wrap_in_document(&html_string, &title, &options)?;
76
77 Ok(complete_html)
78}
79
80fn build_html_dom(events: &[Event]) -> Result<RcDom, FormatError> {
82 let dom = RcDom::default();
83
84 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 let mut in_verbatim = false;
92 let mut verbatim_language: Option<String> = None;
93 let mut verbatim_content = String::new();
94
95 let mut current_heading: Option<Handle> = None;
97
98 for event in events {
99 match event {
100 Event::StartDocument => {
101 }
103
104 Event::EndDocument => {
105 }
107
108 Event::StartHeading(level) => {
109 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 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 current_parent = parent_stack.pop().ok_or_else(|| {
134 FormatError::SerializationError("Unbalanced heading end".to_string())
135 })?;
136 }
137
138 Event::StartContent => {
139 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 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 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 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 if let Some(ref lang) = verbatim_language {
230 if let Some(label) = lang.strip_prefix("lex-metadata:") {
231 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; }
241 }
242
243 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 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_inline_to_node(heading, inline_content)?;
371 } else {
372 add_inline_to_node(¤t_parent, inline_content)?;
374 }
375 }
376
377 Event::StartAnnotation { label, parameters } => {
378 current_heading = None;
379 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 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 dom.document.children.borrow_mut().push(doc_container);
446
447 Ok(dom)
448}
449
450fn 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 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 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
528fn 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
551fn 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
562fn 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
573fn serialize_dom(dom: &RcDom) -> Result<String, FormatError> {
575 let mut output = Vec::new();
576
577 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 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
604fn 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 let custom_css = options.custom_css.as_deref().unwrap_or("");
618
619 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
650fn 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
669fn html_escape(s: &str) -> String {
671 s.replace('&', "&")
672 .replace('<', "<")
673 .replace('>', ">")
674 .replace('"', """)
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")); }
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")); }
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 assert!(html.contains(".my-custom-class { color: red; }"));
739 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}