Skip to main content

ordinary_build/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(clippy::all, clippy::pedantic)]
3#![allow(clippy::missing_errors_doc, clippy::cast_precision_loss)]
4
5// Copyright (C) 2026 Ordinary Labs, LLC.
6//
7// SPDX-License-Identifier: AGPL-3.0-only
8
9pub use ordinary_build_utils::csp::for_html;
10
11use anyhow::bail;
12use fs_err::{DirEntry, File, create_dir_all};
13use std::env::home_dir;
14use std::fmt::{Display, Formatter};
15use std::{
16    collections::BTreeMap,
17    env::{current_dir, set_current_dir},
18    io::Write,
19    path::Path,
20    process::Command,
21    sync::Arc,
22};
23
24pub struct PercentageDisplay(pub f64);
25
26impl Display for PercentageDisplay {
27    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
28        write!(f, "{:.2}%", self.0)
29    }
30}
31
32mod compile;
33mod generate;
34
35use crate::compile::template::v1::CompileResult;
36use crate::generate::action;
37use ordinary_build_utils::csp::save_inline_hashes;
38use ordinary_build_utils::{css, html, js};
39use ordinary_config::{
40    ActionFfiSerialization, ActionFfiVersion, ActionLang, OrdinaryConfig, TemplateFfiVersion,
41};
42use parking_lot::Mutex;
43use tracing::instrument;
44
45const BASE_SERVER_TOML: &str = include_str!(concat!(
46    env!("CARGO_MANIFEST_DIR"),
47    "/static/ServerTemplate.toml"
48));
49
50const BASE_ACTION_TOML: &str = include_str!(concat!(
51    env!("CARGO_MANIFEST_DIR"),
52    "/static/ServerActionGen.toml"
53));
54
55pub(crate) fn before_all(config: &OrdinaryConfig, project: &Path) -> anyhow::Result<()> {
56    if let Some(lifecycle_config) = &config.lifecycle
57        && let Some(before_all) = &lifecycle_config.before_all
58    {
59        OrdinaryConfig::exec_script(project, &None, "all", "before", before_all)?;
60    }
61
62    Ok(())
63}
64
65#[allow(clippy::too_many_lines, clippy::missing_panics_doc)]
66#[instrument(skip_all, err)]
67pub fn build(path: &str, force: bool, generator: &str) -> anyhow::Result<()> {
68    tracing::info!("building...");
69
70    let start_dir = current_dir()?;
71
72    let path = Path::new(path);
73    set_current_dir(path)?;
74    let project_dir = current_dir()?;
75
76    let config = OrdinaryConfig::get(".", true)?;
77    config.validate()?;
78
79    let bin_path = home_dir()
80        .expect("home dir doesn't exist")
81        .join(".ordinary")
82        .join("bin");
83
84    let exiftool_path = bin_path.join("exiftool").join("exiftool");
85
86    let has_wasm = !config.templates.as_ref().unwrap_or(&vec![]).is_empty()
87        && !config.actions.as_ref().unwrap_or(&vec![]).is_empty();
88
89    if has_wasm {
90        let output = Command::new("cargo").arg("--version").output()?;
91
92        if !output.status.success() {
93            tracing::error!(
94                "Rust does not appear to be installed. - for install, run `ordinary doctor --fix rust`",
95            );
96            bail!(
97                "Rust does not appear to be installed. - for install, run `ordinary doctor --fix rust`"
98            );
99        }
100    }
101
102    if !exiftool_path.exists() && config.assets.is_some() {
103        tracing::warn!(
104            "exiftool not installed at {} (images won't be stripped prior to upload) - for install, run `ordinary doctor --fix exiftool`",
105            exiftool_path.display()
106        );
107    }
108
109    before_all(&config, path)?;
110
111    if let Some(lifecycle_config) = &config.lifecycle
112        && let Some(lifecycle) = &lifecycle_config.build
113        && let Some(before) = &lifecycle.before
114    {
115        OrdinaryConfig::exec_script(&project_dir, &None, "build", "before", before)?;
116    }
117
118    let config = OrdinaryConfig::get(".", true)?;
119    config.validate()?;
120
121    let gen_dir_path = project_dir.join(".ordinary").join("gen");
122
123    let client_dir_path = gen_dir_path.join("client");
124    let server_dir_path = gen_dir_path.join("server");
125    let templates_dir_path = gen_dir_path.join("templates");
126    let hashes_dir_path = gen_dir_path.join("hashes");
127
128    create_dir_all(&client_dir_path)?;
129    create_dir_all(&server_dir_path)?;
130    create_dir_all(&templates_dir_path)?;
131    create_dir_all(&hashes_dir_path)?;
132
133    if let Some(fragments_config) = &config.fragments {
134        let src_path = Path::new(&fragments_config.dir_path);
135        let dest_path = templates_dir_path.join(&fragments_config.dir_path);
136
137        if src_path.exists() {
138            copy_dir_all(src_path, dest_path)?;
139        }
140    }
141
142    let mut content_def_map = BTreeMap::new();
143
144    if let Some(content) = &config.content {
145        for content_def in &content.definitions {
146            content_def_map.insert(content_def.name.clone(), content_def.clone());
147        }
148    }
149
150    let mut model_config_map = BTreeMap::new();
151
152    if let Some(models) = &config.models {
153        for model_config in models {
154            model_config_map.insert(model_config.name.clone(), model_config.clone());
155        }
156    }
157
158    let mut integration_config_map = BTreeMap::new();
159
160    if let Some(integrations) = &config.integrations {
161        for integration_config in integrations {
162            integration_config_map
163                .insert(integration_config.name.clone(), integration_config.clone());
164        }
165    }
166
167    if let Some(actions) = &config.actions {
168        for action_config in actions {
169            let cache_dir = gen_dir_path
170                .join("actions")
171                .join("cache")
172                .join(&action_config.name);
173
174            let Some(dir_path) = &action_config.dir_path else {
175                tracing::error!("no dir_path provided for action");
176                bail!("no dir_path provided for action");
177            };
178
179            let input_action_dir = Path::new(dir_path);
180
181            create_dir_all(&cache_dir)?;
182
183            let should_build = Arc::new(Mutex::new(false));
184
185            traverse(input_action_dir, &|entry| {
186                let path = entry.path();
187
188                if let Some(str_path) = path.to_str()
189                    && (str_path.contains(".vscode")
190                        || str_path.contains("target")
191                        || str_path.contains("node_modules"))
192                {
193                    return Ok(());
194                }
195
196                let curr_content = fs_err::read(&path).unwrap_or_else(|_| Vec::new());
197
198                if let Ok(child_path) = path.strip_prefix(input_action_dir) {
199                    if let Some(parent) = child_path.parent()
200                        && let Err(err) = create_dir_all(cache_dir.join(parent))
201                    {
202                        tracing::error!(%err, "failed to create dir");
203                    }
204
205                    let cache_path = cache_dir.join(child_path);
206
207                    let cached_content = fs_err::read(&cache_path).unwrap_or_else(|_| Vec::new());
208
209                    if curr_content != cached_content
210                        && let Ok(mut content_file) = File::create(&cache_path)
211                        && content_file.write_all(&curr_content).is_ok()
212                        && content_file.flush().is_ok()
213                    {
214                        let mut should_build = should_build.lock();
215                        *should_build = true;
216                    }
217                }
218
219                Ok(())
220            })?;
221
222            let should_build = should_build.lock();
223
224            if *should_build || force {
225                let generated_code = match action_config.ffi.version {
226                    ActionFfiVersion::V1 => match action_config.ffi.serialization {
227                        ActionFfiSerialization::FlexBufferVector => {
228                            let (generated_code, extras) =
229                                action::v1::flexbuffer_vector::generate_action_bindings(
230                                    action_config,
231                                    &model_config_map,
232                                    &content_def_map,
233                                    &integration_config_map,
234                                    config.auth.as_ref(),
235                                    &config.domain,
236                                )?;
237
238                            if let Some((main_rs, cargo_toml, type_defs)) = extras {
239                                let path = project_dir.join(dir_path);
240                                create_dir_all(path.join("src"))?;
241
242                                let mut cargo_file = File::create(path.join("Cargo.toml"))?;
243                                cargo_file.write_all(cargo_toml.as_bytes())?;
244                                cargo_file.flush()?;
245
246                                let mut main_file = File::create(path.join("src").join("main.rs"))?;
247                                main_file.write_all(main_rs.as_bytes())?;
248                                main_file.flush()?;
249
250                                match action_config.lang {
251                                    ActionLang::Rust => {}
252                                    ActionLang::JavaScript => {
253                                        let mut type_file = File::create(path.join("index.d.ts"))?;
254                                        type_file.write_all(type_defs.as_bytes())?;
255                                        type_file.flush()?;
256
257                                        let curr_dir = current_dir()?;
258                                        set_current_dir(path)?;
259
260                                        Command::new("pnpm").args(["install"]).output()?;
261                                        Command::new("pnpm").args(["run", "build"]).output()?;
262
263                                        set_current_dir(curr_dir)?;
264                                    }
265                                }
266                            }
267
268                            generated_code
269                        }
270                    },
271                };
272
273                let action_dir_path = gen_dir_path.join("actions").join(&action_config.name);
274
275                create_dir_all(action_dir_path.join("src"))?;
276
277                let cargo = action_dir_path.join("Cargo.toml");
278                let mut cargo_file = File::create(cargo)?;
279                cargo_file.write_all(BASE_ACTION_TOML.as_bytes())?;
280                cargo_file.flush()?;
281
282                let lib = action_dir_path.join("src").join("lib.rs");
283                let mut lib_file = File::create(lib)?;
284                lib_file.write_all(generated_code.as_bytes())?;
285                lib_file.flush()?;
286
287                let path = project_dir.join(dir_path);
288                set_current_dir(path)?;
289
290                let output = Command::new("cargo")
291                    .args(["build", "--release", "--target", "wasm32-wasip1"])
292                    .output()?;
293
294                if output.status.success() {
295                    let action_path = "target/wasm32-wasip1/release/action.wasm";
296                    let action_bytes = fs_err::read(action_path)?;
297
298                    tracing::info!(
299                        name = action_config.name,
300                        language = ?action_config.lang,
301                        size.bin = %bytesize::ByteSize(action_bytes.len() as u64).display().si(),
302                        "action"
303                    );
304                } else {
305                    tracing::error!(
306                        "failed for action '{}'\n\n{}",
307                        action_config.name,
308                        String::from_utf8_lossy(&output.stderr)
309                    );
310                }
311
312                set_current_dir(&project_dir)?;
313            } else {
314                tracing::info!(
315                    name = action_config.name,
316                    "action has not changed; skipping."
317                );
318            }
319        }
320    }
321
322    if let Some(templates) = &config.templates {
323        for template_config in templates {
324            match template_config.ffi_validated().version {
325                TemplateFfiVersion::V1 => {
326                    tracing::warn!(
327                        "V1 template FFI will be deprecated in a future version of ordinary(d). Upgrade to V2."
328                    );
329
330                    let result = compile::template::v1::run(
331                        force,
332                        generator,
333                        &project_dir,
334                        template_config,
335                        &config,
336                        &server_dir_path,
337                        &templates_dir_path,
338                        &hashes_dir_path,
339                        &mut content_def_map,
340                        &mut model_config_map,
341                    )?;
342
343                    match result {
344                        CompileResult::Continue | CompileResult::Done => (),
345                    }
346                }
347                TemplateFfiVersion::V2 => {
348                    ordinary_template::build::v2::build(
349                        template_config,
350                        &project_dir,
351                        config.globals.as_ref(),
352                        &config.content_map(),
353                        &config.model_map(),
354                        config.error.as_ref(),
355                        config.auth.as_ref(),
356                        force,
357                    )?;
358                }
359            }
360        }
361    }
362
363    set_current_dir(&project_dir)?;
364
365    if let Some(assets) = config.assets
366        && (assets.minify_css == Some(true)
367            || assets.minify_js == Some(true)
368            || assets.minify_html == Some(true)
369            || assets.preserve_exif != Some(true))
370    {
371        let base_route = if assets.base_route == "/" {
372            ""
373        } else {
374            assets.base_route.as_str()
375        };
376
377        let Some(assets_dir_path) = &assets.dir_path else {
378            bail!("assets.dir_path cannot be unset");
379        };
380
381        let dir_path = Path::new(&assets_dir_path);
382        let gen_path = Path::new(".ordinary").join("gen");
383
384        if exiftool_path.exists() {
385            let command = format!(
386                "{} -r -all= {} -directory='{}/%d' --icc_profile:all",
387                exiftool_path.display(),
388                dir_path.display(),
389                gen_path.display()
390            );
391
392            tracing::info!(cmd = %command, "exiftool");
393
394            let opt_output = Command::new("sh").args(["-c", &command]).output()?;
395
396            if opt_output.status.success() {
397                tracing::info!(src = %dir_path.display(), dest = %gen_path.display(), "exiftool run");
398            } else {
399                let stderr = std::str::from_utf8(&opt_output.stderr)?.split('\n');
400
401                for line in stderr {
402                    if line.contains("already exists") {
403                        if let Some(msg) = line.strip_prefix("Error: ") {
404                            tracing::info!(%msg, "exiftool");
405                        }
406                    } else if !line.trim().is_empty() {
407                        tracing::error!(stderr = %line, "exiftool");
408                    }
409                }
410            }
411        }
412
413        let Some(assets_dir_path) = &assets.dir_path else {
414            bail!("assets.dir_path cannot be unset");
415        };
416
417        let gen_path = gen_path.join(assets_dir_path);
418        create_dir_all(&gen_path)?;
419
420        traverse(dir_path, &|entry| {
421            let path = entry.path();
422            let path2 = entry.path();
423            let rel_path = path2.strip_prefix(dir_path)?;
424
425            if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
426                if ext == "css" && assets.minify_css == Some(true) {
427                    let file_str = fs_err::read_to_string(path)?;
428                    let file_str_len = file_str.len();
429
430                    if let Ok(minified) = css::minify(&file_str) {
431                        let dest_path = gen_path.join(rel_path);
432                        if let Some(parent) = gen_path.join(rel_path).parent() {
433                            create_dir_all(parent)?;
434
435                            let mut content_file = File::create(&dest_path)?;
436                            content_file.write_all(minified.as_bytes())?;
437
438                            tracing::info!(
439                                path = %format!("{base_route}/{}", rel_path.display()),
440                                ext = "css",
441                                size.source = %bytesize::ByteSize(file_str_len as u64).display().si(),
442                                size.minified = %bytesize::ByteSize(minified.len() as u64).display().si(),
443                                size.reduction = %PercentageDisplay(((file_str_len as f64 - minified.len() as f64) / file_str_len as f64)
444                                    * 100.0)
445                                ,
446                                "asset"
447                            );
448                        }
449                    }
450                } else if ext == "js" && assets.minify_js == Some(true) {
451                    let file_str = fs_err::read_to_string(path)?;
452                    let file_str_len = file_str.len();
453
454                    if let Ok(minified) = js::minify(&file_str) {
455                        let dest_path = gen_path.join(rel_path);
456                        if let Some(parent) = gen_path.join(rel_path).parent() {
457                            create_dir_all(parent)?;
458
459                            let mut content_file = File::create(&dest_path)?;
460                            content_file.write_all(minified.as_bytes())?;
461
462                            tracing::info!(
463                                path = %format!("{base_route}/{}", rel_path.display()),
464                                ext= "js",
465                                size.source = %bytesize::ByteSize(file_str_len as u64).display().si(),
466                                size.minified = %bytesize::ByteSize(minified.len() as u64).display().si(),
467                                size.reduction = %PercentageDisplay(((file_str_len as f64 - minified.len() as f64) / file_str_len as f64)
468                                    * 100.0)
469                                ,
470                                "asset"
471                            );
472                        }
473                    }
474                } else if ext == "html" && assets.minify_html == Some(true) {
475                    let file_str = fs_err::read_to_string(path)?;
476                    let file_str_len = file_str.len();
477
478                    if let Ok(minified) = html::minify(&file_str) {
479                        let dest_path = gen_path.join(rel_path);
480                        if let Some(parent) = gen_path.join(rel_path).parent() {
481                            create_dir_all(parent)?;
482
483                            let mut content_file = File::create(&dest_path)?;
484                            content_file.write_all(minified.as_bytes())?;
485
486                            tracing::info!(
487                                path = %format!("{base_route}/{}", rel_path.display()),
488                                ext= "html",
489                                size.source = %bytesize::ByteSize(file_str_len as u64).display().si(),
490                                size.minified = %bytesize::ByteSize(minified.len() as u64).display().si(),
491                                size.reduction = %PercentageDisplay(((file_str_len as f64 - minified.len() as f64) / file_str_len as f64)
492                                    * 100.0)
493                                ,
494                                "asset"
495                            );
496                        }
497                    }
498                }
499            }
500            Ok(())
501        })?;
502    }
503
504    if let Some(lifecycle_config) = &config.lifecycle
505        && let Some(lifecycle) = &lifecycle_config.build
506        && let Some(after) = &lifecycle.after
507    {
508        OrdinaryConfig::exec_script(&project_dir, &None, "build", "after", after)?;
509    }
510
511    set_current_dir(start_dir)?;
512
513    Ok(())
514}
515
516#[allow(clippy::type_complexity)]
517pub fn traverse(dir: &Path, cb: &dyn Fn(&DirEntry) -> anyhow::Result<()>) -> anyhow::Result<()> {
518    if dir.is_dir() {
519        for entry in fs_err::read_dir(dir)? {
520            let entry = entry?;
521            let path = entry.path();
522            if path.is_dir() {
523                traverse(&path, cb)?;
524            } else {
525                cb(&entry)?;
526            }
527        }
528    }
529    Ok(())
530}
531
532fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> anyhow::Result<()> {
533    create_dir_all(&dst)?;
534    for entry in fs_err::read_dir(src.as_ref())? {
535        let entry = entry?;
536
537        if entry.file_type()?.is_dir() {
538            copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
539        } else {
540            fs_err::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
541        }
542    }
543
544    Ok(())
545}