use crate::customization::normalize_language;
use crate::registry::HighlightedCode;
use crate::renderers::RenderOptions;
use crate::themes::{Color, ThemeVariant};
use std::collections::BTreeMap;
use std::fmt;
#[derive(Debug, PartialEq, Clone, Default)]
pub struct HtmlRenderer {
pub other_metadata: BTreeMap<String, String>,
pub css_class_prefix: Option<String>,
pub classes: Vec<String>,
pub prefix_html: Option<&'static str>,
pub suffix_html: Option<&'static str>,
}
impl HtmlRenderer {
pub fn render(&self, highlighted: &HighlightedCode, options: &RenderOptions) -> String {
let prefix_html = self.prefix_html.unwrap_or("");
let suffix_html = self.suffix_html.unwrap_or("");
let lang = if let Some(normalizer) = highlighted.normalizer {
normalizer(highlighted.language)
} else {
normalize_language(highlighted.language)
};
let css_prefix = self.css_class_prefix.as_deref();
let highlight_attr = if !options.highlight_lines.is_empty() {
if let Some(prefix) = css_prefix {
Some(format!(r#" class="{prefix}hl""#))
} else {
match &highlighted.theme {
ThemeVariant::Single(theme) => theme
.highlight_background_color
.as_ref()
.map(|c| format!(r#" style="{}""#, c.as_css_bg_color_property())),
ThemeVariant::Dual { light, dark } => {
match (
&light.highlight_background_color,
&dark.highlight_background_color,
) {
(Some(l), Some(d)) => Some(format!(
r#" style="{}""#,
Color::as_css_light_dark_bg_color_property(l, d)
)),
_ => None,
}
}
}
}
} else {
None
};
let line_number_style = if options.show_line_numbers && css_prefix.is_none() {
match &highlighted.theme {
ThemeVariant::Single(theme) => theme
.line_number_foreground
.as_ref()
.map(|c| format!(r#" style="{}""#, c.as_css_color_property())),
ThemeVariant::Dual { light, dark } => {
match (&light.line_number_foreground, &dark.line_number_foreground) {
(Some(l), Some(d)) => Some(format!(
r#" style="{}""#,
Color::as_css_light_dark_color_property(l, d)
)),
_ => None,
}
}
}
} else {
None
};
let mut lines = Vec::with_capacity(highlighted.tokens.len() + 4);
let mut tokens = highlighted.tokens.iter().enumerate().peekable();
while let Some((idx, line_tokens)) = tokens.next() {
let line_num = idx + 1;
if tokens.peek().is_none() && line_tokens.is_empty() {
continue;
}
if options.hide_lines.iter().any(|r| r.contains(&line_num)) {
continue;
}
let mut line_content = Vec::with_capacity(line_tokens.len());
for tok in line_tokens {
line_content.push(tok.as_html(&highlighted.theme, css_prefix));
}
let line_content = line_content.join("");
let display_line_num = options.line_number_start + (idx as isize);
let line_number_html = if options.show_line_numbers {
format!(
r#"<span aria-hidden="true" class="z-ln"{}>{display_line_num}</span>"#,
line_number_style.as_deref().unwrap_or_default()
)
} else {
String::new()
};
let is_highlighted = options
.highlight_lines
.iter()
.any(|r| r.contains(&line_num));
let line_html = match (is_highlighted, &highlight_attr) {
(true, Some(hl_class_or_style)) => {
format!(
r#"<span class="z-l{hl_class_or_style}"{hl_style}>{line_number_html}{line_content}</span>"#,
hl_class_or_style = if let Some(p) = css_prefix {
format!(" {p}hl")
} else {
String::new()
},
hl_style = if css_prefix.is_none() {
hl_class_or_style
} else {
""
}
)
}
_ => format!(r#"<span class="z-l">{line_number_html}{line_content}</span>"#),
};
lines.push(line_html);
}
let lines = lines.join("\n");
let mut data_attrs = format!(r#"data-lang="{lang}""#);
for (key, value) in &self.other_metadata {
let slugified_key: String = key
.to_lowercase()
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'-'
}
})
.collect();
data_attrs.push_str(&format!(r#" data-{slugified_key}="{value}""#));
}
let mut classes = format!("language-{lang}");
for class in &self.classes {
classes.push(' ');
classes.push_str(class);
}
if let Some(prefix) = css_prefix {
classes.push(' ');
classes.push_str(prefix);
classes.push_str("code");
}
if css_prefix.is_some() {
return format!(
"{prefix_html}<pre tabindex=\"0\" {data_attrs} class=\"{classes}\"><code {data_attrs} class=\"{classes}\">{lines}</code></pre>{suffix_html}\n"
);
}
match &highlighted.theme {
ThemeVariant::Single(theme) => {
let fg = theme.default_style.foreground.as_css_color_property();
let bg = theme.default_style.background.as_css_bg_color_property();
format!(
"{prefix_html}<pre tabindex=\"0\" class=\"{classes}\" style=\"{fg} {bg}\"><code {data_attrs} class=\"{classes}\">{lines}</code></pre>{suffix_html}\n",
)
}
ThemeVariant::Dual { light, dark } => {
let fg = Color::as_css_light_dark_color_property(
&light.default_style.foreground,
&dark.default_style.foreground,
);
let bg = Color::as_css_light_dark_bg_color_property(
&light.default_style.background,
&dark.default_style.background,
);
format!(
"{prefix_html}<pre tabindex=\"0\" class=\"{classes}\" style=\"color-scheme: light dark; {fg} {bg}\"><code {data_attrs} class=\"{classes}\">{lines}</code></pre>{suffix_html}\n"
)
}
}
}
}
pub(crate) struct HtmlEscaped<'a>(pub &'a str);
impl fmt::Display for HtmlEscaped<'_> {
fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self(s) = *self;
let pile_o_bits = s;
let mut last = 0;
for (i, ch) in s.bytes().enumerate() {
match ch as char {
'<' | '>' | '&' | '\'' | '"' => {
fmt.write_str(&pile_o_bits[last..i])?;
let s = match ch as char {
'>' => ">",
'<' => "<",
'&' => "&",
'\'' => "'",
'"' => """,
_ => unreachable!(),
};
fmt.write_str(s)?;
last = i + 1;
}
_ => {}
}
}
if last < s.len() {
fmt.write_str(&pile_o_bits[last..])?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::HighlightOptions;
use crate::test_utils::get_registry;
#[test]
fn test_highlight_and_hide_lines() {
let registry = get_registry();
let code = "let a = 1;\n\nlet b = 2;\nlet c = 3;\nlet d = 4;\nlet e = 5;\n";
let options = HighlightOptions::new("javascript", ThemeVariant::Single("vitesse-black"));
let highlighted = registry.highlight(code, &options).unwrap();
let render_options = RenderOptions {
show_line_numbers: true,
line_number_start: 10,
highlight_lines: vec![3..=3, 5..=5],
hide_lines: vec![4..=4],
};
let mut other_metadata = BTreeMap::new();
other_metadata.insert("copy".to_string(), "true".to_string());
other_metadata.insert("name".to_string(), "Hello world".to_string());
other_metadata.insert("name with space1".to_string(), "other".to_string());
let html = HtmlRenderer {
other_metadata,
css_class_prefix: None,
classes: Vec::new(),
prefix_html: Some(r#"<div class="t-code-block">"#),
suffix_html: Some("</div>"),
}
.render(&highlighted, &render_options);
insta::assert_snapshot!(html);
}
}