//! Client build script - UnoCSS integration and asset optimization //! //! This build script handles: //! - UnoCSS style generation from component usage //! - Asset processing and optimization //! - WASM-specific configuration constants //! - Client-side route pre-compilation use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::env; use std::fmt::Debug; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use tera::{Context, Tera}; use walkdir::WalkDir; /// UnoCSS configuration #[derive(Deserialize, Serialize, Debug)] pub struct UnocssConfig { pub content: Vec, pub theme: HashMap, pub shortcuts: HashMap, pub presets: Vec, } /// Smart cache for client assets struct ClientCache { cache_dir: PathBuf, } impl ClientCache { fn new() -> Result> { let cache_dir = PathBuf::from(env::var("OUT_DIR")?).join("..\\..\\.rustelo-cache\\client"); fs::create_dir_all(&cache_dir)?; Ok(Self { cache_dir }) } fn get_cache_key(&self, content: &str) -> String { let mut hasher = Sha256::new(); hasher.update(content.as_bytes()); hasher.finalize().iter().fold(String::new(), |mut s, b| { use std::fmt::Write; let _ = write!(s, "{b:02x}"); s }) } fn is_cached(&self, key: &str) -> bool { self.cache_dir.join(format!("{}.css", key)).exists() } fn read_cache(&self, key: &str) -> Result> { let cache_file = self.cache_dir.join(format!("{}.css", key)); Ok(fs::read_to_string(cache_file)?) } fn write_cache(&self, key: &str, content: &str) -> Result<(), Box> { let cache_file = self.cache_dir.join(format!("{}.css", key)); fs::write(cache_file, content)?; Ok(()) } } fn main() -> Result<(), Box> { // Initialize build coordinator to prevent duplicate builds let _build_coordinator = if rustelo_tools::is_cache_available() { Some(rustelo_tools::BuildCoordinator::new("client")?) } else { println!("cargo:warning=Build coordinator not available - using direct build"); None }; // Use new PAP-compliant manifest system let manifest = rustelo_utils::ManifestResolver::load()?; let content_path = rustelo_utils::manifest_content_path(); let config_path = rustelo_utils::manifest_config_path(); let workspace_root = manifest.root(); println!("cargo:rerun-if-changed={}/config", content_path.display()); println!("cargo:rerun-if-changed={}", config_path.display()); println!( "cargo:rerun-if-changed={}/site/config/index.ncl", workspace_root.display() ); // NOTE: site/rbac.ncl is intentionally NOT a rerun-if-changed dependency. // Policy is delivered at runtime via WebSocket (RbacPolicyUpdated message). // Changing rbac.ncl does not require a WASM rebuild. println!("cargo:rerun-if-changed=src/"); println!("cargo:rerun-if-changed=../pages/src/"); println!( "cargo:rerun-if-changed={}/unocss.config.js", workspace_root.display() ); println!( "cargo:rerun-if-changed={}/package.json", workspace_root.display() ); // Initialize smart cache let cache = if rustelo_tools::is_cache_available() { Some(rustelo_tools::SmartCache::new("client")?) } else { println!("cargo:warning=Smart cache not available - using legacy cache"); None }; // Each generator returns the filename it wrote — no filename literals in main(). let mut generated_files: Vec<&'static str> = Vec::new(); generated_files.push(generate_client_config(&manifest)?); // manifest_constants.rs is internal — not included in lib.rs directly. generate_manifest_constants(&manifest)?; // theme_constants.rs is included directly by src/theme.rs — NOT via aggregator. // Including it in the aggregator would create a second `pub mod theme` at crate root, // conflicting with the `pub mod theme;` declaration in lib.rs. generate_theme_constants(&manifest)?; // Process UnoCSS with smart caching if let Some(ref smart_cache) = cache { if let Err(e) = process_unocss_smart(smart_cache) { println!( "cargo:warning=Smart UnoCSS processing failed: {}. Falling back to legacy method.", e ); let legacy_cache = ClientCache::new()?; if let Err(e2) = process_unocss(&legacy_cache) { println!( "cargo:warning=UnoCSS processing failed: {}. Using fallback CSS.", e2 ); generate_fallback_css()?; } } } else { let legacy_cache = ClientCache::new()?; if let Err(e) = process_unocss(&legacy_cache) { println!( "cargo:warning=UnoCSS processing failed: {}. Falling back to basic CSS.", e ); generate_fallback_css()?; } } generated_files.push(generate_client_routes(&manifest)?); generated_files.push(generate_page_provider_impl(&manifest)?); generated_files.push(generate_menu_registry_entries(&manifest)?); generated_files.push(generate_footer_registry_entries(&manifest)?); generated_files.push(generate_ftl_registry_entries(&manifest)?); generated_files.push(generate_palette_app_pages(&manifest)?); // Generate client documentation using rustelo_tools generate_client_documentation(&manifest)?; // Emit the aggregator from the registry built above. let out_dir = std::env::var("OUT_DIR")?; generate_includes_aggregator(&generated_files, &out_dir)?; println!("cargo:warning=Client build completed with smart caching and documentation"); Ok(()) } /// Emit `generated_includes.rs` from the filenames returned by each generator. /// No filename is hardcoded here — generators own their artifact names. fn generate_includes_aggregator( generated_files: &[&str], out_dir: &str, ) -> Result<(), Box> { let mut content = String::from("// Auto-generated aggregator — do not edit directly.\n"); for name in generated_files { content.push_str(&format!( "include!(concat!(env!(\"OUT_DIR\"), \"/{}\"));\n", name )); } std::fs::write( std::path::Path::new(out_dir).join("generated_includes.rs"), content, )?; Ok(()) } fn generate_client_config( manifest_resolver: &rustelo_utils::ManifestResolver, ) -> Result<&'static str, Box> { const OUTPUT: &str = "client_config.rs"; let manifest = manifest_resolver.manifest(); let mut tera = Tera::new("templates/**/*").unwrap_or_else(|err| { eprintln!( "Warning: Failed to load Tera templates: {}. Using default.", err ); Tera::default() }); let template = r#" // Auto-generated client configuration constants // Generated at build time from config.toml /// Client-side configuration constants pub mod config { pub const CONTENT_URL: &str = "{{ content_url }}"; pub const DEFAULT_LANGUAGE: &str = "{{ default_language }}"; pub const SUPPORTED_LANGUAGES: &[&str] = &[{% for lang in languages %}"{{ lang }}"{% if not loop.last %}, {% endif %}{% endfor %}]; pub const CONTENT_TYPES: &[&str] = &[{% for content_type in content_types %}"{{ content_type }}"{% if not loop.last %}, {% endif %}{% endfor %}]; {% if debug %} pub const DEBUG_MODE: bool = true; {% else %} pub const DEBUG_MODE: bool = false; {% endif %} } "#; tera.add_raw_template("client_config", template)?; let mut context = Context::new(); // Extract configuration values context.insert("content_url", &manifest.deployment.content_url); context.insert("default_language", &manifest.discovery.default_lang); if manifest.discovery.languages != "auto" { context.insert("languages", &manifest.discovery.languages); } else { context.insert("languages", &vec!["en"]); } if manifest.discovery.content_types != "auto" { context.insert("content_types", &manifest.discovery.content_types); } else { context.insert("content_types", &vec!["page"]); } context.insert("debug", &manifest.build.debug); let generated_code = tera.render("client_config", &context)?; // Write generated configuration let out_dir = env::var("OUT_DIR")?; let config_file = Path::new(&out_dir).join(OUTPUT); fs::write(config_file, generated_code)?; Ok(OUTPUT) } fn generate_manifest_constants( manifest_resolver: &rustelo_utils::ManifestResolver, ) -> Result<(), Box> { // Prefer rustelo.manifest.ncl; fall back to rustelo.manifest.toml. let ncl_path = manifest_resolver.root().join("rustelo").join("manifest.ncl"); let toml_path = manifest_resolver.root().join("rustelo.manifest.toml"); // legacy let manifest_content = if ncl_path.exists() { let output = std::process::Command::new("nickel") .args(["export", "--format", "json", ncl_path.to_str().unwrap_or("")]) .output() .map_err(|e| format!("nickel export failed: {e}"))?; if !output.status.success() { return Err(String::from_utf8_lossy(&output.stderr).trim().to_string().into()); } String::from_utf8(output.stdout)? } else if toml_path.exists() { fs::read_to_string(&toml_path)? } else { return Err(format!( "Manifest file not found at: {} (or .toml)", ncl_path.display() ) .into()); }; let escaped_content = manifest_content.replace('\\', "\\\\").replace('"', "\\\""); let manifest_constants = format!( "// Auto-generated manifest constants for WASM client\n\n\ /// Raw manifest content (JSON or TOML) embedded at build time\n\ pub const MANIFEST_CONTENT: &str = r#\"{escaped_content}\"#;\n" ); let out_dir = env::var("OUT_DIR")?; let manifest_file = Path::new(&out_dir).join("manifest_constants.rs"); fs::write(manifest_file, manifest_constants)?; Ok(()) } fn process_unocss(cache: &ClientCache) -> Result<(), Box> { let workspace_root = rustelo_utils::manifest::manifest_root(); // Check if UnoCSS is configured let unocss_config = workspace_root.join("uno.config.ts"); let package_json = workspace_root.join("package.json"); if !unocss_config.exists() || !package_json.exists() { return Err("UnoCSS not configured".into()); } // Scan for CSS classes in Rust files let class_usage = scan_for_css_classes(&workspace_root)?; let cache_key = cache.get_cache_key(&class_usage); let generated_css = if cache.is_cached(&cache_key) { println!("cargo:warning=Using cached UnoCSS output"); cache.read_cache(&cache_key)? } else { println!("cargo:warning=Generating UnoCSS styles"); // Write detected classes to temp file for UnoCSS processing let temp_classes_file = workspace_root.join("works-pv").join("temp_classes.txt"); fs::write(&temp_classes_file, &class_usage)?; // Run UnoCSS let output = Command::new("npm") .args(["run", "css:build"]) .current_dir(&workspace_root) .output()?; if !output.status.success() { return Err(format!( "UnoCSS build failed: {}", String::from_utf8_lossy(&output.stderr) ) .into()); } // Read generated CSS let css_output = workspace_root.join("dist").join("styles.css"); if css_output.exists() { let css_content = fs::read_to_string(&css_output)?; cache.write_cache(&cache_key, &css_content)?; // Clean up let _ = fs::remove_file(&temp_classes_file); css_content } else { return Err("UnoCSS output not found".into()); } }; // Embed CSS as constant let out_dir = env::var("OUT_DIR")?; let css_file = Path::new(&out_dir).join("styles.rs"); fs::write( css_file, format!( "pub const STYLES: &str = r#\"{}\"#;", generated_css.replace('\"', "\\\"") ), )?; Ok(()) } fn scan_for_css_classes(workspace_root: &Path) -> Result> { let mut classes = std::collections::HashSet::new(); // Scan Rust files for class attributes, excluding build scripts and generated files for entry in WalkDir::new(workspace_root.join("crates")) { let entry = entry?; if entry.path().extension().is_some_and(|ext| ext == "rs") { // Skip build scripts and generated files that might change between builds let file_name = entry .path() .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown"); let path_str = entry.path().to_str().unwrap_or(""); if file_name == "build.rs" || file_name.starts_with("build_") || path_str.contains("/target/") || path_str.contains("/generated/") { continue; } let content = fs::read_to_string(entry.path())?; // Simple regex to find class attributes (can be enhanced) for line in content.lines() { if line.contains("class=") { // Extract classes between quotes if let Some(start) = line.find("class=\"") { let start = start + 7; // Skip 'class="' if let Some(end) = line[start..].find('"') { let class_str = &line[start..start + end]; for class in class_str.split_whitespace() { classes.insert(class.to_string()); } } } } } } } // Sort classes for deterministic hash generation let mut sorted_classes: Vec<_> = classes.into_iter().collect(); sorted_classes.sort(); Ok(sorted_classes.join(" ")) } fn generate_fallback_css() -> Result<(), Box> { let basic_css = r#" /* Basic fallback styles for Rustelo website */ body { font-family: system-ui, sans-serif; line-height: 1.6; margin: 0; } .container { max-width: 1200px; margin: 0 auto; padding: 0 1rem; } .btn { display: inline-block; padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; cursor: pointer; } .btn-primary { background: #3b82f6; color: white; } .text-center { text-align: center; } .mt-4 { margin-top: 1rem; } .mb-4 { margin-bottom: 1rem; } .grid { display: grid; gap: 1rem; } .grid-cols-2 { grid-template-columns: repeat(2, 1fr); } .grid-cols-3 { grid-template-columns: repeat(3, 1fr); } "#; let out_dir = env::var("OUT_DIR")?; let css_file = Path::new(&out_dir).join("styles.rs"); fs::write( css_file, format!( "pub const STYLES: &str = r#\"{}\"#;", basic_css.replace('\"', "\\\"") ), )?; Ok(()) } fn generate_client_routes( manifest_resolver: &rustelo_utils::ManifestResolver, ) -> Result<&'static str, Box> { const OUTPUT: &str = "client_routes.rs"; let manifest_dir = manifest_resolver.root().to_path_buf(); let mut all_routes: HashMap> = HashMap::new(); let mut path_to_component: HashMap = HashMap::new(); let mut path_to_content_type: HashMap = HashMap::new(); // Tracks all languages that share each path — used below to exclude language-neutral paths. let mut path_language_set: HashMap> = HashMap::new(); // Load from site/config/index.ncl — strict, no fallback let site_cfg = build_config::site_config::load_unified_site_config(&manifest_dir) .unwrap_or_else(|e| { panic!( "Cannot load site/config/index.ncl for client routes: {e}\n\ Install Nickel: cargo install nickel-lang-cli" ) }); // rbac_deny_patterns intentionally empty: WASM policy is driven entirely at runtime // via the WebSocket RbacPolicyUpdated message. No compile-time coupling to rbac.ncl. let rbac_deny_patterns: Vec = Vec::new(); println!( "cargo:warning=Client: Using site/config/index.ncl for routes ({} languages)", site_cfg.languages().len() ); for (lang, routes) in site_cfg.routes_expanded() { let mut lang_routes = HashMap::new(); for route in routes { if route.is_external.unwrap_or(false) || !route.path.starts_with('/') { continue; } // Key encodes component + content_type + param arity so that routes like // ContentIndex/projects (0 params) and ContentIndex/projects/{category} (1 param) // don't overwrite each other in the map. let param_count = route .path .split('/') .filter(|s| s.starts_with('{') && s.ends_with('}')) .count(); let route_key = match route.content_type.as_deref() { Some(ct) => format!("{}_{}_{}", route.component.to_lowercase(), ct, param_count), None => format!("{}_{}", route.component.to_lowercase(), param_count), }; lang_routes.insert(route_key, route.path.clone()); path_to_component .entry(route.path.clone()) .or_insert_with(|| route.component.clone()); // Track which languages each path belongs to — shared paths (same URL in // multiple languages, e.g. "/blog" for both EN and ES) must be excluded from // path_to_language so get_language_for_path returns None for them, allowing // the caller to preserve the current language instead of forcing a switch. path_language_set .entry(route.path.clone()) .or_default() .insert(lang.clone()); if let Some(ct) = route.content_type.as_deref() { path_to_content_type .entry(route.path.clone()) .or_insert_with(|| ct.to_string()); } } all_routes.insert(lang, lang_routes); } // Build path_to_language: only include paths that unambiguously belong to ONE language. // Paths shared across languages (en = "/blog", es = "/blog") are intentionally excluded. let mut path_to_language: HashMap = HashMap::new(); for (path, langs) in &path_language_set { if langs.len() == 1 { if let Some(lang) = langs.iter().next() { path_to_language.insert(path.clone(), lang.clone()); } } } // Build path_localization: from_path → (to_lang → new_path) // Enables WASM-safe URL switching when the user changes language. let mut path_localization: HashMap> = HashMap::new(); for (from_path, component) in &path_to_component { let param_count = from_path .split('/') .filter(|s| s.starts_with('{') && s.ends_with('}')) .count(); let comp_key = match path_to_content_type.get(from_path) { Some(ct) => format!("{}_{}_{}", component.to_lowercase(), ct, param_count), None => format!("{}_{}", component.to_lowercase(), param_count), }; let mut lang_map: HashMap = HashMap::new(); for (to_lang, lang_routes) in &all_routes { if let Some(new_path) = lang_routes.get(&comp_key) { if new_path != from_path { lang_map.insert(to_lang.clone(), new_path.clone()); } } } if !lang_map.is_empty() { path_localization.insert(from_path.clone(), lang_map); } } // Parametric patterns (paths containing `{...}` segments) for runtime matching #[derive(Serialize)] struct ParametricCompPattern { path: String, component: String, } #[derive(Serialize)] struct ParametricLangPattern { path: String, lang: String, } #[derive(Serialize)] struct ParametricCtPattern { path: String, ct: String, } let parametric_patterns: Vec = path_to_component .iter() .filter(|(p, _)| p.contains('{')) .map(|(path, comp)| ParametricCompPattern { path: path.clone(), component: comp.clone(), }) .collect(); let parametric_lang_patterns: Vec = path_to_language .iter() .filter(|(p, _)| p.contains('{')) .map(|(path, lang)| ParametricLangPattern { path: path.clone(), lang: lang.clone(), }) .collect(); let parametric_ct_patterns: Vec = path_to_content_type .iter() .filter(|(p, _)| p.contains('{')) .map(|(path, ct)| ParametricCtPattern { path: path.clone(), ct: ct.clone(), }) .collect(); // Flat list of route entries for code generation (avoids Tera map key lookup limitations) #[derive(Serialize)] struct RouteEntry { path: String, component: String, language: String, content_type: Option, } let route_entries: Vec = path_to_component .iter() .map(|(path, component)| { let language = path_to_language .get(path) .cloned() .unwrap_or_else(|| "en".to_string()); let content_type = path_to_content_type.get(path).cloned(); RouteEntry { path: path.clone(), component: component.clone(), language, content_type, } }) .collect(); let mut tera = Tera::new("templates/**/*").unwrap_or_else(|err| { eprintln!( "Warning: Failed to load Tera templates: {}. Using default.", err ); Tera::default() }); let template = r#" // Auto-generated client-side route constants // Generated from site/config/routes/*.toml /// Client-side route constants for navigation pub mod routes { {% for lang, routes in languages %} pub mod {{ lang }} { {% for route_name, route_path in routes %} pub const {{ route_name | upper }}: &str = "{{ route_path }}"; {% endfor %} } {% endfor %} /// Get route path for language pub fn get_route(language: &str, route_name: &str) -> Option<&'static str> { match (language, route_name) { {% for lang, routes in languages %} {% for route_name, route_path in routes %} ("{{ lang }}", "{{ route_name }}") => Some("{{ route_path }}"), {% endfor %} {% endfor %} _ => None, } } /// Get component name for a given path (reverse route lookup) pub fn get_component_for_path(path: &str) -> Option<&'static str> { let exact = match path { {% for route_path, component_name in path_to_component %} "{{ route_path }}" => Some("{{ component_name }}"), {% endfor %} _ => None, }; if exact.is_some() { return exact; } static COMP_PATTERNS: &[(&str, &str)] = &[ {% for p in parametric_patterns %}("{{ p.path }}", "{{ p.component }}"), {% endfor %} ]; let path_segs: Vec<&str> = path.split('/').collect(); for (pattern, comp) in COMP_PATTERNS { let pat_segs: Vec<&str> = pattern.split('/').collect(); if pat_segs.len() != path_segs.len() { continue; } if pat_segs.iter().zip(path_segs.iter()).all(|(p, s)| (p.starts_with('{') && p.ends_with('}')) || p == s) { return Some(comp); } } None } /// Get language for a given path (reverse route lookup — WASM-safe, no filesystem access) pub fn get_language_for_path(path: &str) -> Option<&'static str> { let exact = match path { {% for route_path, lang in path_to_language %} "{{ route_path }}" => Some("{{ lang }}"), {% endfor %} _ => None, }; if exact.is_some() { return exact; } static LANG_PATTERNS: &[(&str, &str)] = &[ {% for p in parametric_lang_patterns %}("{{ p.path }}", "{{ p.lang }}"), {% endfor %} ]; let path_segs: Vec<&str> = path.split('/').collect(); for (pattern, lang) in LANG_PATTERNS { let pat_segs: Vec<&str> = pattern.split('/').collect(); if pat_segs.len() != path_segs.len() { continue; } if pat_segs.iter().zip(path_segs.iter()).all(|(p, s)| (p.starts_with('{') && p.ends_with('}')) || p == s) { return Some(lang); } } None } /// Compile-time anonymous deny patterns from `site/rbac.ncl`. /// Used by `policy::init_policy_signal` as the initial value for the runtime signal /// so SSR and the first WASM render produce identical DOM (hydration safe). pub static AUTH_PATTERNS: &[&str] = &[ {% for p in rbac_deny_patterns %}"{{ p }}", {% endfor %} ]; /// Returns true when `path` requires an authenticated user. /// /// Derived exclusively from `site/rbac.ncl` `defaults.unauthenticated_allow` deny entries — /// single source of truth. Pattern semantics match the RBAC policy: /// - `"/foo"` → exact match /// - `"/foo/*"` → prefix match (`/foo/...`) pub fn requires_auth_for_path(path: &str) -> bool { for pattern in AUTH_PATTERNS { if let Some(prefix) = pattern.strip_suffix("/*") { if path == prefix || path.starts_with(&format!("{}/", prefix)) { return true; } } else if path == *pattern { return true; } } false } /// Get content_type for a given path — derived from [routes.props] content_type (WASM-safe). pub fn get_content_type_for_path(path: &str) -> Option<&'static str> { let exact = match path { {% for route_path, ct in path_to_content_type %} "{{ route_path }}" => Some("{{ ct }}"), {% endfor %} _ => None, }; if exact.is_some() { return exact; } static CT_PATTERNS: &[(&str, &str)] = &[ {% for p in parametric_ct_patterns %}("{{ p.path }}", "{{ p.ct }}"), {% endfor %} ]; let path_segs: Vec<&str> = path.split('/').collect(); for (pattern, ct) in CT_PATTERNS { let pat_segs: Vec<&str> = pattern.split('/').collect(); if pat_segs.len() != path_segs.len() { continue; } if pat_segs.iter().zip(path_segs.iter()).all(|(p, s)| (p.starts_with('{') && p.ends_with('}')) || p == s) { return Some(ct); } } None } /// Translate a path to its equivalent in the target language (WASM-safe). /// Returns None if path is already in target language or no translation is known. pub fn get_localized_path(path: &str, to_lang: &str) -> Option<&'static str> { match (path, to_lang) { {% for from_path, lang_map in path_localization %} {% for to_lang, new_path in lang_map %} ("{{ from_path }}", "{{ to_lang }}") => Some("{{ new_path }}"), {% endfor %} {% endfor %} _ => None, } } /// Seed the framework ROUTES_CACHE with build-time route data (WASM only). /// Must be called before any code that uses `load_routes_config()` to ensure /// language switching and route resolution work without filesystem access. pub fn seed_routes_cache() { use rustelo_core_lib::routing::core_types::ROUTES_CACHE; use rustelo_core_lib::routing::{RouteConfigToml, RoutesConfig}; let routes = vec![ {% for entry in route_entries %} RouteConfigToml { path: "{{ entry.path }}".to_string(), component: "{{ entry.component }}".to_string(), title_key: "page-title".to_string(), language: "{{ entry.language }}".to_string(), enabled: true, description_key: None, keywords: None, priority: None, menu_group: None, menu_order: None, menu_icon: None, is_external: None, requires_auth: None, show_in_sitemap: None, alternate_paths: None, canonical_path: None, content_type: {% if entry.content_type %}Some("{{ entry.content_type }}".to_string()){% else %}None{% endif %}, rendering_profile: None, template: None, props: None, unified_component: None, lang_prefixes: None, params_component: None, }, {% endfor %} ]; let _ = ROUTES_CACHE.set(RoutesConfig { routes }); } } "#; tera.add_raw_template("client_routes", template)?; let mut context = Context::new(); context.insert("languages", &all_routes); context.insert("path_to_component", &path_to_component); context.insert("path_to_language", &path_to_language); context.insert("path_to_content_type", &path_to_content_type); context.insert("path_localization", &path_localization); context.insert("route_entries", &route_entries); context.insert("rbac_deny_patterns", &rbac_deny_patterns); context.insert("parametric_patterns", ¶metric_patterns); context.insert("parametric_lang_patterns", ¶metric_lang_patterns); context.insert("parametric_ct_patterns", ¶metric_ct_patterns); let generated_code = tera.render("client_routes", &context)?; // Write generated routes let out_dir = env::var("OUT_DIR")?; let routes_file = Path::new(&out_dir).join(OUTPUT); fs::write(routes_file, generated_code)?; Ok(OUTPUT) } /// Process UnoCSS using rustelo_tools SmartCache fn process_unocss_smart( cache: &rustelo_tools::SmartCache, ) -> Result<(), Box> { let resolver = rustelo_utils::ManifestResolver::load()?; let workspace_root = resolver.root(); // Check if UnoCSS is configured let unocss_config = workspace_root.join("uno.config.ts"); let package_json = workspace_root.join("package.json"); if !unocss_config.exists() || !package_json.exists() { return Err("UnoCSS not configured".into()); } // Scan for CSS classes in Rust files let class_usage = scan_for_css_classes(workspace_root)?; // Create dependencies list for cache validation let dependencies = vec![ unocss_config.to_string_lossy().to_string(), package_json.to_string_lossy().to_string(), ]; // Calculate hash for CSS classes let source_hash = rustelo_tools::hash_route_config(&class_usage, &dependencies); let filename = "styles.css"; // Debug cache status println!( "cargo:warning=SmartCache debug: source_hash={}, filename={}", source_hash, filename ); // Check cache status match cache.check_file_cache(filename, &source_hash)? { rustelo_tools::CacheStatus::Fresh(_) => { // Use cached CSS - fast path! let out_dir = std::env::var("OUT_DIR")?; let out_path = std::path::Path::new(&out_dir); cache.copy_from_cache(filename, out_path)?; // Generate styles.rs from cached CSS let cached_css = std::fs::read_to_string(out_path.join(filename))?; let css_file = out_path.join("styles.rs"); std::fs::write( css_file, format!( "pub const STYLES: &str = r#\"{}\"#;", cached_css.replace('\"', "\\\"") ), )?; println!("cargo:warning=✅ Using cached UnoCSS output (CACHE HIT)"); } rustelo_tools::CacheStatus::Stale(_) => { // Generate new CSS - slow path println!("cargo:warning=⚠️ Generating UnoCSS styles (CACHE STALE)"); } rustelo_tools::CacheStatus::Missing(_) => { // Generate new CSS - slow path println!("cargo:warning=🔄 Generating UnoCSS styles (CACHE MISS)"); // Write detected classes to temp file for UnoCSS processing let temp_classes_file = workspace_root.join("works-pv").join("temp_classes.txt"); std::fs::write(&temp_classes_file, &class_usage)?; // Run UnoCSS let output = std::process::Command::new("npm") .args(["run", "css:build"]) .current_dir(workspace_root) .output()?; if !output.status.success() { return Err(format!( "UnoCSS build failed: {}", String::from_utf8_lossy(&output.stderr) ) .into()); } // Read generated CSS let css_output = workspace_root.join("dist").join("styles.css"); if css_output.exists() { let css_content = std::fs::read_to_string(&css_output)?; // Update cache with new content let out_dir = std::env::var("OUT_DIR")?; let out_path = std::path::Path::new(&out_dir); cache.update_cache(filename, &css_content, &source_hash, out_path, dependencies)?; // Generate styles.rs let css_file = out_path.join("styles.rs"); std::fs::write( css_file, format!( "pub const STYLES: &str = r#\"{}\"#;", css_content.replace('\"', "\\\"") ), )?; // Clean up let _ = std::fs::remove_file(&temp_classes_file); println!("cargo:warning=Generated and cached new UnoCSS styles"); } else { return Err("UnoCSS output not found".into()); } } } Ok(()) } /// Generate client documentation using rustelo_tools comprehensive_analysis fn generate_client_documentation( manifest: &rustelo_utils::ManifestResolver, ) -> Result<(), Box> { let project_root = manifest.root(); let info_path = project_root.join("site").join("info"); // Create info directory if it doesn't exist std::fs::create_dir_all(&info_path)?; // Generate client documentation rustelo_tools::build::generate_enhanced_client_documentation( &project_root.to_string_lossy(), &info_path.to_string_lossy(), )?; println!("cargo:warning=Generated client documentation in site/info"); Ok(()) } /// Generate theme configuration constants for WASM client fn generate_theme_constants( manifest_resolver: &rustelo_utils::ManifestResolver, ) -> Result<&'static str, Box> { const OUTPUT: &str = "theme_constants.rs"; // Single source of truth: site/config/index.ncl logo section let manifest_dir = manifest_resolver.root().to_path_buf(); let site_cfg = build_config::site_config::load_unified_site_config(&manifest_dir)?; let logo = &site_cfg.logo; let width_const = match &logo.width { Some(w) => format!("Some(\"{}\")", w), None => "None".to_string(), }; let height_const = match &logo.height { Some(h) => format!("Some(\"{}\")", h), None => "None".to_string(), }; let theme_constants = format!( r#"/// Theme configuration constants embedded for WASM client /// Generated at build time from site/config/index.ncl [logo] #[allow(clippy::module_inception)] pub mod theme {{ pub const DEFAULT_THEME_NAME: &str = "default"; /// Pre-formatted TOML for populating THEME_REGISTRY at WASM startup. /// Avoids hardcoding site-specific values in lib.rs. pub const THEME_TOML: &str = "[assets]\\nlogo_light = \\\"{}\\\"\\nlogo_dark = \\\"{}\\\"\\nlogo_alt = \\\"{}\\\"\\n"; /// Logo configuration — single source of truth: site/config/index.ncl pub mod logo {{ pub const LIGHT_SRC: &str = "{}"; pub const DARK_SRC: &str = "{}"; pub const ALT: &str = "{}"; pub const SHOW_IN_NAV: bool = {}; pub const SHOW_IN_FOOTER: bool = {}; pub const WIDTH: Option<&str> = {}; pub const HEIGHT: Option<&str> = {}; }} }} "#, logo.light_src, logo.dark_src, logo.alt, logo.light_src, logo.dark_src, logo.alt, logo.show_in_nav, logo.show_in_footer, width_const, height_const ); let out_dir = env::var("OUT_DIR")?; let theme_file = Path::new(&out_dir).join(OUTPUT); fs::write(theme_file, theme_constants)?; println!("cargo:warning=Generated theme constants from site/config/index.ncl"); Ok(OUTPUT) } /// Generate PageProvider implementation using generated page components fn generate_page_provider_impl( manifest_resolver: &rustelo_utils::ManifestResolver, ) -> Result<&'static str, Box> { const OUTPUT: &str = "page_provider_impl.rs"; let manifest = manifest_resolver.manifest(); let cache_build_path = &manifest.build.cache_build_path; let manifest_dir = manifest_resolver.root(); // Read generated page files from cache_build_path let pages_cache_dir = manifest_dir .join("target") .join(cache_build_path) .join("pages"); if !pages_cache_dir.exists() { println!( "cargo:warning=Pages cache directory not found: {}", pages_cache_dir.display() ); return generate_fallback_page_provider_impl(); } // Find all page_*.rs files let mut page_files = Vec::new(); for entry in fs::read_dir(&pages_cache_dir)? { let entry = entry?; let path = entry.path(); if let Some(name) = path.file_name() { if let Some(name_str) = name.to_str() { if name_str.starts_with("page_") && name_str.ends_with(".rs") { // Extract component name from filename (page_NAME.rs -> NAME) if let Some(component_name) = name_str .strip_prefix("page_") .and_then(|s| s.strip_suffix(".rs")) { page_files.push((component_name.to_string(), path.clone())); } } } } } if page_files.is_empty() { println!("cargo:warning=No page files found in cache, generating fallback"); return generate_fallback_page_provider_impl(); } // Log discovered components for debugging println!( "cargo:warning=Dynamically discovered {} components in cache:", page_files.len() ); for (component_name, _) in &page_files { println!("cargo:warning= - {}", component_name); } // Generate the PageProvider implementation let mut provider_impl = String::from( r#"// Auto-generated PageProvider implementation // Dynamically generated from ALL components found in cache_build_path #[allow(unused_imports)] // False positive: AnyView and leptos components are used but not always detected by static analysis use leptos::prelude::*; use rustelo_core_lib::{PageProvider, ComponentResult}; use std::collections::HashMap; // Include all generated page wrapper components "#, ); // Add include statements for ALL page files found in cache (truly dynamic) for (component_name, _) in &page_files { // Include ALL components found in cache, not just those in current routes // This makes the system adapt automatically to new/changed components provider_impl.push_str(&format!( "include!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/../../target/{}/pages/page_{}.rs\"));\n", cache_build_path, component_name )); } provider_impl.push_str(r#" /// Website-specific PageProvider implementation pub struct WebsitePageProvider; impl PageProvider for WebsitePageProvider { type Component = String; type View = AnyView; type Props = HashMap; fn get_component(&self, component_id: &str) -> ComponentResult { Ok(component_id.to_string()) } fn render_component(&self, component: &Self::Component, _props: Self::Props, language: &str) -> ComponentResult { let lang = language.to_string(); let view = match component.as_str() { "#); // Add match arms for ALL components found in cache (fully dynamic) with proper props for (component_name, _) in &page_files { // Generate component calls with required props (PAP-compliant with safe defaults) let component_call = match component_name.as_str() { "ContentCategory" => format!(" \"{}\" => {{ let content_type = _props.get(\"content_type\").cloned().unwrap_or_else(|| \"blog\".to_string()); view! {{ <{}Page _language=lang.clone() category=\"default\".to_string() content_type=content_type /> }}.into_any() }},\n", component_name, component_name), "ContentIndex" => format!(" \"{}\" => {{ let content_type = _props.get(\"content_type\").cloned().unwrap_or_else(|| \"blog\".to_string()); view! {{ <{}Page _language=lang.clone() content_type=content_type /> }}.into_any() }},\n", component_name, component_name), "PostViewer" => format!(" \"{}\" => {{ let content_type = _props.get(\"content_type\").cloned().unwrap_or_else(|| \"blog\".to_string()); let slug = _props.get(\"slug\").cloned().unwrap_or_default(); view! {{ <{}Page _language=lang.clone() slug=slug content_type=content_type /> }}.into_any() }},\n", component_name, component_name), _ => format!(" \"{}\" => view! {{ <{}Page _language=lang.clone() /> }}.into_any(),\n", component_name, component_name), }; provider_impl.push_str(&component_call); } let not_found_in_cache = page_files.iter().any(|(n, _)| n == "NotFound"); let wildcard_arm = if not_found_in_cache { " _ => {\n view! { }.into_any()\n }\n" } else { " _ => {\n let _lang = lang;\n view! {

