1537 lines
56 KiB
Rust
1537 lines
56 KiB
Rust
//! 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<usize>,
|
|
pub content_root: String,
|
|
pub public_path: String,
|
|
}
|
|
|
|
/// Smart cache for server generation
|
|
struct ServerCache {
|
|
cache_dir: PathBuf,
|
|
}
|
|
|
|
impl ServerCache {
|
|
fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
|
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<String, Box<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
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<String, Box<dyn std::error::Error>> {
|
|
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<String, String> {
|
|
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::<Vec<_>>()
|
|
.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<dyn std::error::Error>> {
|
|
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<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,
|
|
label: HashMap<String, String>,
|
|
}
|
|
|
|
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<lang, Vec<RouteConfigToml>>
|
|
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<dyn std::error::Error>> {
|
|
let header = "// Auto-generated aggregator — do not edit directly.\n";
|
|
|
|
let write_scope = |names: &[&str], filename: &str| -> Result<(), Box<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
// 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<dyn std::error::Error>> {
|
|
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<String> = 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<Self, Self::Err> {\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<dyn std::error::Error>> {
|
|
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::<String>()
|
|
};
|
|
|
|
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<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
// 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<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
// 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<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
// 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<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
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(())
|
|
}
|
|
|