358 lines
13 KiB
Rust
358 lines
13 KiB
Rust
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",
|
|
"select-ui.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-dynamic");
|
|
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("js/theme-init.js");
|
|
let lang_init_path = web_dir.join("js/lang-init.js");
|
|
let styles_path = web_dir.join("css/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("js/sw.js");
|
|
let manifest_path = web_dir.join("manifest.webmanifest");
|
|
let favicon_path = web_dir.join("favicon.svg");
|
|
for path in [
|
|
&theme_init_path,
|
|
&lang_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 lang_init_bytes = fs::read(&lang_init_path)
|
|
.unwrap_or_else(|error| panic!("cannot read {}: {error}", lang_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 lang_init_hash = content_hash(&lang_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 lang_init_asset_path = format!("/lang-init-{}.js", &lang_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,
|
|
&lang_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<PathBuf> = 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 default_language = "en";
|
|
let mut assets = Vec::new();
|
|
let mut manifest_languages = Vec::new();
|
|
let mut has_default_language = 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 == default_language {
|
|
has_default_language = true;
|
|
}
|
|
|
|
let language_hash = content_hash(source.as_bytes());
|
|
manifest_languages.push(json!({
|
|
"code": code,
|
|
"name": name,
|
|
"native_name": native_name,
|
|
"locale": locale,
|
|
"path": format!("lang/{code}.json?v={}", &language_hash[..12])
|
|
}));
|
|
assets.push((code.to_owned(), path));
|
|
}
|
|
|
|
if !has_default_language {
|
|
panic!("lang/{default_language}.json is required as the default fallback language");
|
|
}
|
|
|
|
let mut preset_files: Vec<PathBuf> = 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": default_language,
|
|
"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 LANG_INIT_ASSET_PATH: &str = {:?};\n",
|
|
lang_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(&format!(
|
|
"pub const DEFAULT_LANGUAGE_CODE: &str = {:?};\n",
|
|
default_language
|
|
));
|
|
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");
|
|
}
|