use std::{fs, path::Path};
pub fn generate(name: &str, root: &Path) -> anyhow::Result<()> {
for dir in &["src/app/controllers", "src/config", "views", "public"] {
fs::create_dir_all(root.join(dir))?;
}
let files: Vec<(&str, String)> = vec![
("Cargo.toml", cargo_toml(name)),
("src/main.rs", main_rs(name)),
(".env.example", ENV_EXAMPLE.into()),
(".gitignore", GITIGNORE.into()),
("src/state.rs", STATE_RS.into()),
("src/app/mod.rs", APP_MOD_RS.into()),
("src/app/controllers/mod.rs", CONTROLLERS_MOD_RS.into()),
(
"src/app/controllers/page_controller.rs",
PAGE_CONTROLLER_RS.into(),
),
("views/layout.html", layout_html(name)),
("views/home.html", HOME_HTML.into()),
("views/users.html", USERS_HTML.into()),
("public/app.css", APP_CSS.into()),
("public/app.js", APP_JS.into()),
];
for (rel, content) in &files {
fs::write(root.join(rel), content)?;
println!(" create {rel}");
}
Ok(())
}
fn cargo_toml(name: &str) -> String {
format!(
r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "{name}"
path = "src/main.rs"
[dependencies]
axum = "0.8"
tokio = {{ version = "1", features = ["full"] }}
tower-http = {{ version = "0.6", features = ["trace", "fs"] }}
serde = {{ version = "1", features = ["derive"] }}
serde_json = "1"
sqlx = {{ version = "0.8", default-features = false, features = [
"macros", "runtime-tokio-native-tls", "chrono", "postgres",
] }}
dotenvy = "0.15"
minijinja = "2"
rok-auth = {{ version = "0.1", features = ["axum"] }}
rok-orm = {{ version = "0.1", features = ["postgres", "axum"] }}
rok-config = {{ version = "0.1" }}
"#
)
}
fn main_rs(name: &str) -> String {
format!(
r#"mod app;
mod state;
use axum::{{routing::get, Router}};
use minijinja::Environment;
use state::AppState;
#[tokio::main]
async fn main() {{
dotenvy::dotenv().ok();
let addr = std::env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".into());
let mut env = Environment::new();
env.add_template("layout.html", include_str!("../views/layout.html")).unwrap();
env.add_template("home.html", include_str!("../views/home.html")).unwrap();
env.add_template("users.html", include_str!("../views/users.html")).unwrap();
let state = AppState {{ env }};
let app = Router::new()
.route("/", get(app::controllers::page_controller::home))
.route("/users", get(app::controllers::page_controller::users))
.with_state(state);
let listener = tokio::net::TcpListener::bind(&addr).await.expect("bind");
println!("{name} listening on {{addr}}");
axum::serve(listener, app).await.expect("server");
}}
"#
)
}
fn layout_html(name: &str) -> String {
format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{name}</title>
<link rel="stylesheet" href="/public/app.css">
<script src="https://unpkg.com/htmx.org@2.0.0/dist/htmx.min.js"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
</head>
<body class="bg-gray-50 text-gray-900">
<nav class="bg-white shadow px-6 py-3 flex items-center gap-4">
<a href="/" class="font-semibold text-blue-600">{name}</a>
<a href="/users" class="text-gray-600 hover:text-blue-600">Users</a>
</nav>
<main class="max-w-4xl mx-auto mt-8 px-4">
{{% block content %}}{{%- endblock %}}
</main>
<script src="/public/app.js"></script>
</body>
</html>
"#
)
}
const ENV_EXAMPLE: &str = r#"LISTEN_ADDR=0.0.0.0:3000
DATABASE_URL=postgres://postgres:postgres@localhost/htmx_app
"#;
const GITIGNORE: &str = "/target\n.env\n";
const STATE_RS: &str = r#"use minijinja::Environment;
#[derive(Clone)]
pub struct AppState {
pub env: Environment<'static>,
}
"#;
const APP_MOD_RS: &str = "pub mod controllers;\n";
const CONTROLLERS_MOD_RS: &str = "pub mod page_controller;\n";
const PAGE_CONTROLLER_RS: &str = r#"use axum::{extract::State, response::Html};
use crate::state::AppState;
pub async fn home(State(state): State<AppState>) -> Html<String> {
let tmpl = state.env.get_template("home.html").unwrap();
let ctx = minijinja::context! { title => "Home" };
Html(tmpl.render(ctx).unwrap())
}
pub async fn users(State(state): State<AppState>) -> Html<String> {
let tmpl = state.env.get_template("users.html").unwrap();
let ctx = minijinja::context! { title => "Users", users => Vec::<String>::new() };
Html(tmpl.render(ctx).unwrap())
}
"#;
const HOME_HTML: &str = r#"{% extends "layout.html" %}
{% block content %}
<div class="text-center mt-16">
<h1 class="text-4xl font-bold text-blue-600">Welcome</h1>
<p class="mt-4 text-gray-600">Your rok + Htmx app is running.</p>
<a href="/users"
class="mt-6 inline-block bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700"
hx-get="/users" hx-target="main" hx-push-url="true">
View Users
</a>
</div>
{% endblock %}
"#;
const USERS_HTML: &str = r#"{% extends "layout.html" %}
{% block content %}
<h1 class="text-2xl font-bold mb-4">Users</h1>
{% if users %}
<ul class="divide-y">
{% for user in users %}
<li class="py-2">{{ user }}</li>
{% endfor %}
</ul>
{% else %}
<p class="text-gray-500">No users yet.</p>
{% endif %}
{% endblock %}
"#;
const APP_CSS: &str = r#"/* Tailwind CSS — add @tailwind directives and configure PostCSS, or use CDN */
@import url("https://cdn.tailwindcss.com");
"#;
const APP_JS: &str = r#"// Alpine.js and Htmx are loaded via CDN in layout.html.
// Add application-specific JavaScript here.
"#;