mod adapters;
mod drive;
use adapters::{changes, http, terminal, watch};
use devpage_core::{
addresses, check, comments, designs, diagrams, document, index, navbar, problem, render,
resolve, served, widgets,
};
use drive::{pdf, server, slide_png, validators, video};
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use tokio::net::TcpListener;
use crate::document::{Document, Entry};
use crate::index::Index;
const FIRST_PORT: u16 = 3030;
#[derive(Parser)]
#[command(version, about)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
path: Option<PathBuf>,
#[arg(long, global = true)]
root: Option<PathBuf>,
#[arg(long, default_value = "127.0.0.1")]
host: IpAddr,
#[arg(long)]
port: Option<u16>,
#[arg(long, default_value = "X-Forwarded-Email")]
user_header: String,
#[arg(long)]
no_comments: bool,
}
#[derive(Subcommand)]
enum Command {
Check { path: Option<PathBuf> },
Pdf {
path: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
},
Server,
#[command(flatten)]
Remote(drive::client::Command),
Video {
path: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let mut cli = Cli::parse();
diagrams::start();
let served = cli.root.clone().unwrap_or_else(|| PathBuf::from("."));
if let Some(Command::Check { path }) = &cli.command {
let document = Document::load(
path.as_deref().unwrap_or(std::path::Path::new(".")),
&served,
)?;
let problems = check::document(&document, Some(&http::external(None))).await;
for problem in &problems {
println!("{problem}");
}
std::process::exit(i32::from(!problems.is_empty()));
}
match cli.command.take() {
Some(Command::Pdf { path, output }) => {
let document = Arc::new(Document::load(&path, &served)?);
let output = output.unwrap_or_else(|| pdf::default_output(&document.title));
pdf::print(document, &output).await?;
println!("{}", output.display());
return Ok(());
}
Some(Command::Video { path, output }) => {
let config = video::live::Config::from_env()?;
let document = Arc::new(Document::load(&path, &served)?);
let output = output.unwrap_or_else(|| video::default_output(&document.title));
tokio::select! {
recorded = video::record(document, &output, &config) => recorded?,
signal = interrupted() => anyhow::bail!("stopped by {signal}"),
}
println!("{}", output.display());
return Ok(());
}
Some(Command::Server) => {
let config = drive::server_mode::Config::from_env()?;
let address = SocketAddr::new(config.host, config.port);
let app = drive::server_mode::router(&config)?;
let listener = TcpListener::bind(address).await?;
println!("devpage server on http://{}", listener.local_addr()?);
axum::serve(listener, app).await?;
return Ok(());
}
Some(Command::Remote(command)) => {
let home = drive::client::contexts::Home::from_env()?;
let context = std::env::var("DEVPAGE_CONTEXT").ok();
let output = drive::client::run(command, &home, &served, context).await?;
print!("{}", output.text);
std::process::exit(output.code);
}
Some(Command::Check { .. }) | None => {}
}
let path = cli.path.unwrap_or_else(|| PathBuf::from("."));
let document = Arc::new(Document::load(&path, &served)?);
let index = Arc::new(Index::build(&document.served).with_search(&document.path()));
let entry = Arc::new(Entry::new(document.clone()));
let designs = designs::Designs::new(&document.served);
let changes = changes::Changes::default();
let preview = Arc::new(check::Preview::new(
http::external(Some(changes.clone())),
Arc::new(terminal::Terminal),
));
let comments = if cli.no_comments {
None
} else if cli.host.is_loopback() {
Some(server::Identity::Local(comments::local_author(
document.served.path(),
)))
} else {
Some(server::Identity::Header(cli.user_header))
};
let shots = slide_png::Shots::new(&changes);
let threads = Arc::new(comments::yaml::Yaml::new(&document.served));
let options = server::Options {
entry,
index,
designs,
changes,
comments,
store: threads.clone(),
threads,
shots: shots.clone(),
preview,
log: true,
};
let _watcher = options.watch()?;
let listener = match cli.port {
Some(port) => TcpListener::bind(SocketAddr::from((cli.host, port)))
.await
.with_context(|| format!("binding {}:{port}", cli.host))?,
None => bind(cli.host, FIRST_PORT).await?,
};
let port = listener.local_addr()?.port();
let shown = if cli.host.is_loopback() {
"localhost".to_string()
} else {
cli.host.to_string()
};
println!("{} → http://{shown}:{port}", document.title);
let router = server::router(options);
tokio::select! {
served = axum::serve(listener, router) => served?,
_ = interrupted() => {}
}
shots.close().await;
Ok(())
}
async fn interrupted() -> &'static str {
use tokio::signal::unix::{SignalKind, signal};
let (Ok(mut term), Ok(mut hangup)) = (
signal(SignalKind::terminate()),
signal(SignalKind::hangup()),
) else {
tokio::signal::ctrl_c().await.ok();
return "Ctrl-C";
};
tokio::select! {
_ = tokio::signal::ctrl_c() => "Ctrl-C",
_ = term.recv() => "SIGTERM",
_ = hangup.recv() => "SIGHUP",
}
}
async fn bind(host: IpAddr, first: u16) -> Result<TcpListener> {
for port in first..=u16::MAX {
if let Ok(listener) = TcpListener::bind(SocketAddr::from((host, port))).await {
return Ok(listener);
}
}
None.context("no free port")
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn takes_the_next_port_when_the_first_is_busy() {
let host = IpAddr::from([127, 0, 0, 1]);
let busy = bind(host, 41000).await.unwrap();
let taken = busy.local_addr().unwrap().port();
let next = bind(host, taken).await.unwrap();
assert!(next.local_addr().unwrap().port() > taken);
}
}