aer 0.2.1

A command-line toolkit for creatives.
Documentation
use std::path::Path;

use rolldown::{Bundler, BundlerOptions};
use rolldown_common::Output;

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

/// Bundles JavaScript entry points and their dependencies into a single file.
///
/// This processor uses [rolldown](https://rolldown.rs) to bundle
/// JavaScript modules, similar to tools like webpack or rollup.
///
/// Each asset passed to this processor is treated as a distinct entry point,
/// and modules are resolved relative to that entry point's location.
///
/// # Example
///
/// ```ignore
/// use aer::proc::js_bundle::JsBundleProcessor;
/// use aer::proc::{Asset, ProcessesAssets};
///
/// let processor = JsBundleProcessor::new();
/// let mut asset = Asset::new("src/index.js".into(), b"".to_vec());
/// processor.process(&mut asset).unwrap();
/// ```
pub struct JsBundleProcessor {
    /// Whether to minify the output.
    minify: bool,
}

impl JsBundleProcessor {
    /// Creates a new JS bundle processor.
    pub fn new(minify: bool) -> Self {
        Self { minify }
    }
}

impl JsBundleProcessor {
    /// Bundles the JavaScript file at `entry_path` and returns the bundled code.
    ///
    /// Modules are resolved relative to the entry point's parent directory.
    fn bundle_js(&self, entry_path: &Path) -> Result<String, ProcessingError> {
        // Get the entry point filename for the bundler input.
        let file_name = entry_path
            .file_name()
            .ok_or_else(|| ProcessingError::Compilation {
                message: format!(
                    "Invalid entry path '{}': must be a file path, not a directory or root",
                    entry_path.display()
                )
                .into(),
            })?;

        // Use the entry point's parent directory as the working directory
        // for module resolution. Must be absolute for rolldown's resolver.
        let cwd = entry_path
            .parent()
            .filter(|p| !p.as_os_str().is_empty())
            .map(|p| {
                if p.is_absolute() {
                    p.to_path_buf()
                } else {
                    std::env::current_dir().unwrap_or_default().join(p)
                }
            });

        let input_path = format!("./{}", file_name.to_string_lossy());

        // Create bundler options.
        let options = BundlerOptions {
            input: Some(vec![input_path.into()]),
            cwd,
            minify: if self.minify {
                Some(rolldown::RawMinifyOptions::Bool(true))
            } else {
                None
            },
            ..Default::default()
        };

        // Create a new runtime for the async bundling operation.
        // Note: rolldown uses async internally, so we need a runtime to
        // execute the bundling operation.
        let rt = tokio::runtime::Runtime::new().map_err(|e| ProcessingError::Compilation {
            message: format!("Failed to create async runtime: {}", e).into(),
        })?;

        // Run the bundler.
        rt.block_on(async {
            let mut bundler = Bundler::new(options).map_err(|e| ProcessingError::Compilation {
                message: format!("Failed to create bundler: {:?}", e).into(),
            })?;

            let output = bundler
                .generate()
                .await
                .map_err(|e| ProcessingError::Compilation {
                    message: format!("Bundling failed: {:?}", e).into(),
                })?;

            // Extract the bundled code from the first chunk.
            for asset in output.assets {
                if let Output::Chunk(chunk) = asset {
                    return Ok(chunk.code.clone());
                }
            }

            Err(ProcessingError::Compilation {
                message: "Bundling produced no output chunks".into(),
            })
        })
    }
}

impl ProcessesAssets for JsBundleProcessor {
    fn process(
        &self,
        _env: &Environment,
        _context: &LayeredContext,
        asset: &mut Asset,
    ) -> Result<bool, ProcessingError> {
        // Skip assets that aren't JavaScript.
        if *asset.media_type() != MediaType::JavaScript {
            return Ok(false);
        }

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

        // Get the path to the JavaScript entry point.
        let entry_path_str = asset.path().clone();
        let entry_path = Path::new(entry_path_str.as_str());

        // Bundle the JavaScript entry point.
        let bundled_code = self.bundle_js(entry_path)?;

        // Update the asset's contents with the bundled code.
        asset.replace_with_text(bundled_code.into(), MediaType::JavaScript);

        Ok(true)
    }
}

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

    #[test]
    fn skips_non_javascript_assets() {
        let processor = JsBundleProcessor { minify: false };

        // Create a non-JavaScript asset.
        let mut css_asset = Asset::new("style.css".into(), "body {}".as_bytes().to_vec());

        // Processing should skip without errors.
        let modified = processor
            .process(
                &Environment::test(),
                &LayeredContext::from_flat(Default::default()),
                &mut css_asset,
            )
            .unwrap();
        assert!(!modified);
    }

    #[test]
    fn bundles_javascript() {
        let processor = JsBundleProcessor { minify: false };

        // Create a JavaScript asset pointing to our test file.
        let mut js_asset = Asset::new("test/js_bundle/entry.js".into(), "".as_bytes().to_vec());

        // Process the asset.
        let result = processor.process(
            &Environment::test(),
            &LayeredContext::from_flat(Default::default()),
            &mut js_asset,
        );
        assert!(result.is_ok());

        // Check that the bundled code contains content from the entry point
        // and the imported modules.
        let bundled = js_asset.as_text().unwrap();
        assert!(bundled.contains("Hello from bundled JavaScript!"));
        assert!(bundled.contains("greet"));
        // Rolldown may inline the imported HELPER_VERSION constant, so check
        // for its value rather than its name.
        assert!(bundled.contains("1.0.0"));
        assert!(bundled.contains("formatMessage"));
    }
}