Skip to main content

ordinary_build/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(clippy::all, clippy::pedantic)]
3#![allow(clippy::missing_errors_doc, clippy::cast_precision_loss)]
4
5// Copyright (C) 2026 Ordinary Labs, LLC.
6//
7// SPDX-License-Identifier: AGPL-3.0-only
8
9pub use 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
71// js
72
73const 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                        // todo: support minification for additional MIME types
426                        (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                // server
508                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}