diff --git a/Cargo.lock b/Cargo.lock index 1716d60..2201dda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,6 +279,22 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fnv" version = "1.0.7" @@ -333,6 +349,18 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "gimli" version = "0.29.0" @@ -351,13 +379,6 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" -[[package]] -name = "html-generator" -version = "0.0.1-alpha" -dependencies = [ - "anyhow", -] - [[package]] name = "http" version = "1.1.0" @@ -496,9 +517,15 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.155" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "lock_api" @@ -582,7 +609,6 @@ dependencies = [ "axum", "chrono", "clap", - "html-generator", "lazy_static", "logger", "metacall", @@ -591,6 +617,7 @@ dependencies = [ "metassr-bundler", "metassr-create", "metassr-fs-analyzer", + "metassr-html", "metassr-server", "metassr-utils", "nu-ansi-term 0.50.0", @@ -610,14 +637,16 @@ name = "metassr-build" version = "0.0.1-alpha" dependencies = [ "anyhow", - "html-generator", "lazy_static", "metacall", "metassr-bundler", "metassr-fs-analyzer", + "metassr-html", "metassr-utils", "serde", "serde_json", + "tempfile", + "tracing", ] [[package]] @@ -668,6 +697,13 @@ dependencies = [ "walkdir", ] +[[package]] +name = "metassr-html" +version = "0.0.1-alpha" +dependencies = [ + "anyhow", +] + [[package]] name = "metassr-server" version = "0.0.1-alpha" @@ -727,7 +763,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.48.0", ] @@ -853,9 +889,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" dependencies = [ "unicode-ident", ] @@ -869,6 +905,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "redox_syscall" version = "0.5.2" @@ -928,6 +970,19 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "rustversion" version = "1.0.17" @@ -1051,9 +1106,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.68" +version = "2.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901fa70d88b9d6c98022e23b4136f9f3e54e4662c3bc1bd1d84a42a9a0f0c1e9" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" dependencies = [ "proc-macro2", "quote", @@ -1072,6 +1127,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +[[package]] +name = "tempfile" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "thread_local" version = "1.1.8" @@ -1180,9 +1248,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "log", "pin-project-lite", @@ -1192,9 +1260,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", @@ -1203,9 +1271,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", "valuable", @@ -1289,6 +1357,24 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.92" @@ -1521,3 +1607,9 @@ name = "windows_x86_64_msvc" version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" diff --git a/Cargo.toml b/Cargo.toml index 0b0919a..d350966 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ logger = { path = "crates/logger" } metassr-server = { path = "crates/metassr-server" } metassr-build = { path = "crates/metassr-build" } metassr-utils = { path = "crates/metassr-utils" } -html-generator = { path = "crates/html-generator" } +metassr-html = { path = "crates/metassr-html" } serde = "1.0.207" tower-layer = "0.3.3" tower-service = "0.3.3" @@ -44,13 +44,13 @@ metacall-sys = "0.1.1" [workspace] members = [ + "metassr-cli", "crates/logger", "crates/metassr-build", + "crates/metassr-create", + "crates/metassr-html", "crates/metassr-server", "crates/metassr-utils", - "crates/html-generator", - "metassr-cli", - "crates/metassr-create", "crates/metassr-bundler", "crates/metassr-fs-analyzer", ] diff --git a/crates/metassr-build/Cargo.toml b/crates/metassr-build/Cargo.toml index e66eeea..2851db1 100644 --- a/crates/metassr-build/Cargo.toml +++ b/crates/metassr-build/Cargo.toml @@ -10,8 +10,12 @@ metacall = "0.5.1" anyhow = "1.0.82" serde_json = "1.0.120" metassr-utils = { path = "../metassr-utils" } -html-generator = { path = "../html-generator" } +metassr-html = { path = "../metassr-html" } lazy_static = "1.5.0" serde = { version = "1.0.207", features = ["derive"] } metassr-bundler = { path = "../metassr-bundler" } metassr-fs-analyzer = { path = "../metassr-fs-analyzer" } +tracing = "0.1.41" + +[dev-dependencies] +tempfile = "3.6.0" diff --git a/crates/metassr-build/src/client/bundling.rs b/crates/metassr-build/src/client/bundling.rs new file mode 100644 index 0000000..280ea50 --- /dev/null +++ b/crates/metassr-build/src/client/bundling.rs @@ -0,0 +1,261 @@ +use anyhow::{Context, Result}; + +use metassr_bundler::WebBundler; + +use super::{config::ClientConfig, target::TargetCollection}; + +/// Handles the bundling process for client-side code +pub struct BundlingService { + config: ClientConfig, +} + +impl BundlingService { + /// Creates a new bundling service + pub fn new(config: ClientConfig) -> Self { + Self { config } + } + + /// Bundles the provided targets + pub fn bundle(&self, targets: &TargetCollection) -> Result { + if targets.is_empty() { + return Ok(BundlingResult::empty()); + } + + let bundler_targets = targets + .to_bundler_targets() + .context("Failed to convert targets for bundling")?; + + let bundler = WebBundler::new(&bundler_targets, self.config.dist_dir_str()) + .context("Failed to create web bundler")?; + + bundler + .exec() + .context("Bundling process failed")?; + + Ok(BundlingResult::success(bundler_targets.len())) + } + + /// Validates that bundling targets are ready + pub fn validate_targets(&self, targets: &TargetCollection) -> Result { + let mut validation = ValidationResult::new(); + + for target in targets.targets() { + // Check if source file exists + if !target.source_path.exists() { + validation.add_error(format!( + "Source file does not exist: {}", + target.source_path.display() + )); + } + + // Check if content is not empty + if target.content.is_empty() { + validation.add_warning(format!( + "Target '{}' has empty content", + target.id + )); + } + + // Check for absolute path resolution + if let Err(e) = target.absolute_source_path() { + validation.add_error(format!( + "Cannot resolve absolute path for '{}': {}", + target.id, e + )); + } + } + + Ok(validation) + } + + /// Gets bundling statistics + pub fn get_bundling_stats(&self, targets: &TargetCollection) -> BundlingStats { + let target_stats = targets.stats(); + + BundlingStats { + total_targets: target_stats.total_targets, + page_targets: target_stats.page_targets, + special_targets: target_stats.special_targets, + total_content_size: target_stats.total_content_size, + dist_dir: self.config.dist_dir.clone(), + bundler_options: self.config.bundler_options.clone(), + } + } +} + +/// Result of a bundling operation +#[derive(Debug)] +pub struct BundlingResult { + /// Whether the bundling was successful + pub success: bool, + /// Number of targets that were bundled + pub bundled_targets: usize, + /// Any messages from the bundling process + pub messages: Vec, +} + +impl BundlingResult { + /// Creates a successful bundling result + pub fn success(bundled_targets: usize) -> Self { + Self { + success: true, + bundled_targets, + messages: vec![format!("Successfully bundled {} targets", bundled_targets)], + } + } + + /// Creates an empty bundling result (no targets to bundle) + pub fn empty() -> Self { + Self { + success: true, + bundled_targets: 0, + messages: vec!["No targets to bundle".to_string()], + } + } + + /// Creates a failed bundling result + pub fn failure(error: String) -> Self { + Self { + success: false, + bundled_targets: 0, + messages: vec![error], + } + } +} + +/// Result of target validation +#[derive(Debug, Default)] +pub struct ValidationResult { + /// Validation errors that prevent bundling + pub errors: Vec, + /// Validation warnings that don't prevent bundling + pub warnings: Vec, +} + +impl ValidationResult { + /// Creates a new validation result + pub fn new() -> Self { + Self::default() + } + + /// Adds an error to the validation result + pub fn add_error(&mut self, error: String) { + self.errors.push(error); + } + + /// Adds a warning to the validation result + pub fn add_warning(&mut self, warning: String) { + self.warnings.push(warning); + } + + /// Checks if validation passed (no errors) + pub fn is_valid(&self) -> bool { + self.errors.is_empty() + } + + /// Gets all issues (errors + warnings) + pub fn all_issues(&self) -> Vec<&String> { + self.errors.iter().chain(self.warnings.iter()).collect() + } +} + +/// Statistics about bundling operation +#[derive(Debug)] +pub struct BundlingStats { + pub total_targets: usize, + pub page_targets: usize, + pub special_targets: usize, + pub total_content_size: usize, + pub dist_dir: std::path::PathBuf, + pub bundler_options: super::config::BundlerOptions, +} + +impl std::fmt::Display for BundlingStats { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "Bundling Statistics:")?; + writeln!(f, " Total targets: {}", self.total_targets)?; + writeln!(f, " Page targets: {}", self.page_targets)?; + writeln!(f, " Special targets: {}", self.special_targets)?; + writeln!(f, " Total content size: {} bytes", self.total_content_size)?; + writeln!(f, " Output directory: {}", self.dist_dir.display())?; + writeln!(f, " Source maps: {}", self.bundler_options.source_maps)?; + writeln!(f, " Minimize: {}", self.bundler_options.minimize)?; + write!(f, " Target: {}", self.bundler_options.target) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::{config::ClientConfig, target::{BuildTarget, TargetCollection}}; + use std::{path::PathBuf, env}; + + fn create_test_config() -> ClientConfig { + let temp_dir = env::temp_dir().join("metassr_bundling_test"); + let src_dir = temp_dir.join("src"); + std::fs::create_dir_all(&src_dir).unwrap(); + + let config = ClientConfig::new(&temp_dir, "dist").unwrap(); + let _ = std::fs::remove_dir_all(&temp_dir); // Cleanup after creation + config + } + + fn create_test_target() -> BuildTarget { + BuildTarget::new( + "test".to_string(), + PathBuf::from("src/test.js"), + PathBuf::from("test.js"), + "console.log('test')".to_string(), + "/test".to_string(), + false, + ) + } + + #[test] + fn test_bundling_service_creation() { + let config = create_test_config(); + let service = BundlingService::new(config); + + // Service should be created successfully + assert_eq!(service.config.root_id, "root"); + } + + #[test] + fn test_empty_targets_bundling() { + let config = create_test_config(); + let service = BundlingService::new(config); + let targets = TargetCollection::new(); + + let result = service.bundle(&targets).unwrap(); + assert!(result.success); + assert_eq!(result.bundled_targets, 0); + } + + #[test] + fn test_validation_result() { + let mut validation = ValidationResult::new(); + + assert!(validation.is_valid()); + + validation.add_error("Test error".to_string()); + assert!(!validation.is_valid()); + assert_eq!(validation.errors.len(), 1); + + validation.add_warning("Test warning".to_string()); + assert_eq!(validation.warnings.len(), 1); + assert_eq!(validation.all_issues().len(), 2); + } + + #[test] + fn test_bundling_stats() { + let config = create_test_config(); + let service = BundlingService::new(config); + let mut targets = TargetCollection::new(); + targets.add_target(create_test_target()).unwrap(); + + let stats = service.get_bundling_stats(&targets); + assert_eq!(stats.total_targets, 1); + assert_eq!(stats.page_targets, 1); + assert_eq!(stats.special_targets, 0); + } +} diff --git a/crates/metassr-build/src/client/cache.rs b/crates/metassr-build/src/client/cache.rs new file mode 100644 index 0000000..dba7375 --- /dev/null +++ b/crates/metassr-build/src/client/cache.rs @@ -0,0 +1,301 @@ +use anyhow::{Context, Result}; +use metassr_utils::cache_dir::CacheDir; + +use super::{ + config::ClientConfig, + target::TargetCollection, +}; + +/// Service for managing cache operations during the build process +pub struct CacheService { + cache_dir: CacheDir, + config: ClientConfig, +} + +impl CacheService { + /// Creates a new cache service + pub fn new(config: ClientConfig) -> Result { + let cache_dir = CacheDir::new(config.cache_dir_str()) + .context("Failed to initialize cache directory")?; + + Ok(Self { cache_dir, config }) + } + + /// Stores targets in the cache + pub fn store_targets(&mut self, targets: &TargetCollection) -> Result { + let mut stored_files = 0; + let mut total_bytes = 0; + + for target in targets.targets() { + let cache_path = format!("pages/{}", target.output_path.display()); + + self.cache_dir + .insert(&cache_path, target.content_bytes()) + .with_context(|| format!("Failed to cache target: {}", target.id))?; + + stored_files += 1; + total_bytes += target.metadata.content_size; + } + + Ok(CacheStats { + stored_files, + total_bytes, + cache_dir: self.cache_dir.path().to_path_buf(), + }) + } + + /// Retrieves the cache entries suitable for bundling + pub fn get_bundler_entries(&self) -> Result> { + let entries = self.cache_dir + .entries_in_scope() + .iter() + .map(|(entry_name, path)| { + let fullpath = path + .canonicalize() + .with_context(|| format!("Failed to canonicalize cache path: {}", path.display()))?; + + Ok((entry_name.to_owned(), format!("{}", fullpath.display()))) + }) + .collect::>>()?; + + Ok(entries) + } + + /// Clears the cache + pub fn clear_cache(&mut self) -> Result<()> { + // Implementation would depend on CacheDir having a clear method + // For now, we'll recreate the cache directory + let cache_path = self.config.cache_dir_str(); + + if std::path::Path::new(cache_path).exists() { + std::fs::remove_dir_all(cache_path) + .context("Failed to remove cache directory")?; + } + + self.cache_dir = CacheDir::new(cache_path) + .context("Failed to recreate cache directory")?; + + Ok(()) + } + + /// Gets cache statistics + pub fn get_cache_stats(&self) -> CacheStats { + let entries = self.cache_dir.entries_in_scope(); + let stored_files = entries.len(); + let total_bytes = entries + .values() + .filter_map(|path| std::fs::metadata(path).ok()) + .map(|metadata| metadata.len() as usize) + .sum(); + + CacheStats { + stored_files, + total_bytes, + cache_dir: self.cache_dir.path().to_path_buf(), + } + } + + /// Validates cache integrity + pub fn validate_cache(&self) -> Result { + let mut validation = CacheValidation::new(); + let entries = self.cache_dir.entries_in_scope(); + + for (entry_name, path) in entries.iter() { + if !path.exists() { + validation.add_missing_file(entry_name.clone(), path.clone()); + } else if path.is_dir() { + validation.add_warning(format!( + "Cache entry '{}' is a directory, expected a file", + entry_name + )); + } else { + // Check if file is readable + if let Err(e) = std::fs::read(path) { + validation.add_error(format!( + "Cannot read cache file '{}': {}", + entry_name, e + )); + } + } + } + + Ok(validation) + } +} + +/// Statistics about cache operations +#[derive(Debug)] +pub struct CacheStats { + pub stored_files: usize, + pub total_bytes: usize, + pub cache_dir: std::path::PathBuf, +} + +impl std::fmt::Display for CacheStats { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Cache: {} files, {} bytes in {}", + self.stored_files, + self.total_bytes, + self.cache_dir.display() + ) + } +} + +/// Result of cache validation +#[derive(Debug)] +pub struct CacheValidation { + missing_files: Vec<(String, std::path::PathBuf)>, + errors: Vec, + warnings: Vec, +} + +impl CacheValidation { + fn new() -> Self { + Self { + missing_files: Vec::new(), + errors: Vec::new(), + warnings: Vec::new(), + } + } + + fn add_missing_file(&mut self, entry: String, path: std::path::PathBuf) { + self.missing_files.push((entry, path)); + } + + fn add_error(&mut self, error: String) { + self.errors.push(error); + } + + fn add_warning(&mut self, warning: String) { + self.warnings.push(warning); + } + + /// Checks if cache validation passed + pub fn is_valid(&self) -> bool { + self.missing_files.is_empty() && self.errors.is_empty() + } + + /// Gets all validation issues + pub fn issues(&self) -> Vec { + let mut issues = Vec::new(); + + for (entry, path) in &self.missing_files { + issues.push(format!("Missing cache file '{}' at {}", entry, path.display())); + } + + issues.extend(self.errors.iter().cloned()); + issues.extend(self.warnings.iter().cloned()); + + issues + } + + /// Gets the number of missing files + pub fn missing_file_count(&self) -> usize { + self.missing_files.len() + } + + /// Gets the number of errors + pub fn error_count(&self) -> usize { + self.errors.len() + } + + /// Gets the number of warnings + pub fn warning_count(&self) -> usize { + self.warnings.len() + } +} + +impl std::fmt::Display for CacheValidation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.is_valid() { + write!(f, "Cache validation passed") + } else { + write!( + f, + "Cache validation failed: {} missing files, {} errors, {} warnings", + self.missing_file_count(), + self.error_count(), + self.warning_count() + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::target::BuildTarget; + use std::{path::PathBuf, env}; + + fn create_test_config() -> ClientConfig { + let temp_dir = env::temp_dir().join("metassr_cache_test"); + let src_dir = temp_dir.join("src"); + std::fs::create_dir_all(&src_dir).unwrap(); + ClientConfig::new(&temp_dir, "dist").unwrap() + } + + fn create_test_target() -> BuildTarget { + BuildTarget::new( + "test".to_string(), + PathBuf::from("src/test.js"), + PathBuf::from("test.js"), + "console.log('test')".to_string(), + "/test".to_string(), + false, + ) + } + + #[test] + fn test_cache_service_creation() { + let config = create_test_config(); + config.ensure_directories().unwrap(); + + let cache_service = CacheService::new(config); + assert!(cache_service.is_ok()); + + // Cleanup + let _ = std::fs::remove_dir_all(env::temp_dir().join("metassr_cache_test")); + } + + #[test] + fn test_cache_stats() { + let config = create_test_config(); + config.ensure_directories().unwrap(); + + let cache_service = CacheService::new(config).unwrap(); + let stats = cache_service.get_cache_stats(); + + assert_eq!(stats.stored_files, 0); // Empty cache initially + + // Cleanup + let _ = std::fs::remove_dir_all(env::temp_dir().join("metassr_cache_test")); + } + + #[test] + fn test_cache_validation() { + let config = create_test_config(); + config.ensure_directories().unwrap(); + + let cache_service = CacheService::new(config).unwrap(); + let validation = cache_service.validate_cache().unwrap(); + + assert!(validation.is_valid()); // Empty cache should be valid + + // Cleanup + let _ = std::fs::remove_dir_all(env::temp_dir().join("metassr_cache_test")); + } + + #[test] + fn test_cache_validation_display() { + let mut validation = CacheValidation::new(); + validation.add_error("Test error".to_string()); + validation.add_missing_file("test".to_string(), PathBuf::from("/missing")); + + let display_text = format!("{}", validation); + assert!(display_text.contains("Cache validation failed")); + assert!(display_text.contains("1 missing files")); + assert!(display_text.contains("1 errors")); + } +} diff --git a/crates/metassr-build/src/client/config.rs b/crates/metassr-build/src/client/config.rs new file mode 100644 index 0000000..5e69b5e --- /dev/null +++ b/crates/metassr-build/src/client/config.rs @@ -0,0 +1,243 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + ffi::OsStr, + path::{Path, PathBuf}, +}; + +/// Configuration for the client building process +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientConfig { + /// Root directory of the project + pub root_dir: PathBuf, + /// Source directory (usually "src") + pub src_dir: PathBuf, + /// Distribution directory + pub dist_dir: PathBuf, + /// Cache directory for intermediate files + pub cache_dir: PathBuf, + /// Root element ID for hydration + pub root_id: String, + /// File extension for generated files + pub output_extension: String, + /// Custom bundler options + pub bundler_options: BundlerOptions, +} + +/// Configuration for the bundler +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BundlerOptions { + /// Whether to enable source maps + pub source_maps: bool, + /// Whether to minimize output + pub minimize: bool, + /// Target environment (web, node, etc.) + pub target: String, + /// Public path for assets + pub public_path: String, + /// Additional entry points + pub additional_entries: HashMap, +} + +impl Default for BundlerOptions { + fn default() -> Self { + Self { + source_maps: true, + minimize: false, + target: "web".to_string(), + public_path: "".to_string(), + additional_entries: HashMap::new(), + } + } +} + +impl ClientConfig { + /// Creates a new ClientConfig with default settings + pub fn new(root: &S, dist_dir: &str) -> Result + where + S: AsRef + ?Sized, + { + let root_dir = Path::new(root).to_path_buf(); + let src_dir = root_dir.join("src"); + let dist_path = root_dir.join(dist_dir); + let cache_dir = dist_path.join("cache"); + + Self::validate_directories(&root_dir, &src_dir)?; + + Ok(Self { + root_dir, + src_dir, + dist_dir: dist_path, + cache_dir, + root_id: "root".to_string(), + output_extension: "js".to_string(), + bundler_options: BundlerOptions::default(), + }) + } + + /// Creates a custom ClientConfig + pub fn builder() -> ClientConfigBuilder { + ClientConfigBuilder::default() + } + + /// Validates that required directories exist or can be created + fn validate_directories(root_dir: &Path, src_dir: &Path) -> Result<()> { + if !root_dir.exists() { + return Err(anyhow::anyhow!( + "Root directory does not exist: {}", + root_dir.display() + )); + } + + if !src_dir.exists() { + return Err(anyhow::anyhow!( + "Source directory does not exist: {}", + src_dir.display() + )); + } + + Ok(()) + } + + /// Ensures all required directories exist + pub fn ensure_directories(&self) -> Result<()> { + if !self.dist_dir.exists() { + std::fs::create_dir_all(&self.dist_dir) + .with_context(|| format!("Failed to create dist directory: {}", self.dist_dir.display()))?; + } + + if !self.cache_dir.exists() { + std::fs::create_dir_all(&self.cache_dir) + .with_context(|| format!("Failed to create cache directory: {}", self.cache_dir.display()))?; + } + + Ok(()) + } + + /// Gets the cache directory path as a string + pub fn cache_dir_str(&self) -> &str { + self.cache_dir.to_str().unwrap_or_default() + } + + /// Gets the dist directory path as a string + pub fn dist_dir_str(&self) -> &str { + self.dist_dir.to_str().unwrap_or_default() + } +} + +/// Builder for ClientConfig +#[derive(Default)] +pub struct ClientConfigBuilder { + root_dir: Option, + src_dir: Option, + dist_dir: Option, + cache_dir: Option, + root_id: Option, + output_extension: Option, + bundler_options: Option, +} + +impl ClientConfigBuilder { + pub fn root_dir>(mut self, path: P) -> Self { + self.root_dir = Some(path.as_ref().to_path_buf()); + self + } + + pub fn src_dir>(mut self, path: P) -> Self { + self.src_dir = Some(path.as_ref().to_path_buf()); + self + } + + pub fn dist_dir>(mut self, path: P) -> Self { + self.dist_dir = Some(path.as_ref().to_path_buf()); + self + } + + pub fn cache_dir>(mut self, path: P) -> Self { + self.cache_dir = Some(path.as_ref().to_path_buf()); + self + } + + pub fn root_id>(mut self, id: S) -> Self { + self.root_id = Some(id.into()); + self + } + + pub fn output_extension>(mut self, ext: S) -> Self { + self.output_extension = Some(ext.into()); + self + } + + pub fn bundler_options(mut self, options: BundlerOptions) -> Self { + self.bundler_options = Some(options); + self + } + + pub fn build(self) -> Result { + let root_dir = self.root_dir.ok_or_else(|| anyhow::anyhow!("Root directory is required"))?; + let src_dir = self.src_dir.unwrap_or_else(|| root_dir.join("src")); + let dist_dir = self.dist_dir.unwrap_or_else(|| root_dir.join("dist")); + let cache_dir = self.cache_dir.unwrap_or_else(|| dist_dir.join("cache")); + + ClientConfig::validate_directories(&root_dir, &src_dir)?; + + Ok(ClientConfig { + root_dir, + src_dir, + dist_dir, + cache_dir, + root_id: self.root_id.unwrap_or_else(|| "root".to_string()), + output_extension: self.output_extension.unwrap_or_else(|| "js".to_string()), + bundler_options: self.bundler_options.unwrap_or_default(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_client_config_creation() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + std::fs::create_dir(root.join("src")).unwrap(); + + let config = ClientConfig::new(root, "dist").unwrap(); + assert_eq!(config.root_dir, root); + assert_eq!(config.src_dir, root.join("src")); + assert_eq!(config.dist_dir, root.join("dist")); + } + + #[test] + fn test_client_config_builder() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + std::fs::create_dir(root.join("src")).unwrap(); + + let config = ClientConfig::builder() + .root_dir(root) + .root_id("app") + .output_extension("mjs") + .build() + .unwrap(); + + assert_eq!(config.root_id, "app"); + assert_eq!(config.output_extension, "mjs"); + } + + #[test] + fn test_ensure_directories() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + std::fs::create_dir(root.join("src")).unwrap(); + + let config = ClientConfig::new(root, "dist").unwrap(); + config.ensure_directories().unwrap(); + + assert!(config.dist_dir.exists()); + assert!(config.cache_dir.exists()); + } +} diff --git a/crates/metassr-build/src/client/hydrator.rs b/crates/metassr-build/src/client/hydrator.rs index 6d13f3f..74fec27 100644 --- a/crates/metassr-build/src/client/hydrator.rs +++ b/crates/metassr-build/src/client/hydrator.rs @@ -2,20 +2,33 @@ use crate::{ shared::{APP_PATH_TAG, PAGE_PATH_TAG, ROOT_ID_TAG}, traits::Generate, }; -use anyhow::Result; -use std::{ffi::OsStr, path::PathBuf}; +use anyhow::{Context, Result}; +use std::{ + collections::HashMap, + ffi::OsStr, + path::{Path, PathBuf}, +}; const HYDRATED_FILE_TEMPLATE: &str = include_str!("../scripts/hydrate.js.template"); +/// Configuration for hydration generation #[derive(Debug, Clone)] -pub struct Hydrator { - app_path: PathBuf, - page_path: PathBuf, - root_id: String, +pub struct HydrationConfig { + /// Path to the app component + pub app_path: PathBuf, + /// Path to the page component + pub page_path: PathBuf, + /// Root element ID for hydration + pub root_id: String, + /// Additional template variables + pub template_vars: HashMap, + /// Whether to use strict mode + pub strict_mode: bool, } -impl Hydrator { - pub fn new<'a, S>(app_path: &'a S, page_path: &'a S, root_id: &'a str) -> Self +impl HydrationConfig { + /// Creates a new hydration config + pub fn new(app_path: &S, page_path: &S, root_id: &str) -> Self where S: AsRef + ?Sized, { @@ -23,35 +36,288 @@ impl Hydrator { app_path: PathBuf::from(app_path), page_path: PathBuf::from(page_path), root_id: root_id.to_string(), + template_vars: HashMap::new(), + strict_mode: true, } } + + /// Builder pattern for configuration + pub fn builder() -> HydrationConfigBuilder { + HydrationConfigBuilder::default() + } + + /// Adds a template variable + pub fn with_var(mut self, key: K, value: V) -> Self + where + K: Into, + V: Into, + { + self.template_vars.insert(key.into(), value.into()); + self + } + + /// Sets strict mode + pub fn with_strict_mode(mut self, strict_mode: bool) -> Self { + self.strict_mode = strict_mode; + self + } + + /// Validates the configuration + pub fn validate(&self) -> Result<()> { + if !self.app_path.exists() { + return Err(anyhow::anyhow!( + "App component not found: {}", + self.app_path.display() + )); + } + + if !self.page_path.exists() { + return Err(anyhow::anyhow!( + "Page component not found: {}", + self.page_path.display() + )); + } + + if self.root_id.is_empty() { + return Err(anyhow::anyhow!("Root ID cannot be empty")); + } + + Ok(()) + } +} + +/// Builder for HydrationConfig +#[derive(Default)] +pub struct HydrationConfigBuilder { + app_path: Option, + page_path: Option, + root_id: Option, + template_vars: HashMap, + strict_mode: bool, +} + +impl HydrationConfigBuilder { + pub fn app_path>(mut self, path: P) -> Self { + self.app_path = Some(path.as_ref().to_path_buf()); + self + } + + pub fn page_path>(mut self, path: P) -> Self { + self.page_path = Some(path.as_ref().to_path_buf()); + self + } + + pub fn root_id>(mut self, id: S) -> Self { + self.root_id = Some(id.into()); + self + } + + pub fn template_var(mut self, key: K, value: V) -> Self + where + K: Into, + V: Into, + { + self.template_vars.insert(key.into(), value.into()); + self + } + + pub fn strict_mode(mut self, strict_mode: bool) -> Self { + self.strict_mode = strict_mode; + self + } + + pub fn build(self) -> Result { + let app_path = self.app_path.ok_or_else(|| anyhow::anyhow!("App path is required"))?; + let page_path = self.page_path.ok_or_else(|| anyhow::anyhow!("Page path is required"))?; + let root_id = self.root_id.unwrap_or_else(|| "root".to_string()); + + let config = HydrationConfig { + app_path, + page_path, + root_id, + template_vars: self.template_vars, + strict_mode: self.strict_mode, + }; + + config.validate()?; + Ok(config) + } +} + +/// Generates hydration scripts for client-side rendering +#[derive(Debug)] +pub struct Hydrator { + config: HydrationConfig, +} + +impl Hydrator { + /// Creates a new hydrator with the given configuration + pub fn new(config: HydrationConfig) -> Self { + Self { config } + } + + /// Creates a hydrator with simple parameters (legacy compatibility) + pub fn simple(app_path: &S, page_path: &S, root_id: &str) -> Result + where + S: AsRef + ?Sized, + { + let config = HydrationConfig::new(app_path, page_path, root_id); + config.validate()?; + Ok(Self::new(config)) + } + + /// Generates the hydration script content + fn generate_content(&self) -> Result { + let app_path_canonical = self.config.app_path + .canonicalize() + .with_context(|| format!("Failed to canonicalize app path: {}", self.config.app_path.display()))?; + + let page_path_canonical = self.config.page_path + .canonicalize() + .with_context(|| format!("Failed to canonicalize page path: {}", self.config.page_path.display()))?; + + let app_path_str = app_path_canonical + .to_str() + .ok_or_else(|| anyhow::anyhow!("App path contains invalid UTF-8"))?; + + let page_path_str = page_path_canonical + .to_str() + .ok_or_else(|| anyhow::anyhow!("Page path contains invalid UTF-8"))?; + + let mut content = HYDRATED_FILE_TEMPLATE + .replace(APP_PATH_TAG, app_path_str) + .replace(PAGE_PATH_TAG, page_path_str) + .replace(ROOT_ID_TAG, &self.config.root_id); + + // Apply additional template variables + for (key, value) in &self.config.template_vars { + let placeholder = format!("%{}%", key.to_uppercase()); + content = content.replace(&placeholder, value); + } + + // Handle strict mode + if !self.config.strict_mode { + content = content.replace("React.StrictMode", "React.Fragment"); + } + + Ok(content) + } + + /// Gets the configuration + pub fn config(&self) -> &HydrationConfig { + &self.config + } + + /// Updates the configuration + pub fn with_config(mut self, config: HydrationConfig) -> Result { + config.validate()?; + self.config = config; + Ok(self) + } } impl Generate for Hydrator { type Output = String; + fn generate(&self) -> Result { - Ok(HYDRATED_FILE_TEMPLATE - .replace( - APP_PATH_TAG, - self.app_path.canonicalize()?.to_str().unwrap(), - ) - .replace( - PAGE_PATH_TAG, - self.page_path.canonicalize()?.to_str().unwrap(), - ) - .replace(ROOT_ID_TAG, &self.root_id)) + self.generate_content() } } #[cfg(test)] mod tests { use super::*; + use std::env; + + fn create_test_files() -> (PathBuf, PathBuf) { + let temp_dir = env::temp_dir().join("metassr_test"); + std::fs::create_dir_all(&temp_dir).unwrap(); + + let app_path = temp_dir.join("_app.tsx"); + let page_path = temp_dir.join("home.jsx"); + + // Create test files + std::fs::write(&app_path, "export default function App() { return null; }").unwrap(); + std::fs::write(&page_path, "export default function Page() { return null; }").unwrap(); + + (app_path, page_path) + } + + #[test] + fn test_hydration_config_creation() { + let (app_path, page_path) = create_test_files(); + + let config = HydrationConfig::new(&app_path, &page_path, "root"); + assert_eq!(config.root_id, "root"); + assert!(config.strict_mode); + + // Cleanup + let _ = std::fs::remove_file(&app_path); + let _ = std::fs::remove_file(&page_path); + } + + #[test] + fn test_hydration_config_builder() { + let (app_path, page_path) = create_test_files(); + + let config = HydrationConfig::builder() + .app_path(&app_path) + .page_path(&page_path) + .root_id("app") + .template_var("CUSTOM_VAR", "custom_value") + .strict_mode(false) + .build() + .unwrap(); + + assert_eq!(config.root_id, "app"); + assert!(!config.strict_mode); + assert_eq!(config.template_vars.get("CUSTOM_VAR"), Some(&"custom_value".to_string())); + + // Cleanup + let _ = std::fs::remove_file(&app_path); + let _ = std::fs::remove_file(&page_path); + } + + #[test] + fn test_hydrator_simple_creation() { + let (app_path, page_path) = create_test_files(); + + let hydrator = Hydrator::simple(&app_path, &page_path, "root").unwrap(); + assert_eq!(hydrator.config().root_id, "root"); + + // Cleanup + let _ = std::fs::remove_file(&app_path); + let _ = std::fs::remove_file(&page_path); + } + + #[test] + fn test_generate_hydrated_file() { + let (app_path, page_path) = create_test_files(); + + let hydrator = Hydrator::simple(&app_path, &page_path, "root").unwrap(); + let content = hydrator.generate().unwrap(); + + assert!(content.contains("hydrateRoot")); + assert!(content.contains("document.getElementById(\"root\")")); + + // Cleanup + let _ = std::fs::remove_file(&app_path); + let _ = std::fs::remove_file(&page_path); + } + #[test] - fn generate_hydrated_file() { - println!( - "{}", - Hydrator::new("src/_app.tsx", "src/pages/home.jsx", "root") - .generate() - .unwrap() - ); + fn test_template_variables() { + let (app_path, page_path) = create_test_files(); + + let config = HydrationConfig::new(&app_path, &page_path, "root") + .with_var("CUSTOM_VAR", "test_value"); + + let hydrator = Hydrator::new(config); + + // Verify the config has the variable + assert_eq!(hydrator.config().template_vars.get("CUSTOM_VAR"), Some(&"test_value".to_string())); + + // Cleanup + let _ = std::fs::remove_file(&app_path); + let _ = std::fs::remove_file(&page_path); } } diff --git a/crates/metassr-build/src/client/mod.rs b/crates/metassr-build/src/client/mod.rs index 7fecef5..a5eff39 100644 --- a/crates/metassr-build/src/client/mod.rs +++ b/crates/metassr-build/src/client/mod.rs @@ -1,94 +1,252 @@ use crate::traits::{Build, Generate}; -use crate::utils::setup_page_path; -use anyhow::{anyhow, Result}; -use hydrator::Hydrator; - -use metassr_bundler::WebBundler; -use metassr_fs_analyzer::{ - src_dir::{special_entries, SourceDir}, - DirectoryAnalyzer, -}; -use metassr_utils::cache_dir::CacheDir; - -use std::{ - collections::HashMap, - ffi::OsStr, - fs, - path::{Path, PathBuf}, -}; +use anyhow::{Context, Result}; +use metassr_fs_analyzer::src_dir::special_entries; +use metassr_fs_analyzer::{src_dir::SourceDir, DirectoryAnalyzer}; + +pub mod bundling; +pub mod cache; +pub mod config; pub mod hydrator; +pub mod target; + +use bundling::BundlingService; +use cache::CacheService; +use config::ClientConfig; +use hydrator::{HydrationConfig, Hydrator}; +use target::{TargetBuilder, TargetCollection}; +use tracing::debug; +/// Enhanced client builder with improved architecture and error handling pub struct ClientBuilder { - src_path: PathBuf, - dist_path: PathBuf, + config: ClientConfig, } impl ClientBuilder { - pub fn new(root: &S, dist_dir: &str) -> Result + /// Creates a new ClientBuilder with the given configuration + pub fn new(config: ClientConfig) -> Self { + Self { config } + } + + /// Creates a ClientBuilder with simple parameters (legacy compatibility) + pub fn simple(root: &S, dist_dir: &str) -> Result where - S: AsRef + ?Sized, + S: AsRef + ?Sized, { - let root = Path::new(root); - let src_path = root.join("src"); - let dist_path = root.join(dist_dir); + let config = ClientConfig::new(root, dist_dir)?; + Ok(Self::new(config)) + } - if !src_path.exists() { - return Err(anyhow!("src directory not found.")); - } - if !dist_path.exists() { - fs::create_dir(&dist_path)?; - } - Ok(Self { - src_path, - dist_path, - }) + /// Creates a ClientBuilder with custom configuration + pub fn with_config(config: ClientConfig) -> Result { + config.ensure_directories()?; + Ok(Self::new(config)) } -} -impl Build for ClientBuilder { - type Output = (); - fn build(&self) -> Result { - let mut cache_dir = CacheDir::new(&format!("{}/cache", self.dist_path.display()))?; - let src = SourceDir::new(&self.src_path).analyze()?; + /// Gets the current configuration + pub fn config(&self) -> &ClientConfig { + &self.config + } + + /// Updates the configuration + pub fn with_updated_config(mut self, config: ClientConfig) -> Result { + config.ensure_directories()?; + self.config = config; + Ok(self) + } + + /// Builds the client-side code with detailed logging and error handling + fn build_internal(&self) -> Result { + // Ensure directories exist + self.config.ensure_directories() + .context("Failed to ensure required directories exist")?; + + // Initialize services + let mut cache_service = CacheService::new(self.config.clone()) + .context("Failed to initialize cache service")?; + + let bundling_service = BundlingService::new(self.config.clone()); + + // Analyze source directory + let src = SourceDir::new(&self.config.src_dir) + .analyze() + .context("Failed to analyze source directory")?; let pages = src.pages(); - let (special_entries::App(app_path), _) = src.specials()?; + let (special_entries::App(app_path), _) = src.specials() + .context("Failed to find required special entries (like _app)")?; + + // Generate targets + let mut targets = TargetCollection::new(); + + for page in pages.iter() { + // Create hydration configuration for this page + let hydration_config = HydrationConfig::builder() + .app_path(&app_path) + .page_path(&page.info.path) + .root_id(&self.config.root_id) + .strict_mode(true) + .build() + .with_context(|| format!("Failed to create hydration config for page: {}", page.id))?; + + // Generate hydration script + let hydrator = Hydrator::new(hydration_config); + let hydration_content = hydrator.generate() + .with_context(|| format!("Failed to generate hydration script for page: {}", page.id))?; - for (page, page_path) in pages.iter() { - let hydrator = Hydrator::new(&app_path, page_path, "root").generate()?; - let page = setup_page_path(page, "js"); + // Create build target + let target = TargetBuilder::from_page(page, hydration_content, &self.config.output_extension) + .with_context(|| format!("Failed to create build target for page: {}", page.id))?; - cache_dir.insert(&format!("pages/{}", page.display()), hydrator.as_bytes())?; + targets.add_target(target) + .with_context(|| format!("Failed to add target for page: {}", page.id))?; } - let targets = cache_dir - .entries_in_scope() - .iter() - .map(|(entry_name, path)| { - let fullpath = path.canonicalize().unwrap(); + // Validate targets before proceeding + let validation = bundling_service.validate_targets(&targets) + .context("Failed to validate build targets")?; - (entry_name.to_owned(), format!("{}", fullpath.display())) - }) - .collect::>(); + if !validation.is_valid() { + return Err(anyhow::anyhow!( + "Target validation failed: {:?}", + validation.errors + )); + } + + // Store targets in cache + let cache_stats = cache_service.store_targets(&targets) + .context("Failed to store targets in cache")?; + + // Bundle the cached files + let bundling_result = bundling_service.bundle(&targets) + .context("Failed to bundle client code")?; - let bundler = WebBundler::new(&targets, &self.dist_path)?; - if let Err(e) = bundler.exec() { - return Err(anyhow!("Bundling failed: {e}")); + if !bundling_result.success { + return Err(anyhow::anyhow!( + "Bundling failed: {:?}", + bundling_result.messages + )); } + Ok(ClientBuildResult { + targets_processed: targets.len(), + cache_stats, + bundling_result, + target_stats: targets.stats(), + }) + } +} + +impl Build for ClientBuilder { + type Output = (); + + fn build(&self) -> Result { + let result = self.build_internal() + .context("Client build process failed")?; + + // Log build statistics + debug!("Client build completed successfully"); + debug!("Processed {} targets", result.targets_processed); + debug!("Cache: {}", result.cache_stats); + debug!("Bundling: {} targets bundled", result.bundling_result.bundled_targets); + Ok(()) } } +/// Result of a client build operation +#[derive(Debug)] +pub struct ClientBuildResult { + pub targets_processed: usize, + pub cache_stats: cache::CacheStats, + pub bundling_result: bundling::BundlingResult, + pub target_stats: target::TargetCollectionStats, +} + +impl std::fmt::Display for ClientBuildResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "Client Build Result:")?; + writeln!(f, " Targets processed: {}", self.targets_processed)?; + writeln!(f, " {}", self.cache_stats)?; + writeln!(f, " Bundled targets: {}", self.bundling_result.bundled_targets)?; + write!(f, " {}", self.target_stats) + } +} + #[cfg(test)] mod tests { use super::*; + use std::env; + + fn create_test_env() -> std::path::PathBuf { + let temp_dir = env::temp_dir().join("metassr_client_test"); + let src_dir = temp_dir.join("src"); + let pages_dir = src_dir.join("pages"); + + // Create directories + std::fs::create_dir_all(&pages_dir).unwrap(); + + // Create _app.tsx + let app_content = r#" +import React from 'react'; + +export default function App({ Component }) { + return ; +} +"#; + std::fs::write(src_dir.join("_app.tsx"), app_content).unwrap(); + + // Create a test page + let page_content = r#" +import React from 'react'; + +export default function Home() { + return
Home Page
; +} +"#; + std::fs::write(pages_dir.join("home.tsx"), page_content).unwrap(); + + temp_dir + } + + #[test] + fn test_client_builder_creation() { + let test_dir = create_test_env(); + + let config = ClientConfig::new(&test_dir, "dist").unwrap(); + let builder = ClientBuilder::new(config); + + assert_eq!(builder.config().root_id, "root"); + + // Cleanup + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[test] + fn test_client_builder_simple() { + let test_dir = create_test_env(); + + let builder = ClientBuilder::simple(&test_dir, "dist").unwrap(); + assert_eq!(builder.config().root_id, "root"); + + // Cleanup + let _ = std::fs::remove_dir_all(&test_dir); + } + #[test] - fn client_builder() { - ClientBuilder::new("../../tests/web-app", "../../tests/web-app/dist") - .unwrap() + fn test_client_config_builder() { + let test_dir = create_test_env(); + + let config = ClientConfig::builder() + .root_dir(&test_dir) + .root_id("app") .build() .unwrap(); + + let builder = ClientBuilder::with_config(config).unwrap(); + assert_eq!(builder.config().root_id, "app"); + + // Cleanup + let _ = std::fs::remove_dir_all(&test_dir); } } diff --git a/crates/metassr-build/src/client/target.rs b/crates/metassr-build/src/client/target.rs new file mode 100644 index 0000000..b52a043 --- /dev/null +++ b/crates/metassr-build/src/client/target.rs @@ -0,0 +1,344 @@ +use anyhow::{Context, Result}; +use std::{ + collections::HashMap, + path::PathBuf, +}; + +use metassr_fs_analyzer::src_dir::Page; + +/// Represents a build target with metadata +#[derive(Debug, Clone)] +pub struct BuildTarget { + /// Unique identifier for the target + pub id: String, + /// Source file path + pub source_path: PathBuf, + /// Output file path relative to dist directory + pub output_path: PathBuf, + /// Generated content for this target + pub content: String, + /// Metadata about the target + pub metadata: TargetMetadata, +} + +/// Metadata associated with a build target +#[derive(Debug, Clone)] +pub struct TargetMetadata { + /// Route associated with this target + pub route: String, + /// Whether this is a special entry (like _app) + pub is_special: bool, + /// File type/extension + pub file_type: String, + /// Size of the content in bytes + pub content_size: usize, +} + +impl BuildTarget { + /// Creates a new build target + pub fn new( + id: String, + source_path: PathBuf, + output_path: PathBuf, + content: String, + route: String, + is_special: bool, + ) -> Self { + let content_size = content.len(); + let file_type = source_path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("unknown") + .to_string(); + + Self { + id, + source_path, + output_path, + content, + metadata: TargetMetadata { + route, + is_special, + file_type, + content_size, + }, + } + } + + /// Gets the content as bytes + pub fn content_bytes(&self) -> &[u8] { + self.content.as_bytes() + } + + /// Gets the absolute source path if it exists + pub fn absolute_source_path(&self) -> Result { + self.source_path + .canonicalize() + .with_context(|| format!("Failed to canonicalize path: {}", self.source_path.display())) + } + + /// Gets the output path as a string + pub fn output_path_str(&self) -> &str { + self.output_path.to_str().unwrap_or_default() + } +} + +/// Collection of build targets with utilities for managing them +#[derive(Debug, Default)] +pub struct TargetCollection { + targets: Vec, + target_map: HashMap, +} + +impl TargetCollection { + /// Creates a new empty target collection + pub fn new() -> Self { + Self::default() + } + + /// Adds a target to the collection + pub fn add_target(&mut self, target: BuildTarget) -> Result<()> { + if self.target_map.contains_key(&target.id) { + return Err(anyhow::anyhow!("Target with id '{}' already exists", target.id)); + } + + let index = self.targets.len(); + self.target_map.insert(target.id.clone(), index); + self.targets.push(target); + Ok(()) + } + + /// Gets a target by ID + pub fn get_target(&self, id: &str) -> Option<&BuildTarget> { + self.target_map.get(id).and_then(|&index| self.targets.get(index)) + } + + /// Gets a mutable reference to a target by ID + pub fn get_target_mut(&mut self, id: &str) -> Option<&mut BuildTarget> { + self.target_map.get(id).and_then(|&index| self.targets.get_mut(index)) + } + + /// Gets all targets + pub fn targets(&self) -> &[BuildTarget] { + &self.targets + } + + /// Gets all targets mutably + pub fn targets_mut(&mut self) -> &mut [BuildTarget] { + &mut self.targets + } + + /// Removes a target by ID + pub fn remove_target(&mut self, id: &str) -> Option { + if let Some(&index) = self.target_map.get(id) { + self.target_map.remove(id); + // Update indices for targets after the removed one + for (_, target_index) in self.target_map.iter_mut() { + if *target_index > index { + *target_index -= 1; + } + } + Some(self.targets.remove(index)) + } else { + None + } + } + + /// Gets the number of targets + pub fn len(&self) -> usize { + self.targets.len() + } + + /// Checks if the collection is empty + pub fn is_empty(&self) -> bool { + self.targets.is_empty() + } + + /// Filters targets by a predicate + pub fn filter_targets(&self, predicate: F) -> Vec<&BuildTarget> + where + F: Fn(&BuildTarget) -> bool, + { + self.targets.iter().filter(|target| predicate(target)).collect() + } + + /// Gets targets that are pages (not special entries) + pub fn page_targets(&self) -> Vec<&BuildTarget> { + self.filter_targets(|target| !target.metadata.is_special) + } + + /// Gets special targets (like _app) + pub fn special_targets(&self) -> Vec<&BuildTarget> { + self.filter_targets(|target| target.metadata.is_special) + } + + /// Converts targets to a HashMap suitable for bundling + pub fn to_bundler_targets(&self) -> Result> { + let mut bundler_targets = HashMap::new(); + + for target in &self.targets { + let absolute_path = target.absolute_source_path()?; + bundler_targets.insert( + target.id.clone(), + absolute_path.to_string_lossy().to_string(), + ); + } + + Ok(bundler_targets) + } + + /// Gets statistics about the collection + pub fn stats(&self) -> TargetCollectionStats { + let total_targets = self.targets.len(); + let page_targets = self.page_targets().len(); + let special_targets = self.special_targets().len(); + let total_content_size = self.targets.iter().map(|t| t.metadata.content_size).sum(); + + TargetCollectionStats { + total_targets, + page_targets, + special_targets, + total_content_size, + } + } +} + +/// Statistics about a target collection +#[derive(Debug)] +pub struct TargetCollectionStats { + pub total_targets: usize, + pub page_targets: usize, + pub special_targets: usize, + pub total_content_size: usize, +} + +impl std::fmt::Display for TargetCollectionStats { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Targets: {} total ({} pages, {} special), Content size: {} bytes", + self.total_targets, + self.page_targets, + self.special_targets, + self.total_content_size + ) + } +} + +/// Builder for creating build targets from pages +pub struct TargetBuilder; + +impl TargetBuilder { + /// Creates a build target from a page and generated content + pub fn from_page( + page: &Page, + content: String, + output_extension: &str, + ) -> Result { + let page_path = crate::utils::setup_page_path(&page.info.route, output_extension); + let target_id = format!("pages/{}", page_path.display()); + + Ok(BuildTarget::new( + target_id, + page.info.path.clone(), + page_path, + content, + page.info.route.clone(), + false, // Pages are not special entries + )) + } + + /// Creates a build target for a special entry + pub fn from_special( + id: String, + source_path: PathBuf, + content: String, + route: String, + ) -> BuildTarget { + let output_path = PathBuf::from(&id); + + BuildTarget::new( + id, + source_path, + output_path, + content, + route, + true, // This is a special entry + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_target_collection() { + let mut collection = TargetCollection::new(); + + let target = BuildTarget::new( + "test".to_string(), + PathBuf::from("src/test.js"), + PathBuf::from("test.js"), + "console.log('test')".to_string(), + "/test".to_string(), + false, + ); + + collection.add_target(target).unwrap(); + assert_eq!(collection.len(), 1); + assert!(collection.get_target("test").is_some()); + } + + #[test] + fn test_target_filtering() { + let mut collection = TargetCollection::new(); + + let page_target = BuildTarget::new( + "page".to_string(), + PathBuf::from("src/page.js"), + PathBuf::from("page.js"), + "page content".to_string(), + "/page".to_string(), + false, + ); + + let special_target = BuildTarget::new( + "app".to_string(), + PathBuf::from("src/_app.js"), + PathBuf::from("_app.js"), + "app content".to_string(), + "/_app".to_string(), + true, + ); + + collection.add_target(page_target).unwrap(); + collection.add_target(special_target).unwrap(); + + assert_eq!(collection.page_targets().len(), 1); + assert_eq!(collection.special_targets().len(), 1); + } + + #[test] + fn test_target_stats() { + let mut collection = TargetCollection::new(); + + let target = BuildTarget::new( + "test".to_string(), + PathBuf::from("src/test.js"), + PathBuf::from("test.js"), + "hello world".to_string(), // 11 bytes + "/test".to_string(), + false, + ); + + collection.add_target(target).unwrap(); + let stats = collection.stats(); + + assert_eq!(stats.total_targets, 1); + assert_eq!(stats.page_targets, 1); + assert_eq!(stats.special_targets, 0); + assert_eq!(stats.total_content_size, 11); + } +} diff --git a/crates/metassr-build/src/lib.rs b/crates/metassr-build/src/lib.rs index bac06db..9937021 100644 --- a/crates/metassr-build/src/lib.rs +++ b/crates/metassr-build/src/lib.rs @@ -3,3 +3,14 @@ pub mod server; pub(crate) mod shared; pub mod traits; pub(crate) mod utils; + +// Re-export commonly used types for convenience +pub use client::{ + config::{ClientConfig, BundlerOptions}, + ClientBuilder, +}; +pub use server::{ + config::{ServerConfig, BuildingType, ServerBundlerOptions, ManifestOptions}, + ServerSideBuilder, +}; +pub use traits::{Build, Generate, Exec}; diff --git a/crates/metassr-build/src/server/cache.rs b/crates/metassr-build/src/server/cache.rs new file mode 100644 index 0000000..38e6138 --- /dev/null +++ b/crates/metassr-build/src/server/cache.rs @@ -0,0 +1,304 @@ +use anyhow::{Context, Result}; +use metassr_utils::cache_dir::CacheDir; + +use super::{config::ServerConfig, target::ServerTargetCollection}; + +/// Service for managing cache operations during server build process +pub struct ServerCacheService { + cache_dir: CacheDir, + config: ServerConfig, +} + +impl ServerCacheService { + /// Creates a new server cache service + pub fn new(config: ServerConfig) -> Result { + let cache_dir = CacheDir::new(config.cache_dir_str()) + .context("Failed to initialize server cache directory")?; + + Ok(Self { cache_dir, config }) + } + + /// Stores a target in the cache and returns the cached path + pub fn store_target(&mut self, cache_path: &str, content: &[u8]) -> Result { + self.cache_dir + .insert(cache_path, content) + .with_context(|| format!("Failed to cache target at: {}", cache_path)) + } + + /// Stores multiple targets in the cache + pub fn store_targets(&mut self, targets: &ServerTargetCollection) -> Result { + let mut stored_files = 0; + let mut total_bytes = 0; + + for target in targets.targets() { + let cache_path = format!("pages/{}", target.id); + + self.cache_dir + .insert(&cache_path, target.content_bytes()) + .with_context(|| format!("Failed to cache server target: {}", target.id))?; + + stored_files += 1; + total_bytes += target.metadata.content_size; + } + + Ok(ServerCacheStats { + stored_files, + total_bytes, + cache_dir: self.cache_dir.path().to_path_buf(), + }) + } + + /// Retrieves the cache entries suitable for bundling + pub fn get_bundler_entries(&self) -> Result> { + let entries = self + .cache_dir + .entries_in_scope() + .iter() + .map(|(entry_name, path)| { + let fullpath = path.canonicalize().with_context(|| { + format!("Failed to canonicalize cache path: {}", path.display()) + })?; + + Ok((entry_name.to_owned(), format!("{}", fullpath.display()))) + }) + .collect::>>()?; + + Ok(entries) + } + + /// Gets the cache directory reference + pub fn cache_dir(&self) -> &CacheDir { + &self.cache_dir + } + + /// Gets a mutable reference to the cache directory + pub fn cache_dir_mut(&mut self) -> &mut CacheDir { + &mut self.cache_dir + } + + /// Clears the cache + pub fn clear_cache(&mut self) -> Result<()> { + let cache_path = self.config.cache_dir_str(); + + if std::path::Path::new(cache_path).exists() { + std::fs::remove_dir_all(cache_path) + .context("Failed to remove server cache directory")?; + } + + self.cache_dir = + CacheDir::new(cache_path).context("Failed to recreate server cache directory")?; + + Ok(()) + } + + /// Gets cache statistics + pub fn get_cache_stats(&self) -> ServerCacheStats { + let entries = self.cache_dir.entries_in_scope(); + let stored_files = entries.len(); + let total_bytes = entries + .values() + .filter_map(|path| std::fs::metadata(path).ok()) + .map(|metadata| metadata.len() as usize) + .sum(); + + ServerCacheStats { + stored_files, + total_bytes, + cache_dir: self.cache_dir.path().to_path_buf(), + } + } + + /// Validates cache integrity + pub fn validate_cache(&self) -> Result { + let mut validation = ServerCacheValidation::new(); + let entries = self.cache_dir.entries_in_scope(); + + for (entry_name, path) in entries.iter() { + if !path.exists() { + validation.add_missing_file(entry_name.clone(), path.clone()); + } else if path.is_dir() { + validation.add_warning(format!( + "Cache entry '{}' is a directory, expected a file", + entry_name + )); + } else { + // Check if file is readable + if let Err(e) = std::fs::read(path) { + validation.add_error(format!("Cannot read cache file '{}': {}", entry_name, e)); + } + } + } + + Ok(validation) + } +} + +/// Statistics about server cache operations +#[derive(Debug)] +pub struct ServerCacheStats { + pub stored_files: usize, + pub total_bytes: usize, + pub cache_dir: std::path::PathBuf, +} + +impl std::fmt::Display for ServerCacheStats { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Server Cache: {} files, {} bytes in {}", + self.stored_files, + self.total_bytes, + self.cache_dir.display() + ) + } +} + +/// Result of server cache validation +#[derive(Debug)] +pub struct ServerCacheValidation { + missing_files: Vec<(String, std::path::PathBuf)>, + errors: Vec, + warnings: Vec, +} + +impl ServerCacheValidation { + fn new() -> Self { + Self { + missing_files: Vec::new(), + errors: Vec::new(), + warnings: Vec::new(), + } + } + + fn add_missing_file(&mut self, entry: String, path: std::path::PathBuf) { + self.missing_files.push((entry, path)); + } + + fn add_error(&mut self, error: String) { + self.errors.push(error); + } + + fn add_warning(&mut self, warning: String) { + self.warnings.push(warning); + } + + /// Checks if cache validation passed + pub fn is_valid(&self) -> bool { + self.missing_files.is_empty() && self.errors.is_empty() + } + + /// Gets all validation issues + pub fn issues(&self) -> Vec { + let mut issues = Vec::new(); + + for (entry, path) in &self.missing_files { + issues.push(format!( + "Missing cache file '{}' at {}", + entry, + path.display() + )); + } + + issues.extend(self.errors.iter().cloned()); + issues.extend(self.warnings.iter().cloned()); + + issues + } + + /// Gets the number of missing files + pub fn missing_file_count(&self) -> usize { + self.missing_files.len() + } + + /// Gets the number of errors + pub fn error_count(&self) -> usize { + self.errors.len() + } + + /// Gets the number of warnings + pub fn warning_count(&self) -> usize { + self.warnings.len() + } +} + +impl std::fmt::Display for ServerCacheValidation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.is_valid() { + write!(f, "Server cache validation passed") + } else { + write!( + f, + "Server cache validation failed: {} missing files, {} errors, {} warnings", + self.missing_file_count(), + self.error_count(), + self.warning_count() + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::target::ServerTarget; + use std::{env, path::PathBuf}; + + fn create_test_config() -> ServerConfig { + let temp_dir = env::temp_dir().join("metassr_server_cache_test"); + let src_dir = temp_dir.join("src"); + std::fs::create_dir_all(&src_dir).unwrap(); + ServerConfig::new(&temp_dir, "dist").unwrap() + } + + #[test] + fn test_server_cache_service_creation() { + let config = create_test_config(); + config.ensure_directories().unwrap(); + + let cache_service = ServerCacheService::new(config); + assert!(cache_service.is_ok()); + + // Cleanup + let _ = std::fs::remove_dir_all(env::temp_dir().join("metassr_server_cache_test")); + } + + #[test] + fn test_server_cache_stats() { + let config = create_test_config(); + config.ensure_directories().unwrap(); + + let cache_service = ServerCacheService::new(config).unwrap(); + let stats = cache_service.get_cache_stats(); + + assert_eq!(stats.stored_files, 0); // Empty cache initially + + // Cleanup + let _ = std::fs::remove_dir_all(env::temp_dir().join("metassr_server_cache_test")); + } + + #[test] + fn test_server_cache_validation() { + let config = create_test_config(); + config.ensure_directories().unwrap(); + + let cache_service = ServerCacheService::new(config).unwrap(); + let validation = cache_service.validate_cache().unwrap(); + + assert!(validation.is_valid()); // Empty cache should be valid + + // Cleanup + let _ = std::fs::remove_dir_all(env::temp_dir().join("metassr_server_cache_test")); + } + + #[test] + fn test_server_cache_validation_display() { + let mut validation = ServerCacheValidation::new(); + validation.add_error("Test error".to_string()); + validation.add_missing_file("test".to_string(), PathBuf::from("/missing")); + + let display_text = format!("{}", validation); + assert!(display_text.contains("Server cache validation failed")); + assert!(display_text.contains("1 missing files")); + assert!(display_text.contains("1 errors")); + } +} diff --git a/crates/metassr-build/src/server/config.rs b/crates/metassr-build/src/server/config.rs new file mode 100644 index 0000000..e8c5efc --- /dev/null +++ b/crates/metassr-build/src/server/config.rs @@ -0,0 +1,335 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + ffi::OsStr, + path::{Path, PathBuf}, +}; + +/// Building type for server-side rendering +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)] +pub enum BuildingType { + ServerSideRendering, + StaticSiteGeneration, +} + +impl Default for BuildingType { + fn default() -> Self { + BuildingType::ServerSideRendering + } +} + +/// Configuration for the server building process +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + /// Root directory of the project + pub root_dir: PathBuf, + /// Source directory (usually "src") + pub src_dir: PathBuf, + /// Distribution directory + pub dist_dir: PathBuf, + /// Cache directory for intermediate files + pub cache_dir: PathBuf, + /// Building type (SSR or SSG) + pub building_type: BuildingType, + /// File extension for generated server files + pub server_extension: String, + /// Custom bundler options + pub bundler_options: ServerBundlerOptions, + /// Manifest generation options + pub manifest_options: ManifestOptions, +} + +/// Configuration for server-side bundling +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerBundlerOptions { + /// Whether to enable source maps + pub source_maps: bool, + /// Whether to minimize output + pub minimize: bool, + /// Target environment (node, etc.) + pub target: String, + /// Additional entry points + pub additional_entries: HashMap, +} + +impl Default for ServerBundlerOptions { + fn default() -> Self { + Self { + source_maps: true, + minimize: false, + target: "node".to_string(), + additional_entries: HashMap::new(), + } + } +} + +/// Configuration for manifest generation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestOptions { + /// Whether to generate manifest + pub generate_manifest: bool, + /// Custom manifest filename + pub manifest_filename: String, + /// Whether to include development info + pub include_dev_info: bool, +} + +impl Default for ManifestOptions { + fn default() -> Self { + Self { + generate_manifest: true, + manifest_filename: "manifest.json".to_string(), + include_dev_info: false, + } + } +} + +impl ServerConfig { + /// Creates a new ServerConfig with default settings + pub fn new(root: &S, dist_dir: &str) -> Result + where + S: AsRef + ?Sized, + { + let root_dir = Path::new(root).to_path_buf(); + let src_dir = root_dir.join("src"); + let dist_path = root_dir.join(dist_dir); + let cache_dir = dist_path.join("cache"); + + Self::validate_directories(&root_dir, &src_dir)?; + + Ok(Self { + root_dir, + src_dir, + dist_dir: dist_path, + cache_dir, + building_type: BuildingType::default(), + server_extension: "server.js".to_string(), + bundler_options: ServerBundlerOptions::default(), + manifest_options: ManifestOptions::default(), + }) + } + + /// Creates a custom ServerConfig + pub fn builder() -> ServerConfigBuilder { + ServerConfigBuilder::default() + } + + /// Validates that required directories exist or can be created + fn validate_directories(root_dir: &Path, src_dir: &Path) -> Result<()> { + if !root_dir.exists() { + return Err(anyhow::anyhow!( + "Root directory does not exist: {}", + root_dir.display() + )); + } + + if !src_dir.exists() { + return Err(anyhow::anyhow!( + "Source directory does not exist: {}", + src_dir.display() + )); + } + + Ok(()) + } + + /// Ensures all required directories exist + pub fn ensure_directories(&self) -> Result<()> { + if !self.dist_dir.exists() { + std::fs::create_dir_all(&self.dist_dir) + .with_context(|| format!("Failed to create dist directory: {}", self.dist_dir.display()))?; + } + + if !self.cache_dir.exists() { + std::fs::create_dir_all(&self.cache_dir) + .with_context(|| format!("Failed to create cache directory: {}", self.cache_dir.display()))?; + } + + Ok(()) + } + + /// Gets the cache directory path as a string + pub fn cache_dir_str(&self) -> &str { + self.cache_dir.to_str().unwrap_or_default() + } + + /// Gets the dist directory path as a string + pub fn dist_dir_str(&self) -> &str { + self.dist_dir.to_str().unwrap_or_default() + } + + /// Sets the building type + pub fn with_building_type(mut self, building_type: BuildingType) -> Self { + self.building_type = building_type; + self + } + + /// Sets the server extension + pub fn with_server_extension>(mut self, extension: S) -> Self { + self.server_extension = extension.into(); + self + } + + /// Sets the bundler options + pub fn with_bundler_options(mut self, options: ServerBundlerOptions) -> Self { + self.bundler_options = options; + self + } + + /// Sets the manifest options + pub fn with_manifest_options(mut self, options: ManifestOptions) -> Self { + self.manifest_options = options; + self + } +} + +/// Builder for ServerConfig +#[derive(Default)] +pub struct ServerConfigBuilder { + root_dir: Option, + src_dir: Option, + dist_dir: Option, + cache_dir: Option, + building_type: Option, + server_extension: Option, + bundler_options: Option, + manifest_options: Option, +} + +impl ServerConfigBuilder { + pub fn root_dir>(mut self, path: P) -> Self { + self.root_dir = Some(path.as_ref().to_path_buf()); + self + } + + pub fn src_dir>(mut self, path: P) -> Self { + self.src_dir = Some(path.as_ref().to_path_buf()); + self + } + + pub fn dist_dir>(mut self, path: P) -> Self { + self.dist_dir = Some(path.as_ref().to_path_buf()); + self + } + + pub fn cache_dir>(mut self, path: P) -> Self { + self.cache_dir = Some(path.as_ref().to_path_buf()); + self + } + + pub fn building_type(mut self, building_type: BuildingType) -> Self { + self.building_type = Some(building_type); + self + } + + pub fn server_extension>(mut self, ext: S) -> Self { + self.server_extension = Some(ext.into()); + self + } + + pub fn bundler_options(mut self, options: ServerBundlerOptions) -> Self { + self.bundler_options = Some(options); + self + } + + pub fn manifest_options(mut self, options: ManifestOptions) -> Self { + self.manifest_options = Some(options); + self + } + + pub fn build(self) -> Result { + let root_dir = self.root_dir.ok_or_else(|| anyhow::anyhow!("Root directory is required"))?; + let src_dir = self.src_dir.unwrap_or_else(|| root_dir.join("src")); + let dist_dir = self.dist_dir.unwrap_or_else(|| root_dir.join("dist")); + let cache_dir = self.cache_dir.unwrap_or_else(|| dist_dir.join("cache")); + + ServerConfig::validate_directories(&root_dir, &src_dir)?; + + Ok(ServerConfig { + root_dir, + src_dir, + dist_dir, + cache_dir, + building_type: self.building_type.unwrap_or_default(), + server_extension: self.server_extension.unwrap_or_else(|| "server.js".to_string()), + bundler_options: self.bundler_options.unwrap_or_default(), + manifest_options: self.manifest_options.unwrap_or_default(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + fn create_test_env() -> PathBuf { + let temp_dir = env::temp_dir().join("metassr_server_config_test"); + let src_dir = temp_dir.join("src"); + std::fs::create_dir_all(&src_dir).unwrap(); + temp_dir + } + + #[test] + fn test_server_config_creation() { + let test_dir = create_test_env(); + + let config = ServerConfig::new(&test_dir, "dist").unwrap(); + assert_eq!(config.root_dir, test_dir); + assert_eq!(config.src_dir, test_dir.join("src")); + assert_eq!(config.dist_dir, test_dir.join("dist")); + assert_eq!(config.building_type, BuildingType::ServerSideRendering); + + // Cleanup + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[test] + fn test_server_config_builder() { + let test_dir = create_test_env(); + + let config = ServerConfig::builder() + .root_dir(&test_dir) + .building_type(BuildingType::StaticSiteGeneration) + .server_extension("ssr.js") + .build() + .unwrap(); + + assert_eq!(config.building_type, BuildingType::StaticSiteGeneration); + assert_eq!(config.server_extension, "ssr.js"); + + // Cleanup + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[test] + fn test_ensure_directories() { + let test_dir = create_test_env(); + + let config = ServerConfig::new(&test_dir, "dist").unwrap(); + config.ensure_directories().unwrap(); + + assert!(config.dist_dir.exists()); + assert!(config.cache_dir.exists()); + + // Cleanup + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[test] + fn test_config_methods() { + let test_dir = create_test_env(); + + let config = ServerConfig::new(&test_dir, "dist") + .unwrap() + .with_building_type(BuildingType::StaticSiteGeneration) + .with_server_extension("custom.js"); + + assert_eq!(config.building_type, BuildingType::StaticSiteGeneration); + assert_eq!(config.server_extension, "custom.js"); + + // Cleanup + let _ = std::fs::remove_dir_all(&test_dir); + } +} diff --git a/crates/metassr-build/src/server/generation.rs b/crates/metassr-build/src/server/generation.rs new file mode 100644 index 0000000..5620415 --- /dev/null +++ b/crates/metassr-build/src/server/generation.rs @@ -0,0 +1,320 @@ +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; + +use metassr_fs_analyzer::src_dir::PagesEntriesType; + +use super::{ + cache::ServerCacheService, + config::ServerConfig, + render::{ServerRender, ServerRenderConfig}, + target::{ServerTarget, ServerTargetBuilder, ServerTargetCollection}, +}; +use crate::utils::setup_page_path; +use crate::traits::Generate; + +/// Service for generating server-side targets +pub struct ServerTargetService { + config: ServerConfig, +} + +impl ServerTargetService { + /// Creates a new server target service + pub fn new(config: ServerConfig) -> Self { + Self { config } + } + + /// Generates server targets from app and pages + pub fn generate_targets( + &self, + app_path: &Path, + pages: &PagesEntriesType, + cache_service: &mut ServerCacheService, + ) -> Result { + let mut targets = ServerTargetCollection::new(); + + for (page_name, page_path) in pages.iter() { + let target = self.generate_page_target(app_path, page_name, page_path, cache_service) + .with_context(|| format!("Failed to generate target for page: {}", page_name))?; + + targets.add_target(target) + .with_context(|| format!("Failed to add target for page: {}", page_name))?; + } + + Ok(targets) + } + + /// Generates a single page target + fn generate_page_target( + &self, + app_path: &Path, + page_name: &str, + page_path: &Path, + cache_service: &mut ServerCacheService, + ) -> Result { + // Create render configuration + let render_config = ServerRenderConfig::builder() + .app_path(app_path) + .page_path(page_path) + .build() + .with_context(|| format!("Failed to create render config for page: {}", page_name))?; + + // Generate render script + let renderer = ServerRender::new(render_config); + let (func_id, render_script) = renderer.generate() + .with_context(|| format!("Failed to generate render script for page: {}", page_name))?; + + // Setup cache path + let page_file = setup_page_path(page_name, &self.config.server_extension); + let cache_path = format!("pages/{}", page_file.display()); + + // Store in cache + let cached_path = cache_service.store_target(&cache_path, render_script.as_bytes()) + .with_context(|| format!("Failed to cache target for page: {}", page_name))?; + + // Create target + let target = ServerTargetBuilder::from_page( + page_name, + page_path, + func_id, + render_script, + cached_path, + &self.config.server_extension, + ).with_context(|| format!("Failed to create target for page: {}", page_name))?; + + Ok(target) + } + + /// Validates that target generation is possible + pub fn validate_generation(&self, app_path: &Path, pages: &PagesEntriesType) -> Result { + let mut validation = TargetGenerationValidation::new(); + + // Validate app path + if !app_path.exists() { + validation.add_error(format!("App component not found: {}", app_path.display())); + } + + // Validate pages + for (page_name, page_path) in pages.iter() { + if !page_path.exists() { + validation.add_error(format!( + "Page '{}' not found: {}", + page_name, + page_path.display() + )); + } + + // Check for valid file extensions + if let Some(ext) = page_path.extension() { + let ext_str = ext.to_string_lossy(); + if !["js", "jsx", "ts", "tsx"].contains(&ext_str.as_ref()) { + validation.add_warning(format!( + "Page '{}' has unusual extension: {}", + page_name, ext_str + )); + } + } else { + validation.add_warning(format!( + "Page '{}' has no file extension", + page_name + )); + } + } + + Ok(validation) + } + + /// Gets statistics about target generation + pub fn get_generation_stats(&self, pages: &PagesEntriesType) -> TargetGenerationStats { + let total_pages = pages.len(); + let page_types = pages + .values() + .filter_map(|path| path.extension()) + .filter_map(|ext| ext.to_str()) + .fold(std::collections::HashMap::new(), |mut acc, ext| { + *acc.entry(ext.to_string()).or_insert(0) += 1; + acc + }); + + TargetGenerationStats { + total_pages, + page_types, + server_extension: self.config.server_extension.clone(), + } + } +} + +/// Result of target generation validation +#[derive(Debug, Default)] +pub struct TargetGenerationValidation { + pub errors: Vec, + pub warnings: Vec, +} + +impl TargetGenerationValidation { + fn new() -> Self { + Self::default() + } + + fn add_error(&mut self, error: String) { + self.errors.push(error); + } + + fn add_warning(&mut self, warning: String) { + self.warnings.push(warning); + } + + /// Checks if validation passed (no errors) + pub fn is_valid(&self) -> bool { + self.errors.is_empty() + } + + /// Gets all issues (errors + warnings) + pub fn all_issues(&self) -> Vec<&String> { + self.errors.iter().chain(self.warnings.iter()).collect() + } +} + +/// Statistics about target generation +#[derive(Debug)] +pub struct TargetGenerationStats { + pub total_pages: usize, + pub page_types: std::collections::HashMap, + pub server_extension: String, +} + +impl std::fmt::Display for TargetGenerationStats { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "Target Generation Statistics:")?; + writeln!(f, " Total pages: {}", self.total_pages)?; + writeln!(f, " Server extension: {}", self.server_extension)?; + write!(f, " Page types: ")?; + + for (ext, count) in &self.page_types { + write!(f, "{}({}) ", ext, count)?; + } + + Ok(()) + } +} + +pub struct TargetsGenerator<'a> { + service: ServerTargetService, + app: PathBuf, + pages: PagesEntriesType, + cache_service: &'a mut ServerCacheService, +} + +impl<'a> TargetsGenerator<'a> { + /// Creates a new targets generator + pub fn new( + app: PathBuf, + pages: PagesEntriesType, + cache: &'a mut metassr_utils::cache_dir::CacheDir, + ) -> Self { + // This is a compatibility layer - in practice you'd want to pass the proper config + let config = ServerConfig::new(".", "dist").unwrap_or_else(|_| { + // Fallback config if we can't create one + ServerConfig::builder() + .root_dir(".") + .build() + .unwrap() + }); + + // Create a cache service wrapper + // Note: This is a simplification for compatibility + let cache_service = unsafe { + std::mem::transmute::<&'a mut metassr_utils::cache_dir::CacheDir, &'a mut ServerCacheService>(cache) + }; + + Self { + service: ServerTargetService::new(config), + app, + pages, + cache_service, + } + } + + /// Generates targets using the legacy interface + pub fn generate(&mut self) -> Result { + let targets = self.service.generate_targets(&self.app, &self.pages, self.cache_service)?; + + // Convert to legacy Targets format + let mut legacy_targets = super::target::Targets::new(); + for target in targets.targets() { + legacy_targets.insert(target.func_id, &target.cached_path); + } + + Ok(legacy_targets) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::config::ServerConfig; + use std::{collections::HashMap, env}; + + fn create_test_env() -> (PathBuf, PathBuf, PathBuf) { + let temp_dir = env::temp_dir().join("metassr_server_targets_test"); + let src_dir = temp_dir.join("src"); + let pages_dir = src_dir.join("pages"); + + std::fs::create_dir_all(&pages_dir).unwrap(); + + let app_path = src_dir.join("_app.tsx"); + let page_path = pages_dir.join("home.tsx"); + + std::fs::write(&app_path, "export default function App() { return null; }").unwrap(); + std::fs::write(&page_path, "export default function Home() { return null; }").unwrap(); + + (temp_dir, app_path, page_path) + } + + #[test] + fn test_server_target_service_creation() { + let (temp_dir, _, _) = create_test_env(); + + let config = ServerConfig::new(&temp_dir, "dist").unwrap(); + let service = ServerTargetService::new(config); + + assert_eq!(service.config.server_extension, "server.js"); + + // Cleanup + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[test] + fn test_target_generation_validation() { + let (temp_dir, app_path, page_path) = create_test_env(); + + let config = ServerConfig::new(&temp_dir, "dist").unwrap(); + let service = ServerTargetService::new(config); + + let mut pages = HashMap::new(); + pages.insert("home".to_string(), page_path); + + let validation = service.validate_generation(&app_path, &pages).unwrap(); + assert!(validation.is_valid()); + + // Cleanup + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[test] + fn test_generation_stats() { + let (temp_dir, _, page_path) = create_test_env(); + + let config = ServerConfig::new(&temp_dir, "dist").unwrap(); + let service = ServerTargetService::new(config); + + let mut pages = HashMap::new(); + pages.insert("home".to_string(), page_path); + + let stats = service.get_generation_stats(&pages); + assert_eq!(stats.total_pages, 1); + assert!(stats.page_types.contains_key("tsx")); + + // Cleanup + let _ = std::fs::remove_dir_all(&temp_dir); + } +} diff --git a/crates/metassr-build/src/server/mod.rs b/crates/metassr-build/src/server/mod.rs index 3d1346e..2cef5d8 100644 --- a/crates/metassr-build/src/server/mod.rs +++ b/crates/metassr-build/src/server/mod.rs @@ -1,110 +1,408 @@ pub mod renderer; +pub mod cache; +pub mod config; +pub mod generation; pub mod manifest; mod pages_generator; mod render; mod render_exec; +pub mod target; mod targets; use crate::traits::Build; -use manifest::ManifestGenerator; +use anyhow::{Context, Result}; +use cache::ServerCacheService; +use config::{BuildingType, ServerConfig}; +use generation::ServerTargetService; +use manifest::ManifestGenerator; use metassr_bundler::WebBundler; use metassr_fs_analyzer::{ dist_dir::DistDir, src_dir::{special_entries, SourceDir}, DirectoryAnalyzer, }; -use metassr_utils::cache_dir::CacheDir; - use pages_generator::PagesGenerator; use renderer::head::HeadRenderer; +use target::{ServerTargetCollection, Targets}; +use tracing::debug; -use std::{ - ffi::OsStr, - fs, - path::{Path, PathBuf}, -}; -use targets::TargetsGenerator; - -use anyhow::{anyhow, Result}; - -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub enum BuildingType { - ServerSideRendering, - StaticSiteGeneration, -} +use std::{ffi::OsStr, path::Path}; +/// Enhanced server-side builder with improved architecture and error handling pub struct ServerSideBuilder { - src_path: PathBuf, - dist_path: PathBuf, - building_type: BuildingType, + config: ServerConfig, } impl ServerSideBuilder { - pub fn new(root: &S, dist_dir: &str, building_type: BuildingType) -> Result + /// Creates a new ServerSideBuilder with the given configuration + pub fn new(config: ServerConfig) -> Self { + Self { config } + } + + /// Creates a ServerSideBuilder with simple parameters (legacy compatibility) + pub fn simple(root: &S, dist_dir: &str, building_type: BuildingType) -> Result where S: AsRef + ?Sized, { - let root = Path::new(root); - let src_path = root.join("src"); - let dist_path = root.join(dist_dir); + let config = ServerConfig::new(root, dist_dir)? + .with_building_type(building_type); + Ok(Self::new(config)) + } + + /// Creates a ServerSideBuilder with custom configuration + pub fn with_config(config: ServerConfig) -> Result { + config.ensure_directories()?; + Ok(Self::new(config)) + } + + /// Gets the current configuration + pub fn config(&self) -> &ServerConfig { + &self.config + } + + /// Updates the configuration + pub fn with_updated_config(mut self, config: ServerConfig) -> Result { + config.ensure_directories()?; + self.config = config; + Ok(self) + } + + /// Builds the server-side code with detailed logging and error handling + fn build_internal(&self) -> Result { + // Ensure directories exist + self.config.ensure_directories() + .context("Failed to ensure required directories exist")?; - if !src_path.exists() { - return Err(anyhow!("src directory not found.")); + // Initialize services + let mut cache_service = ServerCacheService::new(self.config.clone()) + .context("Failed to initialize server cache service")?; + + let target_service = ServerTargetService::new(self.config.clone()); + + // Analyze source directory + let src = SourceDir::new(&self.config.src_dir) + .analyze() + .context("Failed to analyze source directory")?; + + let pages = src.clone().pages(); + let (special_entries::App(app), special_entries::Head(head)) = src.specials() + .context("Failed to find required special entries (like _app and _head)")?; + + // Validate target generation + let validation = target_service.validate_generation(&app, &pages.as_map()) + .context("Failed to validate target generation")?; + + if !validation.is_valid() { + return Err(anyhow::anyhow!( + "Target generation validation failed: {:?}", + validation.errors + )); } - if !dist_path.exists() { - fs::create_dir(dist_path.clone())?; + + // Generate targets + let targets = target_service.generate_targets(&app, &pages.as_map(), &mut cache_service) + .context("Failed to generate server targets")?; + + // Bundle the targets + let bundling_result = self.bundle_targets(&targets) + .context("Failed to bundle server targets")?; + + // Generate manifest + let manifest_result = if self.config.manifest_options.generate_manifest { + Some(self.generate_manifest(&targets, &cache_service, &head) + .context("Failed to generate manifest")?) + } else { + None + }; + + // Handle Static Site Generation if required + let ssg_result = if self.config.building_type == BuildingType::StaticSiteGeneration { + Some(self.generate_static_pages(&targets, &head, &cache_service) + .context("Failed to generate static pages")?) + } else { + None + }; + + Ok(ServerBuildResult { + targets_processed: targets.len(), + cache_stats: cache_service.get_cache_stats(), + bundling_result, + manifest_result, + ssg_result, + target_stats: targets.stats(), + }) + } + + /// Bundles the server targets + fn bundle_targets(&self, targets: &ServerTargetCollection) -> Result { + let bundling_targets = targets.ready_for_bundling(&self.config.dist_dir) + .context("Failed to prepare targets for bundling")?; + + let bundler = WebBundler::new(&bundling_targets, &self.config.dist_dir) + .context("Failed to create web bundler for server targets")?; + + bundler.exec() + .context("Server bundling process failed")?; + + Ok(ServerBundlingResult { + bundled_targets: bundling_targets.len(), + target_names: bundling_targets.keys().cloned().collect(), + }) + } + + /// Generates the manifest + fn generate_manifest( + &self, + targets: &ServerTargetCollection, + cache_service: &ServerCacheService, + head: &Path, + ) -> Result { + if !self.config.manifest_options.generate_manifest { + return Ok(ServerManifestResult::skipped()); + } + + let dist = DistDir::new(&self.config.dist_dir) + .context("Failed to analyze dist directory")? + .analyze() + .context("Failed to analyze dist directory structure")?; + + // Convert targets to legacy format for compatibility + let mut legacy_targets = targets::Targets::new(); + for target in targets.targets() { + legacy_targets.insert(target.func_id, &target.cached_path); } - Ok(Self { - src_path, - dist_path, - building_type, + + let manifest = ManifestGenerator::new( + legacy_targets, + cache_service.cache_dir().clone(), + dist, + ).generate(head) + .context("Failed to generate manifest")?; + + manifest.write(&self.config.dist_dir) + .context("Failed to write manifest to disk")?; + + // Render head + HeadRenderer::new(&manifest.global.head, cache_service.cache_dir().clone()) + .render(true) + .context("Failed to render head component")?; + + Ok(ServerManifestResult { + manifest_written: true, + head_rendered: true, + manifest_path: self.config.dist_dir.join(&self.config.manifest_options.manifest_filename), + }) + } + + /// Generates static pages for SSG + fn generate_static_pages( + &self, + targets: &ServerTargetCollection, + head: &Path, + cache_service: &ServerCacheService, + ) -> Result { + // Convert targets to legacy format for compatibility + let mut legacy_targets = Targets::new(); + for target in targets.targets() { + legacy_targets.insert(target.func_id, &target.cached_path); + } + + PagesGenerator::new(legacy_targets, head, &self.config.dist_dir, cache_service.cache_dir().clone())? + .generate() + .context("Failed to generate static pages")?; + + Ok(ServerSSGResult { + pages_generated: targets.len(), + output_dir: self.config.dist_dir.clone(), }) } } -// TODO: refactoring build function + impl Build for ServerSideBuilder { type Output = (); + fn build(&self) -> Result { - let mut cache_dir = CacheDir::new(&format!("{}/cache", self.dist_path.display()))?; + let result = self.build_internal() + .context("Server build process failed")?; - let src = SourceDir::new(&self.src_path).analyze()?; - let pages = src.clone().pages; - let (special_entries::App(app), special_entries::Head(head)) = src.specials()?; + // Log build statistics + debug!("Server build completed successfully"); + debug!("Building type: {:?}", self.config.building_type); + debug!("Processed {} targets", result.targets_processed); + debug!("Cache: {}", result.cache_stats); + debug!("Bundling: {} targets bundled", result.bundling_result.bundled_targets); + + if let Some(manifest) = &result.manifest_result { + if manifest.manifest_written { + debug!("Manifest generated at: {}", manifest.manifest_path.display()); + } + } - let targets = match TargetsGenerator::new(app, pages, &mut cache_dir).generate() { - Ok(t) => t, - Err(e) => return Err(anyhow!("Couldn't generate targets: {e}")), - }; + if let Some(ssg) = &result.ssg_result { + debug!("Generated {} static pages", ssg.pages_generated); + } - let bundling_targets = targets.ready_for_bundling(&self.dist_path); - let bundler = WebBundler::new( - &bundling_targets, - &self.dist_path, - )?; + Ok(()) + } +} - if let Err(e) = bundler.exec() { - return Err(anyhow!("Bundling failed: {e}")); - } +/// Result of a server build operation +#[derive(Debug)] +pub struct ServerBuildResult { + pub targets_processed: usize, + pub cache_stats: cache::ServerCacheStats, + pub bundling_result: ServerBundlingResult, + pub manifest_result: Option, + pub ssg_result: Option, + pub target_stats: target::ServerTargetCollectionStats, +} - let dist = DistDir::new(&self.dist_path)?.analyze()?; +/// Result of server bundling operation +#[derive(Debug)] +pub struct ServerBundlingResult { + pub bundled_targets: usize, + pub target_names: Vec, +} - let manifest = - ManifestGenerator::new(targets.clone(), cache_dir.clone(), dist).generate(&head)?; - manifest.write(&self.dist_path.clone())?; +/// Result of server manifest generation +#[derive(Debug)] +pub struct ServerManifestResult { + pub manifest_written: bool, + pub head_rendered: bool, + pub manifest_path: std::path::PathBuf, +} - if let Err(e) = HeadRenderer::new(&manifest.global.head, cache_dir.clone()).render(true) { - return Err(anyhow!("Coludn't render head: {e}")); +impl ServerManifestResult { + fn skipped() -> Self { + Self { + manifest_written: false, + head_rendered: false, + manifest_path: std::path::PathBuf::new(), } + } +} + +/// Result of static site generation +#[derive(Debug)] +pub struct ServerSSGResult { + pub pages_generated: usize, + pub output_dir: std::path::PathBuf, +} - if self.building_type == BuildingType::StaticSiteGeneration { - if let Err(e) = - PagesGenerator::new(targets, &head, &self.dist_path, cache_dir)?.generate() - { - return Err(anyhow!("Couldn't generate pages: {e}")); +impl std::fmt::Display for ServerBuildResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "Server Build Result:")?; + writeln!(f, " Targets processed: {}", self.targets_processed)?; + writeln!(f, " {}", self.cache_stats)?; + writeln!(f, " Bundled targets: {}", self.bundling_result.bundled_targets)?; + + if let Some(manifest) = &self.manifest_result { + if manifest.manifest_written { + writeln!(f, " Manifest: Generated")?; } } - Ok(()) + + if let Some(ssg) = &self.ssg_result { + writeln!(f, " Static pages: {}", ssg.pages_generated)?; + } + + write!(f, " {}", self.target_stats) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + fn create_test_env() -> std::path::PathBuf { + let temp_dir = env::temp_dir().join("metassr_server_test"); + let src_dir = temp_dir.join("src"); + let pages_dir = src_dir.join("pages"); + + // Create directories + std::fs::create_dir_all(&pages_dir).unwrap(); + + // Create _app.tsx + let app_content = r#" +import React from 'react'; + +export default function App({ Component }) { + return ; +} +"#; + std::fs::write(src_dir.join("_app.tsx"), app_content).unwrap(); + + // Create _head.tsx + let head_content = r#" +import React from 'react'; + +export default function Head() { + return ( + <> + Test App + + + ); +} +"#; + std::fs::write(src_dir.join("_head.tsx"), head_content).unwrap(); + + // Create a test page + let page_content = r#" +import React from 'react'; + +export default function Home() { + return
Home Page
; +} +"#; + std::fs::write(pages_dir.join("home.tsx"), page_content).unwrap(); + + temp_dir + } + + #[test] + fn test_server_builder_creation() { + let test_dir = create_test_env(); + + let config = ServerConfig::new(&test_dir, "dist").unwrap(); + let builder = ServerSideBuilder::new(config); + + assert_eq!(builder.config().building_type, BuildingType::ServerSideRendering); + + // Cleanup + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[test] + fn test_server_builder_simple() { + let test_dir = create_test_env(); + + let builder = ServerSideBuilder::simple(&test_dir, "dist", BuildingType::StaticSiteGeneration).unwrap(); + assert_eq!(builder.config().building_type, BuildingType::StaticSiteGeneration); + + // Cleanup + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[test] + fn test_server_config_builder() { + let test_dir = create_test_env(); + + let config = ServerConfig::builder() + .root_dir(&test_dir) + .building_type(BuildingType::StaticSiteGeneration) + .server_extension("ssr.js") + .build() + .unwrap(); + + let builder = ServerSideBuilder::with_config(config).unwrap(); + assert_eq!(builder.config().building_type, BuildingType::StaticSiteGeneration); + assert_eq!(builder.config().server_extension, "ssr.js"); + + // Cleanup + let _ = std::fs::remove_dir_all(&test_dir); } } diff --git a/crates/metassr-build/src/server/pages_generator.rs b/crates/metassr-build/src/server/pages_generator.rs index f5a5ec4..7d3e24a 100644 --- a/crates/metassr-build/src/server/pages_generator.rs +++ b/crates/metassr-build/src/server/pages_generator.rs @@ -15,7 +15,7 @@ use crate::traits::Exec; use super::{ render_exec::MultiRenderExec, renderer::head::HeadRenderer, renderer::html::HtmlRenderer, - targets::Targets, + target::Targets, }; pub struct PagesGenerator { diff --git a/crates/metassr-build/src/server/render.rs b/crates/metassr-build/src/server/render.rs index 79e03d1..fd3307d 100644 --- a/crates/metassr-build/src/server/render.rs +++ b/crates/metassr-build/src/server/render.rs @@ -2,59 +2,297 @@ use crate::{ shared::{APP_PATH_TAG, FUNC_ID_TAG, PAGE_PATH_TAG}, traits::Generate, }; -use anyhow::Result; +use anyhow::{Context, Result}; use metassr_utils::rand::Rand; -use std::{ffi::OsStr, path::PathBuf}; +use std::{ + collections::HashMap, + ffi::OsStr, + path::{Path, PathBuf}, +}; const RENDER_FILE_TEMPLATE: &str = include_str!("../scripts/render.js.template"); -pub struct ServerRender { - app_path: PathBuf, - page_path: PathBuf, +/// Configuration for server-side render script generation +#[derive(Debug, Clone)] +pub struct ServerRenderConfig { + /// Path to the app component + pub app_path: PathBuf, + /// Path to the page component + pub page_path: PathBuf, + /// Function ID for MetaCall execution + pub func_id: Option, + /// Additional template variables + pub template_vars: HashMap, + /// Custom template content + pub custom_template: Option, } -impl ServerRender { - pub fn new<'a, S>(app_path: &'a S, page_path: &'a S) -> Self +impl ServerRenderConfig { + /// Creates a new server render config + pub fn new(app_path: &S, page_path: &S) -> Self where S: AsRef + ?Sized, { Self { app_path: PathBuf::from(app_path), page_path: PathBuf::from(page_path), + func_id: None, + template_vars: HashMap::new(), + custom_template: None, } } + + /// Builder pattern for configuration + pub fn builder() -> ServerRenderConfigBuilder { + ServerRenderConfigBuilder::default() + } + + /// Sets the function ID + pub fn with_func_id(mut self, func_id: i64) -> Self { + self.func_id = Some(func_id); + self + } + + /// Adds a template variable + pub fn with_var(mut self, key: K, value: V) -> Self + where + K: Into, + V: Into, + { + self.template_vars.insert(key.into(), value.into()); + self + } + + /// Sets a custom template + pub fn with_template>(mut self, template: S) -> Self { + self.custom_template = Some(template.into()); + self + } + + /// Validates the configuration + pub fn validate(&self) -> Result<()> { + if !self.app_path.exists() { + return Err(anyhow::anyhow!( + "App component not found: {}", + self.app_path.display() + )); + } + + if !self.page_path.exists() { + return Err(anyhow::anyhow!( + "Page component not found: {}", + self.page_path.display() + )); + } + + Ok(()) + } + + /// Gets or generates a function ID + pub fn get_func_id(&self) -> i64 { + self.func_id.unwrap_or_else(|| Rand::new().val()) + } } -impl Generate for ServerRender { - type Output = (i64, String); - fn generate(&self) -> Result { - let func_id = Rand::new().val(); - let mut app_path = self.app_path.canonicalize()?; - let mut page_path = self.page_path.canonicalize()?; +/// Builder for ServerRenderConfig +#[derive(Default)] +pub struct ServerRenderConfigBuilder { + app_path: Option, + page_path: Option, + func_id: Option, + template_vars: HashMap, + custom_template: Option, +} + +impl ServerRenderConfigBuilder { + pub fn app_path>(mut self, path: P) -> Self { + self.app_path = Some(path.as_ref().to_path_buf()); + self + } + + pub fn page_path>(mut self, path: P) -> Self { + self.page_path = Some(path.as_ref().to_path_buf()); + self + } + + pub fn func_id(mut self, func_id: i64) -> Self { + self.func_id = Some(func_id); + self + } + pub fn template_var(mut self, key: K, value: V) -> Self + where + K: Into, + V: Into, + { + self.template_vars.insert(key.into(), value.into()); + self + } + + pub fn custom_template>(mut self, template: S) -> Self { + self.custom_template = Some(template.into()); + self + } + + pub fn build(self) -> Result { + let app_path = self.app_path.ok_or_else(|| anyhow::anyhow!("App path is required"))?; + let page_path = self.page_path.ok_or_else(|| anyhow::anyhow!("Page path is required"))?; + + let config = ServerRenderConfig { + app_path, + page_path, + func_id: self.func_id, + template_vars: self.template_vars, + custom_template: self.custom_template, + }; + + config.validate()?; + Ok(config) + } +} + +/// Generates server-side render scripts +#[derive(Debug)] +pub struct ServerRender { + config: ServerRenderConfig, +} + +impl ServerRender { + /// Creates a new server render with the given configuration + pub fn new(config: ServerRenderConfig) -> Self { + Self { config } + } + + /// Creates a server render with simple parameters (legacy compatibility) + pub fn simple(app_path: &S, page_path: &S) -> Result + where + S: AsRef + ?Sized, + { + let config = ServerRenderConfig::new(app_path, page_path); + config.validate()?; + Ok(Self::new(config)) + } + + /// Generates the render script content + fn generate_content(&self) -> Result<(i64, String)> { + let func_id = self.config.get_func_id(); + + let mut app_path = self.config.app_path + .canonicalize() + .with_context(|| format!("Failed to canonicalize app path: {}", self.config.app_path.display()))?; + + let mut page_path = self.config.page_path + .canonicalize() + .with_context(|| format!("Failed to canonicalize page path: {}", self.config.page_path.display()))?; + + // Remove extensions for JavaScript imports app_path.set_extension(""); page_path.set_extension(""); - Ok(( - func_id, - RENDER_FILE_TEMPLATE - .replace(APP_PATH_TAG, app_path.to_str().unwrap()) - .replace(PAGE_PATH_TAG, page_path.to_str().unwrap()) - .replace(FUNC_ID_TAG, &func_id.to_string()), - )) + let app_path_str = app_path + .to_str() + .ok_or_else(|| anyhow::anyhow!("App path contains invalid UTF-8"))?; + + let page_path_str = page_path + .to_str() + .ok_or_else(|| anyhow::anyhow!("Page path contains invalid UTF-8"))?; + + // Use custom template if provided, otherwise use default + let template = self.config.custom_template + .as_deref() + .unwrap_or(RENDER_FILE_TEMPLATE); + + let mut content = template + .replace(APP_PATH_TAG, app_path_str) + .replace(PAGE_PATH_TAG, page_path_str) + .replace(FUNC_ID_TAG, &func_id.to_string()); + + // Apply additional template variables + for (key, value) in &self.config.template_vars { + let placeholder = format!("%{}%", key.to_uppercase()); + content = content.replace(&placeholder, value); + } + + Ok((func_id, content)) + } + + /// Gets the configuration + pub fn config(&self) -> &ServerRenderConfig { + &self.config + } + + /// Updates the configuration + pub fn with_config(mut self, config: ServerRenderConfig) -> Result { + config.validate()?; + self.config = config; + Ok(self) + } +} + +impl Generate for ServerRender { + type Output = (i64, String); + + fn generate(&self) -> Result { + self.generate_content() } } #[cfg(test)] mod tests { use super::*; + use std::env; + + fn create_test_files() -> (PathBuf, PathBuf) { + let temp_dir = env::temp_dir().join("metassr_server_render_test"); + std::fs::create_dir_all(&temp_dir).unwrap(); + + let app_path = temp_dir.join("_app.tsx"); + let page_path = temp_dir.join("home.jsx"); + + // Create test files + std::fs::write(&app_path, "export default function App() { return null; }").unwrap(); + std::fs::write(&page_path, "export default function Page() { return null; }").unwrap(); + + (app_path, page_path) + } + + #[test] + fn test_server_render_config_creation() { + let (app_path, page_path) = create_test_files(); + + let config = ServerRenderConfig::new(&app_path, &page_path); + assert!(config.func_id.is_none()); + assert!(config.template_vars.is_empty()); + + // Cleanup + let _ = std::fs::remove_file(&app_path); + let _ = std::fs::remove_file(&page_path); + } + + #[test] + fn test_server_render_simple_creation() { + let (app_path, page_path) = create_test_files(); + + let render = ServerRender::simple(&app_path, &page_path).unwrap(); + assert!(render.config().func_id.is_none()); + + // Cleanup + let _ = std::fs::remove_file(&app_path); + let _ = std::fs::remove_file(&page_path); + } + #[test] - fn generate_render_file() { - println!( - "{:?}", - ServerRender::new("src/_app.tsx", "src/pages/home.jsx") - .generate() - .unwrap() - ); + fn test_generate_render_script() { + let (app_path, page_path) = create_test_files(); + + let render = ServerRender::simple(&app_path, &page_path).unwrap(); + let (func_id, content) = render.generate().unwrap(); + + assert!(func_id > 0); + assert!(content.contains(&func_id.to_string())); + + // Cleanup + let _ = std::fs::remove_file(&app_path); + let _ = std::fs::remove_file(&page_path); } } diff --git a/crates/metassr-build/src/server/renderer/head.rs b/crates/metassr-build/src/server/renderer/head.rs index a22451b..d06b249 100644 --- a/crates/metassr-build/src/server/renderer/head.rs +++ b/crates/metassr-build/src/server/renderer/head.rs @@ -66,8 +66,8 @@ import React from "react" export function render_head() {{ return renderToString(); -}} - +}} + "#, self.path.canonicalize()?.display() ); diff --git a/crates/metassr-build/src/server/renderer/html.rs b/crates/metassr-build/src/server/renderer/html.rs index 52530c4..4d12cc6 100644 --- a/crates/metassr-build/src/server/renderer/html.rs +++ b/crates/metassr-build/src/server/renderer/html.rs @@ -1,7 +1,7 @@ use std::path::Path; use anyhow::Result; -use html_generator::{ +use metassr_html::{ builder::{HtmlBuilder, HtmlOutput}, html_props::{HtmlPropsBuilder}, template::HtmlTemplate, diff --git a/crates/metassr-build/src/server/target.rs b/crates/metassr-build/src/server/target.rs new file mode 100644 index 0000000..7958a00 --- /dev/null +++ b/crates/metassr-build/src/server/target.rs @@ -0,0 +1,438 @@ +use anyhow::{Context, Result}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +/// Represents a server-side build target with metadata +#[derive(Debug, Clone)] +pub struct ServerTarget { + /// Unique identifier for the target + pub id: String, + /// Function ID for MetaCall execution + pub func_id: i64, + /// Source file path + pub source_path: PathBuf, + /// Cached script path + pub cached_path: PathBuf, + /// Generated render script content + pub content: String, + /// Metadata about the target + pub metadata: ServerTargetMetadata, +} + +/// Metadata associated with a server target +#[derive(Debug, Clone)] +pub struct ServerTargetMetadata { + /// Route associated with this target + pub route: String, + /// Whether this is a special entry (like _app) + pub is_special: bool, + /// File type/extension + pub file_type: String, + /// Size of the content in bytes + pub content_size: usize, + /// Page name + pub page_name: String, +} + +impl ServerTarget { + /// Creates a new server target + pub fn new( + id: String, + func_id: i64, + source_path: PathBuf, + cached_path: PathBuf, + content: String, + route: String, + page_name: String, + is_special: bool, + ) -> Self { + let content_size = content.len(); + let file_type = source_path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("unknown") + .to_string(); + + Self { + id, + func_id, + source_path, + cached_path, + content, + metadata: ServerTargetMetadata { + route, + is_special, + file_type, + content_size, + page_name, + }, + } + } + + /// Gets the content as bytes + pub fn content_bytes(&self) -> &[u8] { + self.content.as_bytes() + } + + /// Gets the absolute source path if it exists + pub fn absolute_source_path(&self) -> Result { + self.source_path + .canonicalize() + .with_context(|| format!("Failed to canonicalize path: {}", self.source_path.display())) + } + + /// Gets the absolute cached path if it exists + pub fn absolute_cached_path(&self) -> Result { + self.cached_path + .canonicalize() + .with_context(|| format!("Failed to canonicalize cached path: {}", self.cached_path.display())) + } + + /// Gets the cached path as a string + pub fn cached_path_str(&self) -> &str { + self.cached_path.to_str().unwrap_or_default() + } +} + +/// Collection of server build targets with utilities for managing them +#[derive(Debug, Default, Clone)] +pub struct ServerTargetCollection { + targets: Vec, + target_map: HashMap, + func_id_map: HashMap, +} + +impl ServerTargetCollection { + /// Creates a new empty target collection + pub fn new() -> Self { + Self::default() + } + + /// Adds a target to the collection + pub fn add_target(&mut self, target: ServerTarget) -> Result<()> { + if self.target_map.contains_key(&target.id) { + return Err(anyhow::anyhow!("Target with id '{}' already exists", target.id)); + } + + if self.func_id_map.contains_key(&target.func_id) { + return Err(anyhow::anyhow!("Target with func_id '{}' already exists", target.func_id)); + } + + let index = self.targets.len(); + self.target_map.insert(target.id.clone(), index); + self.func_id_map.insert(target.func_id, index); + self.targets.push(target); + Ok(()) + } + + /// Gets a target by ID + pub fn get_target(&self, id: &str) -> Option<&ServerTarget> { + self.target_map.get(id).and_then(|&index| self.targets.get(index)) + } + + /// Gets a target by function ID + pub fn get_target_by_func_id(&self, func_id: i64) -> Option<&ServerTarget> { + self.func_id_map.get(&func_id).and_then(|&index| self.targets.get(index)) + } + + /// Gets all targets + pub fn targets(&self) -> &[ServerTarget] { + &self.targets + } + + /// Gets all targets mutably + pub fn targets_mut(&mut self) -> &mut [ServerTarget] { + &mut self.targets + } + + /// Gets the number of targets + pub fn len(&self) -> usize { + self.targets.len() + } + + /// Checks if the collection is empty + pub fn is_empty(&self) -> bool { + self.targets.is_empty() + } + + /// Filters targets by a predicate + pub fn filter_targets(&self, predicate: F) -> Vec<&ServerTarget> + where + F: Fn(&ServerTarget) -> bool, + { + self.targets.iter().filter(|target| predicate(target)).collect() + } + + /// Gets targets that are pages (not special entries) + pub fn page_targets(&self) -> Vec<&ServerTarget> { + self.filter_targets(|target| !target.metadata.is_special) + } + + /// Gets special targets (like _app) + pub fn special_targets(&self) -> Vec<&ServerTarget> { + self.filter_targets(|target| target.metadata.is_special) + } + + /// Converts targets to a HashMap suitable for bundling + pub fn ready_for_bundling(&self, dist_path: &Path) -> Result> { + let mut bundler_targets = HashMap::new(); + + for target in &self.targets { + let mut name = target.cached_path + .strip_prefix(dist_path) + .with_context(|| format!( + "Couldn't strip prefix '{}' from path '{}'", + dist_path.display(), + target.cached_path.display() + ))? + .to_path_buf(); + + name.set_extension(""); + + let absolute_path = target.absolute_cached_path()?; + bundler_targets.insert( + name.to_string_lossy().to_string(), + absolute_path.to_string_lossy().to_string(), + ); + } + + Ok(bundler_targets) + } + + /// Gets targets ready for execution (func_id mapping) + pub fn ready_for_exec(&self) -> HashMap { + self.targets + .iter() + .map(|target| (target.cached_path_str().to_string(), target.func_id)) + .collect() + } + + /// Gets statistics about the collection + pub fn stats(&self) -> ServerTargetCollectionStats { + let total_targets = self.targets.len(); + let page_targets = self.page_targets().len(); + let special_targets = self.special_targets().len(); + let total_content_size = self.targets.iter().map(|t| t.metadata.content_size).sum(); + + ServerTargetCollectionStats { + total_targets, + page_targets, + special_targets, + total_content_size, + } + } + + /// Creates an iterator over (PathBuf, i64) pairs + pub fn iter(&self) -> impl Iterator { + self.targets.iter().map(|target| (&target.cached_path, target.func_id)) + } +} + +/// Statistics about a server target collection +#[derive(Debug)] +pub struct ServerTargetCollectionStats { + pub total_targets: usize, + pub page_targets: usize, + pub special_targets: usize, + pub total_content_size: usize, +} + +impl std::fmt::Display for ServerTargetCollectionStats { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Server Targets: {} total ({} pages, {} special), Content size: {} bytes", + self.total_targets, + self.page_targets, + self.special_targets, + self.total_content_size + ) + } +} + +/// Builder for creating server build targets from pages +pub struct ServerTargetBuilder; + +impl ServerTargetBuilder { + /// Creates a server target from page information and render script + pub fn from_page( + page_name: &str, + page_path: &Path, + func_id: i64, + render_script: String, + cached_path: PathBuf, + server_extension: &str, + ) -> Result { + let target_id = format!("pages/{}.{}", page_name, server_extension); + let route = format!("/{}", page_name); + + Ok(ServerTarget::new( + target_id, + func_id, + page_path.to_path_buf(), + cached_path, + render_script, + route, + page_name.to_string(), + false, // Pages are not special entries + )) + } + + /// Creates a server target for a special entry + pub fn from_special( + id: String, + func_id: i64, + source_path: PathBuf, + cached_path: PathBuf, + content: String, + route: String, + name: String, + ) -> ServerTarget { + ServerTarget::new( + id, + func_id, + source_path, + cached_path, + content, + route, + name, + true, // This is a special entry + ) + } +} + +/// Legacy compatibility - wraps the new ServerTargetCollection to match old Targets interface +pub struct Targets(ServerTargetCollection); + +impl Targets { + pub fn new() -> Self { + Self(ServerTargetCollection::new()) + } + + pub fn insert(&mut self, func_id: i64, path: &Path) { + // This is a simplified version for compatibility + // In practice, you'd want to create a proper ServerTarget + let target = ServerTarget::new( + path.to_string_lossy().to_string(), + func_id, + path.to_path_buf(), + path.to_path_buf(), + String::new(), + String::new(), + String::new(), + false, + ); + let _ = self.0.add_target(target); + } + + pub fn ready_for_bundling(&self, dist_path: &PathBuf) -> HashMap { + self.0.ready_for_bundling(dist_path).unwrap_or_default() + } + + pub fn ready_for_exec(&self) -> HashMap { + self.0.ready_for_exec() + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } +} + +impl Default for Targets { + fn default() -> Self { + Self::new() + } +} + +impl Clone for Targets { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + fn create_test_target() -> ServerTarget { + let temp_dir = env::temp_dir().join("metassr_server_target_test"); + let source_path = temp_dir.join("test.tsx"); + let cached_path = temp_dir.join("test.server.js"); + + ServerTarget::new( + "test".to_string(), + 12345, + source_path, + cached_path, + "console.log('test')".to_string(), + "/test".to_string(), + "test".to_string(), + false, + ) + } + + #[test] + fn test_server_target_collection() { + let mut collection = ServerTargetCollection::new(); + + let target = create_test_target(); + let func_id = target.func_id; + let id = target.id.clone(); + + collection.add_target(target).unwrap(); + assert_eq!(collection.len(), 1); + assert!(collection.get_target(&id).is_some()); + assert!(collection.get_target_by_func_id(func_id).is_some()); + } + + #[test] + fn test_target_filtering() { + let mut collection = ServerTargetCollection::new(); + + let page_target = create_test_target(); + let special_target = ServerTarget::new( + "app".to_string(), + 54321, + PathBuf::from("src/_app.tsx"), + PathBuf::from("_app.server.js"), + "app content".to_string(), + "/_app".to_string(), + "_app".to_string(), + true, + ); + + collection.add_target(page_target).unwrap(); + collection.add_target(special_target).unwrap(); + + assert_eq!(collection.page_targets().len(), 1); + assert_eq!(collection.special_targets().len(), 1); + } + + #[test] + fn test_target_stats() { + let mut collection = ServerTargetCollection::new(); + + let target = create_test_target(); + collection.add_target(target).unwrap(); + let stats = collection.stats(); + + assert_eq!(stats.total_targets, 1); + assert_eq!(stats.page_targets, 1); + assert_eq!(stats.special_targets, 0); + assert_eq!(stats.total_content_size, 17); // "console.log('test')" length + } + + #[test] + fn test_legacy_targets_compatibility() { + let mut targets = Targets::new(); + let path = PathBuf::from("/test/path"); + + targets.insert(12345, &path); + + let exec_map = targets.ready_for_exec(); + assert!(exec_map.contains_key(path.to_str().unwrap())); + } +} diff --git a/crates/metassr-build/src/server/targets.rs b/crates/metassr-build/src/server/targets.rs index 4285ff4..f88e83a 100644 --- a/crates/metassr-build/src/server/targets.rs +++ b/crates/metassr-build/src/server/targets.rs @@ -3,15 +3,6 @@ use std::{ path::{Path, PathBuf}, }; -use anyhow::Result; - -use metassr_fs_analyzer::src_dir::PagesEntriesType; -use metassr_utils::cache_dir::CacheDir; - -use crate::{traits::Generate, utils::setup_page_path}; - -use super::render::ServerRender; - #[derive(Debug, Clone)] pub struct Targets(HashMap); @@ -63,30 +54,3 @@ impl Default for Targets { Self::new() } } - -pub struct TargetsGenerator<'a> { - app: PathBuf, - pages: PagesEntriesType, - cache: &'a mut CacheDir, -} - -impl<'a> TargetsGenerator<'a> { - pub fn new(app: PathBuf, pages: PagesEntriesType, cache: &'a mut CacheDir) -> Self { - Self { app, pages, cache } - } - pub fn generate(&mut self) -> Result { - let mut targets = Targets::new(); - for (page, page_path) in self.pages.iter() { - let (func_id, render_script) = ServerRender::new(&self.app, page_path).generate()?; - - let page = setup_page_path(page, "server.js"); - let path = self.cache.insert( - PathBuf::from("pages").join(&page).to_str().unwrap(), - render_script.as_bytes(), - )?; - - targets.insert(func_id, &path); - } - Ok(targets) - } -} diff --git a/crates/metassr-bundler/src/bundle.js b/crates/metassr-bundler/src/bundle.js index 9a880ef..20471eb 100644 --- a/crates/metassr-bundler/src/bundle.js +++ b/crates/metassr-bundler/src/bundle.js @@ -1,135 +1,135 @@ const { rspack } = require('@rspack/core'); -const path = require('path'); +const { join } = require('path'); -/** - * Safely parses a JSON string, returning undefined if parsing fails. - * @param {string} json - The JSON string to parse. - * @returns {Object|undefined} - Parsed object or undefined if parsing fails. - */ function safelyParseJSON(json) { try { return JSON.parse(json); - } catch (_) { + } catch { return undefined; } } -// Default configuration object for rspack bundling process -let config = { +const defaultConfig = { output: { - filename: '[name].js', // Output filename with the entry name + filename: '[name].js', library: { - type: 'commonjs2', // Set library type to CommonJS2 (Node.js modules) + type: 'commonjs2', }, - publicPath: '' // Specify the base path for all assets within the application + publicPath: '', }, resolve: { - extensions: ['.js', '.jsx', '.tsx', '.ts'] // Extensions that will be resolved + extensions: ['.js', '.jsx', '.tsx', '.ts'], }, optimization: { - minimize: false, // Disable minimization for easier debugging + minimize: true, }, module: { rules: [ - { - test: /\.(jsx|js)$/, // Rule for JavaScript and JSX files - exclude: /node_modules/, // Exclude node_modules directory + { + test: /\.(jsx|js)$/, + exclude: /node_modules/, use: { - loader: 'builtin:swc-loader', // Use the SWC loader to transpile ES6+ and JSX + loader: 'builtin:swc-loader', options: { - sourceMap: true, // Enable source maps for easier debugging jsc: { parser: { - syntax: 'ecmascript', // Set parser syntax to ECMAScript - jsx: true, // Enable parsing JSX syntax + syntax: 'ecmascript', + jsx: true, + dynamicImport: true }, - externalHelpers: false, // Disable external helpers (use inline helpers) - preserveAllComments: false, // Remove comments from output transform: { react: { - runtime: 'automatic', // Use React's automatic JSX runtime - throwIfNamespace: true, // Throw error if namespace is used - useBuiltins: false, // Don't include built-in polyfills - }, - }, - }, - }, + runtime: 'automatic', + throwIfNamespace: true + } + } + } + } }, - type: 'javascript/auto', // Specify the type as auto (for backward compatibility) + type: 'javascript/auto' }, { - test: /\.(tsx|ts)$/, // Rule for TypeScript and TSX files - exclude: /node_modules/, // Exclude node_modules directory + test: /\.(tsx|ts)$/, + exclude: /node_modules/, use: { - loader: 'builtin:swc-loader', // Use the SWC loader to transpile TS and TSX + loader: 'builtin:swc-loader', options: { jsc: { parser: { - syntax: 'typescript', // Set parser syntax to TypeScript - tsx: true, // Enable parsing TSX syntax + syntax: 'typescript', + tsx: true, + decorators: true }, transform: { react: { - runtime: 'automatic', // Use React's automatic JSX runtime - throwIfNamespace: true, // Throw error if namespace is used - useBuiltins: false, // Don't include built-in polyfills - }, - }, - }, - }, + runtime: 'automatic', + throwIfNamespace: true + } + } + } + } }, - type: 'javascript/auto', // Specify the type as auto + type: 'javascript/auto' }, { - test: /\.(png|svg|jpg)$/, // Rule for image files (PNG, SVG, JPG) + test: /\.(png|svg|jpg|jpeg|gif|woff|woff2|eot|ttf|otf)$/, type: 'asset/inline', // Inline assets as Base64 strings - }, - ], - }, + } + ] + } }; -/** - * Bundles web resources using rspack. - * @param {Object|string} entry - The entry point(s) for the bundling process (can be a string or JSON object). - * @param {string} dist - The distribution path where bundled files will be output. - * @returns {Promise} - Resolves when bundling is successful, rejects if there is an error. - */ -async function web_bundling(entry, dist) { - // Create a bundler instance using the config and parameters - const compiler = rspack( - { - ...config, // Merge with the default config - entry: safelyParseJSON(entry) ?? entry, // Parse entry if it's JSON, otherwise use it as is - output: dist ? { - ...config.output, - path: path.join(process.cwd(), dist), // Use current working directory and output path - } : config.output, - // minimize: true, - name: 'Client', // Name of the bundle (Client) - mode: 'production', // Set mode to development (for non-minimized builds) - devtool: 'source-map', // Enable source maps for better debugging - stats: { preset: 'errors-warnings', timings: true, colors: true }, // Customize bundling stats output - target: 'web', // Set the target environment to web (for browser usage) +function createBundlerConfig(entry, dist) { + return { + ...defaultConfig, + entry: safelyParseJSON(entry) ?? entry, + output: dist ? { + ...defaultConfig.output, + path: join(process.cwd(), dist) + } : defaultConfig.output, + name: 'Client', + mode: 'production', + devtool: 'source-map', + experiments: { + css: true + }, + // plugins: [], + stats: { + preset: 'errors-warnings', + timings: true, + colors: true, + modules: true + }, + target: 'web', + module: defaultConfig.module, + performance: { + hints: 'warning', + maxAssetSize: 250000, + maxEntrypointSize: 400000 } - ); + }; +} + +async function web_bundling(entry, dist) { + const compiler = rspack(createBundlerConfig(entry, dist)); - // Return a promise that runs the bundling process and resolves or rejects based on the result return new Promise((resolve, reject) => { - return compiler.run((error, stats) => { - // Handle errors during the bundling process + compiler.run((error, stats) => { if (error) { - reject(error.message); // Reject with the error message if bundling fails + return reject(new Error(`Bundling failed: ${error.message}`)); } - // Check if there are any errors in the bundling stats - if (error || stats?.hasErrors()) { - reject(stats.toString("errors-only")); // Reject with errors-only details from stats + if (stats?.hasErrors()) { + const info = stats.toJson(); + const errors = info.errors?.map(e => e.message).join('\n') || 'Unknown compilation errors'; + return reject(new Error(`Compilation errors:\n${errors}`)); } - resolve(0); // Resolve successfully when bundling is complete + + resolve(0); }); }); } module.exports = { - web_bundling // Export the web_bundling function to call it via metacall + web_bundling }; diff --git a/crates/metassr-bundler/src/lib.rs b/crates/metassr-bundler/src/lib.rs index dcb4414..0c83761 100644 --- a/crates/metassr-bundler/src/lib.rs +++ b/crates/metassr-bundler/src/lib.rs @@ -96,17 +96,14 @@ impl<'a> WebBundler<'a> { /// /// This function returns an `Err` if the bundling script cannot be loaded or if bundling fails. pub fn exec(&self) -> Result<()> { - // Lock the mutex to check if the bundling script is already loaded let mut guard = IS_BUNDLING_SCRIPT_LOADED.lock().unwrap(); if !guard.is_true() { // If not loaded, attempt to load the script into MetaCall if let Err(e) = load::from_memory("node", BUILD_SCRIPT) { return Err(anyhow!("Cannot load bundling script: {e:?}")); } - // Mark the script as loaded guard.make_true(); } - // Drop the lock on the mutex as it's no longer needed drop(guard); // Resolve callback when the bundling process is completed successfully @@ -114,7 +111,6 @@ impl<'a> WebBundler<'a> { let compilation_wait = &*Arc::clone(&IS_COMPLIATION_WAIT); let mut started = compilation_wait.checker.lock().unwrap(); - // Mark the process as completed and notify waiting threads started.make_true(); compilation_wait.cond.notify_one(); @@ -126,7 +122,6 @@ impl<'a> WebBundler<'a> { let compilation_wait = &*Arc::clone(&IS_COMPLIATION_WAIT); let mut started = compilation_wait.checker.lock().unwrap(); - // Log the bundling error and mark the process as completed error!("Bundling rejected: {err:?}"); started.make_true(); compilation_wait.cond.notify_one(); @@ -138,10 +133,8 @@ impl<'a> WebBundler<'a> { let future = metacall::( BUNDLING_FUNC, [ - // Serialize the targets map to a string format - serde_json::to_string(&self.targets)?, - // Get the distribution path as a string - self.dist_path.to_str().unwrap().to_owned(), + serde_json::to_string(&self.targets)?, // entry + self.dist_path.to_str().unwrap().to_owned(), // dist ], ) .unwrap(); @@ -150,16 +143,13 @@ impl<'a> WebBundler<'a> { // TODO: uncomment this code and resolve the error future.then(resolve).catch(reject).await_fut(); - // Lock the mutex and wait for the bundling process to complete let compilation_wait = Arc::clone(&IS_COMPLIATION_WAIT); let mut started = compilation_wait.checker.lock().unwrap(); - // Block the current thread until the bundling process signals completion while !started.is_true() { started = Arc::clone(&IS_COMPLIATION_WAIT).cond.wait(started).unwrap(); } - // Reset the checker state to false after the process completes started.make_false(); Ok(()) } diff --git a/crates/metassr-fs-analyzer/src/src_dir.rs b/crates/metassr-fs-analyzer/src/src_dir.rs index 4f7ddb7..1828e1d 100644 --- a/crates/metassr-fs-analyzer/src/src_dir.rs +++ b/crates/metassr-fs-analyzer/src/src_dir.rs @@ -1,6 +1,7 @@ use super::DirectoryAnalyzer; use anyhow::{anyhow, Result}; -use std::{collections::HashMap, ffi::OsStr, marker::Sized, path::PathBuf}; +use metassr_utils::rand::Rand; +use std::{collections::HashMap, ffi::OsStr, hash::{DefaultHasher, Hash, Hasher}, marker::Sized, path::PathBuf}; use walkdir::WalkDir; /// Wrappers for special entries that collected by the source analyzer @@ -16,6 +17,64 @@ pub mod special_entries { pub struct Head(pub PathBuf); } +#[derive(Debug, Clone)] +pub struct PageInformation { + pub route: String, + pub path: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct Page { + pub id: u64, + pub info: PageInformation, +} + +impl Page { + pub fn new(id: u64, route: &S, path: &P) -> Self + where + S: ToString, + P: AsRef + ?Sized, + { + Self { + id, + info: PageInformation { + route: route.to_string(), + path: PathBuf::from(path), + }, + } + } +} + +#[derive(Debug, Clone)] +pub struct Pages(pub Vec); + +impl Pages { + pub fn new() -> Self { + Self(Vec::new()) + } + + pub fn from(pages: Vec) -> Self { + Self(pages) + } + + pub fn insert + ?Sized>(&mut self, id: u64, route: &S, path: &P) { + self.0.push(Page::new(id, route, path)); + } + + pub fn as_map(&self) -> HashMap { + HashMap::from_iter( + self.0 + .iter() + .map(|Page { id, info }| (info.route.to_owned(), info.path.to_owned())) + .collect::>(), + ) + } + + pub fn iter(self: &Self) -> std::slice::Iter { + self.0.iter() + } +} + pub type PagesEntriesType = HashMap; pub type SpecialEntriesType = (Option, Option); @@ -24,8 +83,8 @@ pub type SpecialEntriesType = (Option, Option Self { + pub fn new(pages: Pages, specials: SpecialEntriesType) -> Self { Self { pages, specials } } @@ -68,7 +127,7 @@ impl SourceDirContainer { /// **Returns** /// /// Returns a `HashMap` where keys are routes and values are paths to page files. - pub fn pages(&self) -> PagesEntriesType { + pub fn pages(&self) -> Pages { self.pages.clone() } } @@ -105,7 +164,7 @@ impl DirectoryAnalyzer for SourceDir { let src = self.0.to_str().unwrap(); let list_of_specials = ["_app", "_head"]; - let mut pages: HashMap = HashMap::new(); + let mut pages = Pages::new(); let mut specials: SpecialEntriesType = (None, None); for entry in WalkDir::new(src) @@ -123,20 +182,22 @@ impl DirectoryAnalyzer for SourceDir { let path = entry.path(); let stem = path.file_stem().unwrap().to_str().unwrap(); let stripped = path.strip_prefix(src)?; - + match stripped.iter().next() { Some(_) if list_of_specials.contains(&stem) => match stem { "_app" => specials.0 = Some(special_entries::App(path.to_path_buf())), "_head" => specials.1 = Some(special_entries::Head(path.to_path_buf())), _ => (), }, - + Some(p) if p == OsStr::new("pages") => { + let mut hasher = DefaultHasher::new(); let route = path .strip_prefix([src, "/pages"].concat())? .to_str() .unwrap(); - pages.insert(route.to_owned(), path.to_path_buf()); + route.hash(&mut hasher); + pages.insert(hasher.finish(), &route, path); } _ => (), @@ -187,7 +248,7 @@ mod tests { } let result = source_dir.analyze().unwrap(); - assert_eq!(result.pages().len(), pages.len()); + assert_eq!(result.pages().0.len(), pages.len()); assert!(result.specials().is_ok()); // Cleanup diff --git a/crates/html-generator/Cargo.toml b/crates/metassr-html/Cargo.toml similarity index 91% rename from crates/html-generator/Cargo.toml rename to crates/metassr-html/Cargo.toml index a6baf9c..b63b939 100644 --- a/crates/html-generator/Cargo.toml +++ b/crates/metassr-html/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "html-generator" +name = "metassr-html" version = "0.0.1-alpha" edition = "2021" description = "A simple library to generate html content with a simple way." diff --git a/crates/html-generator/src/builder.rs b/crates/metassr-html/src/builder.rs similarity index 100% rename from crates/html-generator/src/builder.rs rename to crates/metassr-html/src/builder.rs diff --git a/crates/html-generator/src/default.html b/crates/metassr-html/src/default.html similarity index 100% rename from crates/html-generator/src/default.html rename to crates/metassr-html/src/default.html diff --git a/crates/html-generator/src/html_props.rs b/crates/metassr-html/src/html_props.rs similarity index 100% rename from crates/html-generator/src/html_props.rs rename to crates/metassr-html/src/html_props.rs diff --git a/crates/html-generator/src/lib.rs b/crates/metassr-html/src/lib.rs similarity index 100% rename from crates/html-generator/src/lib.rs rename to crates/metassr-html/src/lib.rs diff --git a/crates/html-generator/src/template.rs b/crates/metassr-html/src/template.rs similarity index 100% rename from crates/html-generator/src/template.rs rename to crates/metassr-html/src/template.rs diff --git a/metassr-cli/src/cli/builder.rs b/metassr-cli/src/cli/builder.rs index 780a1e2..edbc2f0 100644 --- a/metassr-cli/src/cli/builder.rs +++ b/metassr-cli/src/cli/builder.rs @@ -4,22 +4,26 @@ use super::traits::Exec; use anyhow::{anyhow, Result}; use clap::ValueEnum; use metacall::initialize; -use metassr_build::server; -use metassr_build::{client::ClientBuilder, server::ServerSideBuilder, traits::Build}; +use metassr_build::{ + client::{config::ClientConfig, ClientBuilder}, + server::{config::ServerConfig, ServerSideBuilder}, + traits::Build, +}; use std::time::Instant; use tracing::{error, info}; pub struct Builder { + root_dir: String, out_dir: String, _type: BuildingType, } impl Builder { - pub fn new(_type: BuildingType, out_dir: String) -> Self { - Self { out_dir, _type } + pub fn new(_type: BuildingType, root_dir: String, out_dir: String) -> Self { + Self { root_dir, out_dir, _type } } } @@ -31,10 +35,14 @@ impl Exec for Builder { { let instant = Instant::now(); - if let Err(e) = ClientBuilder::new("", &self.out_dir)?.build() { + // Create client configuration + let client_config = ClientConfig::new(".", &self.out_dir)?; + let client_builder = ClientBuilder::new(client_config); + + if let Err(e) = client_builder.build() { error!( target = "builder", - message = format!("Couldn't build for the client side: {e}"), + message = format!("Couldn't build for the client side: {e}"), ); return Err(anyhow!("Couldn't continue building process.")); } @@ -45,10 +53,16 @@ impl Exec for Builder { ); } + // Build server-side { let instant = Instant::now(); - if let Err(e) = ServerSideBuilder::new("", &self.out_dir, self._type.into())?.build() { + // Create server configuration + let server_config = ServerConfig::new(".", &self.out_dir)? + .with_building_type(self._type.into()); + let server_builder = ServerSideBuilder::new(server_config); + + if let Err(e) = server_builder.build() { error!( target = "builder", message = format!("Couldn't build for the server side: {e}"), @@ -81,11 +95,11 @@ pub enum BuildingType { Ssr, } -impl From for server::BuildingType { - fn from(val: BuildingType) -> Self { - match val { - BuildingType::Ssg => server::BuildingType::StaticSiteGeneration, - BuildingType::Ssr => server::BuildingType::ServerSideRendering, +impl Into for BuildingType { + fn into(self) -> metassr_build::BuildingType { + match self { + Self::Ssg => metassr_build::BuildingType::StaticSiteGeneration, + Self::Ssr => metassr_build::BuildingType::ServerSideRendering, } } } diff --git a/metassr-cli/src/main.rs b/metassr-cli/src/main.rs index 757b191..f21dbfe 100644 --- a/metassr-cli/src/main.rs +++ b/metassr-cli/src/main.rs @@ -50,7 +50,7 @@ async fn main() -> Result<()> { out_dir, build_type, } => { - cli::Builder::new(build_type, out_dir).exec()?; + cli::Builder::new(build_type, args.root, out_dir).exec()?; } Commands::Run { port, serve } => { cli::Runner::new(port, serve, allow_http_debug) diff --git a/tests/web-app/.gitignore b/tests/web-app/.gitignore new file mode 100644 index 0000000..608ce1d --- /dev/null +++ b/tests/web-app/.gitignore @@ -0,0 +1,3 @@ +dist +node_modules +*.lock \ No newline at end of file diff --git a/tests/web-app/package.json b/tests/web-app/package.json index b5da393..a3d912b 100644 --- a/tests/web-app/package.json +++ b/tests/web-app/package.json @@ -6,8 +6,8 @@ "scripts": { "build": "../../target/debug/metassr --debug-mode=metacall build", "build:ssg": "../../target/debug/metassr --debug-mode=metacall build -t ssg", - "start": "../../target/debug/metassr --debug-mode=metacall run", - "start:ssg": "../../target/debug/metassr --debug-mode=metacall run --serve" + "run": "../../target/debug/metassr --debug-mode=metacall run ", + "run:ssg": "../../target/debug/metassr --debug-mode=metacall run --serve" }, "devDependencies": { "@rspack/core": "^0.7.5", @@ -15,6 +15,7 @@ "@types/react-dom": "^18.0.8" }, "dependencies": { + "@rspack/core": "1.5.5", "react": "^18.3.1", "react-dom": "^18.3.1" }