use std::{
fmt::Write as _,
io::{self, Read, Write},
net::{Ipv4Addr, TcpListener, TcpStream},
sync::{Arc, Mutex, PoisonError},
thread,
time::Duration,
};
use chartlet::{ChartWarning, Styles, stylesheet};
use crate::{Failure, Job};
pub struct Preview {
name: String,
stylesheet: Option<String>,
port: u16,
state: Mutex<State>,
}
#[derive(Default)]
struct State {
version: u64,
chart: Option<String>,
error: Option<String>,
warnings: Vec<String>,
}
impl Preview {
pub fn serve(port: u16, job: &Job) -> Result<Arc<Self>, Failure> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, port))
.and_then(|listener| Ok((listener.local_addr()?.port(), listener)))
.map_err(|error| format!("could not serve the preview on 127.0.0.1:{port}: {error}"));
let (port, listener) = listener?;
let preview = Arc::new(Self {
name: job.input.file_name().map_or_else(
|| job.input.display().to_string(),
|name| name.to_string_lossy().into_owned(),
),
stylesheet: (job.options.styles == Styles::External).then(stylesheet),
port,
state: Mutex::new(State::default()),
});
let server = Arc::clone(&preview);
thread::spawn(move || {
for stream in listener.incoming().flatten() {
let _ = server.answer(stream);
}
});
println!("chartlet: preview at http://127.0.0.1:{port}/");
Ok(preview)
}
pub fn update(&self, result: Result<String, String>, warnings: &[ChartWarning]) {
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
state.version += 1;
state.warnings = warnings
.iter()
.map(|warning| {
format!(
"warning[{}] at {}: {}",
warning.code, warning.path, warning.message
)
})
.collect();
match result {
Ok(chart) => {
state.chart = Some(chart);
state.error = None;
}
Err(error) => state.error = Some(error),
}
}
fn answer(&self, mut stream: TcpStream) -> io::Result<()> {
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
let mut request = [0; 8192];
let mut length = 0;
while length < request.len() && !request[..length].windows(4).any(|end| end == b"\r\n\r\n")
{
match stream.read(&mut request[length..])? {
0 => break,
read => length += read,
}
}
let request = String::from_utf8_lossy(&request[..length]);
let mut lines = request.lines();
let mut start = lines.next().unwrap_or_default().split(' ');
let (method, target) = (start.next(), start.next());
let host = lines.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("host").then(|| value.trim())
});
let local = host.is_some_and(|host| {
host == format!("127.0.0.1:{}", self.port) || host == format!("localhost:{}", self.port)
});
let (status, content_type, body) = match (method, target) {
_ if !local => (
"403 Forbidden",
"text/plain",
"not a local request".to_owned(),
),
(Some("GET"), Some("/")) => ("200 OK", "text/html; charset=utf-8", self.page()),
(Some("GET"), Some("/version")) => ("200 OK", "text/plain", self.version().to_string()),
(Some("GET"), _) => ("404 Not Found", "text/plain", "not found".to_owned()),
_ => (
"405 Method Not Allowed",
"text/plain",
"only GET".to_owned(),
),
};
write!(
stream,
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n\
Cache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\n\
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; \
script-src 'unsafe-inline'; connect-src 'self'; img-src data:\r\n\
Connection: close\r\n\r\n{body}",
body.len()
)?;
stream.flush()
}
fn version(&self) -> u64 {
self.state
.lock()
.unwrap_or_else(PoisonError::into_inner)
.version
}
fn page(&self) -> String {
let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
let mut status = String::new();
match (&state.error, &state.chart) {
(None, Some(_)) => {
let _ = write!(
status,
"<p class=\"status ok\" role=\"status\">Rendered, {} warning(s).</p>",
state.warnings.len()
);
}
(None, None) => status.push_str("<p class=\"status\" role=\"status\">Rendering…</p>"),
(Some(error), chart) => {
let _ = write!(
status,
"<p class=\"status error\" role=\"alert\"><strong>Error:</strong> {}<br>{}</p>",
escape(error),
if chart.is_some() {
"Stale: the chart below is the last output that rendered."
} else {
"Nothing has rendered yet."
}
);
}
}
for warning in &state.warnings {
let _ = write!(status, "<p class=\"warning\">{}</p>", escape(warning));
}
format!(
"<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
<title>{name} – chartlet preview</title>
<style>
body {{ margin: 0; padding: 16px; font: 15px/1.45 system-ui, sans-serif; }}
.status, .warning {{ margin: 0 0 8px; padding: 6px 10px; border-radius: 4px; background: #e4e4e7; color: #18181b; }}
.ok {{ background: #dcfce7; }}
.error {{ background: #fee2e2; }}
.warning {{ background: #fef9c3; font-family: ui-monospace, monospace; font-size: 13px; }}
main {{ margin-top: 16px; }}
main.stale {{ opacity: .4; outline: 3px dashed #dc2626; outline-offset: 4px; }}
</style>
{stylesheet}</head>
<body>
<header>{status}</header>
<main{stale}>{chart}</main>
<script>
setInterval(async () => {{
try {{
const response = await fetch('/version', {{ cache: 'no-store' }});
if ((await response.text()) !== '{version}') location.reload();
}} catch {{}}
}}, 500);
</script>
</body>
</html>
",
name = escape(&self.name),
stylesheet = self
.stylesheet
.as_ref()
.map_or_else(String::new, |css| format!("<style>{css}</style>\n")),
stale = if state.error.is_some() && state.chart.is_some() {
" class=\"stale\""
} else {
""
},
chart = state.chart.as_deref().unwrap_or_default(),
version = state.version,
)
}
}
fn escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}