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

1579 lines
58 KiB
Rust

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