aer 0.2.1

A command-line toolkit for creatives.
Documentation
use oxc_allocator::Allocator;
use oxc_codegen::{Codegen, CodegenOptions};
use oxc_minifier::{Minifier, MinifierOptions};
use oxc_parser::Parser;
use oxc_span::SourceType;

use super::{Asset, Environment, LayeredContext, MediaType, ProcessesAssets, ProcessingError};

/// Minifies JavaScript assets by removing unnecessary whitespace and comments.
///
/// Assets with paths ending in `.min.js` are skipped (already minified).
pub struct MinifyJsProcessor;

impl ProcessesAssets for MinifyJsProcessor {
    fn process(
        &self,
        _env: &Environment,
        _context: &LayeredContext,
        asset: &mut Asset,
    ) -> Result<bool, ProcessingError> {
        if asset.media_type() != &MediaType::JavaScript {
            return Ok(false);
        }

        if asset.path().ends_with(".min.js") {
            return Ok(false);
        }

        tracing::trace!("minify_js: {}", asset.path());

        let source = asset.as_text()?;

        let allocator = Allocator::default();

        // Process `.mjs` files as modules. For other kinds of files,
        // infer if they're a module or a classic script based on syntax
        // (e.g., presence of `import` and `export` statements).
        let source_type = if asset.path().ends_with(".mjs") {
            SourceType::mjs()
        } else {
            SourceType::unambiguous()
        };

        // Parse the JavaScript source.
        let ret = Parser::new(&allocator, source, source_type).parse();

        // Check for parse errors.
        if ret.program.is_empty() {
            let error_messages: Vec<_> = ret.diagnostics.iter().map(|d| d.to_string()).collect();
            return Err(ProcessingError::Compilation {
                message: format!("JS parse errors: {}", error_messages.join("; ")).into(),
            });
        }

        // Minify the AST.
        let mut program = ret.program;
        let options = MinifierOptions::default();
        Minifier::new(options).minify(&allocator, &mut program);

        // Generate minified output (removes whitespace and comments).
        let output = Codegen::new()
            .with_options(CodegenOptions::minify())
            .build(&program);

        asset.replace_with_text(output.code.into(), MediaType::JavaScript);
        Ok(true)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::proc::LayeredContext;

    #[test]
    fn minifies_js() {
        let js = r#"
            // This is a comment
            function hello(name) {
                /* Another comment */
                console.log("Hello, " + name);
            }
            hello("world");
        "#;
        let mut asset = Asset::new("script.js".into(), js.as_bytes().to_vec());
        MinifyJsProcessor
            .process(
                &Environment::test(),
                &LayeredContext::from_flat(Default::default()),
                &mut asset,
            )
            .unwrap();

        let result = asset.as_text().unwrap();
        // Comments should be stripped.
        assert!(!result.contains("// This is a comment"));
        assert!(!result.contains("/* Another comment */"));
        // String literals and built-ins should be preserved.
        assert!(result.contains("Hello, "));
        assert!(result.contains("console.log"));
        // Should be smaller than original.
        assert!(result.len() < js.len());
    }

    #[test]
    fn minifies_classic_scripts() {
        // Classic scripts (no import/export) expose top-level bindings as
        // globals for other scripts. Parsing them as modules breaks these bindings.
        let js = r#"
            // A global consumed by another <script>.
            let wasm_bindgen = (function(exports) {
                return Object.assign(function init() {}, exports);
            })({});
        "#;
        let mut asset = Asset::new("js/emhash.js".into(), js.as_bytes().to_vec());
        MinifyJsProcessor
            .process(
                &Environment::test(),
                &LayeredContext::from_flat(Default::default()),
                &mut asset,
            )
            .unwrap();

        let result = asset.as_text().unwrap();
        assert!(result.contains("wasm_bindgen"));
        assert!(!result.contains("// A global"));
    }

    #[test]
    fn minifies_modules() {
        // Files with import/export syntax must still parse as modules.
        let js = r#"
            import { helper } from "./helper.js";
            export function run() { helper(); }
        "#;
        let mut asset = Asset::new("editor.js".into(), js.as_bytes().to_vec());
        MinifyJsProcessor
            .process(
                &Environment::test(),
                &LayeredContext::from_flat(Default::default()),
                &mut asset,
            )
            .unwrap();

        let result = asset.as_text().unwrap();
        assert!(result.contains("import"));
        assert!(result.contains("export"));
    }

    #[test]
    fn skips_non_js() {
        let mut asset = Asset::new("index.html".into(), b"<html></html>".to_vec());
        let modified = MinifyJsProcessor
            .process(
                &Environment::test(),
                &LayeredContext::from_flat(Default::default()),
                &mut asset,
            )
            .unwrap();
        assert!(!modified);
        assert_eq!(asset.as_text().unwrap(), "<html></html>");
    }

    #[test]
    fn skips_already_minified() {
        let js = "function test(){console.log('already minified')}";
        let mut asset = Asset::new("vendor.min.js".into(), js.as_bytes().to_vec());
        let modified = MinifyJsProcessor
            .process(
                &Environment::test(),
                &LayeredContext::from_flat(Default::default()),
                &mut asset,
            )
            .unwrap();
        assert!(!modified);
        assert_eq!(asset.as_text().unwrap(), js);
    }

    #[test]
    fn rejects_invalid_js() {
        let js = "function {{{ invalid syntax";
        let mut asset = Asset::new("bad.js".into(), js.as_bytes().to_vec());
        let result = MinifyJsProcessor.process(
            &Environment::test(),
            &LayeredContext::from_flat(Default::default()),
            &mut asset,
        );
        assert!(result.is_err());
    }
}