graphile_worker_admin_ui 0.1.2

Embedded Leptos admin UI for graphile_worker
Documentation
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

const ADMIN_UI_BUILD_HINT: &str = "Admin UI asset builds require npm, wasm-bindgen, and the wasm32-unknown-unknown Rust target. Install the Rust tooling with `rustup target add wasm32-unknown-unknown` and `cargo install wasm-bindgen-cli --version 0.2.121 --locked`.";
const REBUILD_ASSETS_ENV: &str = "GRAPHILE_WORKER_ADMIN_UI_REBUILD";
const UPDATE_PREBUILT_ASSETS_ENV: &str = "GRAPHILE_WORKER_ADMIN_UI_UPDATE_PREBUILT";
const PREBUILT_ASSETS: &[&str] = &["admin.css", "admin.js", "admin_ui.js", "admin_ui_bg.wasm"];

fn main() {
    let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
    let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap());
    let client_dir = manifest_dir
        .parent()
        .map(|crates_dir| crates_dir.join("graphile-worker-admin-ui-client"))
        .unwrap_or_else(|| manifest_dir.join("../graphile-worker-admin-ui-client"));
    let prebuilt_dir = manifest_dir.join("prebuilt");

    rerun_if_env_changed(REBUILD_ASSETS_ENV);
    rerun_if_env_changed(UPDATE_PREBUILT_ASSETS_ENV);
    for asset in PREBUILT_ASSETS {
        rerun_if_changed(&prebuilt_dir.join(asset));
    }
    rerun_if_changed(&manifest_dir.join("assets/tailwind.css"));
    rerun_if_changed(&manifest_dir.join("tailwind.config.cjs"));
    rerun_if_changed(&manifest_dir.join("package.json"));
    rerun_if_changed(&manifest_dir.join("package-lock.json"));
    rerun_if_changed(&client_dir.join("Cargo.toml"));
    rerun_if_changed(&client_dir.join("src/lib.rs"));

    if should_rebuild_assets(&prebuilt_dir) {
        build_tailwind(&manifest_dir, &out_dir, &client_dir);
        build_wasm_client(&client_dir, &out_dir);
        write_bootstrap(&out_dir);

        if env_flag(UPDATE_PREBUILT_ASSETS_ENV) {
            copy_assets(&out_dir, &prebuilt_dir, "update prebuilt admin UI asset");
        }
        return;
    }

    copy_assets(&prebuilt_dir, &out_dir, "copy prebuilt admin UI asset");
}

fn rerun_if_changed(path: &Path) {
    println!("cargo:rerun-if-changed={}", path.display());
}

fn rerun_if_env_changed(name: &str) {
    println!("cargo:rerun-if-env-changed={name}");
}

fn should_rebuild_assets(prebuilt_dir: &Path) -> bool {
    env_flag(REBUILD_ASSETS_ENV)
        || env_flag(UPDATE_PREBUILT_ASSETS_ENV)
        || !prebuilt_assets_available(prebuilt_dir)
}

fn env_flag(name: &str) -> bool {
    env::var(name)
        .map(|value| {
            matches!(
                value.as_str(),
                "1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON"
            )
        })
        .unwrap_or(false)
}

fn prebuilt_assets_available(prebuilt_dir: &Path) -> bool {
    PREBUILT_ASSETS
        .iter()
        .all(|asset| prebuilt_dir.join(asset).is_file())
}

fn copy_assets(from_dir: &Path, to_dir: &Path, description: &str) {
    fs::create_dir_all(to_dir).unwrap_or_else(|error| {
        panic!(
            "failed to create admin UI asset directory `{}`: {error}",
            to_dir.display()
        )
    });

    for asset in PREBUILT_ASSETS {
        fs::copy(from_dir.join(asset), to_dir.join(asset)).unwrap_or_else(|error| {
            panic!(
                "failed to {description} `{}`: {error}",
                from_dir.join(asset).display()
            )
        });
    }
}

