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,
},
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);
}
});
}
println!();
println!(" What server running!");
println!();
println!(
" Local: http://{}:{}",
state.config.server.host, state.config.server.port
);
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_with_shutdown(state, 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(())
}
#[allow(dead_code)]
fn create_starter_project(project_dir: &PathBuf) -> Result<()> {
let dirs = ["site", "components", "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"
[cache]
enabled = true
ttl = 300
[session]
enabled = true
cookie_name = "w_session"
max_age = 604800
database = "data/sessions.db"
"#;
std::fs::write(project_dir.join("wwwhat.toml"), config)?;
let nav = r##"<what>
props = "active"
defaults.active = "home"
</what>
<nav class="navigation-top navigation-top-sticky">
<a href="/" class="nav-brand">What</a>
<div class="nav-links">
<a href="/" class="nav-link" data-page="home">Home</a>
<a href="/about" class="nav-link" data-page="about">About</a>
</div>
</nav>
"##;
std::fs::write(project_dir.join("components").join("nav.html"), nav)?;
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">
<title>#title|default:"What App"#</title>
</head>
<body>
<what-nav active="#nav_active|default:"home"#"/>
<slot/>
<footer class="border-t py-8 mt-12">
<div class="container text-center text-gray-500 text-sm">
Built with What
</div>
</footer>
</body>
</html>
"##;
std::fs::write(
project_dir.join("site").join("application.what"),
"layout = \"components/layout.html\"\n",
)?;
std::fs::write(project_dir.join("components").join("layout.html"), layout)?;
let index = r##"<what>
title: Welcome to What
nav_active: home
</what>
<section class="jumbo jumbo-lg">
<div class="jumbo-content">
<h1 class="jumbo-title">Welcome to What!</h1>
<p class="jumbo-subtitle">
Build modern web apps with just HTML. No JavaScript frameworks. No build steps.
</p>
</div>
</section>
<main class="container py-12">
<div class="max-w-prose mx-auto">
<!-- Interactive counter — server state, zero JavaScript -->
<section class="card mb-8">
<div class="card-body text-center">
<h2 class="text-2xl font-bold mb-4">Interactive Counter</h2>
<p class="text-5xl font-bold mb-6">#session.count|default:"0"#</p>
<div class="flex gap-4 justify-center">
<button w-set="session.count -= 1" class="btn btn-outline">− Decrease</button>
<button w-set="session.count += 1" class="btn btn-primary">+ Increase</button>
<button w-set="session.count = 0" class="btn btn-ghost">Reset</button>
</div>
<p class="text-gray-500 text-sm mt-4">
This works with server-side sessions — no JavaScript needed!
</p>
</div>
</section>
<section class="mb-12">
<h2 class="text-3xl font-bold mb-6">How It Works</h2>
<div class="space-y-4">
<div class="card">
<div class="card-body">
<h3 class="font-semibold text-lg mb-2">File-Based Routing</h3>
<p class="text-gray-600">Your file structure is your URL structure. <code>site/about.html</code> becomes <code>/about</code>.</p>
</div>
</div>
<div class="card">
<div class="card-body">
<h3 class="font-semibold text-lg mb-2">Session State</h3>
<p class="text-gray-600">Use <code>#session.count#</code> in HTML and <code><what>session.count += 1</what></code> in forms to manage state.</p>
</div>
</div>
<div class="card">
<div class="card-body">
<h3 class="font-semibold text-lg mb-2">Components</h3>
<p class="text-gray-600">Create HTML files in <code>components/</code> and use them as tags: <code><what-nav/></code></p>
</div>
</div>
<div class="card">
<div class="card-body">
<h3 class="font-semibold text-lg mb-2">Live Reload</h3>
<p class="text-gray-600">Changes auto-refresh during development. Edit this file and watch it update.</p>
</div>
</div>
</div>
</section>
</div>
</main>
"##;
std::fs::write(project_dir.join("site").join("index.html"), index)?;
let about = r##"<what>
title: About - What
nav_active: about
</what>
<main class="container py-12">
<div class="max-w-prose mx-auto">
<h1 class="text-4xl font-bold mb-6">About What</h1>
<div class="prose">
<p class="text-lg text-gray-700 mb-4">
What is a Rust-based HTML framework for building server-rendered web applications.
No JavaScript frameworks, no build steps — just HTML with template syntax.
</p>
<h2 class="text-2xl font-bold mt-8 mb-4">Getting Started</h2>
<p class="text-gray-700 mb-4">
Create pages in the <code>site/</code> directory. Each HTML file becomes a route:
</p>
<ul class="list-disc list-inside text-gray-700 space-y-2 mb-4">
<li><code>site/index.html</code> → <code>/</code></li>
<li><code>site/about.html</code> → <code>/about</code></li>
<li><code>site/blog/post.html</code> → <code>/blog/post</code></li>
</ul>
<h2 class="text-2xl font-bold mt-8 mb-4">Template Syntax</h2>
<p class="text-gray-700 mb-4">
Use template variables with <code>#variable#</code> syntax:
</p>
<div class="bg-gray-100 p-4 rounded mb-4">
<code><h1>Hello #user.name#!</h1></code>
</div>
</div>
<div class="mt-8">
<a href="/" class="btn btn-outline">← Back to Home</a>
</div>
</div>
</main>
"##;
std::fs::write(project_dir.join("site").join("about.html"), about)?;
Ok(())
}
#[allow(dead_code)]
fn create_demo_project(project_dir: &PathBuf) -> Result<()> {
fn write_demo_file(project_dir: &PathBuf, relative_path: &str, contents: &str) -> Result<()> {
let path = project_dir.join(relative_path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, contents)?;
Ok(())
}
std::fs::create_dir_all(project_dir.join("data"))?;
let files = [
("wwwhat.toml", include_str!("../assets/demo/wwwhat.toml")),
("site/about.html", include_str!("../assets/demo/pages/about.html")),
("site/application.what", include_str!("../assets/demo/pages/application.what")),
(
"site/demo/application.what",
include_str!("../assets/demo/pages/demo/application.what"),
),
(
"site/demo/apps/application.what",
include_str!("../assets/demo/pages/demo/apps/application.what"),
),
(
"site/demo/apps/chat.html",
include_str!("../assets/demo/pages/demo/apps/chat.html"),
),
(
"site/demo/apps/todo.html",
include_str!("../assets/demo/pages/demo/apps/todo.html"),
),
(
"site/demo/behavior/alerts.html",
include_str!("../assets/demo/pages/demo/behavior/alerts.html"),
),
(
"site/demo/behavior/components.html",
include_str!("../assets/demo/pages/demo/behavior/components.html"),
),
(
"site/demo/behavior/interactions.html",
include_str!("../assets/demo/pages/demo/behavior/interactions.html"),
),
(
"site/demo/behavior/live-content.html",
include_str!("../assets/demo/pages/demo/behavior/live-content.html"),
),
(
"site/demo/data/data.html",
include_str!("../assets/demo/pages/demo/data/data.html"),
),
(
"site/demo/data/remote-data.html",
include_str!("../assets/demo/pages/demo/data/remote-data.html"),
),
(
"site/demo/data/state.html",
include_str!("../assets/demo/pages/demo/data/state.html"),
),
(
"site/demo/ui/buttons.html",
include_str!("../assets/demo/pages/demo/ui/buttons.html"),
),
(
"site/demo/ui/cards.html",
include_str!("../assets/demo/pages/demo/ui/cards.html"),
),
(
"site/demo/ui/forms.html",
include_str!("../assets/demo/pages/demo/ui/forms.html"),
),
(
"site/demo/ui/layout.html",
include_str!("../assets/demo/pages/demo/ui/layout.html"),
),
(
"site/demo/ui/modals.html",
include_str!("../assets/demo/pages/demo/ui/modals.html"),
),
(
"site/demo/ui/navigation.html",
include_str!("../assets/demo/pages/demo/ui/navigation.html"),
),
(
"site/demo/ui/tables.html",
include_str!("../assets/demo/pages/demo/ui/tables.html"),
),
("site/index.html", include_str!("../assets/demo/pages/index.html")),
(
"site/installation.html",
include_str!("../assets/demo/pages/installation.html"),
),
(
"site/partials/application.what",
include_str!("../assets/demo/pages/partials/application.what"),
),
(
"site/partials/chat-room.html",
include_str!("../assets/demo/pages/partials/chat-room.html"),
),
(
"site/partials/clear-notifications.html",
include_str!("../assets/demo/pages/partials/clear-notifications.html"),
),
(
"site/partials/counter.html",
include_str!("../assets/demo/pages/partials/counter.html"),
),
(
"site/partials/dog-breeds.html",
include_str!("../assets/demo/pages/partials/dog-breeds.html"),
),
(
"site/partials/dog-facts.html",
include_str!("../assets/demo/pages/partials/dog-facts.html"),
),
(
"site/partials/dog-images.html",
include_str!("../assets/demo/pages/partials/dog-images.html"),
),
(
"site/partials/items.html",
include_str!("../assets/demo/pages/partials/items.html"),
),
(
"site/partials/next-dog.html",
include_str!("../assets/demo/pages/partials/next-dog.html"),
),
(
"site/partials/notification-counter.html",
include_str!("../assets/demo/pages/partials/notification-counter.html"),
),
(
"site/partials/notification.html",
include_str!("../assets/demo/pages/partials/notification.html"),
),
(
"site/partials/reset-notification.html",
include_str!("../assets/demo/pages/partials/reset-notification.html"),
),
(
"site/partials/time.html",
include_str!("../assets/demo/pages/partials/time.html"),
),
(
"site/partials/todo-list.html",
include_str!("../assets/demo/pages/partials/todo-list.html"),
),
(
"components/accordion.html",
include_str!("../assets/demo/components/accordion.html"),
),
(
"components/add-notification.html",
include_str!("../assets/demo/components/add-notification.html"),
),
("components/alert.html", include_str!("../assets/demo/components/alert.html")),
(
"components/avatar.html",
include_str!("../assets/demo/components/avatar.html"),
),
("components/badge.html", include_str!("../assets/demo/components/badge.html")),
(
"components/breadcrumb.html",
include_str!("../assets/demo/components/breadcrumb.html"),
),
("components/card.html", include_str!("../assets/demo/components/card.html")),
(
"components/chat-bubble.html",
include_str!("../assets/demo/components/chat-bubble.html"),
),
(
"components/coming-soon.html",
include_str!("../assets/demo/components/coming-soon.html"),
),
(
"components/confirm-modal.html",
include_str!("../assets/demo/components/confirm-modal.html"),
),
(
"components/demo-layout.html",
include_str!("../assets/demo/components/demo-layout.html"),
),
(
"components/drawer.html",
include_str!("../assets/demo/components/drawer.html"),
),
(
"components/dropdown.html",
include_str!("../assets/demo/components/dropdown.html"),
),
(
"components/empty-state.html",
include_str!("../assets/demo/components/empty-state.html"),
),
("components/image.html", include_str!("../assets/demo/components/image.html")),
("components/modal.html", include_str!("../assets/demo/components/modal.html")),
("components/nav.html", include_str!("../assets/demo/components/nav.html")),
(
"components/notification-item.html",
include_str!("../assets/demo/components/notification-item.html"),
),
(
"components/notification.html",
include_str!("../assets/demo/components/notification.html"),
),
(
"components/pagination.html",
include_str!("../assets/demo/components/pagination.html"),
),
(
"components/progress.html",
include_str!("../assets/demo/components/progress.html"),
),
(
"components/site-layout.html",
include_str!("../assets/demo/components/site-layout.html"),
),
(
"components/spinner.html",
include_str!("../assets/demo/components/spinner.html"),
),
("components/table.html", include_str!("../assets/demo/components/table.html")),
("components/tabs.html", include_str!("../assets/demo/components/tabs.html")),
(
"components/tooltip.html",
include_str!("../assets/demo/components/tooltip.html"),
),
(
"components/user-card.html",
include_str!("../assets/demo/components/user-card.html"),
),
(
"components/user-groups-card.html",
include_str!("../assets/demo/components/user-groups-card.html"),
),
(
"static/demo-layout.css",
include_str!("../assets/demo/static/demo-layout.css"),
),
(
"static/forms-demo.css",
include_str!("../assets/demo/static/forms-demo.css"),
),
(
"static/homepage.css",
include_str!("../assets/demo/static/homepage.css"),
),
(
"static/site-layout.css",
include_str!("../assets/demo/static/site-layout.css"),
),
];
for (relative_path, contents) in files {
write_demo_file(project_dir, relative_path, contents)?;
}
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(" ");
let content = format!(
r#"<what-page title="{}">
<main class="container py-8">
<h1 class="text-3xl font-bold mb-4">{}</h1>
<p>Your content here...</p>
</main>
</what-page>
"#,
title, title
);
std::fs::write(&file_path, content)?;
println!(" \x1b[32mCreated\x1b[0m {}", file_path.display());
Ok(())
}
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() {
anyhow::bail!("Test directory not found: {}", tests_path.display());
}
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 {
issues.push("wwwhat.toml not found (using defaults)".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() {
issues.push("data/ directory missing — sessions and data store won't work".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;
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()
);
}
}