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