fn build_tailwind(manifest_dir: &Path, out_dir: &Path, client_dir: &Path) {
    let npm_dir = out_dir.join("npm");
    fs::create_dir_all(&npm_dir).expect("failed to create npm build directory");
    fs::copy(
        manifest_dir.join("package.json"),
        npm_dir.join("package.json"),
    )
    .expect("failed to copy package.json for admin UI asset build");
    fs::copy(
        manifest_dir.join("package-lock.json"),
        npm_dir.join("package-lock.json"),
    )
    .expect("failed to copy package-lock.json for admin UI asset build");

    run(
        Command::new("npm")
            .arg("ci")
            .arg("--ignore-scripts")
            .arg("--no-audit")
            .arg("--no-fund")
            .arg("--prefix")
            .arg(&npm_dir),
        "install admin UI npm dependencies",
    );

    let tailwind_config = out_dir.join("tailwind.config.cjs");
    fs::write(
        &tailwind_config,
        format!(
            r#"const {{ iconsPlugin, getIconCollections }} = require("@egoist/tailwindcss-icons");

module.exports = {{
  darkMode: "class",
  content: ["{}", "{}"],
  theme: {{
    extend: {{
      fontFamily: {{
        sans: [
          "Inter",
          "ui-sans-serif",
          "system-ui",
          "-apple-system",
          "BlinkMacSystemFont",
          "Segoe UI",
          "sans-serif",
        ],
      }},
    }},
  }},
  plugins: [
    iconsPlugin({{
      collections: getIconCollections(["lucide", "tabler"]),
      extraProperties: {{
        display: "inline-block",
        "vertical-align": "-0.16em",
      }},
    }}),
  ],
}};
"#,
            js_path(&manifest_dir.join("src/**/*.rs")),
            js_path(&client_dir.join("src/**/*.rs"))
        ),
    )
    .expect("failed to write generated Tailwind config for admin UI asset build");

    let tailwind_input = out_dir.join("tailwind.css");
    let css = fs::read_to_string(manifest_dir.join("assets/tailwind.css"))
        .expect("failed to read admin UI Tailwind input");
    let marker = "@config \"../tailwind.config.cjs\";";
    assert!(
        css.contains(marker),
        "admin UI tailwind.css is missing the expected `{marker}` directive",
    );
    let css = css.replace(
        marker,
        &format!("@config \"{}\";", js_path(&tailwind_config)),
    );
    fs::write(&tailwind_input, css)
        .expect("failed to write generated Tailwind input for admin UI asset build");

    let tailwind = npm_bin(&npm_dir, "tailwindcss");
    run(
        Command::new(tailwind)
            .arg("-i")
            .arg(&tailwind_input)
            .arg("-o")
            .arg(out_dir.join("admin.css"))
            .arg("--minify")
            .current_dir(manifest_dir)
            .env("NODE_PATH", npm_dir.join("node_modules")),
        "build admin UI Tailwind CSS",
    );
}

fn build_wasm_client(client_dir: &Path, out_dir: &Path) {
    let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
    let target_dir = out_dir.join("wasm-target");
    let mut cargo = Command::new(cargo);
    cargo
        .arg("build")
        .arg("--manifest-path")
        .arg(client_dir.join("Cargo.toml"))
        .arg("--lib")
        .arg("--target")
        .arg("wasm32-unknown-unknown")
        .arg("--release")
        .arg("--target-dir")
        .arg(&target_dir);
    clear_clippy_env(&mut cargo);

    run_with_hint(
        &mut cargo,
        "build admin UI Leptos WASM client",
        Some(ADMIN_UI_BUILD_HINT),
    );

    let wasm = target_dir
        .join("wasm32-unknown-unknown")
        .join("release")
        .join("graphile_worker_admin_ui_client.wasm");
    run_with_hint(
        Command::new("wasm-bindgen")
            .arg(&wasm)
            .arg("--target")
            .arg("web")
            .arg("--out-dir")
            .arg(out_dir)
            .arg("--out-name")
            .arg("admin_ui"),
        "run wasm-bindgen for admin UI client",
        Some(ADMIN_UI_BUILD_HINT),
    );

    let _ = fs::remove_file(out_dir.join("admin_ui.d.ts"));
    let _ = fs::remove_file(out_dir.join("admin_ui_bg.wasm.d.ts"));
}

fn js_path(path: &Path) -> String {
    path.to_string_lossy()
        .replace('\\', "/")
        .replace('"', "\\\"")
}

fn clear_clippy_env(command: &mut Command) {
    for key in [
        "CLIPPY_ARGS",
        "CLIPPY_CONF_DIR",
        "RUSTC_WRAPPER",
        "RUSTC_WORKSPACE_WRAPPER",
    ] {
        command.env_remove(key);
    }
}

fn write_bootstrap(out_dir: &Path) {
    fs::write(
        out_dir.join("admin.js"),
        r#"import init from "./admin_ui.js";

const wasmUrl = new URL("./admin_ui_bg.wasm", import.meta.url);

init({ module_or_path: wasmUrl }).catch((error) => {
  console.error("Failed to initialize Graphile Worker Admin UI", error);
});
"#,
    )
    .expect("failed to write admin UI WASM bootstrap");
}

fn npm_bin(npm_dir: &Path, bin: &str) -> PathBuf {
    let bin_name = if cfg!(windows) {
        format!("{bin}.cmd")
    } else {
        bin.to_string()
    };
    npm_dir.join("node_modules").join(".bin").join(bin_name)
}

fn run(command: &mut Command, description: &str) {
    run_with_hint(command, description, None);
}

fn run_with_hint(command: &mut Command, description: &str, hint: Option<&str>) {
    let program = command.get_program().to_owned();
    let args = command
        .get_args()
        .map(OsStr::to_string_lossy)
        .collect::<Vec<_>>()
        .join(" ");
    let hint = hint.map(|hint| format!("\n{hint}")).unwrap_or_default();

    let status = command.status().unwrap_or_else(|error| {
        panic!(
            "failed to {description}: could not run `{}`: {error}{hint}",
            program.to_string_lossy()
        )
    });

    if !status.success() {
        panic!(
            "failed to {description}: `{}` exited with status {status}{hint}",
            format!("{} {args}", program.to_string_lossy()).trim()
        );
    }
}