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 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
67// js
68
69const 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                                        // todo: may be too aggressive for minification
351                                        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                        // todo: support minification for additional MIME types
398                        (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                // server
470                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}