use crate::plugins::{FobPlugin, PluginPhase};
use crate::{
HookLoadArgs, HookLoadOutput, HookLoadReturn, ModuleType, Plugin, PluginContext, Runtime,
};
use anyhow::Context;
use lightningcss::{
printer::PrinterOptions,
stylesheet::{MinifyOptions, ParserOptions, StyleSheet},
};
use std::borrow::Cow;
use std::path::Path;
use std::sync::Arc;
mod config;
pub use config::CssPluginOptions;
#[derive(Clone, Debug)]
pub struct CssPlugin {
options: CssPluginOptions,
runtime: Arc<dyn Runtime>,
}
impl CssPlugin {
pub fn new(runtime: Arc<dyn Runtime>) -> Self {
Self {
options: CssPluginOptions::default(),
runtime,
}
}
pub fn with_options(runtime: Arc<dyn Runtime>, options: CssPluginOptions) -> Self {
Self { options, runtime }
}
fn process_css(&self, path: &Path, source: String) -> anyhow::Result<String> {
let mut stylesheet = StyleSheet::parse(
&source,
ParserOptions {
filename: path.to_string_lossy().to_string(),
..Default::default()
},
)
.map_err(|e| anyhow::anyhow!("Failed to parse CSS from {}: {:?}", path.display(), e))?;
if self.options.minify {
stylesheet.minify(MinifyOptions::default()).map_err(|e| {
anyhow::anyhow!("Failed to minify CSS from {}: {:?}", path.display(), e)
})?;
}
let result = stylesheet
.to_css(PrinterOptions {
minify: self.options.minify,
..Default::default()
})
.map_err(|e| anyhow::anyhow!("Failed to print CSS from {}: {:?}", path.display(), e))?;
Ok(result.code)
}
fn should_process(&self, path: &str) -> bool {
if !self.options.exclude.is_empty() {
for pattern in &self.options.exclude {
if path.contains(pattern.as_str()) {
return false;
}
}
}
if !self.options.include.is_empty() {
return self
.options
.include
.iter()
.any(|pattern| path.contains(pattern.as_str()));
}
true
}
}
impl Plugin for CssPlugin {
fn name(&self) -> Cow<'static, str> {
"fob-css".into()
}
fn register_hook_usage(&self) -> crate::HookUsage {
use crate::HookUsage;
HookUsage::Load
}
fn load(
&self,
_ctx: &PluginContext,
args: &HookLoadArgs<'_>,
) -> impl std::future::Future<Output = HookLoadReturn> + Send {
let id = args.id.to_string();
let options = self.options.clone();
let runtime = Arc::clone(&self.runtime);
async move {
if !id.ends_with(".css") {
return Ok(None);
}
let plugin = CssPlugin::with_options(Arc::clone(&runtime), options);
if !plugin.should_process(&id) {
eprintln!("[fob-css] Skipping excluded file: {}", id);
return Ok(None);
}
let file_path = std::path::Path::new(&id);
let content = runtime
.read_file(file_path)
.await
.with_context(|| format!("Failed to read CSS file: {}", id))?;
let source = String::from_utf8(content)
.with_context(|| format!("CSS file {} contains invalid UTF-8", id))?;
let source_len = source.len();
let path = Path::new(&id);
let processed = plugin.process_css(path, source)?;
eprintln!(
"[fob-css] Processed {} ({} → {} bytes, minify: {})",
id,
source_len,
processed.len(),
plugin.options.minify
);
Ok(Some(HookLoadOutput {
code: processed.into(),
module_type: Some(ModuleType::Css),
..Default::default()
}))
}
}
}
impl FobPlugin for CssPlugin {
fn phase(&self) -> PluginPhase {
PluginPhase::Transform
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Runtime;
use std::sync::Arc;
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_plugin_creation() {
use crate::runtime::BundlerRuntime;
let runtime: Arc<dyn Runtime> = Arc::new(BundlerRuntime::new("."));
let plugin = CssPlugin::new(runtime);
assert_eq!(plugin.name(), "fob-css");
}
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_plugin_with_options() {
use crate::runtime::BundlerRuntime;
let runtime: Arc<dyn Runtime> = Arc::new(BundlerRuntime::new("."));
let options = CssPluginOptions::new().with_minify(true);
let plugin = CssPlugin::with_options(runtime, options);
assert_eq!(plugin.name(), "fob-css");
assert!(plugin.options.minify);
}
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_should_process_exclusions() {
use crate::runtime::BundlerRuntime;
let runtime: Arc<dyn Runtime> = Arc::new(BundlerRuntime::new("."));
let plugin = CssPlugin::with_options(runtime, CssPluginOptions::new().exclude("vendor/"));
assert!(!plugin.should_process("node_modules/vendor/styles.css"));
assert!(plugin.should_process("src/styles.css"));
}
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_should_process_inclusions() {
use crate::runtime::BundlerRuntime;
let runtime: Arc<dyn Runtime> = Arc::new(BundlerRuntime::new("."));
let plugin = CssPlugin::with_options(runtime, CssPluginOptions::new().include("src/"));
assert!(plugin.should_process("src/styles.css"));
assert!(!plugin.should_process("vendor/styles.css"));
}
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_process_basic_css() {
use crate::runtime::BundlerRuntime;
let runtime: Arc<dyn Runtime> = Arc::new(BundlerRuntime::new("."));
let plugin = CssPlugin::new(runtime);
let css = "body { color: red; }".to_string();
let path = Path::new("test.css");
let result = plugin.process_css(path, css);
assert!(result.is_ok());
assert!(result.unwrap().contains("color"));
}
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_process_with_minification() {
use crate::runtime::BundlerRuntime;
let runtime: Arc<dyn Runtime> = Arc::new(BundlerRuntime::new("."));
let plugin = CssPlugin::with_options(runtime, CssPluginOptions::new().with_minify(true));
let css = "body {\n color: red;\n background: blue;\n}";
let path = Path::new("test.css");
let result = plugin.process_css(path, css.to_string()).unwrap();
assert!(result.len() < css.len());
assert!(result.contains("color"));
assert!(result.contains("background"));
}
}