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