nu_cmd_extra/extra/formats/to/html/
mod.rs

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    // Find the theme by theme name
126    let th = themes.iter().find(|n| n.name.eq_ignore_case(theme_name)); // case insensitive search
127
128    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    // Try to make theme work with light or dark but
166    // flipping the foreground and background but leave
167    // the other colors the same.
168    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        // Being essentially a 'help' option, this can afford to be relatively unoptimized
204        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    // change the color of the page
228    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    // Check to see if we want to remove all color or change ansi to html colors
278    if html_color {
279        setup_html_color_regexes(&mut regex_hm, &color_hm);
280        output_string = run_regexes(&regex_hm, &output_string);
281    } else if no_color {
282        setup_no_color_regexes(&mut regex_hm);
283        output_string = run_regexes(&regex_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    // All the bold colors
384    hash.insert(
385        0,
386        (
387            r"(?P<reset>\[0m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
388            // Reset the text color, normal weight font
389            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            // Bold Black
401            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            // Bold Red
414            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            // Bold Green
427            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            // Bold Yellow
440            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            // Bold Blue
453            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            // Bold Magenta
466            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            // Bold Cyan
479            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            // Bold White
492            // Let's change this to black since the html background
493            // is white. White on white = no bueno.
494            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    // All the normal colors
504    hash.insert(
505        9,
506        (
507            // Black
508            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            // Red
521            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            // Green
532            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            // Yellow
545            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            // Blue
558            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            // Magenta
569            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            // Cyan
582            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            // White
593            // Let's change this to black since the html background
594            // is white. White on white = no bueno.
595            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    // We can just use one regex here because we're just removing ansi sequences
608    // and not replacing them with html colors.
609    // attribution: https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python
610    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}