//! 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",
"package.json",
"package-lock.json",
] {
println!("cargo:rerun-if-changed=web/{rel}");
}
// Install JS deps on first build (requires npm + network once), and
// again whenever the lockfile is newer than what npm last installed:
// a node_modules left over from Tailwind 3 has a v3 `tailwindcss`
// binary that cannot read the v4 input.css.
if stale_node_modules(&web) {
run_cmd(
Command::new("npm").arg("ci").current_dir(&web),
"npm ci (install web asset dependencies)",
);
}
// Tailwind scans web/src (input.css's @source) — 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(["-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");
}
/// No node_modules yet, or package-lock.json changed since npm wrote
/// node_modules/.package-lock.json (its record of the last install).
fn stale_node_modules(web: &Path) -> bool {
let modified = |p: &Path| fs::metadata(p).and_then(|m| m.modified()).ok();
match (
modified(&web.join("package-lock.json")),
modified(&web.join("node_modules/.package-lock.json")),
) {
(Some(lock), Some(installed)) => lock > installed,
_ => true,
}
}
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()));
}
}
neiam
/neiam-portal
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
6.2 KiB · text
History
65cab78