rok-cli 0.3.2

Developer CLI for rok-based Axum applications
//! `--template htmx` — Full-stack Htmx + Minijinja server-rendered app.

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.
"#;