// Page generation system for pages crate using rustelo_tools SmartCache // Uses route configuration to generate proper Client/SSR components use serde::{Deserialize, Serialize}; use std::env; use std::fs; use std::path::Path; /// Determine if a component needs ::unified in import path /// This is PAP-compliant as it uses the pattern from rustelo_pages lib.rs /// Components that are direct files (like external_link) don't need ::unified /// Components that are directories with unified.rs files need ::unified fn needs_unified_import(component_name: &str, import_package: &str) -> bool { // Only foundation components (rustelo_pages) use ::unified structure // Local website components (website_pages) never use ::unified if import_package == "website_pages" { return false; } // Based on rustelo_pages/src/lib.rs, external_link is the only direct file // All other foundation components are directories with unified.rs submodules component_name != "external_link" } /// Route configuration structure #[derive(Deserialize, Serialize, Debug, Clone)] pub struct RouteConfig { pub path: String, pub page_component: String, pub language: String, pub enabled: bool, #[serde(default)] pub unified_component: Option, #[serde(default)] pub lang_prefixes: Option>, #[serde(default)] pub props: Option, #[serde(default)] pub content_type: Option, #[serde(default)] pub module_path: Option, #[serde(default)] pub parameter_extraction: Option, #[serde(default)] pub content_type_param: Option, } /// Component generation information #[derive(Debug, Clone, Serialize)] pub struct ComponentInfo { pub component_name: String, pub unified_component: String, pub lang_prefixes: Vec, pub props: Option, pub has_existing_unified: bool, pub has_local_unified: bool, pub is_content_page: bool, pub _module_path: String, pub route_config: RouteConfig, } /// Extract the actual component name from the import path, handling aliases fn get_actual_component_name(import_path: &str) -> String { if import_path.contains(" as ") { // Handle "Component as Alias" format import_path .split(" as ") .last() .unwrap_or(import_path) .to_string() } else { // Handle "path::Component" format import_path .split("::") .last() .unwrap_or(import_path) .to_string() } } /// Get required props for component from route configuration fn get_component_required_props_from_config( route_config: &RouteConfig, ) -> (String, String, String) { let mut prop_defs = Vec::new(); let mut prop_args = Vec::new(); let mut prop_forwards = Vec::new(); let mut clones = Vec::new(); // Check if this is a category page (has category parameter) let path = &route_config.path; let is_content_index = route_config.page_component.contains("ContentIndex"); if path.contains("{category}") { if is_content_index { // ContentIndex maps {category} to filter: String (empty = no filter). // Leptos #[prop(optional)] on Option takes String not Option, // so we keep filter as String across all delegation layers. prop_defs.push("#[prop(default = String::new())] filter: String"); prop_args.push("filter=filter.clone()"); prop_forwards.push("filter=filter.clone()"); clones.push("filter"); } else { prop_defs.push("category: String"); prop_args.push("category=category"); prop_forwards.push("category=category.clone()"); clones.push("category"); } } if path.contains("{slug}") { prop_defs.push("slug: String"); prop_args.push("slug=slug"); prop_forwards.push("slug=slug.clone()"); clones.push("slug"); } // Check parameter_extraction section for required params if let Some(toml::Value::Table(ref param_extraction)) = route_config.parameter_extraction { for (param_name, _extraction_method) in param_extraction { match param_name.as_str() { "category" if !clones.contains(&"category") && !clones.contains(&"filter") => { if is_content_index { prop_defs.push("#[prop(default = String::new())] filter: String"); prop_args.push("filter=filter.clone()"); prop_forwards.push("filter=filter.clone()"); clones.push("filter"); } else { prop_defs.push("category: String"); prop_args.push("category=category"); prop_forwards.push("category=category.clone()"); clones.push("category"); } } "slug" if !clones.contains(&"slug") => { prop_defs.push("slug: String"); prop_args.push("slug=slug"); prop_forwards.push("slug=slug.clone()"); clones.push("slug"); } _ => {} // Ignore other parameters } } } // Add content_type as parameter if content_type_param is true OR if content_type is in props let needs_content_type = match route_config.content_type_param { Some(toml::Value::Boolean(true)) => true, _ => { // Also check if content_type is defined in props section route_config .props .as_ref() .and_then(|props| props.as_table()) .map(|table| table.contains_key("content_type")) .unwrap_or(false) } }; if needs_content_type && !clones.contains(&"content_type") { prop_defs.push("content_type: String"); prop_args.push("content_type=content_type"); prop_forwards.push("content_type=content_type.clone()"); clones.push("content_type"); } let props_str = if !prop_defs.is_empty() { format!("\n {},", prop_defs.join(",\n ")) } else { "".to_string() }; let args_str = if !prop_args.is_empty() { format!(" {}", prop_args.join(" ")) } else { "".to_string() }; let forwards_str = if !prop_forwards.is_empty() { format!("\n {}", prop_forwards.join("\n ")) } else { "".to_string() }; (props_str, args_str, forwards_str) } /// Check if component accepts lang_content prop from route configuration fn component_accepts_lang_content_from_config(route_config: &RouteConfig) -> bool { // Check if route explicitly defines lang_content in props if let Some(toml::Value::Table(props_table)) = &route_config.props { if props_table.contains_key("lang_content") { return true; } } // Check if route has specific properties that indicate it needs lang_content if let Some(component) = &route_config.unified_component { // External links typically don't need lang_content if component.contains("External") { return false; } // Content pages (blog, recipes) need lang_content for i18n if component.contains("ContentIndex") || component.contains("ContentCategory") { return true; } } // Default: all page components accept lang_content (it's #[prop(optional)] on all Unified*Page) // External links are the only exception (already handled above) true } /// Derive the rustelo_pages `src/` directory from the workspace root. /// /// Reads `workspace_root/Cargo.toml`, extracts the `rustelo_pages` path value, /// and resolves it to an absolute `src/` path. fn rustelo_pages_src(workspace_root: &Path) -> Option { let cargo_toml = workspace_root.join("Cargo.toml"); let content = std::fs::read_to_string(&cargo_toml).ok()?; for line in content.lines() { let trimmed = line.trim(); if trimmed.starts_with("rustelo_pages_leptos") && trimmed.contains("path") { // Extract the value inside the first pair of double-quotes let mut chars = trimmed.chars(); if let Some(start) = chars.by_ref().position(|c| c == '"') { let after_open: String = trimmed[start + 1..].to_string(); if let Some(end) = after_open.find('"') { let rel = &after_open[..end]; let src = workspace_root.join(rel).join("src"); return src.canonicalize().ok(); } } } } None } /// Resolve the Rust module path for a snake_case component name within a `src/` tree. /// /// Tries all possible underscore-split points until it finds a directory containing /// `unified.rs`. Returns e.g. `"content::index"` for `"content_index"`. fn resolve_module_path_in_src(src: &Path, snake: &str) -> Option { // Single-segment check first if src.join(snake).join("unified.rs").exists() { return Some(snake.to_string()); } if src.join(format!("{snake}.rs")).exists() { return Some(snake.to_string()); } // Try all underscore split points for multi-segment paths let parts: Vec<&str> = snake.split('_').collect(); for split_at in 1..parts.len() { let prefix = parts[..split_at].join("_"); let suffix = parts[split_at..].join("_"); let candidate = src.join(&prefix).join(&suffix); if candidate.join("unified.rs").exists() { return Some(format!("{prefix}::{suffix}")); } } None } /// Generate unified component import path from route configuration. /// /// `workspace_root` is used to locate the `rustelo_pages` source tree when the /// component lives in the foundation crate (so we can resolve multi-level module /// paths like `content::index` instead of the flattened `content_index`). /// `pages_src` is the `src/` directory of the website pages crate, used to check /// whether a local `unified.rs` submodule exists for website_pages components. fn get_unified_component_path_from_config( route_config: &RouteConfig, component_info: &ComponentInfo, import_package: &str, workspace_root: &Path, pages_src: Option<&Path>, ) -> String { let unified_component = &component_info.unified_component; // Explicit module_path in route config takes top priority if let Some(module_path) = &route_config.module_path { let rust_path = module_path.replace("/", "::"); if needs_unified_import(&rust_path.replace("::", "_"), import_package) { return format!("{}::unified::{}", rust_path, unified_component); } else { return format!("{}::{}", rust_path, unified_component); } } // Derive snake_case component base name (strip Unified…Page wrapper) let bare = unified_component .strip_prefix("Unified") .unwrap_or(unified_component) .strip_suffix("Page") .unwrap_or(unified_component); let snake: String = bare .chars() .enumerate() .map(|(i, c)| { if c.is_uppercase() && i > 0 { format!("_{}", c.to_lowercase()) } else { c.to_lowercase().to_string() } }) .collect(); // For foundation components, search the actual rustelo_pages src tree to // find the real multi-level module path (e.g. content::index, not content_index). if import_package == "rustelo_pages_leptos" { if let Some(src) = rustelo_pages_src(workspace_root) { if let Some(found) = resolve_module_path_in_src(&src, &snake) { if found.contains("::") { // Multi-level path (e.g. content::index) always has unified.rs return format!("{}::unified::{}", found, unified_component); } // Single segment: dir with unified.rs or direct file return if src.join(&snake).join("unified.rs").exists() { format!("{}::unified::{}", found, unified_component) } else { // Direct file (e.g. external_link.rs) — no ::unified:: in path format!("{}::{}", found, unified_component) }; } } } // Fallback: use snake_case path as-is. // For website_pages, check the actual src tree for a unified.rs submodule // (website pages follow the same dir/unified.rs convention as rustelo_pages). if import_package == "website_pages" { let has_unified = pages_src .map(|src| src.join(&snake).join("unified.rs").exists()) .unwrap_or(false); return if has_unified { format!("{}::unified::{}", snake, unified_component) } else { format!("{}::{}", snake, unified_component) }; } if needs_unified_import(&snake, import_package) { format!("{}::unified::{}", snake, unified_component) } else { format!("{}::{}", snake, unified_component) } } /// Generate page components using rustelo_tools SmartCache. /// /// `workspace_root` must be the absolute path to the project workspace root /// (the directory containing `rustelo.manifest.toml`). It is provided by /// `build.rs` via `ManifestResolver::load()?.root()` to avoid a redundant /// manifest search with a potentially different CWD. pub fn generate_page_components(workspace_root: &Path) -> Result<(), Box> { let out_dir = env::var("OUT_DIR")?; let out_path = Path::new(&out_dir); // Check if smart cache is available if !rustelo_tools::is_cache_available() { println!("cargo:warning=Smart cache not available - using direct generation"); return generate_components_direct(out_path, workspace_root); } // Initialize smart cache for pages let cache = rustelo_tools::SmartCache::new("pages")?; // Load route configurations let components = load_route_configurations(workspace_root)?; let components_count = components.len(); println!( "cargo:warning=Using smart cache for {} page components", components_count ); for component_info in components { if component_info.has_existing_unified { let filename = format!("page_{}.rs", component_info.component_name); // Calculate hash for this specific component's config let component_route_content = serde_json::to_string(&component_info)?; let dependencies = vec![get_route_config_path(workspace_root)]; let source_hash = rustelo_tools::hash_route_config(&component_route_content, &dependencies); // Check cache status match cache.check_file_cache(&filename, &source_hash)? { rustelo_tools::CacheStatus::Fresh(_) => { // Use cached version - fast path! cache.copy_from_cache(&filename, out_path)?; println!("cargo:warning=Cached: {}", filename); } rustelo_tools::CacheStatus::Stale(_) | rustelo_tools::CacheStatus::Missing(_) => { // Generate new component - slow path let content = generate_single_component_content( &component_info, &component_info.route_config, workspace_root, )?; cache.update_cache( &filename, &content, &source_hash, out_path, dependencies, )?; println!("cargo:warning=Generated: {}", filename); } } // Copy to reviewable location (build-cache style) copy_to_reviewable_location(&filename, out_path)?; } } // Always show final summary println!( "cargo:warning=✅ Generated {} page components with foundation virtual resolution", components_count ); Ok(()) } /// Direct generation without caching (fallback) fn generate_components_direct( out_path: &Path, workspace_root: &Path, ) -> Result<(), Box> { // Clean output directory clean_generated_output(out_path)?; // Load route configurations let components = load_route_configurations(workspace_root)?; for component_info in components { if component_info.has_existing_unified { // Generate wrapper components for existing unified pages let content = generate_single_component_content( &component_info, &component_info.route_config, workspace_root, )?; let filename = format!("page_{}.rs", component_info.component_name); let file_path = out_path.join(&filename); fs::write(&file_path, content)?; println!( "cargo:warning=Generated page component: {}", component_info.component_name ); // Copy to reviewable location copy_to_reviewable_location(&filename, out_path)?; } } Ok(()) } /// Return the config dependency path for cache invalidation. /// /// # Panics /// Panics if `SITE_CONFIG_PATH` is not set — callers must configure it before /// invoking build scripts. fn get_route_config_path(_workspace_root: &Path) -> String { std::env::var("SITE_CONFIG_PATH").unwrap_or_else(|_| { panic!( "\n\nSITE_CONFIG_PATH is not set.\n\ Set it to your site config file before building:\n\ \n export SITE_CONFIG_PATH=/path/to/site/config/index.ncl\n" ) }) } /// Generate wrapper component content. /// /// `workspace_root` is required to resolve multi-level module paths in /// `rustelo_pages` (e.g. `content::index` vs the flattened `content_index`). fn generate_single_component_content( component_info: &ComponentInfo, route_config: &RouteConfig, workspace_root: &Path, ) -> Result> { if !component_info.has_existing_unified { return Err(format!( "Cannot generate wrapper for {}: unified component does not exist", component_info.component_name ) .into()); } // Build i18n patterns: for content pages, always include "content-" (shared card keys // like content-view-all) plus the content_type-specific prefix. let patterns = if component_info.is_content_page { if let Some(toml::Value::Table(ref props_table)) = component_info.props { if let Some(toml::Value::String(content_type)) = props_table.get("content_type") { vec![ "content-".to_string(), format!("{}-", content_type.to_lowercase()), ] } else { let mut p = component_info.lang_prefixes.clone(); if !p.iter().any(|s| s == "content-") { p.insert(0, "content-".to_string()); } p } } else { let mut p = component_info.lang_prefixes.clone(); if !p.iter().any(|s| s == "content-") { p.insert(0, "content-".to_string()); } p } } else { let mut p = component_info.lang_prefixes.clone(); if !p.iter().any(|s| s == "common-") { p.push("common-".to_string()); } p }; let patterns_str = patterns .iter() .map(|p| format!("\"{}\"", p)) .collect::>() .join(", "); // Get required props from route configuration (PAP-compliant) let (all_props, all_args, all_forward) = get_component_required_props_from_config(route_config); // Create forward strings for both client and SSR let all_forward_client = if !all_forward.is_empty() { format!( "\n {}", all_forward.replace("\n ", "\n ") ) } else { "".to_string() }; let all_forward_ssr = if !all_forward.is_empty() { format!("\n {}", all_forward) } else { "".to_string() }; // Generate clones based on actual props from configuration let all_clones = if !all_props.is_empty() { let mut clone_statements = Vec::new(); if all_props.contains("category") { clone_statements.push("let category = category.clone();"); } if all_props.contains("content_type") { clone_statements.push("let content_type = content_type.clone();"); } if all_props.contains("slug") { clone_statements.push("let slug = slug.clone();"); } if !clone_statements.is_empty() { format!("\n {}", clone_statements.join("\n ")) } else { "".to_string() } } else { "".to_string() }; // Check if component accepts lang_content from configuration let accepts_lang_content = component_accepts_lang_content_from_config(route_config); let lang_content_prop = if accepts_lang_content { "\n lang_content=lang_content.clone()".to_string() } else { "".to_string() }; let lang_content_prop_ssr = if accepts_lang_content { "\n lang_content=lang_content.clone()".to_string() } else { "".to_string() }; let trans_res_setup = "".to_string(); let lang_content_compute = if accepts_lang_content { format!( "let patterns: Vec<&str> = vec![{}];\n let translator = rustelo_core_lib::SsrTranslator::new(_language.clone());\n let lang_content = rustelo_core_lib::i18n::helpers::build_page_content_patterns(&translator, &patterns);", patterns_str ) } else { "".to_string() }; // Determine which package to import from. // A component is website-specific if its module_path maps to an existing // directory inside the pages crate's src/ tree. let pages_src = env::var("CARGO_MANIFEST_DIR") .ok() .map(|d| std::path::Path::new(&d).join("src")) .filter(|p| p.exists()); let import_package = if let Some(module_path) = &route_config.module_path { if let Some(ref src) = pages_src { let module_dir = src.join(module_path.replace("::", "/")); if module_dir.exists() && module_dir.is_dir() { "website_pages" } else { "rustelo_pages_leptos" } } else { "rustelo_pages_leptos" } } else { // Components without an explicit module_path are foundation components. // Still check by snake_case component name in pages_src for local overrides. let snake: String = { let bare = component_info .unified_component .strip_prefix("Unified") .unwrap_or(&component_info.unified_component) .strip_suffix("Page") .unwrap_or(&component_info.unified_component); bare.chars() .enumerate() .map(|(i, c)| { if c.is_uppercase() && i > 0 { format!("_{}", c.to_lowercase()) } else { c.to_lowercase().to_string() } }) .collect() }; if pages_src.as_ref().is_some_and(|s| s.join(&snake).is_dir()) { "website_pages" } else { "rustelo_pages_leptos" } }; let content = format!( r#" // Auto-generated wrapper components for {component_name} // Calls existing {unified_component} from unified module #[allow(unused_imports)] use leptos::prelude::*; use {import_package}::{unified_component_path}; /// Client-side reactive page component (wrapper) #[component] pub fn {component_name}Client( #[prop(default = rustelo_core_lib::config::get_default_language().to_string())] _language: String,{all_props} ) -> impl IntoView {{ {trans_res_setup} // Clone language for use in closure let language = _language.clone();{all_clones} {lang_content_compute} view! {{ <{actual_component} _language=language.clone(){lang_content_prop}{all_forward_client} /> }}.into_any() }} /// SSR static page component (wrapper) #[component] pub fn {component_name}SSR( #[prop(default = rustelo_core_lib::config::get_default_language().to_string())] _language: String,{all_props} ) -> impl IntoView {{ // Create static content for SSR using available translator let patterns: Vec<&str> = vec![{patterns_str}]; let translator = rustelo_core_lib::SsrTranslator::new(_language.clone()); let lang_content = rustelo_core_lib::i18n::helpers::build_page_content_patterns(&translator, &patterns); view! {{ <{actual_component} _language=_language.clone(){lang_content_prop_ssr}{all_forward_ssr} /> }}.into_any() }} /// Main page component with delegation #[component] pub fn {component_name}Page( #[prop(default = rustelo_core_lib::config::get_default_language().to_string())] _language: String,{all_props} ) -> impl IntoView {{ #[cfg(not(target_arch = "wasm32"))] {{ view! {{ <{component_name}SSR _language=_language.clone(){all_forward_as_args} /> }}.into_any() }} #[cfg(target_arch = "wasm32")] {{ view! {{ <{component_name}Client _language=_language.clone(){all_forward_as_args} /> }}.into_any() }} }} "#, component_name = component_info.component_name, unified_component = component_info.unified_component, unified_component_path = get_unified_component_path_from_config( route_config, component_info, import_package, workspace_root, pages_src.as_deref() ), actual_component = get_actual_component_name(&get_unified_component_path_from_config( route_config, component_info, import_package, workspace_root, pages_src.as_deref() )), patterns_str = patterns_str, all_props = all_props, all_clones = all_clones, all_forward_as_args = all_args, import_package = import_package ); Ok(content) } /// Load route configurations from site/config.ncl (single source of truth). /// /// `workspace_root` is the project workspace root containing `site/config.ncl`. /// It is threaded down from `build.rs` via `ManifestResolver::load()?.root()`. fn load_route_configurations( workspace_root: &Path, ) -> Result, Box> { use std::collections::HashMap; let site_cfg = build_config::site_config::load_unified_site_config(workspace_root) .map_err(|e| format!("Cannot load site/config.ncl for pages generation: {e}"))?; let default_lang = site_cfg.site.default_language.clone(); let debug_level = rustelo_utils::get_debug_level(); let mut components_map: HashMap = HashMap::new(); for route in &site_cfg.routes { // Deduplicate by component name; first occurrence wins. if components_map.contains_key(&route.component) { continue; } // Representative path: prefer default language. let representative_path = route .paths .get(&default_lang) .or_else(|| route.paths.values().next()) .cloned() .unwrap_or_default(); // Infer dynamic URL params from any language variant. let has_slug = route.paths.values().any(|p| p.contains("{slug}")); let has_category = route.paths.values().any(|p| p.contains("{category}")); let props = route.content_type.as_ref().map(|ct| { let mut table = toml::map::Map::new(); table.insert("content_type".to_string(), toml::Value::String(ct.clone())); toml::Value::Table(table) }); let parameter_extraction = if has_slug || has_category { let mut table = toml::map::Map::new(); if has_category { table.insert( "category".to_string(), toml::Value::String("path".to_string()), ); } if has_slug { table.insert("slug".to_string(), toml::Value::String("path".to_string())); } Some(toml::Value::Table(table)) } else { None }; let route_config = RouteConfig { path: representative_path, page_component: route.component.clone(), language: default_lang.clone(), enabled: true, unified_component: None, // → Unified{component}Page lang_prefixes: None, // → [{component_lower}-] props, content_type: route.content_type.clone(), module_path: None, // → component_name.to_lowercase() parameter_extraction, content_type_param: None, }; let component_info = process_route_config(route_config)?; if debug_level >= 2 { println!( "cargo:warning= Route → component: {} (unified: {}, exists: {})", component_info.component_name, component_info.unified_component, component_info.has_existing_unified ); } components_map.insert(route.component.clone(), component_info); } if components_map.is_empty() { return Err(format!( "No routes found in configuration at {}.\n\ Verify SITE_CONFIG_PATH points to a valid config file.", std::env::var("SITE_CONFIG_PATH").unwrap_or_else(|_| "".into()) ) .into()); } let mut components: Vec = components_map.into_values().collect(); components.sort_by(|a, b| a.component_name.cmp(&b.component_name)); println!( "cargo:warning=Dynamically discovered {} components from site/config.ncl", components.len() ); Ok(components) } /// Process route config into ComponentInfo fn process_route_config(route: RouteConfig) -> Result> { // Extract component name from page_component let component_name = if route.page_component.ends_with("Page") { route.page_component.trim_end_matches("Page").to_string() } else { route.page_component.clone() }; let unified_component = route .unified_component .clone() .unwrap_or_else(|| format!("Unified{}Page", component_name)); let lang_prefixes = route.lang_prefixes.clone().unwrap_or_else(|| { // CamelCase → kebab-case for i18n keys: "WorkRequest" → "work-request-" let kebab: String = component_name .chars() .enumerate() .map(|(i, c)| { if c.is_uppercase() && i > 0 { format!("-{}", c.to_lowercase()) } else { c.to_lowercase().to_string() } }) .collect(); vec![format!("{}-", kebab)] }); let props = route.props.clone(); let is_content_page = if let Some(toml::Value::Table(ref table)) = props { table.contains_key("content_type") } else { false }; // Check if unified component exists (local OR foundation) let manifest_dir = env::var("CARGO_MANIFEST_DIR")?; // Workspace root is two levels up from crates/ let workspace_root = Path::new(&manifest_dir).join("..").join(".."); let component_path = if let Some(module_path) = &route.module_path { module_path.clone() } else { // CamelCase → snake_case: "WorkRequest" → "work_request", "Services" → "services" component_name .chars() .enumerate() .map(|(i, c)| { if c.is_uppercase() && i > 0 { format!("_{}", c.to_lowercase()) } else { c.to_lowercase().to_string() } }) .collect() }; // Check local unified component first let component_dir = Path::new(&manifest_dir).join("src").join(&component_path); let unified_path = component_dir.join("unified.rs"); let mod_path = component_dir.join("mod.rs"); let has_local_unified = unified_path.exists() || mod_path.exists(); // Check foundation unified component let has_foundation_unified = check_foundation_unified_exists(&unified_component, &workspace_root); let has_existing_unified = has_local_unified || has_foundation_unified; if has_foundation_unified && !has_local_unified { let debug_level = rustelo_utils::get_debug_level(); if debug_level >= 2 { println!( "cargo:warning= Foundation component available: {}", unified_component ); } } Ok(ComponentInfo { component_name, unified_component, lang_prefixes, props, has_existing_unified, has_local_unified, is_content_page, _module_path: component_path, route_config: route.clone(), }) } /// Check if a unified component exists in the foundation fn check_foundation_unified_exists(unified_component: &str, workspace_root: &Path) -> bool { // Primary: derive path from the rustelo_pages dependency declared in Cargo.toml if let Some(src) = rustelo_pages_src(workspace_root) { if search_for_component_in_dir(&src, unified_component) { return true; } } // Secondary: cargo rustelo CLI (available when rustelo toolchain is installed) if let Ok(output) = std::process::Command::new("cargo") .args(["rustelo", "fn", "list", "-f", "page", "--format", "json"]) .output() { if output.status.success() { if let Ok(stdout) = String::from_utf8(output.stdout) { return stdout.contains(unified_component); } } } false } /// Recursively search for component in directory fn search_for_component_in_dir(dir: &Path, component_name: &str) -> bool { if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_file() && path.extension().is_some_and(|ext| ext == "rs") { if let Ok(content) = std::fs::read_to_string(&path) { if content.contains(&format!("pub fn {}", component_name)) || content.contains(&format!("fn {}", component_name)) { return true; } } } else if path.is_dir() && search_for_component_in_dir(&path, component_name) { return true; } } } false } /// Copy generated file to reviewable location using existing cache system fn copy_to_reviewable_location( filename: &str, out_path: &Path, ) -> Result<(), Box> { let source_file = out_path.join(filename); // Use existing cache build path from manifest + pages subdirectory let review_dir = rustelo_utils::cache_build_path().join("pages"); // Ensure directory exists fs::create_dir_all(&review_dir)?; let dest_file = review_dir.join(filename); if source_file.exists() { fs::copy(&source_file, &dest_file)?; let debug_level = rustelo_utils::get_debug_level(); if debug_level >= 2 { println!( "cargo:warning=📄 Copied {} to cache: {}", filename, dest_file.display() ); } } Ok(()) } /// Clean the generated output directory fn clean_generated_output(out_dir: &Path) -> Result<(), Box> { if out_dir.exists() { // Remove all page_*.rs files from previous builds for entry in fs::read_dir(out_dir)? { let entry = entry?; let path = entry.path(); if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { if file_name.starts_with("page_") && file_name.ends_with(".rs") { fs::remove_file(&path)?; } } } } Ok(()) }