mod addresses;
mod check;
mod comments;
mod designs;
mod diagrams;
mod document;
mod highlight;
mod icons;
mod index;
mod nav;
mod navbar;
mod pdf;
mod render;
mod search;
mod server;
mod slide_png;
mod validators;
mod video;
mod watch;
mod widgets;
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 tokio::sync::broadcast;
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>,
},
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, true).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::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.clone());
let (changes, _) = broadcast::channel(16);
check::announce_on(changes.clone());
let _watcher = watch::start(
entry.clone(),
index.clone(),
designs.clone(),
changes.clone(),
)?;
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 comments = if cli.no_comments {
None
} else if cli.host.is_loopback() {
Some(server::Identity::Local(comments::local_author(
&document.served,
)))
} else {
Some(server::Identity::Header(cli.user_header))
};
let shots = slide_png::Shots::new(&changes);
let router = server::router_with_index(entry, index, designs, changes, comments, shots.clone());
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);
}
}