use serde_json::{json, Value}; use std::{ env, fs, path::{Path, PathBuf}, }; fn required_string<'a>(meta: &'a Value, key: &str, file: &str) -> &'a str { meta.get(key) .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| panic!("{file}: meta.{key} must be a non-empty string")) } fn has_text(value: Option<&Value>) -> bool { match value { Some(Value::String(text)) => !text.trim().is_empty(), Some(Value::Object(items)) => items .values() .any(|item| item.as_str().is_some_and(|text| !text.trim().is_empty())), _ => false, } } fn fnv1a_update(mut hash: u64, bytes: &[u8]) -> u64 { for byte in bytes { hash ^= u64::from(*byte); hash = hash.wrapping_mul(0x100000001b3); } hash } fn content_hash(bytes: &[u8]) -> String { format!("{:016x}", fnv1a_update(0xcbf29ce484222325, bytes)) } const APP_JS_MODULES: &[&str] = &[ "core.js", "forms.js", "bootstrap.js", "dashboard.js", "entities.js", "flows.js", "settings-ui.js", "router.js", "navigation.js", "charts.js", "history.js", "realtime.js", "events.js", "settings.js", "main.js", ]; fn bundle_app_js(web_dir: &Path) -> String { let js_dir = web_dir.join("js"); println!("cargo:rerun-if-changed={}", js_dir.display()); let mut bundle = String::new(); for &filename in APP_JS_MODULES { let path = js_dir.join(filename); println!("cargo:rerun-if-changed={}", path.display()); let source = fs::read_to_string(&path) .unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())); bundle.push_str(&source); if !source.ends_with('\n') { bundle.push('\n'); } } bundle } fn main() { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); let lang_dir = manifest_dir.join("lang"); let preset_dir = manifest_dir.join("presets"); let web_dir = manifest_dir.join("web"); println!("cargo:rerun-if-changed={}", lang_dir.display()); println!("cargo:rerun-if-changed={}", preset_dir.display()); let theme_init_path = web_dir.join("theme-init.js"); let styles_path = web_dir.join("styles.css"); let index_path = web_dir.join("index.html"); let not_found_path = web_dir.join("404.html"); let sw_path = web_dir.join("sw.js"); let manifest_path = web_dir.join("manifest.webmanifest"); let favicon_path = web_dir.join("favicon.svg"); for path in [ &theme_init_path, &styles_path, &index_path, ¬_found_path, &sw_path, &manifest_path, &favicon_path, ] { println!("cargo:rerun-if-changed={}", path.display()); } let app_js = bundle_app_js(&web_dir); let app_js_hash = content_hash(app_js.as_bytes()); let theme_init_bytes = fs::read(&theme_init_path) .unwrap_or_else(|error| panic!("cannot read {}: {error}", theme_init_path.display())); let styles_bytes = fs::read(&styles_path) .unwrap_or_else(|error| panic!("cannot read {}: {error}", styles_path.display())); let theme_init_hash = content_hash(&theme_init_bytes); let styles_hash = content_hash(&styles_bytes); let app_js_asset_path = format!("/app-{}.js", &app_js_hash[..12]); let theme_init_asset_path = format!("/theme-init-{}.js", &theme_init_hash[..12]); let styles_asset_path = format!("/styles-{}.css", &styles_hash[..12]); let mut build_hash = fnv1a_update(0xcbf29ce484222325u64, app_js.as_bytes()); for path in [ &theme_init_path, &styles_path, &index_path, ¬_found_path, &sw_path, &manifest_path, &favicon_path, ] { let bytes = fs::read(path) .unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())); build_hash = fnv1a_update(build_hash, &bytes); } let mut files: Vec = fs::read_dir(&lang_dir) .unwrap_or_else(|error| panic!("cannot read {}: {error}", lang_dir.display())) .filter_map(Result::ok) .map(|entry| entry.path()) .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json")) .collect(); files.sort(); if files.is_empty() { panic!("no language files found in {}", lang_dir.display()); } let mut assets = Vec::new(); let mut manifest_languages = Vec::new(); let mut has_english = false; for path in files { println!("cargo:rerun-if-changed={}", path.display()); let filename = path .file_name() .and_then(|v| v.to_str()) .expect("UTF-8 language filename"); let stem = path .file_stem() .and_then(|v| v.to_str()) .expect("UTF-8 language code"); if !stem .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') { panic!("{filename}: filename may only contain ASCII letters, digits, '-' and '_'"); } let source = fs::read_to_string(&path) .unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())); build_hash = fnv1a_update(build_hash, source.as_bytes()); let document: Value = serde_json::from_str(&source) .unwrap_or_else(|error| panic!("{filename}: invalid JSON: {error}")); let root_object = document .as_object() .unwrap_or_else(|| panic!("{filename}: language pack root must be an object")); let unexpected: Vec<&str> = root_object .keys() .filter(|key| key.as_str() != "meta" && key.as_str() != "translations") .map(|key| key.as_str()) .collect(); if !unexpected.is_empty() { panic!("{filename}: unexpected top-level keys {:?}; translation keys must be inside 'translations'", unexpected); } let meta = document .get("meta") .unwrap_or_else(|| panic!("{filename}: missing meta object")); let code = required_string(meta, "code", filename); let name = required_string(meta, "name", filename); let native_name = required_string(meta, "native_name", filename); let locale = required_string(meta, "locale", filename); if code != stem { panic!("{filename}: meta.code '{code}' must match filename '{stem}.json'"); } if !document.get("translations").is_some_and(Value::is_object) { panic!("{filename}: translations must be a JSON object"); } if code == "en" { has_english = true; } manifest_languages.push(json!({ "code": code, "name": name, "native_name": native_name, "locale": locale, "path": format!("lang/{code}.json") })); assets.push((code.to_owned(), path)); } if !has_english { panic!("lang/en.json is required as the default fallback language"); } let mut preset_files: Vec = fs::read_dir(&preset_dir) .unwrap_or_else(|error| panic!("cannot read {}: {error}", preset_dir.display())) .filter_map(Result::ok) .map(|entry| entry.path()) .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json")) .collect(); preset_files.sort(); if preset_files.is_empty() { panic!("no flow preset files found in {}", preset_dir.display()); } let mut preset_assets = Vec::new(); let mut preset_manifest = Vec::new(); for path in preset_files { println!("cargo:rerun-if-changed={}", path.display()); let filename = path .file_name() .and_then(|value| value.to_str()) .expect("UTF-8 preset filename"); let stem = path .file_stem() .and_then(|value| value.to_str()) .expect("UTF-8 preset id"); if !stem .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') { panic!("{filename}: filename may only contain ASCII letters, digits, '-' and '_'"); } let source = fs::read_to_string(&path) .unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())); build_hash = fnv1a_update(build_hash, source.as_bytes()); let document: Value = serde_json::from_str(&source) .unwrap_or_else(|error| panic!("{filename}: invalid JSON: {error}")); let object = document .as_object() .unwrap_or_else(|| panic!("{filename}: preset root must be an object")); let id = object .get("id") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| panic!("{filename}: id must be a non-empty string")); if id != stem { panic!("{filename}: id '{id}' must match filename '{stem}.json'"); } let category = object .get("category") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| panic!("{filename}: category must be a non-empty string")); if !has_text(object.get("name")) { panic!("{filename}: name must be a string or localized object with text"); } if !has_text(object.get("description")) { panic!("{filename}: description must be a string or localized object with text"); } let flow = object .get("flow") .and_then(Value::as_object) .unwrap_or_else(|| panic!("{filename}: flow must be an object")); if !flow.get("nodes").is_some_and(Value::is_array) || !flow.get("edges").is_some_and(Value::is_array) { panic!("{filename}: flow.nodes and flow.edges must be arrays"); } preset_manifest.push(json!({ "id": id, "category": category, "file": filename, })); preset_assets.push((filename.to_owned(), path)); } let manifest_json = serde_json::to_string(&json!({ "default": "en", "languages": manifest_languages })) .expect("serialize language manifest"); let preset_manifest_json = serde_json::to_string(&json!({ "version": 1, "presets": preset_manifest, })) .expect("serialize preset manifest"); let mut generated = String::new(); generated.push_str("// @generated by build.rs - do not edit.\n"); generated.push_str(&format!( "pub const APP_JS_ASSET_PATH: &str = {:?};\n", app_js_asset_path )); generated.push_str(&format!( "pub const THEME_INIT_ASSET_PATH: &str = {:?};\n", theme_init_asset_path )); generated.push_str(&format!( "pub const STYLES_CSS_ASSET_PATH: &str = {:?};\n", styles_asset_path )); generated.push_str(&format!( "pub const ASSET_BUILD_ID: &str = \"{:016x}\";\n", build_hash )); generated.push_str(&format!( "pub const LANGUAGE_MANIFEST_JSON: &str = {:?};\n", manifest_json )); generated.push_str("pub const LANGUAGE_ASSETS: &[(&str, &str)] = &[\n"); for (code, path) in assets { generated.push_str(&format!( " ({:?}, include_str!({:?})),\n", code, path.to_string_lossy() )); } generated.push_str("];\n"); generated.push_str(&format!( "pub const PRESET_MANIFEST_JSON: &str = {:?};\n", preset_manifest_json )); generated.push_str("pub const PRESET_ASSETS: &[(&str, &str)] = &[\n"); for (filename, path) in preset_assets { generated.push_str(&format!( " ({:?}, include_str!({:?})),\n", filename, path.to_string_lossy() )); } generated.push_str("];\n"); let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); fs::write(out_dir.join("app.bundle.js"), app_js).expect("write generated app.bundle.js"); fs::write(out_dir.join("languages.rs"), generated).expect("write generated languages.rs"); }