1use fancy_regex::Regex;
2use nu_cmd_base::formats::to::delimited::merge_descriptors;
3use nu_engine::command_prelude::*;
4use nu_protocol::{Config, DataSource, PipelineMetadata};
5use nu_utils::IgnoreCaseExt;
6use rust_embed::RustEmbed;
7use serde::{Deserialize, Serialize};
8use std::{collections::HashMap, error::Error, fmt::Write};
9
10#[derive(Serialize, Deserialize, Debug)]
11pub struct HtmlThemes {
12 themes: Vec<HtmlTheme>,
13}
14
15#[allow(non_snake_case)]
16#[derive(Serialize, Deserialize, Debug)]
17pub struct HtmlTheme {
18 name: String,
19 black: String,
20 red: String,
21 green: String,
22 yellow: String,
23 blue: String,
24 purple: String,
25 cyan: String,
26 white: String,
27 brightBlack: String,
28 brightRed: String,
29 brightGreen: String,
30 brightYellow: String,
31 brightBlue: String,
32 brightPurple: String,
33 brightCyan: String,
34 brightWhite: String,
35 background: String,
36 foreground: String,
37}
38
39impl Default for HtmlThemes {
40 fn default() -> Self {
41 HtmlThemes {
42 themes: vec![HtmlTheme::default()],
43 }
44 }
45}
46
47impl Default for HtmlTheme {
48 fn default() -> Self {
49 HtmlTheme {
50 name: "nu_default".to_string(),
51 black: "black".to_string(),
52 red: "red".to_string(),
53 green: "green".to_string(),
54 yellow: "#717100".to_string(),
55 blue: "blue".to_string(),
56 purple: "#c800c8".to_string(),
57 cyan: "#037979".to_string(),
58 white: "white".to_string(),
59 brightBlack: "black".to_string(),
60 brightRed: "red".to_string(),
61 brightGreen: "green".to_string(),
62 brightYellow: "#717100".to_string(),
63 brightBlue: "blue".to_string(),
64 brightPurple: "#c800c8".to_string(),
65 brightCyan: "#037979".to_string(),
66 brightWhite: "white".to_string(),
67 background: "white".to_string(),
68 foreground: "black".to_string(),
69 }
70 }
71}
72
73#[derive(RustEmbed)]
74#[folder = "assets/"]
75struct Assets;
76
77#[derive(Clone)]
78pub struct ToHtml;
79
80impl Command for ToHtml {
81 fn name(&self) -> &str {
82 "to html"
83 }
84
85 fn signature(&self) -> Signature {
86 Signature::build("to html")
87 .input_output_types(vec![(Type::Nothing, Type::Any), (Type::Any, Type::String)])
88 .allow_variants_without_examples(true)
89 .switch("html-color", "change ansi colors to html colors", Some('c'))
90 .switch("no-color", "remove all ansi colors in output", Some('n'))
91 .switch(
92 "dark",
93 "indicate your background color is a darker color",
94 Some('d'),
95 )
96 .switch(
97 "partial",
98 "only output the html for the content itself",
99 Some('p'),
100 )
101 .named(
102 "theme",
103 SyntaxShape::String,
104 "the name of the theme to use (github, blulocolight, ...); case-insensitive",
105 Some('t'),
106 )
107 .switch(
108 "list",
109 "produce a color table of all available themes",
110 Some('l'),
111 )
112 .category(Category::Formats)
113 }
114
115 fn examples(&self) -> Vec<Example> {
116 vec![
117 Example {
118 description: "Outputs an HTML string representing the contents of this table",
119 example: "[[foo bar]; [1 2]] | to html",
120 result: Some(Value::test_string(
121 r#"<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>"#,
122 )),
123 },
124 Example {
125 description: "Optionally, only output the html for the content itself",
126 example: "[[foo bar]; [1 2]] | to html --partial",
127 result: Some(Value::test_string(
128 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>"#,
129 )),
130 },
131 Example {
132 description: "Optionally, output the string with a dark background",
133 example: "[[foo bar]; [1 2]] | to html --dark",
134 result: Some(Value::test_string(
135 r#"<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>"#,
136 )),
137 },
138 ]
139 }
140
141 fn description(&self) -> &str {
142 "Convert table into simple HTML."
143 }
144
145 fn extra_description(&self) -> &str {
146 "Screenshots of the themes can be browsed here: https://github.com/mbadolato/iTerm2-Color-Schemes."
147 }
148
149 fn run(
150 &self,
151 engine_state: &EngineState,
152 stack: &mut Stack,
153 call: &Call,
154 input: PipelineData,
155 ) -> Result<PipelineData, ShellError> {
156 to_html(input, call, engine_state, stack)
157 }
158}
159
160fn get_theme_from_asset_file(
161 is_dark: bool,
162 theme: Option<&Spanned<String>>,
163) -> Result<HashMap<&'static str, String>, ShellError> {
164 let theme_name = match theme {
165 Some(s) => &s.item,
166 None => {
167 return Ok(convert_html_theme_to_hash_map(
168 is_dark,
169 &HtmlTheme::default(),
170 ));
171 }
172 };
173
174 let theme_span = theme.map(|s| s.span).unwrap_or(Span::unknown());
175
176 let asset = get_html_themes("228_themes.json").unwrap_or_default();
180
181 let th = asset
183 .themes
184 .into_iter()
185 .find(|n| n.name.eq_ignore_case(theme_name)); let th = match th {
188 Some(t) => t,
189 None => {
190 return Err(ShellError::TypeMismatch {
191 err_message: format!("Unknown HTML theme '{theme_name}'"),
192 span: theme_span,
193 });
194 }
195 };
196
197 Ok(convert_html_theme_to_hash_map(is_dark, &th))
198}
199
200fn convert_html_theme_to_hash_map(
201 is_dark: bool,
202 theme: &HtmlTheme,
203) -> HashMap<&'static str, String> {
204 let mut hm: HashMap<&str, String> = HashMap::with_capacity(18);
205
206 hm.insert("bold_black", theme.brightBlack[..].to_string());
207 hm.insert("bold_red", theme.brightRed[..].to_string());
208 hm.insert("bold_green", theme.brightGreen[..].to_string());
209 hm.insert("bold_yellow", theme.brightYellow[..].to_string());
210 hm.insert("bold_blue", theme.brightBlue[..].to_string());
211 hm.insert("bold_magenta", theme.brightPurple[..].to_string());
212 hm.insert("bold_cyan", theme.brightCyan[..].to_string());
213 hm.insert("bold_white", theme.brightWhite[..].to_string());
214
215 hm.insert("black", theme.black[..].to_string());
216 hm.insert("red", theme.red[..].to_string());
217 hm.insert("green", theme.green[..].to_string());
218 hm.insert("yellow", theme.yellow[..].to_string());
219 hm.insert("blue", theme.blue[..].to_string());
220 hm.insert("magenta", theme.purple[..].to_string());
221 hm.insert("cyan", theme.cyan[..].to_string());
222 hm.insert("white", theme.white[..].to_string());
223
224 if is_dark {
228 hm.insert("background", theme.black[..].to_string());
229 hm.insert("foreground", theme.white[..].to_string());
230 } else {
231 hm.insert("background", theme.white[..].to_string());
232 hm.insert("foreground", theme.black[..].to_string());
233 }
234
235 hm
236}
237
238fn get_html_themes(json_name: &str) -> Result<HtmlThemes, Box<dyn Error>> {
239 match Assets::get(json_name) {
240 Some(content) => Ok(nu_json::from_slice(&content.data)?),
241 None => Ok(HtmlThemes::default()),
242 }
243}
244
245fn to_html(
246 input: PipelineData,
247 call: &Call,
248 engine_state: &EngineState,
249 stack: &mut Stack,
250) -> Result<PipelineData, ShellError> {
251 let head = call.head;
252 let html_color = call.has_flag(engine_state, stack, "html-color")?;
253 let no_color = call.has_flag(engine_state, stack, "no-color")?;
254 let dark = call.has_flag(engine_state, stack, "dark")?;
255 let partial = call.has_flag(engine_state, stack, "partial")?;
256 let list = call.has_flag(engine_state, stack, "list")?;
257 let theme: Option<Spanned<String>> = call.get_flag(engine_state, stack, "theme")?;
258 let config = &stack.get_config(engine_state);
259
260 let vec_of_values = input.into_iter().collect::<Vec<Value>>();
261 let headers = merge_descriptors(&vec_of_values);
262 let headers = Some(headers)
263 .filter(|headers| !headers.is_empty() && (headers.len() > 1 || !headers[0].is_empty()));
264 let mut output_string = String::new();
265 let mut regex_hm: HashMap<u32, (&str, String)> = HashMap::with_capacity(17);
266
267 if list {
268 return Ok(theme_demo(head));
270 }
271 let theme_span = match &theme {
272 Some(v) => v.span,
273 None => head,
274 };
275
276 let color_hm = match get_theme_from_asset_file(dark, theme.as_ref()) {
277 Ok(c) => c,
278 Err(e) => match e {
279 ShellError::TypeMismatch {
280 err_message,
281 span: _,
282 } => {
283 return Err(ShellError::TypeMismatch {
284 err_message,
285 span: theme_span,
286 });
287 }
288 _ => return Err(e),
289 },
290 };
291
292 if !partial {
294 write!(
295 &mut output_string,
296 r"<html><style>body {{ background-color:{};color:{}; }}</style><body>",
297 color_hm
298 .get("background")
299 .expect("Error getting background color"),
300 color_hm
301 .get("foreground")
302 .expect("Error getting foreground color")
303 )
304 .unwrap();
305 } else {
306 write!(
307 &mut output_string,
308 "<div style=\"background-color:{};color:{};\">",
309 color_hm
310 .get("background")
311 .expect("Error getting background color"),
312 color_hm
313 .get("foreground")
314 .expect("Error getting foreground color")
315 )
316 .unwrap();
317 }
318
319 let inner_value = match vec_of_values.len() {
320 0 => String::default(),
321 1 => match headers {
322 Some(headers) => html_table(vec_of_values, headers, config),
323 None => {
324 let value = &vec_of_values[0];
325 html_value(value.clone(), config)
326 }
327 },
328 _ => match headers {
329 Some(headers) => html_table(vec_of_values, headers, config),
330 None => html_list(vec_of_values, config),
331 },
332 };
333
334 output_string.push_str(&inner_value);
335
336 if !partial {
337 output_string.push_str("</body></html>");
338 } else {
339 output_string.push_str("</div>")
340 }
341
342 if html_color {
344 setup_html_color_regexes(&mut regex_hm, &color_hm);
345 output_string = run_regexes(®ex_hm, &output_string);
346 } else if no_color {
347 setup_no_color_regexes(&mut regex_hm);
348 output_string = run_regexes(®ex_hm, &output_string);
349 }
350
351 let metadata = PipelineMetadata {
352 data_source: nu_protocol::DataSource::None,
353 content_type: Some(mime::TEXT_HTML_UTF_8.to_string()),
354 };
355
356 Ok(Value::string(output_string, head).into_pipeline_data_with_metadata(metadata))
357}
358
359fn theme_demo(span: Span) -> PipelineData {
360 let html_themes = get_html_themes("228_themes.json").unwrap_or_default();
362 let result: Vec<Value> = html_themes
363 .themes
364 .into_iter()
365 .map(|n| {
366 Value::record(
367 record! {
368 "name" => Value::string(n.name, span),
369 "black" => Value::string(n.black, span),
370 "red" => Value::string(n.red, span),
371 "green" => Value::string(n.green, span),
372 "yellow" => Value::string(n.yellow, span),
373 "blue" => Value::string(n.blue, span),
374 "purple" => Value::string(n.purple, span),
375 "cyan" => Value::string(n.cyan, span),
376 "white" => Value::string(n.white, span),
377 "brightBlack" => Value::string(n.brightBlack, span),
378 "brightRed" => Value::string(n.brightRed, span),
379 "brightGreen" => Value::string(n.brightGreen, span),
380 "brightYellow" => Value::string(n.brightYellow, span),
381 "brightBlue" => Value::string(n.brightBlue, span),
382 "brightPurple" => Value::string(n.brightPurple, span),
383 "brightCyan" => Value::string(n.brightCyan, span),
384 "brightWhite" => Value::string(n.brightWhite, span),
385 "background" => Value::string(n.background, span),
386 "foreground" => Value::string(n.foreground, span),
387 },
388 span,
389 )
390 })
391 .collect();
392 Value::list(result, span).into_pipeline_data_with_metadata(PipelineMetadata {
393 data_source: DataSource::HtmlThemes,
394 content_type: None,
395 })
396}
397
398fn html_list(list: Vec<Value>, config: &Config) -> String {
399 let mut output_string = String::new();
400 output_string.push_str("<ol>");
401 for value in list {
402 output_string.push_str("<li>");
403 output_string.push_str(&html_value(value, config));
404 output_string.push_str("</li>");
405 }
406 output_string.push_str("</ol>");
407 output_string
408}
409
410fn html_table(table: Vec<Value>, headers: Vec<String>, config: &Config) -> String {
411 let mut output_string = String::new();
412
413 output_string.push_str("<table>");
414
415 output_string.push_str("<thead><tr>");
416 for header in &headers {
417 output_string.push_str("<th>");
418 output_string.push_str(&v_htmlescape::escape(header).to_string());
419 output_string.push_str("</th>");
420 }
421 output_string.push_str("</tr></thead><tbody>");
422
423 for row in table {
424 let span = row.span();
425 if let Value::Record { val: row, .. } = row {
426 output_string.push_str("<tr>");
427 for header in &headers {
428 let data = row
429 .get(header)
430 .cloned()
431 .unwrap_or_else(|| Value::nothing(span));
432 output_string.push_str("<td>");
433 output_string.push_str(&html_value(data, config));
434 output_string.push_str("</td>");
435 }
436 output_string.push_str("</tr>");
437 }
438 }
439 output_string.push_str("</tbody></table>");
440
441 output_string
442}
443
444fn html_value(value: Value, config: &Config) -> String {
445 let mut output_string = String::new();
446 match value {
447 Value::Binary { val, .. } => {
448 let output = nu_pretty_hex::pretty_hex(&val);
449 output_string.push_str("<pre>");
450 output_string.push_str(&output);
451 output_string.push_str("</pre>");
452 }
453 other => output_string.push_str(
454 &v_htmlescape::escape(&other.to_abbreviated_string(config))
455 .to_string()
456 .replace('\n', "<br>"),
457 ),
458 }
459 output_string
460}
461
462fn setup_html_color_regexes(
463 hash: &mut HashMap<u32, (&'static str, String)>,
464 color_hm: &HashMap<&str, String>,
465) {
466 hash.insert(
468 0,
469 (
470 r"(?P<reset>\[0m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
471 format!(
473 r"<span style='color:{};font-weight:normal;'>$word</span>",
474 color_hm
475 .get("foreground")
476 .expect("Error getting reset text color")
477 ),
478 ),
479 );
480 hash.insert(
481 1,
482 (
483 r"(?P<bb>\[1;30m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
485 format!(
486 r"<span style='color:{};font-weight:bold;'>$word</span>",
487 color_hm
488 .get("foreground")
489 .expect("Error getting bold black text color")
490 ),
491 ),
492 );
493 hash.insert(
494 2,
495 (
496 r"(?P<br>\[1;31m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
498 format!(
499 r"<span style='color:{};font-weight:bold;'>$word</span>",
500 color_hm
501 .get("bold_red")
502 .expect("Error getting bold red text color"),
503 ),
504 ),
505 );
506 hash.insert(
507 3,
508 (
509 r"(?P<bg>\[1;32m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
511 format!(
512 r"<span style='color:{};font-weight:bold;'>$word</span>",
513 color_hm
514 .get("bold_green")
515 .expect("Error getting bold green text color"),
516 ),
517 ),
518 );
519 hash.insert(
520 4,
521 (
522 r"(?P<by>\[1;33m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
524 format!(
525 r"<span style='color:{};font-weight:bold;'>$word</span>",
526 color_hm
527 .get("bold_yellow")
528 .expect("Error getting bold yellow text color"),
529 ),
530 ),
531 );
532 hash.insert(
533 5,
534 (
535 r"(?P<bu>\[1;34m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
537 format!(
538 r"<span style='color:{};font-weight:bold;'>$word</span>",
539 color_hm
540 .get("bold_blue")
541 .expect("Error getting bold blue text color"),
542 ),
543 ),
544 );
545 hash.insert(
546 6,
547 (
548 r"(?P<bm>\[1;35m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
550 format!(
551 r"<span style='color:{};font-weight:bold;'>$word</span>",
552 color_hm
553 .get("bold_magenta")
554 .expect("Error getting bold magenta text color"),
555 ),
556 ),
557 );
558 hash.insert(
559 7,
560 (
561 r"(?P<bc>\[1;36m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
563 format!(
564 r"<span style='color:{};font-weight:bold;'>$word</span>",
565 color_hm
566 .get("bold_cyan")
567 .expect("Error getting bold cyan text color"),
568 ),
569 ),
570 );
571 hash.insert(
572 8,
573 (
574 r"(?P<bw>\[1;37m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
578 format!(
579 r"<span style='color:{};font-weight:bold;'>$word</span>",
580 color_hm
581 .get("foreground")
582 .expect("Error getting bold bold white text color"),
583 ),
584 ),
585 );
586 hash.insert(
588 9,
589 (
590 r"(?P<b>\[30m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
592 format!(
593 r"<span style='color:{};'>$word</span>",
594 color_hm
595 .get("foreground")
596 .expect("Error getting black text color"),
597 ),
598 ),
599 );
600 hash.insert(
601 10,
602 (
603 r"(?P<r>\[31m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
605 format!(
606 r"<span style='color:{};'>$word</span>",
607 color_hm.get("red").expect("Error getting red text color"),
608 ),
609 ),
610 );
611 hash.insert(
612 11,
613 (
614 r"(?P<g>\[32m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
616 format!(
617 r"<span style='color:{};'>$word</span>",
618 color_hm
619 .get("green")
620 .expect("Error getting green text color"),
621 ),
622 ),
623 );
624 hash.insert(
625 12,
626 (
627 r"(?P<y>\[33m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
629 format!(
630 r"<span style='color:{};'>$word</span>",
631 color_hm
632 .get("yellow")
633 .expect("Error getting yellow text color"),
634 ),
635 ),
636 );
637 hash.insert(
638 13,
639 (
640 r"(?P<u>\[34m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
642 format!(
643 r"<span style='color:{};'>$word</span>",
644 color_hm.get("blue").expect("Error getting blue text color"),
645 ),
646 ),
647 );
648 hash.insert(
649 14,
650 (
651 r"(?P<m>\[35m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
653 format!(
654 r"<span style='color:{};'>$word</span>",
655 color_hm
656 .get("magenta")
657 .expect("Error getting magenta text color"),
658 ),
659 ),
660 );
661 hash.insert(
662 15,
663 (
664 r"(?P<c>\[36m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
666 format!(
667 r"<span style='color:{};'>$word</span>",
668 color_hm.get("cyan").expect("Error getting cyan text color"),
669 ),
670 ),
671 );
672 hash.insert(
673 16,
674 (
675 r"(?P<w>\[37m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
679 format!(
680 r"<span style='color:{};'>$word</span>",
681 color_hm
682 .get("foreground")
683 .expect("Error getting white text color"),
684 ),
685 ),
686 );
687}
688
689fn setup_no_color_regexes(hash: &mut HashMap<u32, (&'static str, String)>) {
690 hash.insert(
694 0,
695 (
696 r"(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])",
697 r"$name_group_doesnt_exist".to_string(),
698 ),
699 );
700}
701
702fn run_regexes(hash: &HashMap<u32, (&'static str, String)>, contents: &str) -> String {
703 let mut working_string = contents.to_owned();
704 let hash_count: u32 = hash.len() as u32;
705 for n in 0..hash_count {
706 let value = hash.get(&n).expect("error getting hash at index");
707 let re = Regex::new(value.0).expect("problem with color regex");
708 let after = re.replace_all(&working_string, &value.1[..]).to_string();
709 working_string = after;
710 }
711 working_string
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717
718 #[test]
719 fn test_examples() {
720 use crate::test_examples;
721
722 test_examples(ToHtml {})
723 }
724
725 #[test]
726 fn get_theme_from_asset_file_returns_default() {
727 let result = super::get_theme_from_asset_file(false, None);
728
729 assert!(result.is_ok(), "Expected Ok result for None theme");
730
731 let theme_map = result.unwrap();
732
733 assert_eq!(
734 theme_map.get("background").map(String::as_str),
735 Some("white"),
736 "Expected default background color to be white"
737 );
738
739 assert_eq!(
740 theme_map.get("foreground").map(String::as_str),
741 Some("black"),
742 "Expected default foreground color to be black"
743 );
744
745 assert!(
746 theme_map.contains_key("red"),
747 "Expected default theme to have a 'red' color"
748 );
749
750 assert!(
751 theme_map.contains_key("bold_green"),
752 "Expected default theme to have a 'bold_green' color"
753 );
754 }
755
756 #[test]
757 fn returns_a_valid_theme() {
758 let theme_name = "Dracula".to_string().into_spanned(Span::new(0, 7));
759 let result = super::get_theme_from_asset_file(false, Some(&theme_name));
760
761 assert!(result.is_ok(), "Expected Ok result for valid theme");
762 let theme_map = result.unwrap();
763 let required_keys = [
764 "background",
765 "foreground",
766 "red",
767 "green",
768 "blue",
769 "bold_red",
770 "bold_green",
771 "bold_blue",
772 ];
773
774 for key in required_keys {
775 assert!(
776 theme_map.contains_key(key),
777 "Expected theme to contain key '{key}'"
778 );
779 }
780 }
781
782 #[test]
783 fn fails_with_unknown_theme_name() {
784 let result = super::get_theme_from_asset_file(
785 false,
786 Some(&"doesnt-exist".to_string().into_spanned(Span::new(0, 13))),
787 );
788
789 assert!(result.is_err(), "Expected error for invalid theme name");
790
791 if let Err(err) = result {
792 assert!(
793 matches!(err, ShellError::TypeMismatch { .. }),
794 "Expected TypeMismatch error, got: {err:?}"
795 );
796
797 if let ShellError::TypeMismatch { err_message, span } = err {
798 assert!(
799 err_message.contains("doesnt-exist"),
800 "Error message should mention theme name, got: {err_message}"
801 );
802 assert_eq!(span.start, 0);
803 assert_eq!(span.end, 13);
804 }
805 }
806 }
807}