use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use http_handle::Server;
use crate::cmd;
use crate::Paths;
pub trait ServeTransport {
fn start(&self, addr: &str, root: &str) -> Result<()>;
}
#[derive(Debug, Clone, Copy)]
pub struct HttpTransport;
impl ServeTransport for HttpTransport {
fn start(&self, addr: &str, root: &str) -> Result<()> {
let server = Server::new(addr, root);
let _ = server.start();
Ok(())
}
}
pub(crate) fn build_serve_address(site_dir: &Path) -> Result<(String, String)> {
let root = site_dir
.to_str()
.ok_or_else(|| {
anyhow!(
"Site directory path contains invalid UTF-8: {}",
site_dir.display()
)
})?
.to_string();
let addr = format!("{}:{}", cmd::DEFAULT_HOST, cmd::DEFAULT_PORT);
Ok((addr, root))
}
pub fn serve_site_with<T: ServeTransport>(
site_dir: &Path,
transport: &T,
) -> Result<()> {
let (addr, root) = build_serve_address(site_dir)?;
transport.start(&addr, &root)
}
pub fn serve_site(site_dir: &Path) -> Result<()> {
serve_site_with(site_dir, &HttpTransport)
}
pub fn handle_server(
log_file: &mut fs::File,
date: &str,
paths: &Paths,
serve_dir: &PathBuf,
) -> Result<()> {
writeln!(log_file, "[{date}] INFO process: Server initialization")?;
prepare_serve_dir(paths, serve_dir)?;
let host = cmd::resolve_host();
let port = cmd::resolve_port();
let addr = format!("{host}:{port}");
println!("\nStarting server at http://{addr}");
println!("Serving content from: {}", serve_dir.display());
let dir = serve_dir
.to_str()
.ok_or_else(|| anyhow::anyhow!("serve dir contains invalid UTF-8"))?
.to_string();
let bind = addr;
let server = Server::new(&bind, &dir);
let _ = server.start();
Ok(())
}
pub fn generate_locale_redirect(
site_dir: &Path,
available_locales: &[String],
default_locale: &str,
) -> Result<()> {
let index_path = site_dir.join("index.html");
if index_path.exists() {
let existing = fs::read_to_string(&index_path).unwrap_or_default();
if !existing.contains("<!-- ssg-locale-redirect -->") {
return Ok(());
}
}
let locales_js: Vec<String> = available_locales
.iter()
.map(|l| format!("\"{l}\""))
.collect();
let locales_array = locales_js.join(",");
let default_url = format!("/{default_locale}/");
let html = format!(
r#"<!DOCTYPE html>
<!-- ssg-locale-redirect -->
<html>
<head>
<meta charset="utf-8">
<script>
(function() {{
var locales = [{locales_array}];
var defaultLocale = "{default_locale}";
var langs = navigator.languages || [navigator.language || defaultLocale];
for (var i = 0; i < langs.length; i++) {{
var lang = langs[i].toLowerCase();
for (var j = 0; j < locales.length; j++) {{
if (lang === locales[j] || lang.startsWith(locales[j] + "-")) {{
window.location.replace("/" + locales[j] + "/");
return;
}}
}}
var prefix = lang.split("-")[0];
for (var j = 0; j < locales.length; j++) {{
if (prefix === locales[j]) {{
window.location.replace("/" + locales[j] + "/");
return;
}}
}}
}}
window.location.replace("/" + defaultLocale + "/");
}})();
</script>
<noscript>
<meta http-equiv="refresh" content="0; url={default_url}">
</noscript>
</head>
<body></body>
</html>
"#
);
fs::write(&index_path, &html)
.with_context(|| format!("Failed to write {}", index_path.display()))?;
println!(
"[i18n] Generated locale redirect at {}",
index_path.display()
);
Ok(())
}
pub fn prepare_serve_dir(paths: &Paths, serve_dir: &PathBuf) -> Result<()> {
fs::create_dir_all(serve_dir)
.context("Failed to create serve directory")?;
println!("Setting up server...");
println!("Source: {}", paths.site.display());
println!("Serving from: {}", serve_dir.display());
if serve_dir != &paths.site {
crate::fs_ops::verify_and_copy_files_async(&paths.site, serve_dir)?;
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
use tempfile::tempdir;
#[derive(Default)]
struct RecordingTransport {
calls: Arc<Mutex<Vec<(String, String)>>>,
fail: bool,
}
impl ServeTransport for RecordingTransport {
fn start(&self, addr: &str, root: &str) -> Result<()> {
self.calls
.lock()
.unwrap()
.push((addr.to_string(), root.to_string()));
if self.fail {
Err(anyhow!("synthetic transport failure"))
} else {
Ok(())
}
}
}
#[test]
fn build_serve_address_formats_addr_and_returns_root() {
let dir = tempdir().unwrap();
let (addr, root) = build_serve_address(dir.path()).unwrap();
assert!(
addr.contains(cmd::DEFAULT_HOST),
"addr should contain default host: {addr}"
);
assert!(
addr.contains(&cmd::DEFAULT_PORT.to_string()),
"addr should contain default port: {addr}"
);
assert_eq!(root, dir.path().to_str().unwrap());
}
#[test]
fn serve_site_with_invokes_transport_with_resolved_address() {
let dir = tempdir().unwrap();
let transport = RecordingTransport::default();
let calls = transport.calls.clone();
serve_site_with(dir.path(), &transport).unwrap();
let recorded = calls.lock().unwrap().clone();
assert_eq!(recorded.len(), 1);
let (addr, root) = &recorded[0];
assert!(addr.contains(cmd::DEFAULT_HOST));
assert_eq!(root, dir.path().to_str().unwrap());
}
#[test]
fn serve_site_with_propagates_transport_errors() {
let dir = tempdir().unwrap();
let transport = RecordingTransport {
calls: Default::default(),
fail: true,
};
let err = serve_site_with(dir.path(), &transport).unwrap_err();
assert!(
err.to_string().contains("synthetic transport failure"),
"transport error should bubble up, got: {err}"
);
}
#[test]
fn http_transport_implements_serve_transport() {
let _t: &dyn ServeTransport = &HttpTransport;
}
#[test]
fn generate_locale_redirect_creates_index_with_marker() {
let dir = tempdir().unwrap();
generate_locale_redirect(
dir.path(),
&["en".to_string(), "fr".to_string(), "de".to_string()],
"en",
)
.unwrap();
let index = dir.path().join("index.html");
assert!(index.exists(), "index.html should be written");
let html = fs::read_to_string(&index).unwrap();
assert!(html.contains("<!-- ssg-locale-redirect -->"));
assert!(html.contains("\"en\""));
assert!(html.contains("\"fr\""));
assert!(html.contains("\"de\""));
assert!(html.contains("/en/")); }
#[test]
fn generate_locale_redirect_overwrites_own_marker() {
let dir = tempdir().unwrap();
generate_locale_redirect(dir.path(), &["en".to_string()], "en")
.unwrap();
let first = fs::read_to_string(dir.path().join("index.html")).unwrap();
generate_locale_redirect(
dir.path(),
&["en".to_string(), "fr".to_string()],
"en",
)
.unwrap();
let second = fs::read_to_string(dir.path().join("index.html")).unwrap();
assert_ne!(first, second);
assert!(second.contains("\"fr\""));
}
#[test]
fn generate_locale_redirect_preserves_user_index_html() {
let dir = tempdir().unwrap();
let user_html = "<html><body>my hand-written page</body></html>";
fs::write(dir.path().join("index.html"), user_html).unwrap();
generate_locale_redirect(dir.path(), &["en".to_string()], "en")
.unwrap();
let after = fs::read_to_string(dir.path().join("index.html")).unwrap();
assert_eq!(
after, user_html,
"user-authored index.html must not be overwritten"
);
}
#[test]
fn prepare_serve_dir_creates_dir_when_missing() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
fs::write(site.join("a.html"), "x").unwrap();
let serve = dir.path().join("serve-out");
let paths = Paths {
site: site.clone(),
content: dir.path().join("content"),
build: dir.path().join("build"),
template: dir.path().join("templates"),
};
prepare_serve_dir(&paths, &serve).unwrap();
assert!(serve.exists(), "serve dir should be created");
assert!(
serve.join("a.html").exists(),
"files should be copied from site to serve dir"
);
}
#[test]
fn prepare_serve_dir_skips_copy_when_serve_equals_site() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
fs::write(site.join("a.html"), "x").unwrap();
let paths = Paths {
site: site.clone(),
content: dir.path().join("content"),
build: dir.path().join("build"),
template: dir.path().join("templates"),
};
prepare_serve_dir(&paths, &site).unwrap();
assert!(site.join("a.html").exists());
}
#[test]
fn build_serve_address_contains_host_and_port() {
let dir = tempdir().unwrap();
let (addr, root) = build_serve_address(dir.path()).unwrap();
assert_eq!(
addr,
format!("{}:{}", cmd::DEFAULT_HOST, cmd::DEFAULT_PORT)
);
assert_eq!(root, dir.path().to_str().unwrap());
}
#[test]
fn serve_site_with_records_correct_root() {
let dir = tempdir().unwrap();
let sub = dir.path().join("deep").join("nested");
fs::create_dir_all(&sub).unwrap();
let transport = RecordingTransport::default();
let calls = transport.calls.clone();
serve_site_with(&sub, &transport).unwrap();
let recorded = calls.lock().unwrap();
assert_eq!(recorded[0].1, sub.to_str().unwrap());
}
#[test]
fn generate_locale_redirect_single_locale() {
let dir = tempdir().unwrap();
generate_locale_redirect(dir.path(), &["es".to_string()], "es")
.unwrap();
let html = fs::read_to_string(dir.path().join("index.html")).unwrap();
assert!(html.contains("\"es\""));
assert!(html.contains("/es/"));
assert!(html.contains("<!-- ssg-locale-redirect -->"));
}
}