Files
sasedev-knowledge/crates/knowledge-web-lib/src/assets.rs

200 lines
7.4 KiB
Rust

// 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<String>,
#[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<String>,
stylesheets: Vec<String>,
static_assets_directory: Option<std::path::PathBuf>,
static_mount_path: Option<String>,
}
impl crate::ViteAssets {
/// Build development asset references from a configured Vite origin.
pub(crate) fn development(origin: &str) -> Result<crate::ViteAssets, String> {
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<crate::ViteAssets, String> {
let entries: std::collections::BTreeMap<String, ManifestEntry> = 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<crate::ViteAssets, std::io::Error> {
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<String> {
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<crate::ViteAssets, std::io::Error> {
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<String, String> {
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<String> {
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;