mod bundle;
mod deploy;
mod prerender;
mod release;
mod wizard;
use anyhow::Result;
use clap::{Parser, Subcommand};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use std::path::PathBuf;
use std::time::Duration;
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
use wwwhat_core::{Config, server};
#[derive(Parser)]
#[command(name = "run-what")]
#[command(author = "Tedigo")]
#[command(version = env!("CARGO_PKG_VERSION"))]
#[command(about = "What - Build modern web apps with just HTML", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Dev {
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(short = 'P', long)]
port: Option<u16>,
#[arg(short = 'H', long)]
host: Option<String>,
#[arg(short, long, default_value = "true")]
watch: bool,
#[arg(short, long)]
verbose: bool,
#[arg(long, default_value = "info")]
log_level: String,
#[arg(long)]
strict: bool,
#[arg(short, long)]
open: bool,
#[arg(long)]
production: bool,
},
Build {
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(short, long, default_value = "dist")]
output: PathBuf,
#[arg(long)]
no_minify: bool,
#[arg(long)]
hash_assets: bool,
#[arg(short, long)]
verbose: bool,
#[arg(long, default_value = "info")]
log_level: String,
},
Deploy {
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(short, long)]
target: Option<String>,
#[arg(long)]
host: Option<String>,
#[arg(long)]
user: Option<String>,
#[arg(long)]
remote_dir: Option<String>,
#[arg(long)]
image: Option<String>,
#[arg(long)]
registry: Option<String>,
#[arg(short, long, default_value = "dist")]
output: PathBuf,
#[arg(long)]
yes: bool,
#[arg(long)]
dry_run: bool,
},
New {
name: String,
#[arg(short, long)]
template: Option<String>,
#[arg(long)]
no_interactive: bool,
},
Sitemap {
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(long)]
host: Option<String>,
#[arg(short, long, default_value = "sitemap.xml")]
output: String,
},
Test {
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(long, default_value = "tests")]
test_dir: String,
},
Doctor {
#[arg(short, long, default_value = ".")]
path: PathBuf,
},
Bundle {
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(short, long, default_value = "bundle")]
output: PathBuf,
#[arg(long)]
compile: bool,
},
#[command(hide = true)]
Release {
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(value_enum)]
bump: release::VersionBump,
},
#[command(subcommand)]
Generate(GenerateCommands),
}
#[derive(Subcommand)]
enum GenerateCommands {
Page {
name: String,
},
Component {
name: String,
#[arg(short, long)]
props: Option<String>,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let log_level = resolve_log_level(&cli.command);
let log_format = std::env::var("WHAT_LOG_FORMAT").unwrap_or_default();
if log_format.eq_ignore_ascii_case("json") {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::new(&log_level))
.with_target(false)
.with_thread_ids(false)
.json()
.init();
} else {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::new(&log_level))
.with_target(false)
.with_thread_ids(false)
.compact()
.init();
}
match cli.command {
Commands::Dev {
path,
port,
host,
watch,
strict,
open,
production,
..
} => {
run_dev_server(path, port, host, watch, strict, open, production).await?;
}
Commands::Build {
path,
output,
no_minify,
hash_assets,
..
} => {
run_build(path, output, !no_minify, hash_assets).await?;
}
Commands::Deploy {
path,
target,
host,
user,
remote_dir,
image,
registry,
output,
yes,
dry_run,
} => {
run_deploy(
path, target, host, user, remote_dir, image, registry, output, yes, dry_run,
)
.await?;
}
Commands::New {
name,
template,
no_interactive,
} => {
create_project(&name, template.as_deref(), no_interactive)?;
}
Commands::Sitemap { path, host, output } => {
run_sitemap(path, host, &output)?;
}
Commands::Test { path, test_dir } => {
let code = run_tests(path, &test_dir).await?;
if code != 0 {
std::process::exit(code);
}
}
Commands::Doctor { path } => {
run_doctor(path)?;
}
Commands::Bundle {
path,
output,
compile,
} => {
bundle::create_bundle(&path, &output, compile)?;
}
Commands::Release { path, bump } => {
let next = release::bump_repo_version(&path, bump)?;
println!();
println!(" Release version updated to {}", next);
println!(" Website badges remain on the experimental channel");
println!();
}
Commands::Generate(cmd) => match cmd {
GenerateCommands::Page { name } => {
generate_page(&name)?;
}
GenerateCommands::Component { name, props } => {
generate_component(&name, props.as_deref())?;
}
},
}
Ok(())
}
fn resolve_log_level(command: &Commands) -> String {
if let Ok(env_level) = std::env::var("WHAT_LOG") {
let level = env_level.to_lowercase();
if matches!(
level.as_str(),
"error" | "warn" | "info" | "debug" | "trace"
) {
return level;
}
eprintln!(
"Warning: invalid WHAT_LOG value '{}', using default",
env_level
);
}
match command {
Commands::Dev {
verbose, log_level, ..
}
| Commands::Build {
verbose, log_level, ..
} => {
if *verbose {
"debug".to_string()
} else {
log_level.to_lowercase()
}
}
_ => "info".to_string(),
}
}
async fn run_dev_server(
path: PathBuf,
port: Option<u16>,
host: Option<String>,
watch: bool,
strict: bool,
open: bool,
production: bool,
) -> Result<()> {
let project_path = std::fs::canonicalize(&path)?;
info!("Starting What development server...");
info!("Project: {}", project_path.display());
let config_path = project_path.join("wwwhat.toml");
let mut config = if config_path.exists() {
Config::load(&config_path)?
} else {
Config::default()
};
if let Some(p) = port {
config.server.port = p;
}
if let Some(h) = host {
config.server.host = h;
}
if strict {
config.strict = true;
}
let dev_mode = !production;
let state = server::AppState::with_dev_mode(config, project_path.clone(), dev_mode)?;
if let Err(e) = state.init_datasources().await {
eprintln!("Failed to initialize datasources: {}", e);
std::process::exit(1);
}
if watch && dev_mode {
let watch_path = project_path.clone();
let state_clone = state.clone();
tokio::spawn(async move {
if let Err(e) = watch_files(watch_path, state_clone).await {
warn!("File watcher error: {}", e);
}
});
}
let addr = format!("{}:{}", state.config.server.host, state.config.server.port);
let listener = match tokio::net::TcpListener::bind(&addr).await {
Ok(listener) => listener,
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
anyhow::bail!(
"Port {} is already in use — is another server running?\n Try: run-what dev --port {}",
state.config.server.port,
state.config.server.port + 1
);
}
Err(e) => return Err(e.into()),
};
let content_dir = wwwhat_core::server::content_dir_name(&project_path);
let routes = server::discover_routes(&project_path);
println!();
println!(" What server running!");
println!();
println!(
" Local: http://{}:{}",
state.config.server.host, state.config.server.port
);
println!(" Pages: {} ({}/)", routes.len(), content_dir);
println!();
if routes.is_empty() {
println!(" \x1b[33mWarning: no pages found in {}/\x1b[0m", content_dir);
println!(" Pages are HTML files in {}/ — every route will 404 until one exists.", content_dir);
println!(" Not a What project yet? Create one with: run-what new my-site");
println!();
}
if watch && dev_mode {
println!(" Live reload enabled - changes will auto-refresh browsers");
}
if production {
println!(" Production mode - live reload and debug tools disabled");
}
println!(" Press Ctrl+C to stop");
println!();
if open {
let url = format!(
"http://{}:{}",
state.config.server.host, state.config.server.port
);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(500)).await;
if let Err(e) = open::that(&url) {
warn!("Could not open browser: {}", e);
}
});
}
server::serve_on_listener(state, listener, shutdown_signal()).await?;
info!("Server shut down gracefully");
Ok(())
}
async fn shutdown_signal() {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
println!();
println!(" Shutting down gracefully...");
}
async fn watch_files(path: PathBuf, state: server::AppState) -> Result<()> {
let (async_tx, mut async_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<PathBuf>>();
let watch_path = path.clone();
tokio::task::spawn_blocking(move || {
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher: RecommendedWatcher =
Watcher::new(tx, notify::Config::default()).expect("Failed to create file watcher");
watcher
.watch(&watch_path, RecursiveMode::Recursive)
.expect("Failed to watch directory");
loop {
match rx.recv() {
Ok(Ok(event)) => {
let relevant: Vec<PathBuf> = event
.paths
.into_iter()
.filter(|p| {
let path_str = p.to_string_lossy();
if path_str.contains("/.") || path_str.contains("\\.") {
return false;
}
if path_str.ends_with(".db") || path_str.ends_with(".db-journal") {
return false;
}
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
matches!(ext, "html" | "css" | "js" | "json" | "toml" | "what")
})
.collect();
if !relevant.is_empty() {
let _ = async_tx.send(relevant);
}
}
Ok(Err(e)) => warn!("Watch error: {}", e),
Err(_) => break,
}
}
});
let debounce = Duration::from_millis(300);
let mut pending = false;
loop {
if pending {
match tokio::time::timeout(debounce, async_rx.recv()).await {
Ok(Some(paths)) => {
info!("File changed: {:?}", paths);
continue;
}
Ok(None) => break, Err(_) => {
pending = false;
state.trigger_reload_async().await;
}
}
} else {
match async_rx.recv().await {
Some(paths) => {
info!("File changed: {:?}", paths);
pending = true;
}
None => break, }
}
}
Ok(())
}
async fn run_build(path: PathBuf, output: PathBuf, minify: bool, hash_assets: bool) -> Result<()> {
println!();
println!(" Building project for production...");
println!(" Source: {}", path.display());
println!(" Output: {}", output.display());
if minify {
println!(" Minification: enabled");
}
if hash_assets {
println!(" Asset hashing: enabled");
}
println!();
let result = prerender::prerender(prerender::PreRenderConfig {
project_path: path,
output_path: output.clone(),
minify,
})
.await?;
if hash_assets {
let hashed = prerender::hash_static_assets(&output)?;
if !hashed.is_empty() {
println!(" Hashed {} static assets", hashed.len());
}
}
println!();
println!(" Build complete!");
println!(" Pages rendered: {}", result.pages_rendered);
println!(" Total size: {}", format_bytes(result.total_bytes));
if !result.pages_skipped.is_empty() {
println!(" Skipped:");
for page in &result.pages_skipped {
println!(" - {}", page);
}
}
println!();
Ok(())
}
pub(crate) fn format_bytes(bytes: usize) -> String {
if bytes >= 1_048_576 {
format!("{:.1} MB", bytes as f64 / 1_048_576.0)
} else if bytes >= 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{} B", bytes)
}
}
async fn run_deploy(
path: PathBuf,
target: Option<String>,
host: Option<String>,
user: Option<String>,
remote_dir: Option<String>,
image: Option<String>,
registry: Option<String>,
output: PathBuf,
yes: bool,
dry_run: bool,
) -> Result<()> {
let project_path = std::fs::canonicalize(&path)?;
let target = match target {
Some(t) => t,
None => deploy::choose_deploy_target()?,
};
match target.as_str() {
"ssh" => {
deploy::deploy_ssh(
&project_path,
host.as_deref(),
user.as_deref(),
remote_dir.as_deref(),
dry_run,
yes,
)
.await?;
}
"docker" => {
deploy::deploy_docker(
&project_path,
image.as_deref(),
registry.as_deref(),
dry_run,
yes,
)
.await?;
}
"static" => {
deploy::deploy_static(&project_path, &output, dry_run).await?;
}
_ => {
anyhow::bail!(
"Unknown deploy target: '{}'. Use ssh, docker, or static.",
target
);
}
}
Ok(())
}
fn run_sitemap(path: PathBuf, host: Option<String>, output: &str) -> Result<()> {
let project_path = std::fs::canonicalize(&path)?;
let base_url = host.unwrap_or_else(|| "https://example.com".to_string());
let base_url = base_url.trim_end_matches('/');
let sitemap = prerender::generate_sitemap(&project_path, base_url);
let output_path = project_path.join(output);
std::fs::write(&output_path, &sitemap)?;
println!();
println!(" Sitemap generated: {}", output_path.display());
println!(" Base URL: {}", base_url);
println!();
Ok(())
}
fn create_project(name: &str, template: Option<&str>, no_interactive: bool) -> Result<()> {
info!("Creating new What project: {}", name);
let project_dir = PathBuf::from(name);
if project_dir.exists() {
if no_interactive {
anyhow::bail!("Directory '{}' already exists", name);
}
if !wizard::confirm_overwrite(name)? {
anyhow::bail!("Aborted: directory already exists");
}
std::fs::remove_dir_all(&project_dir)?;
}
let choice = match template {
Some("tutorial") | Some("example") | Some("starter") | Some("demo") => {
wizard::TemplateChoice::Tutorial
}
Some("minimum") | Some("blank") | Some("basic") => wizard::TemplateChoice::Minimum,
Some(other) => {
anyhow::bail!("Unknown template: '{}'. Use 'minimum' or 'tutorial'", other)
}
None if no_interactive => wizard::TemplateChoice::Minimum,
None => wizard::choose_template()?,
};
std::fs::create_dir_all(&project_dir)?;
match choice {
wizard::TemplateChoice::Minimum => create_blank_project(&project_dir)?,
wizard::TemplateChoice::Tutorial => create_example_project(&project_dir)?,
}
println!();
println!(" \x1b[32mProject created: {}\x1b[0m", name);
println!();
println!(" To get started:");
println!(" cd {}", name);
println!(" run-what dev");
println!();
println!(" Then open \x1b[36mhttp://127.0.0.1:8085\x1b[0m");
println!();
println!(" Try these:");
println!(" run-what generate page contact \x1b[2m# create a new page\x1b[0m");
println!(" run-what generate component card \x1b[2m# create a component\x1b[0m");
println!(" run-what test \x1b[2m# run template tests\x1b[0m");
println!();
Ok(())
}
fn create_blank_project(project_dir: &PathBuf) -> Result<()> {
let dirs = ["site", "components", "static", "data"];
for dir in dirs {
std::fs::create_dir_all(project_dir.join(dir))?;
}
let config = r#"# What Project Configuration
[server]
port = 8085
host = "127.0.0.1"
[session]
enabled = true
cookie_name = "w_session"
max_age = 604800
database = "data/sessions.db"
"#;
std::fs::write(project_dir.join("wwwhat.toml"), config)?;
let app_what = r#"layout = "components/layout.html"
"#;
std::fs::write(
project_dir.join("site").join("application.what"),
app_what,
)?;
let layout = r##"<what>
layout: none
</what>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/styles.css">
<title>#title|default:"What App"#</title>
</head>
<body>
<slot/>
</body>
</html>
"##;
std::fs::write(
project_dir.join("components").join("layout.html"),
layout,
)?;
let index = r##"<what>
title: "What?"
</what>
<section class="jumbo jumbo-lg">
<div class="jumbo-content">
<h1 class="jumbo-title">What?</h1>
<p class="jumbo-subtitle">Build modern web apps with just HTML.</p>
</div>
</section>
"##;
std::fs::write(project_dir.join("site").join("index.html"), index)?;
let styles_css = r#"/*
* styles.css — Your custom styles
*
* The framework provides what.css with utility classes (layout, typography,
* colors, spacing), components (buttons, cards, forms, modals, tables),
* and dark mode support — all loaded automatically.
*
* Use this file for styles that go beyond the framework.
* See available classes: https://www.getwhatnow.com/docs/css
*/
"#;
std::fs::write(project_dir.join("static").join("styles.css"), styles_css)?;
let app_js = r#"/*
* app.js — Your custom JavaScript
*
* wwwhat handles most interactivity through HTML attributes:
* w-set — update server state on click
* w-get — fetch and swap content without page reload
* w-post — submit data to the server
* w-validate — client-side form validation
*
* You probably don't need this file. Use it only for things HTML
* attributes can't do: third-party libraries, canvas, maps, etc.
*/
"#;
std::fs::write(project_dir.join("static").join("app.js"), app_js)?;
Ok(())
}
fn create_example_project(project_dir: &PathBuf) -> Result<()> {
let dirs = ["site", "site/tutorial", "components", "static", "data"];
for dir in dirs {
std::fs::create_dir_all(project_dir.join(dir))?;
}
std::fs::write(
project_dir.join("wwwhat.toml"),
include_str!("../assets/example/wwwhat.toml"),
)?;
std::fs::write(
project_dir.join("components").join("tutorial-layout.html"),
include_str!("../assets/example/components/tutorial-layout.html"),
)?;
std::fs::write(
project_dir.join("components").join("info-box.html"),
include_str!("../assets/example/components/info-box.html"),
)?;
std::fs::write(
project_dir.join("components").join("stat-card.html"),
include_str!("../assets/example/components/stat-card.html"),
)?;
std::fs::write(
project_dir.join("static").join("styles.css"),
include_str!("../assets/example/static/styles.css"),
)?;
std::fs::write(
project_dir.join("static").join("app.js"),
include_str!("../assets/example/static/app.js"),
)?;
std::fs::write(
project_dir.join("site").join("application.what"),
include_str!("../assets/example/site/application.what"),
)?;
std::fs::write(
project_dir.join("site/tutorial").join("application.what"),
include_str!("../assets/example/site/tutorial/application.what"),
)?;
std::fs::write(
project_dir.join("site").join("index.html"),
include_str!("../assets/example/site/index.html"),
)?;
for i in 1..=10 {
let filename = format!("{}.html", i);
let content = match i {
1 => include_str!("../assets/example/site/tutorial/1.html"),
2 => include_str!("../assets/example/site/tutorial/2.html"),
3 => include_str!("../assets/example/site/tutorial/3.html"),
4 => include_str!("../assets/example/site/tutorial/4.html"),
5 => include_str!("../assets/example/site/tutorial/5.html"),
6 => include_str!("../assets/example/site/tutorial/6.html"),
7 => include_str!("../assets/example/site/tutorial/7.html"),
8 => include_str!("../assets/example/site/tutorial/8.html"),
9 => include_str!("../assets/example/site/tutorial/9.html"),
10 => include_str!("../assets/example/site/tutorial/10.html"),
_ => unreachable!(),
};
std::fs::write(project_dir.join("site/tutorial").join(&filename), content)?;
}
Ok(())
}
fn generate_page(name: &str) -> Result<()> {
let cwd = std::env::current_dir()?;
let content = wwwhat_core::server::content_dir_name(&cwd);
let file_path = PathBuf::from(content).join(format!("{}.html", name));
if file_path.exists() {
anyhow::bail!("Page already exists: {}", file_path.display());
}
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent)?;
}
let title = name.replace('-', " ");
let title = title
.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect::<Vec<_>>()
.join(" ");
std::fs::write(&file_path, generated_page_content(&title))?;
println!(" \x1b[32mCreated\x1b[0m {}", file_path.display());
Ok(())
}
fn generated_page_content(title: &str) -> String {
format!(
r#"<what>
title: "{}"
</what>
<section class="container py-8">
<h1>{}</h1>
<p>Your content here...</p>
</section>
"#,
title, title
)
}
fn generate_component(name: &str, props: Option<&str>) -> Result<()> {
let file_path = PathBuf::from("components").join(format!("{}.html", name));
if file_path.exists() {
anyhow::bail!("Component already exists: {}", file_path.display());
}
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent)?;
}
let props_str = props.unwrap_or("");
let content = if props_str.is_empty() {
format!(
r#"<div class="{}">
<slot/>
</div>
"#,
name
)
} else {
format!(
r#"<what>
props = "{}"
</what>
<div class="{}">
<slot/>
</div>
"#,
props_str, name
)
};
std::fs::write(&file_path, content)?;
println!(
" \x1b[32mCreated\x1b[0m {} \x1b[2m(use as <what-{}>)\x1b[0m",
file_path.display(),
name
);
Ok(())
}
struct TestCase {
name: String,
page: String,
status: Option<u16>,
contains: Vec<String>,
not_contains: Vec<String>,
redirect: Option<String>,
}
fn parse_test_file(path: &std::path::Path) -> Result<TestCase> {
let content = std::fs::read_to_string(path)?;
let name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let mut page = None;
let mut status = None;
let mut contains = Vec::new();
let mut not_contains = Vec::new();
let mut redirect = None;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once(':') {
let key = key.trim();
let value = value.trim().trim_matches('"');
match key {
"page" => page = Some(value.to_string()),
"status" => {
status = Some(
value
.parse()
.map_err(|_| anyhow::anyhow!("Invalid status: {}", value))?,
)
}
"contains" => contains.push(value.to_string()),
"not_contains" => not_contains.push(value.to_string()),
"redirect" => redirect = Some(value.to_string()),
_ => warn!("Unknown test directive '{}' in {}", key, path.display()),
}
}
}
let page = page
.ok_or_else(|| anyhow::anyhow!("Test file {} missing 'page' directive", path.display()))?;
Ok(TestCase {
name,
page,
status,
contains,
not_contains,
redirect,
})
}
async fn run_tests(path: PathBuf, test_dir: &str) -> Result<i32> {
let project_path = std::fs::canonicalize(&path)?;
let tests_path = project_path.join(test_dir);
if !tests_path.exists() {
println!();
println!(" No tests yet — create a {}/ directory with .what test files.", test_dir);
println!();
println!(" Example ({}/home.what):", test_dir);
println!(" page: /");
println!(" status: 200");
println!(" contains: \"<h1>\"");
println!();
return Ok(0);
}
let mut test_files: Vec<_> = std::fs::read_dir(&tests_path)?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "what"))
.map(|e| e.path())
.collect();
test_files.sort();
if test_files.is_empty() {
println!(" No test files found in {}", tests_path.display());
return Ok(0);
}
let config_path = project_path.join("wwwhat.toml");
let config = if config_path.exists() {
Config::load(&config_path)?
} else {
Config::default()
};
let state = server::AppState::new(config, project_path)?;
println!();
println!(" Running {} tests...", test_files.len());
println!();
let mut passed = 0;
let mut failed = 0;
for test_path in &test_files {
let test_case = parse_test_file(test_path)?;
let mut errors: Vec<String> = Vec::new();
let html = server::render_page_to_html(&state, &test_case.page).await;
let (actual_status, body_str, redirect_location): (u16, String, Option<String>) = match html
{
Ok(Some(rendered)) => (200, rendered, None),
Ok(None) => (404, String::new(), None),
Err(e) => {
errors.push(format!("render error: {}", e));
(500, String::new(), None)
}
};
if let Some(expected_status) = test_case.status {
if actual_status != expected_status {
errors.push(format!(
"status: expected {}, got {}",
expected_status, actual_status
));
}
}
if let Some(ref expected_redirect) = test_case.redirect {
match redirect_location {
Some(ref loc) if loc == expected_redirect => {}
Some(ref loc) => errors.push(format!(
"redirect: expected '{}', got '{}'",
expected_redirect, loc
)),
None => errors.push(format!(
"redirect: expected '{}', got no redirect",
expected_redirect
)),
}
}
if !test_case.contains.is_empty() || !test_case.not_contains.is_empty() {
for expected in &test_case.contains {
if !body_str.contains(expected.as_str()) {
errors.push(format!("contains: '{}' not found in response", expected));
}
}
for unexpected in &test_case.not_contains {
if body_str.contains(unexpected.as_str()) {
errors.push(format!("not_contains: '{}' found in response", unexpected));
}
}
}
if errors.is_empty() {
println!(" \x1b[32mPASS\x1b[0m {}", test_case.name);
passed += 1;
} else {
println!(" \x1b[31mFAIL\x1b[0m {}", test_case.name);
for err in &errors {
println!(" \x1b[2m- {}\x1b[0m", err);
}
failed += 1;
}
}
println!();
if failed == 0 {
println!(
" \x1b[32mResults: {} passed, {} failed\x1b[0m",
passed, failed
);
} else {
println!(
" \x1b[31mResults: {} passed, {} failed\x1b[0m",
passed, failed
);
}
println!();
Ok(if failed > 0 { 1 } else { 0 })
}
fn run_doctor(path: PathBuf) -> Result<()> {
let project_path = std::fs::canonicalize(&path)?;
let mut issues: Vec<String> = Vec::new();
let mut ok: Vec<String> = Vec::new();
println!();
println!(
" \x1b[1mWhat Doctor\x1b[0m — checking project at {}",
project_path.display()
);
println!();
let config_path = project_path.join("wwwhat.toml");
if config_path.exists() {
match Config::load(&config_path) {
Ok(_) => ok.push("wwwhat.toml is valid".to_string()),
Err(e) => issues.push(format!("wwwhat.toml parse error: {}", e)),
}
} else {
ok.push("wwwhat.toml not found (zero-config defaults apply)".to_string());
}
let content_dir = wwwhat_core::server::content_dir_name(&project_path);
let content_path = project_path.join(content_dir);
if content_path.is_dir() {
let page_count = std::fs::read_dir(&content_path)
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "html"))
.count()
})
.unwrap_or(0);
ok.push(format!(
"{}/ directory found ({} pages)",
content_dir, page_count
));
} else {
issues.push(format!(
"{}/ directory missing — create it to add pages",
content_dir
));
}
if project_path.join("site").is_dir() && project_path.join("pages").is_dir() {
issues.push("Both site/ and pages/ exist — remove pages/ to avoid confusion".to_string());
}
let comp_path = project_path.join("components");
if comp_path.is_dir() {
let comp_count = std::fs::read_dir(&comp_path)
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "html"))
.count()
})
.unwrap_or(0);
ok.push(format!(
"components/ directory found ({} components)",
comp_count
));
} else {
ok.push("components/ directory missing (optional)".to_string());
}
let static_path = project_path.join("static");
if static_path.is_dir() {
ok.push("static/ directory present (for custom assets)".to_string());
} else {
ok.push(
"static/ directory missing (optional — framework CSS/JS are auto-injected)".to_string(),
);
}
let data_path = project_path.join("data");
if !data_path.is_dir() {
ok.push(
"data/ directory missing (optional — created automatically when sessions or the data store are used)"
.to_string(),
);
} else {
ok.push("data/ directory present".to_string());
}
for item in &ok {
println!(" \x1b[32mâś“\x1b[0m {}", item);
}
for item in &issues {
println!(" \x1b[33m!\x1b[0m {}", item);
}
println!();
if issues.is_empty() {
println!(" \x1b[32mAll checks passed\x1b[0m");
} else {
println!(" {} ok, {} issues", ok.len(), issues.len());
}
println!();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn strip_ignored_regions(html: &str) -> String {
let re =
regex::Regex::new(r"(?is)<(code|pre|script|style|textarea)\b.*?</(code|pre|script|style|textarea)>")
.unwrap();
re.replace_all(html, "").to_string()
}
fn assert_no_legacy_template_patterns(project_dir: &std::path::Path, label: &str) {
let legacy_cond = regex::Regex::new(r"<(?:if|elseif|unless)\b[^>]*\bcond\s*=").unwrap();
let trailing_else = regex::Regex::new(r"</if>\s*<else\s*/?>").unwrap();
for entry in walkdir::WalkDir::new(project_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "html"))
{
let raw = fs::read_to_string(entry.path()).unwrap();
assert!(
!legacy_cond.is_match(&raw),
"{}: {} ships legacy cond= syntax — scaffolds must use the simplified form",
label,
entry.path().display()
);
assert!(
!trailing_else.is_match(&raw),
"{}: {} ships a trailing <else/> after </if> (the else branch would always render)",
label,
entry.path().display()
);
}
}
async fn assert_all_pages_render_clean(project_dir: &std::path::Path, label: &str) {
let project_dir = project_dir.canonicalize().unwrap();
let config = Config::load(&project_dir.join("wwwhat.toml")).unwrap();
let state = server::AppState::new(config, project_dir.clone()).unwrap();
state.init_datasources().await.unwrap();
let unresolved = regex::Regex::new(r"#([a-zA-Z_][\w.]*(?:\|[^#]+)?)#").unwrap();
let unprocessed = regex::Regex::new(r"</?(?:if|unless|loop|include|elseif|else)\b").unwrap();
let routes = server::discover_routes(&project_dir);
assert!(!routes.is_empty(), "{}: no routes discovered", label);
for (url_path, is_dynamic) in &routes {
if *is_dynamic {
continue;
}
let html = match server::render_page_to_html(&state, url_path).await {
Ok(Some(html)) => html,
Ok(None) => panic!(
"{}: route {} rendered to None (auth-protected or excluded) — scaffold pages must be public",
label, url_path
),
Err(e) => panic!("{}: route {} failed to render: {}", label, url_path, e),
};
let visible = strip_ignored_regions(&html);
for cap in unresolved.captures_iter(&visible) {
let var = &cap[1];
assert!(
var.starts_with('_'),
"{}: route {} leaked unresolved variable #{}#",
label,
url_path,
var
);
}
if let Some(m) = unprocessed.find(&visible) {
panic!(
"{}: route {} contains unprocessed template tag near: {}",
label,
url_path,
&visible[m.start()..(m.start() + 60).min(visible.len())]
);
}
for marker in [
"Loop: no data for",
"<!-- loop: no data",
"<!-- include error:",
"<!-- include not found:",
] {
assert!(
!html.contains(marker),
"{}: route {} contains engine error marker {:?}",
label,
url_path,
marker
);
}
}
}
#[tokio::test]
async fn blank_project_renders_clean() {
let temp_dir = tempfile::tempdir().unwrap();
let project_dir = temp_dir.path().join("blank-app");
create_blank_project(&project_dir).unwrap();
assert_no_legacy_template_patterns(&project_dir, "blank");
assert_all_pages_render_clean(&project_dir, "blank").await;
}
#[tokio::test]
async fn generated_page_renders_clean_in_blank_scaffold() {
let temp_dir = tempfile::tempdir().unwrap();
let project_dir = temp_dir.path().join("blank-app");
create_blank_project(&project_dir).unwrap();
fs::write(
project_dir.join("site").join("contact.html"),
generated_page_content("Contact"),
)
.unwrap();
assert_all_pages_render_clean(&project_dir, "blank+generated").await;
let project_dir = project_dir.canonicalize().unwrap();
let config = Config::load(&project_dir.join("wwwhat.toml")).unwrap();
let state = server::AppState::new(config, project_dir).unwrap();
state.init_datasources().await.unwrap();
let html = server::render_page_to_html(&state, "/contact")
.await
.unwrap()
.unwrap();
assert_eq!(
html.matches("<!DOCTYPE").count(),
1,
"generated page must produce exactly one document, got: {}",
&html[..200.min(html.len())]
);
assert!(html.contains("<h1>Contact</h1>"), "got: {}", &html[..200.min(html.len())]);
}
#[tokio::test]
async fn example_project_renders_clean() {
let temp_dir = tempfile::tempdir().unwrap();
let project_dir = temp_dir.path().join("example-app");
create_example_project(&project_dir).unwrap();
assert_no_legacy_template_patterns(&project_dir, "tutorial");
assert_all_pages_render_clean(&project_dir, "tutorial").await;
}
async fn assert_project_starts_and_builds(
project_dir: &std::path::Path,
expected_text: &str,
minimum_pages: usize,
) {
let config = Config::load(&project_dir.join("wwwhat.toml")).unwrap();
let state =
server::AppState::with_dev_mode(config, project_dir.to_path_buf(), true).unwrap();
state.init_datasources().await.unwrap();
let output_dir = project_dir.join("dist");
let result = prerender::prerender(prerender::PreRenderConfig {
project_path: project_dir.to_path_buf(),
output_path: output_dir.clone(),
minify: false,
})
.await
.unwrap();
assert!(
result.pages_rendered >= minimum_pages,
"expected at least {} rendered pages, got {}",
minimum_pages,
result.pages_rendered
);
let index = fs::read_to_string(output_dir.join("index.html")).unwrap();
assert!(
index.contains(expected_text),
"index.html missing expected text"
);
}
#[tokio::test]
async fn blank_project_smoke_test() {
let temp_dir = tempfile::tempdir().unwrap();
let project_dir = temp_dir.path().join("blank-app");
create_blank_project(&project_dir).unwrap();
assert_project_starts_and_builds(&project_dir, "What?", 1).await;
assert!(project_dir.join("site").join("index.html").exists());
assert!(project_dir.join("components").join("layout.html").exists());
assert!(project_dir.join("static").join("styles.css").exists());
}
#[tokio::test]
async fn example_project_smoke_test() {
let temp_dir = tempfile::tempdir().unwrap();
let project_dir = temp_dir.path().join("example-app");
create_example_project(&project_dir).unwrap();
assert_project_starts_and_builds(&project_dir, "Welcome to wwwhat", 11).await;
assert!(
project_dir
.join("site")
.join("tutorial")
.join("10.html")
.exists()
);
assert!(
project_dir
.join("components")
.join("tutorial-layout.html")
.exists()
);
}
}