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