Skip to main content

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(
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    // Find the theme by theme name
130    let th = themes.iter().find(|n| n.name.eq_ignore_case(theme_name)); // case insensitive search
131
132    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    // Try to make theme work with light or dark but
170    // flipping the foreground and background but leave
171    // the other colors the same.
172    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        // Being essentially a 'help' option, this can afford to be relatively unoptimized
208        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    // change the color of the page
232    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    // Check to see if we want to remove all color or change ansi to html colors
282    if html_color {
283        setup_html_color_regexes(&mut regex_hm, &color_hm);
284        output_string = run_regexes(&regex_hm, &output_string);
285    } else if no_color {
286        setup_no_color_regexes(&mut regex_hm);
287        output_string = run_regexes(&regex_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    // All the bold colors
388    hash.insert(
389        0,
390        (
391            r"(?P<reset>\[0m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
392            // Reset the text color, normal weight font
393            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            // Bold Black
405            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            // Bold Red
418            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            // Bold Green
431            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            // Bold Yellow
444            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            // Bold Blue
457            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            // Bold Magenta
470            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            // Bold Cyan
483            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            // Bold White
496            // Let's change this to black since the html background
497            // is white. White on white = no bueno.
498            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    // All the normal colors
508    hash.insert(
509        9,
510        (
511            // Black
512            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            // Red
525            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            // Green
536            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            // Yellow
549            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            // Blue
562            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            // Magenta
573            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            // Cyan
586            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            // White
597            // Let's change this to black since the html background
598            // is white. White on white = no bueno.
599            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    // We can just use one regex here because we're just removing ansi sequences
612    // and not replacing them with html colors.
613    // attribution: https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python
614    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}