website-htmx-rustelo-code/crates/pages/build_page_generator.rs
2026-07-10 03:44:13 +01:00

1013 lines
36 KiB
Rust

// 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<String>,
#[serde(default)]
pub lang_prefixes: Option<Vec<String>>,
#[serde(default)]
pub props: Option<toml::Value>,
#[serde(default)]
pub content_type: Option<String>,
#[serde(default)]
pub module_path: Option<String>,
#[serde(default)]
pub parameter_extraction: Option<toml::Value>,
#[serde(default)]
pub content_type_param: Option<toml::Value>,
}
/// Component generation information
#[derive(Debug, Clone, Serialize)]
pub struct ComponentInfo {
pub component_name: String,
pub unified_component: String,
pub lang_prefixes: Vec<String>,
pub props: Option<toml::Value>,
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<String> takes String not Option<String>,
// 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<std::path::PathBuf> {
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<String> {
// 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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
// 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<String, Box<dyn std::error::Error>> {
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::<Vec<_>>()
.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<Vec<ComponentInfo>, Box<dyn std::error::Error>> {
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<String, ComponentInfo> = 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(|_| "<SITE_CONFIG_PATH not set>".into())
)
.into());
}
let mut components: Vec<ComponentInfo> = 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<ComponentInfo, Box<dyn std::error::Error>> {
// 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/<crate-name>
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(())
}