use fancy_regex::Regex;
use nu_cmd_base::formats::to::delimited::merge_descriptors;
use nu_engine::command_prelude::*;
use nu_protocol::{Config, DataSource, PipelineMetadata};
use nu_utils::IgnoreCaseExt;
use std::{collections::HashMap, fmt::Write};
use theme::HtmlTheme;
mod theme;
mod theme_list {
use super::theme::HtmlTheme;
include!(concat!(env!("OUT_DIR"), "/html_theme_list.rs"));
}
#[derive(Clone)]
pub struct ToHtml;
impl Command for ToHtml {
fn name(&self) -> &str {
"to html"
}
fn signature(&self) -> Signature {
Signature::build("to html")
.input_output_types(vec![(Type::Nothing, Type::Any), (Type::Any, Type::String)])
.allow_variants_without_examples(true)
.switch(
"html-color",
"Change ansi colors to html colors.",
Some('c'),
)
.switch("no-color", "Remove all ansi colors in output.", Some('n'))
.switch(
"dark",
"Indicate your background color is a darker color.",
Some('d'),
)
.switch(
"partial",
"Only output the html for the content itself.",
Some('p'),
)
.named(
"theme",
SyntaxShape::String,
"The name of the theme to use (github, blulocolight, ...); case-insensitive.",
Some('t'),
)
.switch(
"list",
"Produce a color table of all available themes.",
Some('l'),
)
.switch("raw", "Do not escape html tags.", Some('r'))
.category(Category::Formats)
}
fn examples(&self) -> Vec<Example<'_>> {
vec![
Example {
description: "Outputs an HTML string representing the contents of this table",
example: "[[foo bar]; [1 2]] | to html",
result: Some(Value::test_string(
"<html><style>body { background-color:white;color:black; }</style><body><table><thead><tr><th>foo</th><th>bar</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table></body></html>",
)),
},
Example {
description: "Outputs an HTML string using a record of xml data",
example: r#"{tag: a attributes: { style: "color: red" } content: ["hello!"] } | to xml | to html --raw"#,
result: Some(Value::test_string(
r#"<html><style>body { background-color:white;color:black; }</style><body><a style="color: red">hello!</a></body></html>"#,
)),
},
Example {
description: "Optionally, only output the html for the content itself",
example: "[[foo bar]; [1 2]] | to html --partial",
result: Some(Value::test_string(
r#"<div style="background-color:white;color:black;"><table><thead><tr><th>foo</th><th>bar</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table></div>"#,
)),
},
Example {
description: "Optionally, output the string with a dark background",
example: "[[foo bar]; [1 2]] | to html --dark",
result: Some(Value::test_string(
"<html><style>body { background-color:black;color:white; }</style><body><table><thead><tr><th>foo</th><th>bar</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table></body></html>",
)),
},
]
}
fn description(&self) -> &str {
"Convert table into simple HTML."
}
fn extra_description(&self) -> &str {
"Screenshots of the themes can be browsed here: https://github.com/mbadolato/iTerm2-Color-Schemes."
}
fn run(
&self,
engine_state: &EngineState,
stack: &mut Stack,
call: &Call,
input: PipelineData,
) -> Result<PipelineData, ShellError> {
to_html(input, call, engine_state, stack)
}
}
fn get_theme_from_asset_file(
is_dark: bool,
theme: Option<&Spanned<String>>,
) -> Result<HashMap<&'static str, String>, ShellError> {
let theme_name = match theme {
Some(s) => &s.item,
None => {
return Ok(convert_html_theme_to_hash_map(
is_dark,
&HtmlTheme::default(),
));
}
};
let theme_span = theme.map(|s| s.span).unwrap_or(Span::unknown());
let themes = HtmlTheme::list();
let th = themes.iter().find(|n| n.name.eq_ignore_case(theme_name));
let th = match th {
Some(t) => t,
None => {
return Err(ShellError::TypeMismatch {
err_message: format!("Unknown HTML theme '{theme_name}'"),
span: theme_span,
});
}
};
Ok(convert_html_theme_to_hash_map(is_dark, th))
}
fn convert_html_theme_to_hash_map(
is_dark: bool,
theme: &HtmlTheme,
) -> HashMap<&'static str, String> {
let mut hm: HashMap<&str, String> = HashMap::with_capacity(18);
hm.insert("bold_black", theme.bright_black[..].to_string());
hm.insert("bold_red", theme.bright_red[..].to_string());
hm.insert("bold_green", theme.bright_green[..].to_string());
hm.insert("bold_yellow", theme.bright_yellow[..].to_string());
hm.insert("bold_blue", theme.bright_blue[..].to_string());
hm.insert("bold_magenta", theme.bright_purple[..].to_string());
hm.insert("bold_cyan", theme.bright_cyan[..].to_string());
hm.insert("bold_white", theme.bright_white[..].to_string());
hm.insert("black", theme.black[..].to_string());
hm.insert("red", theme.red[..].to_string());
hm.insert("green", theme.green[..].to_string());
hm.insert("yellow", theme.yellow[..].to_string());
hm.insert("blue", theme.blue[..].to_string());
hm.insert("magenta", theme.purple[..].to_string());
hm.insert("cyan", theme.cyan[..].to_string());
hm.insert("white", theme.white[..].to_string());
if is_dark {
hm.insert("background", theme.black[..].to_string());
hm.insert("foreground", theme.white[..].to_string());
} else {
hm.insert("background", theme.white[..].to_string());
hm.insert("foreground", theme.black[..].to_string());
}
hm
}
fn to_html(
input: PipelineData,
call: &Call,
engine_state: &EngineState,
stack: &mut Stack,
) -> Result<PipelineData, ShellError> {
let head = call.head;
let html_color = call.has_flag(engine_state, stack, "html-color")?;
let no_color = call.has_flag(engine_state, stack, "no-color")?;
let dark = call.has_flag(engine_state, stack, "dark")?;
let partial = call.has_flag(engine_state, stack, "partial")?;
let list = call.has_flag(engine_state, stack, "list")?;
let raw = call.has_flag(engine_state, stack, "raw")?;
let theme: Option<Spanned<String>> = call.get_flag(engine_state, stack, "theme")?;
let config = &stack.get_config(engine_state);
let vec_of_values = input.into_iter().collect::<Vec<Value>>();
let headers = merge_descriptors(&vec_of_values);
let headers = Some(headers)
.filter(|headers| !headers.is_empty() && (headers.len() > 1 || !headers[0].is_empty()));
let mut output_string = String::new();
let mut regex_hm: HashMap<u32, (&str, String)> = HashMap::with_capacity(17);
if list {
return Ok(theme_demo(head));
}
let theme_span = match &theme {
Some(v) => v.span,
None => head,
};
let color_hm = match get_theme_from_asset_file(dark, theme.as_ref()) {
Ok(c) => c,
Err(e) => match e {
ShellError::TypeMismatch {
err_message,
span: _,
} => {
return Err(ShellError::TypeMismatch {
err_message,
span: theme_span,
});
}
_ => return Err(e),
},
};
if !partial {
write!(
&mut output_string,
"<html><style>body {{ background-color:{};color:{}; }}</style><body>",
color_hm
.get("background")
.expect("Error getting background color"),
color_hm
.get("foreground")
.expect("Error getting foreground color")
)
.ok();
} else {
write!(
&mut output_string,
"<div style=\"background-color:{};color:{};\">",
color_hm
.get("background")
.expect("Error getting background color"),
color_hm
.get("foreground")
.expect("Error getting foreground color")
)
.ok();
}
let inner_value = match vec_of_values.len() {
0 => String::default(),
1 => match headers {
Some(headers) => html_table(vec_of_values, headers, raw, config),
None => {
let value = &vec_of_values[0];
html_value(value.clone(), raw, config)
}
},
_ => match headers {
Some(headers) => html_table(vec_of_values, headers, raw, config),
None => html_list(vec_of_values, raw, config),
},
};
output_string.push_str(&inner_value);
if !partial {
output_string.push_str("</body></html>");
} else {
output_string.push_str("</div>")
}
if html_color {
setup_html_color_regexes(&mut regex_hm, &color_hm);
output_string = run_regexes(®ex_hm, &output_string);
} else if no_color {
setup_no_color_regexes(&mut regex_hm);
output_string = run_regexes(®ex_hm, &output_string);
}
let metadata = PipelineMetadata {
data_source: nu_protocol::DataSource::None,
content_type: Some(mime::TEXT_HTML_UTF_8.to_string()),
..Default::default()
};
Ok(Value::string(output_string, head).into_pipeline_data_with_metadata(metadata))
}
fn theme_demo(span: Span) -> PipelineData {
Vec::from(HtmlTheme::list())
.into_value(span)
.into_pipeline_data_with_metadata(PipelineMetadata {
data_source: DataSource::HtmlThemes,
..Default::default()
})
}
fn html_list(list: Vec<Value>, raw: bool, config: &Config) -> String {
let mut output_string = String::new();
output_string.push_str("<ol>");
for value in list {
output_string.push_str("<li>");
output_string.push_str(&html_value(value, raw, config));
output_string.push_str("</li>");
}
output_string.push_str("</ol>");
output_string
}
fn html_table(table: Vec<Value>, headers: Vec<String>, raw: bool, config: &Config) -> String {
let mut output_string = String::new();
output_string.push_str("<table>");
output_string.push_str("<thead><tr>");
for header in &headers {
output_string.push_str("<th>");
output_string.push_str(&v_htmlescape::escape(header).to_string());
output_string.push_str("</th>");
}
output_string.push_str("</tr></thead><tbody>");
for row in table {
let span = row.span();
if let Value::Record { val: row, .. } = row {
output_string.push_str("<tr>");
for header in &headers {
let data = row
.get(header)
.cloned()
.unwrap_or_else(|| Value::nothing(span));
output_string.push_str("<td>");
output_string.push_str(&html_value(data, raw, config));
output_string.push_str("</td>");
}
output_string.push_str("</tr>");
}
}
output_string.push_str("</tbody></table>");
output_string
}
fn html_value(value: Value, raw: bool, config: &Config) -> String {
let mut output_string = String::new();
match value {
Value::Binary { val, .. } => {
let output = nu_pretty_hex::pretty_hex(&val);
output_string.push_str("<pre>");
output_string.push_str(&output);
output_string.push_str("</pre>");
}
other => {
if raw {
output_string.push_str(
&other
.to_abbreviated_string(config)
.to_string()
.replace('\n', "<br>"),
)
} else {
output_string.push_str(
&v_htmlescape::escape(&other.to_abbreviated_string(config))
.to_string()
.replace('\n', "<br>"),
)
}
}
}
output_string
}
fn setup_html_color_regexes(
hash: &mut HashMap<u32, (&'static str, String)>,
color_hm: &HashMap<&str, String>,
) {
hash.insert(
0,
(
r"(?P<reset>\[0m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
format!(
"<span style='color:{};font-weight:normal;'>$word</span>",
color_hm
.get("foreground")
.expect("Error getting reset text color")
),
),
);
hash.insert(
1,
(
r"(?P<bb>\[1;30m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
format!(
"<span style='color:{};font-weight:bold;'>$word</span>",
color_hm
.get("foreground")
.expect("Error getting bold black text color")
),
),
);
hash.insert(
2,
(
r"(?P<br>\[1;31m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
format!(
"<span style='color:{};font-weight:bold;'>$word</span>",
color_hm
.get("bold_red")
.expect("Error getting bold red text color"),
),
),
);
hash.insert(
3,
(
r"(?P<bg>\[1;32m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
format!(
"<span style='color:{};font-weight:bold;'>$word</span>",
color_hm
.get("bold_green")
.expect("Error getting bold green text color"),
),
),
);
hash.insert(
4,
(
r"(?P<by>\[1;33m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
format!(
"<span style='color:{};font-weight:bold;'>$word</span>",
color_hm
.get("bold_yellow")
.expect("Error getting bold yellow text color"),
),
),
);
hash.insert(
5,
(
r"(?P<bu>\[1;34m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
format!(
"<span style='color:{};font-weight:bold;'>$word</span>",
color_hm
.get("bold_blue")
.expect("Error getting bold blue text color"),
),
),
);
hash.insert(
6,
(
r"(?P<bm>\[1;35m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
format!(
"<span style='color:{};font-weight:bold;'>$word</span>",
color_hm
.get("bold_magenta")
.expect("Error getting bold magenta text color"),
),
),
);
hash.insert(
7,
(
r"(?P<bc>\[1;36m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
format!(
"<span style='color:{};font-weight:bold;'>$word</span>",
color_hm
.get("bold_cyan")
.expect("Error getting bold cyan text color"),
),
),
);
hash.insert(
8,
(
r"(?P<bw>\[1;37m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
format!(
"<span style='color:{};font-weight:bold;'>$word</span>",
color_hm
.get("foreground")
.expect("Error getting bold bold white text color"),
),
),
);
hash.insert(
9,
(
r"(?P<b>\[30m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
format!(
"<span style='color:{};'>$word</span>",
color_hm
.get("foreground")
.expect("Error getting black text color"),
),
),
);
hash.insert(
10,
(
r"(?P<r>\[31m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
format!(
"<span style='color:{};'>$word</span>",
color_hm.get("red").expect("Error getting red text color"),
),
),
);
hash.insert(
11,
(
r"(?P<g>\[32m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
format!(
"<span style='color:{};'>$word</span>",
color_hm
.get("green")
.expect("Error getting green text color"),
),
),
);
hash.insert(
12,
(
r"(?P<y>\[33m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
format!(
"<span style='color:{};'>$word</span>",
color_hm
.get("yellow")
.expect("Error getting yellow text color"),
),
),
);
hash.insert(
13,
(
r"(?P<u>\[34m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
format!(
"<span style='color:{};'>$word</span>",
color_hm.get("blue").expect("Error getting blue text color"),
),
),
);
hash.insert(
14,
(
r"(?P<m>\[35m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
format!(
"<span style='color:{};'>$word</span>",
color_hm
.get("magenta")
.expect("Error getting magenta text color"),
),
),
);
hash.insert(
15,
(
r"(?P<c>\[36m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
format!(
"<span style='color:{};'>$word</span>",
color_hm.get("cyan").expect("Error getting cyan text color"),
),
),
);
hash.insert(
16,
(
r"(?P<w>\[37m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
format!(
"<span style='color:{};'>$word</span>",
color_hm
.get("foreground")
.expect("Error getting white text color"),
),
),
);
}
fn setup_no_color_regexes(hash: &mut HashMap<u32, (&'static str, String)>) {
hash.insert(
0,
(
r"(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])",
"$name_group_doesnt_exist".to_string(),
),
);
}
fn run_regexes(hash: &HashMap<u32, (&'static str, String)>, contents: &str) -> String {
let mut working_string = contents.to_owned();
let hash_count: u32 = hash.len() as u32;
for n in 0..hash_count {
let value = hash.get(&n).expect("error getting hash at index");
let re = Regex::new(value.0).expect("problem with color regex");
let after = re.replace_all(&working_string, &value.1[..]).to_string();
working_string = after;
}
working_string
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_examples() -> nu_test_support::Result {
nu_test_support::test().examples(ToHtml)
}
#[test]
fn get_theme_from_asset_file_returns_default() {
let result = super::get_theme_from_asset_file(false, None);
assert!(result.is_ok(), "Expected Ok result for None theme");
let theme_map = result.unwrap();
assert_eq!(
theme_map.get("background").map(String::as_str),
Some("white"),
"Expected default background color to be white"
);
assert_eq!(
theme_map.get("foreground").map(String::as_str),
Some("black"),
"Expected default foreground color to be black"
);
assert!(
theme_map.contains_key("red"),
"Expected default theme to have a 'red' color"
);
assert!(
theme_map.contains_key("bold_green"),
"Expected default theme to have a 'bold_green' color"
);
}
#[test]
fn returns_a_valid_theme() {
let theme_name = "Dracula".to_string().into_spanned(Span::new(0, 7));
let result = super::get_theme_from_asset_file(false, Some(&theme_name));
assert!(result.is_ok(), "Expected Ok result for valid theme");
let theme_map = result.unwrap();
let required_keys = [
"background",
"foreground",
"red",
"green",
"blue",
"bold_red",
"bold_green",
"bold_blue",
];
for key in required_keys {
assert!(
theme_map.contains_key(key),
"Expected theme to contain key '{key}'"
);
}
}
#[test]
fn fails_with_unknown_theme_name() {
let result = super::get_theme_from_asset_file(
false,
Some(&"doesnt-exist".to_string().into_spanned(Span::new(0, 13))),
);
assert!(result.is_err(), "Expected error for invalid theme name");
if let Err(err) = result {
assert!(
matches!(err, ShellError::TypeMismatch { .. }),
"Expected TypeMismatch error, got: {err:?}"
);
if let ShellError::TypeMismatch { err_message, span } = err {
assert!(
err_message.contains("doesnt-exist"),
"Error message should mention theme name, got: {err_message}"
);
assert_eq!(span.start, 0);
assert_eq!(span.end, 13);
}
}
}
}