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 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
32pub mod css;
33pub mod html;
34pub mod js;
35
36pub mod csp;
37mod generate;
38
39use crate::csp::{CspValues, walk_document};
40use crate::generate::action;
41use ordinary_config::{
42 ActionFfiSerialization, ActionFfiVersion, ActionLang, OrdinaryConfig, TemplateFfiSerialization,
43 TemplateFfiVersion,
44};
45use parking_lot::Mutex;
46use swc_common::{FileName, FilePathMapping, SourceFile, SourceMap};
47use swc_html_parser::error::Error;
48use swc_html_parser::parse_file_as_document;
49use swc_html_parser::parser::ParserConfig;
50use tracing::instrument;
51
52const BASE_CLIENT: &str = include_str!(concat!(
53 env!("CARGO_MANIFEST_DIR"),
54 "/static/client_template.rs"
55));
56
57const BASE_SERVER_TOML: &str = include_str!(concat!(
58 env!("CARGO_MANIFEST_DIR"),
59 "/static/ServerTemplate.toml"
60));
61const BASE_CLIENT_TOML: &str = include_str!(concat!(
62 env!("CARGO_MANIFEST_DIR"),
63 "/static/ClientTemplate.toml"
64));
65
66const BASE_ACTION_TOML: &str = include_str!(concat!(
67 env!("CARGO_MANIFEST_DIR"),
68 "/static/ServerActionGen.toml"
69));
70
71const APPEND_WASM_JS: &str = include_str!(concat!(
74 env!("CARGO_MANIFEST_DIR"),
75 "/static/append_wasm.js"
76));
77
78const JS_ONLY_JS: &str = include_str!(concat!(
79 env!("CARGO_MANIFEST_DIR"),
80 "/static/javascript_only.js"
81));
82
83const CORE_JS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/static/core.js"));
84
85pub(crate) fn before_all(config: &OrdinaryConfig, project: &Path) -> anyhow::Result<()> {
86 if let Some(lifecycle_config) = &config.lifecycle
87 && let Some(before_all) = &lifecycle_config.before_all
88 {
89 OrdinaryConfig::exec_lifecycle_script(project, &None, "all", "before", before_all)?;
90 }
91
92 Ok(())
93}
94
95#[allow(clippy::too_many_lines, clippy::missing_panics_doc)]
96#[instrument(skip_all, err)]
97pub fn build(path: &str, no_cache: bool, generator: &str) -> anyhow::Result<()> {
98 tracing::info!("building...");
99
100 let start_dir = current_dir()?;
101
102 let path = Path::new(path);
103 set_current_dir(path)?;
104 let project_dir = current_dir()?;
105
106 let config = OrdinaryConfig::get(".")?;
107 config.validate()?;
108
109 let bin_path = home_dir()
110 .expect("home dir doesn't exist")
111 .join(".ordinary")
112 .join("bin");
113
114 let wasm_opt_path = bin_path.join("wasm-opt");
115 let exiftool_path = bin_path.join("exiftool").join("exiftool");
116
117 let has_wasm = !config.templates.as_ref().unwrap_or(&vec![]).is_empty()
118 && !config.actions.as_ref().unwrap_or(&vec![]).is_empty();
119
120 if has_wasm {
121 let output = Command::new("cargo").arg("--version").output()?;
122
123 if !output.status.success() {
124 tracing::error!(
125 "Rust does not appear to be installed. - for install, run `ordinary doctor --fix rust`",
126 );
127 bail!(
128 "Rust does not appear to be installed. - for install, run `ordinary doctor --fix rust`"
129 );
130 }
131
132 if !wasm_opt_path.exists() {
133 tracing::warn!(
134 "wasm-opt not installed at {} (built WASM modules will not be further optimized) - for install, run `ordinary doctor --fix wasm-opt`",
135 wasm_opt_path.display()
136 );
137 }
138 }
139
140 if !exiftool_path.exists() && config.assets.is_some() {
141 tracing::warn!(
142 "exiftool not installed at {} (images won't be stripped prior to upload) - for install, run `ordinary doctor --fix exiftool`",
143 exiftool_path.display()
144 );
145 }
146
147 before_all(&config, path)?;
148
149 if let Some(lifecycle_config) = &config.lifecycle
150 && let Some(lifecycle) = &lifecycle_config.build
151 && let Some(before) = &lifecycle.before
152 {
153 OrdinaryConfig::exec_lifecycle_script(&project_dir, &None, "build", "before", before)?;
154 }
155
156 let config = OrdinaryConfig::get(".")?;
157 config.validate()?;
158
159 let gen_dir_path = project_dir.join(".ordinary").join("gen");
160
161 let client_dir_path = gen_dir_path.join("client");
162 let server_dir_path = gen_dir_path.join("server");
163 let templates_dir_path = gen_dir_path.join("templates");
164 let hashes_dir_path = gen_dir_path.join("hashes");
165
166 create_dir_all(&client_dir_path)?;
167 create_dir_all(&server_dir_path)?;
168 create_dir_all(&templates_dir_path)?;
169 create_dir_all(&hashes_dir_path)?;
170
171 if let Some(fragments_config) = &config.fragments {
172 let src_path = Path::new(&fragments_config.dir_path);
173 let dest_path = templates_dir_path.join(&fragments_config.dir_path);
174
175 if src_path.exists() {
176 copy_dir_all(src_path, dest_path)?;
177 }
178 }
179
180 let mut content_def_map = BTreeMap::new();
181
182 if let Some(content) = config.content {
183 for content_def in content.definitions {
184 content_def_map.insert(content_def.name.clone(), content_def.clone());
185 }
186 }
187
188 let mut model_config_map = BTreeMap::new();
189
190 if let Some(models) = config.models {
191 for model_config in models {
192 model_config_map.insert(model_config.name.clone(), model_config.clone());
193 }
194 }
195
196 let mut integration_config_map = BTreeMap::new();
197
198 if let Some(integrations) = config.integrations {
199 for integration_config in integrations {
200 integration_config_map
201 .insert(integration_config.name.clone(), integration_config.clone());
202 }
203 }
204
205 if let Some(actions) = config.actions.clone() {
206 for action_config in actions {
207 let cache_dir = gen_dir_path
208 .join("actions")
209 .join("cache")
210 .join(&action_config.name);
211
212 let Some(dir_path) = &action_config.dir_path else {
213 tracing::error!("no dir_path provided for action");
214 bail!("no dir_path provided for action");
215 };
216
217 let input_action_dir = Path::new(dir_path);
218
219 create_dir_all(&cache_dir)?;
220
221 let should_build = Arc::new(Mutex::new(false));
222
223 traverse(input_action_dir, &|entry| {
224 let path = entry.path();
225
226 if let Some(str_path) = path.to_str()
227 && (str_path.contains(".vscode")
228 || str_path.contains("target")
229 || str_path.contains("node_modules"))
230 {
231 return Ok(());
232 }
233
234 let curr_content = fs_err::read(&path).unwrap_or_else(|_| Vec::new());
235
236 if let Ok(child_path) = path.strip_prefix(input_action_dir) {
237 if let Some(parent) = child_path.parent()
238 && let Err(err) = create_dir_all(cache_dir.join(parent))
239 {
240 tracing::error!(%err, "failed to create dir");
241 }
242
243 let cache_path = cache_dir.join(child_path);
244
245 let cached_content = fs_err::read(&cache_path).unwrap_or_else(|_| Vec::new());
246
247 if curr_content != cached_content
248 && let Ok(mut content_file) = File::create(&cache_path)
249 && content_file.write_all(&curr_content).is_ok()
250 && content_file.flush().is_ok()
251 {
252 let mut should_build = should_build.lock();
253 *should_build = true;
254 }
255 }
256
257 Ok(())
258 })?;
259
260 let should_build = should_build.lock();
261
262 if *should_build || no_cache {
263 let generated_code = match action_config.ffi.version {
264 ActionFfiVersion::V1 => match action_config.ffi.serialization {
265 ActionFfiSerialization::FlexBufferVector => {
266 let (generated_code, extras) =
267 action::v1::flexbuffer_vector::generate_action_bindings(
268 &action_config,
269 &model_config_map,
270 &content_def_map,
271 &integration_config_map,
272 &config.auth,
273 &config.domain,
274 )?;
275
276 if let Some((main_rs, cargo_toml, type_defs)) = extras {
277 let path = project_dir.join(dir_path);
278 create_dir_all(path.join("src"))?;
279
280 let mut cargo_file = File::create(path.join("Cargo.toml"))?;
281 cargo_file.write_all(cargo_toml.as_bytes())?;
282 cargo_file.flush()?;
283
284 let mut main_file = File::create(path.join("src").join("main.rs"))?;
285 main_file.write_all(main_rs.as_bytes())?;
286 main_file.flush()?;
287
288 match action_config.lang {
289 ActionLang::Rust => {}
290 ActionLang::JavaScript => {
291 let mut type_file = File::create(path.join("index.d.ts"))?;
292 type_file.write_all(type_defs.as_bytes())?;
293 type_file.flush()?;
294
295 let curr_dir = current_dir()?;
296 set_current_dir(path)?;
297
298 Command::new("pnpm").args(["install"]).output()?;
299 Command::new("pnpm").args(["run", "build"]).output()?;
300
301 set_current_dir(curr_dir)?;
302 }
303 }
304 }
305
306 generated_code
307 }
308 },
309 };
310
311 let action_dir_path = gen_dir_path.join("actions").join(&action_config.name);
312
313 create_dir_all(action_dir_path.join("src"))?;
314
315 let cargo = action_dir_path.join("Cargo.toml");
316 let mut cargo_file = File::create(cargo)?;
317 cargo_file.write_all(BASE_ACTION_TOML.as_bytes())?;
318 cargo_file.flush()?;
319
320 let lib = action_dir_path.join("src").join("lib.rs");
321 let mut lib_file = File::create(lib)?;
322 lib_file.write_all(generated_code.as_bytes())?;
323 lib_file.flush()?;
324
325 let path = project_dir.join(dir_path);
326 set_current_dir(path)?;
327
328 let output = Command::new("cargo")
329 .args(["build", "--release", "--target", "wasm32-wasip1"])
330 .output()?;
331
332 if output.status.success() {
333 let action_path = "target/wasm32-wasip1/release/action.wasm";
334
335 if let Some(wasm_opt) = action_config.wasm_opt
336 && wasm_opt_path.exists()
337 {
338 let opt_output = Command::new(&wasm_opt_path)
339 .args([
340 "--all-features",
341 action_path,
342 "-o",
343 action_path,
344 wasm_opt.as_flag(),
345 ])
346 .output()?;
347
348 if !opt_output.status.success() {
349 tracing::error!(stderr = %std::str::from_utf8(&opt_output.stderr)?);
350 }
351 }
352
353 let action_bytes = fs_err::read(action_path)?;
354
355 tracing::info!(
356 name = action_config.name,
357 language = ?action_config.lang,
358 size.bin = %bytesize::ByteSize(action_bytes.len() as u64).display().si(),
359 "action"
360 );
361 } else {
362 tracing::error!(
363 "failed for action '{}'\n\n{}",
364 action_config.name,
365 String::from_utf8_lossy(&output.stderr)
366 );
367 }
368
369 set_current_dir(&project_dir)?;
370 } else {
371 tracing::info!(
372 name = action_config.name,
373 "action has not changed; skipping."
374 );
375 }
376 }
377 }
378
379 let mut client_file = BASE_CLIENT.to_string();
380
381 if let Some(templates) = config.templates {
382 for template_config in templates {
383 if let Some(template_path) = &template_config.path {
384 let path = project_dir.join(template_path);
385
386 let mut template_string = fs_err::read_to_string(&path)?;
387 template_string = template_string.replace("{{ version }}", &config.version);
388
389 if let Some(vars) = &template_config.variables {
390 for var in vars {
391 let env_var = std::env::var(var)?;
392
393 template_string =
394 template_string.replace(&format!("{{{{ {var} }}}}"), &env_var);
395 }
396 }
397
398 let (template_final, csp_hashes) = if template_config.minify == Some(true) {
399 if template_config.mime == "text/html"
400 || template_config.mime == "text/html; charset=utf-8"
401 {
402 let html_string = html::minify(&template_string)?;
403
404 let csp_hashes =
405 swc_common::GLOBALS.set(&swc_common::Globals::new(), || {
406 let mut errors = Vec::new();
407
408 let cm = SourceMap::new(FilePathMapping::empty());
409 let fm =
410 cm.new_source_file(FileName::Anon.into(), html_string.clone());
411
412 save_inline_hashes(
413 &mut errors,
414 &fm,
415 &hashes_dir_path,
416 &template_config.name,
417 )
418 });
419
420 (
421 html_string.as_bytes().to_vec(),
422 csp_hashes.has_any().then_some(csp_hashes),
423 )
424 } else {
425 (template_string.as_bytes().to_vec(), None)
427 }
428 } else if template_config.mime == "text/html"
429 || template_config.mime == "text/html; charset=utf-8"
430 {
431 let cm = SourceMap::new(FilePathMapping::empty());
432 let fm = cm.new_source_file(FileName::Anon.into(), template_string.clone());
433
434 let mut errors = Vec::new();
435
436 let csp_hashes = save_inline_hashes(
437 &mut errors,
438 &fm,
439 &hashes_dir_path,
440 &template_config.name,
441 );
442
443 (
444 template_string.as_bytes().to_vec(),
445 csp_hashes.has_any().then_some(csp_hashes),
446 )
447 } else {
448 (template_string.as_bytes().to_vec(), None)
449 };
450
451 let file_name = match path.extension() {
452 Some(ext) => match ext.to_str() {
453 Some(ext) => &format!("{}.{}", &template_config.name, ext),
454 None => &template_config.name,
455 },
456 None => &template_config.name,
457 };
458
459 let mut global_vars = BTreeMap::new();
460
461 if let Some(globals) = &config.globals {
462 for global_var in globals {
463 global_vars.insert(global_var.name.clone(), global_var.clone());
464 }
465 }
466
467 let (server, client) = match template_config.ffi.version {
468 TemplateFfiVersion::V1 => match template_config.ffi.serialization {
469 TemplateFfiSerialization::FlexBufferVector => {
470 generate::template::v1::flexbuffer_vector::generate_template_renderers(
471 &config.domain,
472 &config.version,
473 &config.canonical.clone().unwrap_or(
474 config
475 .cnames
476 .clone()
477 .unwrap_or(vec![config.domain.clone()])
478 .first()
479 .unwrap_or(&config.domain)
480 .clone(),
481 ),
482 generator,
483 file_name,
484 template_config.clone(),
485 &content_def_map,
486 &model_config_map,
487 &global_vars,
488 &config.error,
489 &config.auth,
490 )
491 }
492 },
493 };
494
495 client_file = format!("{client_file}\n{client}");
496
497 let file_path = templates_dir_path.join(file_name);
498
499 if !no_cache && file_path.exists() && fs_err::read(&file_path)? == template_final {
500 tracing::info!(
501 name = template_config.name,
502 "template has not changed; skipping."
503 );
504 continue;
505 }
506
507 let mut file = File::create(file_path)?;
509
510 file.write_all(&template_final)?;
511 file.flush()?;
512
513 create_dir_all(server_dir_path.join(&template_config.name).join("src"))?;
514
515 let server_main_rs = server_dir_path
516 .join(&template_config.name)
517 .join("src")
518 .join("main.rs");
519 let mut server_main_rs_file = File::create(server_main_rs)?;
520 server_main_rs_file.write_all(server.as_bytes())?;
521 server_main_rs_file.flush()?;
522
523 let server_cargo = server_dir_path
524 .join(&template_config.name)
525 .join("Cargo.toml");
526 let mut server_cargo_file = File::create(server_cargo)?;
527 server_cargo_file.write_all(BASE_SERVER_TOML.as_bytes())?;
528 server_cargo_file.flush()?;
529
530 let server_askama = server_dir_path
531 .join(&template_config.name)
532 .join("askama.toml");
533 let mut server_askama_file = File::create(server_askama)?;
534 server_askama_file.write_all(
535 r#"# generated
536[general]
537dirs = ["../../templates"]
538
539[[escaper]]
540path = "askama::filters::Text"
541extensions = ["js", "json"]
542"#
543 .as_bytes(),
544 )?;
545 server_askama_file.flush()?;
546
547 let path = server_dir_path.join(&template_config.name);
548 set_current_dir(path)?;
549
550 Command::new("cargo").args(["fmt"]).output()?;
551
552 let output = Command::new("cargo")
553 .args(["build", "--release", "--target", "wasm32-wasip1"])
554 .output()?;
555
556 if output.status.success() {
557 let template_wasm_path = "target/wasm32-wasip1/release/template.wasm";
558
559 if let Some(wasm_opt) = template_config.wasm_opt
560 && wasm_opt_path.exists()
561 {
562 let opt_output = Command::new(&wasm_opt_path)
563 .args([
564 "--all-features",
565 template_wasm_path,
566 "-o",
567 template_wasm_path,
568 wasm_opt.as_flag(),
569 ])
570 .output()?;
571
572 if !opt_output.status.success() {
573 tracing::error!(stderr = %std::str::from_utf8(&opt_output.stderr)?);
574 }
575 }
576
577 let template_bytes = fs_err::read(template_wasm_path)?;
578
579 if template_config.minify == Some(true) {
580 tracing::info!(
581 name = template_config.name,
582 mime = template_config.mime,
583 size.bin = %bytesize::ByteSize(template_bytes.len() as u64)
584 .display()
585 .si(),
586 size.source = %bytesize::ByteSize(template_string.len() as u64)
587 .display()
588 .si(),
589 size.minified = %bytesize::ByteSize(template_final.len() as u64)
590 .display()
591 .si(),
592 size.reduction = %PercentageDisplay(((template_string.len() as f64 - template_final.len() as f64)
593 / template_string.len() as f64)
594 * 100.0),
595 csp = csp_hashes.map(display),
596 "template"
597 );
598 } else {
599 tracing::info!(
600 name = template_config.name,
601 mime = template_config.mime,
602 size.bin = %bytesize::ByteSize(template_bytes.len() as u64)
603 .display()
604 .si(),
605 size.source = %bytesize::ByteSize(template_string.len() as u64)
606 .display()
607 .si(),
608 csp = csp_hashes.map(display),
609 "template"
610 );
611 }
612 } else {
613 tracing::error!(
614 name = template_config.name,
615 "failed for template\n{}",
616 String::from_utf8_lossy(&output.stderr)
617 );
618 }
619 }
620
621 set_current_dir(&project_dir)?;
622 }
623 }
624
625 if config.auth.is_some()
626 || config.obfuscation == Some(true)
627 || config.client_rendering == Some(true)
628 {
629 create_dir_all(client_dir_path.join("src"))?;
630
631 let client_lib_rs = client_dir_path.join("src").join("lib.rs");
632 let mut client_lib_file = File::create(client_lib_rs)?;
633 client_lib_file.write_all(client_file.as_bytes())?;
634 client_lib_file.flush()?;
635
636 let client_cargo = client_dir_path.join("Cargo.toml");
637 let mut client_cargo_file = File::create(client_cargo)?;
638 client_cargo_file.write_all(BASE_CLIENT_TOML.as_bytes())?;
639 client_cargo_file.flush()?;
640
641 let client_askama = client_dir_path.join("askama.toml");
642 let mut client_askama_file = File::create(client_askama)?;
643 client_askama_file.write_all(
644 r#"# generated
645[general]
646dirs = ["../templates"]
647
648[[escaper]]
649path = "askama::filters::Text"
650extensions = ["js", "json"]
651"#
652 .as_bytes(),
653 )?;
654 client_askama_file.flush()?;
655
656 set_current_dir("./.ordinary/gen/client")?;
657
658 Command::new("cargo").args(["fmt"]).output()?;
659
660 let build_output = Command::new("cargo")
661 .args([
662 "build",
663 "--release",
664 "--lib",
665 "--target",
666 "wasm32-unknown-unknown",
667 ])
668 .output()?;
669
670 if !build_output.status.success() {
671 tracing::error!(stderr = %std::str::from_utf8(&build_output.stderr)?);
672 }
673
674 wasm_bindgen_cli::wasm_bindgen::run_cli_with_args([
675 "wasm-bindgen",
676 "target/wasm32-unknown-unknown/release/client.wasm",
677 "--out-dir",
678 "wasm",
679 "--typescript",
680 "--target",
681 "web",
682 ])?;
683
684 if wasm_opt_path.exists() {
685 let opt_output = Command::new(wasm_opt_path)
686 .args([
687 "--all-features",
688 "wasm/client_bg.wasm",
689 "-o",
690 "wasm/client_bg_opt.wasm",
691 "-O4",
692 ])
693 .output()?;
694
695 if opt_output.status.success() {
696 let wasm_path = Path::new("wasm").join("client_bg.wasm");
697 let wasm_opt_path = Path::new("wasm").join("client_bg_opt.wasm");
698
699 let wasm = fs_err::read(wasm_path)?;
700 let wasm_opt = fs_err::read(wasm_opt_path)?;
701
702 tracing::info!(
703 path = %format!("/assets/{}/wasm/client_bg.wasm", config.version),
704 ext = "wasm",
705 size.source = %bytesize::ByteSize(wasm.len() as u64)
706 .display()
707 .si(),
708 size.optimized = %bytesize::ByteSize(wasm_opt.len() as u64)
709 .display()
710 .si(),
711 size.reduction = %PercentageDisplay(((wasm.len() as f64 - wasm_opt.len() as f64)
712 / wasm.len() as f64)
713 * 100.0),
714 "asset"
715 );
716 } else {
717 tracing::error!(stderr = %std::str::from_utf8(&opt_output.stderr)?);
718 }
719 } else {
720 let wasm_path = Path::new("wasm").join("client_bg.wasm");
721 let wasm = fs_err::read(wasm_path)?;
722
723 tracing::info!(
724 path = %format!("/assets/{}/wasm/client_bg.wasm", config.version),
725 ext = "wasm",
726 size.source = %bytesize::ByteSize(wasm.len() as u64)
727 .display()
728 .si(),
729 "asset"
730 );
731 }
732 }
733
734 set_current_dir(&project_dir)?;
735
736 if config.auth.is_some()
737 || config.obfuscation == Some(true)
738 || config.client_rendering == Some(true)
739 {
740 let wasm_path = Path::new(".ordinary")
741 .join("gen")
742 .join("client")
743 .join("wasm");
744 let wasm_client_path = wasm_path.join("client.js");
745
746 let client_js = fs_err::read_to_string(&wasm_client_path)?;
747
748 if client_js.contains(&js::minify(APPEND_WASM_JS)?) {
749 tracing::info!(
750 path = %format!("/assets/{}/wasm/client.js", config.version),
751 ext = "js",
752 size.minified = %bytesize::ByteSize(client_js.len() as u64)
753 .display()
754 .si(),
755 "asset"
756 );
757 } else {
758 let final_js = format!("{client_js}\n{APPEND_WASM_JS}");
759
760 let mut client_js_file = File::create(&wasm_client_path)?;
761
762 let minified_client_js_file = js::minify(&final_js)?;
763 client_js_file.write_all(minified_client_js_file.as_bytes())?;
764
765 tracing::info!(
766 path = %format!("/assets/{}/wasm/client.js", config.version),
767 ext = "js",
768 size.source = %bytesize::ByteSize(final_js.len() as u64)
769 .display()
770 .si(),
771 size.minified = %bytesize::ByteSize(minified_client_js_file.len() as u64)
772 .display()
773 .si(),
774 size.reduction = %PercentageDisplay(((final_js.len() as f64 - minified_client_js_file.len() as f64)
775 / final_js.len() as f64)
776 * 100.0),
777 "asset"
778 );
779 }
780 }
781
782 if config.auth.is_some() {
783 let js_path = Path::new(".ordinary").join("gen").join("client").join("js");
784 create_dir_all(&js_path)?;
785
786 let core_js_path = js_path.join("core.js");
787 let mut core_js_file = File::create(&core_js_path)?;
788
789 let minified_core_js = js::minify(CORE_JS)?;
790 core_js_file.write_all(minified_core_js.as_bytes())?;
791
792 tracing::info!(
793 path = %format!("/assets/{}/js/core.js", config.version),
794 ext = "js",
795 size.source = %bytesize::ByteSize(CORE_JS.len() as u64)
796 .display()
797 .si(),
798 size.minified = %bytesize::ByteSize(minified_core_js.len() as u64)
799 .display()
800 .si(),
801 size.reduction = %PercentageDisplay(((CORE_JS.len() as f64 - minified_core_js.len() as f64)
802 / CORE_JS.len() as f64)
803 * 100.0),
804 "asset"
805 );
806 }
807
808 if config.auth.is_some() {
809 let js_path = Path::new(".ordinary").join("gen").join("client").join("js");
810
811 let js_client_path = js_path.join("client.js");
812
813 let mut js_only_client = File::create(&js_client_path)?;
814 let js_client_minified = js::minify(JS_ONLY_JS)?;
815 js_only_client.write_all(js_client_minified.as_bytes())?;
816
817 tracing::info!(
818 path = %format!("/assets/{}/js/client.js", config.version),
819 ext = "js",
820 size.source = %bytesize::ByteSize(JS_ONLY_JS.len() as u64)
821 .display()
822 .si(),
823 size.minified = %bytesize::ByteSize(js_client_minified.len() as u64)
824 .display()
825 .si(),
826 size.reduction = %PercentageDisplay(((JS_ONLY_JS.len() as f64 - js_client_minified.len() as f64)
827 / JS_ONLY_JS.len() as f64)
828 * 100.0),
829 "asset"
830 );
831 }
832
833 if let Some(assets) = config.assets
834 && (assets.minify_css == Some(true)
835 || assets.minify_js == Some(true)
836 || assets.minify_html == Some(true)
837 || assets.preserve_exif != Some(true))
838 {
839 let base_route = if assets.base_route == "/" {
840 ""
841 } else {
842 assets.base_route.as_str()
843 };
844
845 let Some(assets_dir_path) = &assets.dir_path else {
846 bail!("assets.dir_path cannot be unset");
847 };
848
849 let dir_path = Path::new(&assets_dir_path);
850 let gen_path = Path::new(".ordinary").join("gen");
851
852 if exiftool_path.exists() {
853 let command = format!(
854 "{} -r -all= {} -directory='{}/%d' --icc_profile:all",
855 exiftool_path.display(),
856 dir_path.display(),
857 gen_path.display()
858 );
859
860 tracing::info!(cmd = %command, "exiftool");
861
862 let opt_output = Command::new("sh").args(["-c", &command]).output()?;
863
864 if opt_output.status.success() {
865 tracing::info!(src = %dir_path.display(), dest = %gen_path.display(), "exiftool run");
866 } else {
867 let stderr = std::str::from_utf8(&opt_output.stderr)?.split('\n');
868
869 for line in stderr {
870 if line.contains("already exists") {
871 if let Some(msg) = line.strip_prefix("Error: ") {
872 tracing::info!(%msg, "exiftool");
873 }
874 } else if !line.trim().is_empty() {
875 tracing::error!(stderr = %line, "exiftool");
876 }
877 }
878 }
879 }
880
881 let Some(assets_dir_path) = &assets.dir_path else {
882 bail!("assets.dir_path cannot be unset");
883 };
884
885 let gen_path = gen_path.join(assets_dir_path);
886 create_dir_all(&gen_path)?;
887
888 traverse(dir_path, &|entry| {
889 let path = entry.path();
890 let path2 = entry.path();
891 let rel_path = path2.strip_prefix(dir_path)?;
892
893 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
894 if ext == "css" && assets.minify_css == Some(true) {
895 let file_str = fs_err::read_to_string(path)?;
896 let file_str_len = file_str.len();
897
898 if let Ok(minified) = css::minify(&file_str) {
899 let dest_path = gen_path.join(rel_path);
900 if let Some(parent) = gen_path.join(rel_path).parent() {
901 create_dir_all(parent)?;
902
903 let mut content_file = File::create(&dest_path)?;
904 content_file.write_all(minified.as_bytes())?;
905
906 tracing::info!(
907 path = %format!("{base_route}/{}", rel_path.display()),
908 ext = "css",
909 size.source = %bytesize::ByteSize(file_str_len as u64).display().si(),
910 size.minified = %bytesize::ByteSize(minified.len() as u64).display().si(),
911 size.reduction = %PercentageDisplay(((file_str_len as f64 - minified.len() as f64) / file_str_len as f64)
912 * 100.0)
913 ,
914 "asset"
915 );
916 }
917 }
918 } else if ext == "js" && assets.minify_js == Some(true) {
919 let file_str = fs_err::read_to_string(path)?;
920 let file_str_len = file_str.len();
921
922 if let Ok(minified) = js::minify(&file_str) {
923 let dest_path = gen_path.join(rel_path);
924 if let Some(parent) = gen_path.join(rel_path).parent() {
925 create_dir_all(parent)?;
926
927 let mut content_file = File::create(&dest_path)?;
928 content_file.write_all(minified.as_bytes())?;
929
930 tracing::info!(
931 path = %format!("{base_route}/{}", rel_path.display()),
932 ext= "js",
933 size.source = %bytesize::ByteSize(file_str_len as u64).display().si(),
934 size.minified = %bytesize::ByteSize(minified.len() as u64).display().si(),
935 size.reduction = %PercentageDisplay(((file_str_len as f64 - minified.len() as f64) / file_str_len as f64)
936 * 100.0)
937 ,
938 "asset"
939 );
940 }
941 }
942 } else if ext == "html" && assets.minify_html == Some(true) {
943 let file_str = fs_err::read_to_string(path)?;
944 let file_str_len = file_str.len();
945
946 if let Ok(minified) = html::minify(&file_str) {
947 let dest_path = gen_path.join(rel_path);
948 if let Some(parent) = gen_path.join(rel_path).parent() {
949 create_dir_all(parent)?;
950
951 let mut content_file = File::create(&dest_path)?;
952 content_file.write_all(minified.as_bytes())?;
953
954 tracing::info!(
955 path = %format!("{base_route}/{}", rel_path.display()),
956 ext= "html",
957 size.source = %bytesize::ByteSize(file_str_len as u64).display().si(),
958 size.minified = %bytesize::ByteSize(minified.len() as u64).display().si(),
959 size.reduction = %PercentageDisplay(((file_str_len as f64 - minified.len() as f64) / file_str_len as f64)
960 * 100.0)
961 ,
962 "asset"
963 );
964 }
965 }
966 }
967 }
968 Ok(())
969 })?;
970 }
971
972 if let Some(lifecycle_config) = &config.lifecycle
973 && let Some(lifecycle) = &lifecycle_config.build
974 && let Some(after) = &lifecycle.after
975 {
976 OrdinaryConfig::exec_lifecycle_script(&project_dir, &None, "build", "after", after)?;
977 }
978
979 set_current_dir(start_dir)?;
980
981 Ok(())
982}
983
984fn save_inline_hashes(
985 errors: &mut Vec<Error>,
986 fm: &Arc<SourceFile>,
987 dir: &Path,
988 name: &str,
989) -> CspValues {
990 let new_dir_path = dir.join(name);
991 if let Err(err) = create_dir_all(&new_dir_path) {
992 tracing::error!(%err, "failed to create dir");
993 }
994
995 let mut csp_values = CspValues::default();
996
997 if let Ok(document) = parse_file_as_document(fm, ParserConfig::default(), errors) {
998 walk_document(document.children.clone(), &mut csp_values);
999
1000 if let Ok(script_src_json) = serde_json::to_string(&csp_values.script_src_inline_hashes)
1001 && let Err(err) = fs_err::write(
1002 new_dir_path.join("script-src.json"),
1003 script_src_json.as_bytes(),
1004 )
1005 {
1006 tracing::error!(%err, "failed to write file");
1007 }
1008
1009 if let Ok(style_src_json) = serde_json::to_string(&csp_values.style_src_inline_hashes)
1010 && let Err(err) = fs_err::write(
1011 new_dir_path.join("style-src.json"),
1012 style_src_json.as_bytes(),
1013 )
1014 {
1015 tracing::error!(%err, "failed to write file");
1016 }
1017 }
1018
1019 csp_values
1020}
1021
1022#[allow(clippy::type_complexity)]
1023pub fn traverse(dir: &Path, cb: &dyn Fn(&DirEntry) -> anyhow::Result<()>) -> anyhow::Result<()> {
1024 if dir.is_dir() {
1025 for entry in fs_err::read_dir(dir)? {
1026 let entry = entry?;
1027 let path = entry.path();
1028 if path.is_dir() {
1029 traverse(&path, cb)?;
1030 } else {
1031 cb(&entry)?;
1032 }
1033 }
1034 }
1035 Ok(())
1036}
1037
1038fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> anyhow::Result<()> {
1039 create_dir_all(&dst)?;
1040 for entry in fs_err::read_dir(src.as_ref())? {
1041 let entry = entry?;
1042
1043 if entry.file_type()?.is_dir() {
1044 copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
1045 } else {
1046 fs_err::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
1047 }
1048 }
1049
1050 Ok(())
1051}