use super::css_processing::{apply_minification, serialize_stylesheet};
use crate::config::{CssSection, CssTargets};
use crate::core::utils::ensure_directory_exists;
use anyhow::Result;
use include_dir::{Dir, include_dir};
use lightningcss::bundler::{Bundler, FileProvider};
use lightningcss::stylesheet::{ParserOptions, StyleSheet};
use lightningcss::targets::{Browsers, Targets};
use std::fs;
use std::path::{Path, PathBuf};
static STYLES: Dir = include_dir!("$CARGO_MANIFEST_DIR/styles");
#[derive(Debug, Clone)]
pub struct CssProcessor {
pub minify: bool,
pub targets: Targets,
pub enable_css_modules: bool,
pub source_maps: bool,
pub remove_unused: bool,
pub nesting: bool,
}
impl CssProcessor {
pub fn new() -> Self {
Self {
minify: true,
targets: get_default_browser_targets(),
enable_css_modules: false,
source_maps: false,
remove_unused: false,
nesting: true,
}
}
pub fn from_config(css_config: &CssSection, is_development: bool) -> Self {
let mut processor = Self {
minify: css_config.minify.unwrap_or(!is_development),
targets: css_config
.targets
.as_ref()
.map(parse_css_targets)
.unwrap_or_else(get_default_browser_targets),
enable_css_modules: false, source_maps: css_config.source_maps.unwrap_or(is_development),
remove_unused: css_config.remove_unused.unwrap_or(false),
nesting: css_config.nesting.unwrap_or(true),
};
if is_development {
processor.minify = false;
processor.source_maps = true;
}
processor
}
pub fn with_minify(mut self, minify: bool) -> Self {
self.minify = minify;
self
}
pub fn with_targets(mut self, targets: Targets) -> Self {
self.targets = targets;
self
}
pub fn with_css_modules(mut self, enable: bool) -> Self {
self.enable_css_modules = enable;
self
}
pub fn with_source_maps(mut self, enable: bool) -> Self {
self.source_maps = enable;
self
}
pub fn with_remove_unused(mut self, enable: bool) -> Self {
self.remove_unused = enable;
self
}
pub fn with_nesting(mut self, enable: bool) -> Self {
self.nesting = enable;
self
}
pub fn process_css_string(&self, content: &str, filename: &str) -> Result<String> {
let mut stylesheet = StyleSheet::parse(
content,
ParserOptions {
filename: filename.to_string(),
..ParserOptions::default()
},
)
.map_err(|e| anyhow::anyhow!("Failed to parse CSS content from {}: {}", filename, e))?;
apply_minification(&mut stylesheet, self)?;
serialize_stylesheet(&stylesheet, self, filename)
}
pub fn write_processed_css(&self, content: &str, output_path: &Path) -> Result<()> {
ensure_directory_exists(output_path.parent().unwrap_or_else(|| Path::new("")))?;
fs::write(output_path, content)?;
Ok(())
}
pub fn process_css_file(&self, input_path: &Path, output_path: &Path) -> Result<()> {
let css_content = fs::read_to_string(input_path)?;
let filename = input_path.to_string_lossy().to_string();
let processed_content = self.process_css_string(&css_content, &filename)?;
self.write_processed_css(&processed_content, output_path)?;
println!(
"Processed CSS: {} -> {}",
input_path.display(),
output_path.display()
);
Ok(())
}
pub fn bundle_css_files(&self, entry_point: &Path, output_dir: &Path) -> Result<PathBuf> {
let fs_provider = FileProvider::new();
let mut bundler = Bundler::new(
&fs_provider,
None,
ParserOptions {
filename: entry_point.to_string_lossy().to_string(),
..ParserOptions::default()
},
);
let mut stylesheet = bundler.bundle(entry_point).map_err(|e| {
anyhow::anyhow!("Failed to bundle CSS file {}: {}", entry_point.display(), e)
})?;
apply_minification(&mut stylesheet, self)?;
let filename = entry_point.to_string_lossy();
let result = serialize_stylesheet(&stylesheet, self, &filename)?;
let output_path = output_dir.join("main.css");
ensure_directory_exists(output_dir)?;
fs::write(&output_path, &result)?;
println!(
"Bundled CSS: {} -> {}",
entry_point.display(),
output_path.display()
);
Ok(output_path)
}
}
impl Default for CssProcessor {
fn default() -> Self {
Self::new()
}
}
fn parse_css_targets(css_targets: &CssTargets) -> Targets {
let mut browsers = Browsers::default();
if let Some(chrome) = &css_targets.chrome
&& let Ok(version) = parse_browser_version(chrome)
{
browsers.chrome = Some(version);
}
if let Some(firefox) = &css_targets.firefox
&& let Ok(version) = parse_browser_version(firefox)
{
browsers.firefox = Some(version);
}
if let Some(safari) = &css_targets.safari
&& let Ok(version) = parse_browser_version(safari)
{
browsers.safari = Some(version);
}
if let Some(edge) = &css_targets.edge
&& let Ok(version) = parse_browser_version(edge)
{
browsers.edge = Some(version);
}
if css_targets.browserslist.is_some() {
return get_default_browser_targets();
}
Targets {
browsers: Some(browsers),
..Targets::default()
}
}
fn parse_browser_version(version_str: &str) -> Result<u32, std::num::ParseIntError> {
let parts: Vec<&str> = version_str.split('.').collect();
let major: u32 = parts[0].parse()?;
let minor = if parts.len() > 1 {
parts[1].parse().unwrap_or(0)
} else {
0
};
let patch = if parts.len() > 2 {
parts[2].parse().unwrap_or(0)
} else {
0
};
Ok((major << 16) | (minor << 8) | patch)
}
fn get_default_browser_targets() -> Targets {
let browsers = Browsers {
chrome: Some(103 << 16), firefox: Some(115 << 16), safari: Some(15 << 16), edge: Some(127 << 16), ..Browsers::default()
};
Targets {
browsers: Some(browsers),
..Targets::default()
}
}
#[derive(Debug)]
pub struct StyleManager {
styles_dir: PathBuf,
css_processor: CssProcessor,
#[allow(dead_code)]
is_development: bool,
}
#[derive(Debug)]
pub enum EntryPointValidationError {
Empty,
ContainsPathSeparators,
MissingExtension,
InvalidExtension,
}
impl std::fmt::Display for EntryPointValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EntryPointValidationError::Empty => write!(f, "cannot be empty"),
EntryPointValidationError::ContainsPathSeparators => {
write!(f, "must be a simple filename, not a path")
}
EntryPointValidationError::MissingExtension => write!(f, "must have a file extension"),
EntryPointValidationError::InvalidExtension => write!(f, "must end with .css"),
}
}
}
fn validate_css_entry_point(entry_point: &str) -> Result<String, EntryPointValidationError> {
if entry_point.is_empty() {
return Err(EntryPointValidationError::Empty);
}
if entry_point.contains('/') || entry_point.contains('\\') {
return Err(EntryPointValidationError::ContainsPathSeparators);
}
if !entry_point.contains('.') {
return Err(EntryPointValidationError::MissingExtension);
}
if !entry_point.ends_with(".css") {
return Err(EntryPointValidationError::InvalidExtension);
}
Ok(entry_point.to_string())
}
fn resolve_and_validate_entry_point(css_config: Option<&CssSection>) -> String {
if let Some(css_config) = css_config
&& let Some(entry_point) = &css_config.entry_point
{
match validate_css_entry_point(entry_point) {
Ok(validated) => validated,
Err(error) => {
eprintln!(
"⚠️ Warning: Invalid CSS entry point '{}': {}. Using default 'main.css'.",
entry_point, error
);
"main.css".to_string()
}
}
} else {
"main.css".to_string()
}
}
impl StyleManager {
pub fn new(styles_dir: &Path) -> Self {
Self::new_with_config(styles_dir, None, false)
}
pub fn new_development(styles_dir: &Path) -> Self {
Self::new_with_config(styles_dir, None, true)
}
pub fn new_with_config(
styles_dir: &Path,
css_config: Option<&CssSection>,
is_development: bool,
) -> Self {
let css_processor = if let Some(config) = css_config {
CssProcessor::from_config(config, is_development)
} else {
let processor = CssProcessor::new();
if is_development {
processor.with_minify(false).with_source_maps(true)
} else {
processor
}
};
Self {
styles_dir: styles_dir.to_path_buf(),
css_processor,
is_development,
}
}
pub fn with_processor(
styles_dir: &Path,
css_processor: CssProcessor,
is_development: bool,
) -> Self {
Self {
styles_dir: styles_dir.to_path_buf(),
css_processor,
is_development,
}
}
fn list_available_css_files(&self) -> Result<String> {
if !self.styles_dir.exists() {
return Ok("(no styles directory)".to_string());
}
let mut files = Vec::new();
for entry in fs::read_dir(&self.styles_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file()
&& path.extension().is_some_and(|ext| ext == "css")
&& let Some(name) = path.file_name()
{
files.push(name.to_string_lossy().to_string());
}
}
if files.is_empty() {
Ok("(no CSS files found)".to_string())
} else {
Ok(files.join(", "))
}
}
fn list_embedded_css_files(&self) -> String {
let mut files = Vec::new();
for file in STYLES.files() {
if let Some(file_name) = file.path().file_name()
&& file.path().extension().is_some_and(|ext| ext == "css")
{
files.push(file_name.to_string_lossy().to_string());
}
}
if files.is_empty() {
"(no embedded CSS files found)".to_string()
} else {
files.join(", ")
}
}
pub fn generate_css_file(
&self,
output_dir: &Path,
css_config: Option<&CssSection>,
) -> Result<PathBuf> {
let css_dir = output_dir.join("css");
ensure_directory_exists(&css_dir)?;
let entry_point = resolve_and_validate_entry_point(css_config);
let has_explicit_entry_point = css_config
.and_then(|config| config.entry_point.as_ref())
.is_some();
if self.styles_dir.exists() {
self.process_user_css_entry_point(&css_dir, &entry_point)?;
} else if has_explicit_entry_point {
return Err(anyhow::anyhow!(
"CSS entry point '{}' is configured but no styles directory exists at '{}'.\n\
Fix: either create the styles directory with the entry point file, or remove the 'entry_point' configuration to use embedded styles.",
entry_point,
self.styles_dir.display()
));
} else {
self.process_embedded_css_entry_point(&css_dir, &entry_point)?;
}
Ok(css_dir.join("main.css"))
}
fn process_user_css_entry_point(&self, css_dir: &Path, entry_point: &str) -> Result<()> {
let entry_path = self.styles_dir.join(entry_point);
if entry_path.exists() {
self.css_processor.bundle_css_files(&entry_path, css_dir)?;
println!("Bundled CSS: {} -> dist/css/main.css", entry_point);
} else {
return Err(anyhow::anyhow!(
"CSS entry point '{}' not found in styles directory '{}'.\n\
Available files: {}\n\
Fix: either create the file or remove the 'entry_point' configuration to use defaults.",
entry_point,
self.styles_dir.display(),
self.list_available_css_files()?
));
}
Ok(())
}
fn process_embedded_css_entry_point(&self, css_dir: &Path, entry_point: &str) -> Result<()> {
if STYLES.get_file(entry_point).is_some() {
let main_css_path = css_dir.join("main.css");
let temp_dir = tempfile::tempdir()?;
let temp_path = temp_dir.path();
self.extract_embedded_css_to_temp(temp_path)?;
let fs_provider = FileProvider::new();
let mut bundler = Bundler::new(
&fs_provider,
None, ParserOptions {
filename: entry_point.to_string(),
..ParserOptions::default()
},
);
let original_dir = std::env::current_dir()?;
std::env::set_current_dir(temp_path)?;
let mut stylesheet = bundler
.bundle(Path::new(entry_point))
.map_err(|e| anyhow::anyhow!("Failed to bundle embedded CSS: {}", e))?;
std::env::set_current_dir(original_dir)?;
apply_minification(&mut stylesheet, &self.css_processor)?;
let result = serialize_stylesheet(&stylesheet, &self.css_processor, entry_point)?;
fs::write(&main_css_path, &result)?;
println!(
"Bundled embedded CSS: {} -> {}",
entry_point,
main_css_path.display()
);
} else {
return Err(anyhow::anyhow!(
"Embedded CSS entry point '{}' not found.\n\
Available embedded files: {}\n\
Fix: either add the file to styles/ directory or remove 'entry_point' configuration.",
entry_point,
self.list_embedded_css_files()
));
}
Ok(())
}
fn extract_embedded_css_to_temp(&self, temp_dir: &Path) -> Result<()> {
ensure_directory_exists(temp_dir)?;
for file in STYLES.files() {
let file_path = file.path();
if let Some(file_name) = file_path.file_name()
&& let Some(extension) = Path::new(file_name).extension()
&& extension == "css"
{
let dest_path = temp_dir.join(file_name);
if let Some(content) = file.contents_utf8() {
fs::write(&dest_path, content)?;
}
}
}
Ok(())
}
}