ferrosite 0.1.0

A railway-oriented static site generator for personal homepages
Documentation
use std::path::Path;

use crate::config::AzureDeployConfig;
use crate::error::{SiteError, SiteResult};
use crate::plugin::{Plugin, PluginRegistry};

use super::{count_html_files, require_tool, serialized_cqrs, DeployResult, Deployer};

pub struct AzureDeployer {
    config: AzureDeployConfig,
}

impl AzureDeployer {
    pub(super) fn new(config: AzureDeployConfig) -> Self {
        Self { config }
    }
}

impl Deployer for AzureDeployer {
    fn provider_name(&self) -> &'static str {
        "Azure Static Web Apps"
    }

    fn deploy_static(&self, dist_dir: &Path) -> SiteResult<DeployResult> {
        println!(
            "🔷 Deploying to Azure Static Web Apps: {}",
            self.config.app_name
        );

        require_tool(
            "az",
            "https://docs.microsoft.com/cli/azure/install-azure-cli",
        )?;
        require_tool("swa", "npm install -g @azure/static-web-apps-cli")?;

        let swa_config = generate_swa_config();
        std::fs::write(dist_dir.join("staticwebapp.config.json"), swa_config)?;

        let output = std::process::Command::new("swa")
            .args([
                "deploy",
                dist_dir.to_str().unwrap_or("dist"),
                "--app-name",
                &self.config.app_name,
                "--resource-group",
                &self.config.resource_group,
                "--env",
                "production",
            ])
            .output()
            .map_err(|e| SiteError::Deploy {
                provider: "azure".into(),
                message: format!("swa deploy failed: {}", e),
            })?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(SiteError::Deploy {
                provider: "azure".into(),
                message: stderr.to_string(),
            });
        }

        Ok(DeployResult {
            url: format!("https://{}.azurestaticapps.net", self.config.app_name),
            provider: "Azure Static Web Apps".into(),
            pages_deployed: count_html_files(dist_dir),
        })
    }

    fn deploy_workers(&self, _dist_dir: &Path, plugins: &PluginRegistry) -> SiteResult<()> {
        if plugins.is_empty() {
            return Ok(());
        }

        let staging_dir = _dist_dir.join("_ferrosite").join("deploy").join("azure");
        std::fs::create_dir_all(&staging_dir)?;

        for plugin in plugins.workers() {
            let plugin_dir = staging_dir.join(&plugin.manifest.name);
            std::fs::create_dir_all(&plugin_dir)?;
            std::fs::write(
                plugin_dir.join("index.js"),
                generate_azure_function_worker(plugin),
            )?;
        }

        println!("⚡ Azure Functions for plugins — use 'func deploy' or Azure DevOps pipeline.");
        println!(
            "  Staged Azure Function handlers at {}",
            staging_dir.display()
        );
        Ok(())
    }
}

