forte-cli 0.3.25

CLI for the Forte fullstack web framework
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;

pub struct SsrBundler {
    project_root: PathBuf,
    output_path: PathBuf,
}

impl SsrBundler {
    pub fn new(project_root: impl AsRef<Path>) -> Self {
        let project_root = project_root.as_ref().to_path_buf();
        let output_path = project_root.join("fe/.forte/ssr/server.js");
        Self {
            project_root,
            output_path,
        }
    }

    fn load_env_vars(&self) -> HashMap<String, String> {
        let mut env_vars = HashMap::new();
        let env_path = self.project_root.join(".env");

        if let Ok(content) = std::fs::read_to_string(&env_path) {
            for line in content.lines() {
                let line = line.trim();
                if line.is_empty() || line.starts_with('#') {
                    continue;
                }
                if let Some((key, value)) = line.split_once('=') {
                    let key = key.trim();
                    let value = value.trim();
                    if key.starts_with("PUBLIC_") {
                        env_vars.insert(key.to_string(), value.to_string());
                    }
                }
            }
        }

        env_vars
    }

    fn generate_define_config(&self, env_vars: &HashMap<String, String>) -> String {
        let mut defines = vec![
            r#""import.meta.env.SSR": "true""#.to_string(),
            r#""import.meta.env.DEV": "true""#.to_string(),
        ];

        for (key, value) in env_vars {
            let escaped = value.replace('\\', "\\\\").replace('\'', "\\'");
            defines.push(format!(r#""import.meta.env.{}": "'{}'""#, key, escaped));
        }

        format!(
            "  transform: {{\n    define: {{\n      {}\n    }}\n  }},",
            defines.join(",\n      ")
        )
    }

    pub fn build(&self) -> Result<PathBuf> {
        let fe_dir = self.project_root.join("fe");
        let entry_path = fe_dir.join("src/server.tsx");

        if let Some(parent) = self.output_path.parent() {
            std::fs::create_dir_all(parent)?;
        }

        println!("[ssr] Building server bundle...");

        let env_vars = self.load_env_vars();
        let define_config = self.generate_define_config(&env_vars);

        let config_path = self
            .output_path
            .parent()
            .unwrap()
            .join("rolldown.config.mjs");
        let config_content = format!(
            r#"// SSR CSS Plugin - returns empty export for CSS files (no DOM in SSR)
function ssrCssPlugin() {{
  return {{
    name: 'ssr-css',
    transform(code, id) {{
      if (id.endsWith('.css')) {{
        return {{ code: 'export default "";', map: null }};
      }}
      return null;
    }}
  }};
}}

export default {{
  input: "{}",
  output: {{
    file: "{}",
    format: "iife",
    inlineDynamicImports: true,
    sourcemap: 'inline',
  }},
  tsconfig: "tsconfig.json",
  plugins: [ssrCssPlugin()],
{define_config}
}};
"#,
            entry_path.to_str().unwrap(),
            self.output_path.to_str().unwrap()
        );
        std::fs::write(&config_path, &config_content)?;

        let result = Command::new("npx")
            .args(["rolldown", "-c", config_path.to_str().unwrap()])
            .current_dir(&fe_dir)
            .output()
            .context("Failed to run rolldown for SSR bundle")?;

        if !result.status.success() {
            let stderr = String::from_utf8_lossy(&result.stderr);
            anyhow::bail!("rolldown SSR bundle failed: {}", stderr.trim());
        }

        println!("[ssr] Server bundle built: {}", self.output_path.display());
        Ok(self.output_path.clone())
    }

    pub fn output_path(&self) -> &Path {
        &self.output_path
    }

    pub fn bundle_exists(&self) -> bool {
        self.output_path.exists()
    }

    pub fn invalidate(&self) -> Result<()> {
        if self.output_path.exists() {
            std::fs::remove_file(&self.output_path)?;
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_ssr_bundler_paths() {
        let bundler = SsrBundler::new("/tmp/project");
        assert!(
            bundler
                .output_path()
                .to_string_lossy()
                .contains(".forte/ssr/server.js")
        );
    }
}