#![doc = include_str!("../README.md")]
#![warn(clippy::all, clippy::pedantic)]
#![allow(clippy::missing_errors_doc, clippy::cast_precision_loss)]
pub use ordinary_build_utils::csp::for_html;
use anyhow::bail;
use fs_err::{DirEntry, File, create_dir_all};
use std::env::home_dir;
use std::fmt::{Display, Formatter};
use std::{
collections::BTreeMap,
env::{current_dir, set_current_dir},
io::Write,
path::Path,
process::Command,
sync::Arc,
};
pub struct PercentageDisplay(pub f64);
impl Display for PercentageDisplay {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{:.2}%", self.0)
}
}
mod compile;
mod generate;
use crate::compile::template::v1::CompileResult;
use crate::generate::action;
use ordinary_build_utils::csp::save_inline_hashes;
use ordinary_build_utils::{css, html, js};
use ordinary_config::{
ActionFfiSerialization, ActionFfiVersion, ActionLang, OrdinaryConfig, TemplateFfiVersion,
};
use parking_lot::Mutex;
use tracing::instrument;
const BASE_SERVER_TOML: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/static/ServerTemplate.toml"
));
const BASE_ACTION_TOML: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/static/ServerActionGen.toml"
));
pub(crate) fn before_all(config: &OrdinaryConfig, project: &Path) -> anyhow::Result<()> {
if let Some(lifecycle_config) = &config.lifecycle
&& let Some(before_all) = &lifecycle_config.before_all
{
OrdinaryConfig::exec_script(project, &None, "all", "before", before_all)?;
}
Ok(())
}
#[allow(clippy::too_many_lines, clippy::missing_panics_doc)]
#[instrument(skip_all, err)]
pub fn build(path: &str, force: bool, generator: &str) -> anyhow::Result<()> {
tracing::info!("building...");
let start_dir = current_dir()?;
let path = Path::new(path);
set_current_dir(path)?;
let project_dir = current_dir()?;
let config = OrdinaryConfig::get(".", true)?;
config.validate()?;
let bin_path = home_dir()
.expect("home dir doesn't exist")
.join(".ordinary")
.join("bin");
let exiftool_path = bin_path.join("exiftool").join("exiftool");
let has_wasm = !config.templates.as_ref().unwrap_or(&vec![]).is_empty()
&& !config.actions.as_ref().unwrap_or(&vec![]).is_empty();
if has_wasm {
let output = Command::new("cargo").arg("--version").output()?;
if !output.status.success() {
tracing::error!(
"Rust does not appear to be installed. - for install, run `ordinary doctor --fix rust`",
);
bail!(
"Rust does not appear to be installed. - for install, run `ordinary doctor --fix rust`"
);
}
}
if !exiftool_path.exists() && config.assets.is_some() {
tracing::warn!(
"exiftool not installed at {} (images won't be stripped prior to upload) - for install, run `ordinary doctor --fix exiftool`",
exiftool_path.display()
);
}
before_all(&config, path)?;
if let Some(lifecycle_config) = &config.lifecycle
&& let Some(lifecycle) = &lifecycle_config.build
&& let Some(before) = &lifecycle.before
{
OrdinaryConfig::exec_script(&project_dir, &None, "build", "before", before)?;
}
let config = OrdinaryConfig::get(".", true)?;
config.validate()?;
let gen_dir_path = project_dir.join(".ordinary").join("gen");
let client_dir_path = gen_dir_path.join("client");
let server_dir_path = gen_dir_path.join("server");
let templates_dir_path = gen_dir_path.join("templates");
let hashes_dir_path = gen_dir_path.join("hashes");
create_dir_all(&client_dir_path)?;
create_dir_all(&server_dir_path)?;
create_dir_all(&templates_dir_path)?;
create_dir_all(&hashes_dir_path)?;
if let Some(fragments_config) = &config.fragments {
let src_path = Path::new(&fragments_config.dir_path);
let dest_path = templates_dir_path.join(&fragments_config.dir_path);
if src_path.exists() {
copy_dir_all(src_path, dest_path)?;
}
}
let mut content_def_map = BTreeMap::new();
if let Some(content) = &config.content {
for content_def in &content.definitions {
content_def_map.insert(content_def.name.clone(), content_def.clone());
}
}
let mut model_config_map = BTreeMap::new();
if let Some(models) = &config.models {
for model_config in models {
model_config_map.insert(model_config.name.clone(), model_config.clone());
}
}
let mut integration_config_map = BTreeMap::new();
if let Some(integrations) = &config.integrations {
for integration_config in integrations {
integration_config_map
.insert(integration_config.name.clone(), integration_config.clone());
}
}
if let Some(actions) = &config.actions {
for action_config in actions {
let cache_dir = gen_dir_path
.join("actions")
.join("cache")
.join(&action_config.name);
let Some(dir_path) = &action_config.dir_path else {
tracing::error!("no dir_path provided for action");
bail!("no dir_path provided for action");
};
let input_action_dir = Path::new(dir_path);
create_dir_all(&cache_dir)?;
let should_build = Arc::new(Mutex::new(false));
traverse(input_action_dir, &|entry| {
let path = entry.path();
if let Some(str_path) = path.to_str()
&& (str_path.contains(".vscode")
|| str_path.contains("target")
|| str_path.contains("node_modules"))
{
return Ok(());
}
let curr_content = fs_err::read(&path).unwrap_or_else(|_| Vec::new());
if let Ok(child_path) = path.strip_prefix(input_action_dir) {
if let Some(parent) = child_path.parent()
&& let Err(err) = create_dir_all(cache_dir.join(parent))
{
tracing::error!(%err, "failed to create dir");
}
let cache_path = cache_dir.join(child_path);
let cached_content = fs_err::read(&cache_path).unwrap_or_else(|_| Vec::new());
if curr_content != cached_content
&& let Ok(mut content_file) = File::create(&cache_path)
&& content_file.write_all(&curr_content).is_ok()
&& content_file.flush().is_ok()
{
let mut should_build = should_build.lock();
*should_build = true;
}
}
Ok(())
})?;
let should_build = should_build.lock();
if *should_build || force {
let generated_code = match action_config.ffi.version {
ActionFfiVersion::V1 => match action_config.ffi.serialization {
ActionFfiSerialization::FlexBufferVector => {
let (generated_code, extras) =
action::v1::flexbuffer_vector::generate_action_bindings(
action_config,
&model_config_map,
&content_def_map,
&integration_config_map,
config.auth.as_ref(),
&config.domain,
)?;
if let Some((main_rs, cargo_toml, type_defs)) = extras {
let path = project_dir.join(dir_path);
create_dir_all(path.join("src"))?;
let mut cargo_file = File::create(path.join("Cargo.toml"))?;
cargo_file.write_all(cargo_toml.as_bytes())?;
cargo_file.flush()?;
let mut main_file = File::create(path.join("src").join("main.rs"))?;
main_file.write_all(main_rs.as_bytes())?;
main_file.flush()?;
match action_config.lang {
ActionLang::Rust => {}
ActionLang::JavaScript => {
let mut type_file = File::create(path.join("index.d.ts"))?;
type_file.write_all(type_defs.as_bytes())?;
type_file.flush()?;
let curr_dir = current_dir()?;
set_current_dir(path)?;
Command::new("pnpm").args(["install"]).output()?;
Command::new("pnpm").args(["run", "build"]).output()?;
set_current_dir(curr_dir)?;
}
}
}
generated_code
}
},
};
let action_dir_path = gen_dir_path.join("actions").join(&action_config.name);
create_dir_all(action_dir_path.join("src"))?;
let cargo = action_dir_path.join("Cargo.toml");
let mut cargo_file = File::create(cargo)?;
cargo_file.write_all(BASE_ACTION_TOML.as_bytes())?;
cargo_file.flush()?;
let lib = action_dir_path.join("src").join("lib.rs");
let mut lib_file = File::create(lib)?;
lib_file.write_all(generated_code.as_bytes())?;
lib_file.flush()?;
let path = project_dir.join(dir_path);
set_current_dir(path)?;
let output = Command::new("cargo")
.args(["build", "--release", "--target", "wasm32-wasip1"])
.output()?;
if output.status.success() {
let action_path = "target/wasm32-wasip1/release/action.wasm";
let action_bytes = fs_err::read(action_path)?;
tracing::info!(
name = action_config.name,
language = ?action_config.lang,
size.bin = %bytesize::ByteSize(action_bytes.len() as u64).display().si(),
"action"
);
} else {
tracing::error!(
"failed for action '{}'\n\n{}",
action_config.name,
String::from_utf8_lossy(&output.stderr)
);
}
set_current_dir(&project_dir)?;
} else {
tracing::info!(
name = action_config.name,
"action has not changed; skipping."
);
}
}
}
if let Some(templates) = &config.templates {
for template_config in templates {
match template_config.ffi_validated().version {
TemplateFfiVersion::V1 => {
tracing::warn!(
"V1 template FFI will be deprecated in a future version of ordinary(d). Upgrade to V2."
);
let result = compile::template::v1::run(
force,
generator,
&project_dir,
template_config,
&config,
&server_dir_path,
&templates_dir_path,
&hashes_dir_path,
&mut content_def_map,
&mut model_config_map,
)?;
match result {
CompileResult::Continue | CompileResult::Done => (),
}
}
TemplateFfiVersion::V2 => {
ordinary_template::build::v2::build(
template_config,
&project_dir,
config.globals.as_ref(),
&config.content_map(),
&config.model_map(),
config.error.as_ref(),
config.auth.as_ref(),
force,
)?;
}
}
}
}
set_current_dir(&project_dir)?;
if let Some(assets) = config.assets
&& (assets.minify_css == Some(true)
|| assets.minify_js == Some(true)
|| assets.minify_html == Some(true)
|| assets.preserve_exif != Some(true))
{
let base_route = if assets.base_route == "/" {
""
} else {
assets.base_route.as_str()
};
let Some(assets_dir_path) = &assets.dir_path else {
bail!("assets.dir_path cannot be unset");
};
let dir_path = Path::new(&assets_dir_path);
let gen_path = Path::new(".ordinary").join("gen");
if exiftool_path.exists() {
let command = format!(
"{} -r -all= {} -directory='{}/%d' --icc_profile:all",
exiftool_path.display(),
dir_path.display(),
gen_path.display()
);
tracing::info!(cmd = %command, "exiftool");
let opt_output = Command::new("sh").args(["-c", &command]).output()?;
if opt_output.status.success() {
tracing::info!(src = %dir_path.display(), dest = %gen_path.display(), "exiftool run");
} else {
let stderr = std::str::from_utf8(&opt_output.stderr)?.split('\n');
for line in stderr {
if line.contains("already exists") {
if let Some(msg) = line.strip_prefix("Error: ") {
tracing::info!(%msg, "exiftool");
}
} else if !line.trim().is_empty() {
tracing::error!(stderr = %line, "exiftool");
}
}
}
}
let Some(assets_dir_path) = &assets.dir_path else {
bail!("assets.dir_path cannot be unset");
};
let gen_path = gen_path.join(assets_dir_path);
create_dir_all(&gen_path)?;
traverse(dir_path, &|entry| {
let path = entry.path();
let path2 = entry.path();
let rel_path = path2.strip_prefix(dir_path)?;
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if ext == "css" && assets.minify_css == Some(true) {
let file_str = fs_err::read_to_string(path)?;
let file_str_len = file_str.len();
if let Ok(minified) = css::minify(&file_str) {
let dest_path = gen_path.join(rel_path);
if let Some(parent) = gen_path.join(rel_path).parent() {
create_dir_all(parent)?;
let mut content_file = File::create(&dest_path)?;
content_file.write_all(minified.as_bytes())?;
tracing::info!(
path = %format!("{base_route}/{}", rel_path.display()),
ext = "css",
size.source = %bytesize::ByteSize(file_str_len as u64).display().si(),
size.minified = %bytesize::ByteSize(minified.len() as u64).display().si(),
size.reduction = %PercentageDisplay(((file_str_len as f64 - minified.len() as f64) / file_str_len as f64)
* 100.0)
,
"asset"
);
}
}
} else if ext == "js" && assets.minify_js == Some(true) {
let file_str = fs_err::read_to_string(path)?;
let file_str_len = file_str.len();
if let Ok(minified) = js::minify(&file_str) {
let dest_path = gen_path.join(rel_path);
if let Some(parent) = gen_path.join(rel_path).parent() {
create_dir_all(parent)?;
let mut content_file = File::create(&dest_path)?;
content_file.write_all(minified.as_bytes())?;
tracing::info!(
path = %format!("{base_route}/{}", rel_path.display()),
ext= "js",
size.source = %bytesize::ByteSize(file_str_len as u64).display().si(),
size.minified = %bytesize::ByteSize(minified.len() as u64).display().si(),
size.reduction = %PercentageDisplay(((file_str_len as f64 - minified.len() as f64) / file_str_len as f64)
* 100.0)
,
"asset"
);
}
}
} else if ext == "html" && assets.minify_html == Some(true) {
let file_str = fs_err::read_to_string(path)?;
let file_str_len = file_str.len();
if let Ok(minified) = html::minify(&file_str) {
let dest_path = gen_path.join(rel_path);
if let Some(parent) = gen_path.join(rel_path).parent() {
create_dir_all(parent)?;
let mut content_file = File::create(&dest_path)?;
content_file.write_all(minified.as_bytes())?;
tracing::info!(
path = %format!("{base_route}/{}", rel_path.display()),
ext= "html",
size.source = %bytesize::ByteSize(file_str_len as u64).display().si(),
size.minified = %bytesize::ByteSize(minified.len() as u64).display().si(),
size.reduction = %PercentageDisplay(((file_str_len as f64 - minified.len() as f64) / file_str_len as f64)
* 100.0)
,
"asset"
);
}
}
}
}
Ok(())
})?;
}
if let Some(lifecycle_config) = &config.lifecycle
&& let Some(lifecycle) = &lifecycle_config.build
&& let Some(after) = &lifecycle.after
{
OrdinaryConfig::exec_script(&project_dir, &None, "build", "after", after)?;
}
set_current_dir(start_dir)?;
Ok(())
}
#[allow(clippy::type_complexity)]
pub fn traverse(dir: &Path, cb: &dyn Fn(&DirEntry) -> anyhow::Result<()>) -> anyhow::Result<()> {
if dir.is_dir() {
for entry in fs_err::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
traverse(&path, cb)?;
} else {
cb(&entry)?;
}
}
}
Ok(())
}
fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> anyhow::Result<()> {
create_dir_all(&dst)?;
for entry in fs_err::read_dir(src.as_ref())? {
let entry = entry?;
if entry.file_type()?.is_dir() {
copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
} else {
fs_err::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
}
}
Ok(())
}