1#![doc = include_str!("../README.md")]
2#![warn(clippy::all, clippy::pedantic)]
3#![allow(clippy::missing_errors_doc, clippy::cast_precision_loss)]
4
5pub 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}