1579 lines
58 KiB
Rust
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", ¶metric_patterns);
|
|
context.insert("parametric_lang_patterns", ¶metric_lang_patterns);
|
|
context.insert("parametric_ct_patterns", ¶metric_ct_patterns);
|
|
|
|
let generated_code = tera.render("client_routes", &context)?;
|
|
|
|
// Write generated routes
|
|
let out_dir = env::var("OUT_DIR")?;
|
|
let routes_file = Path::new(&out_dir).join(OUTPUT);
|
|
fs::write(routes_file, generated_code)?;
|
|
|
|
Ok(OUTPUT)
|
|
}
|
|
|
|
/// Process UnoCSS using rustelo_tools SmartCache
|
|
fn process_unocss_smart(
|
|
cache: &rustelo_tools::SmartCache,
|
|
) -> Result<(), Box<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)
|
|
}
|