\"404\"

\"Page not found\"

}.into_any()\n }\n" }; provider_impl.push_str(wildcard_arm); provider_impl.push_str( r#" }; Ok(view) } fn list_components(&self) -> Vec { vec![ "#, ); // Add ALL components found in cache to list (fully dynamic) for (component_name, _) in &page_files { provider_impl.push_str(&format!( " \"{}\".to_string(),\n", component_name )); } provider_impl.push_str( r#" ] } } "#, ); // Write to OUT_DIR let out_dir = env::var("OUT_DIR")?; let provider_file = Path::new(&out_dir).join(OUTPUT); fs::write(provider_file, provider_impl)?; println!( "cargo:warning=Generated PageProvider implementation with {} components", page_files.len() ); Ok(OUTPUT) } /// Generate fallback PageProvider when no pages are found - still dynamic! fn generate_fallback_page_provider_impl() -> Result<&'static str, Box> { const OUTPUT: &str = "page_provider_impl.rs"; // Even fallback should try to discover from rustelo_pages_leptos dynamically let fallback_impl = r#"// Dynamic PageProvider implementation // Uses rustelo_pages_leptos components discovered at build time #[allow(unused_imports)] // False positive: view! macro and AnyView are used but not detected by static analysis use leptos::prelude::*; use rustelo_core_lib::{PageProvider, ComponentResult}; use rustelo_pages_leptos::*; use std::collections::HashMap; /// Dynamic PageProvider that uses available rustelo_pages_leptos components pub struct WebsitePageProvider; impl PageProvider for WebsitePageProvider { type Component = String; type View = AnyView; type Props = HashMap; fn get_component(&self, component_id: &str) -> ComponentResult { Ok(component_id.to_string()) } fn render_component(&self, component: &Self::Component, _props: Self::Props, language: &str) -> ComponentResult { let lang = language.to_string(); // Try to render using available components, with NotFound as fallback let view = match component.as_str() { _ => { // Log unknown component for debugging #[cfg(target_arch = "wasm32")] web_sys::console::log_1(&format!("Unknown component '{}', using NotFound", component).into()); view! { }.into_any() } }; Ok(view) } fn list_components(&self) -> Vec { // Return empty list - components will be discovered dynamically vec![] } } "#; let out_dir = env::var("OUT_DIR")?; let provider_file = Path::new(&out_dir).join(OUTPUT); fs::write(provider_file, fallback_impl)?; println!("cargo:warning=Generated minimal PageProvider (no cache found)"); Ok(OUTPUT) } /// Generates `menu_registry_fn.rs` in OUT_DIR from site/config/index.ncl — strict, no fallback. fn generate_menu_registry_entries( manifest_resolver: &rustelo_utils::ManifestResolver, ) -> Result<&'static str, Box> { const OUTPUT: &str = "menu_registry_fn.rs"; let manifest_dir = manifest_resolver.root(); let site_cfg = build_config::site_config::load_unified_site_config(manifest_dir) .unwrap_or_else(|e| { panic!( "Cannot load site/config/index.ncl for WASM menu registry: {e}\n\ Install Nickel: cargo install nickel-lang-cli" ) }); let mut code = String::from( "// Auto-generated WASM menu registry population function\n\ // Source: site/config/index.ncl — do not edit\n\n\ fn populate_wasm_menu_registry() {\n\ if let Ok(mut registry) = rustelo_core_lib::registration::MENU_REGISTRY.write() {\n", ); // Insert per-language header-zone entries keyed by language code. // auth_required is always false here — runtime RBAC policy is delivered via WebSocket. // No compile-time coupling to site/rbac.ncl. for lang in site_cfg.languages() { let menu_toml = site_cfg.menu_toml_for_lang("header", lang); let len = menu_toml.len(); let escaped = menu_toml.replace("\"#", "\"\\#"); code.push_str(&format!( " registry.insert(\"{lang}\".to_string(), r#\"{escaped}\"#.to_string());\n" )); println!( "cargo:warning= ✓ Embedded menu for '{}' ({} bytes, from site/config/index.ncl)", lang, len ); } code.push_str(" }\n}\n"); let out_dir = env::var("OUT_DIR")?; let entries_file = Path::new(&out_dir).join(OUTPUT); fs::write(entries_file, code)?; Ok(OUTPUT) } /// Generates `footer_registry_fn.rs` in OUT_DIR from site/config/index.ncl — strict, no fallback. fn generate_footer_registry_entries( manifest_resolver: &rustelo_utils::ManifestResolver, ) -> Result<&'static str, Box> { const OUTPUT: &str = "footer_registry_fn.rs"; use serde::Serialize; use std::collections::HashMap; // Mirror structs matching the exact TOML shape expected by FooterConfig + SimpleMenuItem. // Field names and renames MUST match rustelo_core_lib::FooterConfig's serde attributes. #[derive(Serialize)] struct FooterToml { title: String, #[serde(rename = "company-desc")] company_desc: String, copyright_text: String, #[serde(skip_serializing_if = "Vec::is_empty")] social_links: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] menu: Vec, } #[derive(Serialize)] struct SocialLinkToml { name: String, url: String, icon_class: String, } #[derive(Serialize)] struct MenuItemToml { route: String, // Translations' GenericTwoFields variant deserializes a flat { lang -> text } map. label: HashMap, } let manifest_dir = manifest_resolver.root(); let site_cfg = build_config::site_config::load_unified_site_config(manifest_dir) .unwrap_or_else(|e| { panic!( "Cannot load site/config/index.ncl for WASM footer registry: {e}\n\ Install Nickel: cargo install nickel-lang-cli" ) }); let footer = &site_cfg.footer; let mut code = String::from( "// Auto-generated WASM footer registry population function\n\ // Source: site/config/index.ncl — do not edit\n\n\ fn populate_wasm_footer_registry() {\n\ if let Ok(mut registry) = rustelo_core_lib::registration::FOOTER_REGISTRY.write() {\n", ); for lang in site_cfg.languages() { let company_desc = footer .company_desc .get(lang.as_str()) .cloned() .unwrap_or_default(); let copyright_text = footer .copyright_text .get(lang.as_str()) .cloned() .unwrap_or_default(); let footer_menu_items = site_cfg.menu_items_for_zone("footer", lang); let footer_data = FooterToml { title: footer.title.clone(), company_desc, copyright_text, social_links: footer .social_links .iter() .map(|l| SocialLinkToml { name: l.name.clone(), url: l.url.clone(), icon_class: l.icon.clone(), }) .collect(), menu: footer_menu_items .iter() .map(|item| MenuItemToml { route: item.route.clone(), label: { let mut m = HashMap::new(); m.insert(lang.to_string(), item.label.clone()); m }, }) .collect(), }; let toml_str = toml::to_string(&footer_data)?; let len = toml_str.len(); let escaped = toml_str.replace("\"#", "\"\\#"); code.push_str(&format!( " registry.insert(\"{lang}\".to_string(), r#\"{escaped}\"#.to_string());\n" )); println!("cargo:warning= ✓ Embedded footer for '{lang}' ({len} bytes, from site/config/index.ncl)"); } code.push_str(" }\n}\n"); let out_dir = env::var("OUT_DIR")?; let entries_file = Path::new(&out_dir).join(OUTPUT); fs::write(entries_file, code)?; Ok(OUTPUT) } /// Generates `ftl_registry_fn.rs` in OUT_DIR by walking `site/i18n/locales/` and /// embedding every `*.ftl` file with `include_str!`. The generated /// `populate_wasm_ftl_registry()` function populates `FTL_REGISTRY` at WASM /// startup so that `get_parsed_ftl_for_language(lang)` returns full translations /// for every supported language — enabling correct language switching in WASM. fn generate_ftl_registry_entries( manifest_resolver: &rustelo_utils::ManifestResolver, ) -> Result<&'static str, Box> { const OUTPUT: &str = "ftl_registry_fn.rs"; let manifest_dir = manifest_resolver.root(); let locales_dir = manifest_dir.join("site").join("i18n").join("locales"); let mut code = String::from( "// Auto-generated WASM FTL registry population function\n\ // Source: site/i18n/locales/ — do not edit\n\n\ fn populate_wasm_ftl_registry() {\n\ use rustelo_core_lib::registration::FTL_REGISTRY;\n\ if let Ok(mut registry) = FTL_REGISTRY.write() {\n\ if !registry.is_empty() { return; }\n", ); let mut count = 0usize; if locales_dir.exists() { for lang_entry in fs::read_dir(&locales_dir)?.filter_map(|e| e.ok()) { let lang_path = lang_entry.path(); if !lang_path.is_dir() { continue; } let lang = match lang_path.file_name().and_then(|n| n.to_str()) { Some(l) => l.to_string(), None => continue, }; // Walk all *.ftl files under this language directory. for ftl_entry in WalkDir::new(&lang_path) .into_iter() .filter_map(|e| e.ok()) .filter(|e| e.path().extension().is_some_and(|x| x == "ftl")) { let ftl_path = ftl_entry.path(); let abs_path = ftl_path.canonicalize()?; // Build registry key: lang + "_" + relative path components joined with "_". // e.g. site/i18n/locales/en/pages/home.ftl → "en_pages_home" let rel = ftl_path.strip_prefix(&lang_path).unwrap_or(ftl_path); let key_suffix = rel .with_extension("") .components() .filter_map(|c| { if let std::path::Component::Normal(n) = c { n.to_str().map(|s| s.replace(['-', ' '], "_")) } else { None } }) .collect::>() .join("_"); let registry_key = format!("{}_{}", lang, key_suffix); let abs_str = abs_path.to_str().unwrap_or("").replace('\\', "/"); code.push_str(&format!( " registry.insert(\"{registry_key}\".to_string(), \ include_str!(\"{abs_str}\").to_string());\n" )); let display_path = abs_path .strip_prefix(manifest_dir) .unwrap_or(&abs_path) .display() .to_string(); println!( "cargo:warning= ✓ Embedded FTL: {} from {}", registry_key, display_path ); println!("cargo:rerun-if-changed={abs_str}"); count += 1; } } } else { println!( "cargo:warning= ⚠️ FTL locales dir not found at {}, skipping FTL embedding", locales_dir.display() ); } code.push_str(" }\n}\n"); let out_dir = env::var("OUT_DIR")?; let entries_file = Path::new(&out_dir).join(OUTPUT); fs::write(entries_file, &code)?; println!("cargo:warning= ✓ Generated ftl_registry_fn.rs with {count} FTL files"); Ok(OUTPUT) } /// Converts a URL path segment like `/provisioning-systems` into a title label /// like `Provisioning Systems`. Single-segment paths like `/vapora` become `Vapora`. fn path_to_title_label(path: &str) -> String { path.trim_start_matches('/') .split('-') .map(|word| { let mut chars = word.chars(); match chars.next() { None => String::new(), Some(first) => first.to_uppercase().collect::() + chars.as_str(), } }) .collect::>() .join(" ") } /// Generates `palette_app_pages_fn.rs` — a static slice of `(label, route)` pairs for /// application pages that have no nav-menu entry. These are injected into the command /// palette via `CommandPaletteExtraItems` context so users can navigate to project pages /// (Provisioning Systems, Vapora, etc.) by typing in the palette. fn generate_palette_app_pages( manifest_resolver: &rustelo_utils::ManifestResolver, ) -> Result<&'static str, Box> { const OUTPUT: &str = "palette_app_pages_fn.rs"; let manifest_dir = manifest_resolver.root(); let site_cfg = build_config::site_config::load_unified_site_config(manifest_dir) .unwrap_or_else(|e| { panic!( "Cannot load site/config/index.ncl for palette app pages: {e}\n\ Install Nickel: cargo install nickel-lang-cli" ) }); // Generic component names that are NOT standalone pages const SKIP_COMPONENTS: &[&str] = &[ "ContentIndex", "PostViewer", "ExternalLink", "NotFound", "HomePage", ]; let mut entries: Vec<(String, String)> = site_cfg .routes .iter() .filter(|r| { !r.menu.enabled && r.component.ends_with("Page") && !SKIP_COMPONENTS.iter().any(|s| r.component.contains(s)) }) .filter_map(|r| { // Prefer the English path; fall back to any available path let path = r.paths.get("en").or_else(|| r.paths.values().next())?; // Skip parametric routes like /blog/{category}/{slug} if path.contains('{') { return None; } let label = path_to_title_label(path); Some((label, path.clone())) }) .collect(); entries.sort_by(|a, b| a.0.cmp(&b.0)); // Remove duplicate routes (keep first by label order) let mut seen_routes = std::collections::HashSet::new(); entries.retain(|(_, route)| seen_routes.insert(route.clone())); let items_literal: String = entries .iter() .map(|(label, route)| format!(" (\"{label}\", \"{route}\"),\n")) .collect(); let code = format!( "// Auto-generated palette application pages — do not edit\n\ // Source: site/config/routes.ncl — regenerated on every build\n\n\ pub fn get_palette_app_pages() -> &'static [(&'static str, &'static str)] {{\n\ &[\n\ {items_literal}\ ]\n\ }}\n" ); let out_dir = env::var("OUT_DIR")?; fs::write(Path::new(&out_dir).join(OUTPUT), &code)?; println!( "cargo:warning= ✓ Embedded {} palette app page entries (palette_app_pages_fn.rs)", entries.len() ); Ok(OUTPUT) }