use handlebars::{Handlebars, RenderError, Template as HandlebarsTemplate};
use serde::{Deserialize, Serialize};
use std::{fmt, io::Write};
mod data;
mod helpers;
mod palette;
pub use self::{
data::{CreatorData, HandlebarsData, SerializedInteraction},
palette::{NamedPalette, NamedPaletteParseError, Palette, TermColors},
};
pub use crate::utils::{RgbColor, RgbColorParseError};
use self::helpers::register_helpers;
use crate::{TermError, Transcript};
const DEFAULT_TEMPLATE: &str = include_str!("default.svg.handlebars");
const MAIN_TEMPLATE_NAME: &str = "main";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LineNumbers {
EachOutput,
ContinuousOutputs,
Continuous,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateOptions {
pub width: usize,
pub palette: Palette,
pub font_family: String,
pub window_frame: bool,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub scroll: Option<ScrollOptions>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub wrap: Option<WrapOptions>,
#[serde(default)]
pub line_numbers: Option<LineNumbers>,
}
impl Default for TemplateOptions {
fn default() -> Self {
Self {
width: 720,
palette: Palette::default(),
font_family: "SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace".to_owned(),
window_frame: false,
scroll: None,
wrap: Some(WrapOptions::default()),
line_numbers: None,
}
}
}
impl TemplateOptions {
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "debug", skip(transcript), err)
)]
pub fn render_data<'s>(
&'s self,
transcript: &'s Transcript,
) -> Result<HandlebarsData<'s>, TermError> {
let rendered_outputs = self.render_outputs(transcript)?;
let mut has_failures = false;
let interactions: Vec<_> = transcript
.interactions()
.iter()
.zip(rendered_outputs)
.map(|(interaction, output_html)| {
let failure = interaction
.exit_status()
.map_or(false, |status| !status.is_success());
has_failures = has_failures || failure;
SerializedInteraction {
input: interaction.input(),
output_html,
exit_status: interaction.exit_status().map(|status| status.0),
failure,
}
})
.collect();
Ok(HandlebarsData {
creator: CreatorData::default(),
interactions,
options: self,
has_failures,
})
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "debug", skip_all, err)
)]
fn render_outputs(&self, transcript: &Transcript) -> Result<Vec<String>, TermError> {
let max_width = self.wrap.as_ref().map(|wrap_options| match wrap_options {
WrapOptions::HardBreakAt(width) => *width,
});
transcript
.interactions
.iter()
.map(|interaction| {
let output = interaction.output();
let mut buffer = String::with_capacity(output.as_ref().len());
output.write_as_html(&mut buffer, max_width)?;
Ok(buffer)
})
.collect()
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ScrollOptions {
pub max_height: usize,
pub interval: f32,
}
impl Default for ScrollOptions {
fn default() -> Self {
const DEFAULT_LINE_HEIGHT: usize = 18; Self {
max_height: DEFAULT_LINE_HEIGHT * 19,
interval: 4.0,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum WrapOptions {
HardBreakAt(usize),
}
impl Default for WrapOptions {
fn default() -> Self {
Self::HardBreakAt(80)
}
}
pub struct Template {
options: TemplateOptions,
handlebars: Handlebars<'static>,
}
impl fmt::Debug for Template {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("Template")
.field("options", &self.options)
.finish_non_exhaustive()
}
}
impl Default for Template {
fn default() -> Self {
Self::new(TemplateOptions::default())
}
}
impl Template {
pub fn new(options: TemplateOptions) -> Self {
let template = HandlebarsTemplate::compile(DEFAULT_TEMPLATE)
.expect("Default template should be valid");
Self::custom(template, options)
}
pub fn custom(template: HandlebarsTemplate, options: TemplateOptions) -> Self {
let mut handlebars = Handlebars::new();
handlebars.set_strict_mode(true);
register_helpers(&mut handlebars);
handlebars.register_template(MAIN_TEMPLATE_NAME, template);
Self {
options,
handlebars,
}
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(skip_all, err, fields(self.options = ?self.options))
)]
pub fn render<W: Write>(
&self,
transcript: &Transcript,
destination: W,
) -> Result<(), RenderError> {
let data = self
.options
.render_data(transcript)
.map_err(|err| RenderError::from_error("content", err))?;
#[cfg(feature = "tracing")]
let _entered = tracing::debug_span!("render_to_write").entered();
self.handlebars
.render_to_write(MAIN_TEMPLATE_NAME, &data, destination)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ExitStatus, Interaction, UserInput};
#[test]
fn rendering_simple_transcript() {
let mut transcript = Transcript::new();
transcript.add_interaction(
UserInput::command("test"),
"Hello, \u{1b}[32mworld\u{1b}[0m!",
);
let mut buffer = vec![];
Template::new(TemplateOptions::default())
.render(&transcript, &mut buffer)
.unwrap();
let buffer = String::from_utf8(buffer).unwrap();
assert!(buffer.starts_with("<!--"));
assert!(
buffer.ends_with("</svg>\n") || buffer.ends_with("</svg>\r\n"),
"unexpected rendering result: {buffer}"
);
assert!(buffer.contains(r#"Hello, <span class="fg2">world</span>!"#));
assert!(!buffer.contains("data-exit-status"));
assert!(!buffer.contains("<circle"));
assert!(!buffer.contains("user-input-failure"));
assert!(!buffer.contains("title=\"This command exited with non-zero code\""));
}
#[test]
fn rendering_transcript_with_explicit_success() {
let mut transcript = Transcript::new();
let interaction = Interaction::new("test", "Hello, \u{1b}[32mworld\u{1b}[0m!")
.with_exit_status(ExitStatus(0));
transcript.add_existing_interaction(interaction);
let mut buffer = vec![];
Template::new(TemplateOptions::default())
.render(&transcript, &mut buffer)
.unwrap();
let buffer = String::from_utf8(buffer).unwrap();
assert!(!buffer.contains("user-input-failure"));
assert!(!buffer.contains("title=\"This command exited with non-zero code\""));
assert!(buffer.contains(r#"data-exit-status="0""#));
}
#[test]
fn rendering_transcript_with_failure() {
let mut transcript = Transcript::new();
let interaction = Interaction::new("test", "Hello, \u{1b}[32mworld\u{1b}[0m!")
.with_exit_status(ExitStatus(1));
transcript.add_existing_interaction(interaction);
let mut buffer = vec![];
Template::new(TemplateOptions::default())
.render(&transcript, &mut buffer)
.unwrap();
let buffer = String::from_utf8(buffer).unwrap();
assert!(buffer.contains("user-input-failure"));
assert!(buffer.contains("title=\"This command exited with non-zero code\""));
assert!(buffer.contains(r#"data-exit-status="1""#));
}
#[test]
fn rendering_transcript_with_frame() {
let mut transcript = Transcript::new();
transcript.add_interaction(
UserInput::command("test"),
"Hello, \u{1b}[32mworld\u{1b}[0m!",
);
let mut buffer = vec![];
let options = TemplateOptions {
window_frame: true,
..TemplateOptions::default()
};
Template::new(options)
.render(&transcript, &mut buffer)
.unwrap();
let buffer = String::from_utf8(buffer).unwrap();
assert!(buffer.contains("<circle"));
}
#[test]
fn rendering_transcript_with_animation() {
let mut transcript = Transcript::new();
transcript.add_interaction(
UserInput::command("test"),
"Hello, \u{1b}[32mworld\u{1b}[0m!\n".repeat(22),
);
let mut buffer = vec![];
let options = TemplateOptions {
scroll: Some(ScrollOptions {
max_height: 240,
interval: 3.0,
}),
..TemplateOptions::default()
};
Template::new(options)
.render(&transcript, &mut buffer)
.unwrap();
let buffer = String::from_utf8(buffer).unwrap();
assert!(buffer.contains(r#"viewBox="0 0 720 260""#), "{buffer}");
assert!(buffer.contains("<animateTransform"), "{buffer}");
}
#[test]
fn rendering_transcript_with_wraps() {
let mut transcript = Transcript::new();
transcript.add_interaction(
UserInput::command("test"),
"Hello, \u{1b}[32mworld\u{1b}[0m!",
);
let mut buffer = vec![];
let options = TemplateOptions {
wrap: Some(WrapOptions::HardBreakAt(5)),
..TemplateOptions::default()
};
Template::new(options)
.render(&transcript, &mut buffer)
.unwrap();
let buffer = String::from_utf8(buffer).unwrap();
assert!(buffer.contains(r#"viewBox="0 0 720 102""#), "{buffer}");
assert!(buffer.contains("<br/>"), "{buffer}");
}
#[test]
fn rendering_transcript_with_line_numbers() {
let mut transcript = Transcript::new();
transcript.add_interaction(
UserInput::command("test"),
"Hello, \u{1b}[32mworld\u{1b}[0m!",
);
transcript.add_interaction(
UserInput::command("another_test"),
"Hello,\n\u{1b}[32mworld\u{1b}[0m!",
);
let mut buffer = vec![];
let options = TemplateOptions {
line_numbers: Some(LineNumbers::EachOutput),
..TemplateOptions::default()
};
Template::new(options)
.render(&transcript, &mut buffer)
.unwrap();
let buffer = String::from_utf8(buffer).unwrap();
assert!(
buffer.contains(r#"<pre class="line-numbers">1</pre>"#),
"{buffer}"
);
assert!(
buffer.contains(r#"<pre class="line-numbers">1<br/>2</pre>"#),
"{buffer}"
);
}
#[test]
fn rendering_transcript_with_continuous_line_numbers() {
let mut transcript = Transcript::new();
transcript.add_interaction(
UserInput::command("test"),
"Hello, \u{1b}[32mworld\u{1b}[0m!",
);
transcript.add_interaction(
UserInput::command("another_test"),
"Hello,\n\u{1b}[32mworld\u{1b}[0m!",
);
let mut buffer = vec![];
let options = TemplateOptions {
line_numbers: Some(LineNumbers::ContinuousOutputs),
..TemplateOptions::default()
};
Template::new(options)
.render(&transcript, &mut buffer)
.unwrap();
let buffer = String::from_utf8(buffer).unwrap();
assert!(
buffer.contains(r#"<pre class="line-numbers">1</pre>"#),
"{buffer}"
);
assert!(
buffer.contains(r#"<pre class="line-numbers">2<br/>3</pre>"#),
"{buffer}"
);
}
#[test]
fn rendering_transcript_with_input_line_numbers() {
let mut transcript = Transcript::new();
transcript.add_interaction(
UserInput::command("test"),
"Hello, \u{1b}[32mworld\u{1b}[0m!",
);
transcript.add_interaction(
UserInput::command("another\ntest"),
"Hello,\n\u{1b}[32mworld\u{1b}[0m!",
);
let mut buffer = vec![];
let options = TemplateOptions {
line_numbers: Some(LineNumbers::Continuous),
..TemplateOptions::default()
};
Template::new(options)
.render(&transcript, &mut buffer)
.unwrap();
let buffer = String::from_utf8(buffer).unwrap();
assert!(
buffer.contains(r#"<div class="user-input"><pre class="line-numbers">"#),
"{buffer}"
);
assert!(
buffer.contains(r#"<pre class="line-numbers">5<br/>6</pre>"#),
"{buffer}"
);
}
}