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
9use 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
66// js
67
68const 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                        // todo: support minification for additional MIME types
421                        (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                // server
503                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}