5.6 KiB · text History b85db9e
//! Places the web assets into `OUT_DIR` for `src/assets.rs` to `include_bytes!`.
//!
//! Two modes:
//!
//! * default — copy the pre-built, pre-compressed files checked into
//!   `assets/`. No Node.js, no network, no npm. This is what consuming
//!   firmware gets unless it asks otherwise.
//! * `feature = "rebuild-assets"` — run `npm ci` (first time) and the Tailwind
//!   pipeline over `web/`, then minify + gzip into `OUT_DIR`. Needed only when
//!   restyling, or when a consumer ships a custom page using classes the
//!   shipped CSS was not purged against.
//!
//! Either way `OUT_DIR` ends up with the same six files, so `assets.rs` has a
//! single code path.

use std::path::PathBuf;

/// Files `src/assets.rs` expects to find in `OUT_DIR`.
const EMITTED: &[&str] = &[
    "portal.html.gz",
    "saved.html.gz",
    "status.html.gz",
    "app.css.gz",
    "font-400.woff2",
    "font-700.woff2",
];

fn main() {
    let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
    let out = PathBuf::from(std::env::var("OUT_DIR").unwrap());

    // The portal form differs by feature: the mqtt variant adds the broker
    // section. Everything else is shared.
    let portal_variant = if cfg!(feature = "mqtt") {
        "portal-mqtt"
    } else {
        "portal-wifi"
    };

    #[cfg(feature = "rebuild-assets")]
    rebuild::run(&manifest, &out, portal_variant);

    #[cfg(not(feature = "rebuild-assets"))]
    use_prebuilt(&manifest, &out, portal_variant);

    for name in EMITTED {
        let path = out.join(name);
        let len = std::fs::metadata(&path)
            .unwrap_or_else(|e| panic!("build.rs did not emit {name}: {e}"))
            .len();
        assert!(len > 0, "build.rs emitted an empty {name}");
    }
}

/// Copy the checked-in pre-built assets.
#[cfg(not(feature = "rebuild-assets"))]
fn use_prebuilt(manifest: &std::path::Path, out: &std::path::Path, portal_variant: &str) {
    let assets = manifest.join("assets");
    println!("cargo:rerun-if-changed=assets");

    copy(
        &assets.join(format!("{portal_variant}.html.gz")),
        &out.join("portal.html.gz"),
    );
    for name in EMITTED.iter().filter(|n| **n != "portal.html.gz") {
        copy(&assets.join(name), &out.join(name));
    }
}

#[cfg(not(feature = "rebuild-assets"))]
fn copy(src: &std::path::Path, dst: &std::path::Path) {
    std::fs::copy(src, dst).unwrap_or_else(|e| {
        panic!(
            "copy {} -> {}: {e}\n\
             (pre-built assets missing? regenerate with `cargo build --features rebuild-assets` \
             in the neiam-portal checkout, then copy OUT_DIR back into assets/)",
            src.display(),
            dst.display()
        )
    });
}

#[cfg(feature = "rebuild-assets")]
mod rebuild {
    use std::fs;
    use std::io::Write;
    use std::path::Path;
    use std::process::Command;

    use flate2::write::GzEncoder;
    use flate2::Compression;

    pub fn run(manifest: &Path, out: &Path, portal_variant: &str) {
        let web = manifest.join("web");

        for rel in [
            "src",
            "input.css",
            "tailwind.config.js",
            "package.json",
            "package-lock.json",
        ] {
            println!("cargo:rerun-if-changed=web/{rel}");
        }

        // Install JS deps on first build (requires npm + network once).
        if !web.join("node_modules").is_dir() {
            run_cmd(
                Command::new("npm").arg("ci").current_dir(&web),
                "npm ci (install web asset dependencies)",
            );
        }

        // Tailwind purges against web/src/*.html — which includes BOTH portal
        // variants and the status page — so one CSS covers every feature
        // combination and swapping the `mqtt` feature needs no CSS rebuild.
        let css = out.join("app.css");
        let tailwind = web.join("node_modules/.bin/tailwindcss");
        run_cmd(
            Command::new(&tailwind)
                .current_dir(&web)
                .args(["-c", "tailwind.config.js", "-i", "input.css"])
                .arg("-o")
                .arg(&css)
                .arg("--minify"),
            "tailwindcss build",
        );

        gzip_to(&css, &out.join("app.css.gz"));
        gzip_to(
            &web.join(format!("src/{portal_variant}.html")),
            &out.join("portal.html.gz"),
        );
        gzip_to(&web.join("src/saved.html"), &out.join("saved.html.gz"));
        gzip_to(&web.join("src/status.html"), &out.join("status.html.gz"));

        // Latin-subset B612 Mono (already compressed; served as-is).
        let fonts = web.join("node_modules/@fontsource/b612-mono/files");
        fs::copy(
            fonts.join("b612-mono-latin-400-normal.woff2"),
            out.join("font-400.woff2"),
        )
        .expect("copy font-400");
        fs::copy(
            fonts.join("b612-mono-latin-700-normal.woff2"),
            out.join("font-700.woff2"),
        )
        .expect("copy font-700");
    }

    fn run_cmd(cmd: &mut Command, what: &str) {
        let status = cmd.status().unwrap_or_else(|e| {
            panic!("failed to run {what}: {e}. Is Node.js / npm installed and on PATH?")
        });
        assert!(status.success(), "{what} exited with {status}");
    }

    fn gzip_to(src: &Path, dst: &Path) {
        let data = fs::read(src).unwrap_or_else(|e| panic!("read {}: {e}", src.display()));
        let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
        encoder.write_all(&data).unwrap();
        let compressed = encoder.finish().unwrap();
        fs::write(dst, compressed).unwrap_or_else(|e| panic!("write {}: {e}", dst.display()));
    }
}