pub(crate) fn generate_azure_function_worker(plugin: &Plugin) -> String {
    let (commands_json, queries_json) = serialized_cqrs(plugin);
    let manifest = &plugin.manifest;

    format!(
        r#"// Auto-generated CQRS wrapper for plugin: {name}
// Route: {route}
// Worker runtime: azure-function

const COMMANDS = {commands};
const QUERIES = {queries};

const CORS_HEADERS = {{
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type",
  "Content-Type": "application/json",
}};

function defaultCommandName() {{
  return COMMANDS.length === 1 ? COMMANDS[0].name : null;
}}

function wantsJsonResponse(headers) {{
  return headerValue(headers, "accept").toLowerCase().includes("application/json");
}}

function normalizeRedirectTarget(target) {{
  if (typeof target !== "string") {{
    return "/contact/";
  }}

  if (!target.startsWith("/") || target.startsWith("//")) {{
    return "/contact/";
  }}

  return target;
}}

function redirectLocation(payload, status) {{
  const base = normalizeRedirectTarget(payload?.redirect_to);
  const url = new URL(base, "https://ferrosite.local");
  url.hash = status === "success" ? "contact-form-success" : "contact-form-error";
  return `${{url.pathname}}${{url.search}}${{url.hash}}`;
}}

function normalizeCommandEnvelope(body) {{
  const raw = body && typeof body === "object" && !Array.isArray(body) ? body : {{}};

  if (typeof raw.command === "string" && raw.payload && typeof raw.payload === "object" && !Array.isArray(raw.payload)) {{
    return {{ command: raw.command, payload: raw.payload }};
  }}

  if (typeof raw.command === "string") {{
    const {{ command, ...payload }} = raw;
    return {{ command, payload }};
  }}

  const inferred = defaultCommandName();
  if (inferred) {{
    return {{ command: inferred, payload: raw }};
  }}

  throw new Error("Command is required");
}}

function headerValue(headers, name) {{
  if (!headers) {{
    return "";
  }}

  const direct = headers[name] ?? headers[name.toLowerCase()] ?? headers[name.toUpperCase()];
  if (typeof direct === "string") {{
    return direct;
  }}

  return "";
}}

function parseCommandRequest(req) {{
  const rawBody = req?.body ?? req?.rawBody ?? {{}};
  if (rawBody && typeof rawBody === "object") {{
    return normalizeCommandEnvelope(rawBody);
  }}

  const raw = typeof rawBody === "string" ? rawBody : "";
  const contentType = headerValue(req?.headers, "content-type").toLowerCase();

  if (!raw.trim()) {{
    return normalizeCommandEnvelope({{}});
  }}

  if (contentType.includes("application/x-www-form-urlencoded") || raw.includes("=")) {{
    return normalizeCommandEnvelope(Object.fromEntries(new URLSearchParams(raw).entries()));
  }}

  return normalizeCommandEnvelope(JSON.parse(raw));
}}

{worker_source}

function response(body, status = 200) {{
  return {{
    status,
    headers: CORS_HEADERS,
    body: JSON.stringify(body),
  }};
}}

function redirectResponse(location) {{
  return {{
    status: 303,
    headers: {{
      ...CORS_HEADERS,
      Location: location,
    }},
    body: "",
  }};
}}

module.exports = async function(context, req) {{
  const method = req?.method || "GET";

  if (method === "OPTIONS") {{
    context.res = {{
      status: 204,
      headers: CORS_HEADERS,
      body: "",
    }};
    return;
  }}

  try {{
    if (method === "POST") {{
      const {{ command, payload }} = parseCommandRequest(req);

      const known = COMMANDS.find(c => c.name === command);
      if (!known) {{
        context.res = response({{ error: `Unknown command: ${{command}}` }}, 400);
        return;
      }}

      const result = await handleCommand(command, payload, process.env, context);
      if (!wantsJsonResponse(req?.headers)) {{
        context.res = redirectResponse(redirectLocation(payload, "success"));
        return;
      }}
      context.res = response({{ ok: true, result }});
      return;
    }}

    if (method === "GET") {{
      const params = req?.query || {{}};
      const query = params.query;

      const known = QUERIES.find(q => q.name === query);
      if (!known) {{
        context.res = response({{ error: `Unknown query: ${{query}}` }}, 400);
        return;
      }}

      const result = await handleQuery(query, params, process.env, context);
      context.res = response({{ ok: true, result }});
      return;
    }}

    context.res = response({{ error: "Method not allowed" }}, 405);
  }} catch (err) {{
    if (method === "POST" && !wantsJsonResponse(req?.headers)) {{
      try {{
        const payload = parseCommandRequest(req);
        context.res = redirectResponse(redirectLocation(payload.payload || payload, "error"));
      }} catch {{
        context.res = redirectResponse(redirectLocation({{}}, "error"));
      }}
      return;
    }}
    context.res = response({{ error: err.message }}, 500);
  }}
}};
"#,
        name = manifest.name,
        route = manifest.worker_route,
        commands = commands_json,
        queries = queries_json,
        worker_source = plugin.worker_source,
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::plugin::{PluginManifest, SandboxConfig};
    use std::path::PathBuf;

    fn fixture_plugin() -> Plugin {
        Plugin {
            manifest: PluginManifest {
                name: "contact-form".into(),
                version: "1.0.0".into(),
                description: String::new(),
                author: "ferrosite".into(),
                slots: vec!["contact-form".into()],
                head_inject: Vec::new(),
                commands: Vec::new(),
                queries: Vec::new(),
                component_file: "component.js".into(),
                worker_file: "worker.js".into(),
                worker_route: "/api/contact".into(),
                worker_runtime: "cloudflare-worker".into(),
                required_env: Vec::new(),
                sandbox: SandboxConfig::default(),
            },
            component_source: String::new(),
            worker_source:
                "async function handleCommand() { return { ok: true }; }\nasync function handleQuery() { return { ok: true }; }"
                    .into(),
            dir: PathBuf::from("plugins/contact-form"),
        }
    }

    #[test]
    fn generates_azure_wrapper() {
        let output = generate_azure_function_worker(&fixture_plugin());
        assert!(output.contains("module.exports = async function"));
      assert!(output.contains("parseCommandRequest"));
      assert!(output.contains("URLSearchParams"));
      assert!(output.contains("contact-form-success"));
    }
}

fn generate_swa_config() -> String {
    r#"{
  "navigationFallback": {
    "rewrite": "/index.html",
    "exclude": ["/assets/*", "*.css", "*.js", "*.png", "*.jpg", "*.ico"]
  },
  "responseOverrides": {
    "404": { "rewrite": "/404/index.html", "statusCode": 404 }
  },
  "mimeTypes": {
    ".mjs": "application/javascript"
  }
}"#
    .into()
}