// file: crates/knowledge-web-lib/src/assets.rs // version: 2 //! Vite development and production asset resolution. #[derive(Clone, Debug, serde::Deserialize)] struct ManifestEntry { file: String, #[serde(default)] css: Vec, #[serde(default, rename = "isEntry")] is_entry: bool, } /// Internal resolved asset set consumed by the Maud renderer and Actix runtime. #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct ViteAssets { scripts: Vec, stylesheets: Vec, static_assets_directory: Option, static_mount_path: Option, } impl crate::ViteAssets { /// Build development asset references from a configured Vite origin. pub(crate) fn development(origin: &str) -> Result { let normalized = origin.trim().trim_end_matches('/'); if normalized.is_empty() { return Err("KNOWLEDGE_VITE_DEV_ORIGIN must not be empty".to_owned()); } return Ok(crate::ViteAssets { scripts: vec![format!("{normalized}/@vite/client"), format!("{normalized}/src/main.ts")], stylesheets: Vec::new(), static_assets_directory: None, static_mount_path: None, }); } /// Resolve production asset references from Vite manifest JSON text. pub(crate) fn from_manifest_text(manifest: &str, asset_base: &str, static_assets_directory: std::path::PathBuf) -> Result { let entries: std::collections::BTreeMap = match serde_json::from_str(manifest) { Ok(value) => value, Err(error) => return Err(format!("invalid Vite manifest JSON: {error}")), }; let entry = match entries.get("src/main.ts") { Some(value) => value, None => return Err("Vite manifest is missing src/main.ts".to_owned()), }; if !entry.is_entry { return Err("Vite manifest src/main.ts is not marked as an entry".to_owned()); } if entry.file.trim().is_empty() { return Err("Vite manifest src/main.ts has an empty JavaScript file".to_owned()); } let javascript = match asset_url(asset_base, entry.file.as_str()) { Ok(value) => value, Err(error) => return Err(error), }; let mut stylesheets = Vec::new(); for stylesheet in &entry.css { let resolved = match asset_url(asset_base, stylesheet.as_str()) { Ok(value) => value, Err(error) => return Err(error), }; stylesheets.push(resolved); } let static_mount_path = local_static_mount_path(asset_base); return Ok(crate::ViteAssets { scripts: vec![javascript], stylesheets, static_assets_directory: Some(static_assets_directory), static_mount_path, }); } /// Return module script URLs in load order. pub(crate) fn scripts(&self) -> &[String] { return self.scripts.as_slice(); } /// Return production stylesheet URLs. Development CSS is injected by Vite. pub(crate) fn stylesheets(&self) -> &[String] { return self.stylesheets.as_slice(); } /// Return the local production assets directory when static serving is available. pub(crate) fn static_assets_directory(&self) -> Option<&std::path::Path> { match self.static_assets_directory.as_deref() { Some(value) => return Some(value), None => return None, } } /// Return the local HTTP mount used for built assets, or `None` for an external asset base. pub(crate) fn static_mount_path(&self) -> Option<&str> { match self.static_mount_path.as_deref() { Some(value) => return Some(value), None => return None, } } } /// Resolve the active Vite asset mode from process environment variables. pub(crate) fn load_assets_from_environment() -> Result { let development_origin = environment_value("KNOWLEDGE_VITE_DEV_ORIGIN"); let manifest_path = environment_value("KNOWLEDGE_VITE_MANIFEST_PATH"); match (development_origin, manifest_path) { (Some(_), Some(_)) => { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "configure either KNOWLEDGE_VITE_DEV_ORIGIN or KNOWLEDGE_VITE_MANIFEST_PATH, not both", )); }, (Some(origin), None) => { let assets = match crate::ViteAssets::development(origin.as_str()) { Ok(value) => value, Err(error) => return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, error)), }; return Ok(assets); }, (None, Some(path)) => { return load_manifest_assets(path.as_str()); }, (None, None) => { return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "configure KNOWLEDGE_VITE_DEV_ORIGIN or KNOWLEDGE_VITE_MANIFEST_PATH")); }, } } fn environment_value(name: &str) -> Option { match std::env::var(name) { Ok(value) => { if value.trim().is_empty() { return None; } return Some(value); }, Err(_) => return None, } } fn load_manifest_assets(path: &str) -> Result { let manifest_path = std::path::PathBuf::from(path); let text = match std::fs::read_to_string(manifest_path.as_path()) { Ok(value) => value, Err(error) => return Err(error), }; let manifest_directory = match manifest_path.parent() { Some(value) => value, None => { return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Vite manifest path has no parent directory")); }, }; let output_root = match manifest_directory.parent() { Some(value) => value, None => { return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Vite manifest path must be located below an output root")); }, }; let asset_base = match environment_value("KNOWLEDGE_ASSET_BASE_PATH") { Some(value) => value, None => "/".to_owned(), }; let assets = match crate::ViteAssets::from_manifest_text(text.as_str(), asset_base.as_str(), output_root.join("assets")) { Ok(value) => value, Err(error) => return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error)), }; return Ok(assets); } fn asset_url(base: &str, relative: &str) -> Result { let normalized_base = base.trim(); let normalized_relative = relative.trim().trim_start_matches('/'); if normalized_base.is_empty() { return Err("KNOWLEDGE_ASSET_BASE_PATH must not be empty".to_owned()); } if normalized_relative.is_empty() { return Err("Vite manifest contains an empty asset path".to_owned()); } if normalized_base == "/" { return Ok(format!("/{normalized_relative}")); } return Ok(format!("{}/{normalized_relative}", normalized_base.trim_end_matches('/'))); } fn local_static_mount_path(base: &str) -> Option { let normalized = base.trim(); if !normalized.starts_with('/') { return None; } if normalized == "/" { return Some("/assets".to_owned()); } return Some(format!("{}/assets", normalized.trim_end_matches('/'))); } #[cfg(test)] #[path = "../unit_tests/assets.rs"] mod tests;