ordinary-build 0.10.0

Build & codegen tool for Ordinary
// Copyright (C) 2026 Ordinary Labs, LLC.
//
// SPDX-License-Identifier: AGPL-3.0-only

use crate::{BASE_SERVER_TOML, PercentageDisplay, generate, save_inline_hashes};
use anyhow::bail;
use fs_err::{File, create_dir_all};
use ordinary_build_utils::html;
use ordinary_config::{
    ContentDefinition, ModelConfig, OrdinaryConfig, TemplateConfig, TemplateFfiSerialization,
};
use std::collections::BTreeMap;
use std::env::set_current_dir;
use std::io::Write;
use std::path::Path;
use std::process::Command;

pub enum CompileResult {
    Continue,
    Done,
}

#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
pub fn run(
    no_cache: bool,
    generator: &str,
    project_dir: &Path,
    template_config: &TemplateConfig,
    config: &OrdinaryConfig,
    server_dir_path: &Path,
    templates_dir_path: &Path,
    hashes_dir_path: &Path,
    content_def_map: &mut BTreeMap<String, ContentDefinition>,
    model_config_map: &mut BTreeMap<String, ModelConfig>,
) -> anyhow::Result<CompileResult> {
    let template_mime = template_config.mime_validated();
    let template_name = template_config.name_validated();

    if let Some(template_path) = &template_config.path {
        let path = project_dir.join(template_path);

        let mut template_string = fs_err::read_to_string(&path)?;
        template_string = template_string.replace("{{ version }}", &config.version);

        if let Some(vars) = &template_config.variables {
            for var in vars {
                let env_var = std::env::var(var)?;

                template_string = template_string.replace(&format!("{{{{ {var} }}}}"), &env_var);
            }
        }

        let (template_final, csp_hashes) = if template_config.minify == Some(true) {
            if template_mime == "text/html" || template_mime == "text/html; charset=utf-8" {
                let html_string = html::minify(&template_string)?;

                let csp_hashes =
                    save_inline_hashes(html_string.clone(), hashes_dir_path, template_name);

                (
                    html_string.as_bytes().to_vec(),
                    csp_hashes.has_any().then_some(csp_hashes),
                )
            } else {
                // todo: support minification for additional MIME types
                (template_string.as_bytes().to_vec(), None)
            }
        } else if template_mime == "text/html" || template_mime == "text/html; charset=utf-8" {
            let csp_hashes =
                save_inline_hashes(template_string.clone(), hashes_dir_path, template_name);

            (
                template_string.as_bytes().to_vec(),
                csp_hashes.has_any().then_some(csp_hashes),
            )
        } else {
            (template_string.as_bytes().to_vec(), None)
        };

        let file_name = match path.extension() {
            Some(ext) => match ext.to_str() {
                Some(ext) => &format!("{template_name}.{ext}"),
                None => template_name,
            },
            None => template_name,
        };

        let mut global_vars = BTreeMap::new();

        if let Some(globals) = &config.globals {
            for global_var in globals {
                global_vars.insert(global_var.name.clone(), global_var.clone());
            }
        }

        let server = match template_config.ffi_validated().serialization {
            TemplateFfiSerialization::FlexBufferVector => {
                generate::template::v1::flexbuffer_vector::generate_template_renderers(
                    &config.domain,
                    &config.version,
                    &config.canonical.clone().unwrap_or(
                        config
                            .cnames
                            .clone()
                            .unwrap_or(vec![config.domain.clone()])
                            .first()
                            .unwrap_or(&config.domain)
                            .clone(),
                    ),
                    generator,
                    file_name,
                    template_config,
                    content_def_map,
                    model_config_map,
                    &global_vars,
                    config.error.as_ref(),
                    config.auth.as_ref(),
                )
            }
            TemplateFfiSerialization::Json => {
                bail!("Json serialization is not supported for V1 FFI templates");
            }
        };

        let file_path = templates_dir_path.join(file_name);

        if !no_cache && file_path.exists() && fs_err::read(&file_path)? == template_final {
            tracing::info!(
                name = template_config.name,
                "template has not changed; skipping."
            );
            return Ok(CompileResult::Continue);
        }

        // server
        let mut file = File::create(file_path)?;

        file.write_all(&template_final)?;
        file.flush()?;

        create_dir_all(server_dir_path.join(template_name).join("src"))?;

        let server_main_rs = server_dir_path
            .join(template_name)
            .join("src")
            .join("main.rs");
        let mut server_main_rs_file = File::create(server_main_rs)?;
        server_main_rs_file.write_all(server.as_bytes())?;
        server_main_rs_file.flush()?;

        let server_cargo = server_dir_path.join(template_name).join("Cargo.toml");
        let mut server_cargo_file = File::create(server_cargo)?;
        server_cargo_file.write_all(BASE_SERVER_TOML.as_bytes())?;
        server_cargo_file.flush()?;

        let server_askama = server_dir_path.join(template_name).join("askama.toml");
        let mut server_askama_file = File::create(server_askama)?;
        server_askama_file.write_all(
            r#"# generated
[general]
dirs = ["../../templates"]

[[escaper]]
path = "askama::filters::Text"
extensions = ["js", "json"]
"#
            .as_bytes(),
        )?;
        server_askama_file.flush()?;

        let path = server_dir_path.join(template_name);
        set_current_dir(path)?;

        Command::new("cargo").args(["fmt"]).output()?;

        let output = Command::new("cargo")
            .args(["build", "--release", "--target", "wasm32-wasip1"])
            .output()?;

        if output.status.success() {
            let template_wasm_path = "target/wasm32-wasip1/release/template.wasm";

            let template_bytes = fs_err::read(template_wasm_path)?;

            if template_config.minify == Some(true) {
                tracing::info!(
                    name = template_config.name,
                    mime = template_config.mime,
                    size.bin = %bytesize::ByteSize(template_bytes.len() as u64)
                        .display()
                        .si(),
                    size.source = %bytesize::ByteSize(template_string.len() as u64)
                        .display()
                        .si(),
                    size.minified = %bytesize::ByteSize(template_final.len() as u64)
                        .display()
                        .si(),
                    size.reduction = %PercentageDisplay(((template_string.len() as f64 - template_final.len() as f64)
                        / template_string.len() as f64)
                        * 100.0),
                    csp = csp_hashes.map(display),
                    "template"
                );
            } else {
                tracing::info!(
                    name = template_config.name,
                    mime = template_config.mime,
                    size.bin = %bytesize::ByteSize(template_bytes.len() as u64)
                        .display()
                        .si(),
                    size.source = %bytesize::ByteSize(template_string.len() as u64)
                        .display()
                        .si(),
                    csp = csp_hashes.map(display),
                    "template"
                );
            }
        } else {
            tracing::error!(
                name = template_config.name,
                "failed for template\n{}",
                String::from_utf8_lossy(&output.stderr)
            );
        }
    }

    set_current_dir(project_dir)?;

    Ok(CompileResult::Done)
}