#![feature(default_field_values)]
use std::{
ffi::OsStr,
hash::{BuildHasher, RandomState},
path::PathBuf,
};
use clap::{Args, Parser, Subcommand, ValueEnum};
use color_eyre::eyre::{Result, WrapErr, bail, eyre};
use tiny_http::{Header, Method, Response, Server};
#[derive(Parser)]
#[command(author, version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("GIT_HASH"), ")"), about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Build {
#[command(flatten)]
script: Script,
#[arg(short)]
o: Option<PathBuf>,
#[arg(long)]
open: bool,
},
Watch {
#[command(flatten)]
script: Script,
},
}
#[derive(Args)]
struct Script {
md: PathBuf,
#[arg(short, long, value_enum, default_value_t = Direction::Vertical)]
direction: Direction,
}
impl Script {
fn title(&self) -> Result<String> {
Ok(self.md.file_stem().ok_or_else(|| eyre!("{} has no file name", self.md.display()))?.to_string_lossy().into_owned())
}
fn read(&self) -> Result<String> {
std::fs::read_to_string(&self.md).wrap_err_with(|| format!("reading {}", self.md.display()))
}
}
fn main() -> Result<()> {
color_eyre::install()?;
match Cli::parse().command {
Command::Build { script, o, open } => {
let title = script.title()?;
let out = match o {
Some(o) => o,
None => {
let dir = std::env::temp_dir().join("call_scripters");
std::fs::create_dir_all(&dir).wrap_err_with(|| format!("creating {}", dir.display()))?;
dir.join(format!("{title}.html"))
}
};
std::fs::write(&out, bundle(&title, script.direction, &script.read()?, None)?).wrap_err_with(|| format!("writing {}", out.display()))?;
if open {
xdg_open(out.as_os_str())?;
}
Ok(())
}
Command::Watch { script } => watch(&script),
}
}
fn xdg_open(target: &OsStr) -> Result<()> {
let status = std::process::Command::new("xdg-open").arg(target).status().wrap_err("running xdg-open")?;
if !status.success() {
bail!("xdg-open {} exited with {status}", target.display());
}
Ok(())
}
fn watch(script: &Script) -> Result<()> {
let title = script.title()?;
let hasher = RandomState::new();
let version = |src: &str| format!("{:x}", hasher.hash_one(src));
let build = |src: &str| -> Result<(String, String)> {
let v = version(src);
let html = bundle(&title, script.direction, src, Some(&v))?;
Ok((v, html))
};
let mut served = build(&script.read()?)?;
let mut rejected = None; let mut errors = Taper { last: None, repeats: 0 };
let server = Server::http("127.0.0.1:0").map_err(|e| eyre!(e))?;
let addr = server.server_addr().to_ip().expect("bound to an ip address");
let host = addr.to_string();
let url = format!("http://{addr}/");
xdg_open(OsStr::new(&url))?;
eprintln!("serving {} at {url}", script.md.display());
loop {
let mut req = server.recv().wrap_err("receiving a request")?;
let disk = match script.read() {
Ok(src) => {
errors.last = None;
let v = version(&src);
if v != served.0 && rejected.as_ref() != Some(&v) {
match build(&src) {
Ok(b) => {
served = b;
rejected = None;
}
Err(e) => {
errors.report(format!("{e:?}"));
rejected = Some(v.clone());
}
}
}
Some(v)
}
Err(e) => {
errors.report(format!("{e:?}")); None
}
};
let header = |name: &'static str| req.headers().iter().find(|h| h.field.equiv(name)).map(|h| h.value.as_str().to_owned());
let if_match = header("If-Match");
let (status, body, content_type) = if header("Host").as_deref() != Some(&host) {
(403, "unexpected Host".to_owned(), "text/plain") } else {
match (req.method().clone(), req.url().to_owned().as_str()) {
(Method::Get, "/") => (200, served.1.clone(), "text/html; charset=utf-8"),
(Method::Get, "/version") => (200, served.0.clone(), "text/plain"),
(Method::Put, "/") if disk.is_none() || if_match != disk => (
409,
format!("{} changed on disk since this page loaded; download a copy with Save to keep these edits", script.md.display()),
"text/plain",
),
(Method::Put, "/") => {
let mut src = String::new();
match req.as_reader().read_to_string(&mut src).wrap_err("reading the request body").and_then(|_| build(&src)) {
Err(e) => (400, format!("{e}"), "text/plain"),
Ok(b) => match std::fs::write(&script.md, &src).wrap_err_with(|| format!("writing {}", script.md.display())) {
Err(e) => (500, format!("{e:?}"), "text/plain"),
Ok(()) => {
served = b;
(200, served.0.clone(), "text/plain")
}
},
}
}
_ => (404, "not found".to_owned(), "text/plain"),
}
};
let response = Response::from_string(body)
.with_status_code(status)
.with_header(Header::from_bytes("Content-Type", content_type).expect("static header is valid"));
if let Err(e) = req.respond(response) {
errors.report(format!("responding: {e}")); }
}
}
struct Taper {
last: Option<String>,
repeats: u32,
}
impl Taper {
fn report(&mut self, msg: String) {
if self.last.as_ref() == Some(&msg) {
self.repeats += 1;
} else {
self.last = Some(msg);
self.repeats = 1;
}
match self.repeats {
1 => eprintln!("{}", self.last.as_ref().expect("set above")),
n if n.is_power_of_two() => eprintln!("^ repeated {n} times"),
_ => {}
}
}
}
#[derive(Clone, Copy, ValueEnum)]
enum Direction {
Horizontal,
Vertical,
}
fn bundle(title: &str, direction: Direction, md: &str, live: Option<&str>) -> Result<String> {
let lower = md.to_ascii_lowercase();
if lower.contains("<script") || lower.contains("</script") {
bail!("markdown contains a <script> tag; it would break out of the embedding <script> block");
}
let title = title.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """);
let live = live.map(|v| format!(" data-live=\"{v}\"")).unwrap_or_default();
let icon = include_str!("../web/icon.svg").replace('"', "'").replace('#', "%23"); let parts = [
("@TITLE@", title.as_str()),
("@ICON@", icon.as_str()),
("@REPO@", env!("CARGO_PKG_REPOSITORY")),
("@LIVE@", live.as_str()),
(
"@RANKDIR@",
match direction {
Direction::Horizontal => "LR",
Direction::Vertical => "TB",
},
),
("/*@STYLE@*/", include_str!("../web/style.css")),
("@SRC@", md),
("/*@MARKED@*/", include_str!("../web/vendor/marked.min.js")),
("/*@DAGRE@*/", include_str!("../web/vendor/dagre.min.js")),
("/*@MODEL@*/", include_str!("../web/model.js")),
("/*@PLACE@*/", include_str!("../web/place.js")),
("/*@VIEW@*/", include_str!("../web/view.js")),
];
let template = include_str!("../web/template.html");
let mut at: Vec<(usize, &str, &str)> = parts
.iter()
.map(|&(placeholder, content)| {
let found: Vec<usize> = template.match_indices(placeholder).map(|(i, _)| i).collect();
assert_eq!(found.len(), 1, "template must hold `{placeholder}` exactly once");
(found[0], placeholder, content)
})
.collect();
at.sort_by_key(|&(i, ..)| i);
let mut html = String::with_capacity(template.len() + parts.iter().map(|(_, c)| c.len()).sum::<usize>());
let mut cursor = 0;
for (i, placeholder, content) in at {
assert!(!content.to_ascii_lowercase().contains("</script"), "`{placeholder}` content would close its <script>");
html.push_str(&template[cursor..i]);
html.push_str(content);
cursor = i + placeholder.len();
}
html.push_str(&template[cursor..]);
Ok(html)
}