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