1013 lines
36 KiB
Rust
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(())
|
|
}
|