//! Server build script - Uses Rustelo Foundation tools //! //! This build script uses rustelo_tools for all generation instead of //! custom implementations, following the PAP-compliant approach. use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; // Note: HashMap is imported locally within functions (generate_resource_contributor, etc.) // not used at module level, so keeping it local to avoid confusion use std::env; use std::fs; use std::path::{Path, PathBuf}; /// Server configuration structure #[derive(Deserialize, Serialize, Debug)] pub struct ServerConfig { pub host: String, pub port: u16, pub workers: Option, pub content_root: String, pub public_path: String, } /// Smart cache for server generation struct ServerCache { cache_dir: PathBuf, } impl ServerCache { fn new() -> Result> { let manifest = rustelo_utils::ManifestResolver::load()?; let cache_dir = manifest .root() .join("target/site_build/devtools/build-cache/server"); fs::create_dir_all(&cache_dir)?; Ok(Self { cache_dir }) } fn get_content_hash( &self, routes_dir: &Path, components_dir: Option<&Path>, ) -> Result> { let mut hasher = Sha256::new(); // Hash the routes source. `routes_path()` is a stem (e.g. site/config/routes) // whose real source is `routes.ncl` alongside it — NCL, not TOML. Hashing only // `.toml` entries of a non-existent dir yielded an EMPTY hash → a constant cache // key (e3b0c44…, the SHA of "") → a permanent stale CACHE HIT that never picked up // route changes. Hash the `.ncl` file directly, plus any `.toml`/`.ncl` in a dir. let ncl_file = routes_dir.with_extension("ncl"); if ncl_file.is_file() { let content = fs::read_to_string(&ncl_file)?; hasher.update(ncl_file.to_string_lossy().as_bytes()); hasher.update(content.as_bytes()); } if routes_dir.is_dir() { let mut route_files: Vec<_> = fs::read_dir(routes_dir)? .filter_map(|entry| entry.ok()) .filter(|entry| { entry .path() .extension() .is_some_and(|ext| ext == "toml" || ext == "ncl") }) .collect(); route_files.sort_by_key(|entry| entry.path()); for entry in route_files { let content = fs::read_to_string(entry.path())?; hasher.update(entry.path().to_string_lossy().as_bytes()); hasher.update(content.as_bytes()); } } // Hash component files if provided if let Some(comp_dir) = components_dir { if comp_dir.exists() { let mut comp_files: Vec<_> = std::fs::read_dir(comp_dir)? .filter_map(|entry| entry.ok()) .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "rs")) .collect(); comp_files.sort_by_key(|entry| entry.path()); for entry in comp_files { let content = fs::read_to_string(entry.path())?; hasher.update(entry.path().to_string_lossy().as_bytes()); hasher.update(content.as_bytes()); } } } let hash = hasher.finalize(); Ok(hash.iter().fold(String::new(), |mut s, b| { use std::fmt::Write; let _ = write!(s, "{b:02x}"); s })[..16] .to_string()) } #[allow(dead_code)] fn get_cache_status(&self, cache_key: &str, cache_type: &str) -> String { let cache_file = self .cache_dir .join(format!("{}_{}.cache", cache_type, cache_key)); if cache_file.exists() { "CACHE HIT".to_string() } else { "CACHE MISS".to_string() } } fn is_cached(&self, cache_key: &str, cache_type: &str) -> bool { let cache_file = self .cache_dir .join(format!("{}_{}.cache", cache_type, cache_key)); cache_file.exists() } fn save_cache( &self, cache_key: &str, cache_type: &str, content: &str, ) -> Result<(), Box> { let cache_file = self .cache_dir .join(format!("{}_{}.cache", cache_type, cache_key)); fs::write(cache_file, content)?; Ok(()) } fn read_cache( &self, cache_key: &str, cache_type: &str, ) -> Result> { let cache_file = self .cache_dir .join(format!("{}_{}.cache", cache_type, cache_key)); Ok(fs::read_to_string(cache_file)?) } } /// Walk `site/i18n/locales/` and collect all FTL file contents keyed as /// `{lang}_{path_underscore}` (e.g. `en_pages_home`). This matches the key /// format expected by `get_parsed_ftl_for_language`. Replaces the legacy /// `build_config::load_config()` approach that required `config.dev.toml`. fn load_ftl_from_locales(project_root: &Path) -> std::collections::HashMap { let locales_dir = project_root.join("site").join("i18n").join("locales"); let mut ftl_files = std::collections::HashMap::new(); if !locales_dir.exists() { println!( "cargo:warning= ⚠️ FTL locales dir not found: {}", locales_dir.display() ); return ftl_files; } let lang_entries = match std::fs::read_dir(&locales_dir) { Ok(r) => r, Err(e) => { println!("cargo:warning= ⚠️ Cannot read FTL locales dir: {}", e); return ftl_files; } }; for lang_entry in lang_entries.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, }; // BFS walk to collect all .ftl files under this language directory. let mut stack = vec![lang_path.clone()]; while let Some(dir) = stack.pop() { let entries = match std::fs::read_dir(&dir) { Ok(e) => e, Err(_) => continue, }; for entry in entries.filter_map(|e| e.ok()) { let path = entry.path(); if path.is_dir() { stack.push(path); } else if path.extension().is_some_and(|x| x == "ftl") { let rel = path.strip_prefix(&lang_path).unwrap_or(&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.to_string()) } else { None } }) .collect::>() .join("_"); let key = format!("{}_{}", lang, key_suffix); match std::fs::read_to_string(&path) { Ok(content) => { println!( "cargo:warning= ✓ Loaded FTL: {} ({} bytes)", key, content.len() ); println!("cargo:rerun-if-changed={}", path.display()); ftl_files.insert(key, content); } Err(e) => { println!("cargo:warning= ⚠️ Cannot read {}: {}", path.display(), e); } } } } } } ftl_files } /// Generate ResourceContributor implementation for menus, footers, themes, and FTL files. /// /// In htmx-ssr mode, menus and footers are resolved at runtime from NCL via J2 partials. /// The build script emits empty maps for those two, so MENU_REGISTRY stays empty and /// the htmx shell reads routes.ncl / footer.ncl directly — no baked data, no rebuild needed /// when config changes. fn generate_resource_contributor( config_path: &Path, out_dir: &Path, ) -> Result<(), Box> { use std::collections::HashMap; println!("cargo:warning=🔨 Generating ResourceContributor implementation"); println!("cargo:warning= Config path: site/config"); let manifest_root = rustelo_utils::ManifestResolver::load() .map(|m| m.root().to_path_buf()) .unwrap_or_else(|_| config_path.parent().unwrap_or(config_path).to_path_buf()); let site_cfg = build_config::site_config::load_unified_site_config(&manifest_root) .unwrap_or_else(|e| { panic!( "Cannot load site/config/index.ncl for menu generation: {e}\n\ Install Nickel: cargo install nickel-lang-cli" ) }); // In htmx-ssr mode, nav/menu/footer are resolved at runtime from J2 partials + // routes.ncl / footer.ncl. Baking them here would require a rebuild on every // config change and would conflict with runtime NCL loading. Emit empty maps. let is_htmx_ssr = site_cfg .rendering .as_ref() .map(|r| r.default_profile.skips_wasm()) .unwrap_or(false); let mut menus = HashMap::new(); if !is_htmx_ssr { let rbac_deny_patterns = build_config::rbac_config::load_rbac_anonymous_deny_patterns(&manifest_root) .unwrap_or_default(); let menu_toml = site_cfg.menu_toml_unified(); let len = menu_toml.len(); menus.insert("main".to_string(), menu_toml); println!( "cargo:warning= ✓ Loaded unified menu from site/config/index.ncl ({} bytes)", len ); // Per-language header-zone entries so get_menu("en"/"es") resolves from MENU_REGISTRY. for lang in site_cfg.languages() { let lang_toml = site_cfg.menu_toml_for_lang_with_rbac("header", lang, &rbac_deny_patterns); let lang_len = lang_toml.len(); menus.insert(lang.to_string(), lang_toml); println!( "cargo:warning= ✓ Loaded per-language menu for '{}' ({} bytes)", lang, lang_len ); } } // end if !is_htmx_ssr (menus) // Footers: empty for htmx-ssr (loaded at runtime from footer.ncl + routes.ncl) let mut footers = HashMap::new(); if !is_htmx_ssr { { use serde::Serialize; #[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, #[serde(skip_serializing_if = "Vec::is_empty")] menu: Vec, } #[derive(Serialize)] struct SocialLinkToml { name: String, url: String, icon_class: String, } #[derive(Serialize)] struct MenuItemToml { route: String, label: HashMap, } let footer = &site_cfg.footer; 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(); footers.insert(lang.to_string(), toml_str); println!("cargo:warning= ✓ Generated footer for '{lang}' ({len} bytes, from site/config/index.ncl)"); } } } // end if !is_htmx_ssr (footers) // Load themes from config/themes/*.toml let mut themes = HashMap::new(); let themes_dir = config_path.join("themes"); println!("cargo:warning= Looking for themes in: site/config/themes"); if themes_dir.exists() { for entry in fs::read_dir(&themes_dir)? { let entry = entry?; let path = entry.path(); if path.extension().is_some_and(|ext| ext == "toml") { if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) { let content = fs::read_to_string(&path)?; let content_len = content.len(); themes.insert(filename.to_string(), content); println!( "cargo:warning= ✓ Loaded theme: {} ({} bytes)", filename, content_len ); } } } } // Routes are baked even in htmx-ssr mode because the runtime fallback // (rustelo_core_lib::routing::load_routes_from_directory) only knows how to // read TOML files from a site/config/routes/ subdirectory, which this project // does not have — routes live in site/config/index.ncl (NCL). // TODO(rustelo): add a runtime NCL→routes path in rustelo_core_lib so that // htmx-ssr can skip this step and pick up route changes without a rebuild. let mut routes = HashMap::new(); { use build_config::site_config::RoutesConfig; let expanded = site_cfg.routes_expanded(); // HashMap> for (lang, route_vec) in expanded { let cfg = RoutesConfig { routes: route_vec }; match toml::to_string(&cfg) { Ok(toml_str) => { let len = toml_str.len(); println!( "cargo:warning= ✓ Generated routes for '{lang}' \ ({len} bytes, from site/config/index.ncl)" ); routes.insert(lang, toml_str); } Err(e) => { println!("cargo:warning= ⚠️ Failed to serialize routes for '{lang}': {e}"); } } } } // htmx-ssr: skip build-time FTL baking — rustelo_core_lib now walks // site/i18n/locales/ recursively at runtime via load_ftl_from_config. // Translation changes take effect without a rebuild. let ftl_files = if !is_htmx_ssr { println!("cargo:warning= Looking for FTL files in: site/i18n/locales"); load_ftl_from_locales(&manifest_root) } else { println!("cargo:warning= htmx-ssr: FTL deferred to runtime (site/i18n/locales)"); std::collections::HashMap::new() }; // Delegate code generation to rustelo_tools rustelo_tools::build::generate_resource_contributor_code( "WebsiteResourceContributor", &menus, &footers, &themes, &ftl_files, &routes, out_dir, )?; // Generate server_theme_constants.rs — same pattern as client's theme_constants.rs // Single source of truth: site/config/index.ncl [logo] 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 server_theme = format!( r#"/// Theme configuration constants for SSR server /// Generated at build time from site/config/index.ncl [logo] #[allow(clippy::module_inception)] pub mod theme {{ pub const DEFAULT_THEME_NAME: &str = "default"; /// 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.show_in_nav, logo.show_in_footer, width_const, height_const ); let server_theme_file = out_dir.join("server_theme_constants.rs"); fs::write(&server_theme_file, server_theme)?; println!( "cargo:warning= ✓ Logo config generated from site/config/index.ncl → server_theme_constants.rs" ); Ok(()) } /// Emit three scope-specific aggregator files consumed by server/src/lib.rs. /// /// `include!()` expands into the surrounding item context — so top-level, /// `pub mod generated {}`, and `pub mod config_constants {}` each need their /// own aggregator file; a single flat file cannot span module scopes. fn generate_server_includes_aggregator( top_level: &[&str], generated_mod: &[&str], config_mod: &[&str], out_dir: &str, ) -> Result<(), Box> { let header = "// Auto-generated aggregator — do not edit directly.\n"; let write_scope = |names: &[&str], filename: &str| -> Result<(), Box> { let mut content = header.to_string(); for name in names { content.push_str(&format!( "include!(concat!(env!(\"OUT_DIR\"), \"/{}\"));\n", name )); } std::fs::write(std::path::Path::new(out_dir).join(filename), content)?; Ok(()) }; write_scope(top_level, "server_top_includes.rs")?; write_scope(generated_mod, "server_generated_mod_includes.rs")?; write_scope(config_mod, "server_config_mod_includes.rs")?; Ok(()) } fn main() -> Result<(), Box> { // Use new PAP-compliant manifest system let manifest = rustelo_utils::get_manifest()?; let content_path = rustelo_utils::manifest_content_path(); let config_path = rustelo_utils::manifest_config_path(); println!("cargo:rerun-if-changed={}/config", content_path.display()); println!("cargo:rerun-if-changed={}", config_path.display()); println!("cargo:rerun-if-changed=src/"); println!("cargo:rerun-if-changed={}/content", content_path.display()); println!("cargo:rerun-if-changed={}", content_path.display()); let out_dir = env::var("OUT_DIR")?; // config_constants module: NCL-sourced files (local generator — owns its artifact names) let ncl_consts = generate_config_constants()?; // ResourceContributor (not included in lib.rs — build artifact only) generate_resource_contributor(&config_path, Path::new(&out_dir))?; // Initialize cache for server builds let cache = ServerCache::new()?; // Top-level: server config + shell asset paths let server_cfg = generate_server_config()?; let shell_assets = generate_shell_asset_paths(manifest)?; // config_constants module: env-var helpers (external tool — filename declared here as the // single point of knowledge between the call site and the aggregator) const TOOLS_CONFIG: &str = "config_constants.rs"; rustelo_tools::build::config_constants::generate_config_constants(&out_dir)?; // config_constants module: content types from site/config/index.ncl [content_types] let content_types_const = generate_content_types_constants(manifest)?; // generated module: RouteComponent enum from site/config/index.ncl let route_handlers = generate_route_handlers_with_tools()?; // resource_registry.rs path notification (build artifact only, not included in lib.rs) let resource_registry_path = Path::new(&out_dir).join("resource_registry.rs"); if resource_registry_path.exists() { println!( "cargo:rustc-env=RUSTELO_RESOURCE_REGISTRY_PATH={}", resource_registry_path.display() ); println!("cargo:warning=✅ Set RUSTELO_RESOURCE_REGISTRY_PATH for rustelo_core_lib"); } // route_table.rs (not included in lib.rs — build artifact only) { let manifest_dir = manifest.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 route table: {e}\n\ Install Nickel: cargo install nickel-lang-cli" ) }); let mut routes = std::collections::BTreeMap::new(); for (_lang, route_list) in site_cfg.routes_expanded() { for route in route_list { if route.path.starts_with('/') { routes.insert(route.path, route.component); } } } println!( "cargo:warning=Server: Route table built from site/config/index.ncl ({} entries)", routes.len() ); rustelo_tools::build::generate_route_table(&routes, Path::new(&out_dir))?; } // Top-level: server routing (page dispatch + full-path OnceLock lookup) const SERVER_ROUTING: &str = "server_routing.rs"; { let manifest_dir = manifest.root(); let cache_build_path = manifest.manifest().build.cache_build_path.clone(); let pages_cache_dir = manifest_dir .join("target") .join(&cache_build_path) .join("pages"); let crate_relative = format!("../../target/{}/pages", cache_build_path); 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 page provider: {e}\n\ Install Nickel: cargo install nickel-lang-cli" ) }); let mut route_mappings = std::collections::HashMap::new(); let mut path_to_content_type = std::collections::HashMap::new(); for (_lang, route_list) in site_cfg.routes_expanded() { for route in route_list { if !route.path.starts_with('/') { continue; } route_mappings .entry(route.path.clone()) .or_insert_with(|| route.component.clone()); if let Some(ct) = route.content_type { path_to_content_type.entry(route.path).or_insert(ct); } } } println!( "cargo:warning=Server: Loaded {} route mappings from configuration", route_mappings.len() ); rustelo_tools::build::generate_server_routing( &pages_cache_dir, &crate_relative, &route_mappings, &path_to_content_type, Path::new(&out_dir), )?; } let server_routing = SERVER_ROUTING; generate_server_documentation(manifest, &cache)?; create_code_review_copy()?; // Emit the 3 scope-specific aggregator files consumed by server/src/lib.rs. // Ordering within each scope matches the original include!() order in lib.rs. generate_server_includes_aggregator( &[server_cfg, shell_assets, server_routing], &[route_handlers], &[TOOLS_CONFIG, ncl_consts[0], ncl_consts[1], content_types_const], &out_dir, )?; println!("cargo:warning=Server build completed (tools + server-specific)"); Ok(()) } /// Convert a component name like "HomePage" to its i18n key prefix "homepage-page". fn component_to_key_prefix(component: &str) -> String { format!("{}-page", component.to_lowercase()) } /// Generate `generated_routes.rs` directly from site/config/index.ncl. /// /// Replaces the `rustelo_tools::generate_route_components` call which required /// `site/config/routes/*.toml` to exist on disk. With NCL as the single source /// of truth, we derive the `RouteComponent` enum from the already-loaded config. fn generate_route_handlers_with_tools() -> Result<&'static str, Box> { const OUTPUT: &str = "generated_routes.rs"; println!("cargo:warning=🚀 Generating RouteComponent enum from site/config/index.ncl"); let out_dir = env::var("OUT_DIR")?; let manifest = rustelo_utils::ManifestResolver::load()?; let manifest_dir = manifest.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 route components: {e}\n\ Install Nickel: cargo install nickel-lang-cli" ) }); // Collect unique component names in deterministic order. // NotFound is always required as the wildcard arm. let mut components: std::collections::BTreeSet = site_cfg .routes .iter() .map(|r| r.component.clone()) .collect(); components.insert("NotFound".to_string()); let output_path = std::path::Path::new(&out_dir).join(OUTPUT); let mut code = String::new(); code.push_str("// Auto-generated route component definitions\n"); code.push_str("// Regenerated at build time from site/config/index.ncl — do not edit\n\n"); code.push_str("use serde::{Deserialize, Serialize};\n\n"); code.push_str("#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]\n"); code.push_str("pub enum RouteComponent {\n"); for c in &components { code.push_str(&format!(" {},\n", c)); } code.push_str("}\n\n"); code.push_str("impl RouteComponent {\n"); // from_str (inherent, mirrors trait below) code.push_str(" #[allow(clippy::should_implement_trait)]\n"); code.push_str(" pub fn from_str(component: &str) -> Self {\n"); code.push_str(" match component {\n"); for c in &components { if c != "NotFound" { code.push_str(&format!( " \"{}\" => RouteComponent::{},\n", c, c )); } } code.push_str(" _ => RouteComponent::NotFound,\n"); code.push_str(" }\n }\n\n"); // as_str code.push_str(" pub fn as_str(&self) -> &'static str {\n"); code.push_str(" match self {\n"); for c in &components { code.push_str(&format!( " RouteComponent::{} => \"{}\",\n", c, c )); } code.push_str(" }\n }\n\n"); // title_key / description_key / keywords_key for suffix in &["title", "description", "keywords"] { code.push_str(&format!( " pub fn {}_key(&self) -> &'static str {{\n match self {{\n", suffix )); for c in &components { code.push_str(&format!( " RouteComponent::{} => \"{}-{}\",\n", c, component_to_key_prefix(c), suffix )); } code.push_str(" }\n }\n\n"); } // is_multilingual code.push_str( " pub fn is_multilingual(&self) -> bool {\n \ !matches!(self, RouteComponent::NotFound)\n }\n}\n\n", ); // std::str::FromStr code.push_str("impl std::str::FromStr for RouteComponent {\n"); code.push_str(" type Err = String;\n"); code.push_str(" fn from_str(s: &str) -> Result {\n"); code.push_str(" let component = match s {\n"); for c in &components { if c != "NotFound" { code.push_str(&format!( " \"{}\" => RouteComponent::{},\n", c, c )); } } code.push_str(" _ => return Err(format!(\"Unknown route component: {}\", s)),\n"); code.push_str(" };\n Ok(component)\n }\n}\n\n"); code.push_str("// Trait-based rendering — no macro generation needed\n"); fs::write(&output_path, &code)?; println!( "cargo:warning=✅ RouteComponent enum written to generated_routes.rs ({} variants from site/config/index.ncl)", components.len() ); Ok(OUTPUT) } /// Generate `shell_asset_paths.rs` — CSS/JS path slices from `site/config/assets.ncl`. /// /// Produces `source_mode::{CSS, JS}` and `bundled_mode::{CSS, JS}` static slices /// consumed by `shell.rs`. Adding or removing stylesheets requires only editing /// `site/config/assets.ncl` — no Rust changes needed. fn generate_shell_asset_paths( manifest_resolver: &rustelo_utils::ManifestResolver, ) -> Result<&'static str, Box> { const OUTPUT: &str = "shell_asset_paths.rs"; let site_cfg = build_config::site_config::load_unified_site_config(manifest_resolver.root()) .unwrap_or_else(|e| { panic!( "Cannot load site/config/index.ncl for shell assets: {e}\n\ Install Nickel: cargo install nickel-lang-cli" ) }); let fmt_slice = |paths: &[String]| -> String { paths .iter() .map(|p| format!(" \"{}\",\n", p)) .collect::() }; let code = format!( r#"// Auto-generated shell asset paths — sourced from site/config/assets.ncl // DO NOT EDIT — regenerated on every build. pub mod source_mode {{ pub static CSS: &[&str] = &[ {src_css} ]; pub static JS: &[&str] = &[ {src_js} ]; }} pub mod bundled_mode {{ pub static CSS: &[&str] = &[ {bnd_css} ]; pub static JS: &[&str] = &[ {bnd_js} ]; }} pub mod htmx_mode {{ pub static CSS: &[&str] = &[ {htmx_css} ]; }} "#, src_css = fmt_slice(&site_cfg.assets.source_css), src_js = fmt_slice(&site_cfg.assets.source_js), bnd_css = fmt_slice(&site_cfg.assets.bundled_css), bnd_js = fmt_slice(&site_cfg.assets.bundled_js), htmx_css = fmt_slice(&site_cfg.assets.htmx_css), ); let out_dir = env::var("OUT_DIR")?; fs::write(Path::new(&out_dir).join(OUTPUT), code)?; println!("cargo:rerun-if-changed=site/config/assets.ncl"); println!("cargo:warning=✅ Shell asset paths written to OUT_DIR/shell_asset_paths.rs"); Ok(OUTPUT) } fn generate_server_config() -> Result<&'static str, Box> { const OUTPUT: &str = "server_config.rs"; let out_dir = env::var("OUT_DIR")?; let server_config_content = r#" // Auto-generated server configuration // This is a default configuration to prevent build errors pub const DEFAULT_HOST: &str = "127.0.0.1"; pub const DEFAULT_PORT: u16 = 3030; pub const DEFAULT_WORKERS: usize = 4; #[derive(Debug, Clone)] pub struct ServerConfigData { pub host: String, pub port: u16, pub workers: usize, pub content_root: String, pub public_path: String, } impl Default for ServerConfigData { fn default() -> Self { Self { host: DEFAULT_HOST.to_string(), port: DEFAULT_PORT, workers: DEFAULT_WORKERS, content_root: "../site".to_string(), public_path: "public".to_string(), } } } pub fn get_server_config() -> ServerConfigData { ServerConfigData::default() } "#; let server_config_file = Path::new(&out_dir).join(OUTPUT); fs::write(server_config_file, server_config_content)?; Ok(OUTPUT) } fn generate_config_constants() -> Result<[&'static str; 2], Box> { const NCL_DB: &str = "ncl_database_constants.rs"; const NCL_PATHS: &str = "ncl_path_constants.rs"; let out_dir = env::var("OUT_DIR")?; // Load NCL database config — non-fatal if NCL is unavailable (e.g. CI without nickel). let config_path = env::var("CARGO_MANIFEST_DIR") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from(".")); let manifest_root = rustelo_utils::ManifestResolver::load() .map(|m| m.root().to_path_buf()) .unwrap_or_else(|_| { config_path .parent() .unwrap_or(config_path.as_path()) .to_path_buf() }); let ncl_cfg = build_config::site_config::load_unified_site_config(&manifest_root).ok(); let ncl_db = ncl_cfg.as_ref().map(|cfg| &cfg.database); let ncl_db_url = ncl_db .map(|d| d.url.as_str()) .unwrap_or("sqlite:data/dev_database.db"); let ncl_db_create = ncl_db.map(|d| d.create_if_missing).unwrap_or(true); let ncl_db_max_conn = ncl_db.map(|d| d.max_connections).unwrap_or(5); let ncl_db_min_conn = ncl_db.map(|d| d.min_connections).unwrap_or(1); let ncl_db_connect_timeout = ncl_db.map(|d| d.connect_timeout).unwrap_or(30); let ncl_db_idle_timeout = ncl_db.map(|d| d.idle_timeout).unwrap_or(600); let ncl_db_max_lifetime = ncl_db.map(|d| d.max_lifetime).unwrap_or(1800); // Write NCL database constants to a separate file. // config_constants.rs (env-var helpers) is generated separately via // rustelo_tools::build::config_constants::generate_config_constants(). let ncl_db_content = format!( r#"// Auto-generated NCL database constants — sourced from site/config/index.ncl [database] // DO NOT EDIT — regenerated on every build. // .env DATABASE_URL takes priority over NCL_DATABASE_URL at runtime. pub const NCL_DATABASE_URL: &str = "{}"; pub const NCL_DATABASE_CREATE_IF_MISSING: bool = {}; pub const NCL_DATABASE_MAX_CONNECTIONS: u32 = {}; pub const NCL_DATABASE_MIN_CONNECTIONS: u32 = {}; pub const NCL_DATABASE_CONNECT_TIMEOUT: u64 = {}; pub const NCL_DATABASE_IDLE_TIMEOUT: u64 = {}; pub const NCL_DATABASE_MAX_LIFETIME: u64 = {}; "#, ncl_db_url, ncl_db_create, ncl_db_max_conn, ncl_db_min_conn, ncl_db_connect_timeout, ncl_db_idle_timeout, ncl_db_max_lifetime, ); let ncl_db_file = Path::new(&out_dir).join(NCL_DB); fs::write(&ncl_db_file, ncl_db_content)?; println!("cargo:warning=✅ NCL database constants written to OUT_DIR/ncl_database_constants.rs (url={ncl_db_url})"); // Write NCL path constants — sourced from site/config/index.ncl [paths]. let ncl_paths = ncl_cfg.as_ref().map(|cfg| &cfg.paths); let migrations_path = ncl_paths .and_then(|p| p.migrations.as_deref()) .unwrap_or("rustelo/migrations"); let email_templates_path = ncl_paths .and_then(|p| p.email_templates.as_deref()) .unwrap_or("email_templates"); let ncl_paths_content = format!( r#"// Auto-generated NCL path constants — sourced from site/config/index.ncl [paths] // DO NOT EDIT — regenerated on every build. // Values are project-root-relative paths as declared in config.ncl. pub const SITE_MIGRATIONS_PATH: &str = "{}"; pub const SITE_EMAIL_TEMPLATES_PATH: &str = "{}"; "#, migrations_path, email_templates_path, ); let ncl_paths_file = Path::new(&out_dir).join(NCL_PATHS); fs::write(&ncl_paths_file, ncl_paths_content)?; println!("cargo:rerun-if-changed=site/config/index.ncl"); println!("cargo:rerun-if-changed=site/rbac.ncl"); println!( "cargo:warning=✅ NCL path constants written \ (migrations={migrations_path}, email_templates={email_templates_path})" ); Ok([NCL_DB, NCL_PATHS]) } /// Generate content type constants from site/config/index.ncl [content_types]. /// /// Produces `pub const CONTENT_TYPES: &[(&str, &str)]` included in the /// `config_constants` module. At runtime, `resources::register_content_kinds()` /// iterates the slice and builds `ContentConfig` entries — no TOML I/O needed. fn generate_content_types_constants( manifest_resolver: &rustelo_utils::ManifestResolver, ) -> Result<&'static str, Box> { const OUTPUT: &str = "ncl_content_types.rs"; let out_dir = env::var("OUT_DIR")?; let site_cfg = build_config::site_config::load_unified_site_config(manifest_resolver.root())?; let entries: String = site_cfg .content_types .iter() .filter(|ct| ct.enabled) .map(|ct| format!(" (\"{}\", \"{}\"),\n", ct.name, ct.directory)) .collect(); let content = format!( r#"// Auto-generated — sourced from site/config/index.ncl [content_types]. DO NOT EDIT. /// Enabled content types as (name, directory) pairs. /// Consumed by `resources::register_content_kinds()` at server startup. pub const CONTENT_TYPES: &[(&str, &str)] = &[ {}];"#, entries ); fs::write(Path::new(&out_dir).join(OUTPUT), content)?; println!("cargo:warning=Generated content type constants ({} types)", site_cfg.content_types.iter().filter(|ct| ct.enabled).count()); Ok(OUTPUT) } /// Generate server documentation using rustelo_tools fn generate_server_documentation( manifest: &rustelo_utils::ManifestResolver, _cache: &ServerCache, ) -> Result<(), Box> { // Create info directory let info_path = manifest.root().join("site/info"); std::fs::create_dir_all(&info_path)?; // Use rustelo_tools to generate comprehensive server analysis let project_root = manifest.root().to_string_lossy(); // Try to generate server documentation, but handle the array conversion error gracefully if let Err(e) = rustelo_tools::comprehensive_analysis::generate_server_documentation( &project_root, &info_path.to_string_lossy(), ) { // Log the specific error for debugging but continue the build println!( "cargo:warning=Server documentation generation failed ({}), continuing build", e ); // Create a basic server analysis file as fallback let server_analysis_path = info_path.join("server_analysis.md"); let basic_analysis = format!( "# Server Analysis\n\n\ Generated at: {}\n\ Project root: {}\n\n\ ## Build Status\n\ - Route metadata: ✅ Generated\n\ - Server config: ✅ Generated\n\ - Comprehensive analysis: ❌ Failed due to array conversion\n\n\ ## Notes\n\ The comprehensive server analysis failed due to TOML array conversion issues.\n\ Route metadata and basic server configuration were generated successfully.\n", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"), project_root ); std::fs::write(server_analysis_path, basic_analysis)?; } else { println!("cargo:warning=Server documentation generated successfully"); } // Generate route metadata in data format similar to p-website generate_route_metadata(manifest)?; println!("cargo:warning=Generated server documentation in site/info/"); Ok(()) } /// Generate route metadata (similar to p-website's data.toml) fn generate_route_metadata( manifest: &rustelo_utils::ManifestResolver, ) -> Result<(), Box> { let routes_dir = rustelo_utils::routes_path(); let info_path = manifest.root().join("site/info"); // Initialize cache for route metadata let cache = ServerCache::new()?; let components_dir = manifest.root().join("crates/client/src/components"); let cache_key = cache.get_content_hash(&routes_dir, Some(&components_dir))?; let cache_status = if cache.is_cached(&cache_key, "metadata_json") && cache.is_cached(&cache_key, "metadata_toml") { "CACHE HIT".to_string() } else { "CACHE MISS".to_string() }; println!( "cargo:warning=ServerCache metadata: {} (hash: {})", cache_status, cache_key ); // Try to use cached metadata if cache.is_cached(&cache_key, "metadata_json") && cache.is_cached(&cache_key, "metadata_toml") { let cached_json = cache.read_cache(&cache_key, "metadata_json")?; let cached_toml = cache.read_cache(&cache_key, "metadata_toml")?; fs::create_dir_all(&info_path)?; fs::write(info_path.join("data.json"), cached_json)?; fs::write(info_path.join("data.toml"), cached_toml)?; println!("cargo:warning=✅ Using cached route metadata (CACHE HIT)"); return Ok(()); } println!("cargo:warning=🔄 Generating route metadata (CACHE MISS)"); let mut route_data = serde_json::json!({ "page_routes": [], "api_routes": [], "components": [], "generated_at": chrono::Utc::now().to_rfc3339() }); if routes_dir.exists() { // Use typed route loading (NCL-first, TOML-fallback) match build_config::route_config::load_all_routes(&routes_dir) { Ok(routes_by_lang) => { for (lang, routes) in routes_by_lang { for route in routes { // Only include internal routes if !route.is_external.unwrap_or(false) && route.path.starts_with('/') { let route_info = serde_json::json!({ "path": route.path, "component": route.component, "language": lang, "enabled": route.enabled, "handler": format!("handle_{}_{}", lang, route.component.to_lowercase()) }); if let Some(arr) = route_data["page_routes"].as_array_mut() { arr.push(route_info); } } } } } Err(e) => { println!("cargo:warning=Failed to load routes for metadata: {}", e); // Continue without route metadata } } } // Write JSON format let json_content = serde_json::to_string_pretty(&route_data)?; let json_path = info_path.join("data.json"); std::fs::write(&json_path, &json_content)?; // Write TOML format - manually format since JSON to TOML conversion is problematic let mut toml_content = String::new(); toml_content.push_str("# Route metadata generated at build time\n\n"); if let Some(generated_at) = route_data["generated_at"].as_str() { toml_content.push_str(&format!("generated_at = \"{}\"\n", generated_at)); } toml_content.push_str("components = []\n\n"); toml_content.push_str("[[page_routes]]\n"); // Write a simplified TOML format for compatibility if let Some(routes) = route_data["page_routes"].as_array() { for (i, route) in routes.iter().enumerate() { if i > 0 { toml_content.push_str("\n[[page_routes]]\n"); } if let Some(path) = route.get("path").and_then(|p| p.as_str()) { toml_content.push_str(&format!("path = \"{}\"\n", path)); } if let Some(component) = route.get("component").and_then(|c| c.as_str()) { toml_content.push_str(&format!("component = \"{}\"\n", component)); } if let Some(language) = route.get("language").and_then(|l| l.as_str()) { toml_content.push_str(&format!("language = \"{}\"\n", language)); } if let Some(enabled) = route.get("enabled").and_then(|e| e.as_bool()) { toml_content.push_str(&format!("enabled = {}\n", enabled)); } } } let toml_path = info_path.join("data.toml"); std::fs::write(&toml_path, &toml_content)?; // Save to cache for future builds cache.save_cache(&cache_key, "metadata_json", &json_content)?; cache.save_cache(&cache_key, "metadata_toml", &toml_content)?; println!("cargo:warning=✅ Route metadata generated and cached"); Ok(()) } /// Copy the generated resource registry to rustelo_core_lib's OUT_DIR #[allow(dead_code)] fn copy_resource_registry_to_core_lib() -> Result<(), Box> { // Find the resource registry in our OUT_DIR let our_out_dir = env::var("OUT_DIR")?; let resource_registry_path = Path::new(&our_out_dir).join("resource_registry.rs"); if !resource_registry_path.exists() { println!( "cargo:warning=Resource registry not found at {}", resource_registry_path.display() ); return Ok(()); } // Find the target directory let target_dir = Path::new(&our_out_dir) .ancestors() .find(|p| p.file_name().is_some_and(|name| name == "build")) .and_then(|build_dir| build_dir.parent()) .ok_or("Could not find target directory")?; let mut copied_count = 0; // Copy to all possible target directories (server and client/WASM) let target_paths = vec![ target_dir.join("debug/build"), // Server target target_dir.join("front/wasm32-unknown-unknown/debug/build"), // Client/WASM target ]; let content = std::fs::read_to_string(&resource_registry_path)?; for build_dir in target_paths { if !build_dir.exists() { continue; } // Look for rustelo_core_lib build directories in this target if let Ok(entries) = std::fs::read_dir(&build_dir) { for entry in entries.flatten() { if entry .file_name() .to_string_lossy() .starts_with("rustelo_core_lib-") { let core_lib_out_dir = entry.path().join("out"); let dest_path = core_lib_out_dir.join("resource_registry.rs"); if let Ok(()) = std::fs::create_dir_all(&core_lib_out_dir) { if let Ok(()) = std::fs::write(&dest_path, &content) { println!( "cargo:warning=✅ Copied resource registry to rustelo_core_lib: {}", dest_path.display() ); copied_count += 1; } } } } } } // NOTE: Removed rustc-cfg=has_generated_resources in Phase 2.11 // The registration system no longer requires conditional compilation // Resources are registered dynamically at startup via resources::initialize() if copied_count > 0 { // NOTE: RUSTELO_HAS_GENERATED_RESOURCES env var kept for backward compatibility // but no longer used by the registration system println!("cargo:rustc-env=RUSTELO_HAS_GENERATED_RESOURCES=1"); println!( "cargo:warning=📋 Successfully copied resource registry to {} locations", copied_count ); } else { println!("cargo:warning=⚠️ No rustelo_core_lib build directories found to copy to"); println!("cargo:warning=✅ But resource_registry.rs is generated for server use"); } Ok(()) } /// Create a clean copy of all generated files for code review /// Removes cache core path and creates fresh copy in works-pv/out/ fn create_code_review_copy() -> Result<(), Box> { let manifest = rustelo_utils::get_manifest()?; let root_dir = manifest.root(); // Define out directory for code review (ephemeral, in works-pv/) let out_dir = root_dir.join("works-pv/out"); // Remove existing out directory if it exists if out_dir.exists() { std::fs::remove_dir_all(&out_dir)?; println!("cargo:warning=🧹 Removed existing works-pv/out/ directory"); } // Create fresh out directory structure std::fs::create_dir_all(&out_dir)?; let generated_dir = out_dir.join("generated"); let cache_dir = out_dir.join("cache-snapshot"); std::fs::create_dir_all(&generated_dir)?; std::fs::create_dir_all(&cache_dir)?; println!("cargo:warning=📁 Created fresh works-pv/out/ directory structure"); // Copy generated files from BUILD_OUT_DIR let build_out_dir = env::var("OUT_DIR")?; copy_build_generated_files(&build_out_dir, &generated_dir)?; // Copy files from rustelo-cache if it exists copy_cache_files(root_dir, &cache_dir)?; // Copy any other generated files from target/ copy_target_generated_files(root_dir, &out_dir)?; // Create summary file create_code_review_summary(&out_dir)?; println!("cargo:warning=✅ Code review copy created at: works-pv/out/"); Ok(()) } /// Copy generated files from build OUT_DIR fn copy_build_generated_files( build_out_dir: &str, dest_dir: &Path, ) -> Result<(), Box> { let build_out_path = Path::new(build_out_dir); if !build_out_path.exists() { return Ok(()); } // List of generated files to copy let generated_files = [ "resource_registry.rs", "generated_routes.rs", "route_handlers.rs", "embedded_routes.toml", "config_constants.rs", "content_handlers.rs", "content_kinds_generated.rs", "server_config.rs", ]; for file_name in &generated_files { let src_path = build_out_path.join(file_name); if src_path.exists() { let dest_path = dest_dir.join(file_name); std::fs::copy(&src_path, &dest_path)?; println!("cargo:warning=📄 Copied {}", file_name); } } // Copy generated_pages directory if it exists let generated_pages_src = build_out_path.join("generated_pages"); if generated_pages_src.exists() { let generated_pages_dest = dest_dir.join("generated_pages"); copy_directory(&generated_pages_src, &generated_pages_dest)?; println!("cargo:warning=📁 Copied generated_pages/ directory"); } Ok(()) } /// Copy files from rustelo-cache directories fn copy_cache_files(root_dir: &Path, dest_dir: &Path) -> Result<(), Box> { // Look for rustelo-cache in various locations let cache_locations = [ root_dir.join("target/rustelo-cache"), root_dir.join("target/site_build"), root_dir.join("cache"), ]; for cache_location in &cache_locations { if cache_location.exists() { let cache_name = cache_location .file_name() .unwrap_or_else(|| std::ffi::OsStr::new("cache")) .to_string_lossy(); let dest_cache_dir = dest_dir.join(&*cache_name); copy_directory(cache_location, &dest_cache_dir)?; println!("cargo:warning=💾 Copied cache: {}", cache_name); } } Ok(()) } /// Copy other relevant generated files from target/ fn copy_target_generated_files( root_dir: &Path, dest_dir: &Path, ) -> Result<(), Box> { let target_dir = root_dir.join("target"); if !target_dir.exists() { return Ok(()); } let manifest_dir = dest_dir.join("build-manifests"); std::fs::create_dir_all(&manifest_dir)?; // Copy any .toml files that might be build-generated if let Ok(entries) = std::fs::read_dir(&target_dir) { for entry in entries.flatten() { let path = entry.path(); if let Some(ext) = path.extension() { if ext == "toml" && path .file_name() .map(|f| f.to_string_lossy().contains("embedded")) .unwrap_or(false) { if let Some(file_name) = path.file_name() { let dest_path = manifest_dir.join(file_name); if std::fs::copy(&path, &dest_path).is_ok() { println!( "cargo:warning=📋 Copied manifest: {}", file_name.to_string_lossy() ); } } } } } } Ok(()) } /// Recursively copy a directory fn copy_directory(src: &Path, dest: &Path) -> Result<(), Box> { if !src.exists() { return Ok(()); } std::fs::create_dir_all(dest)?; for entry in std::fs::read_dir(src)? { let entry = entry?; let src_path = entry.path(); let dest_path = dest.join(entry.file_name()); if src_path.is_dir() { copy_directory(&src_path, &dest_path)?; } else { std::fs::copy(&src_path, &dest_path)?; } } Ok(()) } /// Create a summary file for code review fn create_code_review_summary(out_dir: &Path) -> Result<(), Box> { let summary_content = format!( r#"# Build Generated Files - Code Review Generated at: {} Build system: Rustelo website implementation ## Directory Structure - `generated/` - Files generated during build process - `resource_registry.rs` - Auto-generated resource registry - `generated_routes.rs` - Route handlers - `route_handlers.rs` - Route implementations - `embedded_routes.toml` - Route configuration - `config_constants.rs` - Configuration constants - `content_handlers.rs` - Content handling code - `content_kinds_generated.rs` - Content type definitions - `server_config.rs` - Server configuration - `generated_pages/` - Individual page components - `cache-snapshot/` - Snapshot of build caches - `rustelo-cache/` - Rustelo smart caching system - `site_build/` - Site build cache - `build-manifests/` - Build-time manifests and metadata ## Build Process 1. **Resource Discovery**: Scans site/ directory for configuration files 2. **Resource Generation**: Creates resource registry with all assets 3. **Route Generation**: Generates route handlers from configuration 4. **Caching**: Implements smart caching for faster rebuilds 5. **Code Review Copy**: This clean snapshot for review ## Key Features - **Configuration-driven**: No hardcoded paths or routes - **Language-agnostic**: Supports multiple languages dynamically - **Smart caching**: Incremental builds with hash-based invalidation - **PAP compliant**: Follows Pattern-Aligned Programming principles ## Files for Review All generated files maintain the configuration-driven architecture. No anti-PAP fallbacks or hardcoded paths should be present. Generated on: {} "#, chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"), chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC") ); let summary_path = out_dir.join("README.md"); std::fs::write(summary_path, summary_content)?; // Also create a simple file list let mut file_list = String::new(); file_list.push_str("# Generated Files List\n\n"); collect_file_list(out_dir, &mut file_list, 0)?; let file_list_path = out_dir.join("file-list.txt"); std::fs::write(file_list_path, file_list)?; Ok(()) } /// Recursively collect file list for summary fn collect_file_list( dir: &Path, output: &mut String, depth: usize, ) -> Result<(), Box> { if !dir.exists() { return Ok(()); } let indent = " ".repeat(depth); for entry in std::fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); let name = entry.file_name().to_string_lossy().to_string(); if path.is_dir() { output.push_str(&format!("{}📁 {}/\n", indent, name)); collect_file_list(&path, output, depth + 1)?; } else { let size = std::fs::metadata(&path)?.len(); output.push_str(&format!("{}📄 {} ({} bytes)\n", indent, name, size)); } } Ok(()) }