use insta::assert_snapshot;
use lex_babel::format::Format;
use lex_babel::formats::html::{HtmlFormat, HtmlTheme};
use lex_core::lex::transforms::standard::STRING_TO_AST;
use once_cell::sync::Lazy;
use regex::Regex;
fn lex_to_html(lex_src: &str, theme: HtmlTheme) -> String {
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let html_format = HtmlFormat::new(theme);
html_format.serialize(&lex_doc).unwrap()
}
#[test]
fn test_paragraph_simple() {
let lex_src = "This is a simple paragraph.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("<div class=\"lex-document\">"));
assert!(html.contains("<p class=\"lex-paragraph\">"));
assert!(html.contains("This is a simple paragraph."));
}
#[test]
fn test_heading_simple() {
let lex_src = "1. Introduction\n\n Some content.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<section class=\"lex-session lex-session-2\">"));
assert!(html.contains("<h2>"));
assert!(html.contains("Introduction"));
assert!(html.contains("<p class=\"lex-paragraph\">"));
assert!(html.contains("Some content."));
}
#[test]
fn test_multiple_heading_levels() {
let lex_src = "1. Level 1\n\n 1.1. Level 2\n\n Content here.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<section class=\"lex-session lex-session-2\">"));
assert!(html.contains("<section class=\"lex-session lex-session-3\">"));
assert!(html.contains("<h2>"));
assert!(html.contains("<h3>"));
}
#[test]
fn test_unordered_list() {
let lex_src = "- Item 1\n- Item 2\n- Item 3\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<ul class=\"lex-list\">"));
assert!(html.contains("<li class=\"lex-list-item\">"));
assert!(html.contains("Item 1"));
assert!(html.contains("Item 2"));
assert!(html.contains("Item 3"));
}
#[test]
fn test_ordered_list() {
let lex_src = "1) First item\n2) Second item\n3) Third item\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<ol class=\"lex-list\">"));
assert!(html.contains("<li class=\"lex-list-item\">"));
assert!(html.contains("First item"));
assert!(html.contains("Second item"));
}
#[test]
fn test_bold_text() {
let lex_src = "This is *bold text* in a paragraph.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<strong>"));
assert!(html.contains("bold text"));
assert!(html.contains("</strong>"));
}
#[test]
fn test_italic_text() {
let lex_src = "This is _italic text_ in a paragraph.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<em>"));
assert!(html.contains("italic text"));
assert!(html.contains("</em>"));
}
#[test]
fn test_code_inline() {
let lex_src = "This is `inline code` in a paragraph.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<code>"));
assert!(html.contains("inline code"));
assert!(html.contains("</code>"));
}
#[test]
fn test_code_block() {
let lex_src =
"Code Example:\n\n function hello() {\n return \"world\";\n }\n\n:: rust ::\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<pre class=\"lex-verbatim\" data-language=\"rust\">"));
assert!(html.contains("<code class=\"language-rust\">"));
assert!(html.contains("function hello()"));
assert!(html.contains("return \"world\""));
assert!(html.contains("highlight.min.js"));
assert!(html.contains("hljs.highlightAll()"));
}
#[test]
fn test_definition_list() {
let lex_src = "Term 1:\n Definition 1\n\nTerm 2:\n Definition 2\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<dl class=\"lex-definition\">"));
assert!(html.contains("<dt>"));
assert!(html.contains("<dd>"));
assert!(html.contains("Term 1"));
assert!(html.contains("Definition 1"));
}
#[test]
fn test_math_inline() {
let lex_src = "The formula is #E = mc^2# here.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<span class=\"lex-math\">"));
assert!(html.contains("$E = mc^2$")); }
#[test]
fn test_reference() {
let lex_src = "Visit [example.com] for more info.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<a href=\"example.com\">"));
}
#[test]
fn test_citation_href_format() {
let lex_src = "According to [@smith2023], this is correct.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(
html.contains("<a href=\"#ref-smith2023\">"),
"Citation should use #ref-smith2023, not @smith2023"
);
assert!(
!html.contains("<a href=\"@smith2023\">"),
"Citation should not use @ in href"
);
}
#[test]
fn test_multiple_citations() {
let lex_src = "Research from [@jones2020] and [@brown2021] supports this.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<a href=\"#ref-jones2020\">"));
assert!(html.contains("<a href=\"#ref-brown2021\">"));
}
#[test]
fn test_url_reference_unchanged() {
let lex_src = "Visit [https://example.com] for details.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<a href=\"https://example.com\">"));
}
#[test]
fn test_anchor_reference_unchanged() {
let lex_src = "See [#section-3] above.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<a href=\"#section-3\">"));
}
#[test]
fn test_highlight_js_injected() {
let lex_src = "Hello world.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(
html.contains("highlight.min.js"),
"highlight.js script should be included"
);
assert!(
html.contains("hljs.highlightAll()"),
"hljs.highlightAll() should be called"
);
assert!(
html.contains("github.min.css"),
"highlight.js github theme should be linked"
);
}
#[test]
fn test_code_block_language_class() {
let lex_src = "Example:\n\n print(\"hello\")\n\n:: python ::\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(
html.contains("<code class=\"language-python\">"),
"code should have language-python class"
);
assert!(
html.contains("data-language=\"python\""),
"pre should keep data-language attribute"
);
}
#[test]
fn test_code_block_language_alias_js() {
let lex_src = "Example:\n\n console.log(\"hello\")\n\n:: js ::\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(
html.contains("<code class=\"language-javascript\">"),
"js should be normalized to javascript"
);
}
#[test]
fn test_code_block_language_alias_py() {
let lex_src = "Example:\n\n print(\"hello\")\n\n:: py ::\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(
html.contains("<code class=\"language-python\">"),
"py should be normalized to python"
);
}
#[test]
fn test_code_block_language_alias_ts() {
let lex_src = "Example:\n\n const x: number = 1\n\n:: ts ::\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(
html.contains("<code class=\"language-typescript\">"),
"ts should be normalized to typescript"
);
}
#[test]
fn test_code_block_no_language() {
let lex_src = "Example:\n\n some code here\n\n:: ::\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(
html.contains("<code>") || html.contains("<code "),
"code element should exist"
);
assert!(
!html.contains("language-"),
"no language class when language is unspecified"
);
}
#[test]
fn test_css_embedded_modern() {
let lex_src = "Test document.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<style"));
assert!(html.contains("Lex HTML Export - Baseline Styles"));
}
#[test]
fn test_css_embedded_fancy_serif() {
let lex_src = "Test document.\n";
let html = lex_to_html(lex_src, HtmlTheme::FancySerif);
assert!(html.contains("<style"));
assert!(html.contains("Lex HTML Export - Fancy Serif Theme"));
}
#[test]
fn test_viewport_meta_tag() {
let lex_src = "Mobile test.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<meta name=\"viewport\""));
assert!(html.contains("width=device-width"));
}
fn snapshot_without_styles(html: &str) -> String {
static STYLE_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new("(?is)<style[^>]*?>.*?</style>").expect("valid regex for stripping style blocks")
});
STYLE_REGEX
.replace_all(html, "<style data-lex-snapshot=\"removed\"></style>")
.into_owned()
}
#[test]
fn test_trifecta_010_paragraphs_sessions_flat_single() {
let lex_src = std::fs::read_to_string(
"../../comms/specs/trifecta/010-paragraphs-sessions-flat-single.lex",
)
.expect("trifecta 010 file should exist");
let html = lex_to_html(&lex_src, HtmlTheme::Modern);
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("<div class=\"lex-document\">"));
assert_snapshot!(snapshot_without_styles(&html));
}
#[test]
fn test_trifecta_020_paragraphs_sessions_flat_multiple() {
let lex_src = std::fs::read_to_string(
"../../comms/specs/trifecta/020-paragraphs-sessions-flat-multiple.lex",
)
.expect("trifecta 020 file should exist");
let html = lex_to_html(&lex_src, HtmlTheme::Modern);
assert!(html.contains("<section class=\"lex-session lex-session-2\">"));
assert_snapshot!(snapshot_without_styles(&html));
}
#[test]
fn test_trifecta_060_nesting() {
let lex_src = std::fs::read_to_string("../../comms/specs/trifecta/060-trifecta-nesting.lex")
.expect("trifecta 060 file should exist");
let html = lex_to_html(&lex_src, HtmlTheme::Modern);
assert!(html.contains("<section class=\"lex-session lex-session-2\">"));
assert!(html.contains("<section class=\"lex-session lex-session-3\">"));
assert_snapshot!(snapshot_without_styles(&html));
}
#[test]
fn test_document_title_from_lex_document() {
let lex_src = std::fs::read_to_string(
"../../comms/specs/elements/document.docs/document-01-title-explicit.lex",
)
.expect("document-01 spec file should exist");
let html = lex_to_html(&lex_src, HtmlTheme::Modern);
assert!(html.contains("<title>My Document Title</title>"));
}
#[test]
fn test_document_title_first_paragraph() {
let lex_src = std::fs::read_to_string(
"../../comms/specs/elements/document.docs/document-06-title-empty.lex",
)
.expect("document-06 spec file should exist");
let html = lex_to_html(&lex_src, HtmlTheme::Modern);
assert!(html.contains("<title>Just a paragraph with no title.</title>"));
}
#[test]
fn test_document_title_session_without_title() {
let lex_src = std::fs::read_to_string(
"../../comms/specs/elements/document.docs/document-05-title-session-hoist.lex",
)
.expect("document-05 spec file should exist");
let html = lex_to_html(&lex_src, HtmlTheme::Modern);
assert!(html.contains("<title>Lex Document</title>"));
}
#[test]
fn test_document_title_rendered_in_body() {
let lex_src = std::fs::read_to_string(
"../../comms/specs/elements/document.docs/document-01-title-explicit.lex",
)
.expect("document-01 spec file should exist");
let html = lex_to_html(&lex_src, HtmlTheme::Modern);
assert!(
html.contains("<header class=\"lex-doc-header\">"),
"body should include a doc-header element: {html}"
);
assert!(
html.contains("<h1 class=\"lex-doc-title\">My Document Title</h1>"),
"body should include an h1 with the title: {html}"
);
}
#[test]
fn test_document_title_no_body_header_when_no_title() {
let lex_src = std::fs::read_to_string(
"../../comms/specs/elements/document.docs/document-05-title-session-hoist.lex",
)
.expect("document-05 spec file should exist");
let html = lex_to_html(&lex_src, HtmlTheme::Modern);
assert!(
!html.contains("lex-doc-header"),
"no doc-header should be present when document has no title: {html}"
);
}
#[test]
fn test_katex_injected_when_math_present() {
let lex_src = "1. Math\n\n Inline: #\\log_2# present.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(
html.contains("katex.min.js"),
"KaTeX script should be loaded when math is present: {html}"
);
assert!(
html.contains("auto-render.min.js"),
"KaTeX auto-render should be loaded: {html}"
);
assert!(
html.contains("renderMathInElement"),
"KaTeX auto-render onload trigger expected: {html}"
);
}
#[test]
fn test_highlight_js_tags_have_sri_integrity_hashes() {
let lex_src = "Plain document for the highlight.js tag check.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(
html.contains(
"integrity=\"sha384-eFTL69TLRZTkNfYZOLM+G04821K1qZao/4QLJbet1pP4tcF+fdXq/9CdqAbWRl/L\""
),
"highlight.js github.min.css must carry verified SRI hash: {html}"
);
assert!(
html.contains(
"integrity=\"sha384-RH2xi4eIQ/gjtbs9fUXM68sLSi99C7ZWBRX1vDrVv6GQXRibxXLbwO2NGZB74MbU\""
),
"highlight.min.js must carry verified SRI hash: {html}"
);
}
#[test]
fn test_katex_tags_have_sri_integrity_hashes() {
let lex_src = "1. Math\n\n Inline: #\\log_2# present.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(
html.contains(
"integrity=\"sha384-nB0miv6/jRmo5UMMR1wu3Gz6NLsoTkbqJghGIsx//Rlm+ZU03BU6SQNC66uf4l5+\""
),
"katex.min.css must carry verified SRI hash: {html}"
);
assert!(
html.contains(
"integrity=\"sha384-7zkQWkzuo3B5mTepMUcHkMB5jZaolc2xDwL6VFqjFALcbeS9Ggm/Yr2r3Dy4lfFg\""
),
"katex.min.js must carry verified SRI hash: {html}"
);
assert!(
html.contains(
"integrity=\"sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk\""
),
"auto-render.min.js must carry verified SRI hash: {html}"
);
}
#[test]
fn test_katex_not_injected_when_no_math() {
let lex_src = "Just a paragraph with no math at all.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(
!html.contains("katex"),
"KaTeX must not be loaded for math-free documents: {html}"
);
}
#[test]
fn test_katex_not_injected_for_verbatim_containing_math_class_text() {
let lex_src = concat!(
"1. Verbatim\n\n",
" Here's an HTML sample.\n\n",
" ===\n",
" <span class=\"lex-math\">$x$</span>\n",
" ===\n",
);
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(
!html.contains("katex.min.js"),
"KaTeX must not load when the only `lex-math` occurrence is inside verbatim text: {html}"
);
}
#[test]
fn test_consecutive_definitions_share_one_dl() {
let lex_src = concat!(
"term-a:\n",
" def a\n\n",
"term-b:\n",
" def b\n\n",
"term-c:\n",
" def c\n",
);
let html = lex_to_html(lex_src, HtmlTheme::Modern);
let dl_count = html.matches("<dl class=\"lex-definition\">").count();
let dt_count = html.matches("<dt>").count();
assert_eq!(
dl_count, 1,
"three consecutive defs should share one <dl>, got {dl_count}: {html}"
);
assert_eq!(dt_count, 3, "three terms expected, got {dt_count}");
}
#[test]
fn test_non_definition_between_definitions_breaks_dl_grouping() {
let lex_src = concat!(
"term-a:\n",
" def a\n\n",
"Just a paragraph between def groups.\n\n",
"term-b:\n",
" def b\n",
);
let html = lex_to_html(lex_src, HtmlTheme::Modern);
let dl_count = html.matches("<dl class=\"lex-definition\">").count();
assert_eq!(
dl_count, 2,
"a paragraph between def groups should yield two <dl>s, got {dl_count}: {html}"
);
}
#[test]
fn test_section_body_emits_no_redundant_content_wrapper() {
let lex_src = concat!(
"1. Primary Session\n\n",
" A simple paragraph inside the session.\n",
);
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(
!html.contains("<section class=\"lex-session lex-session-2\"><h2>Primary Session</h2><div class=\"lex-content\">"),
"section body should not open with <div class=\"lex-content\">: {html}"
);
assert!(
html.contains("</h2><p class=\"lex-paragraph\">A simple paragraph inside the session.</p>"),
"section body should emit <p> directly under the heading: {html}"
);
}
#[test]
fn test_nested_sections_share_no_redundant_wrappers() {
let lex_src = concat!(
"1. Outer\n\n",
" Outer paragraph.\n\n",
" 1.1. Inner\n\n",
" Inner paragraph.\n",
);
let html = lex_to_html(lex_src, HtmlTheme::Modern);
let body_start = html.find("<body>").expect("body tag present");
let body = &html[body_start..];
let wrapper_count = body.matches("<div class=\"lex-content\">").count();
assert_eq!(
wrapper_count, 0,
"expected zero .lex-content wrappers in nested sessions, got {wrapper_count}: {body}"
);
}
#[test]
fn test_dd_body_emits_p_directly_without_content_wrapper() {
let lex_src = "term:\n A simple definition body.\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(
html.contains("<dd><p>A simple definition body.</p></dd>"),
"dd should emit <p> directly, no inner div.lex-content: {html}"
);
assert!(
!html.contains("<dd><div class=\"lex-content\">"),
"dd should not wrap its content in <div class=\"lex-content\">: {html}"
);
}
#[test]
fn test_dd_body_with_multiple_blocks_no_content_wrapper() {
let lex_src = concat!(
"term:\n",
" First paragraph in definition.\n\n",
" - item one\n",
" - item two\n",
);
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(
!html.contains("<dd><div class=\"lex-content\">"),
"dd should not open with <div class=\"lex-content\">: {html}"
);
assert!(
html.contains("<dd><p>First paragraph in definition.</p>"),
"first paragraph expected as direct child of dd: {html}"
);
assert!(
html.contains("<ul class=\"lex-list\">"),
"list still present: {html}"
);
}
#[test]
fn test_document_subtitle_rendered_in_body() {
let lex_src = std::fs::read_to_string(
"../../comms/specs/elements/document.docs/document-07-title-with-subtitle.lex",
)
.expect("document-07 spec file should exist");
let html = lex_to_html(&lex_src, HtmlTheme::Modern);
assert!(
html.contains("<h1 class=\"lex-doc-title\">The Art of War</h1>"),
"title h1 expected: {html}"
);
assert!(
html.contains("<p class=\"lex-doc-subtitle\">A New Translation</p>"),
"subtitle paragraph expected: {html}"
);
}
#[test]
fn test_alphabetical_list_html_type() {
let lex_src = "a. First item\nb. Second item\nc. Third item\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(
html.contains("<ol") && html.contains("type=\"a\""),
"Lowercase alpha list should have type=\"a\": {html}"
);
}
#[test]
fn test_roman_numeral_list_html_type() {
let lex_src = "I. First item\nII. Second item\nIII. Third item\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(
html.contains("<ol") && html.contains("type=\"I\""),
"Uppercase roman list should have type=\"I\": {html}"
);
}
#[test]
fn test_numeric_list_no_type_attr() {
let lex_src = "1. First item\n2. Second item\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<ol"), "Should be an ordered list");
assert!(
!html.contains("type="),
"Numeric lists should not have a type attribute: {html}"
);
}
#[test]
fn test_bullet_list_is_ul() {
let lex_src = "- First item\n- Second item\n";
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<ul"), "Bullet list should use <ul>");
assert!(!html.contains("<ol"), "Bullet list should not use <ol>");
}
#[test]
fn test_deep_session_beyond_h6_gets_class() {
let lex_src = concat!(
"1. Level One\n\n",
" 1.1. Level Two\n\n",
" 1.1.1. Level Three\n\n",
" 1.1.1.1. Level Four\n\n",
" 1.1.1.1.1. Level Five\n\n",
" 1.1.1.1.1.1. Level Six\n\n",
" Deep content.\n",
);
let html = lex_to_html(lex_src, HtmlTheme::Modern);
assert!(html.contains("<h2>"), "Level 1 session should be h2");
assert!(
html.contains("lex-level-7"),
"Session at level 7 must have class lex-level-7 for lossless depth: {html}"
);
assert!(
html.contains("lex-session-7"),
"Section wrapper should have lex-session-7 class"
);
}
#[test]
fn test_kitchensink() {
let lex_src = std::fs::read_to_string("../../comms/specs/benchmark/010-kitchensink.lex")
.expect("kitchensink file should exist");
let html = lex_to_html(&lex_src, HtmlTheme::Modern);
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("<html lang=\"en\">"));
assert!(html.contains("</html>"));
assert!(html.contains("<p class=\"lex-paragraph\">"));
assert!(html.contains("<section class=\"lex-session"));
assert!(html.contains("<ul class=\"lex-list\">"));
assert!(html.contains("<pre class=\"lex-verbatim\""));
assert!(html.contains("<strong>"));
assert!(html.contains("<em>"));
assert!(html.contains("<code>"));
assert!(html.contains("<dl class=\"lex-definition\">"));
assert_snapshot!(snapshot_without_styles(&html));
}