use axum::Router;
use axum::routing::{get, post};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::broadcast;
mod assets;
mod build;
mod config;
mod dashboard;
mod html;
mod pages;
mod sections;
pub(crate) struct AppState {
pub root: PathBuf,
pub output_dir: PathBuf,
pub sandbox: Option<PathBuf>,
pub reload_tx: broadcast::Sender<()>,
}
impl AppState {
fn site_title(&self) -> String {
let config_path = self.root.join("config.toml");
if let Ok(content) = std::fs::read_to_string(&config_path) {
if let Ok(config) = toml::from_str::<toml::Value>(&content) {
if let Some(title) = config.get("title").and_then(|v| v.as_str()) {
return title.to_string();
}
}
}
"Zorto Site".to_string()
}
}
pub fn run_webapp(root: &Path, output_dir: &Path, sandbox: Option<&Path>) -> anyhow::Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let port: u16 = 1112;
let (reload_tx, _) = broadcast::channel::<()>(16);
let state = Arc::new(AppState {
root: root.to_path_buf(),
output_dir: output_dir.to_path_buf(),
sandbox: sandbox.map(|p| p.to_path_buf()),
reload_tx,
});
let app = Router::new()
.route("/", get(dashboard::index))
.route("/pages", get(pages::list))
.route("/pages/new", get(pages::new_form).post(pages::create))
.route("/pages/{*path}", get(pages::edit).post(pages::save))
.route("/pages/delete/{*path}", post(pages::delete))
.route("/sections", get(sections::list))
.route(
"/sections/{*path}",
get(sections::edit).post(sections::save),
)
.route("/config", get(config::edit).post(config::save))
.route("/assets", get(assets::list))
.route("/assets/upload", post(assets::upload))
.route("/build", post(build::trigger))
.route("/preview/render", post(build::render_preview))
.with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l,
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
eprintln!("Port {port} is in use, using a random available port...");
let fallback = SocketAddr::from(([127, 0, 0, 1], 0));
tokio::net::TcpListener::bind(fallback).await?
}
Err(e) => return Err(e.into()),
};
let actual_addr = listener.local_addr()?;
println!("zorto webapp: http://localhost:{}", actual_addr.port());
let _ = open::that(format!("http://localhost:{}", actual_addr.port()));
axum::serve(listener, app)
.with_graceful_shutdown(async {
tokio::signal::ctrl_c().await.ok();
println!("\nshutting down...");
})
.await?;
Ok(())
})
}
pub(crate) fn rebuild_site(state: &AppState) -> Result<(), String> {
match zorto_core::site::Site::load(&state.root, &state.output_dir, true) {
Ok(mut site) => {
site.sandbox = state.sandbox.clone();
site.build().map_err(|e| e.to_string())?;
let _ = state.reload_tx.send(());
Ok(())
}
Err(e) => Err(e.to_string()),
}
}
pub(crate) fn escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}