//! HTML formatter with inline CSS styles.
//!
//! This module provides the [`HtmlInline`] formatter that generates HTML output with
//! inline CSS styles for syntax highlighting. It supports themes, line highlighting,
//! and various customization options.
//!
//! # Example Output
//!
//! For the Rust code `fn main() { println!("Hello"); }` with the dracula theme,
//! the formatter generates self-contained HTML like:
//!
//! ```html
//! <pre class="lumis" style="color: #f8f8f2; background-color: #282a36;"><code class="language-rust" translate="no" tabindex="0"><div class="line" data-line="1"><span style="color: #8be9fd;">fn</span> <span style="color: #50fa7b;">main</span><span style="color: #f8f8f2;">(</span><span style="color: #f8f8f2;">)</span> <span style="color: #f8f8f2;">{</span> <span style="color: #bd93f9;">println</span><span style="color: #50fa7b;">!</span><span style="color: #f8f8f2;">(</span><span style="color: #f1fa8c;">"Hello"</span><span style="color: #f8f8f2;">)</span><span style="color: #f8f8f2;">;</span> <span style="color: #f8f8f2;">}</span></div></code></pre>
//! ```
//!
//! See the [formatter](crate::formatter) module for more information and examples.
use super::{Formatter, HtmlElement};
use crate::highlight;
use crate::languages::Language;
use crate::themes::Theme;
use derive_builder::Builder;
use lumis_core::formatter::Formatter as _;
use std::{
io::{self, Write},
ops::RangeInclusive,
};
/// Configuration for highlighting specific lines in HTML inline output.
///
/// This struct allows you to specify which lines should be highlighted and how
/// they should be styled using either theme-based styling or custom CSS.
///
/// # Examples
///
/// Using theme-based highlighting (requires a theme with 'highlighted' style):
/// ```rust
/// use lumis::formatter::html_inline::{HighlightLines, HighlightLinesStyle};
///
/// let highlight_lines = HighlightLines {
/// lines: vec![1..=1, 5..=7],
/// style: Some(HighlightLinesStyle::Theme),
/// class: None,
/// };
/// ```
///
// The resulting HTML will include the theme style for highlighted lines:
/// ```html
/// <div class="line" style="background-color: #dae9f9;" data-line="1">fn main() {</div>
/// ```
///
/// Using both style and class:
/// ```rust
/// use lumis::formatter::html_inline::{HighlightLines, HighlightLinesStyle};
///
/// let highlight_lines = HighlightLines {
/// lines: vec![2..=3],
/// style: Some(HighlightLinesStyle::Theme),
/// class: Some("w-full inline-block bg-yellow-500".to_string()),
/// };
/// ```
///
/// The resulting HTML will look like:
/// ```html
/// <div class="line w-full inline-block bg-yellow-500" style="background-color: #dae9f9;" data-line="3"> let x = 42;</div>
/// ```
///
/// Or disable either one of them:
/// ```rust
/// use lumis::formatter::html_inline::{HighlightLines, HighlightLinesStyle};
///
/// let highlight_lines = HighlightLines {
/// lines: vec![2..=3],
/// style: Some(HighlightLinesStyle::Style("background-color: yellow; border-left: 3px solid red".to_string())),
/// class: None,
/// };
/// ```
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HighlightLines {
/// List of line ranges to highlight.
///
/// Each range is inclusive on both ends. Line numbers are 1-based.
/// Multiple ranges can overlap and will be merged during rendering.
pub lines: Vec<RangeInclusive<usize>>,
/// The styling method to use for highlighted lines.
pub style: Option<HighlightLinesStyle>,
/// Optional CSS class to add to highlighted lines.
pub class: Option<String>,
}
/// Defines how highlighted lines should be styled in HTML inline output.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HighlightLinesStyle {
/// Use the theme's 'highlighted' style if available.
///
/// This looks for a 'highlighted' style definition in the current theme.
/// If no theme is provided or the theme doesn't define 'highlighted',
/// no styling will be applied.
Theme,
/// Use a custom CSS style string.
///
/// The provided string will be used directly as the `style` attribute
/// for highlighted line elements. Should contain valid CSS properties.
///
/// # Example
/// ```rust
/// use lumis::formatter::html_inline::HighlightLinesStyle;
///
/// let style = HighlightLinesStyle::Style(
/// "background-color: rgba(255, 255, 0, 0.3); border-left: 2px solid orange".to_string()
/// );
/// ```
Style(String),
}
impl Default for HighlightLines {
fn default() -> Self {
Self {
lines: Vec::new(),
style: Some(HighlightLinesStyle::Theme),
class: None,
}
}
}
/// HTML formatter with inline CSS styles.
///
/// Generates self-contained HTML with styles embedded directly in elements.
/// Use this when you need standalone HTML without external stylesheets.
/// Use [`HtmlInlineBuilder`] to create instances.
///
/// # When to use
///
/// - Need standalone HTML with no external dependencies
/// - Embedding code snippets in emails or restricted environments
/// - Quick prototyping without CSS setup
///
/// # Example
///
/// ```rust
/// use lumis::{HtmlInlineBuilder, languages::Language, themes, formatter::Formatter};
/// use std::io::Write;
///
/// let code = "const x = 42;";
/// let theme = themes::get("github_dark").unwrap();
///
/// let formatter = HtmlInlineBuilder::new()
/// .lang(Language::JavaScript)
/// .theme(Some(theme))
/// .pre_class(Some("code-block".to_string()))
/// .build()
/// .unwrap();
///
/// let mut output = Vec::new();
/// formatter.format(code, &mut output).unwrap();
/// ```
#[derive(Builder, Clone, Debug)]
#[builder(default)]
pub struct HtmlInline {
lang: Language,
theme: Option<Theme>,
pre_class: Option<String>,
italic: bool,
include_highlights: bool,
highlight_lines: Option<HighlightLines>,
header: Option<HtmlElement>,
}
impl HtmlInlineBuilder {
pub fn new() -> Self {
Self::default()
}
}
impl HtmlInline {
#[allow(clippy::too_many_arguments)]
pub fn new(
lang: Language,
theme: Option<Theme>,
pre_class: Option<String>,
italic: bool,
include_highlights: bool,
highlight_lines: Option<HighlightLines>,
header: Option<HtmlElement>,
) -> Self {
Self {
lang,
theme,
pre_class,
italic,
include_highlights,
highlight_lines,
header,
}
}
}
impl Default for HtmlInline {
fn default() -> Self {
Self {
lang: Language::PlainText,
theme: None,
pre_class: None,
italic: false,
include_highlights: false,
highlight_lines: None,
header: None,
}
}
}
impl Formatter for HtmlInline {
fn format(&self, source: &str, output: &mut dyn Write) -> io::Result<()> {
let events = highlight::highlight_events(source, self.lang).map_err(io::Error::other)?;
let core_formatter = lumis_core::formatter::html_inline::HtmlInline::new(
self.lang,
self.theme.clone(),
self.pre_class.clone(),
self.italic,
self.include_highlights,
self.highlight_lines.clone().map(map_highlight_lines),
self.header.clone(),
);
core_formatter.render(source, &events, output)
}
}
fn map_highlight_lines(
highlight_lines: HighlightLines,
) -> lumis_core::formatter::html_inline::HighlightLines {
lumis_core::formatter::html_inline::HighlightLines {
lines: highlight_lines.lines,
style: highlight_lines.style.map(|style| match style {
HighlightLinesStyle::Theme => {
lumis_core::formatter::html_inline::HighlightLinesStyle::Theme
}
HighlightLinesStyle::Style(style) => {
lumis_core::formatter::html_inline::HighlightLinesStyle::Style(style)
}
}),
class: highlight_lines.class,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::formatter::HtmlInlineBuilder;
use crate::themes;
#[cfg(test)]
use pretty_assertions::assert_str_eq;
#[test]
fn test_no_attrs() {
let code = "@lang :rust";
let formatter = HtmlInline::new(Language::Elixir, None, None, false, false, None, None);
let mut buffer = Vec::new();
formatter.format(code, &mut buffer).unwrap();
let result = String::from_utf8(buffer).unwrap();
let expected = r#"<pre class="lumis"><code class="language-elixir" translate="no" tabindex="0"><div class="line" data-line="1"><span ><span >@<span ><span >lang <span >:rust</span></span></span></span></span>
</div></code></pre>"#;
assert_eq!(result, expected)
}
#[test]
fn test_do_not_append_pre_style_if_missing_theme_style() {
let formatter = HtmlInline::default();
let mut buffer = Vec::new();
crate::formatter::html::open_pre_tag(
&mut buffer,
formatter.pre_class.as_deref(),
formatter.theme.as_ref(),
)
.unwrap();
let pre_tag = String::from_utf8(buffer).unwrap();
assert!(pre_tag.contains("<pre class=\"lumis\">"));
}
#[test]
fn test_include_pre_class() {
let formatter = HtmlInline::new(
Language::PlainText,
None,
Some("test-pre-class".to_string()),
false,
false,
None,
None,
);
let mut buffer = Vec::new();
crate::formatter::html::open_pre_tag(
&mut buffer,
formatter.pre_class.as_deref(),
formatter.theme.as_ref(),
)
.unwrap();
let pre_tag = String::from_utf8(buffer).unwrap();
assert!(pre_tag.contains("<pre class=\"lumis test-pre-class\">"));
}
#[test]
fn test_include_pre_class_with_theme() {
let theme = themes::get("github_light").unwrap();
let formatter = HtmlInline::new(
Language::PlainText,
Some(theme),
Some("test-pre-class".to_string()),
false,
false,
None,
None,
);
let mut buffer = Vec::new();
crate::formatter::html::open_pre_tag(
&mut buffer,
formatter.pre_class.as_deref(),
formatter.theme.as_ref(),
)
.unwrap();
let pre_tag = String::from_utf8(buffer).unwrap();
assert!(pre_tag.contains("<pre class=\"lumis test-pre-class\" style=\"color: #1f2328; background-color: #ffffff;\">"));
}
#[test]
fn test_builder_pattern() {
let theme = themes::get("github_light").unwrap();
let formatter = HtmlInlineBuilder::new()
.lang(Language::Rust)
.theme(Some(theme))
.pre_class(Some("test-pre-class".to_string()))
.italic(true)
.include_highlights(true)
.build()
.unwrap();
let mut buffer = Vec::new();
crate::formatter::html::open_pre_tag(
&mut buffer,
formatter.pre_class.as_deref(),
formatter.theme.as_ref(),
)
.unwrap();
let pre_tag = String::from_utf8(buffer).unwrap();
assert!(pre_tag.contains("<pre class=\"lumis test-pre-class\" style=\"color: #1f2328; background-color: #ffffff;\">"));
}
#[test]
fn test_highlight_lines_with_theme() {
let theme = themes::get("github_light").unwrap();
let highlight_lines = HighlightLines {
lines: vec![1..=1, 3..=4],
style: Some(HighlightLinesStyle::Theme),
class: None,
};
let code = "line 1\nline 2\nline 3\nline 4\nline 5";
let formatter = HtmlInline::new(
Language::PlainText,
Some(theme),
None,
false,
false,
Some(highlight_lines),
None,
);
let mut buffer = Vec::new();
formatter.format(code, &mut buffer).unwrap();
let result = String::from_utf8(buffer).unwrap();
let expected = r#"<pre class="lumis" style="color: #1f2328; background-color: #ffffff;"><code class="language-plaintext" translate="no" tabindex="0"><div class="line" style="background-color: #e7eaf0;" data-line="1">line 1
</div><div class="line" data-line="2">line 2
</div><div class="line" style="background-color: #e7eaf0;" data-line="3">line 3
</div><div class="line" style="background-color: #e7eaf0;" data-line="4">line 4
</div><div class="line" data-line="5">line 5
</div></code></pre>"#;
assert_str_eq!(result, expected);
}
#[test]
fn test_highlight_lines_with_custom_style() {
let highlight_lines = HighlightLines {
lines: vec![1..=1, 3..=4],
style: Some(HighlightLinesStyle::Style(
"background-color: yellow".to_string(),
)),
class: None,
};
let code = "line 1\nline 2\nline 3\nline 4\nline 5";
let formatter = HtmlInline::new(
Language::PlainText,
None,
None,
false,
false,
Some(highlight_lines),
None,
);
let mut buffer = Vec::new();
formatter.format(code, &mut buffer).unwrap();
let result = String::from_utf8(buffer).unwrap();
let expected = r#"<pre class="lumis"><code class="language-plaintext" translate="no" tabindex="0"><div class="line" style="background-color: yellow" data-line="1">line 1
</div><div class="line" data-line="2">line 2
</div><div class="line" style="background-color: yellow" data-line="3">line 3
</div><div class="line" style="background-color: yellow" data-line="4">line 4
</div><div class="line" data-line="5">line 5
</div></code></pre>"#;
assert_str_eq!(result, expected);
}
#[test]
fn test_highlight_lines_with_custom_class() {
let highlight_lines = HighlightLines {
lines: vec![1..=1, 3..=3],
style: Some(HighlightLinesStyle::Style(
"background-color: yellow".to_string(),
)),
class: Some("custom-highlight".to_string()),
};
let code = "line 1\nline 2\nline 3\nline 4";
let formatter = HtmlInline::new(
Language::PlainText,
None,
None,
false,
false,
Some(highlight_lines),
None,
);
let mut buffer = Vec::new();
formatter.format(code, &mut buffer).unwrap();
let result = String::from_utf8(buffer).unwrap();
let expected = r#"<pre class="lumis"><code class="language-plaintext" translate="no" tabindex="0"><div class="line custom-highlight" style="background-color: yellow" data-line="1">line 1
</div><div class="line" data-line="2">line 2
</div><div class="line custom-highlight" style="background-color: yellow" data-line="3">line 3
</div><div class="line" data-line="4">line 4
</div></code></pre>"#;
assert_str_eq!(result, expected);
}
#[test]
fn test_highlight_lines_with_custom_class_and_no_style() {
let highlight_lines = HighlightLines {
lines: vec![1..=1, 3..=3],
style: None,
class: Some("custom-highlight".to_string()),
};
let code = "fn main() {\n println!(\"Hello, world!\");\n let x = 42;\n}";
let formatter = HtmlInline::new(
Language::Rust,
None,
None,
false,
false,
Some(highlight_lines),
None,
);
let mut buffer = Vec::new();
formatter.format(code, &mut buffer).unwrap();
let result = String::from_utf8(buffer).unwrap();
let expected = r#"<pre class="lumis"><code class="language-rust" translate="no" tabindex="0"><div class="line custom-highlight" data-line="1"><span >fn</span> <span >main</span><span >(</span><span >)</span> <span >{</span>
</div><div class="line" data-line="2"> <span >println</span><span >!</span><span >(</span><span >"Hello, world!"</span><span >)</span><span >;</span>
</div><div class="line custom-highlight" data-line="3"> <span >let</span> <span >x</span> <span >=</span> <span >42</span><span >;</span>
</div><div class="line" data-line="4"><span >}</span>
</div></code></pre>"#;
assert_str_eq!(result, expected);
}
#[test]
fn test_header_wrapping() {
let header = HtmlElement {
open_tag: "<div class=\"code-wrapper\">".to_string(),
close_tag: "</div>".to_string(),
};
let code = "line 1\nline 2";
let formatter = HtmlInline::new(
Language::PlainText,
None,
None,
false,
false,
None,
Some(header),
);
let mut buffer = Vec::new();
formatter.format(code, &mut buffer).unwrap();
let result = String::from_utf8(buffer).unwrap();
let expected = r#"<div class="code-wrapper"><pre class="lumis"><code class="language-plaintext" translate="no" tabindex="0"><div class="line" data-line="1">line 1
</div><div class="line" data-line="2">line 2
</div></code></pre></div>"#;
assert_str_eq!(result, expected);
}
#[test]
fn test_header_with_complex_structure() {
let header = HtmlElement {
open_tag: "<section class=\"highlight\" data-lang=\"rust\">".to_string(),
close_tag: "</section>".to_string(),
};
let code = "fn main() { }";
let formatter = HtmlInline::new(
Language::Rust,
None,
Some("custom-class".to_string()),
false,
false,
None,
Some(header),
);
let mut buffer = Vec::new();
formatter.format(code, &mut buffer).unwrap();
let result = String::from_utf8(buffer).unwrap();
let expected = r#"<section class="highlight" data-lang="rust"><pre class="lumis custom-class"><code class="language-rust" translate="no" tabindex="0"><div class="line" data-line="1"><span >fn</span> <span >main</span><span >(</span><span >)</span> <span >{</span> <span >}</span>
</div></code></pre></section>"#;
assert_str_eq!(result, expected);
}
}